mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-13 16:46:55 +00:00
Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a609896511 | |||
| 3dabac4a09 | |||
| 15c2bade5f | |||
| 624a4ab837 | |||
| 4f43c0ca7e | |||
| 3405b4e980 | |||
| 510b0350d6 | |||
| b14805af47 | |||
| f3eba31aee | |||
| 42af6e3b63 | |||
| a43267160d | |||
| 2039ba5f65 | |||
| 3a6f630ff1 | |||
| 1695873e55 | |||
| c26419e357 | |||
| d401c65719 | |||
| eb87bb1faf | |||
| 93fcd6b6b5 | |||
| c63dba7d3f | |||
| 31959b90db | |||
| 3c8bd5b929 | |||
| ec106548ba | |||
| cfce7bd9b1 | |||
| b71483b1c8 | |||
| cebc28f678 | |||
| d6e11cf018 | |||
| 71f2e7a209 | |||
| 1934cddd66 | |||
| e08cf474db | |||
| 48c8d85f3b | |||
| 99701e9f52 | |||
| e64ed14fb0 | |||
| cad0fd9b2f | |||
| de8cb5f26c | |||
| 98d3619613 | |||
| 5237a4465d | |||
| 62cc19e937 | |||
| d8d22599fe | |||
| ee55691f63 | |||
| 3ce17cd7dd | |||
| 4310850103 | |||
| acce8b2253 | |||
| e20892ace9 | |||
| accc906b33 |
@@ -60,6 +60,16 @@ test-group = 'ecstore-serial-flaky'
|
||||
filter = 'package(rustfs-ecstore) & test(manual_transition_page_checkpoint_persists_durable_job_progress)'
|
||||
test-group = 'ecstore-serial-flaky'
|
||||
|
||||
# Serialize the bucket-incarnation / lifecycle-fence tests. They drive
|
||||
# init_bucket_metadata_sys and bucket_metadata_sys_of, i.e. process-global
|
||||
# OnceLock state that serial_test's #[serial] cannot protect across nextest's
|
||||
# process boundary, and they delete+recreate buckets — the same shape that
|
||||
# raced into InsufficientWriteQuorum in backlog#937. Preventive only, no
|
||||
# retries. The matching ci-profile override is after [profile.ci].
|
||||
[[profile.default.overrides]]
|
||||
filter = 'package(rustfs-ecstore) & (test(bucket_delete_waits_for_config_mutation_fence) | test(stale_config_request_cannot_mutate_a_recreated_bucket) | test(disk_incarnation_read_detects_stale_cache_until_peer_reload) | test(lifecycle_expiry_fails_closed_on_corrupt_object_lock_metadata) | test(expiry_configs_are_resolved_from_the_owning_store))'
|
||||
test-group = 'ecstore-serial-flaky'
|
||||
|
||||
# Serialize the 4-disk reliability / degraded-read e2e tests (see the
|
||||
# e2e-reliability test-group note above). The matching ci-profile override is at
|
||||
# the end of the file, after [profile.ci] is declared.
|
||||
@@ -139,6 +149,12 @@ test-group = 'ecstore-serial-flaky'
|
||||
filter = 'package(rustfs-ecstore) & test(manual_transition_page_checkpoint_persists_durable_job_progress)'
|
||||
test-group = 'ecstore-serial-flaky'
|
||||
|
||||
# Serialize the bucket-incarnation / lifecycle-fence tests under the ci profile
|
||||
# too (see the matching default-profile override near the top). No retries.
|
||||
[[profile.ci.overrides]]
|
||||
filter = 'package(rustfs-ecstore) & (test(bucket_delete_waits_for_config_mutation_fence) | test(stale_config_request_cannot_mutate_a_recreated_bucket) | test(disk_incarnation_read_detects_stale_cache_until_peer_reload) | test(lifecycle_expiry_fails_closed_on_corrupt_object_lock_metadata) | test(expiry_configs_are_resolved_from_the_owning_store))'
|
||||
test-group = 'ecstore-serial-flaky'
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# e2e-smoke profile — PR smoke subset of the e2e_test crate (backlog#1149 ci-4)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -24,6 +24,7 @@ on:
|
||||
- '.github/actions/**'
|
||||
- '.github/workflows/**'
|
||||
- 'scripts/release/create_or_update_release.sh'
|
||||
- 'scripts/security/check_performance_ab_workflow.sh'
|
||||
- 'scripts/security/check_preview_release_workflow.sh'
|
||||
- 'scripts/security/check_workflow_pins.sh'
|
||||
pull_request:
|
||||
@@ -36,6 +37,7 @@ on:
|
||||
- '.github/actions/**'
|
||||
- '.github/workflows/**'
|
||||
- 'scripts/release/create_or_update_release.sh'
|
||||
- 'scripts/security/check_performance_ab_workflow.sh'
|
||||
- 'scripts/security/check_preview_release_workflow.sh'
|
||||
- 'scripts/security/check_workflow_pins.sh'
|
||||
schedule:
|
||||
@@ -141,6 +143,9 @@ jobs:
|
||||
- name: Check preview release workflow policy
|
||||
run: ./scripts/security/check_preview_release_workflow.sh
|
||||
|
||||
- name: Check performance A/B workflow trust boundary
|
||||
run: ./scripts/security/check_performance_ab_workflow.sh
|
||||
|
||||
dependency-review:
|
||||
name: Dependency Review
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -17,10 +17,10 @@
|
||||
# Two entry points, honestly scoped:
|
||||
# * schedule (nightly, on main): post-merge detection — catches a regression
|
||||
# within 24h of landing, not before merge.
|
||||
# * pull_request labeled `perf-ab`: opt-in pre-merge gate for a specific PR.
|
||||
# The `perf-deliberate-tradeoff` label runs the gate with --allow-regression so
|
||||
# a deliberate correctness cost (e.g. the #4221 fsync durability fix) is
|
||||
# recorded but does not block (rustfs/backlog#935 correction 1).
|
||||
# * workflow_dispatch: an explicitly selected trusted ref.
|
||||
# The dispatch input can run the gate with --allow-regression so a deliberate
|
||||
# correctness cost (e.g. the #4221 fsync durability fix) is recorded, not
|
||||
# blocked (rustfs/backlog#935 correction 1).
|
||||
|
||||
# DISABLED. This workflow is switched off in the repository's Actions settings
|
||||
# (state: disabled_manually) and does not run on any trigger, including its cron
|
||||
@@ -46,8 +46,6 @@ on:
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
pull_request:
|
||||
types: [labeled, synchronize, reopened]
|
||||
push:
|
||||
# Every main commit pre-builds and caches its release binary (perf-3) so the
|
||||
# nightly A/B restores a ready baseline instead of paying the double build.
|
||||
@@ -55,14 +53,6 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
# Per-PR: a new push cancels the previous (up to 90-minute) A/B run instead of
|
||||
# stacking them. Nightly schedule and manual dispatch get a unique group and
|
||||
# always run to completion.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
@@ -70,8 +60,8 @@ env:
|
||||
|
||||
jobs:
|
||||
# perf-3: on every push to main, build the release binary once and cache it
|
||||
# keyed by commit SHA (rustfs-baseline-<sha>). The nightly A/B (and, later, the
|
||||
# perf-7 PR gate) restore this instead of paying the ~32min-per-side source
|
||||
# keyed by commit SHA (rustfs-baseline-<sha>). The warp-ab measurements
|
||||
# restore this instead of paying the ~32min-per-side source
|
||||
# build. That double build is what pushed the expanded 24-cell nightly past its
|
||||
# ceiling — 2026-07-11..07-14 all cancelled on the 120min timeout. Incremental
|
||||
# builds off the shared cargo cache keep each push cheap, and building on the
|
||||
@@ -126,17 +116,11 @@ jobs:
|
||||
|
||||
warp-ab:
|
||||
name: Warp A/B budget gate
|
||||
# Always run on schedule / manual dispatch. Opt-in on PRs: only when the
|
||||
# `perf-ab` label is present, and for `labeled` events only when the label
|
||||
# being added is `perf-ab` itself (adding an unrelated label to an opted-in
|
||||
# PR must not re-run the gate). Never on push — that event only feeds
|
||||
# build-baseline-cache above.
|
||||
# Always run on schedule / manual dispatch. Never on push — that event only
|
||||
# feeds build-baseline-cache above.
|
||||
if: >-
|
||||
github.event_name == 'schedule' ||
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(github.event_name == 'pull_request' &&
|
||||
contains(github.event.pull_request.labels.*.name, 'perf-ab') &&
|
||||
(github.event.action != 'labeled' || github.event.label.name == 'perf-ab'))
|
||||
github.event_name == 'workflow_dispatch'
|
||||
runs-on: sm-standard-2
|
||||
# With perf-3's cached baseline binary the common (cache-hit) nightly is
|
||||
# measurement-only and finishes well under 50min. This ceiling stays
|
||||
@@ -174,10 +158,6 @@ jobs:
|
||||
INPUT_ALLOW_REGRESSION: ${{ github.event.inputs.allow_regression }}
|
||||
run: |
|
||||
allow="false"
|
||||
if [[ "${{ github.event_name }}" == "pull_request" ]] \
|
||||
&& ${{ contains(github.event.pull_request.labels.*.name, 'perf-deliberate-tradeoff') }}; then
|
||||
allow="true"
|
||||
fi
|
||||
if [[ "$INPUT_ALLOW_REGRESSION" == "true" ]]; then
|
||||
allow="true"
|
||||
fi
|
||||
@@ -314,10 +294,10 @@ jobs:
|
||||
echo "candidate binary: $cand_src"
|
||||
|
||||
if [[ "${{ steps.exempt.outputs.allow_regression }}" == "true" ]]; then
|
||||
args+=(--allow-regression --exemption-reason "labeled perf-deliberate-tradeoff / dispatch override")
|
||||
args+=(--allow-regression --exemption-reason "workflow dispatch override")
|
||||
fi
|
||||
# Do not let a gate FAIL abort the job here; capture status and surface
|
||||
# it after the PR comment is posted.
|
||||
# it after the step summary is written.
|
||||
set +e
|
||||
bash scripts/run_hotpath_warp_abba.sh "${args[@]}"
|
||||
echo "status=$?" >> "$GITHUB_OUTPUT"
|
||||
@@ -382,13 +362,6 @@ jobs:
|
||||
fi
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Comment gate result on PR
|
||||
if: always() && github.event_name == 'pull_request' && steps.ab.outputs.gate_md != ''
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh pr comment "${{ github.event.pull_request.number }}" --body-file "${{ steps.ab.outputs.gate_md }}"
|
||||
|
||||
# Scheduled failure alerting is handled by the alert-on-failure job below
|
||||
# (perf-2 consuming ci-8's schedule-failure-issue composite action).
|
||||
|
||||
@@ -397,7 +370,7 @@ jobs:
|
||||
run: |
|
||||
status="${{ steps.ab.outputs.status }}"
|
||||
if [[ "$status" != "0" ]]; then
|
||||
echo "::error::warp A/B budget gate failed (exit $status). See the step summary / PR comment / gate.md artifact." >&2
|
||||
echo "::error::warp A/B budget gate failed (exit $status). See the step summary / gate.md artifact." >&2
|
||||
exit "$status"
|
||||
fi
|
||||
echo "warp A/B budget gate passed."
|
||||
@@ -407,14 +380,12 @@ jobs:
|
||||
needs: [warp-ab]
|
||||
# `always()` is required: without it this job is skipped when a needed
|
||||
# job fails. Alerts only for scheduled (nightly) runs (backlog#1149
|
||||
# ci-8); PR and manual dispatch failures are already watched by a human.
|
||||
# ci-8); manual dispatch failures are already watched by a human.
|
||||
# `cancelled` is included alongside `failure` on purpose: a job that hits
|
||||
# timeout-minutes ends as `cancelled`, and the 2026-07-11..07-14 nightly
|
||||
# timeouts went silent precisely because the guard was failure-only. The
|
||||
# composite action already reports cancelled/timed-out jobs in the issue
|
||||
# body. (Scheduled runs get a unique concurrency group with
|
||||
# cancel-in-progress off, so a cancellation here means a timeout/manual
|
||||
# abort, never a superseding run.)
|
||||
# body.
|
||||
if: >-
|
||||
always() && github.event_name == 'schedule' &&
|
||||
(contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled'))
|
||||
|
||||
@@ -83,3 +83,7 @@ worktrees/*
|
||||
|
||||
# Local AI-agent review artifacts (omo evidence dumps)
|
||||
.omo/
|
||||
|
||||
# insta scratch files; the accepted .snap files ARE the assertions and are committed
|
||||
*.snap.new
|
||||
*.pending-snap
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
---
|
||||
name: issue-triage
|
||||
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
|
||||
|
||||
Use this skill when the user provides a GitHub issue URL and asks "can this be closed?", "is this already implemented?", "check completion status", or similar triage questions.
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Fetch issue context
|
||||
|
||||
```bash
|
||||
gh issue view <N> --repo <owner/repo> --json title,body,state,comments,labels,updatedAt
|
||||
```
|
||||
|
||||
Read the issue body to understand what was requested. Extract:
|
||||
- The specific feature/fix/behavior described.
|
||||
- Any linked PRs or commits mentioned in the body or comments.
|
||||
- Any checklist items or sub-issues.
|
||||
|
||||
### 2. Search for related work
|
||||
|
||||
Search git history for commits referencing the issue:
|
||||
```bash
|
||||
git log --oneline --all --grep="<N>" | head -30
|
||||
```
|
||||
|
||||
Search for related PRs:
|
||||
```bash
|
||||
gh pr list --search "fixes #<N> OR closes #<N> OR #<N>" --state all --json number,title,state,mergedAt
|
||||
```
|
||||
|
||||
If the issue mentions specific PRs, check their status:
|
||||
```bash
|
||||
gh pr view <PR_N> --json state,mergedAt,title
|
||||
```
|
||||
|
||||
### 3. Verify implementation
|
||||
|
||||
For each linked or related PR that is merged, verify the fix is actually present on the current main branch:
|
||||
```bash
|
||||
git log --oneline main | grep -i "<keyword>"
|
||||
# or
|
||||
git log --oneline main --grep="<PR_N>"
|
||||
```
|
||||
|
||||
If the issue describes a specific defect, check the relevant code to confirm the fix is in place:
|
||||
```bash
|
||||
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:
|
||||
```bash
|
||||
gh issue view <SUB_N> --repo <owner/repo> --json state
|
||||
```
|
||||
|
||||
### 4. Determine verdict
|
||||
|
||||
- **All items fixed and merged**: Close with a summary comment listing what was fixed and which PRs.
|
||||
- **Some items fixed, some remaining**: Comment with status of each item. Do not close.
|
||||
- **Not yet implemented**: Comment with a summary of what remains. Do not close.
|
||||
- **Superseded or no longer relevant**: Close with explanation.
|
||||
|
||||
### 5. Take action
|
||||
|
||||
Close with comment:
|
||||
```bash
|
||||
gh issue close <N> --repo <owner/repo> --comment "<body>"
|
||||
```
|
||||
|
||||
Comment without closing:
|
||||
```bash
|
||||
gh issue comment <N> --repo <owner/repo> --body-file /tmp/triage.md
|
||||
```
|
||||
|
||||
Update issue labels if needed:
|
||||
```bash
|
||||
gh issue edit <N> --repo <owner/repo> --add-label "completed" --remove-label "needs-triage"
|
||||
```
|
||||
|
||||
Always use `--body-file` for multiline content, never inline `--body`.
|
||||
|
||||
### 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"):
|
||||
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.
|
||||
3. Report a summary table of all triaged issues with verdicts.
|
||||
|
||||
## Output format
|
||||
|
||||
### Issue Triage: #<N> — <title>
|
||||
|
||||
**State**: OPEN / CLOSED
|
||||
**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
|
||||
|
||||
- The user may ask in Chinese ("是否可以关闭", "检查完成情况"); respond in the same language.
|
||||
- When closing, always include a summary of what was fixed and which PRs resolved it — this creates a useful audit trail.
|
||||
- For issues in `rustfs/backlog`, use `--repo rustfs/backlog`.
|
||||
- For issues in `rustfs/rustfs`, use `--repo rustfs/rustfs`.
|
||||
- If the issue has sub-issues (GitHub sub-issues API), check each one's state before declaring the parent complete.
|
||||
@@ -0,0 +1,147 @@
|
||||
---
|
||||
name: pr-review
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
- Read `AGENTS.md` for the repository's adversarial validation policy and change-style rules.
|
||||
- The `adversarial-validation` skill handles the review role playbooks; this skill orchestrates the workflow around it.
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Gather PR context
|
||||
|
||||
```bash
|
||||
gh pr view <N> --json title,author,state,body,additions,deletions,changedFiles,commits,baseRefName,headRefName
|
||||
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:
|
||||
```bash
|
||||
gh issue view <ISSUE> --json title,body,state
|
||||
```
|
||||
|
||||
### 2. Fetch the diff and classify the change
|
||||
|
||||
```bash
|
||||
git fetch origin pull/<N>/head:pr-<N>
|
||||
git diff main...pr-<N> --stat
|
||||
```
|
||||
|
||||
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. Cluster changed files and delegate review
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
```bash
|
||||
gh pr checks <N>
|
||||
```
|
||||
|
||||
If any checks fail, investigate:
|
||||
```bash
|
||||
gh run view --log-failed --job=<JOB_ID>
|
||||
```
|
||||
|
||||
Determine whether failures are pre-existing (on main), flaky, or caused by the PR.
|
||||
|
||||
### 5. Synthesize findings
|
||||
|
||||
Combine all subagent findings into a structured review:
|
||||
- **Summary**: one-paragraph overview of the change and overall assessment.
|
||||
- **Findings**: each finding with severity (critical/major/minor/nit), file:line, concrete failure scenario, and suggested fix.
|
||||
- **CI status**: pass/fail with notes on any failures.
|
||||
- **Verdict**: APPROVE, REQUEST_CHANGES, or COMMENT.
|
||||
|
||||
### 6. Post the review
|
||||
|
||||
Write the review body to a temp file and post via CLI:
|
||||
```bash
|
||||
# Request changes
|
||||
gh pr review <N> --request-changes --body-file /tmp/pr_review.md
|
||||
|
||||
# Approve
|
||||
gh pr review <N> --approve --body-file /tmp/pr_review.md
|
||||
|
||||
# Comment only (no verdict)
|
||||
gh pr review <N> --comment --body-file /tmp/pr_review.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`.
|
||||
|
||||
### 7. Handle follow-up
|
||||
|
||||
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
|
||||
|
||||
- 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.
|
||||
- If the PR is from a fork, check `maintainerCanModify` before attempting to push fixes.
|
||||
- For very large PRs (>50 files), cluster aggressively and delegate in parallel to keep review time reasonable.
|
||||
Generated
+5
-5
@@ -9135,11 +9135,11 @@ name = "rustfs-audit"
|
||||
version = "1.0.0-beta.12"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
"const-str",
|
||||
"futures",
|
||||
"hashbrown 0.17.1",
|
||||
"hotpath",
|
||||
"jiff",
|
||||
"metrics",
|
||||
"rustfs-config",
|
||||
"rustfs-s3-types",
|
||||
@@ -9417,6 +9417,7 @@ dependencies = [
|
||||
"rustfs-utils",
|
||||
"s3s",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"thiserror 2.0.19",
|
||||
"time",
|
||||
@@ -9736,11 +9737,11 @@ dependencies = [
|
||||
"arc-swap",
|
||||
"async-trait",
|
||||
"axum",
|
||||
"chrono",
|
||||
"criterion",
|
||||
"form_urlencoded",
|
||||
"hashbrown 0.17.1",
|
||||
"hotpath",
|
||||
"jiff",
|
||||
"metrics",
|
||||
"percent-encoding",
|
||||
"quick-xml",
|
||||
@@ -9861,10 +9862,10 @@ version = "1.0.0-beta.12"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64-simd",
|
||||
"chrono",
|
||||
"futures",
|
||||
"hotpath",
|
||||
"ipnetwork",
|
||||
"jiff",
|
||||
"jsonwebtoken 11.0.0",
|
||||
"moka",
|
||||
"pollster",
|
||||
@@ -10090,7 +10091,6 @@ dependencies = [
|
||||
"s3s",
|
||||
"serde_json",
|
||||
"serial_test",
|
||||
"tempfile",
|
||||
"thiserror 2.0.19",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
@@ -10204,7 +10204,6 @@ dependencies = [
|
||||
"arc-swap",
|
||||
"async-nats",
|
||||
"async-trait",
|
||||
"chrono",
|
||||
"criterion",
|
||||
"deadpool-postgres",
|
||||
"futures-util",
|
||||
@@ -10212,6 +10211,7 @@ dependencies = [
|
||||
"hotpath",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"jiff",
|
||||
"lapin",
|
||||
"libc",
|
||||
"metrics",
|
||||
|
||||
@@ -274,7 +274,6 @@ num_cpus = { version = "1.17.0" }
|
||||
nvml-wrapper = "0.12.1"
|
||||
parking_lot = "0.12.5"
|
||||
path-absolutize = "4.0.1"
|
||||
path-clean = "1.0.1"
|
||||
percent-encoding = "2.3.2"
|
||||
pin-project-lite = "0.2.17"
|
||||
pretty_assertions = "1.4.1"
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
# RustFS 站点复制 / 桶复制 — MinIO 兼容性审查报告
|
||||
|
||||
> 审查日期:2026-08-05
|
||||
> 审查对象:RustFS(worktree `reatang/minio-compatibility-review-03a7fb`)vs MinIO(`/Users/tang/Documents/GitHub/minio`)
|
||||
> 审查方式:白盒代码对比(5 个维度并行审查)+ P0 问题对抗性复核
|
||||
> 审查维度:站点复制白盒对比、桶复制白盒对比、mc 工具兼容性、S3 标准协议兼容性、代码结构与分层
|
||||
|
||||
---
|
||||
|
||||
## 一、总体结论
|
||||
|
||||
| 领域 | 兼容性评价 |
|
||||
|---|---|
|
||||
| **站点复制(RustFS↔RustFS + mc 管理)** | 良好。admin 端点全覆盖、JSON 结构对齐 madmin-go、请求体 DARE 加密兼容,mc admin replicate 全家桶基本可用 |
|
||||
| **站点复制(RustFS↔MinIO 混合组网)** | **断裂**。4 个 P0:出站 join 路径 404、metainfo 大小写解析失败、STS item 类型名不一致、policy-mapping userType 数值错位 |
|
||||
| **桶复制(控制面,S3 标准 API)** | 良好。Put/Get/DeleteBucketReplication、错误码、状态机字符串、xl.meta 内部键均对齐 |
|
||||
| **桶复制(数据面,RustFS→MinIO)** | **断裂**。复制 PUT 缺 `?versionId=` 导致目标端版本漂移(P0);CopyObject 完全不复制(P0) |
|
||||
| **mc 桶复制命令** | **部分断裂**。`mc replicate add` 默认参数即失败(P0);status/resync/backlog 响应结构不匹配导致静默空输出(P1) |
|
||||
| **代码结构** | 桶复制侧迁移架构有纪律但成本高;**站点复制侧无领域层,约 9500 行业务逻辑堆在 admin handler,且存在 3 处反向依赖违反项目分层不变量(P0)** |
|
||||
|
||||
**做得好的地方**(已确认兼容,无需整改):复制状态机字符串(PENDING/COMPLETED/FAILED/REPLICA 含 legacy COMPLETE)、xl.meta 内部键双前缀(x-rustfs-internal- + x-minio-internal-)读写、ReplicateDecision 内部状态串格式、复制内部头主链路双前缀、Delete/VersionPurge 语义、Resync reset-id 判定、admin 路由 `/minio/admin/v3` 前缀别名、madmin DARE 加密流解密、站点复制 gob netperf 编码、`site-repl-<deploymentID>` 规则模板。
|
||||
|
||||
---
|
||||
|
||||
## 二、P0 问题清单(8 项)
|
||||
|
||||
| # | 问题 | 来源维度 | 断裂方向 |
|
||||
|---|---|---|---|
|
||||
| P0-1 | 出站 peer join 使用 MinIO 已移除的遗留路径 `/site-replication/join` → 404 | 站点复制 | RustFS→MinIO |
|
||||
| P0-2 | 解析 MinIO metainfo(SRInfo)字段大小写不匹配 → add preflight 失败 | 站点复制 | RustFS→MinIO |
|
||||
| P0-3 | STS 凭证复制 item 类型名 `sts-credential` vs `sts-account` | 站点复制 | 双向 |
|
||||
| P0-4 | policy-mapping `userType` 数值语义错位(RustFS: None=0/Svc=1/Sts=2/Reg=3;MinIO: reg=0/sts=1/svc=2)→ 权限静默漂移 | 站点复制 | 双向 |
|
||||
| P0-5 | 复制 PUT/CompleteMultipart 不携带 `?versionId=` query → MinIO 端版本号漂移、版本删除永久 no-op、双端静默发散(**功能视角复核:定级调整为 P1**,问题重述为"普通复制对象缺少可靠的源→目标版本身份策略";versionId query 是可行修复之一而非唯一正确方案) | 桶复制 | RustFS→MinIO |
|
||||
| P0-6 | CopyObject(含 metadata-replace 自拷贝)完全不触发复制调度,对象静默不复制(**功能视角复核:定级调整为 P1**;scanner 在 ExistingObjectReplication 启用+状态为空时可最终补齐,但同步复制语义失效,且继承 stale COMPLETED / 显式 Disabled 场景长期漏复制) | 桶复制 + S3 协议 | 所有方向 |
|
||||
| P0-7 | `mc replicate add` 默认参数(healthcheck-seconds=60)被硬拒 400;且字段单位按秒解析而 wire 为纳秒 | mc 兼容 | mc→RustFS |
|
||||
| P0-8 | 架构:站点复制约 9500 行业务逻辑堆在 admin handler 单文件;app/storage 层 3 处反向导入 admin 层,违反 ARCHITECTURE.md 分层不变量 #1(**对抗复核后降级为 P1**:反向边已被 arch 守卫棘轮基线锁死,属受控技术债) | 代码结构 | — |
|
||||
|
||||
每项 P0 的对抗性复核结论、验证方案与解决方案见 **第五节**。
|
||||
|
||||
**修复状态(2026-08-05)**:7 项确认 P0 已全部修复并创建 PR(红灯→绿灯 TDD):P0-1 [#5748](https://github.com/rustfs/rustfs/pull/5748)、P0-2 [#5749](https://github.com/rustfs/rustfs/pull/5749)、P0-3 [#5750](https://github.com/rustfs/rustfs/pull/5750)、P0-4 [#5751](https://github.com/rustfs/rustfs/pull/5751)、P0-5 [#5752](https://github.com/rustfs/rustfs/pull/5752)、P0-6+P1-10 [#5753](https://github.com/rustfs/rustfs/pull/5753)、P0-7 [#5754](https://github.com/rustfs/rustfs/pull/5754)。合并顺序:#5748+#5749 同批;#5752 先于 #5753。
|
||||
|
||||
---
|
||||
|
||||
## 三、P1 问题清单
|
||||
|
||||
### 站点复制
|
||||
|
||||
| # | 问题 | 证据 | 影响 |
|
||||
|---|---|---|---|
|
||||
| P1-1 | ILM(lc-config)复制语义:对外开关限定 `replicateILMExpiry`,但发送端把**完整** lifecycle.xml 放入 `expiry_lc_config`,接收端整体覆盖/删除本地配置(功能视角复核:**确认,维持 P1**;更新时间检查只能拒旧,不能修复整体覆盖语义) | RustFS `bucket_meta.rs:948-951`、`site_replication.rs:7590-7683` vs MinIO `site-replication.go:1784-1810,6138` | lifecycle 同时含 expiry 与本地 transition 时,非 expiry 规则被错误传播或本地 transition 被覆盖。**缺"同步 expiry 后保留本地 transition"测试** |
|
||||
| ~~P1-2~~→**P2-25** | `SRInfo.ilmExpiryRules` 从不填充,ILM 一致性状态恒为空(功能视角复核:**降级 P2**——仅影响管理面可观测性,不改变对象数据) | `site_replication.rs:4152-4266,4855-4868` | `mc admin replicate status --ilm-expiry-rules` 恒空,ILM 漂移不可见 |
|
||||
| P1-3 | 无自动跨站元数据 heal(MinIO 有周期 heal 协程) | RustFS 仅 600s 本地 wiring 修复(`site_replication_reconcile.rs:34,59-81`)+ 手动 repair 端点 vs MinIO `site-replication.go:4257-4288` | 错过的 IAM/bucket 元数据更新持续漂移,须手工 repair |
|
||||
| P1-4(拆分) | ①`sync` 同步复制指控:功能视角复核**不成立/证据不足**——RustFS 自身契约明确将 `sync_state` 定义为站点可达性/配置完整性健康状态且有测试,不能以他家同名字段判其错误(属"RustFS 独特设计保持不变"项,撤销);②`defaultbandwidth`:**确认,降级 P2**——公共 API 接受并持久化,但建 site replication bucket target 时不应用,reconcile 只保留既有 `bandwidth_limit`,配置成功但不生效 | `site_replication.rs:6303-6357,5004-5027` | ②为用户可见的"配置成功但无效"能力缺口 |
|
||||
|
||||
### 桶复制 / S3 协议
|
||||
|
||||
| # | 问题 | 证据 | 影响 |
|
||||
|---|---|---|---|
|
||||
| P1-5 | 未复制完成对象的 GET/HEAD 远端 proxy 未实现;也不识别 MinIO 的 `X-Minio-Source-Proxy-Request` 防环头 | 仅指标占位(`storage_api.rs:799-804`);`SUFFIX_SOURCE_PROXY_REQUEST` 定义后无人使用 vs MinIO `bucket-replication.go:2334,2409,2534` | active-active 复制滞后窗口内 RustFS 端 404 |
|
||||
| P1-6 | `X-Minio-Source-Replication-{Tagging,Retention,LegalHold}-Timestamp` 三个时间戳头收发均缺失 | `replication_target_boundary.rs:251-297` 填了 options 但 `PutObjectOptions::header()` 不序列化;接收端不解析 vs MinIO `object-api-options.go:377-399` | active-active 下标签/retention/legal-hold 并发修改的 LWW 冲突解析退化,可能元数据回滚 |
|
||||
| P1-7 | ARN 前缀 `arn:rustfs:` 与 `arn:minio:` 不互认(解析侧强制 `arn:rustfs:`) | `crates/ecstore/src/bucket/target/arn.rs:43,51` vs MinIO `bucket-targets.go:709` | 存量 MinIO 复制配置迁移被 StaleTarget 拒;原生 madmin SDK 解析 RustFS ARN 失败 |
|
||||
| P1-8 | PutBucketReplication 校验缺口(规则数/Priority 唯一/ID 长度/Filter 互斥/2MB 上限全缺)+ 主动拒绝 `Destination.StorageClass` 等 MinIO/AWS 合法字段 | `bucket_usecase.rs:582-616`、`config.rs:143-232` vs MinIO `internal/bucket/replication/replication.go:29-90` | 非法配置被接受、优先级冲突行为不可预测;存量 AWS/Terraform 配置(含 StorageClass)直接 400 |
|
||||
| ~~P1-9~~→**P2-26** | GetObject 响应缺 `x-amz-replication-status` 头(HEAD 有 GET 无),且 GET 专门把它从 metadata 过滤掉(功能视角复核:**降级 P2**;GET/HEAD 不一致确认,缺 GET replication-status 回归测试) | `object_usecase.rs:5696-5735`、`options.rs:702` vs MinIO `api-headers.go:236-238` | 依赖 GET 判断复制状态的客户端/监控失效;修复约一行 |
|
||||
| P1-10 | Snowball auto-extract 解包对象不触发复制(功能视角复核:**确认,维持 P1**,但"全部永不复制"不准确——scanner 在状态空+ExistingObjectReplication 启用时可补齐;显式 Disabled 等场景长期遗漏,即时复制始终失效。带 REPLICA 状态的入站成员须继续避免回环)。**已随 [#5753](https://github.com/rustfs/rustfs/pull/5753) 修复**(含入站复制 PUT 不再被误派发 extract 的次生缺陷) | `object_usecase.rs:8201` vs MinIO `object-handlers.go:2452,2510-2511` | 批量导入对象不即时复制;缺普通解包成员复制结果的测试(已在 #5753 补充 e2e) |
|
||||
|
||||
### mc 响应结构(静默空输出类)
|
||||
|
||||
| # | 问题 | 证据 | 影响 |
|
||||
|---|---|---|---|
|
||||
| P1-11 | `?replication-metrics[=2]` 响应为 Rust snake_case,minio-go MetricsV2 期望 camelCase(`currStats`/`queueStats`/…) | `stats.rs:617-770`、`admin/router.rs:1583-1592` vs MinIO `bucket-stats.go:154-188` | `mc replicate status` 不报错但全零(静默错误) |
|
||||
| P1-12 | replication-reset(resync)响应壳不匹配:`{"Targets":[{"Arn","ResetID",...}]}` vs `{"target":[{"arn","resetid","resyncStatus",...}]}` | `router.rs:126-198,1735-1803` vs MinIO `bucket-replication-utils.go:613-636` | `mc replicate resync start/status` 输出空;仅响应壳问题,修复成本低 |
|
||||
| P1-13 | `/v3/replication/mrf` 与 `/v3/replication/diff` 返回单个聚合对象而非条目流(代码自述 deliberate) | `replication.rs:695-725,879-911,998-1047` vs madmin-go `replication-api.go:104-176` | `mc replicate backlog` 输出空;`node`/`arn`/`verbose` 参数被忽略 |
|
||||
| P1-14 | set-remote-target 请求体 `deny_unknown_fields` + 字段名偏差(期望 `bandwidth_limit`,madmin 发 `bandwidthlimit`;`session_token` vs `sessionToken` 等) | `handlers/replication.rs:88-95,108-163` vs madmin-go `bucket-targets.go:76` | `mc replicate add/update --bandwidth` 整请求失败;凡 omitempty 字段一旦出现即 400 |
|
||||
|
||||
### 代码结构
|
||||
|
||||
| # | 问题 | 证据 | 影响 |
|
||||
|---|---|---|---|
|
||||
| P1-15 | 站点复制状态两套归一化实现(handler 类型化 vs service 无类型 JSON),且 reload 的 read→normalize→save 全程无共同分布式对象锁,存在 lost-update 竞争;repair state 已用 `with_config_object_write_lock` 包住完整 RMW,主 state 未采用同等保护(功能视角复核:**确认,维持 P1**;进程内 `SITE_REPLICATION_STATE_LOCK` 与单次 read/save 各自的对象锁均不能保护跨调用 RMW:A 读旧→B 另节点写入→A 用旧快照覆盖,B 丢失) | `handlers/site_replication.rs:114,347,1039-1130` vs `service/site_replication.rs:26-135` | 归一化语义可 drift;多节点/RPC 并发写状态互相覆盖。**缺多节点/双写者 lost-update 回归测试** |
|
||||
| P1-16 | 复制状态机类型双份定义:`rustfs-filemeta` 与 `rustfs-replication` 各持一份(ReplicationStatusType/VersionPurgeStatusType/ReplicationState/MrfReplicateEntry/ReplicateObjectInfo),靠 boundary 双向转换 | `crates/filemeta/src/replication.rs` vs `crates/replication/src/filemeta.rs` | 状态机语义修改须同步两处+转换层,漏一处即静默数据语义错误;建议加 enum 对账测试 |
|
||||
| P1-17 | 桶复制逻辑分裂:`crates/replication` 仅契约,执行引擎(pool 5947 行、resyncer 4090 行)仍在 ecstore,中间 20+ 个 boundary/bridge 微文件;迁移无完成判据,脚手架有固化风险 | `crates/ecstore/src/bucket/replication/README.md`、`mod.rs:15-45` | 可读性/可维护性成本;需设定迁移里程碑 |
|
||||
| P1-18 | 超长函数集中在复制热路径:`resync_bucket` 536 行、`replicate_all` 403 行、`start_mrf_processor` 305 行、`apply_iam_item` 248 行 | `replication_resyncer.rs:546`、`replication_pool.rs`、`site_replication.rs:7806` | 正确性审查与修改风险高 |
|
||||
|
||||
### 第三方复审新增与调整项(功能视角二次复核后)
|
||||
|
||||
| # | 问题 | 来源 | 影响 |
|
||||
|---|---|---|---|
|
||||
| P1-19 | 普通复制对象缺少可靠的源→目标版本身份策略:PUT 响应的目标版本 ID 未捕获/持久化,对不支持 versionId query 的目标(原生 AWS S3 等),后续版本删除复制落空;MRF 只会重试同一个错误身份,HEAD ETag fallback 不能修复删除 | P0-5 复审 | 非 MinIO 系目标的版本化复制双端发散。缺"目标自行分配版本 ID"场景测试 |
|
||||
| P1-20 | 缺少 scanner 补偿边界的 e2e:ExistingObjectReplication Enabled/Disabled × 空状态/继承状态 组合下的补齐与不补齐行为无回归覆盖(Copy 与 Snowball 两路径) | P0-6/P1-10 复审 | scanner 兜底语义变化不可见 |
|
||||
| P1-21 | delete-marker 延迟 purge 失败静默丢弃(由 P2-20① 升级):目标删除失败无日志/状态/MRF,目标端 marker/版本可能永久残留 | P2-20 复核升级 | 数据一致性;缺失败注入测试 |
|
||||
| P1-22 | 桶复制整体 SSE 支持能力缺口(替代原 P2-23):SSE-S3/SSE-KMS 所有复制模式统一 fail closed,SSE-C 失败被 e2e 钉为当前行为,无 encrypted-object resync e2e | P2-23 复核改写 | 加密对象跨站不复制;需覆盖普通复制/Heal/Resync/Multipart 四模式 |
|
||||
|
||||
### 功能视角二次复核采纳记录(backlog#1675,基于 main f0c4fbd28)
|
||||
|
||||
复核共 10 项,判定依据为 RustFS 自身功能契约与实际调用链,不以对齐 MinIO 为正确性标准。采纳结果:
|
||||
|
||||
| 原编号 | 复核结论 | 采纳动作 |
|
||||
|---|---|---|
|
||||
| P0-5 | 确认,P0→P1,问题重述为"源→目标版本身份策略缺失" | 定级调整;修复已合 [#5752](https://github.com/rustfs/rustfs/pull/5752);残留缺口 P1-19 |
|
||||
| P0-6 | 确认,P0→P1,scanner 描述纠正 | 定级调整;修复已合 [#5753](https://github.com/rustfs/rustfs/pull/5753);测试缺口 P1-20 |
|
||||
| P1-1 | 确认,维持 P1 | 补记"expiry 同步后保留本地 transition"测试缺口 |
|
||||
| P1-2 | 确认,P1→P2(仅管理面可观测性) | 改编号 P2-25 |
|
||||
| P1-4 | 拆分:`sync` 指控不成立(RustFS 自身契约定义为健康状态,有测试);`defaultbandwidth` 确认为 P2 能力缺口 | `sync` 撤销并归入"独特设计保持不变";`defaultbandwidth` 降 P2 |
|
||||
| P1-9 | 确认,P1→P2 | 改编号 P2-26;补记缺 GET 回归测试 |
|
||||
| P1-10 | 确认,维持 P1,"全部永不复制"改为"即时复制失效+部分场景长期遗漏" | 已随 [#5753](https://github.com/rustfs/rustfs/pull/5753) 修复(含回环防护) |
|
||||
| P1-15 | 确认,维持 P1(竞争机理精确化:跨调用 RMW 无共同分布式锁) | 补记缺双写者 lost-update 测试 |
|
||||
| P2-20① | 确认,P2→P1(延迟 purge 失败静默丢弃部分) | 升级为 P1-21;②③维持 P2 |
|
||||
| P2-23 | resync 专属指控不成立;暴露桶复制整体 SSE 能力缺口 | 撤销原表述,改立 P1-22 |
|
||||
|
||||
**复核指出的测试补齐清单**(均未运行跨实例集成验证,需落地):目标自行分配版本 ID、Copy/Snowball scanner 补偿边界、lifecycle expiry/transition 保留、site state 双写竞争、delayed purge 失败注入、encrypted-object resync。
|
||||
|
||||
---
|
||||
|
||||
## 四、P2 问题清单
|
||||
|
||||
### 站点复制
|
||||
- **P2-1** `showDeleted` 选项与 `bucketDeletedTimestamp` 未实现(`site_replication.rs:1364-1381`)
|
||||
- **P2-2** 错误码泛化:统一 `InvalidRequest`/`InternalError`,无 MinIO 的 9 个 `XMinioSiteReplication*` 专用码(400/503 语义丢失)
|
||||
- **P2-3** `make-with-versioning` 忽略 `versioningEnabled`/`forceCreate` 参数,恒 true(`site_replication.rs:8597-8627`)
|
||||
- **P2-4** netperf 返回"不支持"占位(gob 格式兼容不会崩);devnull 有请求体大小上限(MinIO 无限 discard)
|
||||
- **P2-5** Metrics 摘要仅含本站,无 per-peer 链路统计(downtime/latency/失败窗口)
|
||||
- **P2-6** `external-user`/`credential` IAM item 未实现——与本仓 MinIO 版本等价缺失,结构已预留;对接新版 MinIO 时会成缺口
|
||||
- **P2-7** 本地 deploymentID 缺失时回退 endpoint 哈希(16 位 hex,非 UUID 形态)
|
||||
|
||||
### 桶复制 / S3 协议
|
||||
- **P2-8** 遗留内部 client 头名错误:`X-Source-DeleteMarker`/`X-Check-Replication-Ready` 缺 `X-Minio-` 前缀(`client/api_stat.rs:191-231`,当前路径未激活,潜伏缺陷)
|
||||
- **P2-9** Remote target admin 错误码扁平化(MinIO 有 404/503 专用码,RustFS 统一 400/500)
|
||||
- **P2-10** Remote target 拒绝 `disableProxy`/`edge`/`edgeSyncBeforeExpiry` 等 madmin 字段(非默认参数,影响小)
|
||||
- **P2-11** `list-remote-targets` 序列化偏差:`bandwidth_limit`/`storage_class`/`deployment_id`/`reset_id`/`session_token` vs madmin 的 `bandwidthlimit`/`storageclass`/`deploymentID`/`resetID`/`sessionToken`;`healthCheckDuration`/`totalDowntime` 按秒序列化而 Go 按纳秒解;`type` 过滤参数被忽略
|
||||
- **P2-12** set-remote-target?update=true 忽略 madmin 的 op 标志(creds/sync/proxy/…),固定整体覆盖
|
||||
- **P2-13** XML 反序列化:Rule 内未知元素严格报 MalformedXML(顶层却跳过,行为不一致);缺 `<Role>` 报 MalformedXML(Go 容忍)——向前兼容性差,当前主流客户端不受影响
|
||||
- **P2-14** `ReplicaModifications` 默认 Disabled(与 AWS 一致、与 MinIO 的注入 Enabled 分歧);PUT 时不像 MinIO 那样注入默认元素回写
|
||||
- **P2-15** PutBucketReplication 要求预先注册 remote target(与 MinIO 同构、与纯 AWS 流程分歧),报错未指引先建 target
|
||||
- **P2-16** GetBucketReplication 响应无 xmlns(与 MinIO 一致,极少数严格 SDK 可能拒收)
|
||||
- **P2-17** 站点复制启用时不阻止普通用户直接改桶复制配置(MinIO 非 root 报 `ErrReplicationDenyEditError`)
|
||||
- **P2-18** Prometheus 指标名对齐 metrics-v3 但注册前缀为 rustfs 体系;versioning 错误文案与 MinIO 不同(code 一致)
|
||||
|
||||
### 代码结构
|
||||
- **P2-19** `apply_iam_item` / bucket-ops 用裸字符串 match 分发,无法穷尽检查;建议改 `#[serde(tag)]` 枚举
|
||||
- **P2-20(拆分)** 静默吞错:①`replication_resyncer.rs:1693` delete-marker 延迟 purge 失败被 `let _ =` 丢弃,target client 缺失时直接跳过——**功能视角复核:升级为 P1-21**(失败后无日志、无状态更新、不入 MRF,目标 delete marker/版本可能永久残留;启动前的 5 次循环只是等源 marker 消失,不是对目标删除失败的重试。缺注入目标删除失败并验证重试/状态/MRF 的测试);②`site_replication.rs:8661` purge-deleted-bucket 吞掉非 NotFound 错误、`:9227` cancel resync 失败无痕迹——维持 P2
|
||||
- **P2-21** `MrfV2` 全套机制(Error/Capabilities/Readiness/Reader/Envelope)未接线,生产只用 v1,属投机代码
|
||||
- **P2-22** `persist_site_replication_state` 双重 clone + 双重 normalize(`site_replication.rs:1143-1152` → `:1116-1122`)
|
||||
- **P2-23(撤销并改写)** 原"resync 不处理 SSE"指控不成立——`ReplicationType::Resync` 与普通复制/Heal 最终走同一 `replication_put_object_options`,`// TODO: SSE` 不构成 resync 独立行为差异。真实状态:SSE-S3/SSE-KMS 在**所有复制模式**下统一 fail closed,SSE-C 普通桶复制失败已被现有 e2e 钉为当前行为,且无 encrypted-object resync e2e → 改立能力项 **P1-22"桶复制整体 SSE 支持"**(需分别覆盖普通复制、Heal、手动 Resync、Multipart)
|
||||
- **P2-24** `crates/replication` 命名误导(名为复制引擎实为契约库),建议 lib.rs 顶部文档说明
|
||||
- 正面确认:生产代码 unwrap/expect 纪律良好(几乎全在测试模块);MinIO 概念映射(ReplicationPool/Resyncer/MRF/TargetClient)桶复制侧清晰,站点复制侧缺 `SiteReplicationSys` 聚合体
|
||||
|
||||
---
|
||||
|
||||
## 五、P0 问题对抗性分析(复核结论 + 验证方案 + 解决方案)
|
||||
|
||||
### P0-1 出站 peer join 路径 — **CONFIRMED(比原指控更严重)**
|
||||
|
||||
**复核结论**:指控全部成立,且加重三点:
|
||||
1. `/minio/admin/v3/site-replication/join` 在 MinIO 历史上**从未存在过**(`git log -S` 追到功能诞生的 2021 年首个提交,注册的就是 `peer/join`)。RustFS 实现者疑似被 MinIO `admin-handlers-site-replication.go:76` 一条过时的文档注释误导。
|
||||
2. 无任何 404 回退、版本探测或 feature flag;唯一的重试逻辑只针对 secret 不匹配(`site_replication.rs:3036-3082`),404 直接失败。
|
||||
3. 现有单测 `:13683-13696` 正在**固化错误行为**(测试名声称匹配 MinIO 路由,断言的却是不存在的路由)。RustFS↔RustFS 之所以不暴雷,是因为 RustFS 入站自己注册了该错误路径的兼容别名,掩盖了 bug。
|
||||
|
||||
**影响面**:RustFS 发起的 add(含 MinIO 站点)、服务账号轮换通知 MinIO peer 均断;MinIO→RustFS 与 RustFS↔RustFS 不受影响;其余 peer/* 端点走通用前缀改写,路径正确。
|
||||
|
||||
**修路径还不够,还有三处 join 协议分歧须同批修**:①加密判定 `site_replication_peer_payload_encrypted`(:2899-2901)只对旧路径加密,MinIO `SRPeerJoin` 强制解密,须跟随路径改;②MinIO join 成功返回**空 body**,RustFS `:8163` 强制解析 `SRPeerJoinResponse` 会失败,须容忍空 body(peer 身份回退用 preflight 已取得的数据合成);③`deferSyncStateEnable`/`bootstrapToken` 对 MinIO 无效但不阻断(行为差异,建议日志标注)。
|
||||
|
||||
**验证方案**:
|
||||
- 单测:翻转 `:13683`/`:13699` 两个测试断言为 `peer/join`(把固化 bug 的测试变成回归防护)。
|
||||
- 集成测:测试内起 axum stub 精确复刻 `admin-router.go` 路由(仅注册 `PUT .../peer/join`,其余 404),handler 内用 `decrypt_stream_io` 验证 body 是 madmin 兼容密文,返回 200 空 body;断言修复前 404、修复后全链路成功。
|
||||
- e2e:docker compose(rustfs+minio),RustFS 侧 `mc admin replicate add`,MinIO 侧 `mc admin trace -a` 断言 `PUT .../peer/join` 200。注意:**e2e 会先被 P0-2 的 preflight 挡住,两问题必须同批修复才能全链路验证**。
|
||||
|
||||
**解决方案**(均在 `handlers/site_replication.rs`):删除 :2885-2886 的 join 特判使其落入通用前缀改写;:2899-2901 加密判定改为对 `peer/join` 返回 true;:8163 响应解析容忍空 body;更新两个单测。
|
||||
**滚动升级风险**:必须保留入站的 `/v3/site-replication/join` 旧路径路由(旧版 RustFS 出站仍发它);发版前对最近 release tag 复核旧版入站已注册 `peer/join`。
|
||||
|
||||
### P0-2 SRInfo 大小写不匹配 — **CONFIRMED(范围精确化)**
|
||||
|
||||
**复核结论**:成立。madmin-go v3.0.109(minio go.mod 锁定版)`SRInfo` 除 `APIVersion` 外 12 个顶层字段**全部无 json tag**,Go 按 PascalCase 序列化;RustFS `SRInfo` serde 大小写敏感、全字段 `#[serde(default)]` → 解析 MinIO 输出**不报错而是静默全空**。精确化:**不兼容仅限 SRInfo 顶层 12 个字段**,嵌套结构(SRBucketInfo/SRStateInfo/SRIAMPolicy 等)madmin 本就带小写 tag,不受影响。`:5581` 的 `"buckets"|"Buckets"` 手写双读证明作者已知 MinIO 输出 PascalCase,只是未系统化修复。
|
||||
|
||||
**影响面**:RustFS 发起 add 时 preflight 硬失败("site did not report deploymentID")——**触发顺序先于 P0-1 的 join**;`mc admin replicate status` 对 MinIO peer 静默显示全空/全 mismatch(HTTP 200,无报错)。MinIO 读 RustFS 方向因 Go unmarshal 大小写不敏感而无恙。
|
||||
|
||||
**验证方案**:
|
||||
- 单测(crates/madmin):用 Go `json.Marshal(madmin.SRInfo{...})` 真实生成的 PascalCase JSON 作 fixture,断言反序列化后字段非空;再加序列化回归断言输出仍为 camelCase(保证 RustFS↔RustFS 不回归)。
|
||||
- 集成测:stub 在 metainfo 端点返回 PascalCase body,走 `remote_add_preflight_info`,断言不再报错。
|
||||
- e2e:与 P0-1 同批,`mc admin replicate status --json` 断言 MinIO 站点条目完整。
|
||||
|
||||
**解决方案**:`crates/madmin/src/site_replication.rs:642-670` 为 12 个顶层字段逐一加 `#[serde(alias = "...")]`(精确取 Go 字段名,注意是 `ILMExpiryRules` 不是 `IlmExpiryRules`)。alias 只影响反序列化,出站格式零变化,风险几乎为零。**只加顶层、不扩散到嵌套结构**,并留注释说明原因。回归防护关键是把 Go 真实输出固化为测试 fixture。
|
||||
|
||||
### P0-7 `mc replicate add` 默认参数被拒 + 单位错误 — **CONFIRMED**
|
||||
|
||||
**复核结论**:全部反驳方向反向坐实(本地有 mc 源码,非推断):
|
||||
- mc `replicate-add.go:93-95` 默认 `healthcheck-seconds=60`,`:301-303` 无条件调用 `SetRemoteTarget`,失败即终止,无跳过路径;
|
||||
- madmin `bucket-targets.go:79` `HealthCheckDuration time.Duration` 无自定义 Marshal → wire 上是纳秒整数 `60000000000`;
|
||||
- RustFS `handlers/replication.rs:213-225` 对非零值必拒 400;`mc replicate update` 同样失败;无老端点绕过。
|
||||
- **单位错误独立成立且双向**:请求侧按 `Duration::from_secs` 解析(60e9 ns 会被当 60e9 秒 ≈ 1900 年);响应/持久化侧 `bucket_target.rs:195-197` 按秒序列化,mc 按纳秒解(60s 显示为 60ns),同时构成与 MinIO `bucket-targets.json` 的持久化格式偏差。
|
||||
- **为何没被发现**:这是刻意的"能力契约式拒绝"策略,且有单测 `replication.rs:1353-1379` 固化拒绝行为;e2e 全部自行构造 JSON、不含该字段,测的是"RustFS 自己的请求形态"而非"mc 默认请求形态"。缓解:`--healthcheck-seconds 0` 时字段 omitempty 被省略可通过,但默认路径必失败,P0 成立。
|
||||
|
||||
**验证方案**:复现——`mc replicate add rustfs/src --remote-bucket http://ak:sk@target/dst` 预期 400;修复后——madmin 形态 payload(60e9 ns)单测断言内部 Duration==60s;set→list 往返断言响应为纳秒;e2e 增加"mc 默认 payload"用例;持久化防御性读回归(旧秒格式升级后读取不变)。
|
||||
|
||||
**解决方案(分阶段)**:
|
||||
1. **解阻塞**:从不支持清单移除 `healthCheckDuration`(能力契约版本号递增);请求按 `Duration::from_nanos` 解析(`total_downtime` 同步核查);调度上显式忽略并在契约/文档标注"接受但暂不生效";响应侧新增 DTO 按纳秒序列化(**勿直接改 `bucket_target.rs` 的 `duration_seconds`,它同时是持久化格式**);持久化读取加防御(≥10^7 视为纳秒),写入统一新格式。
|
||||
2. **落地语义**:`bucket_target_sys.rs:332-441` heartbeat 循环改为按 target 取值,对齐 MinIO(默认 5s、有下限)。
|
||||
3. **防复发**:建立容器内跑真 mc 命令的兼容 e2e 通道,覆盖 `replicate add/update/status`。
|
||||
|
||||
### P0-8 站点复制架构 — **事实 CONFIRMED,定性部分 REFUTED,降级为 P1**
|
||||
|
||||
**复核结论**:巨型文件(14614 行,非测试约 9533 行,24 个 handler)与三处反向导入全部属实;但"失察"定性被推翻:
|
||||
- `scripts/check_layer_dependencies.sh` **已建模并拦截**这些边,`layer-dependency-baseline.txt` 棘轮基线逐条列出全部 46 条存量反向边,**新增反向边 CI 必炸**;
|
||||
- `ecfs.rs` 被脚本刻意归类为 interface 层(有意的建模决策);
|
||||
- ARCHITECTURE.md 自己声明部分不变量 "currently violated... documenting them makes violations explicit and trackable";git 历史显示这是已知、受控、正在偿还的过渡态。
|
||||
- **结论:不构成正确性风险,从 P0 降为 P1(可维护性债务)**。真实成本:9.5k 行单文件的评审/合并冲突/增量编译负担,hook 直连使 app/storage 单测无法脱离 admin 层。
|
||||
|
||||
**验证方案**:每阶段跑 `make pre-pr`;每消除一条反向边即**删除基线对应行**(而非重生成),使回归必炸;行为回归靠 site replication e2e + 路由快照测试 + `git diff --color-moved` 评审纯移动。
|
||||
|
||||
**解决方案(分阶段)**:
|
||||
1. **解反向依赖(低风险,先做)**:复用 `site_replication_reconcile.rs` 已验证的 OnceLock 注册模式——bucket 三个 hook 在 app 层定义 fn-pointer 契约、admin 构建路由时注册;`node_service.rs` 的 reload 走 infra 层"运行时重载注册表"。注册缺失时显式降级(warn + no-op)。
|
||||
2. **文件拆分(纯移动)**:`site_replication.rs` → 模块目录:`transport`(peer client/DNS/TLS)、`gob`、`state`(注意 config key 路径不可变)、`iam_sync`、`heal`、`handlers`(24 个薄 handler)。
|
||||
3. **领域下沉(风险最高,最后做)**:hook 解耦后把 gob/transport/状态机移入独立 crate,注意全局状态清单(`docs/architecture/global-state-inventory.md:114`)。
|
||||
|
||||
### P0-3 STS item 类型名不一致 — **CONFIRMED(双向硬断)**
|
||||
|
||||
**复核结论**:成立,且两端都是**报错而非静默忽略**:MinIO 收到 `"sts-credential"` 走 default 分支返回 400 `errSRInvalidRequest`;RustFS 收到 `"sts-account"` 返回 NotImplemented。两端 heal/重试机制都会永久重试失败(MinIO 日志持续 "Unable to heal temporary credentials")。MinIO 当前版本 STS 复制发送面很广(AssumeRole/WebIdentity/ClientGrants/LDAPIdentity/Certificate 全系 + sftp/ftp + heal 路径)。除类型串外 `SRSTSCredential` 字段双方完全对齐——**只差这一个字符串**(推测 RustFS 实现时把 madmin 的 JSON 字段名 `stsCredential` 误当成了类型常量)。
|
||||
|
||||
**影响面**:跨厂商 STS 临时凭证双向不复制(客户端在对端站点 `InvalidAccessKeyId`),纯可用性问题,无权限漂移;RustFS↔RustFS 自洽。
|
||||
|
||||
**验证方案**:单测——出站产物断言 `type == "sts-account"`(改 `federated_identity.rs:497` 现有快照测试);入站构造 `"sts-account"` item 断言不落 NotImplemented。e2e——compose(RustFS+MinIO,root 凭证必须一致,否则 token 验签失败会误判修复无效):对 MinIO assume-role 拿临时凭证访问 RustFS,修复前 InvalidAccessKeyId、修复后成功;反向同测。
|
||||
|
||||
**解决方案**:出站(`sts.rs:248`、`federated_identity.rs:241`)改发 `"sts-account"`(提常量集中定义);入站(`site_replication.rs:7857`)match 臂改 `"sts-account" | "sts-credential"`(**永久保留旧别名**兼容旧 RustFS peer)。滚动升级窗口内新→旧 RustFS 会降级(warn+重试,peer 升级后收敛);STS 凭证短生命周期,不建议为此拆两阶段发布。
|
||||
|
||||
### P0-4 policy-mapping userType 数值错位 — **CONFIRMED(比指控更严重)**
|
||||
|
||||
**复核结论**:数值表属实(RustFS: None=0/Svc=1/Sts=2/Reg=3;MinIO: unknown=-1/reg=0/sts=1/svc=2),wire 上确为数值、无翻译层。对抗复核修正与加重:
|
||||
- **RustFS→MinIO 方向今天"侥幸能用"**:RustFS 当前只出站 Reg=3 与组的 0,MinIO 对超范围值静默落 default 分支,恰好落对位置;
|
||||
- **MinIO→RustFS 方向三类断裂**:①**组映射硬失败(新发现)**——MinIO 组映射发 `UserType: -1`,RustFS `user_type: u64` 反序列化直接报错,整个 item 被拒,组→策略映射完全无法同步;②STS 用户映射(MinIO 发 1)被 RustFS 解释为 Svc,落错前缀/缓存,联邦用户在 RustFS 站点**静默丢权限**;③svc=2 被解释为 Sts,同类错位;
|
||||
- **低概率提权路径**:LDAP DN/OIDC 主体的映射被误存入常规用户缓存后,若本地恰有同名静态用户则继承本不属于它的策略——名字碰撞概率低但非零,这是保 P0 的理由。
|
||||
|
||||
**验证方案**:单测——wire 编解码全矩阵(-1/0/1/2/3/非法值);e2e——MinIO 侧 `mc admin policy attach --group` 修复前 RustFS 查不到组实体、修复后可见;`mc idp ldap policy attach` 修复前落 `policydb/service-accounts/` 且访问被拒、修复后落 `sts-users/` 且放行;反向回归守住"侥幸兼容";混版本(旧+新 RustFS)双向 attach 互通。
|
||||
|
||||
**解决方案(核心原则:不改 `UserType::to_u64/from_u64`)**——该编码被集群内部节点 RPC 使用(`node_service.rs:1513`),改动会破坏同集群滚动重启。只在站点复制 wire 边界加 MinIO 语义编解码:
|
||||
1. `SRPolicyMapping.user_type` 由 `u64` 改 `i64`(必须,才能收下 -1);
|
||||
2. 出站 `sr_wire_user_type`:Reg→0/Sts→1/Svc→2,组一律发 0(对 MinIO 与旧 RustFS 同时兼容);入站 `user_type_from_sr_wire`:-1→None/0→Reg/1→Sts/2→Svc/**3→Reg(旧 RustFS 别名,永久保留)**;
|
||||
3. 兼容矩阵已逐格验证:新↔旧 RustFS、MinIO↔新 RustFS 全通;唯一残余窗口(未来出站 Sts/Svc 映射对旧 RustFS 错读)当前不可达,在 doc comment 写明约束;
|
||||
4. 回归防护:编解码矩阵单测 + "wire 常量契约"字面值断言测试(防止将来被"顺手统一"回内部编码)+ e2e 进 P0 套件;顺带把 `SRCredInfo.iam_user_type` 一并改 `i64` 复用同一编解码,消除同族隐患。
|
||||
|
||||
### P0-5 复制 PUT 缺 `?versionId=` query — **CONFIRMED**
|
||||
|
||||
**复核结论**:所有反驳方向均失败,指控成立:
|
||||
- minio-go 官方复制端(v7.0.91)`api-put-object-streaming.go:767-776` 等三处全部是 `urlValues.Set("versionId", ...)`——**query,不是 header**;`x-minio-source-version-id` 这个 header 在 MinIO 全仓不存在,被静默忽略;
|
||||
- multipart 的版本在 **initiate 时**决定(`erasure-multipart.go:458-460`,为空即生成新 UUID),complete 不读 versionId;
|
||||
- aws-sdk-s3 `PutObjectInput` 无 versionId 成员属实,但 DELETE 路径已用 `.set_version_id()` 正确落 query,证明是遗漏而非不可行;
|
||||
- RustFS↔RustFS 不受影响的原因:RustFS 接收端有私有 header fallback(`options.rs:296-301`),恰好掩盖了 bug。
|
||||
|
||||
**影响加重**:除版本漂移与按版本删除永久 no-op 外,目标校验/heal 用源 versionId `head_object` 永远 miss → **反复重传,目标端版本无限膨胀**。另有边缘缺陷:RustFS 内部 null 版本是 nil-UUID,直接发 query 会被 MinIO 当真实版本;minio-go 约定发字面 `"null"`。
|
||||
|
||||
**验证方案**:L1 e2e(本仓可落地,红→绿)——复用 `crates/e2e_test/src/fake_s3_target/`(已解析 versionId query 并写 journal),断言 PutObject/CreateMultipartUpload 请求的 query == 源版本;L2 互操作(docker + 真 MinIO)`mc ls --versions` 断言目标 versionId == 源、删源版本目标同步消失;L3 单测 nil-UUID→`"null"` 映射。
|
||||
|
||||
**解决方案**(`bucket_target_sys.rs`):`put_object`/`create_multipart_upload` 在 `map_request` 闭包内改写 URI 追加 `versionId` query(nil-UUID 映射 `"null"`);保留双 header 兼容旧版 RustFS 接收端;顺带核对 delete 路径的 nil-UUID 映射。**签名安全性已验证**:`map_request` 挂在 `modify_before_signing`,query 会进 canonical request,不会 SignatureDoesNotMatch。非版本化目标桶沿用"空则不发",`"null"` 值 MinIO 免检。
|
||||
|
||||
### P0-6 CopyObject 不触发复制 — **CONFIRMED(附带加重发现)**
|
||||
|
||||
**复核结论**:三个反驳方向全部不成立:
|
||||
- copy 直接调 `store.copy_object`,不经 put 路径;ecstore 层 copy 实现无任何调度;
|
||||
- **scanner 兜底不存在(关键)**:heal 入队条件是状态为 Pending/Failed 或手动 resync;而 copy 路径不 stamp PENDING(对照 put 路径 `object_usecase.rs:5255-5266`),状态为空 → heal 判定 Skip。
|
||||
- **加重发现**:copy 路径没有 MinIO `filterReplicationStatusMetadata` 的等价清理——COPY 指令下源对象的旧复制状态可能原样带到目的对象,**伪造 COMPLETED 假状态**。
|
||||
- 附带 P1(snowball `execute_put_object_extract`)同样确认:无 stamp 无 schedule。
|
||||
|
||||
**影响面**:配复制规则的桶上,CopyObject 写入的对象(跨桶复制、rename 工作流、REPLACE 元数据更新)永不复制、scanner 不捞、仅手动 resync 可补;还可能带 stale 假状态。
|
||||
|
||||
**验证方案**:e2e(参照 `replication_extension_test.rs` 双实例)——copy 后断言目的对象在目标桶超时内出现、源 COMPLETED、目标 REPLICA、无 stale 状态;snowball 参照 `snowball_auto_extract_test.rs` 加成员对象复制断言;usecase 单测用 `storage_api.rs:641` 现有 test-only 调用计数断言 copy/extract 触发决策与调度。
|
||||
|
||||
**解决方案**(`object_usecase.rs`):
|
||||
1. `execute_copy_object` 在 `store.copy_object` 之前算一次 `dsc = must_replicate_object(...)`,`replicate_any` 时向 `dst_opts.user_defined` stamp pending + timestamp(严格镜像 put 路径,单一 dsc 决策贯穿两阶段);
|
||||
2. 同处清理源带来的复制状态 reserved 元数据;
|
||||
3. copy 成功、锁释放后 `schedule_object_replication`;
|
||||
4. `execute_put_object_extract` 对每个解出对象同样处理。
|
||||
风险已排除:replica 判定内置于 `must_replicate_object` 不会回环;self-copy 调度与 MinIO 一致。
|
||||
**落地顺序约束:先修 P0-5 再修 P0-6**——否则 copy 的失败重试经 heal 兜底后,只会在 MinIO 端制造更多漂移版本。
|
||||
|
||||
### 第三方复审修正(2026-08-05,修复分支均已完成 review)
|
||||
|
||||
**P0-5 修正**:问题的准确表述应为"**普通复制对象缺少可靠的源→目标版本身份策略**"——复制 PUT 只返回成功/失败,未捕获目标实际分配的版本 ID(已核实 `bucket_target_sys.rs` put 路径无 `res.version_id()` 捕获,delete 路径 :2030 有);multipart 只保留 upload ID。`fix/p0-5` 的 versionId query 方案对 MinIO/RustFS 目标成立(目标端沿用源版本 ID,身份问题消解),但对**忽略该私有 query 的目标(如原生 AWS S3)**身份问题仍在:目标自行生成版本 ID → 后续按源版本 ID 的删除复制落空。第三方建议定级 P1(修复已完成,残留缺口另行跟进):可选方案包括捕获 PUT 响应的 `x-amz-version-id` 并持久化源→目标映射。→ 记为 **P1-19(新增)**。
|
||||
|
||||
**P0-6 修正**:scanner"兜底不存在"的表述过度。已核实 `crates/replication/src/operation.rs` `resync_target_for_object`:无 reset 记录且复制状态为 Empty 时返回 `replicate=true`,即 ExistingObjectReplication 启用时 scanner **可能最终补齐**空状态对象,无需手动 resync。准确结论:即时/同步复制语义失效(P0 定级依据),且以下场景**长期**漏复制——①源对象 COMPLETED 等复制元数据被 Copy 继承致误判(`fix/p0-6` 已修,清理先于决策);②显式 ExistingObjectReplication=Disabled;③其他无法进入 existing-object 补偿的场景。`fix/p0-6` 分支已含 copy 调度 e2e 与 stale 元数据白盒断言;**scanner 补偿边界的 e2e 仍缺** → 记为 **P1-20(新增)**。
|
||||
|
||||
### 对抗性复核总览
|
||||
|
||||
| 问题 | 复核结论 | 关键修正/加重 |
|
||||
|---|---|---|
|
||||
| P0-1 join 路径 | CONFIRMED,加重 | 路径在 MinIO 从未存在;现有单测固化错误;修复需同批改加密判定与空响应容忍 |
|
||||
| P0-2 SRInfo 大小写 | CONFIRMED,精确化 | 仅顶层 12 个无 tag 字段;preflight 失败先于 P0-1 触发 |
|
||||
| P0-3 STS 类型名 | CONFIRMED | 双向硬断、两端 heal 永久重试;只差一个字符串 |
|
||||
| P0-4 userType 错位 | CONFIRMED,加重 | MinIO 组映射发 -1 → RustFS u64 解析硬失败;存在低概率名字碰撞提权路径;修复不得触碰内部 RPC 编码 |
|
||||
| P0-5 versionId query | CONFIRMED,加重 | heal 反复重传致目标版本膨胀;nil-UUID 需映射 "null" |
|
||||
| P0-6 CopyObject | CONFIRMED,加重 | scanner 兜底不存在;stale COMPLETED 假状态;须在 P0-5 之后落地 |
|
||||
| P0-7 healthCheckDuration | CONFIRMED | 单位错误双向独立成立;有单测固化拒绝行为 |
|
||||
| P0-8 架构 | 事实 CONFIRMED,定性 REFUTED | 反向边被棘轮基线锁死,降级 P1(受控技术债) |
|
||||
|
||||
---
|
||||
|
||||
## 六、修复路线图(2026-08-05 更新)
|
||||
|
||||
**✅ 第一批已完成**:全部 7 项 P0 已修复并创建 PR(见第二节修复状态;P1-10 snowball 随 #5753 一并修复)。待合并,注意顺序约束:#5748+#5749 同批、#5752 先于 #5753。
|
||||
|
||||
**第二批(数据一致性优先,采纳功能视角复核定级)**
|
||||
1. **P1-21** delete-marker 延迟 purge 失败静默丢弃(复核升级,数据一致性,建议单独小 PR + 失败注入测试)
|
||||
2. **P1-19** 源→目标版本身份策略(捕获 PUT 响应 `x-amz-version-id` / 持久化映射,覆盖非 MinIO 系目标)
|
||||
3. **P1-1** ILM expiry 同步语义(只传播 expiry、保留接收端本地 transition + 对应测试)
|
||||
4. **P1-15** site state RMW 分布式锁统一(对齐 repair state 的 `with_config_object_write_lock` 模式)+ 双写者回归测试
|
||||
5. **P1-22** 桶复制 SSE 能力(普通复制/Heal/Resync/Multipart 四模式,先补 encrypted-object e2e 钉现状)
|
||||
|
||||
**第三批(mc 可观测性与互操作补齐)**
|
||||
6. P1-11/12/14 mc 响应结构 serde rename(改动小、消除静默空输出)
|
||||
7. P1-7 ARN 解析侧兼容 `arn:minio:` 前缀
|
||||
8. P1-5 GET/HEAD proxy、P1-6 时间戳头、P1-3 自动跨站 heal
|
||||
9. P1-20 scanner 补偿边界 e2e;P0-7 阶段 2(per-target 心跳 + healthcheck update op)
|
||||
10. P2-26 GET 补 `x-amz-replication-status`(约一行)+ 回归测试;P2 清单其余项
|
||||
|
||||
**第四批(架构与长期)**
|
||||
11. P0-8(降级 P1)架构:先解 3 处反向依赖(复用 reconcile 注册模式),再拆分/下沉站点复制领域模块
|
||||
12. P1-16 类型对账测试、P1-17 迁移完成判据、P1-8 配置校验补齐
|
||||
@@ -0,0 +1,195 @@
|
||||
# P1 逐条复审订正与方案计划
|
||||
|
||||
> 复审基线:main @ `77f2b948c`(7 个 P0 修复 #5748~#5754 已全部合入)
|
||||
> 复审方式:5 组对抗性复审 agent 并行,先怀疑后确认;以 RustFS 自身功能契约为正确性标准,不以"未对齐 MinIO"为根因;RustFS 更优/独特设计标注"保持不变"
|
||||
> 参照:MinIO 源码、mc@cf909e1063a9、madmin-go v3.0.109、minio-go v7.0.91
|
||||
> 日期:2026-08-06
|
||||
|
||||
---
|
||||
|
||||
## 〇、复审总裁定表
|
||||
|
||||
| 项 | 主题 | 复审结论 | 关键订正 | 工作量 |
|
||||
|---|---|---|---|---|
|
||||
| P1-1 | ILM expiry 复制语义 | CONFIRMED(范围扩大) | 发送点共 4 处非 1 处;接收端无门禁;修复重心移到接收端 merge | M |
|
||||
| P1-3 | 自动跨站元数据 heal | CONFIRMED(范围收窄) | 真实缺口="retry queue 有账本无消费者";不移植 MinIO 全量 heal | M |
|
||||
| P1-5 | GET/HEAD 远端 proxy | CONFIRMED | 同步复制模式是已实现的部分缓解(保持不变);proxy 指标语义被出站 HEAD 污染 | L(P0 段 M) |
|
||||
| P1-6 | 三类时间戳头收发 | CONFIRMED(缺口扩大) | 实为三段缺失:tagging 无本地写入方 + 不发头 + 接收端无 LWW 合并点 | M |
|
||||
| P1-7 | ARN 前缀不互认 | CONFIRMED+(加重) | 新发现 FromStr id/region 互换 bug;madmin ParseARN 硬校验实锤 → 生成侧必须改 | M |
|
||||
| P1-8 | 配置校验缺口 + StorageClass | 部分 CONFIRMED | 2MB 子项 REFUTED(MinIO 亦无);StorageClass 属刻意设计成立(MinIO 也不消费 rule 级,target 级 RustFS 已生效)| S |
|
||||
| P1-11 | replication-metrics snake_case | CONFIRMED | BucketStats 复用内部 RPC 线格式实锤 → 必须独立响应 DTO | M |
|
||||
| P1-12 | replication-reset 响应壳 | CONFIRMED(面缩小) | 致命键仅 5 个(壳 `Targets`≠`target` + 4 个字段名);其余靠 Go 大小写不敏感能对上 | S |
|
||||
| P1-13 | mrf/diff 聚合响应 | CONFIRMED(症状加重) | 实际输出**伪数据行**而非空;diff/mrf 数据源均可支撑逐条流 | diff S / mrf M |
|
||||
| P1-14 | set-remote-target 请求体 | 原缺口已缓解;**新 CONFIRMED 阻断** | #5754 后 26 字段已全覆盖;但**零值 `expiration` 恒被拒 → mc replicate add 仍 100% 失败**;latency 单位 round-trip 污染 | S(**建议立即修**) |
|
||||
| P1-15 | site state RMW 竞争 | CONFIRMED(加重) | hook 路径 enqueue/dequeue 同进程内绕过既有 Mutex → 单节点即可触发 | M-L |
|
||||
| P1-16 | 状态机类型双份定义 | CONFIRMED(加重+收窄) | drift 已发生(MrfOpKind 两侧不一致);但 filemeta 侧 worker DTO 是死代码,活跃双份仅 3 个 wire 类型;"抽公共 crate"否决 | S+M |
|
||||
| P1-17 | 桶复制逻辑分裂 | CONFIRMED;微文件合并子项 REFUTED | boundary 微文件是棘轮机制的机械接缝(守护脚本按文件名锚定),合并负收益;缺的是完成判据 | M0=S,整体 L |
|
||||
| P1-18 | 超长函数 | 行数 CONFIRMED;apply_iam_item 降级 | apply_iam_item 长而不复杂(6 臂 dispatch),不拆降 P2;其余 4 个给纯移动拆分草案 | M |
|
||||
| P1-19 | 源→目标版本身份策略 | CONFIRMED(范围收窄) | delete-marker 的"捕获+持久化映射"模式已落地(保持不变);推荐能力探测+显式拒绝而非全量映射 | M |
|
||||
| P1-20 | scanner 补偿边界 e2e | CONFIRMED(缺口收窄) | 决策函数单测与 Failed-heal e2e 已存在;缺 existing-object 矩阵与 Replica 防环 e2e;附完整入队真值表 | M |
|
||||
| P1-21 | delayed purge 静默丢弃 | CONFIRMED | 映射损坏防护已加固(保持不变);`let _ =` 与无 MRF 通道仍在;附带发现 MRF outcome 恒 false 滞留问题 | M |
|
||||
| P1-22 | 桶复制 SSE 能力 | CONFIRMED(前提订正) | SSE-S3 自 #5633 已 fail closed,被 ignore 的 e2e 理由过期(先摘 ignore);SSE-C 缺的是目标侧头摄取 | L(4 阶段) |
|
||||
|
||||
**"保持不变"清单(复审确认的 RustFS 更优/刻意设计,不纳入修复)**:per-PUT 即时元数据传播 hook(优于 MinIO 纯周期 heal)、单向推送+stale 守卫收敛模型、delete 走 merge-with-empty(优于 MinIO 整删)、delete-marker 版本映射持久化+损坏拒猜、同步复制模式(partition_by_sync)、能力契约式显式拒绝+`deny_unknown_fields`(字段清单已与 madmin v3.0.109 同步)、StorageClass 显式拒绝非 STANDARD(target 级已真正生效)、replication-check 真实探针写删、响应中的 RustFS 增强字段(ResetBeforeDate/Error/可观测性键,Go 忽略未知键可共存)。
|
||||
|
||||
---
|
||||
|
||||
## 一、紧急项(建议立即处理)
|
||||
|
||||
### ⚡ P1-14 新阻断:零值 `expiration` 拒绝 → mc replicate add 仍 100% 失败
|
||||
|
||||
- **证据**:Go `omitempty` 不省略零值 `time.Time`(已用 Go 程序按 madmin 逐字 tag 实测),mc/madmin marshal 恒输出 `"credentials":{"expiration":"0001-01-01T00:00:00Z"}` 与 `"resetBeforeDate":"0001-01-01T00:00:00Z"`;RustFS `handlers/replication.rs:286-291` 对 `expiration.is_some()` 一律 400。#5754 的测试全部用手写 payload(`expiration: None`),未被现网形状打中。
|
||||
- **修复(S)**:①`expiration` 改"非 Go 零值时间才拒"(与 `sessionToken` trim-empty 判断对称);②`latency` 请求字段直接忽略(消除 #5754 后纳秒响应 ↔ 毫秒请求的 round-trip 1e6 倍污染);③把"Go 真实 marshal 形状 payload"固化为测试夹具惯例。
|
||||
- **红灯测试**:用实测 Go marshal 全形状 body(含零值 expiration/resetBeforeDate/latency{0,0,0}/edge:false/healthCheckDuration:60000000000)打 set-remote-target,期望 200;非零 expiration 仍 400(能力契约保持)。
|
||||
|
||||
### ⚡ P1-7 附带 bug:ARN FromStr 字段互换
|
||||
|
||||
`arn.rs` Display 输出 `{type}:{region}:{id}:{bucket}`,FromStr 却读 `id=parts[3], region=parts[4]`——id 与 region 互换。当前仅因消费方只用 arn_type 而潜伏。随 P1-7 一并修。
|
||||
|
||||
---
|
||||
|
||||
## 二、逐项方案计划
|
||||
|
||||
### P1-1 ILM expiry 复制语义(M)
|
||||
|
||||
**订正后事实**:发送完整 lifecycle XML 的路径 4 处——PUT hook(`bucket_usecase.rs:2177-2180`)、DELETE hook(`:1512-1514`,触发接收端**整删**)、import(`bucket_meta.rs:948-951`)、build_sr_info/bootstrap(`site_replication.rs:4190,2241-2249`);接收端 `apply_bucket_meta_item`(`:7669-7683`)整体覆盖/删除,且**无 `replicate_ilm_expiry` 门禁**。P0 后已有缓解(发送开关、bootstrap 跳过、stale 判定)只解决"发不发/新旧",不解决"发什么/怎么合"。
|
||||
|
||||
**方案**:接收端 merge 为主(信任边界),发送端 expiry-only 提取为辅:
|
||||
1. 新增纯函数 `extract_expiry_only(cfg)` 与 `merge_expiry_rules(local, incoming)`——语义对齐 MinIO `mergeWithCurrentLCConfig`,两处 RustFS 改进:incoming 一律先剥 transition(防旧端);`None` 走 merge-with-empty 而非整删(**MinIO 整删连本地 transition 一起删是缺陷,不照抄**);
|
||||
2. 接收端 lc-config 分支改 读→merge→条件写/删,保留 stale 判定与 incarnation 守卫;补 `replicate_ilm_expiry` 门禁;
|
||||
3. 4 个发送点接 `extract_expiry_only`;expiry 判定用 RustFS 口径(含 `del_marker_expiration`)。
|
||||
|
||||
**红灯测试**:L1 单测 5 例(提取剥离/合并保留 T/防御剥离/merge-with-empty/import 无 transition);L3 e2e——B 配本地 transition,A PUT expiry → B 两者共存;A DELETE lifecycle → B transition 仍在。
|
||||
**兼容**:旧端发完整 XML → 新接收端剥后 merge 正确;新端 expiry-only → 旧接收端仍整覆盖(不劣于现状)。规则按 ID 对齐,`rule-{idx}` 撞名同 MinIO 语义,文档注明。
|
||||
|
||||
### P1-3 自动跨站 heal → 改为"retry queue 自动 drain"(M)
|
||||
|
||||
**订正后事实**:retry queue 是现成增量账本(失败即入队 `:3243-3262`,持久化于 state,`retry_count` 字段存在)但**全库无消费者**;手动 repair 是本地快照单向推送,收敛方向依赖运维判断。即时 hook + 显式 repair 模型保持不变。
|
||||
|
||||
**方案**:
|
||||
- 阶段 1(核心):周期任务挂进现有 reconcile ticker,per-event 重发(body 从本地当前元数据重建,复用 `SiteReplicationRepairTask::send`,天然发"当前值"+对端 stale 守卫幂等);指数退避(`retry_count`+上限转 failed);drain 全程包分布式锁去抖(先用 `with_config_object_write_lock` 专用对象,P1-15 落地后并入统一 state store);结构化 tracing 汇总一条。
|
||||
- 阶段 2(可选,默认关闭):每 N tick 比对 repair plan token,不同才自动 dry-run→execute。**不移植** MinIO 跨站取最新 pull 语义(各站各自 drain 即双向收敛)。
|
||||
|
||||
**红灯测试**:L2——state 带 retry event,调 `drain_site_replication_retry_queue()`(现不存在),fake peer 成功后断言队列清空;退避断言。L3——停 B→A PUT policy 失败入队→起 B→drain 后 B 收到且 SRRetryStats 归零。
|
||||
|
||||
### P1-5 GET/HEAD 远端 proxy(L;P0 段 M)
|
||||
|
||||
**订正后事实**:`SUFFIX_SOURCE_PROXY_REQUEST` 零消费者;`ProxyMetric` 字段与 admin 汇总通路已就位,但 resyncer 把**出站** HEAD 计入 `head_total` 污染语义;`disable_proxy` 管道存在无人消费;同步复制模式(`partition_by_sync`,`replication_pool.rs:2667-2689`)是部分缓解但不等价(手动 per-target、失败仍 404、不覆盖兜底窗口)。防环头当前仅潜在问题,但 proxy 实现与防环识别**必须同 PR**(否则 RustFS↔RustFS 成环)。
|
||||
|
||||
**方案**(P0 段):新增 `replication_proxy_boundary.rs`——`proxy_targets`(version_suspended/入站 proxy 头/disable_proxy 三重 gate)+ `proxy_get/head_to_replication_target`(走现有 TargetClient,range/条件头透传);触发点在 usecase 层 NotFound/VersionNotFound 分支;接收侧 options.rs 解析防环头,出站双前缀发送;`tokio::timeout`(~3s env 可调)、仅 2xx 采纳其余回落本地 404、复用离线标记短路;指标接 `record_replication_proxy` 并纠正 resyncer 计数语义。P1 段:tagging 三操作 proxy(依赖 P1-6)。
|
||||
**红灯测试**:e2e 双站断复制链路后从对端 GET/HEAD 应 200(现 404);防环负例(带头请求不转发、计数不增);降级负例(target 全离线时限时 404);disable_proxy 负例。
|
||||
|
||||
### P1-6 时间戳头收发(M;三段修复)
|
||||
|
||||
**订正后事实**:①`SUFFIX_TAGGING_TIMESTAMP` 全仓无写入方(retention/legalhold 已有双前缀写入);②`PutObjectOptions::header()` 只序列化 4 个内部头,三类时间戳被丢弃,multipart 同;③接收端不解析,且 replica PUT 是 verbatim 覆盖——解析后必须在写盘前与本地版本做 per-类别 LWW 合并才有效;④`AdvancedPutOptions` 默认 `now_utc()` 无法当"未设置"哨兵,需 Option 化。
|
||||
|
||||
**方案**:阶段 0——`put/delete_object_tagging` 落 `SUFFIX_TAGGING_TIMESTAMP`(双前缀);阶段 1——新增三个 suffix 常量(对齐 MinIO headers.go:239-243),三字段 Option 化,`header()` 与 multipart 条件序列化;阶段 2——接收端解析(仅授权复制请求)+ PUT 路径 LWW 合并并持久化赢家时间戳(合并仅限三类元数据,不触碰数据与其余元数据,与 verbatim-replica 不变式共存)。
|
||||
**红灯测试**:单测 header 双前缀序列化断言/未设置缺席断言;接收端解析单测;e2e active-active tagging 并发收敛(晚者胜,现 main 旧值覆盖新值为红)。
|
||||
|
||||
### P1-7 ARN 前缀(M)
|
||||
|
||||
**订正后事实**:madmin `ParseARN` 硬校验 `arn:minio:` 前缀 + ID/bucket 非空(v3.0.109 remote-target-commands.go:50-63);mc 爆炸点仅 `replicate update`(fatalIf)与 `replicate ls`(软降级);`replicate add` 把 ARN 当不透明串不受影响——解释了"add 通 update 挂"。RustFS ARN 结构(`type::id:bucket`)与 madmin 兼容,仅 vendor token 障碍;另有 FromStr id/region 互换 bug(见紧急项)。
|
||||
|
||||
**方案(推荐路线 A)**:生成侧默认改 `arn:minio:`(留常量可品牌化);解析侧接受双前缀(存量 `arn:rustfs:` 靠双前缀解析 + 现有字符串等值匹配继续工作);修字段序;改 `generate_arn`、`site_replication.rs:6329` 与相关测试断言。混合版本集群前缀不一致靠双前缀解析吸收;不做存量数据前缀归一化改写。
|
||||
**红灯测试**:单测 `from_str("arn:minio:replication:us-east-1:depl:bucket")` 成功且 id/region 正确(现双重红灯);round-trip 属性测试;e2e set-remote-target 返回 ARN 可被 madmin 语义解析、预置 `arn:minio:` 目标可 remove。
|
||||
|
||||
### P1-8 配置校验(S)
|
||||
|
||||
**订正后事实**:2MB 上限 REFUTED(MinIO 亦无显式检查,剔除);StorageClass 已缓解且刻意设计成立——MinIO 自己也不消费 rule 级 `Destination.StorageClass`(复制 PUT 用 target 级 `tgt.StorageClass`),RustFS target 级 storage_class 已真正生效(`bucket_target_sys.rs:1633-1634`),容忍显式 STANDARD 已实现。仍缺:规则数≤1000、≥1 条、Priority 唯一非负、ID≤255、Filter 互斥、Tag×DeleteMarkerReplication 互斥、sameTarget 拒绝。
|
||||
|
||||
**方案**:`config.rs` 新增 `validate_replication_config_structure` 纯函数,`bucket_usecase.rs:2418` 接入;StorageClass 保持现状+契约文档化("rule 级请改用 remote target 的 storageclass 字段")。
|
||||
**红灯测试**:单测逐格(1001 规则/重复 Priority/256 字符 ID/Filter 并存/Tag+DMR)期望特定错误;e2e aws-sdk 形状 XML 断言 InvalidRequest。
|
||||
|
||||
### P1-11 replication-metrics DTO(M)
|
||||
|
||||
**订正后事实**:`BucketStats` 走内部 peer RPC 线格式(`rmp_serde::to_vec_named` 字段名入线,node_service.rs:1401 / peer_rest_client.rs:88-104)——**改原结构 serde 名会破坏混合版本集群 RPC,禁止**;必须走 #5754 的响应 DTO 模式(同文件先例 `remote_target_admin_json`)。
|
||||
|
||||
**方案**:新增仅 Serialize 的 `MetricsV2Dto{uptime,currStats,queueStats,downtimeInfo}`/`MetricsDto`/`TargetMetricsDto`,显式映射(`q_stat`→`queued`、`bandwidth_limit_bytes_per_sec`→`limitInBits`、failed→TimedErrStats total-only);`queueStats.nodes` 先填本机一条;RustFS 可观测性扩展键保留(Go 忽略未知键,双栖零成本)。
|
||||
**红灯测试**:e2e 用镜像 minio-go MetricsV2 tag 的结构反序列化断言 `currStats.completedReplicationSize > 0`(现全零);DTO 键名 snapshot 单测。
|
||||
|
||||
### P1-12 replication-reset 响应壳(S)
|
||||
|
||||
**订正后事实**:致命键仅 5 个——壳 `Targets`≠`target`、`Status`≠`resyncStatus`、`ReplicatedSize`≠`completedReplicationSize`、`ReplicatedCount`≠`replicationCount`、`FailedSize/FailedCount`≠`failedReplicationSize/failedReplicationCount`;其余(Arn/ResetID/StartTime/...)靠 Go 大小写不敏感能对上;`ResetBeforeDate`/`Error` 是增强字段可保留。响应结构是 router.rs 独立 DTO 无内部复用,改名零风险。
|
||||
|
||||
**方案**:纯 serde rename(建议全字段精确对齐 madmin 小写形态),保留增强键+文档标注。
|
||||
**红灯测试**:e2e 断言响应含 `target` 数组且 `target[0].resetid` 非空、status 侧 `resyncStatus`/`completedReplicationSize` 键存在。
|
||||
|
||||
### P1-13 mrf/diff 流式响应(diff S / mrf M)
|
||||
|
||||
**订正后事实**:症状比"输出空"更糟——聚合对象会被 madmin `json.Decoder` 成功解码一次,`mc replicate backlog` 输出一条 object 为空的**伪行**(静默伪数据);路线 A(保持聚合+文档化)无法消除伪行且与 madmin 同 path 无内容协商,**不可行**。数据源评估:diff 已逐条扫描只需去壳;mrf 的 durable backlog(`MrfReplicateEntry` 字段恰好覆盖 `ReplicationMRF` 所需)已可枚举。
|
||||
|
||||
**方案(路线 B)**:diff 去壳输出 NDJSON `DiffInfo` 形状(仅 `IsDeleteMarker`/`ReplicationStatus` 需 rename;truncation 信息入日志不入流);mrf 遍历 durable entries 逐条输出 `ReplicationMRF` 形状(nodeName 填本机);聚合响应保留在 `?aggregate=true`(RustFS 扩展,deliberate 注释随迁)。条目量有 `REPLICATION_DIFF_MAX_SCAN` 封顶,内存拼 NDJSON 即可不必真流式。
|
||||
**红灯测试**:e2e 制造失败复制后逐行反序列化断言至少一条 `object` 非空(现为伪空行);diff 断言无 `Entries` 壳。
|
||||
|
||||
### P1-14 set-remote-target(S,含紧急项)
|
||||
|
||||
见"一、紧急项"。另:`deny_unknown_fields` **保留**(推荐)——字段清单已与 madmin v3.0.109 全同步,严格模式+显式清单兼得契约哲学与防静默;代价写进维护清单:"madmin 版本升级时同步字段清单"(加对照 madmin tag 列表的常量测试防漂移)。
|
||||
|
||||
### P1-15 site state 统一 store(M-L,两 PR)
|
||||
|
||||
**订正后事实**:主 state 有进程内 Mutex(`:347`)但两处不完备——①无分布式锁(多节点 RMW 丢更新);②**retry event enqueue/dequeue 不持锁**(挂在所有 hook 广播路径上,同进程即可丢更新);reload 路径完全无锁(稳态不写盘收窄窗口,迁移期可覆盖并发写)。repair state 的 `with_config_object_write_lock` + no-lock IO 是正确样板(`:1097-1114`);两套归一化的语义差异(JSON-level 容忍畸形 peer)是**有意的**,统一时必须保留。锁序注释 `:346` 可挂靠。
|
||||
|
||||
**方案**:PR1——新建 `admin/site_replication_state.rs`:两阶段归一化合一(JSON 宽容清洗→类型化)、`read_state()/update_state(F)`(分布式锁包完整 RMW,锁内禁网络调用与嵌套配置锁)、常量收敛;service reload 接入;迁移 service 侧 5 个归一化测试保语义。PR2——迁移全部 ~30 个 RMW 调用点(含 enqueue/dequeue),**移除**进程内 Mutex(避免双锁新顺序约束);dequeue 热路径保留"先无锁读、命中才进 update_state"两段式;更新锁序注释。每个调用点做重入审查(现有 drop-reacquire 模式保持)。
|
||||
**红灯测试**:L2 单进程并发——持锁 RMW(mark_pending_rotation_peer_acked)×绕锁写者(enqueue_retry_event)注入交错,断言最终 state 两者共存(现必丢其一,确定性红灯);L1 归一化等价性测试迁移;L3 双节点并发(nice-to-have)。
|
||||
**风险**:盘上格式不变;锁超时从"静默丢更新"变"显式报错",hook 路径保持 warn 不阻断 S3 主路径。
|
||||
|
||||
### P1-16 类型对账护栏(S)+ 死代码清理(M)
|
||||
|
||||
**订正后事实**:drift 已发生(filemeta 侧 `MrfOpKind` 缺 Metadata/Heal/ExistingObject 三 variant、`MrfReplicateEntry` 缺 force_delete/target_arns)——但 filemeta 侧 8 个 worker DTO 全是**死代码**(零消费者);活跃双份仅 `ReplicationStatusType/VersionPurgeStatusType/ReplicationState` 三个 wire 类型(filemeta 绑 xl.meta 磁盘格式,replication 绑 MRF/resync 持久化格式);boundary 枚举转换 `as_str()` 兜底 `_ => Empty` 会静默降级。"抽公共 leaf crate"否决(两 wire 格式演进节奏不同,迁移规则 #12 本意是所有权独立)。
|
||||
|
||||
**方案**:Step 1(S,即刻)——boundary 加对账测试:两侧枚举穷尽 match(新增 variant 即编译失败)+ as_str 双向 round-trip + ReplicationState 全字段往返;Step 2(M)——清理 filemeta 侧 ~600 行死代码 DTO,注意 crates.io semver(先 `#[deprecated]` 一版再删);Step 3(S)——replication 侧注释指向对账测试。
|
||||
|
||||
### P1-17 迁移完成判据(M0=S;整体 L)
|
||||
|
||||
**订正后事实**:"合并 boundary 微文件"REFUTED——守护脚本按具体文件名锚定每个 boundary,合并要同步改脚本+mod+导入点而功能收益为零;微文件是棘轮机制的机械接缝。唯一可退役:`datatypes.rs`(消费者迁完即删)。README 建议的第一步(event sink/runtime boundary)实际已部分落地,文档滞后。
|
||||
|
||||
**方案**:M0(S)文档 PR——完成判据 = Required Contracts 表 "Current dependency to remove" 列清空;终态 = pool/resyncer/state 移入 crates/replication,boundary 随 crate 移动自然消解;更新 split-plan "Proposal only" 状态。M2(M)resyncer 纯决策逻辑下沉;M3(L)trait 稳定后移 worker 运行时(全计划唯一高危段,最后做);M4(S)统一退役 boundary 与守护条目。**不做**批量合并微文件。
|
||||
|
||||
### P1-18 超长函数拆分(M;4 个 PR)
|
||||
|
||||
**订正后事实**:行数确认(resync_bucket 537 / start_mrf_processor 306 / replicate_all 409 / delete 路径 replicate_object 299 / apply_iam_item 255);`apply_iam_item` **降级 P2 不拆**(6 臂 dispatch,每臂线性短小,拆分违反 "Prefer direct, local code");`replicate_object` 有两个同名体,原清单指 delete 路径 trait impl。
|
||||
|
||||
**方案**(每函数独立 PR,纯移动,`git diff --color-moved=dimmed-zebra` 验证):
|
||||
1. `resync_bucket`(最优先,三处历史并发 bug 注释所在):acquire_resync_leadership / load_resync_replication_config / spawn workers+collector 三段抽出,并发 bug 注释随代码移动,每个 return 前的 mark_status 逐一保持;
|
||||
2. `start_mrf_processor`:抽 `reconstruct_mrf_delete/object` 纯函数(主循环 -150 行,重建逻辑可单测);
|
||||
3. `replicate_all` + delete 路径 `replicate_object`:各拆 3-4 个阶段 helper;**明确不合并两函数**(delete-marker 404/405 校验语义是刻意差异)。
|
||||
**排序依赖**:先 P1-18 拆分、后 P1-17 M2/M3 迁移(小函数降低搬运风险)。
|
||||
|
||||
### P1-19 版本身份策略(M,推荐方案 B)
|
||||
|
||||
**订正后事实**:#5752 已合入(PUT/multipart initiate 带 query,RustFS 目标侧也支持);PUT 响应 `x-amz-version-id` 仍被丢弃(`:1891 Ok(_)`);**delete-marker 子案已系统性缓解**——`remove_object` 捕获目标版本号→`target_delete_marker_version_ids` 持久化进 xl.meta(含上限与损坏标记)→延迟 purge 优先用映射、损坏拒猜(**保持不变**);RustFS 无"仅支持 MinIO 目标"契约声明;replication-check 探针已捕获响应版本号但不比对。MinIO 同样丢弃响应版本号(平价),RustFS 已有两点增强。
|
||||
|
||||
**方案对比**:A 全量映射持久化(完整但 xl.meta 膨胀、全链路改造,L);**B(推荐)**:契约=仅支持"沿用源版本 ID"的目标,在 replication-check 增加 VersionFidelity phase(探针 PUT 带 versionId query,比对响应版本号)+ `validate_target` 复用同一探测,不镜像则新错误 `BucketRemoteTargetVersionMismatch` 显式拒绝/告警(M);C 混合(无需求支撑)。探针是主动写,进 validate_target 会扩 set-target 副作用面——可先只做 check phase + 运行期首次 PUT 抽查告警。
|
||||
**红灯测试**:FakeS3Target 加 `assign_own_version_ids` 开关模拟原生 S3,断言版本删除复制落空(现红)与探测后显式拒绝(修后绿)。
|
||||
|
||||
### P1-20 scanner 补偿边界 e2e(M,纯测试)
|
||||
|
||||
**订正后事实**:决策函数单测(queue.rs 7 例等)与 scanner 驱动的 Failed-heal e2e(target 断电恢复/源重启重放,FAST_SCANNER_ENV)已存在;真实缺口=无任何"先写对象→后配复制"的 existing-object 用例。完整入队真值表已梳理(见复审记录):Enabled×Empty 补齐、Pending/Failed 恒补(不受 existing 开关影响)、Disabled×Empty 永不补、Replica 恒不补(防环)、null-version 永不入队、reset_id 重置补齐。
|
||||
|
||||
**方案**:e2e 矩阵 1-2 个用例(先 PUT 四种来源对象含 Copy/Snowball 产物→后配 Enabled/Disabled 规则→正例 wait_for_replicated_object / 负例 assert_failed_replication_stays_absent_for ≥3 周期,**"永不补齐"是契约必须显式断言**)+ Replica 防环变体 + queue.rs 补 2 格单测;null-version 跳过行为先写"记录现状"断言并注明出处。不改产品代码。
|
||||
|
||||
### P1-21 delayed purge 失败处理(M)
|
||||
|
||||
**订正后事实**:静默点两处——target client 缺失 `continue` 无日志(`:1673-1675`)、`let _ = remove_object`(`:1693-1700`);5 次循环是等源 marker 消失非重试;purge 调用后无条件 break;MRF 入队接口(`queue_replica_delete_task`,队满自动落盘)同 crate 可用无分层障碍;映射优先/损坏拒猜是已加固项保持不变。**附带发现**(建议单独跟进):`requires_delayed_purge` 恒真使 delete-marker 类 MRF 条目 outcome 恒 false → 重放永远 Missed 保留,可能永久滞留。
|
||||
|
||||
**方案**:①purge 函数返回 per-target 成败,失败 warn(带 event 常量)+ metrics,client 缺失同样 warn(S);②循环内失败重试、轮次耗尽入 MRF、入队失败 warn+metric 兜底(S/M);③两层失败注入测试(mock 503 断言重试/状态/MRF;FakeS3Target inject 断言故障清除后最终收敛)(M)。风险:MRF 重放重发 DELETE marker 创建——mtime 幂等,风险低。
|
||||
|
||||
### P1-22 SSE 能力(L,4 阶段)
|
||||
|
||||
**订正后事实**:fail-closed 由 #5633 引入(`replication_target_boundary.rs:101-174`),普通/Heal/Resync/Multipart 全走同一函数;SSE-C 发送半边已建(内部头→`X-Rustfs-Replication-*` 映射+CRC),**目标侧摄取代码完全缺失**(链路必断,e2e 已钉 FAILED);SSE-S3 契约 e2e 的 `#[ignore]` 理由(backlog#1291 silently drops)已被 #5633 过期;直传托管 SSE 不可行(封存密钥绑本站 KMS),MinIO 是源解密+目标重加密;ecstore 已有 `ObjectEncryptionResolver` trait seam,解密不破分层。
|
||||
|
||||
**方案**:阶段 0(S)摘 ignore + 补 encrypted resync/heal e2e 钉全矩阵 fail-closed 现状;阶段 1(M)SSE-C 目标侧头摄取+加密尺寸/CRC(MinIO :1670-1740 参照);阶段 2(M/L)SSE-S3 经 resolver 解密+目标 AES256 重加密(resolver 未注册必须继续 fail closed;multipart 按明文尺寸分片);阶段 3(L)SSE-KMS + key id 随行开关(目标站无同名 key 显式失败,禁止回退 SSE-S3)。过渡期全矩阵维持 fail closed,禁止明文降级。
|
||||
|
||||
---
|
||||
|
||||
## 三、执行批次建议
|
||||
|
||||
| 批次 | 内容 | 性质 |
|
||||
|---|---|---|
|
||||
| **B0 立即** | P1-14 零值 expiration + latency 忽略(S);P1-7 FromStr 字段互换(并入 P1-7 或先行) | mc 阻断修复 |
|
||||
| **B1 小改动高收益** | P1-12 响应壳 rename(S)、P1-13 diff 去壳(S)、P1-8 结构校验(S)、P1-16 Step1 对账测试(S)、P1-17 M0 文档判据(S)、P1-22 阶段 0 摘 ignore(S) | serde/校验/测试护栏 |
|
||||
| **B2 数据一致性** | P1-21 purge 失败处理(M)→ P1-20 scanner 矩阵 e2e(M,纯测试)→ P1-19 方案 B 能力探测(M)→ P1-15 state store PR1+PR2(M-L) | 一致性核心 |
|
||||
| **B3 互操作补齐** | P1-7 ARN 路线 A(M)、P1-11 MetricsV2 DTO(M)、P1-13 mrf 流(M)、P1-6 时间戳三段(M)、P1-1 ILM merge(M)、P1-3 retry drain(M) | mc/跨站语义 |
|
||||
| **B4 大功能与架构** | P1-5 proxy P0 段(M→L)、P1-22 阶段 1-3(L)、P1-18 四函数拆分(M)→ P1-17 M2-M4(L)、P1-16 Step2 死代码(M) | 长期 |
|
||||
|
||||
**批内依赖**:P1-6 先于 P1-5 的 tagging proxy;P1-18 先于 P1-17 M2/M3;P1-15 PR1 的锁对象可先供 P1-3 drain 使用。
|
||||
@@ -55,10 +55,10 @@ hotpath.workspace = true
|
||||
rustfs-targets = { workspace = true }
|
||||
rustfs-config = { workspace = true, features = ["audit", "server-config-model"] }
|
||||
rustfs-s3-types = { workspace = true }
|
||||
chrono = { workspace = true, features = ["serde"] }
|
||||
const-str = { workspace = true, features = ["std", "proc"] }
|
||||
futures = { workspace = true }
|
||||
hashbrown = { workspace = true, features = ["serde", "rayon"] }
|
||||
jiff = { workspace = true, features = ["serde"] }
|
||||
metrics = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true, features = ["raw_value"] }
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use hashbrown::HashMap;
|
||||
use jiff::Timestamp;
|
||||
use rustfs_s3_types::EventName;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
@@ -151,8 +151,8 @@ pub struct AuditEntry {
|
||||
pub deployment_id: Option<String>,
|
||||
#[serde(rename = "siteName", skip_serializing_if = "Option::is_none")]
|
||||
pub site_name: Option<String>,
|
||||
#[serde(with = "chrono::serde::ts_milliseconds")]
|
||||
pub time: DateTime<Utc>,
|
||||
#[serde(with = "jiff::fmt::serde::timestamp::millisecond::required")]
|
||||
pub time: Timestamp,
|
||||
pub event: EventName,
|
||||
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
|
||||
pub entry_type: Option<String>,
|
||||
@@ -198,7 +198,7 @@ impl AuditEntryBuilder {
|
||||
pub fn new(version: impl Into<String>, event: EventName, trigger: impl Into<String>, api: ApiDetails) -> Self {
|
||||
Self(AuditEntry {
|
||||
version: version.into(),
|
||||
time: Utc::now(),
|
||||
time: Timestamp::now(),
|
||||
event,
|
||||
trigger: trigger.into(),
|
||||
api,
|
||||
@@ -232,7 +232,7 @@ impl AuditEntryBuilder {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn time(mut self, time: DateTime<Utc>) -> Self {
|
||||
pub fn time(mut self, time: Timestamp) -> Self {
|
||||
self.0.time = time;
|
||||
self
|
||||
}
|
||||
@@ -342,4 +342,23 @@ mod tests {
|
||||
assert_eq!(value["requestID"], Value::String("req-audit-123".to_string()));
|
||||
assert!(value.get("request_id").is_none(), "historical audit contract must not expose request_id");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audit_entry_time_serializes_as_epoch_milliseconds() {
|
||||
let entry = AuditEntryBuilder::new(
|
||||
"1",
|
||||
EventName::ObjectCreatedPut,
|
||||
"s3",
|
||||
ApiDetailsBuilder::new()
|
||||
.name("PutObject")
|
||||
.status("OK")
|
||||
.status_code(200)
|
||||
.build(),
|
||||
)
|
||||
.time(Timestamp::from_millisecond(1_711_423_698_870).expect("timestamp should be valid"))
|
||||
.build();
|
||||
|
||||
let value = serde_json::to_value(entry).expect("audit entry should serialize");
|
||||
assert_eq!(value["time"], Value::Number(1_711_423_698_870_i64.into()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@ async fn test_audit_log_dispatch_performance() {
|
||||
return; // Alternatively: assert!(false, "AuditSystem failed to start");
|
||||
}
|
||||
|
||||
use chrono::Utc;
|
||||
use jiff::Timestamp;
|
||||
use rustfs_targets::EventName;
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
@@ -136,7 +136,7 @@ async fn test_audit_log_dispatch_performance() {
|
||||
version: "1".to_string(),
|
||||
deployment_id: Some(format!("test-deployment-{id}")),
|
||||
site_name: Some("test-site".to_string()),
|
||||
time: Utc::now(),
|
||||
time: Timestamp::now(),
|
||||
event: EventName::ObjectCreatedPut,
|
||||
entry_type: Some("object".to_string()),
|
||||
trigger: "api".to_string(),
|
||||
@@ -298,7 +298,7 @@ fn test_performance_requirements() {
|
||||
for i in 0..3000 {
|
||||
// Simulate event name parsing and processing
|
||||
let _event_id = format!("s3:ObjectCreated:Put_{i}");
|
||||
let _timestamp = chrono::Utc::now().to_rfc3339();
|
||||
let _timestamp = jiff::Timestamp::now().to_string();
|
||||
|
||||
// Simulate basic audit entry creation overhead
|
||||
let _entry_size = 512; // bytes
|
||||
|
||||
@@ -264,7 +264,7 @@ fn create_sample_audit_entry() -> AuditEntry {
|
||||
}
|
||||
|
||||
fn create_sample_audit_entry_with_id(id: u32) -> AuditEntry {
|
||||
use chrono::Utc;
|
||||
use jiff::Timestamp;
|
||||
use rustfs_targets::EventName;
|
||||
use serde_json::json;
|
||||
|
||||
@@ -301,7 +301,7 @@ fn create_sample_audit_entry_with_id(id: u32) -> AuditEntry {
|
||||
version: "1".to_string(),
|
||||
deployment_id: Some(format!("test-deployment-{id}")),
|
||||
site_name: Some("test-site".to_string()),
|
||||
time: Utc::now(),
|
||||
time: Timestamp::now(),
|
||||
event: EventName::ObjectCreatedPut,
|
||||
entry_type: Some("object".to_string()),
|
||||
trigger: "api".to_string(),
|
||||
|
||||
@@ -356,6 +356,8 @@ pub struct HealChannelRequest {
|
||||
pub recursive: Option<bool>,
|
||||
/// Whether to dry run
|
||||
pub dry_run: Option<bool>,
|
||||
/// Whether to skip namespace locking
|
||||
pub no_lock: Option<bool>,
|
||||
/// Timeout in seconds (optional)
|
||||
pub timeout_seconds: Option<u64>,
|
||||
/// Origin of the request for operational status and queue accounting
|
||||
@@ -560,6 +562,7 @@ pub fn create_heal_request(
|
||||
update_parity: None,
|
||||
recursive: None,
|
||||
dry_run: None,
|
||||
no_lock: None,
|
||||
timeout_seconds: None,
|
||||
source: HealRequestSource::Internal,
|
||||
disk: None,
|
||||
@@ -718,6 +721,7 @@ pub async fn send_heal_disk(set_disk_id: String, priority: Option<HealChannelPri
|
||||
update_parity: None,
|
||||
recursive: None,
|
||||
dry_run: None,
|
||||
no_lock: None,
|
||||
timeout_seconds: None,
|
||||
source: HealRequestSource::AutoHeal,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Regression tests for bucket statistics and data usage accuracy.
|
||||
//!
|
||||
//! Covers the recurring pattern where bucket statistics (object count, size)
|
||||
//! show stale/incorrect values, remain at 0, or oscillate between complete,
|
||||
//! partial, and zero. This has regressed 10+ times.
|
||||
//!
|
||||
//! ## Regression Issues
|
||||
//!
|
||||
//! - rustfs#5615: bucket statistics remain unchanged after data expiration
|
||||
//! - rustfs#5008: Admin usage reports only one pool
|
||||
//! - rustfs#5116: Admin usage reports stale 0/0 for non-empty bucket after upgrade
|
||||
//! - rustfs#5055: console object count and size still loading
|
||||
//! - rustfs#5010: Storage usage info changed abnormally
|
||||
//! - rustfs#3662: Incorrect bucket, object count and size
|
||||
//! - rustfs#3898: DataUsageInfo undercounts versioned bucket versions
|
||||
//! - rustfs#1012: Object count in the console doesn't change
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::common::{RustFSTestEnvironment, awscurl_get, init_logging};
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use aws_sdk_s3::types::{BucketVersioningStatus, VersioningConfiguration};
|
||||
use rustfs_data_usage::DataUsageInfo;
|
||||
use serial_test::serial;
|
||||
use std::error::Error;
|
||||
use tokio::time::{Duration, sleep};
|
||||
use tracing::info;
|
||||
|
||||
type TestResult = Result<(), Box<dyn Error + Send + Sync>>;
|
||||
|
||||
async fn get_data_usage(env: &RustFSTestEnvironment) -> Result<DataUsageInfo, Box<dyn Error + Send + Sync>> {
|
||||
let url = format!("{}/rustfs/admin/v3/datausageinfo", env.url);
|
||||
let resp = awscurl_get(&url, &env.access_key, &env.secret_key).await?;
|
||||
Ok(serde_json::from_str(&resp)?)
|
||||
}
|
||||
|
||||
/// RT-09: Verify bucket object count updates after PUT.
|
||||
///
|
||||
/// Regression pattern: bucket stats remain at 0 after objects are uploaded
|
||||
/// (rustfs#5055, rustfs#1012).
|
||||
///
|
||||
/// Steps:
|
||||
/// 1. Create a bucket
|
||||
/// 2. Upload 10 objects
|
||||
/// 3. Query admin data usage API
|
||||
/// 4. Verify object count > 0
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_bucket_object_count_updates_after_put() -> TestResult {
|
||||
init_logging();
|
||||
info!("RT-09: bucket object count updates after PUT");
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await.expect("create test environment");
|
||||
env.start_rustfs_server_with_env(vec![], &[("RUSTFS_CONSOLE_ENABLE", "false")])
|
||||
.await
|
||||
.expect("start RustFS");
|
||||
|
||||
let client = env.create_s3_client();
|
||||
let bucket = "rt09-stats-put";
|
||||
|
||||
client.create_bucket().bucket(bucket).send().await.expect("create bucket");
|
||||
|
||||
// Upload 10 objects
|
||||
for i in 0..10 {
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(format!("stat-obj-{i:04}.txt"))
|
||||
.body(ByteStream::from_static(b"statistical data"))
|
||||
.send()
|
||||
.await
|
||||
.expect("put object");
|
||||
}
|
||||
|
||||
// Wait for scanner to process (up to 90 seconds)
|
||||
let mut found_nonzero = false;
|
||||
for attempt in 0..18 {
|
||||
sleep(Duration::from_secs(5)).await;
|
||||
|
||||
if let Ok(usage) = get_data_usage(&env).await
|
||||
&& let Some(bucket_usage) = usage.buckets_usage.get(bucket)
|
||||
{
|
||||
info!(" attempt {attempt}: objectsCount = {}", bucket_usage.objects_count);
|
||||
if bucket_usage.objects_count >= 10 {
|
||||
found_nonzero = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert!(
|
||||
found_nonzero,
|
||||
"RT-09 FAIL: bucket object count did not update after PUT 10 objects (regression: stats stuck at 0)"
|
||||
);
|
||||
|
||||
info!("RT-09 PASS: bucket object count updates after PUT");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// RT-09b: Verify bucket stats update after DELETE.
|
||||
///
|
||||
/// Regression pattern: stats remain unchanged after objects are deleted
|
||||
/// (rustfs#5615).
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_bucket_object_count_updates_after_delete() -> TestResult {
|
||||
init_logging();
|
||||
info!("RT-09b: bucket object count updates after DELETE");
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await.expect("create test environment");
|
||||
env.start_rustfs_server_with_env(vec![], &[("RUSTFS_CONSOLE_ENABLE", "false")])
|
||||
.await
|
||||
.expect("start RustFS");
|
||||
|
||||
let client = env.create_s3_client();
|
||||
let bucket = "rt09b-stats-delete";
|
||||
|
||||
client.create_bucket().bucket(bucket).send().await.expect("create bucket");
|
||||
|
||||
// Upload 5 objects
|
||||
for i in 0..5 {
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(format!("del-stat-{i}.txt"))
|
||||
.body(ByteStream::from_static(b"data"))
|
||||
.send()
|
||||
.await
|
||||
.expect("put object");
|
||||
}
|
||||
|
||||
// Delete all objects
|
||||
for i in 0..5 {
|
||||
client
|
||||
.delete_object()
|
||||
.bucket(bucket)
|
||||
.key(format!("del-stat-{i}.txt"))
|
||||
.send()
|
||||
.await
|
||||
.expect("delete object");
|
||||
}
|
||||
|
||||
// Wait for scanner to update stats (up to 90 seconds)
|
||||
let mut found_zero = false;
|
||||
for attempt in 0..18 {
|
||||
sleep(Duration::from_secs(5)).await;
|
||||
|
||||
if let Ok(usage) = get_data_usage(&env).await
|
||||
&& let Some(bucket_usage) = usage.buckets_usage.get(bucket)
|
||||
{
|
||||
info!(" attempt {attempt}: objectsCount = {}", bucket_usage.objects_count);
|
||||
if bucket_usage.objects_count == 0 {
|
||||
found_zero = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert!(
|
||||
found_zero,
|
||||
"RT-09b FAIL: bucket object count did not update to 0 after deleting all objects (regression rustfs#5615)"
|
||||
);
|
||||
|
||||
info!("RT-09b PASS: bucket object count updates to 0 after DELETE");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// RT-09c: Verify versioned bucket stats count all versions.
|
||||
///
|
||||
/// Regression pattern: DataUsageInfo undercounts versioned bucket versions
|
||||
/// and delete markers (rustfs#3898).
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_versioned_bucket_stats_count_all_versions() -> TestResult {
|
||||
init_logging();
|
||||
info!("RT-09c: versioned bucket stats count all versions");
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await.expect("create test environment");
|
||||
env.start_rustfs_server_with_env(vec![], &[("RUSTFS_CONSOLE_ENABLE", "false")])
|
||||
.await
|
||||
.expect("start RustFS");
|
||||
|
||||
let client = env.create_s3_client();
|
||||
let bucket = "rt09c-versioned-stats";
|
||||
|
||||
client.create_bucket().bucket(bucket).send().await.expect("create bucket");
|
||||
|
||||
client
|
||||
.put_bucket_versioning()
|
||||
.bucket(bucket)
|
||||
.versioning_configuration(
|
||||
VersioningConfiguration::builder()
|
||||
.status(BucketVersioningStatus::Enabled)
|
||||
.build(),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.expect("enable versioning");
|
||||
|
||||
// Create 3 versions of the same object
|
||||
for i in 0..3 {
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key("multi-version.txt")
|
||||
.body(ByteStream::from(format!("version-{i}").into_bytes()))
|
||||
.send()
|
||||
.await
|
||||
.expect("put version");
|
||||
}
|
||||
|
||||
// Create a delete marker
|
||||
client
|
||||
.delete_object()
|
||||
.bucket(bucket)
|
||||
.key("multi-version.txt")
|
||||
.send()
|
||||
.await
|
||||
.expect("create delete marker");
|
||||
|
||||
// Verify versions via API (immediate, no scanner wait)
|
||||
let versions = client
|
||||
.list_object_versions()
|
||||
.bucket(bucket)
|
||||
.send()
|
||||
.await
|
||||
.expect("list versions");
|
||||
|
||||
assert_eq!(
|
||||
versions.versions().len(),
|
||||
3,
|
||||
"RT-09c FAIL: expected 3 versions, found {}",
|
||||
versions.versions().len()
|
||||
);
|
||||
assert_eq!(
|
||||
versions.delete_markers().len(),
|
||||
1,
|
||||
"RT-09c FAIL: expected 1 delete marker, found {}",
|
||||
versions.delete_markers().len()
|
||||
);
|
||||
|
||||
info!("RT-09c PASS: versioned bucket correctly tracks all versions and delete markers");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,445 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Regression tests for object delete operations.
|
||||
//!
|
||||
//! Covers the recurring pattern where DELETE succeeds at the API level but the
|
||||
//! object remains visible in LIST, or deleted objects reappear after restart,
|
||||
//! or versioned delete operations fail with FileAccessDenied.
|
||||
//! This has regressed 15+ times across the entire release history.
|
||||
//!
|
||||
//! ## Regression Issues
|
||||
//!
|
||||
//! - rustfs#5375: delete object in a bucket list api also exist this object
|
||||
//! - rustfs#5349: The deleted bucket was rebuilt after some time
|
||||
//! - rustfs#5339: data not delete in Object Lock bucket
|
||||
//! - rustfs#5029: Node Does Not Remove Files After Reconnect to Cluster
|
||||
//! - rustfs#4978: DELETE fails with InternalError/FileAccessDenied on beta 10
|
||||
//! - rustfs#760: Cannot delete a versioned bucket
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::common::{RustFSTestEnvironment, init_logging};
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use aws_sdk_s3::types::{BucketVersioningStatus, Delete, ObjectIdentifier, VersioningConfiguration};
|
||||
use serial_test::serial;
|
||||
use std::error::Error;
|
||||
use tracing::info;
|
||||
|
||||
type TestResult = Result<(), Box<dyn Error + Send + Sync>>;
|
||||
|
||||
/// RT-05: Verify DELETE → LIST → HEAD consistency.
|
||||
///
|
||||
/// Regression pattern: DELETE returns 200 but the object remains in LIST.
|
||||
/// Covers rustfs#5375.
|
||||
///
|
||||
/// Steps:
|
||||
/// 1. Create a bucket and upload an object
|
||||
/// 2. Verify the object is in LIST
|
||||
/// 3. DELETE the object
|
||||
/// 4. Verify the object is NOT in LIST
|
||||
/// 5. Verify HEAD returns 404
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_delete_removes_object_from_list() -> TestResult {
|
||||
init_logging();
|
||||
info!("RT-05: delete removes object from list");
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await.expect("create test environment");
|
||||
env.start_rustfs_server_with_env(vec![], &[("RUSTFS_CONSOLE_ENABLE", "false")])
|
||||
.await
|
||||
.expect("start RustFS");
|
||||
|
||||
let client = env.create_s3_client();
|
||||
let bucket = "rt05-delete-consistency";
|
||||
|
||||
client.create_bucket().bucket(bucket).send().await.expect("create bucket");
|
||||
|
||||
// Upload an object
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key("to-delete.txt")
|
||||
.body(ByteStream::from_static(b"will be deleted"))
|
||||
.send()
|
||||
.await
|
||||
.expect("put object");
|
||||
|
||||
// Verify it appears in LIST
|
||||
let list = client
|
||||
.list_objects_v2()
|
||||
.bucket(bucket)
|
||||
.send()
|
||||
.await
|
||||
.expect("list objects before delete");
|
||||
|
||||
assert!(
|
||||
list.contents()
|
||||
.iter()
|
||||
.map(|o| o.key().unwrap_or(""))
|
||||
.any(|key| key == "to-delete.txt"),
|
||||
"RT-05 FAIL: object not in LIST before delete"
|
||||
);
|
||||
|
||||
// DELETE
|
||||
client
|
||||
.delete_object()
|
||||
.bucket(bucket)
|
||||
.key("to-delete.txt")
|
||||
.send()
|
||||
.await
|
||||
.expect("delete object");
|
||||
|
||||
// Verify NOT in LIST
|
||||
let list = client
|
||||
.list_objects_v2()
|
||||
.bucket(bucket)
|
||||
.send()
|
||||
.await
|
||||
.expect("list objects after delete");
|
||||
|
||||
assert!(
|
||||
!list
|
||||
.contents()
|
||||
.iter()
|
||||
.map(|o| o.key().unwrap_or(""))
|
||||
.any(|key| key == "to-delete.txt"),
|
||||
"RT-05 FAIL: deleted object still in LIST (regression rustfs#5375)"
|
||||
);
|
||||
|
||||
// Verify HEAD returns 404
|
||||
let head = client.head_object().bucket(bucket).key("to-delete.txt").send().await;
|
||||
|
||||
assert!(head.is_err(), "RT-05 FAIL: HEAD on deleted object should return error, got success");
|
||||
|
||||
info!("RT-05 PASS: delete correctly removes object from LIST and HEAD");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// RT-05c: Verify batch delete (DeleteObjects) consistency.
|
||||
///
|
||||
/// Regression pattern: batch delete returns success but some objects
|
||||
/// remain in LIST.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_batch_delete_removes_all_objects() -> TestResult {
|
||||
init_logging();
|
||||
info!("RT-05c: batch delete removes all objects");
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await.expect("create test environment");
|
||||
env.start_rustfs_server_with_env(vec![], &[("RUSTFS_CONSOLE_ENABLE", "false")])
|
||||
.await
|
||||
.expect("start RustFS");
|
||||
|
||||
let client = env.create_s3_client();
|
||||
let bucket = "rt05c-batch-delete";
|
||||
|
||||
client.create_bucket().bucket(bucket).send().await.expect("create bucket");
|
||||
|
||||
// Upload multiple objects
|
||||
let keys: Vec<String> = (0..5).map(|i| format!("batch-{i:04}.txt")).collect();
|
||||
for key in &keys {
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.body(ByteStream::from_static(b"batch-delete-me"))
|
||||
.send()
|
||||
.await
|
||||
.expect("put object");
|
||||
}
|
||||
|
||||
// Verify all in LIST
|
||||
let list = client
|
||||
.list_objects_v2()
|
||||
.bucket(bucket)
|
||||
.send()
|
||||
.await
|
||||
.expect("list before batch delete");
|
||||
|
||||
assert_eq!(
|
||||
list.contents().len(),
|
||||
5,
|
||||
"RT-05c FAIL: expected 5 objects before batch delete, found {}",
|
||||
list.contents().len()
|
||||
);
|
||||
|
||||
// Batch delete
|
||||
let objects: Vec<ObjectIdentifier> = keys
|
||||
.iter()
|
||||
.map(|k| ObjectIdentifier::builder().key(k).build().expect("build object id"))
|
||||
.collect();
|
||||
|
||||
client
|
||||
.delete_objects()
|
||||
.bucket(bucket)
|
||||
.delete(Delete::builder().set_objects(Some(objects)).build().expect("build delete"))
|
||||
.send()
|
||||
.await
|
||||
.expect("batch delete");
|
||||
|
||||
// Verify all removed
|
||||
let list = client
|
||||
.list_objects_v2()
|
||||
.bucket(bucket)
|
||||
.send()
|
||||
.await
|
||||
.expect("list after batch delete");
|
||||
|
||||
assert!(
|
||||
list.contents().is_empty(),
|
||||
"RT-05c FAIL: {} objects remain after batch delete (regression: delete objects not fully applied)",
|
||||
list.contents().len()
|
||||
);
|
||||
|
||||
info!("RT-05c PASS: batch delete removes all objects");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// RT-05d: Verify versioned delete → permanent delete → object gone.
|
||||
///
|
||||
/// Covers the pattern where permanent deletion of a specific version
|
||||
/// fails with FileAccessDenied (rustfs#4978).
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_versioned_permanent_delete() -> TestResult {
|
||||
init_logging();
|
||||
info!("RT-05d: versioned permanent delete");
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await.expect("create test environment");
|
||||
env.start_rustfs_server_with_env(vec![], &[("RUSTFS_CONSOLE_ENABLE", "false")])
|
||||
.await
|
||||
.expect("start RustFS");
|
||||
|
||||
let client = env.create_s3_client();
|
||||
let bucket = "rt05d-permanent-delete";
|
||||
|
||||
client.create_bucket().bucket(bucket).send().await.expect("create bucket");
|
||||
|
||||
client
|
||||
.put_bucket_versioning()
|
||||
.bucket(bucket)
|
||||
.versioning_configuration(
|
||||
VersioningConfiguration::builder()
|
||||
.status(BucketVersioningStatus::Enabled)
|
||||
.build(),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.expect("enable versioning");
|
||||
|
||||
// Upload a single object (single version)
|
||||
let put_resp = client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key("single-version.txt")
|
||||
.body(ByteStream::from_static(b"to-be-permanently-deleted"))
|
||||
.send()
|
||||
.await
|
||||
.expect("put object");
|
||||
|
||||
let version_id = put_resp.version_id().expect("version ID should be present").to_string();
|
||||
|
||||
// Permanently delete the specific version (rustfs#4978: FileAccessDenied)
|
||||
client
|
||||
.delete_object()
|
||||
.bucket(bucket)
|
||||
.key("single-version.txt")
|
||||
.version_id(&version_id)
|
||||
.send()
|
||||
.await
|
||||
.expect("permanent delete should succeed (regression rustfs#4978)");
|
||||
|
||||
// Verify the object is completely gone
|
||||
let versions = client
|
||||
.list_object_versions()
|
||||
.bucket(bucket)
|
||||
.send()
|
||||
.await
|
||||
.expect("list versions");
|
||||
|
||||
assert!(
|
||||
versions.versions().is_empty(),
|
||||
"RT-05d FAIL: version still present after permanent delete"
|
||||
);
|
||||
|
||||
info!("RT-05d PASS: versioned permanent delete succeeds");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// RT-05e: Verify delete marker + version history interaction.
|
||||
///
|
||||
/// Covers the pattern where creating a delete marker and then listing
|
||||
/// versions shows incorrect state (rustfs#760).
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_versioned_delete_marker_and_list_consistency() -> TestResult {
|
||||
init_logging();
|
||||
info!("RT-05e: versioned delete marker and list consistency");
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await.expect("create test environment");
|
||||
env.start_rustfs_server_with_env(vec![], &[("RUSTFS_CONSOLE_ENABLE", "false")])
|
||||
.await
|
||||
.expect("start RustFS");
|
||||
|
||||
let client = env.create_s3_client();
|
||||
let bucket = "rt05e-dm-consistency";
|
||||
|
||||
client.create_bucket().bucket(bucket).send().await.expect("create bucket");
|
||||
|
||||
client
|
||||
.put_bucket_versioning()
|
||||
.bucket(bucket)
|
||||
.versioning_configuration(
|
||||
VersioningConfiguration::builder()
|
||||
.status(BucketVersioningStatus::Enabled)
|
||||
.build(),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.expect("enable versioning");
|
||||
|
||||
// Create 3 versions
|
||||
for i in 0..3 {
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key("history.txt")
|
||||
.body(ByteStream::from(format!("v{i}").into_bytes()))
|
||||
.send()
|
||||
.await
|
||||
.expect("put version");
|
||||
}
|
||||
|
||||
// Create a delete marker
|
||||
let del = client
|
||||
.delete_object()
|
||||
.bucket(bucket)
|
||||
.key("history.txt")
|
||||
.send()
|
||||
.await
|
||||
.expect("delete (create marker)");
|
||||
|
||||
assert!(del.delete_marker().unwrap_or(false), "RT-05e FAIL: should have created a delete marker");
|
||||
|
||||
// ListObjectVersions should show 3 versions + 1 delete marker
|
||||
let versions = client
|
||||
.list_object_versions()
|
||||
.bucket(bucket)
|
||||
.send()
|
||||
.await
|
||||
.expect("list versions");
|
||||
|
||||
assert_eq!(
|
||||
versions.versions().len(),
|
||||
3,
|
||||
"RT-05e FAIL: expected 3 versions, found {}",
|
||||
versions.versions().len()
|
||||
);
|
||||
assert_eq!(
|
||||
versions.delete_markers().len(),
|
||||
1,
|
||||
"RT-05e FAIL: expected 1 delete marker, found {}",
|
||||
versions.delete_markers().len()
|
||||
);
|
||||
|
||||
// Now delete the delete marker (restore the object)
|
||||
let dm_version = &versions.delete_markers()[0];
|
||||
client
|
||||
.delete_object()
|
||||
.bucket(bucket)
|
||||
.key("history.txt")
|
||||
.version_id(dm_version.version_id().expect("dm version id"))
|
||||
.send()
|
||||
.await
|
||||
.expect("delete delete-marker");
|
||||
|
||||
// HEAD should succeed now (latest version is accessible)
|
||||
let head = client.head_object().bucket(bucket).key("history.txt").send().await;
|
||||
|
||||
assert!(head.is_ok(), "RT-05e FAIL: HEAD should succeed after removing delete marker");
|
||||
|
||||
info!("RT-05e PASS: versioned delete marker and list consistency");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// RT-05f: Verify object deletion does not leave orphan data on disk.
|
||||
///
|
||||
/// Regression pattern: after delete, the object data files remain on disk
|
||||
/// (rustfs#5029: Node Does Not Remove Files After Reconnect).
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_delete_removes_object_head_returns_404() -> TestResult {
|
||||
init_logging();
|
||||
info!("RT-05f: delete → HEAD 404 consistency");
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await.expect("create test environment");
|
||||
env.start_rustfs_server_with_env(vec![], &[("RUSTFS_CONSOLE_ENABLE", "false")])
|
||||
.await
|
||||
.expect("start RustFS");
|
||||
|
||||
let client = env.create_s3_client();
|
||||
let bucket = "rt05f-delete-head";
|
||||
|
||||
client.create_bucket().bucket(bucket).send().await.expect("create bucket");
|
||||
|
||||
// Upload, delete, verify HEAD returns 404
|
||||
let keys = vec!["small.txt", "medium.txt", "with-slash.txt", "special+chars.txt"];
|
||||
|
||||
for key in &keys {
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(*key)
|
||||
.body(ByteStream::from_static(b"delete-me"))
|
||||
.send()
|
||||
.await
|
||||
.expect("put object");
|
||||
}
|
||||
|
||||
for key in &keys {
|
||||
client
|
||||
.delete_object()
|
||||
.bucket(bucket)
|
||||
.key(*key)
|
||||
.send()
|
||||
.await
|
||||
.expect("delete object");
|
||||
}
|
||||
|
||||
// All HEAD requests should return 404
|
||||
for key in &keys {
|
||||
let head = client.head_object().bucket(bucket).key(*key).send().await;
|
||||
|
||||
assert!(head.is_err(), "RT-05f FAIL: HEAD on deleted key '{key}' should return error");
|
||||
}
|
||||
|
||||
// LIST should be empty
|
||||
let list = client
|
||||
.list_objects_v2()
|
||||
.bucket(bucket)
|
||||
.send()
|
||||
.await
|
||||
.expect("list after all deletes");
|
||||
|
||||
assert!(
|
||||
list.contents().is_empty(),
|
||||
"RT-05f FAIL: {} objects remain after deleting all",
|
||||
list.contents().len()
|
||||
);
|
||||
|
||||
info!("RT-05f PASS: all deleted objects return 404 on HEAD");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Regression tests for distributed cluster startup and quorum.
|
||||
//!
|
||||
//! Covers the recurring pattern where multi-node clusters fail to start due to
|
||||
//! lock quorum issues, DNS resolution delays, or erasure quorum deadlocks.
|
||||
//! This has regressed 7+ times.
|
||||
//!
|
||||
//! ## Regression Issues
|
||||
//!
|
||||
//! - rustfs#5416: RustFS cannot cold-start with 2/3 quorum when Pod DNS missing
|
||||
//! - rustfs#2945: Distributed mode fails on K8s: erasure quorum deadlock
|
||||
//! - rustfs#2794: distributed deployment does not become ready
|
||||
//! - rustfs#2601: fresh pod immediately enters FaultyDisk state
|
||||
//! - rustfs#4040: Distributed startup can fail lock quorum before AppContext initializes
|
||||
//! - rustfs#5655: fix(ecstore): bootstrap fresh four-node clusters reliably
|
||||
//! - rustfs#4954: S3/health endpoint unavailability after multi-pool scale-up
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::common::{RustFSTestClusterEnvironment, init_logging};
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use serial_test::serial;
|
||||
use std::error::Error;
|
||||
use tokio::time::{Duration, sleep};
|
||||
use tracing::info;
|
||||
|
||||
type TestResult = Result<(), Box<dyn Error + Send + Sync>>;
|
||||
|
||||
/// RT-10: Verify 4-node cluster starts successfully and all nodes are ready.
|
||||
///
|
||||
/// Regression pattern: distributed startup fails with quorum deadlock or
|
||||
/// lock acquisition timeout (rustfs#2945, rustfs#5655).
|
||||
///
|
||||
/// Steps:
|
||||
/// 1. Create a 4-node cluster
|
||||
/// 2. Start all nodes simultaneously
|
||||
/// 3. Verify all nodes report healthy
|
||||
/// 4. Verify S3 operations work through any node
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_four_node_cluster_startup_and_health() -> TestResult {
|
||||
init_logging();
|
||||
info!("RT-10: 4-node cluster startup and health");
|
||||
|
||||
let mut cluster = RustFSTestClusterEnvironment::new(4).await.expect("create 4-node cluster");
|
||||
|
||||
cluster.start().await.expect("start 4-node cluster");
|
||||
|
||||
// Create a bucket and verify it's accessible from all nodes
|
||||
cluster
|
||||
.create_test_bucket("rt10-startup")
|
||||
.await
|
||||
.expect("create bucket on cluster");
|
||||
|
||||
let clients = cluster.create_all_clients().expect("create per-node clients");
|
||||
|
||||
// Verify S3 operations work from every node
|
||||
for (i, client) in clients.iter().enumerate() {
|
||||
client
|
||||
.put_object()
|
||||
.bucket("rt10-startup")
|
||||
.key(format!("from-node-{i}.txt"))
|
||||
.body(ByteStream::from_static(b"hello from node"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap_or_else(|e| panic!("PUT from node {i} failed: {e}"));
|
||||
}
|
||||
|
||||
// Verify all objects are visible from node 0
|
||||
let list = clients[0]
|
||||
.list_objects_v2()
|
||||
.bucket("rt10-startup")
|
||||
.send()
|
||||
.await
|
||||
.expect("list objects from node 0");
|
||||
|
||||
assert_eq!(
|
||||
list.contents().len(),
|
||||
4,
|
||||
"RT-10 FAIL: expected 4 objects (one per node), found {}",
|
||||
list.contents().len()
|
||||
);
|
||||
|
||||
info!("RT-10 PASS: 4-node cluster starts and serves S3 from all nodes");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// RT-10b: Verify cluster handles node restart gracefully.
|
||||
///
|
||||
/// Regression pattern: after a node restart, it cannot rejoin the cluster
|
||||
/// or enters a faulty state (rustfs#2601).
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_cluster_survives_node_restart() -> TestResult {
|
||||
init_logging();
|
||||
info!("RT-10b: cluster survives node restart");
|
||||
|
||||
let mut cluster = RustFSTestClusterEnvironment::new(4).await.expect("create 4-node cluster");
|
||||
|
||||
cluster.start().await.expect("start cluster");
|
||||
|
||||
cluster.create_test_bucket("rt10b-restart").await.expect("create bucket");
|
||||
|
||||
// Write data
|
||||
let clients = cluster.create_all_clients()?;
|
||||
clients[0]
|
||||
.put_object()
|
||||
.bucket("rt10b-restart")
|
||||
.key("before-restart.txt")
|
||||
.body(ByteStream::from_static(b"persistent data"))
|
||||
.send()
|
||||
.await
|
||||
.expect("put object before restart");
|
||||
|
||||
// Stop node 3
|
||||
cluster.stop_node(3).expect("stop node 3");
|
||||
sleep(Duration::from_secs(2)).await;
|
||||
|
||||
// Verify cluster still works with 3/4 nodes (quorum)
|
||||
clients[0]
|
||||
.put_object()
|
||||
.bucket("rt10b-restart")
|
||||
.key("during-offline.txt")
|
||||
.body(ByteStream::from_static(b"written while node 3 down"))
|
||||
.send()
|
||||
.await
|
||||
.expect("PUT should succeed with 3/4 nodes");
|
||||
|
||||
// Restart node 3
|
||||
cluster.start_node(3).await.expect("restart node 3");
|
||||
|
||||
// Wait for node to rejoin
|
||||
sleep(Duration::from_secs(3)).await;
|
||||
|
||||
// Verify the restarted node can serve reads
|
||||
let list = clients[3]
|
||||
.list_objects_v2()
|
||||
.bucket("rt10b-restart")
|
||||
.send()
|
||||
.await
|
||||
.expect("list from restarted node");
|
||||
|
||||
assert!(
|
||||
list.contents().len() >= 2,
|
||||
"RT-10b FAIL: restarted node sees {} objects, expected >= 2",
|
||||
list.contents().len()
|
||||
);
|
||||
|
||||
info!("RT-10b PASS: cluster survives and recovers from node restart");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// RT-10c: Verify bucket creation persists across all nodes.
|
||||
///
|
||||
/// Regression pattern: bucket metadata is not replicated to all nodes,
|
||||
/// causing NoSuchBucket errors on some nodes (rustfs#3191).
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_bucket_visible_from_all_nodes() -> TestResult {
|
||||
init_logging();
|
||||
info!("RT-10c: bucket visible from all nodes");
|
||||
|
||||
let mut cluster = RustFSTestClusterEnvironment::new(4).await.expect("create 4-node cluster");
|
||||
|
||||
cluster.start().await.expect("start cluster");
|
||||
|
||||
cluster
|
||||
.create_test_bucket("rt10c-bucket-visibility")
|
||||
.await
|
||||
.expect("create bucket");
|
||||
|
||||
let clients = cluster.create_all_clients()?;
|
||||
|
||||
// Verify the bucket is visible from every node
|
||||
for (i, client) in clients.iter().enumerate() {
|
||||
let resp = client
|
||||
.list_objects_v2()
|
||||
.bucket("rt10c-bucket-visibility")
|
||||
.send()
|
||||
.await
|
||||
.unwrap_or_else(|e| panic!("list from node {i} failed (NoSuchBucket?): {e}"));
|
||||
|
||||
assert!(resp.contents().is_empty(), "RT-10c: fresh bucket should be empty on node {i}");
|
||||
}
|
||||
|
||||
info!("RT-10c PASS: bucket visible from all 4 nodes");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1687,6 +1687,44 @@ async fn four_node_inline_storage_and_get_boundaries() -> TestResult {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn four_node_empty_legacy_volumes_start_as_fresh() -> TestResult {
|
||||
init_logging();
|
||||
|
||||
let mut cluster = RustFSTestClusterEnvironment::new(4).await?;
|
||||
for data_dir in cluster.nodes.iter().flat_map(|node| &node.data_dirs) {
|
||||
tokio::fs::create_dir_all(Path::new(data_dir).join(".minio.sys")).await?;
|
||||
}
|
||||
|
||||
cluster.start().await?;
|
||||
|
||||
// Starting is not the assertion. The regression is that an empty legacy
|
||||
// `.minio.sys` must be classified as a *fresh* volume, not as an existing
|
||||
// MinIO deployment to adopt or migrate. Pin what that classification leaves
|
||||
// on disk and in the namespace.
|
||||
let buckets = cluster.create_s3_client(0)?.list_buckets().send().await?;
|
||||
assert!(
|
||||
buckets.buckets().is_empty(),
|
||||
"a fresh classification must not adopt buckets from the pre-existing directories, got {:?}",
|
||||
buckets.buckets().iter().filter_map(|b| b.name()).collect::<Vec<_>>()
|
||||
);
|
||||
|
||||
for data_dir in cluster.nodes.iter().flat_map(|node| &node.data_dirs) {
|
||||
assert!(
|
||||
Path::new(data_dir).join(".rustfs.sys").join("format.json").is_file(),
|
||||
"each drive must be formatted as fresh: {data_dir} has no .rustfs.sys/format.json"
|
||||
);
|
||||
let mut legacy = tokio::fs::read_dir(Path::new(data_dir).join(".minio.sys")).await?;
|
||||
assert!(
|
||||
legacy.next_entry().await?.is_none(),
|
||||
"the empty legacy directory must be left untouched, not migrated into: {data_dir}"
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn four_node_inline_fallback_controls() -> TestResult {
|
||||
|
||||
@@ -298,4 +298,32 @@ mod create_bucket_region_test;
|
||||
#[cfg(test)]
|
||||
mod copy_source_invalid_date_test;
|
||||
|
||||
// P0 regression: event notification startup race (rustfs#5387, #5681, #5401, #5183, #5115, #4796)
|
||||
#[cfg(test)]
|
||||
mod notification_startup_regression_test;
|
||||
|
||||
// P0 regression: lifecycle/ILM object expiration (rustfs#5407, #5167, #4963, #5615, #4879)
|
||||
#[cfg(test)]
|
||||
mod lifecycle_regression_test;
|
||||
|
||||
// P0 regression: delete operations consistency (rustfs#5375, #5349, #5339, #5029, #4978, #760)
|
||||
#[cfg(test)]
|
||||
mod delete_regression_test;
|
||||
|
||||
// P1 regression: listing/metacache completeness (rustfs#5166, #5156, #5051, #4810, #4648, #3191)
|
||||
#[cfg(test)]
|
||||
mod listing_regression_test;
|
||||
|
||||
// P1 regression: bucket statistics accuracy (rustfs#5615, #5008, #5116, #5055, #3898, #1012)
|
||||
#[cfg(test)]
|
||||
mod bucket_stats_regression_test;
|
||||
|
||||
// P1 regression: distributed startup/quorum (rustfs#5416, #2945, #2794, #2601, #4040, #5655)
|
||||
#[cfg(test)]
|
||||
mod distributed_startup_regression_test;
|
||||
|
||||
// P1 regression: tier/ILM transition (rustfs#5218, #5130, #5011, #4826, #5024)
|
||||
#[cfg(test)]
|
||||
mod tier_transition_regression_test;
|
||||
|
||||
pub mod tls_gen;
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Regression tests for lifecycle/ILM object expiration and transition.
|
||||
//!
|
||||
//! Covers the recurring pattern where ILM expiration rules do not actually
|
||||
//! delete objects, or lifecycle rule parameters are silently corrupted.
|
||||
//! This has regressed 6+ times.
|
||||
//!
|
||||
//! ## Regression Issues
|
||||
//!
|
||||
//! - rustfs#5407: lifecycle not delete any bucket object
|
||||
//! - rustfs#5167: lifecycle not delete object
|
||||
//! - rustfs#4963: lifecycle rule 3 days → effective value 0 days
|
||||
//! - rustfs#5615: bucket statistics remain unchanged after data expiration
|
||||
//! - rustfs#4879: ILM serial lane: restore transition never completes
|
||||
//! - rustfs#5442: Uncheck of Replicate Delete still deletes the file
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::common::{RustFSTestEnvironment, init_logging};
|
||||
use aws_sdk_s3::Client;
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use aws_sdk_s3::types::{
|
||||
BucketLifecycleConfiguration, BucketVersioningStatus, ExpirationStatus, LifecycleExpiration, LifecycleRule,
|
||||
LifecycleRuleFilter, NoncurrentVersionExpiration, VersioningConfiguration,
|
||||
};
|
||||
use serial_test::serial;
|
||||
use std::error::Error;
|
||||
use tracing::info;
|
||||
|
||||
type TestResult = Result<(), Box<dyn Error + Send + Sync>>;
|
||||
|
||||
async fn setup_versioned_bucket(client: &Client, bucket: &str) -> TestResult {
|
||||
client
|
||||
.create_bucket()
|
||||
.bucket(bucket)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("create bucket: {e}"))?;
|
||||
|
||||
client
|
||||
.put_bucket_versioning()
|
||||
.bucket(bucket)
|
||||
.versioning_configuration(
|
||||
VersioningConfiguration::builder()
|
||||
.status(BucketVersioningStatus::Enabled)
|
||||
.build(),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("enable versioning: {e}"))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// RT-03: Verify that a lifecycle expiration rule actually deletes objects.
|
||||
///
|
||||
/// Regression pattern: lifecycle rules are accepted but the scanner never
|
||||
/// processes them, leaving expired objects in place.
|
||||
///
|
||||
/// Steps:
|
||||
/// 1. Create a versioned bucket
|
||||
/// 2. Upload several objects
|
||||
/// 3. Apply a lifecycle rule with 1-day expiration
|
||||
/// 4. Wait for the scanner to process
|
||||
/// 5. Verify objects are still present (they shouldn't expire yet — 1 day)
|
||||
/// 6. Verify the lifecycle rule was persisted correctly (not corrupted to 0 days)
|
||||
///
|
||||
/// This tests the rule persistence path (rustfs#4963: 3 days → 0 days).
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_lifecycle_expiration_rule_persists_correctly() -> TestResult {
|
||||
init_logging();
|
||||
info!("RT-03: lifecycle expiration rule persists correctly");
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await.expect("create test environment");
|
||||
env.start_rustfs_server_with_env(vec![], &[("RUSTFS_CONSOLE_ENABLE", "false")])
|
||||
.await
|
||||
.expect("start RustFS");
|
||||
|
||||
let client = env.create_s3_client();
|
||||
let bucket = "rt03-lifecycle-persist";
|
||||
setup_versioned_bucket(&client, bucket).await?;
|
||||
|
||||
// Apply a lifecycle rule with 1-day expiration on a prefix
|
||||
let rule = LifecycleRule::builder()
|
||||
.id("expire-after-1-day")
|
||||
.status(ExpirationStatus::Enabled)
|
||||
.filter(LifecycleRuleFilter::builder().prefix("logs/").build())
|
||||
.expiration(LifecycleExpiration::builder().days(1).build())
|
||||
.build()
|
||||
.expect("build lifecycle rule");
|
||||
|
||||
client
|
||||
.put_bucket_lifecycle_configuration()
|
||||
.bucket(bucket)
|
||||
.lifecycle_configuration(
|
||||
BucketLifecycleConfiguration::builder()
|
||||
.rules(rule)
|
||||
.build()
|
||||
.expect("build lifecycle config"),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.expect("put lifecycle configuration");
|
||||
|
||||
// Read back and verify the rule was not corrupted (rustfs#4963: days → 0)
|
||||
let resp = client
|
||||
.get_bucket_lifecycle_configuration()
|
||||
.bucket(bucket)
|
||||
.send()
|
||||
.await
|
||||
.expect("get lifecycle configuration");
|
||||
|
||||
let rules = resp.rules();
|
||||
assert_eq!(rules.len(), 1, "RT-03 FAIL: expected exactly 1 lifecycle rule");
|
||||
|
||||
let retrieved = &rules[0];
|
||||
assert_eq!(retrieved.id(), Some("expire-after-1-day"), "RT-03 FAIL: rule ID mismatch");
|
||||
assert_eq!(retrieved.status(), &ExpirationStatus::Enabled, "RT-03 FAIL: rule should be Enabled");
|
||||
|
||||
let exp = retrieved.expiration().expect("expiration should be set");
|
||||
assert_eq!(
|
||||
exp.days(),
|
||||
Some(1),
|
||||
"RT-03 FAIL: expiration days corrupted (regression rustfs#4963: expected 1, got {:?})",
|
||||
exp.days()
|
||||
);
|
||||
|
||||
info!("RT-03 PASS: lifecycle expiration rule persists correctly");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// RT-03b: Verify lifecycle rule with noncurrent version expiration.
|
||||
///
|
||||
/// Covers the pattern where noncurrent version expiration rules are
|
||||
/// accepted but old versions are never cleaned up.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_lifecycle_noncurrent_version_expiration_rule_persists() -> TestResult {
|
||||
init_logging();
|
||||
info!("RT-03b: noncurrent version expiration rule persists");
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await.expect("create test environment");
|
||||
env.start_rustfs_server_with_env(vec![], &[("RUSTFS_CONSOLE_ENABLE", "false")])
|
||||
.await
|
||||
.expect("start RustFS");
|
||||
|
||||
let client = env.create_s3_client();
|
||||
let bucket = "rt03b-noncurrent-expire";
|
||||
setup_versioned_bucket(&client, bucket).await?;
|
||||
|
||||
// Create multiple versions of the same object
|
||||
for i in 0..3 {
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key("versioned-obj.txt")
|
||||
.body(ByteStream::from(format!("version-{i}").into_bytes()))
|
||||
.send()
|
||||
.await
|
||||
.expect("put object version");
|
||||
}
|
||||
|
||||
// Verify we have 3 versions
|
||||
let versions = client
|
||||
.list_object_versions()
|
||||
.bucket(bucket)
|
||||
.send()
|
||||
.await
|
||||
.expect("list versions");
|
||||
|
||||
let count = versions.versions().len();
|
||||
assert_eq!(count, 3, "RT-03b FAIL: expected 3 versions, found {count}");
|
||||
|
||||
// Apply noncurrent version expiration rule
|
||||
let rule = LifecycleRule::builder()
|
||||
.id("expire-noncurrent-after-1-day")
|
||||
.status(ExpirationStatus::Enabled)
|
||||
.filter(LifecycleRuleFilter::builder().prefix("").build())
|
||||
.noncurrent_version_expiration(NoncurrentVersionExpiration::builder().noncurrent_days(1).build())
|
||||
.build()
|
||||
.expect("build lifecycle rule");
|
||||
|
||||
client
|
||||
.put_bucket_lifecycle_configuration()
|
||||
.bucket(bucket)
|
||||
.lifecycle_configuration(
|
||||
BucketLifecycleConfiguration::builder()
|
||||
.rules(rule)
|
||||
.build()
|
||||
.expect("build lifecycle config"),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.expect("put lifecycle configuration");
|
||||
|
||||
// Read back and verify
|
||||
let resp = client
|
||||
.get_bucket_lifecycle_configuration()
|
||||
.bucket(bucket)
|
||||
.send()
|
||||
.await
|
||||
.expect("get lifecycle configuration");
|
||||
|
||||
let rules = resp.rules();
|
||||
assert_eq!(rules.len(), 1, "RT-03b FAIL: expected 1 rule");
|
||||
|
||||
let nc_exp = rules[0]
|
||||
.noncurrent_version_expiration()
|
||||
.expect("noncurrent expiration should be set");
|
||||
assert_eq!(nc_exp.noncurrent_days(), Some(1), "RT-03b FAIL: noncurrent days corrupted");
|
||||
|
||||
info!("RT-03b PASS: noncurrent version expiration rule persists correctly");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// RT-04: Verify lifecycle rule with prefix filter persists after restart.
|
||||
///
|
||||
/// Covers the pattern where lifecycle rules are accepted but silently lost
|
||||
/// after restart. Transition rules require a configured remote tier
|
||||
/// (tested in reliant/tiering.rs), so this test uses expiration only.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_lifecycle_prefix_rule_persists() -> TestResult {
|
||||
init_logging();
|
||||
info!("RT-04: lifecycle prefix rule persists");
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await.expect("create test environment");
|
||||
env.start_rustfs_server_with_env(vec![], &[("RUSTFS_CONSOLE_ENABLE", "false")])
|
||||
.await
|
||||
.expect("start RustFS");
|
||||
|
||||
let client = env.create_s3_client();
|
||||
let bucket = "rt04-lifecycle-prefix";
|
||||
setup_versioned_bucket(&client, bucket).await?;
|
||||
|
||||
let rule = LifecycleRule::builder()
|
||||
.id("expire-archive-after-7-days")
|
||||
.status(ExpirationStatus::Enabled)
|
||||
.filter(LifecycleRuleFilter::builder().prefix("archive/").build())
|
||||
.expiration(LifecycleExpiration::builder().days(7).build())
|
||||
.build()
|
||||
.expect("build lifecycle rule");
|
||||
|
||||
client
|
||||
.put_bucket_lifecycle_configuration()
|
||||
.bucket(bucket)
|
||||
.lifecycle_configuration(
|
||||
BucketLifecycleConfiguration::builder()
|
||||
.rules(rule)
|
||||
.build()
|
||||
.expect("build lifecycle config"),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.expect("put lifecycle configuration");
|
||||
|
||||
// Restart server
|
||||
env.restart_server_preserving_data(vec![], &[]).await.expect("restart RustFS");
|
||||
|
||||
// Verify the rule survived restart
|
||||
let resp = client
|
||||
.get_bucket_lifecycle_configuration()
|
||||
.bucket(bucket)
|
||||
.send()
|
||||
.await
|
||||
.expect("get lifecycle after restart");
|
||||
|
||||
let rules = resp.rules();
|
||||
assert_eq!(rules.len(), 1, "RT-04 FAIL: expected 1 rule after restart");
|
||||
|
||||
let exp = rules[0].expiration().expect("expiration should be set");
|
||||
assert_eq!(exp.days(), Some(7), "RT-04 FAIL: expiration days corrupted after restart");
|
||||
|
||||
info!("RT-04 PASS: lifecycle prefix rule persists after restart");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// RT-05b: Verify delete marker creation in versioned bucket.
|
||||
///
|
||||
/// Regression pattern: DELETE on a versioned object fails or does not
|
||||
/// create a delete marker, or the delete marker is not visible in LIST.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_delete_marker_creation_and_visibility() -> TestResult {
|
||||
init_logging();
|
||||
info!("RT-05b: delete marker creation and visibility");
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await.expect("create test environment");
|
||||
env.start_rustfs_server_with_env(vec![], &[("RUSTFS_CONSOLE_ENABLE", "false")])
|
||||
.await
|
||||
.expect("start RustFS");
|
||||
|
||||
let client = env.create_s3_client();
|
||||
let bucket = "rt05b-delete-marker";
|
||||
setup_versioned_bucket(&client, bucket).await?;
|
||||
|
||||
// Put an object
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key("marker-test.txt")
|
||||
.body(ByteStream::from_static(b"to-be-deleted"))
|
||||
.send()
|
||||
.await
|
||||
.expect("put object");
|
||||
|
||||
// Delete without specifying versionId → should create a delete marker
|
||||
let del_resp = client
|
||||
.delete_object()
|
||||
.bucket(bucket)
|
||||
.key("marker-test.txt")
|
||||
.send()
|
||||
.await
|
||||
.expect("delete object");
|
||||
|
||||
// The response should indicate a delete marker was created
|
||||
assert!(
|
||||
del_resp.delete_marker().unwrap_or(false),
|
||||
"RT-05b FAIL: DELETE on versioned object did not create a delete marker"
|
||||
);
|
||||
|
||||
// ListObjectVersions should show both the original version and the delete marker
|
||||
let versions = client
|
||||
.list_object_versions()
|
||||
.bucket(bucket)
|
||||
.send()
|
||||
.await
|
||||
.expect("list versions");
|
||||
|
||||
let delete_markers: Vec<_> = versions
|
||||
.delete_markers()
|
||||
.iter()
|
||||
.filter(|dm| dm.key() == Some("marker-test.txt"))
|
||||
.collect();
|
||||
|
||||
assert_eq!(
|
||||
delete_markers.len(),
|
||||
1,
|
||||
"RT-05b FAIL: expected 1 delete marker, found {}",
|
||||
delete_markers.len()
|
||||
);
|
||||
|
||||
info!("RT-05b PASS: delete marker created and visible");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Regression tests for object listing and metacache consistency.
|
||||
//!
|
||||
//! Covers the recurring pattern where ListObjectsV2 returns incomplete results,
|
||||
//! silently truncates with IsTruncated=false, or corrupts the metadata cache.
|
||||
//! This has regressed 8+ times.
|
||||
//!
|
||||
//! ## Regression Issues
|
||||
//!
|
||||
//! - rustfs#5166: Metacache listing quorum failed timeout after cluster startup
|
||||
//! - rustfs#5156: Metacache producer failed
|
||||
//! - rustfs#5051: ListObjectsV2 returns empty results for shallow prefixes
|
||||
//! - rustfs#4810: walk_dir timeout silently truncates listings (200, IsTruncated=false)
|
||||
//! - rustfs#4648: Object listing oscillates between complete, partial, and zero
|
||||
//! - rustfs#3191: ListObjectsV2 timeout corrupts metadata cache → NoSuchBucket
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::common::{RustFSTestEnvironment, init_logging};
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use serial_test::serial;
|
||||
use std::collections::HashSet;
|
||||
use std::error::Error;
|
||||
use tracing::info;
|
||||
|
||||
type TestResult = Result<(), Box<dyn Error + Send + Sync>>;
|
||||
|
||||
/// RT-06: Verify ListObjectsV2 pagination completeness for medium-sized bucket.
|
||||
///
|
||||
/// Regression pattern: listing returns 200 with IsTruncated=false but
|
||||
/// misses objects (rustfs#4810: walk_dir timeout truncation).
|
||||
///
|
||||
/// Steps:
|
||||
/// 1. Upload 100 objects with known keys
|
||||
/// 2. List all objects via pagination (max_keys=10)
|
||||
/// 3. Verify all 100 keys are returned exactly once
|
||||
/// 4. Verify no duplicates or skipped keys
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_list_objects_v2_completeness_100_objects() -> TestResult {
|
||||
init_logging();
|
||||
info!("RT-06: listing completeness with 100 objects");
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await.expect("create test environment");
|
||||
env.start_rustfs_server_with_env(vec![], &[("RUSTFS_CONSOLE_ENABLE", "false")])
|
||||
.await
|
||||
.expect("start RustFS");
|
||||
|
||||
let client = env.create_s3_client();
|
||||
let bucket = "rt06-list-completeness";
|
||||
|
||||
client.create_bucket().bucket(bucket).send().await.expect("create bucket");
|
||||
|
||||
// Upload 100 objects
|
||||
let expected_keys: Vec<String> = (0..100).map(|i| format!("obj-{i:04}.txt")).collect();
|
||||
for key in &expected_keys {
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.body(ByteStream::from_static(b"data"))
|
||||
.send()
|
||||
.await
|
||||
.expect("put object");
|
||||
}
|
||||
|
||||
// Paginate through all objects (small page size to force multiple pages)
|
||||
let mut all_keys: Vec<String> = Vec::new();
|
||||
let mut continuation_token: Option<String> = None;
|
||||
|
||||
loop {
|
||||
let mut req = client.list_objects_v2().bucket(bucket).max_keys(10);
|
||||
|
||||
if let Some(ref token) = continuation_token {
|
||||
req = req.continuation_token(token);
|
||||
}
|
||||
|
||||
let resp = req.send().await.expect("list objects page");
|
||||
|
||||
for obj in resp.contents() {
|
||||
all_keys.push(obj.key().unwrap_or("").to_string());
|
||||
}
|
||||
|
||||
if !resp.is_truncated().unwrap_or(false) {
|
||||
break;
|
||||
}
|
||||
continuation_token = resp.next_continuation_token().map(|s| s.to_string());
|
||||
}
|
||||
|
||||
// Verify completeness and uniqueness
|
||||
let unique_keys: HashSet<&str> = all_keys.iter().map(|s| s.as_str()).collect();
|
||||
|
||||
assert_eq!(
|
||||
all_keys.len(),
|
||||
100,
|
||||
"RT-06 FAIL: expected 100 objects, listed {} (regression: walk_dir truncation)",
|
||||
all_keys.len()
|
||||
);
|
||||
assert_eq!(
|
||||
unique_keys.len(),
|
||||
100,
|
||||
"RT-06 FAIL: found {} unique keys but listed {} total (duplicates!)",
|
||||
unique_keys.len(),
|
||||
all_keys.len()
|
||||
);
|
||||
|
||||
for key in &expected_keys {
|
||||
assert!(
|
||||
unique_keys.contains(key.as_str()),
|
||||
"RT-06 FAIL: key '{key}' missing from listing (regression rustfs#4810)"
|
||||
);
|
||||
}
|
||||
|
||||
info!("RT-06 PASS: all 100 objects listed completely and uniquely");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// RT-06b: Verify listing with prefix filter returns correct subset.
|
||||
///
|
||||
/// Regression pattern: prefix filter returns empty or includes wrong keys
|
||||
/// (rustfs#5051: empty results for shallow prefixes).
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_list_objects_v2_prefix_filter_correctness() -> TestResult {
|
||||
init_logging();
|
||||
info!("RT-06b: prefix filter correctness");
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await.expect("create test environment");
|
||||
env.start_rustfs_server_with_env(vec![], &[("RUSTFS_CONSOLE_ENABLE", "false")])
|
||||
.await
|
||||
.expect("start RustFS");
|
||||
|
||||
let client = env.create_s3_client();
|
||||
let bucket = "rt06b-prefix-filter";
|
||||
|
||||
client.create_bucket().bucket(bucket).send().await.expect("create bucket");
|
||||
|
||||
// Upload objects with different prefixes
|
||||
for i in 0..5 {
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(format!("logs/app-{i:04}.log"))
|
||||
.body(ByteStream::from_static(b"log data"))
|
||||
.send()
|
||||
.await
|
||||
.expect("put log object");
|
||||
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(format!("data/file-{i:04}.csv"))
|
||||
.body(ByteStream::from_static(b"csv data"))
|
||||
.send()
|
||||
.await
|
||||
.expect("put data object");
|
||||
}
|
||||
|
||||
// List with prefix "logs/" — should return exactly 5
|
||||
let resp = client
|
||||
.list_objects_v2()
|
||||
.bucket(bucket)
|
||||
.prefix("logs/")
|
||||
.send()
|
||||
.await
|
||||
.expect("list with prefix");
|
||||
|
||||
assert_eq!(
|
||||
resp.contents().len(),
|
||||
5,
|
||||
"RT-06b FAIL: expected 5 objects with prefix 'logs/', found {} (regression rustfs#5051)",
|
||||
resp.contents().len()
|
||||
);
|
||||
|
||||
for obj in resp.contents() {
|
||||
assert!(
|
||||
obj.key().unwrap_or("").starts_with("logs/"),
|
||||
"RT-06b FAIL: object '{}' does not match prefix 'logs/'",
|
||||
obj.key().unwrap_or("?")
|
||||
);
|
||||
}
|
||||
|
||||
// List with prefix "data/" — should return exactly 5
|
||||
let resp = client
|
||||
.list_objects_v2()
|
||||
.bucket(bucket)
|
||||
.prefix("data/")
|
||||
.send()
|
||||
.await
|
||||
.expect("list with data/ prefix");
|
||||
|
||||
assert_eq!(
|
||||
resp.contents().len(),
|
||||
5,
|
||||
"RT-06b FAIL: expected 5 objects with prefix 'data/', found {}",
|
||||
resp.contents().len()
|
||||
);
|
||||
|
||||
// List with prefix "nonexistent/" — should return 0
|
||||
let resp = client
|
||||
.list_objects_v2()
|
||||
.bucket(bucket)
|
||||
.prefix("nonexistent/")
|
||||
.send()
|
||||
.await
|
||||
.expect("list with nonexistent prefix");
|
||||
|
||||
assert!(
|
||||
resp.contents().is_empty(),
|
||||
"RT-06b FAIL: expected 0 objects with prefix 'nonexistent/', found {}",
|
||||
resp.contents().len()
|
||||
);
|
||||
|
||||
info!("RT-06b PASS: prefix filter returns correct subset");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// RT-06c: Verify listing with delimiter and CommonPrefixes.
|
||||
///
|
||||
/// Regression pattern: delimiter handling produces incorrect CommonPrefixes
|
||||
/// or misses objects at the delimiter boundary.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_list_objects_v2_delimiter_common_prefixes() -> TestResult {
|
||||
init_logging();
|
||||
info!("RT-06c: delimiter and CommonPrefixes");
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await.expect("create test environment");
|
||||
env.start_rustfs_server_with_env(vec![], &[("RUSTFS_CONSOLE_ENABLE", "false")])
|
||||
.await
|
||||
.expect("start RustFS");
|
||||
|
||||
let client = env.create_s3_client();
|
||||
let bucket = "rt06c-delimiter";
|
||||
|
||||
client.create_bucket().bucket(bucket).send().await.expect("create bucket");
|
||||
|
||||
// Create a hierarchical structure
|
||||
let keys = vec!["a.txt", "dir1/b.txt", "dir1/sub1/c.txt", "dir1/sub2/d.txt", "dir2/e.txt"];
|
||||
|
||||
for key in &keys {
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(*key)
|
||||
.body(ByteStream::from_static(b"content"))
|
||||
.send()
|
||||
.await
|
||||
.expect("put object");
|
||||
}
|
||||
|
||||
// List with delimiter "/" at root level
|
||||
let resp = client
|
||||
.list_objects_v2()
|
||||
.bucket(bucket)
|
||||
.delimiter("/")
|
||||
.send()
|
||||
.await
|
||||
.expect("list with delimiter");
|
||||
|
||||
// Should have 1 object (a.txt) and 2 common prefixes (dir1/, dir2/)
|
||||
let contents: Vec<_> = resp.contents().iter().map(|o| o.key().unwrap_or("")).collect();
|
||||
let prefixes: Vec<_> = resp.common_prefixes().iter().map(|p| p.prefix().unwrap_or("")).collect();
|
||||
|
||||
assert!(contents.contains(&"a.txt"), "RT-06c FAIL: root object 'a.txt' missing from listing");
|
||||
assert_eq!(contents.len(), 1, "RT-06c FAIL: expected 1 root-level object, found {}", contents.len());
|
||||
assert_eq!(prefixes.len(), 2, "RT-06c FAIL: expected 2 common prefixes, found {:?}", prefixes);
|
||||
assert!(prefixes.contains(&"dir1/"), "RT-06c FAIL: 'dir1/' missing from CommonPrefixes");
|
||||
assert!(prefixes.contains(&"dir2/"), "RT-06c FAIL: 'dir2/' missing from CommonPrefixes");
|
||||
|
||||
info!("RT-06c PASS: delimiter and CommonPrefixes correct");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// RT-06d: Verify listing returns correct IsTruncated flag.
|
||||
///
|
||||
/// Regression pattern: IsTruncated=false when there are more objects
|
||||
/// (rustfs#4810: walk_dir timeout truncation with false IsTruncated).
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_list_objects_v2_is_truncated_correctness() -> TestResult {
|
||||
init_logging();
|
||||
info!("RT-06d: IsTruncated correctness");
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await.expect("create test environment");
|
||||
env.start_rustfs_server_with_env(vec![], &[("RUSTFS_CONSOLE_ENABLE", "false")])
|
||||
.await
|
||||
.expect("start RustFS");
|
||||
|
||||
let client = env.create_s3_client();
|
||||
let bucket = "rt06d-truncated";
|
||||
|
||||
client.create_bucket().bucket(bucket).send().await.expect("create bucket");
|
||||
|
||||
// Upload 15 objects
|
||||
for i in 0..15 {
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(format!("item-{i:04}.txt"))
|
||||
.body(ByteStream::from_static(b"data"))
|
||||
.send()
|
||||
.await
|
||||
.expect("put object");
|
||||
}
|
||||
|
||||
// List with max_keys=5 — should be truncated
|
||||
let resp = client
|
||||
.list_objects_v2()
|
||||
.bucket(bucket)
|
||||
.max_keys(5)
|
||||
.send()
|
||||
.await
|
||||
.expect("list with max_keys=5");
|
||||
|
||||
assert!(
|
||||
resp.is_truncated().unwrap_or(false),
|
||||
"RT-06d FAIL: IsTruncated should be true with 15 objects and max_keys=5"
|
||||
);
|
||||
assert_eq!(resp.contents().len(), 5, "RT-06d FAIL: expected 5 objects in first page");
|
||||
assert!(
|
||||
resp.next_continuation_token().is_some(),
|
||||
"RT-06d FAIL: NextContinuationToken should be present when truncated"
|
||||
);
|
||||
|
||||
// List with max_keys=100 — should NOT be truncated
|
||||
let resp = client
|
||||
.list_objects_v2()
|
||||
.bucket(bucket)
|
||||
.max_keys(100)
|
||||
.send()
|
||||
.await
|
||||
.expect("list with max_keys=100");
|
||||
|
||||
assert!(
|
||||
!resp.is_truncated().unwrap_or(false),
|
||||
"RT-06d FAIL: IsTruncated should be false with 15 objects and max_keys=100"
|
||||
);
|
||||
assert_eq!(resp.contents().len(), 15, "RT-06d FAIL: expected 15 objects with max_keys=100");
|
||||
|
||||
info!("RT-06d PASS: IsTruncated flag is correct");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -62,6 +62,33 @@ fn md5_hex(input: impl AsRef<[u8]>) -> String {
|
||||
hex::encode(hasher.finalize())
|
||||
}
|
||||
|
||||
async fn create_restricted_user(
|
||||
env: &RustFSTestEnvironment,
|
||||
username: &str,
|
||||
secret_key: &str,
|
||||
) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
let url = format!("{}/rustfs/admin/v3/add-user?accessKey={username}", env.url);
|
||||
let body = serde_json::json!({
|
||||
"secretKey": secret_key,
|
||||
"status": "enabled"
|
||||
})
|
||||
.to_string();
|
||||
crate::common::awscurl_put(&url, &body, &env.access_key, &env.secret_key).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn restricted_user_client(env: &RustFSTestEnvironment, username: &str, secret_key: &str) -> aws_sdk_s3::Client {
|
||||
let credentials = aws_sdk_s3::config::Credentials::new(username, secret_key, None, None, "snowball-pax-auth-test");
|
||||
let config = aws_sdk_s3::Config::builder()
|
||||
.credentials_provider(credentials)
|
||||
.region(aws_sdk_s3::config::Region::new("us-east-1"))
|
||||
.endpoint_url(&env.url)
|
||||
.force_path_style(true)
|
||||
.behavior_version_latest()
|
||||
.build();
|
||||
aws_sdk_s3::Client::from_conf(config)
|
||||
}
|
||||
|
||||
/// Env var consumed by the local SSE-S3 DEK provider when KMS is not configured.
|
||||
///
|
||||
/// Since rustfs#3564 the server fails closed on managed SSE (SSE-S3 or
|
||||
@@ -3557,8 +3584,8 @@ async fn test_anonymous_post_object_rejects_expires_field_missing_from_policy_co
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_anonymous_post_object_accepts_object_lock_retention_fields() -> Result<(), Box<dyn std::error::Error + Send + Sync>>
|
||||
{
|
||||
async fn test_anonymous_post_object_rejects_object_lock_retention_without_permission()
|
||||
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
@@ -3567,8 +3594,6 @@ async fn test_anonymous_post_object_accepts_object_lock_retention_fields() -> Re
|
||||
let bucket = "anon-post-policy-object-lock-retention";
|
||||
let object_key = "uploads/object-lock-retention.txt";
|
||||
let retain_until = "2037-10-21T07:28:00Z";
|
||||
let expected_body = b"post-policy-object-lock-retention-body".to_vec();
|
||||
|
||||
let admin_client = env.create_s3_client();
|
||||
admin_client
|
||||
.create_bucket()
|
||||
@@ -3593,7 +3618,7 @@ async fn test_anonymous_post_object_accepts_object_lock_retention_fields() -> Re
|
||||
.text("x-amz-object-lock-retain-until-date", retain_until)
|
||||
.part(
|
||||
"file",
|
||||
reqwest::multipart::Part::bytes(expected_body.clone())
|
||||
reqwest::multipart::Part::bytes(b"post-policy-object-lock-retention-body".to_vec())
|
||||
.file_name("upload.txt")
|
||||
.mime_str("text/plain")?,
|
||||
);
|
||||
@@ -3607,26 +3632,8 @@ async fn test_anonymous_post_object_accepts_object_lock_retention_fields() -> Re
|
||||
let status = post_resp.status();
|
||||
let response_body = post_resp.text().await?;
|
||||
|
||||
assert_eq!(status, reqwest::StatusCode::NO_CONTENT);
|
||||
assert!(response_body.is_empty(), "204 response should not contain a body, got: {response_body}");
|
||||
|
||||
let retention = admin_client
|
||||
.get_object_retention()
|
||||
.bucket(bucket)
|
||||
.key(object_key)
|
||||
.send()
|
||||
.await?;
|
||||
let retention = retention.retention().expect("retention should be present");
|
||||
assert_eq!(retention.mode().map(|value| value.as_str()), Some("GOVERNANCE"));
|
||||
let retain_until_out = retention
|
||||
.retain_until_date()
|
||||
.expect("retain_until_date should be present")
|
||||
.fmt(aws_sdk_s3::primitives::DateTimeFormat::DateTime)?;
|
||||
assert_eq!(retain_until_out, retain_until);
|
||||
|
||||
let get_out = admin_client.get_object().bucket(bucket).key(object_key).send().await?;
|
||||
let uploaded = get_out.body.collect().await?.into_bytes();
|
||||
assert_eq!(uploaded.as_ref(), expected_body.as_slice());
|
||||
assert_eq!(status, reqwest::StatusCode::FORBIDDEN);
|
||||
assert!(response_body.contains("AccessDenied"));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -3815,8 +3822,8 @@ async fn test_anonymous_post_object_rejects_object_lock_retention_missing_from_p
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_anonymous_post_object_accepts_object_lock_legal_hold_field() -> Result<(), Box<dyn std::error::Error + Send + Sync>>
|
||||
{
|
||||
async fn test_anonymous_post_object_rejects_object_lock_legal_hold_without_permission()
|
||||
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
@@ -3824,8 +3831,6 @@ async fn test_anonymous_post_object_accepts_object_lock_legal_hold_field() -> Re
|
||||
|
||||
let bucket = "anon-post-policy-object-lock-legal-hold";
|
||||
let object_key = "uploads/object-lock-legal-hold.txt";
|
||||
let expected_body = b"post-policy-object-lock-legal-hold-body".to_vec();
|
||||
|
||||
let admin_client = env.create_s3_client();
|
||||
admin_client
|
||||
.create_bucket()
|
||||
@@ -3848,7 +3853,7 @@ async fn test_anonymous_post_object_accepts_object_lock_legal_hold_field() -> Re
|
||||
.text("x-amz-object-lock-legal-hold", "ON")
|
||||
.part(
|
||||
"file",
|
||||
reqwest::multipart::Part::bytes(expected_body.clone())
|
||||
reqwest::multipart::Part::bytes(b"post-policy-object-lock-legal-hold-body".to_vec())
|
||||
.file_name("upload.txt")
|
||||
.mime_str("text/plain")?,
|
||||
);
|
||||
@@ -3862,26 +3867,8 @@ async fn test_anonymous_post_object_accepts_object_lock_legal_hold_field() -> Re
|
||||
let status = post_resp.status();
|
||||
let response_body = post_resp.text().await?;
|
||||
|
||||
assert_eq!(status, reqwest::StatusCode::NO_CONTENT);
|
||||
assert!(response_body.is_empty(), "204 response should not contain a body, got: {response_body}");
|
||||
|
||||
let legal_hold = admin_client
|
||||
.get_object_legal_hold()
|
||||
.bucket(bucket)
|
||||
.key(object_key)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
legal_hold
|
||||
.legal_hold()
|
||||
.and_then(|value| value.status())
|
||||
.map(|value| value.as_str()),
|
||||
Some("ON")
|
||||
);
|
||||
|
||||
let get_out = admin_client.get_object().bucket(bucket).key(object_key).send().await?;
|
||||
let uploaded = get_out.body.collect().await?.into_bytes();
|
||||
assert_eq!(uploaded.as_ref(), expected_body.as_slice());
|
||||
assert_eq!(status, reqwest::StatusCode::FORBIDDEN);
|
||||
assert!(response_body.contains("AccessDenied"));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -5658,6 +5645,70 @@ async fn test_signed_put_object_extract_preserves_object_lock_retention() -> Res
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_signed_put_object_extract_pax_retention_overrides_request_retention()
|
||||
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(vec![]).await?;
|
||||
|
||||
let bucket = "signed-extract-pax-retention-precedence";
|
||||
let archive_key = "retention.tar";
|
||||
let extracted_key = "alpha.txt";
|
||||
let request_retain_until = aws_sdk_s3::primitives::DateTime::from_secs(2_114_380_800);
|
||||
let pax_retain_until = "2040-01-01T00:00:00Z";
|
||||
|
||||
let client = env.create_s3_client();
|
||||
client
|
||||
.create_bucket()
|
||||
.bucket(bucket)
|
||||
.object_lock_enabled_for_bucket(true)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let pax = HashMap::from([
|
||||
("minio.metadata.x-amz-object-lock-mode", "COMPLIANCE".to_string()),
|
||||
("minio.metadata.x-amz-object-lock-retain-until-date", pax_retain_until.to_string()),
|
||||
]);
|
||||
let archive = make_tar_with_pax_entry(extracted_key, b"alpha-body", None, &pax).await;
|
||||
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(archive_key)
|
||||
.object_lock_mode(aws_sdk_s3::types::ObjectLockMode::Governance)
|
||||
.object_lock_retain_until_date(request_retain_until)
|
||||
.body(ByteStream::from(archive))
|
||||
.customize()
|
||||
.mutate_request(|req| {
|
||||
req.headers_mut().insert("x-amz-meta-snowball-auto-extract", "true");
|
||||
})
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let retention = client
|
||||
.get_object_retention()
|
||||
.bucket(bucket)
|
||||
.key(extracted_key)
|
||||
.send()
|
||||
.await?
|
||||
.retention()
|
||||
.expect("retention should be present")
|
||||
.clone();
|
||||
assert_eq!(retention.mode().map(|value| value.as_str()), Some("COMPLIANCE"));
|
||||
assert_eq!(
|
||||
retention
|
||||
.retain_until_date()
|
||||
.expect("retain_until_date should be present")
|
||||
.fmt(aws_sdk_s3::primitives::DateTimeFormat::DateTime)?,
|
||||
pax_retain_until
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_signed_put_object_extract_returns_archive_etag() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
@@ -5782,6 +5833,282 @@ async fn test_signed_put_object_extract_preserves_pax_metadata_and_version_id()
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_signed_put_object_extract_authorizes_each_pax_privilege_and_retention_conditions()
|
||||
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
if !crate::common::awscurl_available() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(vec![]).await?;
|
||||
|
||||
let bucket = "signed-extract-pax-auth";
|
||||
let put_only_user = "snowball-put-only";
|
||||
let put_only_secret = "snowball-put-only-secret";
|
||||
let conditional_user = "snowball-retention-condition";
|
||||
let conditional_secret = "snowball-retention-condition-secret";
|
||||
let wrong_action_user = "snowball-wrong-action";
|
||||
let wrong_action_secret = "snowball-wrong-action-secret";
|
||||
let version_condition_user = "snowball-version-condition";
|
||||
let version_condition_secret = "snowball-version-condition-secret";
|
||||
let pax_context_user = "snowball-pax-context";
|
||||
let pax_context_secret = "snowball-pax-context-secret";
|
||||
let conditional_version_id = Uuid::new_v4().to_string();
|
||||
let admin_client = env.create_s3_client();
|
||||
admin_client
|
||||
.create_bucket()
|
||||
.bucket(bucket)
|
||||
.object_lock_enabled_for_bucket(true)
|
||||
.send()
|
||||
.await?;
|
||||
create_restricted_user(&env, put_only_user, put_only_secret).await?;
|
||||
create_restricted_user(&env, conditional_user, conditional_secret).await?;
|
||||
create_restricted_user(&env, wrong_action_user, wrong_action_secret).await?;
|
||||
create_restricted_user(&env, version_condition_user, version_condition_secret).await?;
|
||||
create_restricted_user(&env, pax_context_user, pax_context_secret).await?;
|
||||
|
||||
let object_resource = format!("arn:aws:s3:::{bucket}/*");
|
||||
let context_archive_resources = [
|
||||
format!("arn:aws:s3:::{bucket}/tag-context.tar"),
|
||||
format!("arn:aws:s3:::{bucket}/lock-context.tar"),
|
||||
];
|
||||
let tag_entry_resource = format!("arn:aws:s3:::{bucket}/tag-context-entry.txt");
|
||||
let lock_entry_resource = format!("arn:aws:s3:::{bucket}/lock-context-entry.txt");
|
||||
let policy = serde_json::json!({
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Sid": "PutOnly",
|
||||
"Effect": "Allow",
|
||||
"Principal": { "AWS": [put_only_user] },
|
||||
"Action": ["s3:PutObject"],
|
||||
"Resource": [object_resource.clone()]
|
||||
},
|
||||
{
|
||||
"Sid": "RetentionWithLimit",
|
||||
"Effect": "Allow",
|
||||
"Principal": { "AWS": [conditional_user] },
|
||||
"Action": ["s3:PutObject", "s3:PutObjectRetention"],
|
||||
"Resource": [object_resource.clone()]
|
||||
},
|
||||
{
|
||||
"Sid": "DenyRetentionBeyondCutoff",
|
||||
"Effect": "Deny",
|
||||
"Principal": { "AWS": [conditional_user] },
|
||||
"Action": ["s3:PutObject"],
|
||||
"Resource": [object_resource.clone()],
|
||||
"Condition": {
|
||||
"DateGreaterThan": {
|
||||
"s3:object-lock-retain-until-date": "2030-01-01T00:00:00Z"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Sid": "WrongAdditionalAction",
|
||||
"Effect": "Allow",
|
||||
"Principal": { "AWS": [wrong_action_user] },
|
||||
"Action": ["s3:PutObject", "s3:PutObjectLegalHold"],
|
||||
"Resource": [object_resource.clone()]
|
||||
},
|
||||
{
|
||||
"Sid": "VersionConditionPut",
|
||||
"Effect": "Allow",
|
||||
"Principal": { "AWS": [version_condition_user] },
|
||||
"Action": ["s3:PutObject"],
|
||||
"Resource": [object_resource.clone()]
|
||||
},
|
||||
{
|
||||
"Sid": "VersionConditionReplicate",
|
||||
"Effect": "Allow",
|
||||
"Principal": { "AWS": [version_condition_user] },
|
||||
"Action": ["s3:ReplicateObject"],
|
||||
"Resource": [object_resource],
|
||||
"Condition": {
|
||||
"StringEquals": {
|
||||
"s3:VersionId": conditional_version_id.clone()
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Sid": "PaxContextArchives",
|
||||
"Effect": "Allow",
|
||||
"Principal": { "AWS": [pax_context_user] },
|
||||
"Action": ["s3:PutObject", "s3:PutObjectRetention", "s3:PutObjectTagging"],
|
||||
"Resource": context_archive_resources
|
||||
},
|
||||
{
|
||||
"Sid": "PaxTagContextPut",
|
||||
"Effect": "Allow",
|
||||
"Principal": { "AWS": [pax_context_user] },
|
||||
"Action": ["s3:PutObject"],
|
||||
"Resource": [tag_entry_resource.clone()],
|
||||
"Condition": {
|
||||
"StringEquals": {
|
||||
"s3:RequestObjectTag/classification": "public"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Sid": "PaxTagContextAction",
|
||||
"Effect": "Allow",
|
||||
"Principal": { "AWS": [pax_context_user] },
|
||||
"Action": ["s3:PutObjectTagging"],
|
||||
"Resource": [tag_entry_resource]
|
||||
},
|
||||
{
|
||||
"Sid": "PaxLockContextPut",
|
||||
"Effect": "Allow",
|
||||
"Principal": { "AWS": [pax_context_user] },
|
||||
"Action": ["s3:PutObject"],
|
||||
"Resource": [lock_entry_resource.clone()],
|
||||
"Condition": {
|
||||
"StringEquals": {
|
||||
"s3:object-lock-mode": "COMPLIANCE"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Sid": "PaxLockContextAction",
|
||||
"Effect": "Allow",
|
||||
"Principal": { "AWS": [pax_context_user] },
|
||||
"Action": ["s3:PutObjectRetention"],
|
||||
"Resource": [lock_entry_resource]
|
||||
}
|
||||
]
|
||||
})
|
||||
.to_string();
|
||||
admin_client.put_bucket_policy().bucket(bucket).policy(policy).send().await?;
|
||||
|
||||
let put_only_client = restricted_user_client(&env, put_only_user, put_only_secret);
|
||||
let conditional_client = restricted_user_client(&env, conditional_user, conditional_secret);
|
||||
let wrong_action_client = restricted_user_client(&env, wrong_action_user, wrong_action_secret);
|
||||
let cases = [
|
||||
(
|
||||
"legal-hold.tar",
|
||||
put_only_client,
|
||||
HashMap::from([("minio.metadata.x-amz-object-lock-legal-hold", "ON".to_string())]),
|
||||
),
|
||||
(
|
||||
"retention-condition.tar",
|
||||
conditional_client,
|
||||
HashMap::from([
|
||||
("minio.metadata.x-amz-object-lock-mode", "COMPLIANCE".to_string()),
|
||||
("minio.metadata.x-amz-object-lock-retain-until-date", "2099-01-01T00:00:00Z".to_string()),
|
||||
]),
|
||||
),
|
||||
(
|
||||
"version-id.tar",
|
||||
wrong_action_client,
|
||||
HashMap::from([("minio.versionId", Uuid::new_v4().to_string())]),
|
||||
),
|
||||
];
|
||||
|
||||
for (archive_key, client, pax) in cases {
|
||||
let archive = make_tar_with_pax_entry("entry.txt", b"must-not-write", None, &pax).await;
|
||||
let err = client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(archive_key)
|
||||
.body(ByteStream::from(archive))
|
||||
.customize()
|
||||
.mutate_request(|req| {
|
||||
req.headers_mut().insert("x-amz-meta-snowball-auto-extract", "true");
|
||||
})
|
||||
.send()
|
||||
.await
|
||||
.expect_err("missing, conditional, or wrong PAX privilege must be rejected");
|
||||
assert_eq!(
|
||||
err.as_service_error().and_then(|error| error.meta().code()),
|
||||
Some("AccessDenied"),
|
||||
"{archive_key}"
|
||||
);
|
||||
}
|
||||
|
||||
let version_condition_client = restricted_user_client(&env, version_condition_user, version_condition_secret);
|
||||
let matching_version_pax = HashMap::from([("minio.versionId", conditional_version_id)]);
|
||||
let archive = make_tar_with_pax_entry("condition-entry.txt", b"condition-body", None, &matching_version_pax).await;
|
||||
version_condition_client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key("version-condition.tar")
|
||||
.body(ByteStream::from(archive))
|
||||
.customize()
|
||||
.mutate_request(|req| {
|
||||
req.headers_mut().insert("x-amz-meta-snowball-auto-extract", "true");
|
||||
})
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let pax_context_client = restricted_user_client(&env, pax_context_user, pax_context_secret);
|
||||
let tag_pax = HashMap::from([("minio.metadata.x-amz-tagging", "classification=public".to_string())]);
|
||||
let archive = make_tar_with_pax_entry("tag-context-entry.txt", b"tag-context-body", None, &tag_pax).await;
|
||||
pax_context_client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key("tag-context.tar")
|
||||
.tagging("classification=restricted")
|
||||
.body(ByteStream::from(archive))
|
||||
.customize()
|
||||
.mutate_request(|req| {
|
||||
req.headers_mut().insert("x-amz-meta-snowball-auto-extract", "true");
|
||||
})
|
||||
.send()
|
||||
.await?;
|
||||
let tags = admin_client
|
||||
.get_object_tagging()
|
||||
.bucket(bucket)
|
||||
.key("tag-context-entry.txt")
|
||||
.send()
|
||||
.await?;
|
||||
assert!(
|
||||
tags.tag_set()
|
||||
.iter()
|
||||
.any(|tag| tag.key() == "classification" && tag.value() == "public")
|
||||
);
|
||||
|
||||
let pax_retain_until = "2040-01-01T00:00:00Z";
|
||||
let lock_pax = HashMap::from([
|
||||
("minio.metadata.x-amz-object-lock-mode", "COMPLIANCE".to_string()),
|
||||
("minio.metadata.x-amz-object-lock-retain-until-date", pax_retain_until.to_string()),
|
||||
]);
|
||||
let archive = make_tar_with_pax_entry("lock-context-entry.txt", b"lock-context-body", None, &lock_pax).await;
|
||||
pax_context_client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key("lock-context.tar")
|
||||
.object_lock_mode(aws_sdk_s3::types::ObjectLockMode::Governance)
|
||||
.object_lock_retain_until_date(aws_sdk_s3::primitives::DateTime::from_secs(2_114_380_800))
|
||||
.body(ByteStream::from(archive))
|
||||
.customize()
|
||||
.mutate_request(|req| {
|
||||
req.headers_mut().insert("x-amz-meta-snowball-auto-extract", "true");
|
||||
})
|
||||
.send()
|
||||
.await?;
|
||||
let retention = admin_client
|
||||
.get_object_retention()
|
||||
.bucket(bucket)
|
||||
.key("lock-context-entry.txt")
|
||||
.send()
|
||||
.await?
|
||||
.retention()
|
||||
.expect("PAX retention should be present")
|
||||
.clone();
|
||||
assert_eq!(retention.mode().map(|mode| mode.as_str()), Some("COMPLIANCE"));
|
||||
assert_eq!(
|
||||
retention
|
||||
.retain_until_date()
|
||||
.expect("PAX retain-until should be present")
|
||||
.fmt(aws_sdk_s3::primitives::DateTimeFormat::DateTime)?,
|
||||
pax_retain_until
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_signed_put_object_extract_accepts_compat_header() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Regression tests for the event notification startup race.
|
||||
//!
|
||||
//! Covers the recurring pattern where webhook/audit targets fail to load at boot
|
||||
//! due to startup ordering (notification runtime starts before server config is
|
||||
//! loaded). This has regressed 9+ times across beta.3 ~ beta.12.
|
||||
//!
|
||||
//! ## Regression Issues
|
||||
//!
|
||||
//! - rustfs#5387: webhook notifications broken again in beta.9+
|
||||
//! - rustfs#5681: Audit webhook targets are not loaded at boot
|
||||
//! - rustfs#5401: Event Destinations broken again
|
||||
//! - rustfs#5183: Audit webhooks stay offline after restart
|
||||
//! - rustfs#5115: init_event_notifier loses startup race against server config load
|
||||
//! - rustfs#4796: Pulsar event destinations offline after restart
|
||||
//! - rustfs#5428: MQTT bucket notifications stop on restarted cluster node
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::common::{RustFSTestEnvironment, init_logging};
|
||||
use aws_sdk_s3::types::{BucketVersioningStatus, VersioningConfiguration};
|
||||
use serial_test::serial;
|
||||
use std::error::Error;
|
||||
use tracing::info;
|
||||
|
||||
type TestResult = Result<(), Box<dyn Error + Send + Sync>>;
|
||||
|
||||
/// RT-01: Verify that the notification runtime initializes correctly at boot.
|
||||
///
|
||||
/// Regression pattern: notification runtime initializes before server config
|
||||
/// is fully loaded, causing webhook targets to never come online.
|
||||
///
|
||||
/// This test verifies the startup ordering by checking that the server
|
||||
/// starts successfully with notification enabled and can serve S3 requests.
|
||||
/// A full webhook delivery test is in notification_webhook_test.rs.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_notification_enabled_server_starts_cleanly() -> TestResult {
|
||||
init_logging();
|
||||
info!("RT-01: notification enabled server starts cleanly");
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await.expect("create test environment");
|
||||
env.start_rustfs_server_with_env(vec![], &[("RUSTFS_CONSOLE_ENABLE", "false"), ("RUSTFS_NOTIFY_ENABLE", "true")])
|
||||
.await
|
||||
.expect("start RustFS with notifications enabled");
|
||||
|
||||
let client = env.create_s3_client();
|
||||
let bucket = "rt01-notify-startup";
|
||||
|
||||
// Server should be healthy and able to serve S3 requests
|
||||
client
|
||||
.create_bucket()
|
||||
.bucket(bucket)
|
||||
.send()
|
||||
.await
|
||||
.expect("create bucket with notifications enabled");
|
||||
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key("test.txt")
|
||||
.body(aws_sdk_s3::primitives::ByteStream::from_static(b"test"))
|
||||
.send()
|
||||
.await
|
||||
.expect("put object with notifications enabled");
|
||||
|
||||
info!("RT-01 PASS: notification enabled server starts and serves S3");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// RT-02: Verify notification config persists after server restart.
|
||||
///
|
||||
/// Regression pattern: after a node restart, notification targets stay
|
||||
/// offline permanently because the config is not re-loaded.
|
||||
///
|
||||
/// Steps:
|
||||
/// 1. Start server with notification enabled
|
||||
/// 2. Create bucket and configure notification
|
||||
/// 3. Restart server
|
||||
/// 4. Verify notification config still exists
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_notification_config_survives_restart() -> TestResult {
|
||||
init_logging();
|
||||
info!("RT-02: notification config survives restart");
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await.expect("create test environment");
|
||||
env.start_rustfs_server_with_env(vec![], &[("RUSTFS_CONSOLE_ENABLE", "false"), ("RUSTFS_NOTIFY_ENABLE", "true")])
|
||||
.await
|
||||
.expect("start RustFS");
|
||||
|
||||
let client = env.create_s3_client();
|
||||
let bucket = "rt02-notify-restart";
|
||||
|
||||
client.create_bucket().bucket(bucket).send().await.expect("create bucket");
|
||||
|
||||
// Enable versioning (required for notification configuration)
|
||||
client
|
||||
.put_bucket_versioning()
|
||||
.bucket(bucket)
|
||||
.versioning_configuration(
|
||||
VersioningConfiguration::builder()
|
||||
.status(BucketVersioningStatus::Enabled)
|
||||
.build(),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.expect("enable versioning");
|
||||
|
||||
// Note: We can't fully test notification config persistence without a
|
||||
// configured target. But we verify the server restarts cleanly with
|
||||
// notification enabled, which is the core regression scenario.
|
||||
env.restart_server_preserving_data(vec![], &[])
|
||||
.await
|
||||
.expect("restart RustFS with notifications enabled");
|
||||
|
||||
// Verify bucket still exists and is accessible after restart
|
||||
let list = client
|
||||
.list_objects_v2()
|
||||
.bucket(bucket)
|
||||
.send()
|
||||
.await
|
||||
.expect("list objects after restart");
|
||||
|
||||
assert!(list.contents().is_empty(), "RT-02: bucket should be empty after restart");
|
||||
|
||||
// Verify we can still write objects (notification runtime initialized)
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key("after-restart.txt")
|
||||
.body(aws_sdk_s3::primitives::ByteStream::from_static(b"post-restart"))
|
||||
.send()
|
||||
.await
|
||||
.expect("put object after restart — notification runtime must be initialized");
|
||||
|
||||
info!("RT-02 PASS: server with notifications survives restart");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,7 @@
|
||||
//! * PUT / multipart-complete / DeleteObject / DeleteObjects each deliver one event with the correct
|
||||
//! eventName, bucket, key, versionId and eTag.
|
||||
//! * prefix/suffix filters drop non-matching keys (rule-engine gate).
|
||||
//! * an event queued while the target endpoint is unreachable is redelivered
|
||||
//! * an event queued while the target endpoint rejects delivery is redelivered
|
||||
//! from the on-disk store once the endpoint recovers (store-and-forward).
|
||||
//! * responseElements and the S3 response use the canonical request ID while
|
||||
//! requestParameters preserve a conflicting client-supplied value.
|
||||
@@ -897,11 +897,10 @@ async fn test_webhook_event_delivery_and_filtering() -> TestResult {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// An event queued while the target endpoint is unreachable survives on the
|
||||
/// An event queued while the target endpoint rejects delivery survives on the
|
||||
/// durable store and is redelivered once the endpoint comes back.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
#[ignore = "FAILING deterministically on main since it landed (#4821): the target is created but never appears in /rustfs/admin/v3/target/arns, so wait_for_target_registered times out. Quarantined per the flake policy; remove with the fix for rustfs#4852"]
|
||||
async fn test_webhook_redelivers_event_after_target_recovers() -> TestResult {
|
||||
init_logging();
|
||||
|
||||
@@ -932,28 +931,55 @@ async fn test_webhook_redelivers_event_after_target_recovers() -> TestResult {
|
||||
wait_for_target_registered(&env, target).await?;
|
||||
put_notification_config(&client, bucket, target, "uploads/", ".dat").await?;
|
||||
|
||||
// Take the endpoint down (drops the listener, so connections are refused —
|
||||
// a retryable NotConnected), then PUT: the event cannot be delivered and
|
||||
// must survive on the durable queue store.
|
||||
// Replace the healthy setup listener with one that rejects the first POST.
|
||||
// Waiting for that response below proves the queued event reached a failed
|
||||
// delivery attempt before the endpoint recovers.
|
||||
setup_handle.abort();
|
||||
let _ = setup_handle.await;
|
||||
|
||||
let listener = TcpListener::bind(("0.0.0.0", port)).await?;
|
||||
let key = "uploads/redeliver.dat";
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.body(ByteStream::from_static(b"queued while target down"))
|
||||
.body(ByteStream::from_static(b"queued while target rejects"))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
// Hold the endpoint down long enough for at least one replay attempt to
|
||||
// fail (the replay worker scans the store every 500ms), so recovery below
|
||||
// exercises real redelivery rather than a first-attempt success.
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
let mut failure_handle = tokio::spawn(async move {
|
||||
loop {
|
||||
let (mut stream, _) = listener.accept().await?;
|
||||
let (method, _) = timeout(Duration::from_secs(5), read_http_message(&mut stream)).await??;
|
||||
if method == "HEAD" {
|
||||
stream
|
||||
.write_all(b"HTTP/1.1 200 OK\r\ncontent-length: 0\r\nconnection: close\r\n\r\n")
|
||||
.await?;
|
||||
stream.shutdown().await?;
|
||||
continue;
|
||||
}
|
||||
if method == "POST" {
|
||||
stream
|
||||
.write_all(b"HTTP/1.1 503 Service Unavailable\r\ncontent-length: 0\r\nconnection: close\r\n\r\n")
|
||||
.await?;
|
||||
stream.shutdown().await?;
|
||||
return Ok::<(), BoxError>(());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Bring the endpoint back on the same port; the replay worker retries with
|
||||
// exponential backoff and delivers the queued event.
|
||||
let rejected = match timeout(Duration::from_secs(20), &mut failure_handle).await {
|
||||
Ok(rejected) => rejected,
|
||||
Err(_) => {
|
||||
failure_handle.abort();
|
||||
let _ = failure_handle.await;
|
||||
return Err("webhook replay did not reach the rejecting endpoint".into());
|
||||
}
|
||||
};
|
||||
rejected??;
|
||||
|
||||
// Bring the endpoint back on the same port; the replay worker rescans the
|
||||
// durable queue and delivers the retained event.
|
||||
let listener = TcpListener::bind(("0.0.0.0", port)).await?;
|
||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||
let handle = serve_event_collector(listener, tx);
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Regression tests for Tier/ILM transition operations.
|
||||
//!
|
||||
//! Covers the recurring pattern where tier transition fails silently, the
|
||||
//! free-version recovery task loops forever, or transitioned objects cannot
|
||||
//! be read back. This has regressed 6+ times.
|
||||
//!
|
||||
//! ## Regression Issues
|
||||
//!
|
||||
//! - rustfs#5218: Remote tier mutation commit failed
|
||||
//! - rustfs#5130: tier_free_version_recovery task loops forever
|
||||
//! - rustfs#5011: Idle tier free-version recovery rescans every 60 seconds
|
||||
//! - rustfs#4826: Full GET of multipart transitioned object fails
|
||||
//! - rustfs#5024: Some files succeeded in tier offloading, others failed
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::common::{RustFSTestEnvironment, admin_ok, init_logging};
|
||||
use serde_json::Value;
|
||||
use serial_test::serial;
|
||||
use std::error::Error;
|
||||
use tracing::info;
|
||||
|
||||
type TestResult = Result<(), Box<dyn Error + Send + Sync>>;
|
||||
|
||||
/// RT-13: Verify lifecycle rule with transition persists and is retrievable.
|
||||
///
|
||||
/// Note: Actual transition requires a configured remote tier. This test
|
||||
/// validates that an expiration-only rule (the persistence path) survives
|
||||
/// a server restart.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_lifecycle_rule_persists_after_restart() -> TestResult {
|
||||
init_logging();
|
||||
info!("RT-13: lifecycle rule persists after restart");
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await.expect("create test environment");
|
||||
env.start_rustfs_server_with_env(vec![], &[("RUSTFS_CONSOLE_ENABLE", "false")])
|
||||
.await
|
||||
.expect("start RustFS");
|
||||
|
||||
let client = env.create_s3_client();
|
||||
let bucket = "rt13-tier-persist";
|
||||
|
||||
client.create_bucket().bucket(bucket).send().await.expect("create bucket");
|
||||
|
||||
// Apply a lifecycle rule with expiration (transition needs a real tier)
|
||||
let rule = aws_sdk_s3::types::LifecycleRule::builder()
|
||||
.id("expire-after-90d")
|
||||
.status(aws_sdk_s3::types::ExpirationStatus::Enabled)
|
||||
.filter(aws_sdk_s3::types::LifecycleRuleFilter::builder().prefix("archive/").build())
|
||||
.expiration(aws_sdk_s3::types::LifecycleExpiration::builder().days(90).build())
|
||||
.build()
|
||||
.expect("build rule");
|
||||
|
||||
client
|
||||
.put_bucket_lifecycle_configuration()
|
||||
.bucket(bucket)
|
||||
.lifecycle_configuration(
|
||||
aws_sdk_s3::types::BucketLifecycleConfiguration::builder()
|
||||
.rules(rule)
|
||||
.build()
|
||||
.expect("build config"),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.expect("put lifecycle");
|
||||
|
||||
// Restart server
|
||||
env.restart_server_preserving_data(vec![], &[]).await.expect("restart RustFS");
|
||||
|
||||
// Verify the rule survived restart
|
||||
let resp = client
|
||||
.get_bucket_lifecycle_configuration()
|
||||
.bucket(bucket)
|
||||
.send()
|
||||
.await
|
||||
.expect("get lifecycle after restart");
|
||||
|
||||
let rules = resp.rules();
|
||||
assert_eq!(rules.len(), 1, "RT-13 FAIL: expected 1 rule after restart");
|
||||
|
||||
let exp = rules[0].expiration().expect("expiration should be set");
|
||||
assert_eq!(exp.days(), Some(90), "RT-13 FAIL: expiration days corrupted after restart");
|
||||
|
||||
info!("RT-13 PASS: lifecycle rule persists after restart");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// RT-13b: Verify admin tier configuration API is functional.
|
||||
///
|
||||
/// Regression pattern: tier add/verify/delete API fails or the tier
|
||||
/// configuration is not persisted (rustfs#5218).
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_admin_tier_list_endpoint_returns_json() -> TestResult {
|
||||
init_logging();
|
||||
info!("RT-13b: admin tier list endpoint returns JSON");
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await.expect("create test environment");
|
||||
env.start_rustfs_server_with_env(vec![], &[("RUSTFS_CONSOLE_ENABLE", "false")])
|
||||
.await
|
||||
.expect("start RustFS");
|
||||
|
||||
// Query the tier list endpoint
|
||||
let body = admin_ok(&env, http::Method::GET, "/rustfs/admin/v3/tier", None)
|
||||
.await
|
||||
.expect("list remote tiers");
|
||||
|
||||
let json: Value = serde_json::from_str(&body).expect("tier list response should be valid JSON");
|
||||
|
||||
// Should return an array (possibly empty)
|
||||
assert!(json.is_array(), "RT-13b FAIL: tier list response is not an array: {json}");
|
||||
|
||||
info!("RT-13b PASS: admin tier list endpoint returns valid JSON array");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// RT-13c: Verify scanner configuration persistence.
|
||||
///
|
||||
/// Regression pattern: scanner admin config update reports success but
|
||||
/// is not persisted (rustfs#5013), causing the scanner to not run or
|
||||
/// use stale settings.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_scanner_config_persists_after_restart() -> TestResult {
|
||||
init_logging();
|
||||
info!("RT-13c: scanner config persists after restart");
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await.expect("create test environment");
|
||||
env.start_rustfs_server_with_env(vec![], &[("RUSTFS_CONSOLE_ENABLE", "false")])
|
||||
.await
|
||||
.expect("start RustFS");
|
||||
|
||||
// Get current scanner status
|
||||
let body = admin_ok(&env, http::Method::GET, "/rustfs/admin/v3/scanner/status", None)
|
||||
.await
|
||||
.expect("get scanner status");
|
||||
|
||||
let json: Value = serde_json::from_str(&body).expect("scanner status should be valid JSON");
|
||||
|
||||
info!(" scanner status: {:?}", json.as_object().map(|o| o.keys().collect::<Vec<_>>()));
|
||||
|
||||
// Restart and verify config is still accessible
|
||||
env.restart_server_preserving_data(vec![], &[]).await.expect("restart RustFS");
|
||||
|
||||
let body2 = admin_ok(&env, http::Method::GET, "/rustfs/admin/v3/scanner/status", None)
|
||||
.await
|
||||
.expect("get scanner status after restart");
|
||||
|
||||
let json2: Value = serde_json::from_str(&body2).expect("scanner status after restart should be valid JSON");
|
||||
|
||||
// Both should be valid JSON objects
|
||||
assert!(json2.is_object(), "RT-13c FAIL: scanner status after restart is not a valid JSON object");
|
||||
|
||||
info!("RT-13c PASS: scanner/config persists across restart");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -130,13 +130,15 @@ pub mod bucket {
|
||||
|
||||
pub mod metadata_sys {
|
||||
pub use crate::bucket::metadata_sys::{
|
||||
BucketMetadataSys, acquire_bucket_metadata_transaction_lock, delete, get, get_accelerate_config, get_bucket_policy,
|
||||
BucketMetadataMutationGuard, BucketMetadataSys, ObjectLockConfigState, acquire_bucket_metadata_transaction_lock,
|
||||
capture_bucket_metadata_incarnation, delete, delete_if_incarnation, get, get_accelerate_config, get_bucket_policy,
|
||||
get_bucket_policy_raw, get_bucket_targets_config, get_config_from_disk, get_cors_config, get_durability_config,
|
||||
get_global_bucket_metadata_sys, get_lifecycle_config, get_logging_config, get_notification_config,
|
||||
get_object_lock_config, get_public_access_block_config, get_quota_config, get_replication_config,
|
||||
get_request_payment_config, get_sse_config, get_tagging_config, get_versioning_config, get_website_config,
|
||||
init_bucket_metadata_sys, list_bucket_targets, reload_bucket_metadata, remove_bucket_metadata, set_bucket_metadata,
|
||||
update, update_bucket_targets_under_transaction_lock, update_config_with, update_under_transaction_lock,
|
||||
get_object_lock_config, get_object_lock_config_state, get_public_access_block_config, get_quota_config,
|
||||
get_replication_config, get_request_payment_config, get_sse_config, get_tagging_config, get_versioning_config,
|
||||
get_website_config, init_bucket_metadata_sys, list_bucket_targets, reload_bucket_metadata, remove_bucket_metadata,
|
||||
set_bucket_metadata, update, update_bucket_targets_under_transaction_lock, update_config_with, update_if_incarnation,
|
||||
update_under_transaction_lock,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -403,11 +405,11 @@ pub mod notification {
|
||||
pub mod object {
|
||||
pub use crate::object_api::{
|
||||
BLOCK_SIZE_V2, ERASURE_ALGORITHM, EncryptionResolutionError, EncryptionResolutionErrorKind, GetObjectBodyCacheHook,
|
||||
GetObjectBodyCacheHookLookup, GetObjectBodySource, GetObjectReader, ObjectEncryptionResolver, ObjectInfo,
|
||||
ObjectMutationHook, ObjectOptions, PutObjReader, RangedDecompressReader, ReadEncryptionMaterial, ReadEncryptionMode,
|
||||
ReadEncryptionRequest, StreamConsumer, get_object_body_cache_plaintext_len, lookup_get_object_body_cache_hook,
|
||||
register_get_object_body_cache_hook, register_object_mutation_hook, unregister_get_object_body_cache_hook,
|
||||
unregister_object_mutation_hook,
|
||||
GetObjectBodyCacheHookLookup, GetObjectBodySource, GetObjectReader, NamespaceLockFence, ObjectEncryptionResolver,
|
||||
ObjectInfo, ObjectLockConfigSnapshot, ObjectMutationHook, ObjectOptions, PutObjReader, RangedDecompressReader,
|
||||
ReadEncryptionMaterial, ReadEncryptionMode, ReadEncryptionRequest, StreamConsumer, get_object_body_cache_plaintext_len,
|
||||
lookup_get_object_body_cache_hook, register_get_object_body_cache_hook, register_object_mutation_hook,
|
||||
unregister_get_object_body_cache_hook, unregister_object_mutation_hook,
|
||||
};
|
||||
pub use crate::store::PreparedGetObjectReader;
|
||||
}
|
||||
|
||||
@@ -2000,7 +2000,7 @@ impl TargetClient {
|
||||
object: &str,
|
||||
version_id: Option<String>,
|
||||
opts: RemoveObjectOptions,
|
||||
) -> Result<(), S3ClientError> {
|
||||
) -> Result<Option<String>, S3ClientError> {
|
||||
let headers = build_remove_object_headers(version_id.as_deref(), &opts);
|
||||
let api_version_id = resolve_delete_api_version_id(version_id, &opts);
|
||||
|
||||
@@ -2023,7 +2023,11 @@ impl TargetClient {
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(_res) => Ok(()),
|
||||
// A DELETE without a version id on a versioned target creates a delete
|
||||
// marker and reports the version it assigned. That id is the only
|
||||
// reliable handle for purging the marker later: a generic S3 target
|
||||
// does not mirror source version ids.
|
||||
Ok(res) => Ok(res.version_id().map(ToOwned::to_owned)),
|
||||
Err(e) => match e {
|
||||
SdkError::ServiceError(service_err) => {
|
||||
let err = service_err.into_err();
|
||||
|
||||
@@ -42,6 +42,7 @@ use crate::bucket::lifecycle::tier_free_version_recovery::{
|
||||
use crate::bucket::lifecycle::tier_last_day_stats::{DailyAllTierStats, LastDayTierStats};
|
||||
use crate::bucket::lifecycle::tier_sweeper::{Jentry, delete_object_from_remote_tier_idempotent_with_manager_and_identity};
|
||||
use crate::bucket::lifecycle::transition_transaction::run_transition_transaction_recovery_loop;
|
||||
use crate::bucket::object_lock::ObjectLockApi;
|
||||
use crate::bucket::versioning::VersioningApi as _;
|
||||
use crate::bucket::versioning_sys::BucketVersioningSys;
|
||||
use crate::client::object_api_utils::new_getobjectreader;
|
||||
@@ -90,8 +91,8 @@ use rustfs_utils::{
|
||||
string::{parse_bool, strings_has_prefix_fold},
|
||||
};
|
||||
use s3s::dto::{
|
||||
BucketLifecycleConfiguration, DefaultRetention, ExpirationStatus, ObjectLockConfiguration, RestoreRequest,
|
||||
RestoreRequestType, RestoreStatus, Timestamp,
|
||||
BucketLifecycleConfiguration, ExpirationStatus, ObjectLockConfiguration, RestoreRequest, RestoreRequestType, RestoreStatus,
|
||||
Timestamp,
|
||||
};
|
||||
use s3s::header::{X_AMZ_RESTORE, X_AMZ_SERVER_SIDE_ENCRYPTION};
|
||||
use sha2::{Digest, Sha256};
|
||||
@@ -309,6 +310,7 @@ struct ExpiryTask {
|
||||
obj_info: ObjectInfo,
|
||||
event: lifecycle::Event,
|
||||
src: LcEventSrc,
|
||||
bucket_incarnation_id: Uuid,
|
||||
}
|
||||
|
||||
impl ExpiryOp for ExpiryTask {
|
||||
@@ -587,6 +589,7 @@ struct NewerNoncurrentTask {
|
||||
versions: Vec<ObjectToDelete>,
|
||||
event: lifecycle::Event,
|
||||
src: LcEventSrc,
|
||||
bucket_incarnation_id: Uuid,
|
||||
}
|
||||
|
||||
impl ExpiryOp for NewerNoncurrentTask {
|
||||
@@ -697,13 +700,20 @@ impl ExpiryState {
|
||||
queued
|
||||
}
|
||||
|
||||
pub fn enqueue_by_days(&mut self, oi: &ObjectInfo, event: &lifecycle::Event, src: &LcEventSrc) -> bool {
|
||||
pub fn enqueue_by_days(
|
||||
&mut self,
|
||||
oi: &ObjectInfo,
|
||||
event: &lifecycle::Event,
|
||||
src: &LcEventSrc,
|
||||
bucket_incarnation_id: Uuid,
|
||||
) -> bool {
|
||||
let trace = LifecycleExpiryTrace::for_object(oi, event, src, 1);
|
||||
trace.emit(EVENT_LIFECYCLE_EXPIRED_DETECTED, "detected", None);
|
||||
let task = ExpiryTask {
|
||||
obj_info: oi.clone(),
|
||||
event: event.clone(),
|
||||
src: src.clone(),
|
||||
bucket_incarnation_id,
|
||||
};
|
||||
let wrkr = self.get_worker_ch(task.op_hash());
|
||||
if wrkr.is_none() {
|
||||
@@ -730,6 +740,7 @@ impl ExpiryState {
|
||||
versions: Vec<ObjectToDelete>,
|
||||
lc_event: lifecycle::Event,
|
||||
src: &LcEventSrc,
|
||||
bucket_incarnation_id: Uuid,
|
||||
) -> bool {
|
||||
if versions.is_empty() {
|
||||
return true;
|
||||
@@ -743,6 +754,7 @@ impl ExpiryState {
|
||||
versions,
|
||||
event: lc_event.clone(),
|
||||
src: src.clone(),
|
||||
bucket_incarnation_id,
|
||||
};
|
||||
let wrkr = self.get_worker_ch(task.op_hash());
|
||||
if wrkr.is_none() {
|
||||
@@ -855,9 +867,23 @@ impl ExpiryState {
|
||||
let trace = LifecycleExpiryTrace::for_object(&v.obj_info, &v.event, &v.src, 1);
|
||||
trace.emit(EVENT_LIFECYCLE_DELETE_DISPATCHED, "delete_dispatched", None);
|
||||
let deleted = if !v.obj_info.transitioned_object.status.is_empty() {
|
||||
apply_expiry_on_transitioned_object(api.clone(), &v.obj_info, &v.event, &v.src).await
|
||||
apply_expiry_on_transitioned_object(
|
||||
api.clone(),
|
||||
&v.obj_info,
|
||||
&v.event,
|
||||
&v.src,
|
||||
v.bucket_incarnation_id,
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
apply_expiry_on_non_transitioned_objects(api.clone(), &v.obj_info, &v.event, &v.src).await
|
||||
apply_expiry_on_non_transitioned_objects(
|
||||
api.clone(),
|
||||
&v.obj_info,
|
||||
&v.event,
|
||||
&v.src,
|
||||
v.bucket_incarnation_id,
|
||||
)
|
||||
.await
|
||||
};
|
||||
if deleted {
|
||||
trace.emit(EVENT_LIFECYCLE_DELETE_COMPLETED, "delete_completed", None);
|
||||
@@ -875,7 +901,14 @@ impl ExpiryState {
|
||||
let version_count = u64::try_from(v.versions.len()).unwrap_or(u64::MAX);
|
||||
let trace = LifecycleExpiryTrace::for_batch(&v.bucket, &v.event, &v.src, version_count);
|
||||
trace.emit(EVENT_LIFECYCLE_DELETE_DISPATCHED, "delete_dispatched", None);
|
||||
crate::client::object_handlers_common::delete_object_versions(&api, &v.bucket, &v.versions, v.event.clone()).await;
|
||||
crate::client::object_handlers_common::delete_object_versions(
|
||||
&api,
|
||||
&v.bucket,
|
||||
&v.versions,
|
||||
v.event.clone(),
|
||||
v.bucket_incarnation_id,
|
||||
)
|
||||
.await;
|
||||
trace.emit(EVENT_LIFECYCLE_DELETE_COMPLETED, "delete_completed", None);
|
||||
}
|
||||
else if v.as_any().is::<Jentry>() {
|
||||
@@ -2886,7 +2919,7 @@ async fn read_stale_multipart_candidate(
|
||||
) {
|
||||
Ok(file_info) => (Some(file_info.metadata), file_info.mod_time),
|
||||
Err(err) => {
|
||||
warn!(
|
||||
debug!(
|
||||
event = EVENT_LIFECYCLE_STALE_MULTIPART_CLEANUP,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_LIFECYCLE,
|
||||
@@ -3265,10 +3298,27 @@ pub async fn enqueue_transition_immediate(oi: &ObjectInfo, src: LcEventSrc) {
|
||||
}
|
||||
|
||||
pub async fn enqueue_immediate_expiry(oi: &ObjectInfo, src: LcEventSrc) {
|
||||
let Some(lifecycle) = runtime_sources::bucket_lifecycle_config(&oi.bucket).await else {
|
||||
let Some(api) = runtime_sources::object_store_handle() else {
|
||||
return;
|
||||
};
|
||||
let Some(api) = runtime_sources::object_store_handle() else {
|
||||
let configs = match metadata_boundary::get_expiry_configs(&api, &oi.bucket).await {
|
||||
Ok(configs) => configs,
|
||||
Err(err) => {
|
||||
observe_lifecycle_observability_event(EVENT_LIFECYCLE_EVALUATION_FAILED, "failed", Some("metadata_unavailable"));
|
||||
warn!(
|
||||
event = EVENT_LIFECYCLE_EVALUATION_FAILED,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_LIFECYCLE,
|
||||
bucket = %oi.bucket,
|
||||
object = %oi.name,
|
||||
error = %err,
|
||||
reason = "metadata_unavailable",
|
||||
"Failed to load authoritative lifecycle metadata"
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let Some(lifecycle) = configs.lifecycle else {
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -3312,16 +3362,13 @@ pub async fn enqueue_immediate_expiry(oi: &ObjectInfo, src: LcEventSrc) {
|
||||
object_infos.push(oi.clone());
|
||||
}
|
||||
|
||||
let lock_config = match metadata_boundary::get_object_lock_config(&oi.bucket).await {
|
||||
Ok((cfg, _)) => Some(Arc::new(cfg)),
|
||||
Err(_) => None,
|
||||
};
|
||||
let object_opts = object_infos
|
||||
.iter()
|
||||
.map(lifecycle::object_opts_from_object_info)
|
||||
.collect::<Vec<ObjectOpts>>();
|
||||
let events = match Evaluator::new(Arc::new(lifecycle))
|
||||
.with_lock_retention(lock_config)
|
||||
let lock_config = configs.object_lock;
|
||||
let events = match Evaluator::new(lifecycle)
|
||||
.with_lock_retention(lock_config.clone())
|
||||
.eval(&object_opts)
|
||||
.await
|
||||
{
|
||||
@@ -3350,6 +3397,19 @@ pub async fn enqueue_immediate_expiry(oi: &ObjectInfo, src: LcEventSrc) {
|
||||
if event.due != Some(OffsetDateTime::UNIX_EPOCH) {
|
||||
continue;
|
||||
}
|
||||
if matches!(
|
||||
event.action,
|
||||
IlmAction::DeleteAction
|
||||
| IlmAction::DeleteVersionAction
|
||||
| IlmAction::DeleteAllVersionsAction
|
||||
| IlmAction::DelMarkerDeleteAllVersionsAction
|
||||
) && !matches!(
|
||||
object_lock_boundary::check_object_lock_for_deletion_with_config(lock_config.as_deref(), object, false),
|
||||
Ok(None)
|
||||
) {
|
||||
record_scanner_lifecycle_expiry_blocked(&src, 1);
|
||||
continue;
|
||||
}
|
||||
|
||||
match event.action {
|
||||
IlmAction::DeleteAction
|
||||
@@ -3357,7 +3417,7 @@ pub async fn enqueue_immediate_expiry(oi: &ObjectInfo, src: LcEventSrc) {
|
||||
| IlmAction::DeleteRestoredVersionAction
|
||||
| IlmAction::DeleteAllVersionsAction
|
||||
| IlmAction::DelMarkerDeleteAllVersionsAction => {
|
||||
apply_expiry_rule(event, &src, object).await;
|
||||
enqueue_expiry_rule_with_incarnation(event, &src, object, configs.bucket_incarnation_id).await;
|
||||
}
|
||||
IlmAction::DeleteVersionAction => {
|
||||
to_delete_objs.push(ObjectToDelete {
|
||||
@@ -3377,10 +3437,13 @@ pub async fn enqueue_immediate_expiry(oi: &ObjectInfo, src: LcEventSrc) {
|
||||
&& let Some(event) = noncurrent_event
|
||||
{
|
||||
let expiry_state = runtime_sources::expiry_state_handle();
|
||||
expiry_state
|
||||
.write()
|
||||
.await
|
||||
.enqueue_by_newer_noncurrent(&oi.bucket, to_delete_objs, event, &src);
|
||||
expiry_state.write().await.enqueue_by_newer_noncurrent(
|
||||
&oi.bucket,
|
||||
to_delete_objs,
|
||||
event,
|
||||
&src,
|
||||
configs.bucket_incarnation_id,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3757,11 +3820,17 @@ fn should_defer_date_expiry_for_recent_config_update(lc: &BucketLifecycleConfigu
|
||||
})
|
||||
}
|
||||
|
||||
async fn apply_existing_object_expiry(api: Arc<ECStore>, object: &ObjectInfo, event: &lifecycle::Event, src: &LcEventSrc) {
|
||||
async fn apply_existing_object_expiry(
|
||||
api: Arc<ECStore>,
|
||||
object: &ObjectInfo,
|
||||
event: &lifecycle::Event,
|
||||
src: &LcEventSrc,
|
||||
bucket_incarnation_id: Uuid,
|
||||
) {
|
||||
if object.is_remote() {
|
||||
apply_expiry_on_transitioned_object(api, object, event, src).await;
|
||||
apply_expiry_on_transitioned_object(api, object, event, src, bucket_incarnation_id).await;
|
||||
} else {
|
||||
apply_expiry_on_non_transitioned_objects(api, object, event, src).await;
|
||||
apply_expiry_on_non_transitioned_objects(api, object, event, src, bucket_incarnation_id).await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3770,6 +3839,7 @@ struct ExistingObjectExpiryContext<'a> {
|
||||
bucket: &'a str,
|
||||
lc: Arc<BucketLifecycleConfiguration>,
|
||||
lock_config: Option<Arc<ObjectLockConfiguration>>,
|
||||
bucket_incarnation_id: Uuid,
|
||||
src: &'a LcEventSrc,
|
||||
defer_date_expiry_once: bool,
|
||||
}
|
||||
@@ -3821,6 +3891,17 @@ async fn enqueue_expiry_for_existing_object_group(
|
||||
| IlmAction::DeleteRestoredVersionAction
|
||||
| IlmAction::DeleteAllVersionsAction
|
||||
| IlmAction::DelMarkerDeleteAllVersionsAction => {
|
||||
if !event.action.delete_restored() {
|
||||
let object_lock_result = object_lock_boundary::check_object_lock_for_deletion_with_config(
|
||||
context.lock_config.as_deref(),
|
||||
object,
|
||||
false,
|
||||
);
|
||||
if !matches!(object_lock_result, Ok(None)) {
|
||||
record_scanner_lifecycle_expiry_blocked(context.src, 1);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let now = OffsetDateTime::now_utc();
|
||||
if event.due.is_some_and(|due| due.unix_timestamp() <= now.unix_timestamp()) {
|
||||
if context.defer_date_expiry_once
|
||||
@@ -3864,10 +3945,17 @@ async fn enqueue_expiry_for_existing_object_group(
|
||||
record_scanner_lifecycle_expiry_blocked(context.src, 1);
|
||||
continue;
|
||||
}
|
||||
apply_existing_object_expiry(context.api.clone(), object, event, context.src).await;
|
||||
apply_existing_object_expiry(
|
||||
context.api.clone(),
|
||||
object,
|
||||
event,
|
||||
context.src,
|
||||
context.bucket_incarnation_id,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
} else {
|
||||
apply_expiry_rule(event, context.src, object).await;
|
||||
enqueue_expiry_rule_with_incarnation(event, context.src, object, context.bucket_incarnation_id).await;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
@@ -3878,22 +3966,22 @@ async fn enqueue_expiry_for_existing_object_group(
|
||||
&& let Some(event) = noncurrent_event
|
||||
{
|
||||
let expiry_state = runtime_sources::expiry_state_handle();
|
||||
expiry_state
|
||||
.write()
|
||||
.await
|
||||
.enqueue_by_newer_noncurrent(context.bucket, to_delete_objs, event, context.src);
|
||||
expiry_state.write().await.enqueue_by_newer_noncurrent(
|
||||
context.bucket,
|
||||
to_delete_objs,
|
||||
event,
|
||||
context.src,
|
||||
context.bucket_incarnation_id,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn enqueue_expiry_for_existing_objects(api: Arc<ECStore>, bucket: &str) -> Result<(), Error> {
|
||||
let Ok((lc, _)) = metadata_boundary::get_lifecycle_config(bucket).await else {
|
||||
let configs = metadata_boundary::get_expiry_configs(&api, bucket).await?;
|
||||
let Some(lc) = configs.lifecycle else {
|
||||
return Ok(());
|
||||
};
|
||||
let lc = Arc::new(lc);
|
||||
let lock_config = metadata_boundary::get_object_lock_config(bucket)
|
||||
.await
|
||||
.ok()
|
||||
.map(|(cfg, _)| Arc::new(cfg));
|
||||
let lock_config = configs.object_lock;
|
||||
let mut marker = None;
|
||||
let mut version_marker = None;
|
||||
let src = LcEventSrc::Scanner;
|
||||
@@ -3903,6 +3991,7 @@ pub async fn enqueue_expiry_for_existing_objects(api: Arc<ECStore>, bucket: &str
|
||||
bucket,
|
||||
lc: lc.clone(),
|
||||
lock_config: lock_config.clone(),
|
||||
bucket_incarnation_id: configs.bucket_incarnation_id,
|
||||
src: &src,
|
||||
defer_date_expiry_once,
|
||||
};
|
||||
@@ -4057,20 +4146,45 @@ fn transitioned_object_delete_opts(
|
||||
action: IlmAction,
|
||||
versioned: bool,
|
||||
version_suspended: bool,
|
||||
) -> ObjectOptions {
|
||||
bucket_incarnation_id: Uuid,
|
||||
) -> crate::error::Result<ObjectOptions> {
|
||||
let mut opts = ObjectOptions {
|
||||
versioned,
|
||||
version_suspended,
|
||||
expiration: ExpirationOptions { expire: true },
|
||||
expected_bucket_incarnation_id: Some(bucket_incarnation_id),
|
||||
..Default::default()
|
||||
};
|
||||
if action.delete_versioned() {
|
||||
opts.version_id = oi.version_id.map(|id| id.to_string());
|
||||
}
|
||||
if action.delete_restored() {
|
||||
let etag = oi
|
||||
.etag
|
||||
.as_deref()
|
||||
.filter(|etag| !etag.is_empty())
|
||||
.ok_or_else(|| Error::other("restored-copy expiry requires an object etag"))?;
|
||||
let data_dir = oi
|
||||
.data_dir
|
||||
.ok_or_else(|| Error::other("restored-copy expiry requires a local data directory"))?;
|
||||
let restore_expiry = oi
|
||||
.restore_expires
|
||||
.ok_or_else(|| Error::other("restored-copy expiry requires a restore expiry"))?;
|
||||
opts.transition.expire_restored = true;
|
||||
opts.transition.status.clone_from(&oi.transitioned_object.status);
|
||||
opts.transition.tier.clone_from(&oi.transitioned_object.tier);
|
||||
opts.transition.etag = etag.to_string();
|
||||
opts.transition.expected_data_dir = Some(data_dir);
|
||||
opts.transition.expected_remote_name.clone_from(&oi.transitioned_object.name);
|
||||
opts.transition
|
||||
.expected_remote_version_id
|
||||
.clone_from(&oi.transitioned_object.version_id);
|
||||
opts.transition.restore_expiry = restore_expiry;
|
||||
if let Some(version_id) = oi.version_id {
|
||||
opts.version_id = Some(version_id.to_string());
|
||||
}
|
||||
}
|
||||
opts
|
||||
Ok(opts)
|
||||
}
|
||||
|
||||
pub async fn expire_transitioned_object(
|
||||
@@ -4078,12 +4192,14 @@ pub async fn expire_transitioned_object(
|
||||
oi: &ObjectInfo,
|
||||
lc_event: &lifecycle::Event,
|
||||
_src: &LcEventSrc,
|
||||
bucket_incarnation_id: Uuid,
|
||||
) -> Result<ObjectInfo, std::io::Error> {
|
||||
let snapshot = lifecycle_delete_config_snapshot(&api, oi)
|
||||
.await
|
||||
.map_err(std::io::Error::other)?;
|
||||
let (versioned, version_suspended) = snapshot.versioning_config().delete_state(&oi.name);
|
||||
let mut opts = transitioned_object_delete_opts(oi, lc_event.action, versioned, version_suspended);
|
||||
let mut opts = transitioned_object_delete_opts(oi, lc_event.action, versioned, version_suspended, bucket_incarnation_id)
|
||||
.map_err(std::io::Error::other)?;
|
||||
opts.delete_replication_config_snapshot = Some(Arc::new(snapshot));
|
||||
//let tags = LcAuditEvent::new(src, lcEvent).Tags();
|
||||
if lc_event.action.delete_restored() {
|
||||
@@ -4509,7 +4625,7 @@ const _MAX_RESTORE_OBJECT_REQUEST_SIZE: i64 = 2 << 20;
|
||||
|
||||
pub async fn eval_action_from_lifecycle(
|
||||
lc: &BucketLifecycleConfiguration,
|
||||
lr: Option<DefaultRetention>,
|
||||
lock_config: Option<&ObjectLockConfiguration>,
|
||||
oi: &ObjectInfo,
|
||||
) -> lifecycle::Event {
|
||||
let event = lc.eval(&oi.to_lifecycle_opts()).await;
|
||||
@@ -4522,7 +4638,7 @@ pub async fn eval_action_from_lifecycle(
|
||||
"Evaluated lifecycle action during secondary scan"
|
||||
);
|
||||
|
||||
let lock_enabled = if let Some(lr) = lr { lr.mode.is_some() } else { false };
|
||||
let lock_enabled = lock_config.is_some_and(ObjectLockApi::enabled);
|
||||
let object_locked = object_lock_boundary::is_object_locked_by_metadata(&oi.user_defined, oi.delete_marker);
|
||||
|
||||
match event.action {
|
||||
@@ -4538,12 +4654,14 @@ pub async fn eval_action_from_lifecycle(
|
||||
{
|
||||
return lifecycle::Event::default();
|
||||
}
|
||||
// Lifecycle operations should never bypass governance retention
|
||||
if object_locked
|
||||
|| (lock_enabled
|
||||
&& object_lock_boundary::check_object_lock_for_deletion(&oi.bucket, oi, false)
|
||||
.await
|
||||
.is_some())
|
||||
// Destructive expiry never bypasses retention. Restore expiry only
|
||||
// removes the local copy; the retained logical version remains.
|
||||
if !event.action.delete_restored()
|
||||
&& (object_locked
|
||||
|| !matches!(
|
||||
object_lock_boundary::check_object_lock_for_deletion_with_config(lock_config, oi, false),
|
||||
Ok(None)
|
||||
))
|
||||
{
|
||||
//if serverDebugLog {
|
||||
if oi.version_id.is_some() {
|
||||
@@ -4676,9 +4794,10 @@ pub async fn apply_expiry_on_transitioned_object(
|
||||
oi: &ObjectInfo,
|
||||
lc_event: &lifecycle::Event,
|
||||
src: &LcEventSrc,
|
||||
bucket_incarnation_id: Uuid,
|
||||
) -> bool {
|
||||
let time_ilm = Metrics::time_ilm(lc_event.action);
|
||||
if let Err(_err) = expire_transitioned_object(api, oi, lc_event, src).await {
|
||||
if let Err(_err) = expire_transitioned_object(api, oi, lc_event, src, bucket_incarnation_id).await {
|
||||
return false;
|
||||
}
|
||||
time_ilm(1)();
|
||||
@@ -4691,6 +4810,7 @@ pub async fn apply_expiry_on_non_transitioned_objects(
|
||||
oi: &ObjectInfo,
|
||||
lc_event: &lifecycle::Event,
|
||||
_src: &LcEventSrc,
|
||||
bucket_incarnation_id: Uuid,
|
||||
) -> bool {
|
||||
let snapshot = match lifecycle_delete_config_snapshot(&api, oi).await {
|
||||
Ok(snapshot) => snapshot,
|
||||
@@ -4714,6 +4834,7 @@ pub async fn apply_expiry_on_non_transitioned_objects(
|
||||
version_suspended,
|
||||
expiration: ExpirationOptions { expire: true },
|
||||
delete_replication_config_snapshot: Some(Arc::new(snapshot)),
|
||||
expected_bucket_incarnation_id: Some(bucket_incarnation_id),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
@@ -4775,10 +4896,61 @@ pub async fn apply_expiry_on_non_transitioned_objects(
|
||||
true
|
||||
}
|
||||
|
||||
pub async fn apply_expiry_rule(event: &lifecycle::Event, src: &LcEventSrc, oi: &ObjectInfo) -> bool {
|
||||
async fn enqueue_expiry_rule_with_incarnation(
|
||||
event: &lifecycle::Event,
|
||||
src: &LcEventSrc,
|
||||
oi: &ObjectInfo,
|
||||
bucket_incarnation_id: Uuid,
|
||||
) -> bool {
|
||||
let expiry_state = runtime_sources::expiry_state_handle();
|
||||
let mut expiry_state = expiry_state.write().await;
|
||||
expiry_state.enqueue_by_days(oi, event, src)
|
||||
expiry_state.enqueue_by_days(oi, event, src, bucket_incarnation_id)
|
||||
}
|
||||
|
||||
pub(crate) async fn apply_expiry_rule_in(api: Arc<ECStore>, event: &lifecycle::Event, src: &LcEventSrc, oi: &ObjectInfo) -> bool {
|
||||
let Ok(_lifecycle_guard) = api.acquire_bucket_lifecycle_read_lock(&oi.bucket).await else {
|
||||
return false;
|
||||
};
|
||||
let Ok(bucket_incarnation_id) = api.bucket_incarnation_id_from_disk(&oi.bucket).await else {
|
||||
return false;
|
||||
};
|
||||
let current = match api
|
||||
.get_object_info(
|
||||
&oi.bucket,
|
||||
&oi.name,
|
||||
&ObjectOptions {
|
||||
version_id: oi.version_id.map(|version_id| version_id.to_string()),
|
||||
versioned: oi.version_id.is_some(),
|
||||
expected_bucket_incarnation_id: Some(bucket_incarnation_id),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(current) => current,
|
||||
Err(_) => return false,
|
||||
};
|
||||
if current.version_id != oi.version_id
|
||||
|| current.data_dir != oi.data_dir
|
||||
|| current.mod_time != oi.mod_time
|
||||
|| current.etag != oi.etag
|
||||
|| current.delete_marker != oi.delete_marker
|
||||
|| current.transitioned_object.name != oi.transitioned_object.name
|
||||
|| current.transitioned_object.version_id != oi.transitioned_object.version_id
|
||||
|| current.transitioned_object.tier != oi.transitioned_object.tier
|
||||
|| current.transitioned_object.status != oi.transitioned_object.status
|
||||
|| current.restore_expires != oi.restore_expires
|
||||
{
|
||||
return false;
|
||||
}
|
||||
enqueue_expiry_rule_with_incarnation(event, src, oi, bucket_incarnation_id).await
|
||||
}
|
||||
|
||||
pub async fn apply_expiry_rule(event: &lifecycle::Event, src: &LcEventSrc, oi: &ObjectInfo) -> bool {
|
||||
let Some(api) = runtime_sources::object_store_handle() else {
|
||||
return false;
|
||||
};
|
||||
apply_expiry_rule_in(api, event, src, oi).await
|
||||
}
|
||||
|
||||
fn lifecycle_deleted_object(oi: &ObjectInfo, dobj: &ObjectInfo) -> DeletedObject {
|
||||
@@ -4853,11 +5025,11 @@ pub async fn apply_lifecycle_action(event: &lifecycle::Event, src: &LcEventSrc,
|
||||
mod tests {
|
||||
use super::{
|
||||
DATE_EXPIRY_EXISTING_OBJECTS_GRACE_SECS, DEFAULT_TRANSITION_QUEUE_CAPACITY, DEFAULT_TRANSITION_WORKERS_ABSOLUTE_MAX,
|
||||
DEFAULT_TRANSITION_WORKERS_CAP, EVENT_LIFECYCLE_EXPIRED_DETECTED, EVENT_LIFECYCLE_NOT_ENQUEUED, ExpiryState,
|
||||
FreeVersionTask, ManualTransitionJobRecoveryOutcome, ManualTransitionQueueSnapshot, ManualTransitionRunOptions,
|
||||
ManualTransitionRunReport, StaleMultipartUploadCandidate, TIER_FREE_VERSION_RECOVERY_BASE_INTERVAL,
|
||||
TIER_FREE_VERSION_RECOVERY_MAX_IDLE_INTERVAL, TRANSITION_COMPLETE, TierFreeVersionRecoverySchedule,
|
||||
TransitionEnqueueOutcome, TransitionState, TransitionedObject, VersionReplicationScan,
|
||||
DEFAULT_TRANSITION_WORKERS_CAP, EVENT_LIFECYCLE_EVALUATION_FAILED, EVENT_LIFECYCLE_EXPIRED_DETECTED,
|
||||
EVENT_LIFECYCLE_NOT_ENQUEUED, ExpiryState, ExpiryTask, FreeVersionTask, ManualTransitionJobRecoveryOutcome,
|
||||
ManualTransitionQueueSnapshot, ManualTransitionRunOptions, ManualTransitionRunReport, StaleMultipartUploadCandidate,
|
||||
TIER_FREE_VERSION_RECOVERY_BASE_INTERVAL, TIER_FREE_VERSION_RECOVERY_MAX_IDLE_INTERVAL, TRANSITION_COMPLETE,
|
||||
TierFreeVersionRecoverySchedule, TransitionEnqueueOutcome, TransitionState, TransitionedObject, VersionReplicationScan,
|
||||
cleanup_empty_multipart_sha_dirs_on_local_disks, cleanup_stale_multipart_uploads_once_at,
|
||||
enqueue_recovered_free_version_with_state, enqueue_transition_for_existing_objects_scoped,
|
||||
enqueue_transition_with_lifecycle, enqueue_transition_with_lifecycle_report, eval_action_from_lifecycle,
|
||||
@@ -4937,8 +5109,9 @@ mod tests {
|
||||
use rustfs_data_usage::TierStats;
|
||||
use rustfs_filemeta::{FileInfo, FileMeta};
|
||||
use s3s::dto::{
|
||||
BucketLifecycleConfiguration, ExpirationStatus, LifecycleExpiration, LifecycleRule, MetadataEntry, OutputLocation,
|
||||
RestoreRequest, RestoreRequestType, S3Location, Timestamp, Transition, TransitionStorageClass,
|
||||
BucketLifecycleConfiguration, DefaultRetention, ExpirationStatus, LifecycleExpiration, LifecycleRule, MetadataEntry,
|
||||
ObjectLockConfiguration, ObjectLockEnabled, ObjectLockRetentionMode, ObjectLockRule, OutputLocation, RestoreRequest,
|
||||
RestoreRequestType, S3Location, Timestamp, Transition, TransitionStorageClass,
|
||||
};
|
||||
use s3s::header::{X_AMZ_OBJECT_LOCK_LEGAL_HOLD, X_AMZ_OBJECT_LOCK_MODE, X_AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE};
|
||||
use serial_test::serial;
|
||||
@@ -5854,29 +6027,44 @@ mod tests {
|
||||
bucket: "bucket".to_string(),
|
||||
name: "object".to_string(),
|
||||
version_id: Some(vid),
|
||||
data_dir: Some(Uuid::new_v4()),
|
||||
etag: Some("etag".to_string()),
|
||||
restore_expires: Some(OffsetDateTime::now_utc() - StdDuration::from_secs(1)),
|
||||
transitioned_object: TransitionedObject {
|
||||
name: "remote-object".to_string(),
|
||||
tier: "tier".to_string(),
|
||||
status: TRANSITION_COMPLETE.to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// Plain version expiry: exact version, real delete.
|
||||
let opts = transitioned_object_delete_opts(&oi, IlmAction::DeleteVersionAction, true, false);
|
||||
let incarnation = Uuid::new_v4();
|
||||
let opts = transitioned_object_delete_opts(&oi, IlmAction::DeleteVersionAction, true, false, incarnation)
|
||||
.expect("build version expiry options");
|
||||
assert_eq!(opts.version_id.as_deref(), Some(vid_str.as_str()));
|
||||
assert_eq!(opts.expected_bucket_incarnation_id, Some(incarnation));
|
||||
assert!(!opts.transition.expire_restored);
|
||||
assert!(opts.expiration.expire);
|
||||
|
||||
// Restore-expiry of the latest version: restored-copy cleanup only.
|
||||
let opts = transitioned_object_delete_opts(&oi, IlmAction::DeleteRestoredAction, true, false);
|
||||
assert!(opts.version_id.is_none());
|
||||
let opts = transitioned_object_delete_opts(&oi, IlmAction::DeleteRestoredAction, true, false, incarnation)
|
||||
.expect("build restored expiry options");
|
||||
assert_eq!(opts.version_id.as_deref(), Some(vid_str.as_str()));
|
||||
assert!(opts.transition.expire_restored);
|
||||
|
||||
// Restore-expiry of a noncurrent version: restored-copy cleanup of the
|
||||
// exact version. Routing this through the full transitioned-object
|
||||
// delete instead would remove the remote tier data.
|
||||
let opts = transitioned_object_delete_opts(&oi, IlmAction::DeleteRestoredVersionAction, true, false);
|
||||
let opts = transitioned_object_delete_opts(&oi, IlmAction::DeleteRestoredVersionAction, true, false, incarnation)
|
||||
.expect("build restored-version expiry options");
|
||||
assert_eq!(opts.version_id.as_deref(), Some(vid_str.as_str()));
|
||||
assert!(opts.transition.expire_restored);
|
||||
|
||||
// Whole-object expiry stays a real delete.
|
||||
let opts = transitioned_object_delete_opts(&oi, IlmAction::DeleteAction, false, false);
|
||||
let opts = transitioned_object_delete_opts(&oi, IlmAction::DeleteAction, false, false, incarnation)
|
||||
.expect("build object expiry options");
|
||||
assert!(opts.version_id.is_none());
|
||||
assert!(!opts.transition.expire_restored);
|
||||
}
|
||||
@@ -5905,7 +6093,7 @@ mod tests {
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let queued = state.enqueue_by_days(&object, &event, &LcEventSrc::Scanner);
|
||||
let queued = state.enqueue_by_days(&object, &event, &LcEventSrc::Scanner, Uuid::new_v4());
|
||||
|
||||
assert!(!queued);
|
||||
assert_eq!(state.stats.missed_tasks(), 1);
|
||||
@@ -6011,8 +6199,9 @@ mod tests {
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let first = state.enqueue_by_days(&object, &event, &LcEventSrc::Scanner);
|
||||
let second = state.enqueue_by_days(&object, &event, &LcEventSrc::Scanner);
|
||||
let incarnation = Uuid::new_v4();
|
||||
let first = state.enqueue_by_days(&object, &event, &LcEventSrc::Scanner, incarnation);
|
||||
let second = state.enqueue_by_days(&object, &event, &LcEventSrc::Scanner, incarnation);
|
||||
|
||||
assert!(first);
|
||||
assert!(!second);
|
||||
@@ -6031,6 +6220,39 @@ mod tests {
|
||||
assert!(observed.contains(&(EVENT_LIFECYCLE_NOT_ENQUEUED, "not_enqueued", Some("queue_full"))));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn expiry_task_retains_enqueue_time_bucket_incarnation() {
|
||||
let state = ExpiryState::new_with_unconsumed_worker_channel(1);
|
||||
let incarnation = Uuid::new_v4();
|
||||
let object = ObjectInfo {
|
||||
bucket: "bucket".to_string(),
|
||||
name: "object".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
let event = crate::bucket::lifecycle::lifecycle::Event {
|
||||
action: IlmAction::DeleteAction,
|
||||
..Default::default()
|
||||
};
|
||||
{
|
||||
let mut state = state.write().await;
|
||||
assert!(state.enqueue_by_days(&object, &event, &LcEventSrc::Scanner, incarnation));
|
||||
}
|
||||
|
||||
let receiver = state.read().await.tasks_rx[0].clone();
|
||||
let task = receiver
|
||||
.lock()
|
||||
.await
|
||||
.recv()
|
||||
.await
|
||||
.expect("expiry task should be queued")
|
||||
.expect("expiry task payload should be present");
|
||||
let task = task
|
||||
.as_any()
|
||||
.downcast_ref::<ExpiryTask>()
|
||||
.expect("queued payload should be an expiry task");
|
||||
assert_eq!(task.bucket_incarnation_id, incarnation);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn enqueue_tier_journal_entry_reports_error_when_worker_queue_full() {
|
||||
let state = ExpiryState::new_with_unconsumed_worker_channel(1);
|
||||
@@ -7741,6 +7963,48 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn all_versions_expiration_lifecycle() -> BucketLifecycleConfiguration {
|
||||
BucketLifecycleConfiguration {
|
||||
expiry_updated_at: None,
|
||||
rules: vec![LifecycleRule {
|
||||
status: ExpirationStatus::from_static(ExpirationStatus::ENABLED),
|
||||
expiration: Some(LifecycleExpiration {
|
||||
days: Some(1),
|
||||
expired_object_all_versions: Some(true),
|
||||
..Default::default()
|
||||
}),
|
||||
abort_incomplete_multipart_upload: None,
|
||||
del_marker_expiration: None,
|
||||
filter: None,
|
||||
id: Some("delete-all".to_string()),
|
||||
noncurrent_version_expiration: None,
|
||||
noncurrent_version_transitions: None,
|
||||
prefix: None,
|
||||
transitions: None,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
fn lock_enabled_without_default_retention() -> ObjectLockConfiguration {
|
||||
ObjectLockConfiguration {
|
||||
object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)),
|
||||
rule: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn lock_enabled_with_default_retention() -> ObjectLockConfiguration {
|
||||
ObjectLockConfiguration {
|
||||
object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)),
|
||||
rule: Some(ObjectLockRule {
|
||||
default_retention: Some(DefaultRetention {
|
||||
days: Some(30),
|
||||
mode: Some(ObjectLockRetentionMode::from_static(ObjectLockRetentionMode::COMPLIANCE)),
|
||||
years: None,
|
||||
}),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn latest_transition_lifecycle() -> BucketLifecycleConfiguration {
|
||||
BucketLifecycleConfiguration {
|
||||
expiry_updated_at: None,
|
||||
@@ -9937,6 +10201,124 @@ mod tests {
|
||||
assert_eq!(event.action, IlmAction::DeleteAction);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn existing_object_lifecycle_skips_current_expiration_for_bucket_default_retention() {
|
||||
let lc = latest_expiration_lifecycle();
|
||||
let mut object = current_object(ReplicationStatusType::Completed);
|
||||
object.mod_time = Some(OffsetDateTime::now_utc());
|
||||
let lock_config = lock_enabled_with_default_retention();
|
||||
|
||||
let event = eval_action_from_lifecycle(&lc, Some(&lock_config), &object).await;
|
||||
|
||||
assert_eq!(event.action, IlmAction::NoneAction);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn existing_object_lifecycle_skips_delete_all_when_lock_enabled_without_default_retention() {
|
||||
let lc = all_versions_expiration_lifecycle();
|
||||
let object = current_object(ReplicationStatusType::Completed);
|
||||
let lock_config = lock_enabled_without_default_retention();
|
||||
|
||||
let event = eval_action_from_lifecycle(&lc, Some(&lock_config), &object).await;
|
||||
|
||||
assert_eq!(event.action, IlmAction::NoneAction);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn lifecycle_expiry_fails_closed_on_corrupt_object_lock_metadata() {
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
let bucket = format!("lifecycle-lock-metadata-error-{}", Uuid::new_v4().simple());
|
||||
let object = "due/object";
|
||||
create_test_bucket(&ecstore, &bucket).await;
|
||||
|
||||
let mut reader = PutObjReader::from_vec(b"must survive lifecycle metadata failure".to_vec());
|
||||
let object_info = ecstore
|
||||
.put_object(
|
||||
&bucket,
|
||||
object,
|
||||
&mut reader,
|
||||
&ObjectOptions {
|
||||
mod_time: Some(OffsetDateTime::now_utc() - time::Duration::days(2)),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("due object should be created");
|
||||
|
||||
let lifecycle = latest_expiration_lifecycle();
|
||||
let sys = metadata_sys::bucket_metadata_sys_of(&ecstore.ctx).expect("metadata system should be initialized");
|
||||
let sys = sys.read().await.clone();
|
||||
let mut metadata = (*sys.get(&bucket).await.expect("bucket metadata should exist")).clone();
|
||||
metadata.lifecycle_config_xml = crate::bucket::utils::serialize(&lifecycle).unwrap();
|
||||
metadata.lifecycle_config = Some(lifecycle);
|
||||
metadata.object_lock_config_xml = b"<ObjectLockConfiguration>".to_vec();
|
||||
metadata.object_lock_config = None;
|
||||
sys.persist_and_set(metadata)
|
||||
.await
|
||||
.expect("corrupt Object Lock payload should be persisted for the read-boundary test");
|
||||
sys.reload_from_store(&bucket)
|
||||
.await
|
||||
.expect("peer-style reload should publish the malformed persisted snapshot");
|
||||
|
||||
let exact_error = super::metadata_boundary::get_expiry_configs(&ecstore, &bucket)
|
||||
.await
|
||||
.expect_err("malformed Object Lock metadata must reject lifecycle config resolution");
|
||||
assert!(
|
||||
exact_error
|
||||
.to_string()
|
||||
.contains("persisted bucket Object Lock configuration is invalid")
|
||||
);
|
||||
|
||||
let runtime_state = install_unconsumed_runtime_expiry_worker(&ecstore, 1).await;
|
||||
let observed = Arc::new(StdMutex::new(Vec::new()));
|
||||
let observed_events = Arc::clone(&observed);
|
||||
let _observer = set_lifecycle_observability_observer(move |event, state, reason| {
|
||||
observed_events
|
||||
.lock()
|
||||
.expect("lifecycle metadata error observer should not poison")
|
||||
.push((event, state, reason));
|
||||
});
|
||||
|
||||
super::enqueue_immediate_expiry(&object_info, LcEventSrc::S3PutObject).await;
|
||||
|
||||
assert!(
|
||||
observed.lock().expect("observed events should not poison").contains(&(
|
||||
EVENT_LIFECYCLE_EVALUATION_FAILED,
|
||||
"failed",
|
||||
Some("metadata_unavailable")
|
||||
)),
|
||||
"immediate expiry must expose the authoritative metadata failure"
|
||||
);
|
||||
{
|
||||
let state = runtime_state.read().await;
|
||||
assert_eq!(state.stats.pending_tasks(), 0, "immediate expiry must not enqueue a delete");
|
||||
}
|
||||
assert!(
|
||||
ecstore
|
||||
.get_object_info(&bucket, object, &ObjectOptions::default())
|
||||
.await
|
||||
.is_ok(),
|
||||
"immediate expiry must leave the due object intact"
|
||||
);
|
||||
|
||||
let scanner_error = super::enqueue_expiry_for_existing_objects(ecstore.clone(), &bucket)
|
||||
.await
|
||||
.expect_err("scanner must propagate the authoritative Object Lock metadata error");
|
||||
assert_eq!(scanner_error.to_string(), exact_error.to_string());
|
||||
{
|
||||
let state = runtime_state.read().await;
|
||||
assert_eq!(state.stats.pending_tasks(), 0, "scanner must not enqueue a delete");
|
||||
}
|
||||
assert!(
|
||||
ecstore
|
||||
.get_object_info(&bucket, object, &ObjectOptions::default())
|
||||
.await
|
||||
.is_ok(),
|
||||
"scanner must leave the due object intact"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn existing_object_lifecycle_skips_current_expiration_for_explicit_legal_hold() {
|
||||
let lc = latest_expiration_lifecycle();
|
||||
@@ -9972,6 +10354,37 @@ mod tests {
|
||||
assert_eq!(event.action, IlmAction::NoneAction);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn restored_copy_expiry_is_not_blocked_by_retention() {
|
||||
let lifecycle = BucketLifecycleConfiguration {
|
||||
expiry_updated_at: None,
|
||||
rules: Vec::new(),
|
||||
};
|
||||
let retain_until = (OffsetDateTime::now_utc() + time::Duration::days(30))
|
||||
.format(&time::format_description::well_known::Rfc3339)
|
||||
.unwrap();
|
||||
let mut object = current_object_with_metadata(
|
||||
ReplicationStatusType::Completed,
|
||||
HashMap::from([
|
||||
(
|
||||
X_AMZ_OBJECT_LOCK_MODE.as_str().to_string(),
|
||||
ObjectLockRetentionMode::COMPLIANCE.to_string(),
|
||||
),
|
||||
(X_AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE.as_str().to_string(), retain_until),
|
||||
]),
|
||||
);
|
||||
object.transitioned_object.status = TRANSITION_COMPLETE.to_string();
|
||||
object.restore_expires = Some(OffsetDateTime::now_utc() - time::Duration::hours(1));
|
||||
|
||||
let current = eval_action_from_lifecycle(&lifecycle, None, &object).await;
|
||||
assert_eq!(current.action, IlmAction::DeleteRestoredAction);
|
||||
|
||||
object.is_latest = false;
|
||||
object.successor_mod_time = Some(OffsetDateTime::now_utc());
|
||||
let noncurrent = eval_action_from_lifecycle(&lifecycle, None, &object).await;
|
||||
assert_eq!(noncurrent.action, IlmAction::DeleteRestoredVersionAction);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn existing_object_lifecycle_skips_transition_while_replication_pending() {
|
||||
let lc = latest_transition_lifecycle();
|
||||
|
||||
@@ -12,20 +12,127 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use s3s::dto::{BucketLifecycleConfiguration, ObjectLockConfiguration, ReplicationConfiguration};
|
||||
use time::OffsetDateTime;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::bucket::metadata_sys;
|
||||
use crate::error::Result;
|
||||
use s3s::dto::{BucketLifecycleConfiguration, ObjectLockConfiguration};
|
||||
use time::OffsetDateTime;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::bucket::metadata_sys::{self, ObjectLockConfigState};
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct LifecycleExpiryConfigs {
|
||||
pub(crate) lifecycle: Option<Arc<BucketLifecycleConfiguration>>,
|
||||
pub(crate) object_lock: Option<Arc<ObjectLockConfiguration>>,
|
||||
pub(crate) bucket_incarnation_id: Uuid,
|
||||
}
|
||||
|
||||
pub(crate) async fn get_expiry_configs(api: &crate::store::ECStore, bucket: &str) -> Result<LifecycleExpiryConfigs> {
|
||||
let bucket_incarnation_id = api.bucket_incarnation_id_from_disk(bucket).await?;
|
||||
let sys = metadata_sys::bucket_metadata_sys_of(&api.ctx)?;
|
||||
let sys = sys.read().await.clone();
|
||||
let metadata = sys.get_authoritative_metadata(bucket).await?;
|
||||
if !metadata.bucket_incarnation_sidecar || metadata.bucket_incarnation_id != bucket_incarnation_id {
|
||||
return Err(Error::other(format!("bucket lifecycle metadata is not authoritative: {bucket}")));
|
||||
}
|
||||
|
||||
let lifecycle = if metadata.lifecycle_config.is_none() && !metadata.lifecycle_config_xml.is_empty() {
|
||||
return Err(Error::other("persisted bucket lifecycle configuration is invalid"));
|
||||
} else {
|
||||
metadata
|
||||
.lifecycle_config
|
||||
.clone()
|
||||
.filter(|config| !config.rules.is_empty())
|
||||
.map(Arc::new)
|
||||
};
|
||||
if lifecycle.is_none() {
|
||||
return Ok(LifecycleExpiryConfigs {
|
||||
lifecycle: None,
|
||||
object_lock: None,
|
||||
bucket_incarnation_id,
|
||||
});
|
||||
}
|
||||
let object_lock = match metadata_sys::object_lock_config_state_from_authoritative_metadata(&metadata)? {
|
||||
ObjectLockConfigState::Configured { config, .. } => Some(Arc::new(config)),
|
||||
ObjectLockConfigState::ConfirmedAbsent => None,
|
||||
ObjectLockConfigState::Fabricated => {
|
||||
return Err(Error::other(format!("bucket Object Lock metadata is not authoritative: {bucket}")));
|
||||
}
|
||||
};
|
||||
|
||||
Ok(LifecycleExpiryConfigs {
|
||||
lifecycle,
|
||||
object_lock,
|
||||
bucket_incarnation_id,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn get_lifecycle_config(bucket: &str) -> Result<(BucketLifecycleConfiguration, OffsetDateTime)> {
|
||||
metadata_sys::get_lifecycle_config(bucket).await
|
||||
}
|
||||
|
||||
pub(crate) async fn get_object_lock_config(bucket: &str) -> Result<(ObjectLockConfiguration, OffsetDateTime)> {
|
||||
metadata_sys::get_object_lock_config(bucket).await
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::bucket::metadata::BucketMetadata;
|
||||
use crate::bucket::metadata_sys::{self, test_support::isolated_store_over_temp_disks};
|
||||
use crate::storage_api_contracts::bucket::MakeBucketOptions;
|
||||
use s3s::dto::{ExpirationStatus, LifecycleExpiration, LifecycleRule};
|
||||
use serial_test::serial;
|
||||
|
||||
pub(crate) async fn get_replication_config(bucket: &str) -> Result<(ReplicationConfiguration, OffsetDateTime)> {
|
||||
metadata_sys::get_replication_config(bucket).await
|
||||
fn lifecycle_config() -> BucketLifecycleConfiguration {
|
||||
BucketLifecycleConfiguration {
|
||||
expiry_updated_at: None,
|
||||
rules: vec![LifecycleRule {
|
||||
status: ExpirationStatus::from_static(ExpirationStatus::ENABLED),
|
||||
expiration: Some(LifecycleExpiration {
|
||||
days: Some(1),
|
||||
..Default::default()
|
||||
}),
|
||||
abort_incomplete_multipart_upload: None,
|
||||
del_marker_expiration: None,
|
||||
filter: None,
|
||||
id: Some("expire".to_string()),
|
||||
noncurrent_version_expiration: None,
|
||||
noncurrent_version_transitions: None,
|
||||
prefix: None,
|
||||
transitions: None,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn expiry_configs_are_resolved_from_the_owning_store() {
|
||||
let (_dirs_a, store_a) = isolated_store_over_temp_disks().await;
|
||||
let (_dirs_b, store_b) = isolated_store_over_temp_disks().await;
|
||||
let bucket = "same-name-expiry-config";
|
||||
store_a
|
||||
.peer_sys
|
||||
.make_bucket(bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.unwrap();
|
||||
store_b
|
||||
.peer_sys
|
||||
.make_bucket(bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.unwrap();
|
||||
metadata_sys::init_bucket_metadata_sys(store_a.clone(), vec![bucket.to_string()]).await;
|
||||
metadata_sys::init_bucket_metadata_sys(store_b.clone(), vec![bucket.to_string()]).await;
|
||||
|
||||
let mut metadata = BucketMetadata::new(bucket);
|
||||
let lifecycle = lifecycle_config();
|
||||
metadata.lifecycle_config_xml = crate::bucket::utils::serialize(&lifecycle).unwrap();
|
||||
metadata.lifecycle_config = Some(lifecycle);
|
||||
metadata_sys::set_new_bucket_metadata_in(&store_a.ctx, metadata)
|
||||
.await
|
||||
.unwrap();
|
||||
metadata_sys::set_new_bucket_metadata_in(&store_b.ctx, BucketMetadata::new(bucket))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(get_expiry_configs(&store_a, bucket).await.unwrap().lifecycle.is_some());
|
||||
assert!(get_expiry_configs(&store_b, bucket).await.unwrap().lifecycle.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ pub mod core;
|
||||
pub mod evaluator;
|
||||
pub mod manual_transition_job;
|
||||
mod metadata_boundary;
|
||||
pub(crate) use metadata_boundary::get_expiry_configs;
|
||||
mod object_lock_boundary;
|
||||
pub use self::core as lifecycle;
|
||||
mod replication_sink;
|
||||
|
||||
@@ -21,12 +21,12 @@ pub(crate) fn is_object_locked_by_metadata(user_defined: &HashMap<String, String
|
||||
rustfs_lifecycle::object_lock::is_object_locked_by_metadata(user_defined, is_delete_marker)
|
||||
}
|
||||
|
||||
pub(crate) async fn check_object_lock_for_deletion(
|
||||
bucket: &str,
|
||||
pub(crate) fn check_object_lock_for_deletion_with_config(
|
||||
config: Option<&s3s::dto::ObjectLockConfiguration>,
|
||||
obj_info: &ObjectInfo,
|
||||
bypass_governance: bool,
|
||||
) -> Option<ObjectLockBlockReason> {
|
||||
objectlock_sys::check_object_lock_for_deletion(bucket, obj_info, bypass_governance).await
|
||||
) -> crate::error::Result<Option<ObjectLockBlockReason>> {
|
||||
objectlock_sys::check_object_lock_for_deletion_with_config(config, obj_info, bypass_governance)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -18,7 +18,7 @@ use super::versioning::VersioningApi;
|
||||
use super::{quota::BucketQuota, target::BucketTargets};
|
||||
use crate::bucket::replication::invalid_replication_config_status_field;
|
||||
use crate::bucket::utils::deserialize;
|
||||
use crate::config::com::{read_config, save_config};
|
||||
use crate::config::com::{read_config, read_config_preserve_empty, save_config};
|
||||
use crate::disk::BUCKET_META_PREFIX;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::runtime::sources as runtime_sources;
|
||||
@@ -37,6 +37,7 @@ use std::io::{Read, Write};
|
||||
use std::sync::Arc;
|
||||
use time::{Date, OffsetDateTime, PrimitiveDateTime, Time as CivilTime, UtcOffset};
|
||||
use tracing::error;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn read_msgp_str<R: Read>(rd: &mut R) -> Result<String> {
|
||||
let len = rmp::decode::read_str_len(rd)? as usize;
|
||||
@@ -226,6 +227,7 @@ fn write_bin_field<W: Write>(wr: &mut W, key: &str, val: &[u8]) -> Result<()> {
|
||||
}
|
||||
|
||||
pub const BUCKET_METADATA_FILE: &str = ".metadata.bin";
|
||||
pub const BUCKET_INCARNATION_FILE: &str = ".bucket-incarnation";
|
||||
pub const BUCKET_METADATA_FORMAT: u16 = 1;
|
||||
pub const BUCKET_METADATA_VERSION: u16 = 1;
|
||||
|
||||
@@ -277,6 +279,8 @@ pub struct BucketMetadata {
|
||||
pub name: String,
|
||||
pub created: OffsetDateTime,
|
||||
pub lock_enabled: bool, // While marked as unused, it may need to be retained
|
||||
pub bucket_incarnation_id: Uuid,
|
||||
pub(crate) bucket_incarnation_sidecar: bool,
|
||||
pub policy_config_json: Vec<u8>,
|
||||
pub notification_config_xml: Vec<u8>,
|
||||
pub lifecycle_config_xml: Vec<u8>,
|
||||
@@ -347,6 +351,8 @@ impl Default for BucketMetadata {
|
||||
name: Default::default(),
|
||||
created: OffsetDateTime::UNIX_EPOCH,
|
||||
lock_enabled: Default::default(),
|
||||
bucket_incarnation_id: Uuid::nil(),
|
||||
bucket_incarnation_sidecar: false,
|
||||
policy_config_json: Default::default(),
|
||||
notification_config_xml: Default::default(),
|
||||
lifecycle_config_xml: Default::default(),
|
||||
@@ -414,6 +420,7 @@ impl BucketMetadata {
|
||||
pub fn new(name: &str) -> Self {
|
||||
BucketMetadata {
|
||||
name: name.to_string(),
|
||||
bucket_incarnation_id: Uuid::new_v4(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
@@ -479,6 +486,11 @@ impl BucketMetadata {
|
||||
"Name" => self.name = read_msgp_str(rd)?,
|
||||
"Created" => self.created = read_msgp_time_value(rd)?,
|
||||
"LockEnabled" => self.lock_enabled = read_msgp_bool(rd)?,
|
||||
"BucketIncarnationID" => {
|
||||
let bytes = read_msgp_bin(rd)?;
|
||||
self.bucket_incarnation_id =
|
||||
Uuid::from_slice(&bytes).map_err(|err| Error::other(format!("invalid BucketIncarnationID: {err}")))?;
|
||||
}
|
||||
"PolicyConfigJSON" | "PolicyConfigJson" => self.policy_config_json = read_msgp_bin(rd)?,
|
||||
"NotificationConfigXML" | "NotificationConfigXml" => self.notification_config_xml = read_msgp_bin(rd)?,
|
||||
"LifecycleConfigXML" | "LifecycleConfigXml" => self.lifecycle_config_xml = read_msgp_bin(rd)?,
|
||||
@@ -535,8 +547,8 @@ impl BucketMetadata {
|
||||
|
||||
/// Encode to msgp bytes. Field order follows MinIO BucketMetadata for compatibility.
|
||||
pub fn encode_to<W: Write>(&self, wr: &mut W) -> Result<()> {
|
||||
// Map size: MinIO fields (25) + RustFS extensions (18)
|
||||
let map_len: u32 = 43;
|
||||
// Map size: MinIO fields (25) + RustFS extensions (19)
|
||||
let map_len: u32 = 44;
|
||||
rmp::encode::write_map_len(wr, map_len)?;
|
||||
|
||||
// MinIO field order (same as Go struct)
|
||||
@@ -549,6 +561,8 @@ impl BucketMetadata {
|
||||
rmp::encode::write_str(wr, "LockEnabled")?;
|
||||
rmp::encode::write_bool(wr, self.lock_enabled)?;
|
||||
|
||||
write_bin_field(wr, "BucketIncarnationID", self.bucket_incarnation_id.as_bytes())?;
|
||||
|
||||
write_bin_field(wr, "PolicyConfigJSON", &self.policy_config_json)?;
|
||||
write_bin_field(wr, "NotificationConfigXML", &self.notification_config_xml)?;
|
||||
write_bin_field(wr, "LifecycleConfigXML", &self.lifecycle_config_xml)?;
|
||||
@@ -748,6 +762,10 @@ impl BucketMetadata {
|
||||
self.quota_config_updated_at = updated;
|
||||
}
|
||||
OBJECT_LOCK_CONFIG => {
|
||||
self.object_lock_config = None;
|
||||
if !data.is_empty() {
|
||||
self.lock_enabled = true;
|
||||
}
|
||||
self.object_lock_config_xml = data;
|
||||
self.object_lock_config_updated_at = updated;
|
||||
}
|
||||
@@ -1115,6 +1133,29 @@ impl BucketMetadata {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn load_bucket_incarnation(api: Arc<ECStore>, bucket: &str) -> Result<Option<Uuid>> {
|
||||
let path = format!("{BUCKET_META_PREFIX}/{bucket}/{BUCKET_INCARNATION_FILE}");
|
||||
let data = match read_config_preserve_empty(api, &path).await {
|
||||
Ok(data) => data,
|
||||
Err(Error::ConfigNotFound) => return Ok(None),
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
let incarnation =
|
||||
Uuid::from_slice(&data).map_err(|err| Error::other(format!("persisted bucket incarnation is invalid: {err}")))?;
|
||||
if incarnation.is_nil() {
|
||||
return Err(Error::other("persisted bucket incarnation is nil"));
|
||||
}
|
||||
Ok(Some(incarnation))
|
||||
}
|
||||
|
||||
pub(crate) async fn save_bucket_incarnation(api: Arc<ECStore>, bucket: &str, incarnation: Uuid) -> Result<()> {
|
||||
if incarnation.is_nil() {
|
||||
return Err(Error::other("cannot persist a nil bucket incarnation"));
|
||||
}
|
||||
let path = format!("{BUCKET_META_PREFIX}/{bucket}/{BUCKET_INCARNATION_FILE}");
|
||||
save_config(api, &path, incarnation.as_bytes().to_vec()).await
|
||||
}
|
||||
|
||||
pub async fn load_bucket_metadata(api: Arc<ECStore>, bucket: &str) -> Result<BucketMetadata> {
|
||||
load_bucket_metadata_parse(api, bucket, true).await
|
||||
}
|
||||
@@ -1142,6 +1183,23 @@ pub(crate) async fn load_bucket_metadata_parse_with_presence(
|
||||
}
|
||||
};
|
||||
|
||||
let incarnation = load_bucket_incarnation(api, bucket).await?;
|
||||
if persisted {
|
||||
if let Some(incarnation) = incarnation {
|
||||
if !bm.bucket_incarnation_id.is_nil() && bm.bucket_incarnation_id != incarnation {
|
||||
return Err(Error::other("bucket incarnation sidecar does not match bucket metadata"));
|
||||
}
|
||||
bm.bucket_incarnation_id = incarnation;
|
||||
bm.bucket_incarnation_sidecar = true;
|
||||
} else if !bm.bucket_incarnation_id.is_nil() {
|
||||
return Err(Error::other(format!(
|
||||
"bucket incarnation sidecar is missing for new-format metadata: {bucket}"
|
||||
)));
|
||||
}
|
||||
} else if incarnation.is_some() {
|
||||
return Err(Error::other("bucket incarnation sidecar exists without bucket metadata"));
|
||||
}
|
||||
|
||||
bm.default_timestamps();
|
||||
|
||||
if parse {
|
||||
@@ -1209,6 +1267,10 @@ mod test {
|
||||
// Same 4-byte format|version header (1|1) and msgpack layout as MinIO.
|
||||
BucketMetadata::check_header(&blob).expect("valid .metadata.bin header");
|
||||
let mut bm = BucketMetadata::unmarshal(&blob[4..]).expect("unmarshal MinIO bucket metadata");
|
||||
assert!(
|
||||
bm.bucket_incarnation_id.is_nil(),
|
||||
"legacy MinIO metadata has no RustFS bucket incarnation field"
|
||||
);
|
||||
|
||||
// Raw config fields survive the msgpack decode (PascalCase MinIO field names).
|
||||
assert_eq!(bm.name, "interop");
|
||||
@@ -1291,6 +1353,42 @@ mod test {
|
||||
let new = BucketMetadata::unmarshal(&buf).unwrap();
|
||||
|
||||
assert_eq!(bm.name, new.name);
|
||||
assert!(!bm.bucket_incarnation_id.is_nil());
|
||||
assert_eq!(bm.bucket_incarnation_id, new.bucket_incarnation_id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bucket_incarnation_msgpack_rejects_invalid_binary_length() {
|
||||
let mut fixture = Vec::new();
|
||||
rmp::encode::write_map_len(&mut fixture, 1).unwrap();
|
||||
rmp::encode::write_str(&mut fixture, "BucketIncarnationID").unwrap();
|
||||
rmp::encode::write_bin(&mut fixture, &[0_u8; 15]).unwrap();
|
||||
|
||||
let err = BucketMetadata::unmarshal(&fixture).expect_err("non-UUID incarnation bytes must fail closed");
|
||||
assert!(err.to_string().contains("invalid BucketIncarnationID"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_name_bucket_metadata_gets_a_new_incarnation() {
|
||||
let old = BucketMetadata::new("recreated");
|
||||
let new = BucketMetadata::new("recreated");
|
||||
|
||||
assert!(!old.bucket_incarnation_id.is_nil());
|
||||
assert!(!new.bucket_incarnation_id.is_nil());
|
||||
assert_ne!(old.bucket_incarnation_id, new.bucket_incarnation_id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn site_replication_config_updates_cannot_replace_bucket_incarnation() {
|
||||
let mut metadata = BucketMetadata::new("site-replication-update");
|
||||
let incarnation = metadata.bucket_incarnation_id;
|
||||
|
||||
metadata
|
||||
.update_config(BUCKET_POLICY_CONFIG, br#"{"Version":"2012-10-17","Statement":[]}"#.to_vec())
|
||||
.unwrap();
|
||||
metadata.update_config(OBJECT_LOCK_CONFIG, Vec::new()).unwrap();
|
||||
|
||||
assert_eq!(metadata.bucket_incarnation_id, incarnation);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,10 +12,12 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use crate::bucket::metadata_sys::get_object_lock_config;
|
||||
use crate::bucket::metadata_sys::{ObjectLockConfigState, get_object_lock_config, get_object_lock_config_state};
|
||||
use crate::bucket::object_lock::objectlock;
|
||||
use crate::error::{Error, Result, StorageError};
|
||||
use crate::object_api::ObjectInfo;
|
||||
use s3s::dto::{DefaultRetention, ObjectLockLegalHoldStatus, ObjectLockRetentionMode};
|
||||
use s3s::dto::{Date, DefaultRetention, ObjectLockConfiguration, ObjectLockLegalHoldStatus, ObjectLockRetentionMode};
|
||||
use s3s::header::{X_AMZ_OBJECT_LOCK_LEGAL_HOLD, X_AMZ_OBJECT_LOCK_MODE, X_AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE};
|
||||
use std::sync::Arc;
|
||||
use time::OffsetDateTime;
|
||||
|
||||
@@ -37,6 +39,20 @@ impl BucketObjectLockSys {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn ensure_recursive_force_delete_allowed_for_state(bucket: &str, state: &ObjectLockConfigState) -> Result<()> {
|
||||
match state {
|
||||
ObjectLockConfigState::ConfirmedAbsent => Ok(()),
|
||||
ObjectLockConfigState::Configured { .. } => Err(StorageError::InvalidArgument(
|
||||
bucket.to_string(),
|
||||
String::new(),
|
||||
"force-delete is forbidden on Object Locking enabled buckets".to_string(),
|
||||
)),
|
||||
ObjectLockConfigState::Fabricated => {
|
||||
Err(Error::other(format!("bucket Object Lock metadata is not authoritative: {bucket}")))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a retention period is still active based on mode and retain_until_date
|
||||
pub fn is_retention_active(mode: &str, retain_until_date: Option<&s3s::dto::Date>) -> bool {
|
||||
if mode != ObjectLockRetentionMode::COMPLIANCE && mode != ObjectLockRetentionMode::GOVERNANCE {
|
||||
@@ -205,71 +221,122 @@ fn check_retention_blocks_deletion(
|
||||
None
|
||||
}
|
||||
|
||||
/// Check an object's lock metadata using an already resolved bucket Object
|
||||
/// Lock configuration. `None` means the configuration is confirmed absent.
|
||||
///
|
||||
/// # S3 Standard Behavior
|
||||
/// - COMPLIANCE mode: Cannot be deleted even with bypass header
|
||||
/// - GOVERNANCE mode: Can be deleted if bypass_governance is true (caller must verify s3:BypassGovernanceRetention permission)
|
||||
/// - Legal Hold: Cannot be bypassed regardless of mode
|
||||
pub async fn check_object_lock_for_deletion(
|
||||
bucket: &str,
|
||||
pub(crate) fn check_object_lock_for_deletion_with_config(
|
||||
config: Option<&ObjectLockConfiguration>,
|
||||
obj_info: &ObjectInfo,
|
||||
bypass_governance: bool,
|
||||
) -> Option<ObjectLockBlockReason> {
|
||||
) -> Result<Option<ObjectLockBlockReason>> {
|
||||
if obj_info.delete_marker {
|
||||
return None;
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// 1. Check legal hold - cannot be bypassed (reuse has_legal_hold)
|
||||
if has_legal_hold(&obj_info.user_defined) {
|
||||
return Some(ObjectLockBlockReason::LegalHold);
|
||||
}
|
||||
|
||||
// 2. Check explicit retention
|
||||
let explicit_ret = objectlock::get_object_retention_meta(&obj_info.user_defined);
|
||||
if let Some(mode) = &explicit_ret.mode {
|
||||
let mode_str = mode.as_str();
|
||||
if is_retention_active(mode_str, explicit_ret.retain_until_date.as_ref())
|
||||
&& let Some(reason) = check_retention_blocks_deletion(
|
||||
mode_str,
|
||||
explicit_ret.retain_until_date.map(OffsetDateTime::from),
|
||||
bypass_governance,
|
||||
)
|
||||
{
|
||||
return Some(reason);
|
||||
if let Some(status) = obj_info.user_defined.get(X_AMZ_OBJECT_LOCK_LEGAL_HOLD.as_str()) {
|
||||
if status.eq_ignore_ascii_case(ObjectLockLegalHoldStatus::ON) {
|
||||
return Ok(Some(ObjectLockBlockReason::LegalHold));
|
||||
}
|
||||
if !status.eq_ignore_ascii_case(ObjectLockLegalHoldStatus::OFF) {
|
||||
return Err(Error::other("persisted object legal-hold metadata is invalid"));
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Check default retention only if no explicit retention is set
|
||||
if explicit_ret.mode.is_none()
|
||||
&& let Some(default_retention) = BucketObjectLockSys::get(bucket).await
|
||||
let mode = obj_info.user_defined.get(X_AMZ_OBJECT_LOCK_MODE.as_str());
|
||||
let retain_until = obj_info.user_defined.get(X_AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE.as_str());
|
||||
let explicit_ret = match (mode, retain_until) {
|
||||
(None, None) => None,
|
||||
(Some(mode), Some(retain_until)) => {
|
||||
let mode =
|
||||
objectlock::parse_ret_mode(mode).ok_or_else(|| Error::other("persisted object retention mode is invalid"))?;
|
||||
let retain_until = OffsetDateTime::parse(retain_until, &time::format_description::well_known::Iso8601::DEFAULT)
|
||||
.map(Date::from)
|
||||
.map_err(|_| Error::other("persisted object retention date is invalid"))?;
|
||||
Some((mode, retain_until))
|
||||
}
|
||||
_ => return Err(Error::other("persisted object retention metadata is incomplete")),
|
||||
};
|
||||
|
||||
if let Some((mode, retain_until)) = &explicit_ret {
|
||||
let mode_str = mode.as_str();
|
||||
if is_retention_active(mode_str, Some(retain_until))
|
||||
&& let Some(reason) =
|
||||
check_retention_blocks_deletion(mode_str, Some(OffsetDateTime::from(retain_until.clone())), bypass_governance)
|
||||
{
|
||||
return Ok(Some(reason));
|
||||
}
|
||||
}
|
||||
|
||||
if explicit_ret.is_none()
|
||||
&& let Some(default_retention) = config.and_then(|config| config.rule.as_ref()?.default_retention.as_ref())
|
||||
&& let Some(mode) = &default_retention.mode
|
||||
{
|
||||
let mode_str = mode.as_str();
|
||||
if mode_str == ObjectLockRetentionMode::COMPLIANCE || mode_str == ObjectLockRetentionMode::GOVERNANCE {
|
||||
// Calculate retention expiration date from object modification time
|
||||
if let Some(mod_time) = obj_info.mod_time {
|
||||
let now = objectlock::utc_now_ntp();
|
||||
let retain_until = if let Some(days) = default_retention.days {
|
||||
mod_time.saturating_add(time::Duration::days(days as i64))
|
||||
} else {
|
||||
let years = default_retention.years?;
|
||||
add_years(mod_time, years)
|
||||
};
|
||||
let mod_time = obj_info
|
||||
.mod_time
|
||||
.ok_or_else(|| Error::other("persisted object modification time is missing"))?;
|
||||
let now = objectlock::utc_now_ntp();
|
||||
let retain_until = if let Some(days) = default_retention.days {
|
||||
mod_time.saturating_add(time::Duration::days(i64::from(days)))
|
||||
} else {
|
||||
let years = default_retention
|
||||
.years
|
||||
.ok_or_else(|| Error::other("persisted bucket Object Lock retention period is invalid"))?;
|
||||
add_years(mod_time, years)
|
||||
};
|
||||
|
||||
if retain_until.unix_timestamp() > now.unix_timestamp()
|
||||
&& let Some(reason) = check_retention_blocks_deletion(mode_str, Some(retain_until), bypass_governance)
|
||||
{
|
||||
return Some(reason);
|
||||
}
|
||||
if retain_until.unix_timestamp() > now.unix_timestamp()
|
||||
&& let Some(reason) = check_retention_blocks_deletion(mode_str, Some(retain_until), bypass_governance)
|
||||
{
|
||||
return Ok(Some(reason));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
pub(crate) fn check_object_lock_for_deletion_with_state(
|
||||
state: &ObjectLockConfigState,
|
||||
obj_info: &ObjectInfo,
|
||||
bypass_governance: bool,
|
||||
) -> Result<Option<ObjectLockBlockReason>> {
|
||||
match state {
|
||||
ObjectLockConfigState::Configured { config, .. } => {
|
||||
check_object_lock_for_deletion_with_config(Some(config), obj_info, bypass_governance)
|
||||
}
|
||||
ObjectLockConfigState::ConfirmedAbsent => check_object_lock_for_deletion_with_config(None, obj_info, bypass_governance),
|
||||
ObjectLockConfigState::Fabricated => Err(Error::other("bucket Object Lock metadata is not authoritative")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Compatibility wrapper for callers that predate fallible metadata lookup.
|
||||
/// An authority/read/parse failure is represented as a blocking reason rather
|
||||
/// than the old fail-open `None` result.
|
||||
pub async fn check_object_lock_for_deletion(
|
||||
bucket: &str,
|
||||
obj_info: &ObjectInfo,
|
||||
bypass_governance: bool,
|
||||
) -> Option<ObjectLockBlockReason> {
|
||||
match get_object_lock_config_state(bucket)
|
||||
.await
|
||||
.and_then(|state| check_object_lock_for_deletion_with_state(&state, obj_info, bypass_governance))
|
||||
{
|
||||
Ok(reason) => reason,
|
||||
Err(_) => Some(ObjectLockBlockReason::LegalHold),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use s3s::dto::{ObjectLockEnabled, ObjectLockRule};
|
||||
use time::{Date, Month, PrimitiveDateTime, Time};
|
||||
|
||||
fn make_datetime(year: i32, month: u8, day: u8) -> OffsetDateTime {
|
||||
@@ -278,6 +345,160 @@ mod tests {
|
||||
PrimitiveDateTime::new(date, time).assume_utc()
|
||||
}
|
||||
|
||||
fn default_retention_config(mode: &'static str) -> ObjectLockConfiguration {
|
||||
ObjectLockConfiguration {
|
||||
object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)),
|
||||
rule: Some(ObjectLockRule {
|
||||
default_retention: Some(DefaultRetention {
|
||||
mode: Some(ObjectLockRetentionMode::from_static(mode)),
|
||||
days: Some(30),
|
||||
years: None,
|
||||
}),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deletion_with_config_blocks_active_default_compliance_even_with_bypass() {
|
||||
let config = default_retention_config(ObjectLockRetentionMode::COMPLIANCE);
|
||||
let obj_info = ObjectInfo {
|
||||
mod_time: Some(OffsetDateTime::now_utc()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let result = check_object_lock_for_deletion_with_config(Some(&config), &obj_info, true);
|
||||
|
||||
assert!(matches!(result, Ok(Some(ObjectLockBlockReason::Retention { .. }))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deletion_with_config_allows_active_default_governance_with_bypass() {
|
||||
let config = default_retention_config(ObjectLockRetentionMode::GOVERNANCE);
|
||||
let obj_info = ObjectInfo {
|
||||
mod_time: Some(OffsetDateTime::now_utc()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert!(matches!(
|
||||
check_object_lock_for_deletion_with_config(Some(&config), &obj_info, true),
|
||||
Ok(None)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deletion_with_default_retention_rejects_missing_object_mod_time() {
|
||||
let config = default_retention_config(ObjectLockRetentionMode::COMPLIANCE);
|
||||
|
||||
let err = check_object_lock_for_deletion_with_config(Some(&config), &ObjectInfo::default(), false)
|
||||
.expect_err("default retention needs an authoritative object modification time");
|
||||
|
||||
assert!(err.to_string().contains("modification time"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deletion_with_confirmed_absence_still_blocks_explicit_compliance() {
|
||||
let retain_until = OffsetDateTime::now_utc() + time::Duration::days(30);
|
||||
let mut user_defined = std::collections::HashMap::new();
|
||||
user_defined.insert("x-amz-object-lock-mode".to_string(), ObjectLockRetentionMode::COMPLIANCE.to_string());
|
||||
user_defined.insert(
|
||||
"x-amz-object-lock-retain-until-date".to_string(),
|
||||
retain_until
|
||||
.format(&time::format_description::well_known::Rfc3339)
|
||||
.expect("retain-until date should format"),
|
||||
);
|
||||
let obj_info = ObjectInfo {
|
||||
user_defined: Arc::new(user_defined),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let result = check_object_lock_for_deletion_with_config(None, &obj_info, true);
|
||||
|
||||
assert!(matches!(result, Ok(Some(ObjectLockBlockReason::Retention { .. }))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deletion_with_fabricated_bucket_metadata_fails_closed() {
|
||||
let err = check_object_lock_for_deletion_with_state(&ObjectLockConfigState::Fabricated, &ObjectInfo::default(), false)
|
||||
.expect_err("non-authoritative Object Lock metadata must block deletion");
|
||||
|
||||
assert!(err.to_string().contains("not authoritative"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recursive_force_delete_with_fabricated_bucket_metadata_fails_closed() {
|
||||
let err = ensure_recursive_force_delete_allowed_for_state("bucket", &ObjectLockConfigState::Fabricated)
|
||||
.expect_err("non-authoritative Object Lock metadata must block recursive deletion");
|
||||
|
||||
assert!(err.to_string().contains("not authoritative"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deletion_rejects_incomplete_persisted_retention_metadata() {
|
||||
let mut user_defined = std::collections::HashMap::new();
|
||||
user_defined.insert(
|
||||
X_AMZ_OBJECT_LOCK_MODE.as_str().to_string(),
|
||||
ObjectLockRetentionMode::COMPLIANCE.to_string(),
|
||||
);
|
||||
let obj_info = ObjectInfo {
|
||||
user_defined: Arc::new(user_defined),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let err = check_object_lock_for_deletion_with_config(None, &obj_info, false)
|
||||
.expect_err("mode without retain-until date must fail closed");
|
||||
|
||||
assert!(err.to_string().contains("incomplete"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deletion_rejects_each_malformed_persisted_retention_shape() {
|
||||
let valid_date = (OffsetDateTime::now_utc() + time::Duration::days(30))
|
||||
.format(&time::format_description::well_known::Rfc3339)
|
||||
.expect("retain-until date should format");
|
||||
let cases = [
|
||||
("invalid mode", Some("INVALID"), Some(valid_date.as_str()), "retention mode"),
|
||||
(
|
||||
"invalid date",
|
||||
Some(ObjectLockRetentionMode::COMPLIANCE),
|
||||
Some("not-a-date"),
|
||||
"retention date",
|
||||
),
|
||||
("date only", None, Some(valid_date.as_str()), "incomplete"),
|
||||
];
|
||||
|
||||
for (case, mode, retain_until, expected) in cases {
|
||||
let mut user_defined = std::collections::HashMap::new();
|
||||
if let Some(mode) = mode {
|
||||
user_defined.insert(X_AMZ_OBJECT_LOCK_MODE.as_str().to_string(), mode.to_string());
|
||||
}
|
||||
if let Some(retain_until) = retain_until {
|
||||
user_defined.insert(X_AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE.as_str().to_string(), retain_until.to_string());
|
||||
}
|
||||
let obj_info = ObjectInfo {
|
||||
user_defined: Arc::new(user_defined),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let err = check_object_lock_for_deletion_with_config(None, &obj_info, false).expect_err(case);
|
||||
assert!(err.to_string().contains(expected), "unexpected {case} error: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deletion_rejects_invalid_persisted_legal_hold_metadata() {
|
||||
let mut user_defined = std::collections::HashMap::new();
|
||||
user_defined.insert(X_AMZ_OBJECT_LOCK_LEGAL_HOLD.as_str().to_string(), "INVALID".to_string());
|
||||
let obj_info = ObjectInfo {
|
||||
user_defined: Arc::new(user_defined),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let err = check_object_lock_for_deletion_with_config(None, &obj_info, false)
|
||||
.expect_err("invalid legal-hold value must fail closed");
|
||||
|
||||
assert!(err.to_string().contains("legal-hold"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_add_years_normal() {
|
||||
// Normal case: add 1 year to a regular date
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
// limitations under the License.
|
||||
|
||||
use super::{BucketQuota, QuotaCheckResult, QuotaError, QuotaOperation};
|
||||
use crate::bucket::metadata_sys::{BucketMetadataSys, update};
|
||||
use crate::bucket::metadata_sys::{BucketMetadataSys, update, update_if_incarnation};
|
||||
use crate::data_usage::get_bucket_usage_memory;
|
||||
use rustfs_common::metrics::Metric;
|
||||
use rustfs_config::QUOTA_CONFIG_FILE;
|
||||
@@ -145,14 +145,35 @@ impl QuotaChecker {
|
||||
}
|
||||
|
||||
pub async fn set_quota_config(&mut self, bucket: &str, quota: BucketQuota) -> Result<OffsetDateTime, QuotaError> {
|
||||
self.set_quota_config_for_incarnation(bucket, quota, None).await
|
||||
}
|
||||
|
||||
pub async fn set_quota_config_if_incarnation(
|
||||
&mut self,
|
||||
bucket: &str,
|
||||
quota: BucketQuota,
|
||||
expected_incarnation_id: uuid::Uuid,
|
||||
) -> Result<OffsetDateTime, QuotaError> {
|
||||
self.set_quota_config_for_incarnation(bucket, quota, Some(expected_incarnation_id))
|
||||
.await
|
||||
}
|
||||
|
||||
async fn set_quota_config_for_incarnation(
|
||||
&mut self,
|
||||
bucket: &str,
|
||||
quota: BucketQuota,
|
||||
expected_incarnation_id: Option<uuid::Uuid>,
|
||||
) -> Result<OffsetDateTime, QuotaError> {
|
||||
let json_data = serde_json::to_vec("a).map_err(|e| QuotaError::InvalidConfig {
|
||||
reason: format!("Failed to serialize quota config: {}", e),
|
||||
})?;
|
||||
let start_time = Instant::now();
|
||||
|
||||
let updated_at = update(bucket, QUOTA_CONFIG_FILE, json_data)
|
||||
.await
|
||||
.map_err(QuotaError::StorageError)?;
|
||||
let updated_at = match expected_incarnation_id {
|
||||
Some(incarnation_id) => update_if_incarnation(bucket, QUOTA_CONFIG_FILE, json_data, incarnation_id).await,
|
||||
None => update(bucket, QUOTA_CONFIG_FILE, json_data).await,
|
||||
}
|
||||
.map_err(QuotaError::StorageError)?;
|
||||
|
||||
rustfs_common::metrics::Metrics::inc_time(Metric::QuotaSync, start_time.elapsed());
|
||||
Ok(updated_at)
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
// limitations under the License.
|
||||
|
||||
use super::replication_error_boundary::Result;
|
||||
use super::replication_storage_boundary::ReplicationObjectIO;
|
||||
use super::replication_storage_boundary::{HTTPPreconditions, ObjectInfo, ObjectOptions, ReplicationObjectIO};
|
||||
use crate::config::{com, storageclass};
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -30,6 +30,13 @@ impl ReplicationConfigStore {
|
||||
com::read_config(api, file).await
|
||||
}
|
||||
|
||||
pub(crate) async fn read_limited<S>(api: Arc<S>, file: &str, max_bytes: usize) -> Result<Vec<u8>>
|
||||
where
|
||||
S: ReplicationObjectIO,
|
||||
{
|
||||
com::read_config_limited(api, file, max_bytes).await
|
||||
}
|
||||
|
||||
pub(crate) async fn read_no_lock<S>(api: Arc<S>, file: &str) -> Result<Vec<u8>>
|
||||
where
|
||||
S: ReplicationObjectIO,
|
||||
@@ -37,6 +44,28 @@ impl ReplicationConfigStore {
|
||||
com::read_config_no_lock(api, file).await
|
||||
}
|
||||
|
||||
pub(crate) async fn read_no_lock_with_metadata<S>(api: Arc<S>, file: &str) -> Result<(Vec<u8>, ObjectInfo)>
|
||||
where
|
||||
S: ReplicationObjectIO,
|
||||
{
|
||||
com::read_config_with_metadata(
|
||||
api,
|
||||
file,
|
||||
&ObjectOptions {
|
||||
no_lock: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn read_no_lock_with_metadata_preserve_empty<S>(api: Arc<S>, file: &str) -> Result<(Vec<u8>, ObjectInfo)>
|
||||
where
|
||||
S: ReplicationObjectIO,
|
||||
{
|
||||
com::read_config_no_lock_preserve_empty_with_metadata(api, file).await
|
||||
}
|
||||
|
||||
pub(crate) async fn save<S>(api: Arc<S>, file: &str, data: Vec<u8>) -> Result<()>
|
||||
where
|
||||
S: ReplicationObjectIO,
|
||||
@@ -50,4 +79,49 @@ impl ReplicationConfigStore {
|
||||
{
|
||||
com::save_config_no_lock(api, file, data).await
|
||||
}
|
||||
|
||||
pub(crate) async fn save_conditional<S>(
|
||||
api: Arc<S>,
|
||||
file: &str,
|
||||
data: Vec<u8>,
|
||||
http_preconditions: HTTPPreconditions,
|
||||
) -> Result<()>
|
||||
where
|
||||
S: ReplicationObjectIO,
|
||||
{
|
||||
com::save_config_with_opts_quiet(
|
||||
api,
|
||||
file,
|
||||
data,
|
||||
&ObjectOptions {
|
||||
max_parity: true,
|
||||
http_preconditions: Some(http_preconditions),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn save_conditional_no_lock<S>(
|
||||
api: Arc<S>,
|
||||
file: &str,
|
||||
data: Vec<u8>,
|
||||
http_preconditions: HTTPPreconditions,
|
||||
) -> Result<()>
|
||||
where
|
||||
S: ReplicationObjectIO,
|
||||
{
|
||||
com::save_config_with_opts_quiet(
|
||||
api,
|
||||
file,
|
||||
data,
|
||||
&ObjectOptions {
|
||||
max_parity: true,
|
||||
no_lock: true,
|
||||
http_preconditions: Some(http_preconditions),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,8 @@ pub(crate) fn replication_state_from_filemeta(state: &rustfs_filemeta::Replicati
|
||||
.map(|(arn, status)| (arn.clone(), version_purge_status_from_filemeta(status.clone())))
|
||||
.collect(),
|
||||
reset_statuses_map: state.reset_statuses_map.clone(),
|
||||
target_delete_marker_version_ids: state.target_delete_marker_version_ids.clone(),
|
||||
target_delete_marker_version_ids_corrupt: state.target_delete_marker_version_ids_corrupt,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,5 +85,7 @@ pub fn replication_state_to_filemeta(state: &ReplicationState) -> rustfs_filemet
|
||||
.map(|(arn, status)| (arn.clone(), version_purge_status_to_filemeta(status.clone())))
|
||||
.collect(),
|
||||
reset_statuses_map: state.reset_statuses_map.clone(),
|
||||
target_delete_marker_version_ids: state.target_delete_marker_version_ids.clone(),
|
||||
target_delete_marker_version_ids_corrupt: state.target_delete_marker_version_ids_corrupt,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,9 @@ pub(crate) struct ReplicationMetadataStore;
|
||||
|
||||
impl ReplicationMetadataStore {
|
||||
pub(crate) const MRF_REPLICATION_FILE: &'static str = "config/replication/mrf.bin";
|
||||
pub(crate) const MRF_REPLICATION_RECOVERY_LOCK: &'static str = "config/replication/mrf.bin.recovery";
|
||||
pub(crate) const FORCE_DELETE_REPLICATION_FILE: &'static str = "config/replication/force-delete.bin";
|
||||
pub(crate) const FORCE_DELETE_REPLICATION_TRANSACTION_LOCK: &'static str = "config/replication/force-delete.bin.transaction";
|
||||
|
||||
pub(crate) async fn replication_config(bucket: &str) -> Result<(ReplicationConfiguration, OffsetDateTime)> {
|
||||
metadata_sys::get_replication_config(bucket).await
|
||||
@@ -110,9 +112,17 @@ mod tests {
|
||||
"buckets/bucket-a/.replication/resync.bin"
|
||||
);
|
||||
assert_eq!(ReplicationMetadataStore::MRF_REPLICATION_FILE, "config/replication/mrf.bin");
|
||||
assert_eq!(
|
||||
ReplicationMetadataStore::MRF_REPLICATION_RECOVERY_LOCK,
|
||||
"config/replication/mrf.bin.recovery"
|
||||
);
|
||||
assert_eq!(
|
||||
ReplicationMetadataStore::FORCE_DELETE_REPLICATION_FILE,
|
||||
"config/replication/force-delete.bin"
|
||||
);
|
||||
assert_eq!(
|
||||
ReplicationMetadataStore::FORCE_DELETE_REPLICATION_TRANSACTION_LOCK,
|
||||
"config/replication/force-delete.bin.transaction"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -566,7 +566,7 @@ pub(crate) async fn must_replicate(bucket: &str, object: &str, mopts: MustReplic
|
||||
let mut sopts = opts.clone();
|
||||
sopts.target_arn = arn.clone();
|
||||
|
||||
let replicate = cfg.replicate(&sopts);
|
||||
let replicate = cfg.replicate(&sopts) && mopts.metadata_target_is_eligible(&arn);
|
||||
let synchronous = if let Some(cli) = cli { cli.replicate_sync } else { false };
|
||||
|
||||
dsc.set(ReplicateTargetDecision::new(arn, replicate, synchronous));
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -23,6 +23,7 @@ pub(crate) use rustfs_replication::{
|
||||
|
||||
pub(crate) const RESYNC_META_FORMAT: u16 = rustfs_replication::resync::RESYNC_META_FORMAT;
|
||||
pub(crate) const RESYNC_META_VERSION: u16 = rustfs_replication::resync::RESYNC_META_VERSION;
|
||||
pub(crate) const RESYNC_FILE_MAX_BYTES: usize = rustfs_replication::RESYNC_FILE_MAX_BYTES;
|
||||
pub(crate) const WIRE_ZERO_TIME_UNIX: i64 = rustfs_replication::resync::WIRE_ZERO_TIME_UNIX;
|
||||
pub(crate) const MRF_META_FORMAT: u16 = rustfs_replication::mrf::MRF_META_FORMAT;
|
||||
pub(crate) const MRF_META_VERSION: u16 = rustfs_replication::mrf::MRF_META_VERSION;
|
||||
|
||||
@@ -1203,12 +1203,19 @@ pub(crate) async fn save_resync_status<S: ReplicationObjectIO>(
|
||||
}
|
||||
|
||||
pub async fn replicate_delete<S: ReplicationStorage>(dobj: DeletedObjectReplicationInfo, storage: Arc<S>) {
|
||||
let _ = replicate_delete_with_outcome(dobj, storage).await;
|
||||
}
|
||||
|
||||
pub(crate) async fn replicate_delete_with_outcome<S: ReplicationStorage>(
|
||||
dobj: DeletedObjectReplicationInfo,
|
||||
storage: Arc<S>,
|
||||
) -> bool {
|
||||
if dobj.delete_object.force_delete {
|
||||
replicate_force_delete_to_targets(&dobj, storage).await;
|
||||
return;
|
||||
return replicate_force_delete_to_targets(&dobj, storage).await;
|
||||
}
|
||||
|
||||
let bucket = dobj.bucket.clone();
|
||||
let mut source_state_verified = true;
|
||||
let version_id = if let Some(version_id) = &dobj.delete_object.delete_marker_version_id {
|
||||
Some(version_id.to_owned())
|
||||
} else {
|
||||
@@ -1245,7 +1252,7 @@ pub async fn replicate_delete<S: ReplicationStorage>(dobj: DeletedObjectReplicat
|
||||
reason = "source_not_delete_marker",
|
||||
"Skipping stale delete-marker replication"
|
||||
);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
Err(err) if is_err_object_not_found(&err) || is_err_version_not_found(&err) => {
|
||||
debug!(
|
||||
@@ -1258,9 +1265,10 @@ pub async fn replicate_delete<S: ReplicationStorage>(dobj: DeletedObjectReplicat
|
||||
reason = "source_version_missing",
|
||||
"Skipping stale delete-marker replication"
|
||||
);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
Err(err) => {
|
||||
source_state_verified = false;
|
||||
debug!(
|
||||
event = EVENT_REPLICATION_DELETE_SKIPPED,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
@@ -1310,7 +1318,7 @@ pub async fn replicate_delete<S: ReplicationStorage>(dobj: DeletedObjectReplicat
|
||||
user_agent: "Internal: [Replication]".to_string(),
|
||||
..Default::default()
|
||||
});
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
};
|
||||
let ns_lock = match storage
|
||||
@@ -1342,7 +1350,7 @@ pub async fn replicate_delete<S: ReplicationStorage>(dobj: DeletedObjectReplicat
|
||||
user_agent: "Internal: [Replication]".to_string(),
|
||||
..Default::default()
|
||||
});
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1372,7 +1380,7 @@ pub async fn replicate_delete<S: ReplicationStorage>(dobj: DeletedObjectReplicat
|
||||
user_agent: "Internal: [Replication]".to_string(),
|
||||
..Default::default()
|
||||
});
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1386,6 +1394,11 @@ pub async fn replicate_delete<S: ReplicationStorage>(dobj: DeletedObjectReplicat
|
||||
|
||||
// Process each target
|
||||
let target_arns = dobj.admitted_target_arns();
|
||||
let expected_targets = dsc
|
||||
.targets_map
|
||||
.values()
|
||||
.filter(|target| target.replicate && (target_arns.is_empty() || target_arns.iter().any(|arn| arn == &target.arn)))
|
||||
.count();
|
||||
for tgt_entry in dsc.targets_map.values() {
|
||||
// Skip targets that should not be replicated
|
||||
if !tgt_entry.replicate {
|
||||
@@ -1465,7 +1478,8 @@ pub async fn replicate_delete<S: ReplicationStorage>(dobj: DeletedObjectReplicat
|
||||
|
||||
let is_version_purge = is_version_delete_replication(&dobj.delete_object);
|
||||
|
||||
if should_retry_delete_marker_purge(&dobj.delete_object) {
|
||||
let requires_delayed_purge = should_retry_delete_marker_purge(&dobj.delete_object);
|
||||
if requires_delayed_purge {
|
||||
let bucket_clone = bucket.clone();
|
||||
let dobj_clone = dobj.clone();
|
||||
let dsc_clone = dsc.clone();
|
||||
@@ -1536,7 +1550,7 @@ pub async fn replicate_delete<S: ReplicationStorage>(dobj: DeletedObjectReplicat
|
||||
EventName::ObjectReplicationFailed.to_string()
|
||||
};
|
||||
|
||||
match storage
|
||||
let state_persisted = match storage
|
||||
.delete_object(
|
||||
&bucket,
|
||||
&dobj.delete_object.object_name,
|
||||
@@ -1558,6 +1572,7 @@ pub async fn replicate_delete<S: ReplicationStorage>(dobj: DeletedObjectReplicat
|
||||
object,
|
||||
..Default::default()
|
||||
});
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
error!(
|
||||
@@ -1583,8 +1598,16 @@ pub async fn replicate_delete<S: ReplicationStorage>(dobj: DeletedObjectReplicat
|
||||
},
|
||||
..Default::default()
|
||||
});
|
||||
false
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
expected_targets > 0
|
||||
&& rinfos.targets.len() == expected_targets
|
||||
&& state_persisted
|
||||
&& source_state_verified
|
||||
&& !requires_delayed_purge
|
||||
&& replication_status == ReplicationStatusType::Completed
|
||||
}
|
||||
|
||||
async fn source_delete_marker_missing<S: EcstoreObjectOperations>(
|
||||
@@ -1611,6 +1634,29 @@ async fn source_delete_marker_missing<S: EcstoreObjectOperations>(
|
||||
}
|
||||
}
|
||||
|
||||
/// Which version a delete-marker purge should address on one target.
|
||||
///
|
||||
/// `None` means do not purge at all: the recorded mapping disagreed across the
|
||||
/// dual internal prefixes, and guessing an id could destroy a live version on
|
||||
/// the target. `Some(id)` is the exact version the target reported when it
|
||||
/// accepted the marker; falling back to a source-derived id is only correct
|
||||
/// when the target mirrors source version ids, which a generic S3 target does
|
||||
/// not.
|
||||
fn delete_marker_purge_version_id(
|
||||
state: Option<&ReplicationState>,
|
||||
arn: &str,
|
||||
delete_marker_version_id: Uuid,
|
||||
) -> Option<Option<String>> {
|
||||
if state.is_some_and(|state| state.target_delete_marker_version_ids_corrupt) {
|
||||
return None;
|
||||
}
|
||||
let recorded = state.and_then(|state| state.target_delete_marker_version_ids.get(arn).cloned());
|
||||
Some(match recorded {
|
||||
Some(version_id) => Some(version_id),
|
||||
None => target_delete_version_id(delete_marker_version_id, true),
|
||||
})
|
||||
}
|
||||
|
||||
async fn replicate_delete_marker_purge_to_targets(bucket: &str, dobj: &DeletedObjectReplicationInfo, dsc: &ReplicateDecision) {
|
||||
let Some(delete_marker_version_id) = dobj.delete_object.delete_marker_version_id else {
|
||||
return;
|
||||
@@ -1628,18 +1674,34 @@ async fn replicate_delete_marker_purge_to_targets(bucket: &str, dobj: &DeletedOb
|
||||
continue;
|
||||
};
|
||||
|
||||
let Some(purge_version_id) = delete_marker_purge_version_id(
|
||||
dobj.delete_object.replication_state.as_ref(),
|
||||
&tgt_entry.arn,
|
||||
delete_marker_version_id,
|
||||
) else {
|
||||
warn!(
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
|
||||
bucket,
|
||||
object = dobj.delete_object.object_name,
|
||||
arn = tgt_entry.arn,
|
||||
"Skipping delete-marker purge: recorded target version metadata is inconsistent"
|
||||
);
|
||||
continue;
|
||||
};
|
||||
|
||||
let _ = tgt_client
|
||||
.remove_object(
|
||||
&tgt_client.bucket,
|
||||
&dobj.delete_object.object_name,
|
||||
target_delete_version_id(delete_marker_version_id, true),
|
||||
purge_version_id,
|
||||
replication_delete_marker_purge_remove_options(dobj.delete_object.delete_marker_mtime),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn replicate_force_delete_to_targets<S: ReplicationStorage>(dobj: &DeletedObjectReplicationInfo, storage: Arc<S>) {
|
||||
async fn replicate_force_delete_to_targets<S: ReplicationStorage>(dobj: &DeletedObjectReplicationInfo, storage: Arc<S>) -> bool {
|
||||
let bucket = &dobj.bucket;
|
||||
let object_name = &dobj.delete_object.object_name;
|
||||
let admitted_target_arns = dobj.admitted_target_arns();
|
||||
@@ -1727,7 +1789,7 @@ async fn replicate_force_delete_to_targets<S: ReplicationStorage>(dobj: &Deleted
|
||||
user_agent: "Internal: [Replication]".to_string(),
|
||||
..Default::default()
|
||||
});
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1755,7 +1817,7 @@ async fn replicate_force_delete_to_targets<S: ReplicationStorage>(dobj: &Deleted
|
||||
user_agent: "Internal: [Replication]".to_string(),
|
||||
..Default::default()
|
||||
});
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1764,6 +1826,9 @@ async fn replicate_force_delete_to_targets<S: ReplicationStorage>(dobj: &Deleted
|
||||
} else {
|
||||
admitted_target_arns
|
||||
};
|
||||
if tgt_arns.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let mut join_set = JoinSet::new();
|
||||
let mut all_succeeded = true;
|
||||
@@ -1888,7 +1953,10 @@ async fn replicate_force_delete_to_targets<S: ReplicationStorage>(dobj: &Deleted
|
||||
error = %error,
|
||||
"Force-delete replication completed but durable intent cleanup failed"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
all_succeeded
|
||||
}
|
||||
|
||||
fn target_delete_version_id(version_id: Uuid, version_purge: bool) -> Option<String> {
|
||||
@@ -1978,16 +2046,24 @@ async fn replicate_delete_to_target(dobj: &DeletedObjectReplicationInfo, tgt_cli
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
Ok(assigned_version_id) => {
|
||||
debug!(
|
||||
bucket = tgt_client.bucket,
|
||||
object = dobj.delete_object.object_name,
|
||||
version_id = ?version_id,
|
||||
assigned_version_id = ?assigned_version_id,
|
||||
delete_marker = dobj.delete_object.delete_marker,
|
||||
is_version_purge,
|
||||
"replicate_delete_to_target succeeded"
|
||||
);
|
||||
if !is_version_purge {
|
||||
// Record the version the target actually assigned to the marker it
|
||||
// just created. A later purge addresses that id directly instead of
|
||||
// deriving one from the source uuid, which only holds when the
|
||||
// target mirrors source version ids.
|
||||
if dobj.delete_object.delete_marker {
|
||||
rinfo.target_delete_marker_version_id = assigned_version_id.filter(|version_id| !version_id.is_empty());
|
||||
}
|
||||
rinfo.replication_status = ReplicationStatusType::Completed;
|
||||
} else {
|
||||
rinfo.version_purge_status = VersionPurgeStatusType::Complete;
|
||||
@@ -2034,6 +2110,13 @@ async fn replicate_delete_to_target(dobj: &DeletedObjectReplicationInfo, tgt_cli
|
||||
}
|
||||
|
||||
pub async fn replicate_object<S: ReplicationStorage>(roi: ReplicateObjectInfo, storage: Arc<S>) -> ReplicationState {
|
||||
replicate_object_with_outcome(roi, storage).await.0
|
||||
}
|
||||
|
||||
pub(crate) async fn replicate_object_with_outcome<S: ReplicationStorage>(
|
||||
roi: ReplicateObjectInfo,
|
||||
storage: Arc<S>,
|
||||
) -> (ReplicationState, bool) {
|
||||
let bucket = roi.bucket.clone();
|
||||
let object = roi.name.clone();
|
||||
|
||||
@@ -2062,7 +2145,7 @@ pub async fn replicate_object<S: ReplicationStorage>(roi: ReplicateObjectInfo, s
|
||||
user_agent: "Internal: [Replication]".to_string(),
|
||||
..Default::default()
|
||||
});
|
||||
return roi.replication_state.unwrap_or_default();
|
||||
return (roi.replication_state.unwrap_or_default(), false);
|
||||
}
|
||||
};
|
||||
let _obj_lock_guard = match obj_ns_lock.get_write_lock(ReplicationLockTiming::acquire_timeout()).await {
|
||||
@@ -2085,7 +2168,7 @@ pub async fn replicate_object<S: ReplicationStorage>(roi: ReplicateObjectInfo, s
|
||||
user_agent: "Internal: [Replication]".to_string(),
|
||||
..Default::default()
|
||||
});
|
||||
return roi.replication_state.unwrap_or_default();
|
||||
return (roi.replication_state.unwrap_or_default(), false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2166,6 +2249,7 @@ pub async fn replicate_object<S: ReplicationStorage>(roi: ReplicateObjectInfo, s
|
||||
let replication_status = merged_state.composite_replication_status();
|
||||
let new_replication_internal = merged_state.replication_status_internal.clone();
|
||||
let mut object_info = roi.to_object_info();
|
||||
let mut state_persisted = true;
|
||||
|
||||
if roi.replication_status_internal != new_replication_internal || rinfos.replication_resynced() {
|
||||
let mut eval_metadata = HashMap::new();
|
||||
@@ -2181,6 +2265,7 @@ pub async fn replicate_object<S: ReplicationStorage>(roi: ReplicateObjectInfo, s
|
||||
match storage.put_object_metadata(&bucket, &object, &popts).await {
|
||||
Ok(u) => object_info = u,
|
||||
Err(e) => {
|
||||
state_persisted = false;
|
||||
// Persisting the resynced replication status failed. Don't swallow
|
||||
// it silently — the object's on-disk status now disagrees with the
|
||||
// resync result and needs operator visibility (backlog#799 B23).
|
||||
@@ -2234,7 +2319,7 @@ pub async fn replicate_object<S: ReplicationStorage>(roi: ReplicateObjectInfo, s
|
||||
}
|
||||
}
|
||||
|
||||
merged_state
|
||||
(merged_state, state_persisted)
|
||||
}
|
||||
|
||||
trait ReplicateObjectInfoExt {
|
||||
@@ -3971,4 +4056,35 @@ mod tests {
|
||||
assert_eq!(target_delete_version_id(Uuid::nil(), true).as_deref(), Some(NULL_VERSION_ID));
|
||||
assert_eq!(target_delete_version_id(Uuid::nil(), false), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_marker_purge_prefers_the_recorded_target_version() {
|
||||
let source = Uuid::new_v4();
|
||||
let arn = "arn:rustfs:replication::target:bucket";
|
||||
|
||||
// No recorded mapping: fall back to deriving from the source uuid.
|
||||
assert_eq!(delete_marker_purge_version_id(None, arn, source), Some(Some(source.to_string())));
|
||||
|
||||
// Recorded mapping wins — a generic S3 target assigns its own id, so the
|
||||
// derived one would purge the wrong version or nothing at all.
|
||||
let mut state = ReplicationState::default();
|
||||
state
|
||||
.target_delete_marker_version_ids
|
||||
.insert(arn.to_string(), "target-assigned-id".to_string());
|
||||
assert_eq!(
|
||||
delete_marker_purge_version_id(Some(&state), arn, source),
|
||||
Some(Some("target-assigned-id".to_string()))
|
||||
);
|
||||
|
||||
// A mapping recorded for a different ARN must not be reused.
|
||||
assert_eq!(
|
||||
delete_marker_purge_version_id(Some(&state), "arn:rustfs:replication::other:bucket", source),
|
||||
Some(Some(source.to_string()))
|
||||
);
|
||||
|
||||
// Inconsistent persisted metadata: refuse to purge rather than guess.
|
||||
let mut corrupt = state.clone();
|
||||
corrupt.target_delete_marker_version_ids_corrupt = true;
|
||||
assert_eq!(delete_marker_purge_version_id(Some(&corrupt), arn, source), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ pub(crate) use crate::storage_api_contracts::list::{
|
||||
};
|
||||
pub(crate) use crate::storage_api_contracts::namespace::NamespaceLocking as StorageNamespaceLocking;
|
||||
pub(crate) use crate::storage_api_contracts::object::{
|
||||
DeletedObject, EcstoreObjectOperations, ObjectIO, ObjectOperations, ObjectToDelete,
|
||||
DeletedObject, EcstoreObjectOperations, HTTPPreconditions, ObjectIO, ObjectOperations, ObjectToDelete,
|
||||
};
|
||||
pub(crate) use crate::storage_api_contracts::range::HTTPRangeSpec;
|
||||
pub(crate) use rustfs_replication::{DeletedObject as ReplicationDeletedObject, ObjectToDelete as ReplicationObjectToDelete};
|
||||
|
||||
@@ -26,8 +26,15 @@ use crate::object_api::ObjectOptions;
|
||||
use crate::storage_api_contracts::object::{ObjectOperations as _, ObjectToDelete};
|
||||
use crate::store::ECStore;
|
||||
use rustfs_lock::MAX_DELETE_LIST;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub async fn delete_object_versions(api: &Arc<ECStore>, bucket: &str, to_del: &[ObjectToDelete], _lc_event: lifecycle::Event) {
|
||||
pub async fn delete_object_versions(
|
||||
api: &Arc<ECStore>,
|
||||
bucket: &str,
|
||||
to_del: &[ObjectToDelete],
|
||||
_lc_event: lifecycle::Event,
|
||||
bucket_incarnation_id: Uuid,
|
||||
) {
|
||||
let delete_config_snapshot = match ReplicationObjectBridge::delete_request_config(api, bucket).await {
|
||||
Ok(snapshot) => Arc::new(snapshot),
|
||||
Err(err) => {
|
||||
@@ -59,6 +66,7 @@ pub async fn delete_object_versions(api: &Arc<ECStore>, bucket: &str, to_del: &[
|
||||
to_del.to_vec(),
|
||||
ObjectOptions {
|
||||
delete_replication_config_snapshot: Some(Arc::clone(&delete_config_snapshot)),
|
||||
expected_bucket_incarnation_id: Some(bucket_incarnation_id),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
|
||||
@@ -99,6 +99,69 @@ impl DeleteBucketEmptyScanBarrier {
|
||||
#[cfg(test)]
|
||||
static DELETE_BUCKET_EMPTY_SCAN_BARRIER: StdMutex<Option<Arc<DeleteBucketEmptyScanBarrier>>> = StdMutex::new(None);
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
enum HealBucketOperation {
|
||||
Make,
|
||||
Delete,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
struct HealBucketOperationFailure {
|
||||
bucket: String,
|
||||
disk_index: usize,
|
||||
operation: HealBucketOperation,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
type HealBucketOperationFailureKey = (String, usize, HealBucketOperation);
|
||||
|
||||
#[cfg(test)]
|
||||
fn heal_bucket_operation_failures() -> &'static StdMutex<HashMap<HealBucketOperationFailureKey, Error>> {
|
||||
static FAILURES: std::sync::OnceLock<StdMutex<HashMap<HealBucketOperationFailureKey, Error>>> = std::sync::OnceLock::new();
|
||||
FAILURES.get_or_init(|| StdMutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl HealBucketOperationFailure {
|
||||
fn install(bucket: &str, disk_index: usize, operation: HealBucketOperation, error: Error) -> Self {
|
||||
let key = (bucket.to_string(), disk_index, operation);
|
||||
let previous = heal_bucket_operation_failures()
|
||||
.lock()
|
||||
.expect("heal bucket failure registry should not poison")
|
||||
.insert(key, error);
|
||||
assert!(previous.is_none(), "heal bucket operation failure already installed");
|
||||
Self {
|
||||
bucket: bucket.to_string(),
|
||||
disk_index,
|
||||
operation,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl Drop for HealBucketOperationFailure {
|
||||
fn drop(&mut self) {
|
||||
heal_bucket_operation_failures()
|
||||
.lock()
|
||||
.expect("heal bucket failure registry should not poison")
|
||||
.remove(&(self.bucket.clone(), self.disk_index, self.operation));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn injected_heal_bucket_operation_error(bucket: &str, disk_index: usize, operation: HealBucketOperation) -> Option<Error> {
|
||||
heal_bucket_operation_failures()
|
||||
.lock()
|
||||
.expect("heal bucket failure registry should not poison")
|
||||
.get(&(bucket.to_string(), disk_index, operation))
|
||||
.cloned()
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
fn injected_heal_bucket_operation_error(_bucket: &str, _disk_index: usize, _operation: HealBucketOperation) -> Option<Error> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn install_delete_bucket_empty_scan_barrier() -> Arc<DeleteBucketEmptyScanBarrier> {
|
||||
let barrier = Arc::new(DeleteBucketEmptyScanBarrier::default());
|
||||
@@ -1207,10 +1270,6 @@ pub(crate) async fn heal_bucket_local_on_disks(
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
if opts.dry_run {
|
||||
return Ok(res);
|
||||
}
|
||||
|
||||
for (disk, state) in disks.iter().zip(before_state.read().await.iter()) {
|
||||
res.before.drives.push(HealDriveInfo {
|
||||
uuid: "".to_string(),
|
||||
@@ -1219,35 +1278,68 @@ pub(crate) async fn heal_bucket_local_on_disks(
|
||||
});
|
||||
}
|
||||
|
||||
if opts.dry_run {
|
||||
for (disk, state) in disks.iter().zip(after_state.read().await.iter()) {
|
||||
res.after.drives.push(HealDriveInfo {
|
||||
uuid: "".to_string(),
|
||||
endpoint: disk.clone().map(|s| s.to_string()).unwrap_or_default(),
|
||||
state: state.to_string(),
|
||||
});
|
||||
}
|
||||
return Ok(res);
|
||||
}
|
||||
|
||||
let mut operation_error = errs
|
||||
.iter()
|
||||
.filter_map(|err| match err {
|
||||
Some(Error::VolumeNotFound) | None => None,
|
||||
Some(err) => Some(err.clone()),
|
||||
})
|
||||
.next();
|
||||
|
||||
if opts.remove && !bucket.starts_with(disk::RUSTFS_META_BUCKET) && !is_all_buckets_not_found(&errs) {
|
||||
let mut futures = Vec::new();
|
||||
for disk in disks.iter() {
|
||||
let disk = disk.clone();
|
||||
for (index, disk) in disks.iter().enumerate() {
|
||||
if matches!(errs[index].as_ref(), Some(Error::DiskNotFound | Error::VolumeNotFound)) {
|
||||
continue;
|
||||
}
|
||||
let Some(disk) = disk.clone() else {
|
||||
continue;
|
||||
};
|
||||
let bucket = bucket.to_string();
|
||||
info!("heal_bucket_local, errs: {:?}, opts: {:?}", errs, opts);
|
||||
futures.push(async move {
|
||||
match disk {
|
||||
Some(disk) => {
|
||||
// Non-force: a bucket that still holds object data refuses
|
||||
// deletion (VolumeNotEmpty) instead of being recursively
|
||||
// wiped, so a misclassified "dangling" bucket cannot lose
|
||||
// data (backlog#799 B1). Surface that refusal instead of
|
||||
// discarding it — it signals the bucket is not dangling.
|
||||
match disk.delete_volume(&bucket, false).await {
|
||||
Ok(()) => None,
|
||||
Err(Error::VolumeNotEmpty) => {
|
||||
warn!("heal declined to remove non-empty bucket {bucket} (not dangling)");
|
||||
None
|
||||
}
|
||||
Err(e) => Some(e),
|
||||
}
|
||||
}
|
||||
None => Some(Error::DiskNotFound),
|
||||
if let Some(err) = injected_heal_bucket_operation_error(&bucket, index, HealBucketOperation::Delete) {
|
||||
return (index, Err(err));
|
||||
}
|
||||
(index, disk.delete_volume(&bucket, false).await)
|
||||
});
|
||||
}
|
||||
|
||||
let _ = join_all(futures).await;
|
||||
for (index, result) in join_all(futures).await {
|
||||
match result {
|
||||
Ok(()) | Err(Error::VolumeNotFound) => {
|
||||
after_state.write().await[index] = DriveState::Missing.to_string();
|
||||
}
|
||||
Err(Error::VolumeNotEmpty) => {
|
||||
warn!(
|
||||
bucket,
|
||||
operation = "heal_bucket_delete_volume",
|
||||
result = "preserved_non_empty_bucket",
|
||||
"heal declined to remove non-empty bucket"
|
||||
);
|
||||
after_state.write().await[index] = DriveState::Ok.to_string();
|
||||
}
|
||||
Err(err) => {
|
||||
after_state.write().await[index] = match &err {
|
||||
Error::DiskNotFound => DriveState::Offline.to_string(),
|
||||
_ => DriveState::Corrupt.to_string(),
|
||||
};
|
||||
if operation_error.is_none() {
|
||||
operation_error = Some(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !opts.remove {
|
||||
@@ -1256,41 +1348,56 @@ pub(crate) async fn heal_bucket_local_on_disks(
|
||||
let disk = disk.clone();
|
||||
let bucket = bucket.to_string();
|
||||
let bs_clone = before_state.clone();
|
||||
let as_clone = after_state.clone();
|
||||
let errs_clone = errs.to_vec();
|
||||
futures.push(async move {
|
||||
if bs_clone.read().await[idx] == DriveState::Missing.to_string() {
|
||||
let Some(disk) = disk.as_ref() else {
|
||||
return Some(Error::DiskNotFound);
|
||||
return (idx, Some(Error::DiskNotFound));
|
||||
};
|
||||
|
||||
info!("bucket not find, will recreate");
|
||||
if let Some(err) = injected_heal_bucket_operation_error(&bucket, idx, HealBucketOperation::Make) {
|
||||
return (idx, Some(err));
|
||||
}
|
||||
match disk.make_volume(&bucket).await {
|
||||
Ok(_) => {
|
||||
as_clone.write().await[idx] = DriveState::Ok.to_string();
|
||||
return None;
|
||||
}
|
||||
Err(err) => {
|
||||
return Some(err);
|
||||
}
|
||||
Ok(()) | Err(Error::VolumeExists) => return (idx, None),
|
||||
Err(err) => return (idx, Some(err)),
|
||||
}
|
||||
}
|
||||
errs_clone[idx].clone()
|
||||
(idx, None)
|
||||
});
|
||||
}
|
||||
|
||||
let _ = join_all(futures).await;
|
||||
for (index, result) in join_all(futures).await {
|
||||
match result {
|
||||
None => {
|
||||
if before_state.read().await[index] == DriveState::Missing.to_string() {
|
||||
after_state.write().await[index] = DriveState::Ok.to_string();
|
||||
}
|
||||
}
|
||||
Some(err) => {
|
||||
after_state.write().await[index] = match &err {
|
||||
Error::DiskNotFound => DriveState::Offline.to_string(),
|
||||
_ => DriveState::Corrupt.to_string(),
|
||||
};
|
||||
if operation_error.is_none() {
|
||||
operation_error = Some(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (disk, state) in disks.iter().zip(after_state.read().await.iter()) {
|
||||
res.before.drives.push(HealDriveInfo {
|
||||
res.after.drives.push(HealDriveInfo {
|
||||
uuid: "".to_string(),
|
||||
endpoint: disk.clone().map(|s| s.to_string()).unwrap_or_default(),
|
||||
state: state.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(res)
|
||||
match operation_error {
|
||||
Some(err) => Err(err),
|
||||
None => Ok(res),
|
||||
}
|
||||
}
|
||||
|
||||
async fn clone_drives() -> Vec<Option<DiskStore>> {
|
||||
@@ -1756,7 +1863,7 @@ mod tests {
|
||||
.await
|
||||
.expect_err("second disk should start missing the bucket");
|
||||
|
||||
heal_bucket_local(
|
||||
let result = heal_bucket_local(
|
||||
bucket,
|
||||
&HealOpts {
|
||||
recreate: true,
|
||||
@@ -1766,6 +1873,25 @@ mod tests {
|
||||
.await
|
||||
.expect("bucket heal should recreate missing volumes");
|
||||
|
||||
assert_eq!(result.before.drives.len(), 2);
|
||||
assert_eq!(result.after.drives.len(), 2);
|
||||
assert!(
|
||||
result
|
||||
.before
|
||||
.drives
|
||||
.iter()
|
||||
.any(|drive| drive.state == DriveState::Missing.to_string()),
|
||||
"one bucket volume must be reported missing before heal"
|
||||
);
|
||||
assert!(
|
||||
result
|
||||
.after
|
||||
.drives
|
||||
.iter()
|
||||
.all(|drive| drive.state == DriveState::Ok.to_string()),
|
||||
"all bucket volumes must be reported healthy after heal"
|
||||
);
|
||||
|
||||
for disk in disks {
|
||||
disk.stat_volume(bucket).await.expect("bucket should exist after heal");
|
||||
}
|
||||
@@ -1773,6 +1899,166 @@ mod tests {
|
||||
reset_local_disk_test_state().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn heal_bucket_local_dry_run_reports_discovered_drive_states() {
|
||||
reset_local_disk_test_state().await;
|
||||
|
||||
let temp_dir = TempDir::new().expect("create temp dir for bucket heal dry-run regression");
|
||||
let disks = init_test_local_disks(&temp_dir, 2, "heal-bucket-local-dry-run-reports-state").await;
|
||||
let bucket = "dry-run-healed-bucket";
|
||||
disks[0]
|
||||
.make_volume(bucket)
|
||||
.await
|
||||
.expect("bucket should exist on the first disk");
|
||||
|
||||
let result = heal_bucket_local_on_disks(
|
||||
bucket,
|
||||
&HealOpts {
|
||||
dry_run: true,
|
||||
..Default::default()
|
||||
},
|
||||
vec![Some(disks[0].clone()), Some(disks[1].clone()), None],
|
||||
)
|
||||
.await
|
||||
.expect("dry-run bucket heal should inspect disks");
|
||||
|
||||
assert_eq!(result.before.drives.len(), 3);
|
||||
assert_eq!(result.after.drives.len(), 3);
|
||||
assert_eq!(result.before.drives[0].state, DriveState::Ok.to_string());
|
||||
assert_eq!(result.before.drives[1].state, DriveState::Missing.to_string());
|
||||
assert_eq!(result.before.drives[2].state, DriveState::Offline.to_string());
|
||||
for (before, after) in result.before.drives.iter().zip(&result.after.drives) {
|
||||
assert_eq!(after.endpoint, before.endpoint);
|
||||
assert_eq!(after.state, before.state);
|
||||
}
|
||||
assert!(matches!(disks[1].stat_volume(bucket).await, Err(Error::VolumeNotFound)));
|
||||
|
||||
reset_local_disk_test_state().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn heal_bucket_local_propagates_recreate_failure() {
|
||||
reset_local_disk_test_state().await;
|
||||
|
||||
let temp_dir = TempDir::new().expect("create temp dir for bucket recreate failure regression");
|
||||
let disks = init_test_local_disks(&temp_dir, 2, "heal-bucket-local-propagates-recreate-failure").await;
|
||||
let bucket = "recreate-failure-bucket";
|
||||
disks[0]
|
||||
.make_volume(bucket)
|
||||
.await
|
||||
.expect("bucket should exist on the first disk");
|
||||
let _failure = HealBucketOperationFailure::install(bucket, 1, HealBucketOperation::Make, Error::DiskAccessDenied);
|
||||
|
||||
let error = heal_bucket_local_on_disks(
|
||||
bucket,
|
||||
&HealOpts {
|
||||
recreate: true,
|
||||
..Default::default()
|
||||
},
|
||||
disks.iter().cloned().map(Some).collect(),
|
||||
)
|
||||
.await
|
||||
.expect_err("failed volume recreation must fail bucket heal");
|
||||
|
||||
assert_eq!(error, Error::DiskAccessDenied);
|
||||
assert!(matches!(disks[1].stat_volume(bucket).await, Err(Error::VolumeNotFound)));
|
||||
|
||||
reset_local_disk_test_state().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn heal_bucket_local_propagates_delete_failure() {
|
||||
reset_local_disk_test_state().await;
|
||||
|
||||
let temp_dir = TempDir::new().expect("create temp dir for bucket delete failure regression");
|
||||
let disks = init_test_local_disks(&temp_dir, 2, "heal-bucket-local-propagates-delete-failure").await;
|
||||
let bucket = "delete-failure-bucket";
|
||||
disks[0]
|
||||
.make_volume(bucket)
|
||||
.await
|
||||
.expect("bucket should exist on the first disk");
|
||||
let _failure = HealBucketOperationFailure::install(bucket, 0, HealBucketOperation::Delete, Error::DiskAccessDenied);
|
||||
|
||||
let error = heal_bucket_local_on_disks(
|
||||
bucket,
|
||||
&HealOpts {
|
||||
remove: true,
|
||||
..Default::default()
|
||||
},
|
||||
disks.iter().cloned().map(Some).collect(),
|
||||
)
|
||||
.await
|
||||
.expect_err("failed volume deletion must fail bucket heal");
|
||||
|
||||
assert_eq!(error, Error::DiskAccessDenied);
|
||||
disks[0]
|
||||
.stat_volume(bucket)
|
||||
.await
|
||||
.expect("failed deletion must leave the bucket volume present");
|
||||
|
||||
reset_local_disk_test_state().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn heal_bucket_local_preserves_non_empty_bucket() {
|
||||
reset_local_disk_test_state().await;
|
||||
|
||||
let temp_dir = TempDir::new().expect("create temp dir for non-empty bucket heal regression");
|
||||
let disks = init_test_local_disks(&temp_dir, 1, "heal-bucket-local-preserves-non-empty").await;
|
||||
let bucket = "non-empty-bucket";
|
||||
disks[0]
|
||||
.make_volume(bucket)
|
||||
.await
|
||||
.expect("bucket should exist on the first disk");
|
||||
let _failure = HealBucketOperationFailure::install(bucket, 0, HealBucketOperation::Delete, Error::VolumeNotEmpty);
|
||||
|
||||
let result = heal_bucket_local_on_disks(
|
||||
bucket,
|
||||
&HealOpts {
|
||||
remove: true,
|
||||
..Default::default()
|
||||
},
|
||||
disks.iter().cloned().map(Some).collect(),
|
||||
)
|
||||
.await
|
||||
.expect("a non-empty bucket refusal is an expected safety result");
|
||||
|
||||
assert_eq!(result.after.drives.len(), 1);
|
||||
assert_eq!(result.after.drives[0].state, DriveState::Ok.to_string());
|
||||
disks[0]
|
||||
.stat_volume(bucket)
|
||||
.await
|
||||
.expect("the non-empty bucket must remain present");
|
||||
|
||||
reset_local_disk_test_state().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn heal_bucket_local_propagates_preexisting_offline_disk() {
|
||||
reset_local_disk_test_state().await;
|
||||
|
||||
let temp_dir = TempDir::new().expect("create temp dir for offline bucket heal regression");
|
||||
let disks = init_test_local_disks(&temp_dir, 1, "heal-bucket-local-preexisting-offline").await;
|
||||
let bucket = "offline-disk-bucket";
|
||||
disks[0]
|
||||
.make_volume(bucket)
|
||||
.await
|
||||
.expect("bucket should exist on the online disk");
|
||||
|
||||
let error = heal_bucket_local_on_disks(bucket, &HealOpts::default(), vec![Some(disks[0].clone()), None])
|
||||
.await
|
||||
.expect_err("a prepass offline disk must keep the bucket heal incomplete");
|
||||
|
||||
assert_eq!(error, Error::DiskNotFound);
|
||||
|
||||
reset_local_disk_test_state().await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reduce_pool_write_quorum_uses_only_pool_participants() {
|
||||
let clients = vec![
|
||||
|
||||
@@ -1117,15 +1117,31 @@ impl RemoteDisk {
|
||||
}
|
||||
|
||||
/// Initial capacity hint (bytes) for msgpack encode buffers, sized to cover a typical single-
|
||||
/// version `FileInfo` without repeated growth reallocations. Larger payloads still grow as needed.
|
||||
/// request without repeated growth reallocations. Larger payloads still grow as needed.
|
||||
const MSGPACK_ENCODE_CAPACITY_HINT: usize = 512;
|
||||
const FILE_INFO_MSGPACK_ENCODE_CAPACITY_HINT: usize = 1024;
|
||||
|
||||
fn encode_msgpack<T: Serialize>(value: &T) -> Result<Vec<u8>> {
|
||||
let mut serializer = rmp_serde::Serializer::new(Vec::with_capacity(MSGPACK_ENCODE_CAPACITY_HINT));
|
||||
fn encode_msgpack_with_capacity<T: Serialize>(value: &T, capacity: usize) -> Result<Vec<u8>> {
|
||||
let mut serializer = rmp_serde::Serializer::new(Vec::with_capacity(capacity));
|
||||
value.serialize(&mut serializer)?;
|
||||
Ok(serializer.into_inner())
|
||||
}
|
||||
|
||||
fn encode_msgpack<T: Serialize>(value: &T) -> Result<Vec<u8>> {
|
||||
encode_msgpack_with_capacity(value, MSGPACK_ENCODE_CAPACITY_HINT)
|
||||
}
|
||||
|
||||
fn encode_file_info_msgpack(value: &FileInfo) -> Result<Vec<u8>> {
|
||||
encode_msgpack_with_capacity(value, FILE_INFO_MSGPACK_ENCODE_CAPACITY_HINT)
|
||||
}
|
||||
|
||||
fn encode_file_info_versions_msgpack(value: &FileInfoVersions) -> Result<Vec<u8>> {
|
||||
let version_count = value.versions.len().saturating_add(value.free_versions.len());
|
||||
let capacity =
|
||||
MSGPACK_ENCODE_CAPACITY_HINT.saturating_add(FILE_INFO_MSGPACK_ENCODE_CAPACITY_HINT.saturating_mul(version_count));
|
||||
encode_msgpack_with_capacity(value, capacity)
|
||||
}
|
||||
|
||||
/// JSON compatibility string for a dual-encoded (`_bin` + text) request field. Returns an empty
|
||||
/// string only when msgpack-only mode and its explicit fleet confirmation guard are both enabled;
|
||||
/// otherwise the legacy JSON encoding is retained for old peers.
|
||||
@@ -1136,12 +1152,6 @@ fn compat_json<T: Serialize>(value: &T) -> Result<String> {
|
||||
Ok(serde_json::to_string(value)?)
|
||||
}
|
||||
|
||||
fn encode_msgpack_named<T: Serialize>(value: &T) -> Result<Vec<u8>> {
|
||||
let mut serializer = rmp_serde::Serializer::new(Vec::with_capacity(MSGPACK_ENCODE_CAPACITY_HINT)).with_struct_map();
|
||||
value.serialize(&mut serializer)?;
|
||||
Ok(serializer.into_inner())
|
||||
}
|
||||
|
||||
fn decode_msgpack_or_json<T: DeserializeOwned>(binary: &[u8], json: &str, value_name: &'static str) -> Result<T> {
|
||||
if !binary.is_empty() {
|
||||
let mut deserializer = rmp_serde::Deserializer::new(Cursor::new(binary));
|
||||
@@ -1580,7 +1590,7 @@ impl DiskAPI for RemoteDisk {
|
||||
|| async {
|
||||
// `_bin` support for DeleteVersion is new (grpc-optimization P2); always dual-write
|
||||
// JSON + msgpack until its fallback counter has read zero across a release window.
|
||||
let file_info_bin = encode_msgpack(&fi)?;
|
||||
let file_info_bin = encode_file_info_msgpack(&fi)?;
|
||||
let opts_bin = encode_msgpack(&opts)?;
|
||||
let file_info = serde_json::to_string(&fi)?;
|
||||
let opts = serde_json::to_string(&opts)?;
|
||||
@@ -1670,7 +1680,7 @@ impl DiskAPI for RemoteDisk {
|
||||
return errors;
|
||||
}
|
||||
});
|
||||
versions_bin.push(match encode_msgpack(file_info_versions) {
|
||||
versions_bin.push(match encode_file_info_versions_msgpack(file_info_versions) {
|
||||
Ok(versions_bin) => Bytes::from(versions_bin),
|
||||
Err(err) => {
|
||||
let mut errors = Vec::with_capacity(versions.len());
|
||||
@@ -1886,7 +1896,7 @@ impl DiskAPI for RemoteDisk {
|
||||
"Remote disk RPC started"
|
||||
);
|
||||
let file_info = compat_json(&fi)?;
|
||||
let file_info_bin = encode_msgpack(&fi)?;
|
||||
let file_info_bin = encode_file_info_msgpack(&fi)?;
|
||||
|
||||
self.execute_with_timeout_for_op(
|
||||
"write_metadata",
|
||||
@@ -1965,7 +1975,7 @@ impl DiskAPI for RemoteDisk {
|
||||
);
|
||||
let file_info = compat_json(&fi)?;
|
||||
let opts_str = compat_json(&opts)?;
|
||||
let file_info_bin = encode_msgpack(&fi)?;
|
||||
let file_info_bin = encode_file_info_msgpack(&fi)?;
|
||||
let opts_bin = encode_msgpack(opts)?;
|
||||
|
||||
self.execute_with_timeout_for_op(
|
||||
@@ -2229,7 +2239,7 @@ impl DiskAPI for RemoteDisk {
|
||||
"rename_data",
|
||||
|| async {
|
||||
let file_info = compat_json(&fi)?;
|
||||
let file_info_bin = encode_msgpack_named(&fi)?;
|
||||
let file_info_bin = encode_file_info_msgpack(&fi)?;
|
||||
let mut client = self
|
||||
.get_client()
|
||||
.await
|
||||
@@ -3371,6 +3381,8 @@ mod tests {
|
||||
crate::cluster::rpc::runtime_sources::reset_internode_metrics_for_test();
|
||||
let response = RenameDataResp {
|
||||
old_data_dir: Some(Uuid::new_v4()),
|
||||
rollback_data_dir: Some(Uuid::new_v4()),
|
||||
cleanup_data_dir: Some(Uuid::new_v4()),
|
||||
sign: Some(vec![0x14, 0x35]),
|
||||
old_current_size: Some(crate::disk::OldCurrentSize::Present(64 * 1024)),
|
||||
};
|
||||
@@ -3384,6 +3396,8 @@ mod tests {
|
||||
let decode_errors_after = crate::cluster::rpc::runtime_sources::internode_msgpack_json_decode_error_total_for_test();
|
||||
|
||||
assert_eq!(decoded.old_data_dir, response.old_data_dir);
|
||||
assert_eq!(decoded.rollback_data_dir, response.rollback_data_dir);
|
||||
assert_eq!(decoded.cleanup_data_dir, response.cleanup_data_dir);
|
||||
assert_eq!(decoded.sign, response.sign);
|
||||
assert_eq!(decoded.old_current_size, response.old_current_size);
|
||||
assert!(
|
||||
@@ -3733,8 +3747,13 @@ mod tests {
|
||||
fn rename_data_file_info_named_msgpack_is_smaller_than_json() {
|
||||
let file_info = sample_rename_data_file_info();
|
||||
let json = serde_json::to_vec(&file_info).expect("file info json should encode");
|
||||
let named_msgpack = encode_msgpack_named(&file_info).expect("file info named msgpack should encode");
|
||||
let named_msgpack = encode_file_info_msgpack(&file_info).expect("file info named msgpack should encode");
|
||||
|
||||
assert!(
|
||||
named_msgpack.len() <= FILE_INFO_MSGPACK_ENCODE_CAPACITY_HINT,
|
||||
"typical FileInfo should fit the msgpack capacity hint (msgpack={}, hint={FILE_INFO_MSGPACK_ENCODE_CAPACITY_HINT})",
|
||||
named_msgpack.len()
|
||||
);
|
||||
assert!(
|
||||
named_msgpack.len() < json.len(),
|
||||
"expected named msgpack payload to be smaller than json (msgpack={}, json={})",
|
||||
@@ -3747,11 +3766,13 @@ mod tests {
|
||||
fn rename_data_resp_named_msgpack_is_smaller_than_json() {
|
||||
let response = RenameDataResp {
|
||||
old_data_dir: Some(Uuid::new_v4()),
|
||||
rollback_data_dir: Some(Uuid::new_v4()),
|
||||
cleanup_data_dir: Some(Uuid::new_v4()),
|
||||
sign: Some(vec![1_u8; 32]),
|
||||
old_current_size: Some(crate::disk::OldCurrentSize::Present(4096)),
|
||||
};
|
||||
let json = serde_json::to_vec(&response).expect("rename data response json should encode");
|
||||
let named_msgpack = encode_msgpack_named(&response).expect("rename data response named msgpack should encode");
|
||||
let named_msgpack = rmp_serde::encode::to_vec_named(&response).expect("rename data response named msgpack should encode");
|
||||
|
||||
assert!(
|
||||
named_msgpack.len() < json.len(),
|
||||
|
||||
@@ -51,6 +51,7 @@ use serde_json::{Map, Value};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::LazyLock;
|
||||
use std::sync::{Arc, RwLock};
|
||||
use tokio::io::AsyncReadExt;
|
||||
use tokio::sync::{OwnedRwLockWriteGuard, RwLock as AsyncRwLock};
|
||||
use tracing::{debug, error, info, instrument, warn};
|
||||
use uuid::Uuid;
|
||||
@@ -400,6 +401,14 @@ where
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
pub(crate) async fn read_config_limited<S>(api: Arc<S>, file: &str, max_bytes: usize) -> Result<Vec<u8>>
|
||||
where
|
||||
S: EcstoreObjectIO,
|
||||
{
|
||||
let (data, _obj) = read_config_with_metadata_inner(api, file, &ObjectOptions::default(), false, Some(max_bytes)).await?;
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
/// Read an existing config object without treating an empty payload as absent.
|
||||
/// Callers that validate their own payload format need to distinguish corruption
|
||||
/// from `ConfigNotFound`.
|
||||
@@ -407,7 +416,7 @@ pub(crate) async fn read_config_preserve_empty<S>(api: Arc<S>, file: &str) -> Re
|
||||
where
|
||||
S: EcstoreObjectIO,
|
||||
{
|
||||
let (data, _obj) = read_config_with_metadata_inner(api, file, &ObjectOptions::default(), true).await?;
|
||||
let (data, _obj) = read_config_with_metadata_inner(api, file, &ObjectOptions::default(), true, None).await?;
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
@@ -435,6 +444,23 @@ where
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
pub(crate) async fn read_config_no_lock_preserve_empty_with_metadata<S>(api: Arc<S>, file: &str) -> Result<(Vec<u8>, ObjectInfo)>
|
||||
where
|
||||
S: EcstoreObjectIO,
|
||||
{
|
||||
read_config_with_metadata_inner(
|
||||
api,
|
||||
file,
|
||||
&ObjectOptions {
|
||||
no_lock: true,
|
||||
..Default::default()
|
||||
},
|
||||
true,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn read_config_with_metadata<S>(api: Arc<S>, file: &str, opts: &ObjectOptions) -> Result<(Vec<u8>, ObjectInfo)>
|
||||
where
|
||||
S: ObjectIO<
|
||||
@@ -447,7 +473,7 @@ where
|
||||
PutObjectReader = PutObjReader,
|
||||
>,
|
||||
{
|
||||
read_config_with_metadata_inner(api, file, opts, false).await
|
||||
read_config_with_metadata_inner(api, file, opts, false, None).await
|
||||
}
|
||||
|
||||
async fn read_config_with_metadata_inner<S>(
|
||||
@@ -455,6 +481,7 @@ async fn read_config_with_metadata_inner<S>(
|
||||
file: &str,
|
||||
opts: &ObjectOptions,
|
||||
preserve_empty: bool,
|
||||
max_bytes: Option<usize>,
|
||||
) -> Result<(Vec<u8>, ObjectInfo)>
|
||||
where
|
||||
S: ObjectIO<
|
||||
@@ -480,7 +507,25 @@ where
|
||||
}
|
||||
})?;
|
||||
|
||||
let data = rd.read_all().await?;
|
||||
let data = if let Some(max_bytes) = max_bytes {
|
||||
let object_size = usize::try_from(rd.object_info.size).map_err(|_| Error::CorruptedFormat)?;
|
||||
if object_size > max_bytes {
|
||||
return Err(Error::CorruptedFormat);
|
||||
}
|
||||
|
||||
let read_limit = max_bytes.checked_add(1).ok_or(Error::CorruptedFormat)?;
|
||||
let mut data = Vec::with_capacity(read_limit.min(64 * 1024));
|
||||
(&mut rd)
|
||||
.take(u64::try_from(read_limit).map_err(|_| Error::CorruptedFormat)?)
|
||||
.read_to_end(&mut data)
|
||||
.await?;
|
||||
if data.len() > max_bytes {
|
||||
return Err(Error::CorruptedFormat);
|
||||
}
|
||||
data
|
||||
} else {
|
||||
rd.read_all().await?
|
||||
};
|
||||
|
||||
if data.is_empty() && !preserve_empty {
|
||||
return Err(Error::ConfigNotFound);
|
||||
@@ -586,10 +631,47 @@ where
|
||||
PutObjectReader = PutObjReader,
|
||||
>,
|
||||
{
|
||||
save_config_with_opts_and_metadata(api, file, data, opts).await.map(|_| ())
|
||||
save_config_with_opts_inner(api, file, data, opts, true).await.map(|_| ())
|
||||
}
|
||||
|
||||
/// Saves a configuration object without logging an error for a retryable caller-owned failure.
|
||||
pub async fn save_config_with_opts_quiet<S>(api: Arc<S>, file: &str, data: Vec<u8>, opts: &ObjectOptions) -> Result<()>
|
||||
where
|
||||
S: ObjectIO<
|
||||
Error = Error,
|
||||
RangeSpec = HTTPRangeSpec,
|
||||
HeaderMap = HeaderMap,
|
||||
ObjectOptions = ObjectOptions,
|
||||
ObjectInfo = ObjectInfo,
|
||||
GetObjectReader = GetObjectReader,
|
||||
PutObjectReader = PutObjReader,
|
||||
>,
|
||||
{
|
||||
save_config_with_opts_inner(api, file, data, opts, false).await.map(|_| ())
|
||||
}
|
||||
|
||||
async fn save_config_with_opts_and_metadata<S>(api: Arc<S>, file: &str, data: Vec<u8>, opts: &ObjectOptions) -> Result<ObjectInfo>
|
||||
where
|
||||
S: ObjectIO<
|
||||
Error = Error,
|
||||
RangeSpec = HTTPRangeSpec,
|
||||
HeaderMap = HeaderMap,
|
||||
ObjectOptions = ObjectOptions,
|
||||
ObjectInfo = ObjectInfo,
|
||||
GetObjectReader = GetObjectReader,
|
||||
PutObjectReader = PutObjReader,
|
||||
>,
|
||||
{
|
||||
save_config_with_opts_inner(api, file, data, opts, true).await
|
||||
}
|
||||
|
||||
async fn save_config_with_opts_inner<S>(
|
||||
api: Arc<S>,
|
||||
file: &str,
|
||||
data: Vec<u8>,
|
||||
opts: &ObjectOptions,
|
||||
log_error: bool,
|
||||
) -> Result<ObjectInfo>
|
||||
where
|
||||
S: ObjectIO<
|
||||
Error = Error,
|
||||
@@ -605,7 +687,9 @@ where
|
||||
match api.put_object(RUSTFS_META_BUCKET, file, &mut put_data, opts).await {
|
||||
Ok(object_info) => Ok(object_info),
|
||||
Err(err) => {
|
||||
error!("save_config_with_opts: err: {:?}, file: {}", err, file);
|
||||
if log_error {
|
||||
error!("save_config_with_opts: err: {:?}, file: {}", err, file);
|
||||
}
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
@@ -2299,7 +2383,7 @@ where
|
||||
let lock = api.new_ns_lock(RUSTFS_META_BUCKET, &transaction_lock).await?;
|
||||
let guard = lock.get_write_lock(get_lock_acquire_timeout()).await?;
|
||||
let read_options = ObjectOptions::default();
|
||||
match read_config_with_metadata_inner(api, &config_file, &read_options, true).await {
|
||||
match read_config_with_metadata_inner(api, &config_file, &read_options, true, None).await {
|
||||
Ok((raw, object_info)) => {
|
||||
let (config, seed) = decode_persisted_server_config_with_seed(&raw)?;
|
||||
Ok(ServerConfigSnapshot {
|
||||
@@ -2555,9 +2639,10 @@ mod tests {
|
||||
use super::{
|
||||
SERVER_CONFIG_LOCK, ServerConfigSnapshot, apply_dynamic_config_for_sub_sys_with, config_task_join_error,
|
||||
configs_semantically_equal, decode_server_config_blob, encode_server_config_blob, is_standard_object_server_config,
|
||||
lookup_configs, new_and_save_server_config, read_config, read_config_preserve_empty, read_config_with_metadata,
|
||||
read_config_without_migrate, read_server_config_snapshot, save_server_config, save_server_config_snapshot,
|
||||
save_server_config_snapshot_with_generation, server_config_transaction_lock_path, storage_class_kvs_mut,
|
||||
lookup_configs, new_and_save_server_config, read_config, read_config_no_lock_preserve_empty_with_metadata,
|
||||
read_config_preserve_empty, read_config_with_metadata, read_config_without_migrate, read_server_config_snapshot,
|
||||
save_server_config, save_server_config_snapshot, save_server_config_snapshot_with_generation,
|
||||
server_config_transaction_lock_path, storage_class_kvs_mut,
|
||||
};
|
||||
use crate::config::{audit, heal, notify, oidc, scanner};
|
||||
use crate::disk::endpoint::Endpoint;
|
||||
@@ -4988,10 +5073,15 @@ mod tests {
|
||||
.expect_err("the existing config contract treats empty objects as missing");
|
||||
assert!(matches!(err, Error::ConfigNotFound));
|
||||
|
||||
let data = read_config_preserve_empty(store, "config/empty.json")
|
||||
let data = read_config_preserve_empty(store.clone(), "config/empty.json")
|
||||
.await
|
||||
.expect("payload-validating callers must observe the empty object");
|
||||
assert!(data.is_empty());
|
||||
|
||||
let (data, _) = read_config_no_lock_preserve_empty_with_metadata(store, "config/empty.json")
|
||||
.await
|
||||
.expect("no-lock payload-validating callers must observe the empty object");
|
||||
assert!(data.is_empty());
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
|
||||
@@ -18,13 +18,13 @@ use crate::bucket::{
|
||||
lifecycle::{
|
||||
bucket_lifecycle_audit::LcEventSrc,
|
||||
bucket_lifecycle_ops::{
|
||||
LifecycleOps, apply_expiry_on_transitioned_object, apply_expiry_rule, eval_action_from_lifecycle,
|
||||
LifecycleOps, apply_expiry_on_transitioned_object, apply_expiry_rule_in, eval_action_from_lifecycle,
|
||||
lifecycle_delete_all_versions_blocked_by_replication,
|
||||
},
|
||||
get_expiry_configs,
|
||||
lifecycle::IlmAction,
|
||||
},
|
||||
metadata_sys,
|
||||
object_lock::objectlock_sys::BucketObjectLockSys,
|
||||
};
|
||||
use crate::cache_value::metacache_set::{ListPathRawOptions, list_path_raw};
|
||||
use crate::config::com::{CONFIG_PREFIX, read_config, read_config_no_lock, save_config, save_config_with_opts};
|
||||
@@ -60,7 +60,7 @@ use rustfs_common::defer;
|
||||
use rustfs_common::heal_channel::HealOpts;
|
||||
use rustfs_filemeta::{FileInfoVersions, MetaCacheEntries, MetaCacheEntry, MetadataResolutionParams};
|
||||
use rustfs_utils::path::{encode_dir_object, path_join, path_to_bucket_object, path_to_bucket_object_with_base_path};
|
||||
use s3s::dto::{BucketLifecycleConfiguration, DefaultRetention, ReplicationConfiguration};
|
||||
use s3s::dto::{BucketLifecycleConfiguration, ObjectLockConfiguration, ReplicationConfiguration};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fmt::Display;
|
||||
@@ -2192,7 +2192,7 @@ pub(crate) async fn should_skip_lifecycle_for_data_movement(
|
||||
bucket: &str,
|
||||
version: &rustfs_filemeta::FileInfo,
|
||||
lifecycle_config: Option<&BucketLifecycleConfiguration>,
|
||||
lock_retention: Option<DefaultRetention>,
|
||||
object_lock_config: Option<&ObjectLockConfiguration>,
|
||||
apply_actions: bool,
|
||||
event_source: &LcEventSrc,
|
||||
) -> Result<bool> {
|
||||
@@ -2202,12 +2202,16 @@ pub(crate) async fn should_skip_lifecycle_for_data_movement(
|
||||
|
||||
let versioned = BucketVersioningSys::prefix_enabled(bucket, &version.name).await;
|
||||
let object_info = crate::object_api::ObjectInfo::from_file_info(version, bucket, &version.name, versioned);
|
||||
let event = eval_action_from_lifecycle(lifecycle_config, lock_retention, &object_info).await;
|
||||
let event = eval_action_from_lifecycle(lifecycle_config, object_lock_config, &object_info).await;
|
||||
|
||||
match event.action {
|
||||
IlmAction::DeleteRestoredAction | IlmAction::DeleteRestoredVersionAction => {
|
||||
if apply_actions && object_info.is_remote() {
|
||||
let _ = apply_expiry_on_transitioned_object(store, &object_info, &event, event_source).await;
|
||||
let Ok(bucket_incarnation_id) = store.bucket_incarnation_id_from_disk(bucket).await else {
|
||||
return Ok(false);
|
||||
};
|
||||
let _ =
|
||||
apply_expiry_on_transitioned_object(store, &object_info, &event, event_source, bucket_incarnation_id).await;
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
@@ -2215,7 +2219,7 @@ pub(crate) async fn should_skip_lifecycle_for_data_movement(
|
||||
if lifecycle_delete_all_versions_blocked_by_replication(store.clone(), bucket, &object_info.name, action).await? {
|
||||
return Ok(false);
|
||||
}
|
||||
let applied = !apply_actions || apply_expiry_rule(&event, event_source, &object_info).await;
|
||||
let applied = !apply_actions || apply_expiry_rule_in(store, &event, event_source, &object_info).await;
|
||||
resolve_data_movement_lifecycle_expiry_result(action, apply_actions, applied)
|
||||
}
|
||||
_ => Ok(false),
|
||||
@@ -2647,7 +2651,7 @@ impl ECStore {
|
||||
}
|
||||
|
||||
#[allow(unused_assignments, clippy::too_many_arguments)]
|
||||
#[tracing::instrument(skip(self, set, _worker_permit, lifecycle_config, lock_retention, replication_config))]
|
||||
#[tracing::instrument(skip(self, set, _worker_permit, lifecycle_config, object_lock_config, replication_config))]
|
||||
async fn decommission_entry(
|
||||
self: &Arc<Self>,
|
||||
rx: CancellationToken,
|
||||
@@ -2657,7 +2661,7 @@ impl ECStore {
|
||||
set: Arc<SetDisks>,
|
||||
_worker_permit: OwnedSemaphorePermit,
|
||||
lifecycle_config: Option<BucketLifecycleConfiguration>,
|
||||
lock_retention: Option<DefaultRetention>,
|
||||
object_lock_config: Option<ObjectLockConfiguration>,
|
||||
replication_config: Option<(ReplicationConfiguration, OffsetDateTime)>,
|
||||
) -> Result<()> {
|
||||
debug!(
|
||||
@@ -2708,7 +2712,7 @@ impl ECStore {
|
||||
&bucket,
|
||||
version,
|
||||
lifecycle_config.as_ref(),
|
||||
lock_retention.clone(),
|
||||
object_lock_config.as_ref(),
|
||||
true,
|
||||
&LcEventSrc::Decom,
|
||||
)
|
||||
@@ -3113,7 +3117,7 @@ impl ECStore {
|
||||
let mut listing_workers = Vec::with_capacity(pool.disk_set.len());
|
||||
|
||||
let mut lifecycle_config = None;
|
||||
let mut lock_retention = None;
|
||||
let mut object_lock_config = None;
|
||||
let mut replication_config = None;
|
||||
|
||||
if bi.name != RUSTFS_META_BUCKET {
|
||||
@@ -3122,8 +3126,9 @@ impl ECStore {
|
||||
"versioning",
|
||||
BucketVersioningSys::get(&bi.name).await,
|
||||
)?;
|
||||
lifecycle_config = runtime_sources::bucket_lifecycle_config(&bi.name).await;
|
||||
lock_retention = BucketObjectLockSys::get(&bi.name).await;
|
||||
let expiry_configs = get_expiry_configs(self, &bi.name).await?;
|
||||
lifecycle_config = expiry_configs.lifecycle.map(|config| (*config).clone());
|
||||
object_lock_config = expiry_configs.object_lock.map(|config| (*config).clone());
|
||||
replication_config = resolve_decommission_optional_bucket_config_result(
|
||||
&bi.name,
|
||||
"replication",
|
||||
@@ -3155,7 +3160,7 @@ impl ECStore {
|
||||
let workers = workers.clone();
|
||||
let set = set.clone();
|
||||
let lifecycle_config = lifecycle_config.clone();
|
||||
let lock_retention = lock_retention.clone();
|
||||
let object_lock_config = object_lock_config.clone();
|
||||
let replication_config = replication_config.clone();
|
||||
let entry_error = entry_error.clone();
|
||||
let callback_rx = rx.clone();
|
||||
@@ -3165,7 +3170,7 @@ impl ECStore {
|
||||
let workers = workers.clone();
|
||||
let set = set.clone();
|
||||
let lifecycle_config = lifecycle_config.clone();
|
||||
let lock_retention = lock_retention.clone();
|
||||
let object_lock_config = object_lock_config.clone();
|
||||
let replication_config = replication_config.clone();
|
||||
let entry_error = entry_error.clone();
|
||||
let callback_rx = callback_rx.clone();
|
||||
@@ -3227,7 +3232,7 @@ impl ECStore {
|
||||
set,
|
||||
worker_permit,
|
||||
lifecycle_config,
|
||||
lock_retention,
|
||||
object_lock_config,
|
||||
replication_config,
|
||||
)
|
||||
.await
|
||||
@@ -3960,10 +3965,11 @@ impl ECStore {
|
||||
for set in &pool.disk_set {
|
||||
for bucket_info in &buckets {
|
||||
let mut lifecycle_config = None;
|
||||
let mut lock_retention = None;
|
||||
let mut object_lock_config = None;
|
||||
if bucket_info.name != RUSTFS_META_BUCKET {
|
||||
lifecycle_config = runtime_sources::bucket_lifecycle_config(&bucket_info.name).await;
|
||||
lock_retention = BucketObjectLockSys::get(&bucket_info.name).await;
|
||||
let expiry_configs = get_expiry_configs(self, &bucket_info.name).await?;
|
||||
lifecycle_config = expiry_configs.lifecycle.map(|config| (*config).clone());
|
||||
object_lock_config = expiry_configs.object_lock.map(|config| (*config).clone());
|
||||
}
|
||||
|
||||
let versions_found = Arc::new(AtomicUsize::new(0));
|
||||
@@ -3973,7 +3979,7 @@ impl ECStore {
|
||||
let entry_error_cb = entry_error.clone();
|
||||
let bucket_name = bucket_info.name.clone();
|
||||
let lifecycle_config_cb = lifecycle_config.clone();
|
||||
let lock_retention_cb = lock_retention.clone();
|
||||
let object_lock_config_cb = object_lock_config.clone();
|
||||
let store = Arc::clone(self);
|
||||
let callback_rx_cb = callback_rx.clone();
|
||||
|
||||
@@ -3982,7 +3988,7 @@ impl ECStore {
|
||||
let entry_error = entry_error_cb.clone();
|
||||
let bucket_name = bucket_name.clone();
|
||||
let lifecycle_config = lifecycle_config_cb.clone();
|
||||
let lock_retention = lock_retention_cb.clone();
|
||||
let object_lock_config = object_lock_config_cb.clone();
|
||||
let store = Arc::clone(&store);
|
||||
let callback_rx = callback_rx_cb.clone();
|
||||
Box::pin(async move {
|
||||
@@ -4024,7 +4030,7 @@ impl ECStore {
|
||||
&bucket_name,
|
||||
version,
|
||||
lifecycle_config.as_ref(),
|
||||
lock_retention.clone(),
|
||||
object_lock_config.as_ref(),
|
||||
false,
|
||||
&LcEventSrc::Decom,
|
||||
)
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
// limitations under the License.
|
||||
|
||||
use crate::disk::error_reduce::count_errs;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::error::{Error, Result, is_all_volume_not_found, is_err_object_not_found, is_err_strict_volume_not_found};
|
||||
use crate::layout::set_heal::{formats_to_drives_info, new_heal_format_sets};
|
||||
use crate::multipart_listing::paginate_multipart_listing;
|
||||
use crate::storage_api_contracts::{
|
||||
@@ -71,6 +71,10 @@ type WalkOptions = StorageWalkOptions<fn(&FileInfo) -> bool>;
|
||||
|
||||
const LIST_MULTIPART_SETS_CONCURRENCY: usize = 4;
|
||||
|
||||
fn is_idempotent_delete_prefix_error(err: &Error) -> bool {
|
||||
is_err_object_not_found(err) || is_err_strict_volume_not_found(err)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Sets {
|
||||
pub id: Uuid,
|
||||
@@ -339,8 +343,18 @@ impl Sets {
|
||||
futures.push(set.delete_object(bucket, object, opt.clone()));
|
||||
}
|
||||
|
||||
if let Some(err) = join_all(futures).await.into_iter().find_map(Result::err) {
|
||||
return Err(err);
|
||||
let errs = join_all(futures)
|
||||
.await
|
||||
.into_iter()
|
||||
.map(|result| result.err())
|
||||
.collect::<Vec<_>>();
|
||||
if is_all_volume_not_found(&errs) {
|
||||
return Err(StorageError::BucketNotFound(bucket.to_string()));
|
||||
}
|
||||
for err in errs.into_iter().flatten() {
|
||||
if !is_idempotent_delete_prefix_error(&err) {
|
||||
return Err(err);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -816,8 +830,19 @@ impl crate::storage_api_contracts::multipart::MultipartOperations for Sets {
|
||||
let upload_id_marker = upload_id_marker.clone();
|
||||
let delimiter = delimiter.clone();
|
||||
async move {
|
||||
set.list_multipart_uploads(bucket, prefix, key_marker, upload_id_marker, delimiter, per_set_limit)
|
||||
.await
|
||||
// ECStore owns the bucket lifecycle fence and calls the
|
||||
// incarnation-aware pool helper. This lower-level trait
|
||||
// surface has no ECStore guard to propagate.
|
||||
set.list_multipart_uploads_for_incarnation(
|
||||
bucket,
|
||||
prefix,
|
||||
key_marker,
|
||||
upload_id_marker,
|
||||
delimiter,
|
||||
per_set_limit,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
})
|
||||
.buffer_unordered(LIST_MULTIPART_SETS_CONCURRENCY)
|
||||
@@ -1276,6 +1301,19 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_prefix_error_classification_only_ignores_absence() {
|
||||
assert!(is_idempotent_delete_prefix_error(&StorageError::FileNotFound));
|
||||
assert!(is_idempotent_delete_prefix_error(&StorageError::ObjectNotFound(
|
||||
"bucket".to_string(),
|
||||
"prefix".to_string()
|
||||
)));
|
||||
assert!(is_idempotent_delete_prefix_error(&StorageError::VolumeNotFound));
|
||||
assert!(is_idempotent_delete_prefix_error(&StorageError::BucketNotFound("bucket".to_string())));
|
||||
assert!(!is_idempotent_delete_prefix_error(&StorageError::DiskNotFound));
|
||||
assert!(!is_idempotent_delete_prefix_error(&StorageError::ErasureWriteQuorum));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sets_get_pool_and_set_returns_matching_coordinates() {
|
||||
let format = FormatV3::new(2, 2);
|
||||
@@ -1393,6 +1431,161 @@ mod tests {
|
||||
(temp_dirs, sets)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_prefix_surfaces_a_hard_error_from_any_set() {
|
||||
let (_temp_dirs, sets) = two_set_test_sets().await;
|
||||
let bucket = format!("delete-prefix-{}", Uuid::new_v4().simple());
|
||||
sets.make_bucket(&bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("bucket should be created across both sets");
|
||||
|
||||
let healthy_disks = sets.disk_set[0].disks.read().await.clone();
|
||||
for disk in healthy_disks.iter().flatten() {
|
||||
disk.write_all(&bucket, "blocked/prefix/object", bytes::Bytes::from_static(b"data"))
|
||||
.await
|
||||
.expect("healthy set should contain the prefix");
|
||||
}
|
||||
|
||||
let failing_disks = sets.disk_set[1].disks.read().await.clone();
|
||||
for disk in failing_disks.iter().flatten() {
|
||||
disk.write_all(&bucket, "blocked", bytes::Bytes::from_static(b"not-a-directory"))
|
||||
.await
|
||||
.expect("failing set should contain a parent file");
|
||||
}
|
||||
|
||||
let err = sets
|
||||
.delete_object(
|
||||
&bucket,
|
||||
"blocked/prefix",
|
||||
ObjectOptions {
|
||||
delete_prefix: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect_err("a hard failure from one set must not be reported as success");
|
||||
|
||||
match err {
|
||||
StorageError::PrefixAccessDenied(error_bucket, error_prefix) => {
|
||||
assert_eq!(error_bucket, bucket);
|
||||
assert_eq!(error_prefix, "blocked/prefix");
|
||||
}
|
||||
other => panic!("unexpected recursive delete error: {other:?}"),
|
||||
}
|
||||
for disk in healthy_disks.iter().flatten() {
|
||||
assert!(
|
||||
matches!(disk.read_all(&bucket, "blocked/prefix/object").await, Err(DiskError::FileNotFound)),
|
||||
"the healthy set should still complete its prefix deletion"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_prefix_keeps_a_missing_bucket_idempotent_across_sets() {
|
||||
let (_temp_dirs, sets) = two_set_test_sets().await;
|
||||
let bucket = format!("delete-prefix-{}", Uuid::new_v4().simple());
|
||||
sets.make_bucket(&bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("bucket should be created across both sets");
|
||||
|
||||
let healthy_disks = sets.disk_set[0].disks.read().await.clone();
|
||||
for disk in healthy_disks.iter().flatten() {
|
||||
disk.write_all(&bucket, "existing/prefix/object", bytes::Bytes::from_static(b"data"))
|
||||
.await
|
||||
.expect("healthy set should contain the prefix");
|
||||
}
|
||||
let missing_bucket_disks = sets.disk_set[1].disks.read().await.clone();
|
||||
for disk in missing_bucket_disks.iter().flatten() {
|
||||
disk.delete_volume(&bucket, true)
|
||||
.await
|
||||
.expect("the bucket should be removed from one set");
|
||||
}
|
||||
|
||||
sets.delete_object(
|
||||
&bucket,
|
||||
"existing/prefix",
|
||||
ObjectOptions {
|
||||
delete_prefix: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("a missing bucket on one set should remain an idempotent success");
|
||||
for disk in healthy_disks.iter().flatten() {
|
||||
assert!(
|
||||
matches!(disk.read_all(&bucket, "existing/prefix/object").await, Err(DiskError::FileNotFound)),
|
||||
"the healthy set should still complete its prefix deletion"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_prefix_preserves_a_completely_missing_bucket_error() {
|
||||
let (_temp_dirs, sets) = two_set_test_sets().await;
|
||||
let bucket = format!("delete-prefix-missing-{}", Uuid::new_v4().simple());
|
||||
|
||||
let err = sets
|
||||
.delete_object(
|
||||
&bucket,
|
||||
"missing/prefix",
|
||||
ObjectOptions {
|
||||
delete_prefix: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect_err("a completely missing bucket must not be reported as a successful object deletion");
|
||||
|
||||
assert_eq!(err, StorageError::BucketNotFound(bucket));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_prefix_fails_when_one_set_is_entirely_offline() {
|
||||
let (_temp_dirs, sets) = two_set_test_sets().await;
|
||||
let bucket = format!("delete-prefix-{}", Uuid::new_v4().simple());
|
||||
sets.make_bucket(&bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("bucket should be created across both sets");
|
||||
|
||||
let online_disks = sets.disk_set[0].disks.read().await.clone();
|
||||
let offline_disks = sets.disk_set[1].disks.read().await.clone();
|
||||
for disk in online_disks.iter().chain(offline_disks.iter()).flatten() {
|
||||
disk.write_all(&bucket, "offline/prefix/object", bytes::Bytes::from_static(b"data"))
|
||||
.await
|
||||
.expect("each set should contain the prefix before the outage");
|
||||
}
|
||||
*sets.disk_set[1].disks.write().await = vec![None, None];
|
||||
|
||||
let err = sets
|
||||
.delete_object(
|
||||
&bucket,
|
||||
"offline/prefix",
|
||||
ObjectOptions {
|
||||
delete_prefix: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect_err("an entirely offline set must make the recursive delete fail");
|
||||
|
||||
assert!(
|
||||
matches!(err, StorageError::InsufficientWriteQuorum(ref error_bucket, ref error_prefix)
|
||||
if error_bucket == &bucket && error_prefix == "offline/prefix"),
|
||||
"unexpected offline-set error: {err:?}"
|
||||
);
|
||||
for disk in online_disks.iter().flatten() {
|
||||
assert!(matches!(
|
||||
disk.read_all(&bucket, "offline/prefix/object").await,
|
||||
Err(DiskError::FileNotFound)
|
||||
));
|
||||
}
|
||||
for disk in offline_disks.iter().flatten() {
|
||||
disk.read_all(&bucket, "offline/prefix/object")
|
||||
.await
|
||||
.expect("the offline set's untouched prefix must still be present");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn set_format_heal_accepts_quorum_from_a_nonzero_set() {
|
||||
let (_temp_dirs, sets) = two_set_test_sets().await;
|
||||
|
||||
@@ -567,6 +567,7 @@ pub(crate) async fn cleanup_source_entry_if_unchanged(
|
||||
ObjectOptions {
|
||||
delete_prefix: true,
|
||||
delete_prefix_object: true,
|
||||
data_movement: true,
|
||||
no_lock: true,
|
||||
..Default::default()
|
||||
},
|
||||
|
||||
@@ -24,6 +24,7 @@ pub(crate) const GET_OBJECT_PATH_EMPTY: &str = "empty";
|
||||
pub(crate) const GET_OBJECT_PATH_DIRECT_MEMORY: &str = "direct_memory";
|
||||
pub(crate) const GET_OBJECT_PATH_BODY_CACHE: &str = "body_cache";
|
||||
pub(crate) const GET_OBJECT_PATH_INLINE_DIRECT: &str = "inline_direct";
|
||||
pub(crate) const GET_OBJECT_PATH_INTERNAL_META: &str = "internal_meta";
|
||||
pub(crate) const GET_OBJECT_PATH_LEGACY_DUPLEX: &str = "legacy_duplex";
|
||||
pub(crate) const GET_OBJECT_PATH_REMOTE_TRANSITION: &str = "remote_transition";
|
||||
pub(crate) const GET_OBJECT_PATH_SET_DISK: &str = "set_disk";
|
||||
@@ -163,6 +164,7 @@ pub(crate) enum GetObjectFailureReason {
|
||||
DecodeError,
|
||||
DownstreamClosed,
|
||||
Io,
|
||||
MetadataMissing,
|
||||
RangeOrLengthInvalid,
|
||||
ReadQuorum,
|
||||
ShortRead,
|
||||
@@ -177,6 +179,7 @@ impl GetObjectFailureReason {
|
||||
Self::DecodeError => "decode_error",
|
||||
Self::DownstreamClosed => "downstream_closed",
|
||||
Self::Io => "io",
|
||||
Self::MetadataMissing => "metadata_missing",
|
||||
Self::RangeOrLengthInvalid => "range_or_length_invalid",
|
||||
Self::ReadQuorum => "read_quorum",
|
||||
Self::ShortRead => "short_read",
|
||||
@@ -190,6 +193,13 @@ pub(crate) fn classify_storage_error(err: &StorageError) -> GetObjectFailureReas
|
||||
match err {
|
||||
StorageError::ErasureReadQuorum | StorageError::InsufficientReadQuorum(_, _) => GetObjectFailureReason::ReadQuorum,
|
||||
StorageError::FileCorrupt => GetObjectFailureReason::BitrotMismatch,
|
||||
StorageError::FileNotFound
|
||||
| StorageError::FileVersionNotFound
|
||||
| StorageError::VolumeNotFound
|
||||
| StorageError::BucketNotFound(_)
|
||||
| StorageError::ObjectNotFound(_, _)
|
||||
| StorageError::VersionNotFound(_, _, _)
|
||||
| StorageError::ConfigNotFound => GetObjectFailureReason::MetadataMissing,
|
||||
StorageError::InvalidRangeSpec(_) => GetObjectFailureReason::RangeOrLengthInvalid,
|
||||
StorageError::Io(io_err) => classify_io_error(io_err),
|
||||
_ => GetObjectFailureReason::Unknown,
|
||||
@@ -293,6 +303,34 @@ mod tests {
|
||||
classify_storage_error(&StorageError::InvalidRangeSpec("bad range".to_string())),
|
||||
GetObjectFailureReason::RangeOrLengthInvalid
|
||||
);
|
||||
assert_eq!(
|
||||
classify_storage_error(&StorageError::FileNotFound),
|
||||
GetObjectFailureReason::MetadataMissing
|
||||
);
|
||||
assert_eq!(
|
||||
classify_storage_error(&StorageError::VolumeNotFound),
|
||||
GetObjectFailureReason::MetadataMissing
|
||||
);
|
||||
assert_eq!(
|
||||
classify_storage_error(&StorageError::ObjectNotFound("bucket".to_string(), "object".to_string())),
|
||||
GetObjectFailureReason::MetadataMissing
|
||||
);
|
||||
assert_eq!(
|
||||
classify_storage_error(&StorageError::BucketNotFound("bucket".to_string())),
|
||||
GetObjectFailureReason::MetadataMissing
|
||||
);
|
||||
assert_eq!(
|
||||
classify_storage_error(&StorageError::VersionNotFound(
|
||||
"bucket".to_string(),
|
||||
"object".to_string(),
|
||||
"version".to_string()
|
||||
)),
|
||||
GetObjectFailureReason::MetadataMissing
|
||||
);
|
||||
assert_eq!(
|
||||
classify_storage_error(&StorageError::ConfigNotFound),
|
||||
GetObjectFailureReason::MetadataMissing
|
||||
);
|
||||
|
||||
let internal_broken_pipe = StorageError::Io(io::Error::from(io::ErrorKind::BrokenPipe));
|
||||
assert_eq!(classify_storage_error(&internal_broken_pipe), GetObjectFailureReason::Io);
|
||||
@@ -354,10 +392,12 @@ mod tests {
|
||||
assert_eq!(GetObjectFailureReason::DownstreamClosed.as_str(), "downstream_closed");
|
||||
assert_eq!(GetObjectFailureReason::BitrotMismatch.as_str(), "bitrot_mismatch");
|
||||
assert_eq!(GetObjectFailureReason::DecodeError.as_str(), "decode_error");
|
||||
assert_eq!(GetObjectFailureReason::MetadataMissing.as_str(), "metadata_missing");
|
||||
assert_eq!(GET_READER_BUFFER_OUTPUT, "output");
|
||||
assert_eq!(GET_READER_BUFFER_PREFETCH, "prefetch");
|
||||
assert_eq!(GET_OBJECT_PATH_CODEC_STREAMING_LEGACY_ENGINE, "codec_streaming_legacy_engine");
|
||||
assert_eq!(GET_OBJECT_PATH_CODEC_STREAMING_RUSTFS_ENGINE, "codec_streaming_rustfs_engine");
|
||||
assert_eq!(GET_OBJECT_PATH_INTERNAL_META, "internal_meta");
|
||||
assert_eq!(GET_DIRECT_MEMORY_DECISION_USE, "use");
|
||||
assert_eq!(GET_DIRECT_MEMORY_DECISION_FALLBACK, "fallback");
|
||||
assert_eq!(GET_DIRECT_MEMORY_REASON_NONE, "none");
|
||||
|
||||
@@ -1088,6 +1088,10 @@ impl LocalDiskWrapper {
|
||||
self.disk.clone()
|
||||
}
|
||||
|
||||
pub fn get_object_path_if_local(&self, volume: &str, path: &str) -> crate::disk::error::Result<std::path::PathBuf> {
|
||||
self.disk.get_object_path(volume, path)
|
||||
}
|
||||
|
||||
pub fn runtime_state(&self) -> RuntimeDriveHealthState {
|
||||
self.health.runtime_state()
|
||||
}
|
||||
|
||||
@@ -321,6 +321,14 @@ fn io_error_chain_contains_kind(io_error: &std::io::Error, kind: std::io::ErrorK
|
||||
|
||||
impl From<std::io::Error> for DiskError {
|
||||
fn from(e: std::io::Error) -> Self {
|
||||
if let Some(error) = e.get_ref().and_then(|source| source.downcast_ref::<InternodeHttpError>()) {
|
||||
if error.is_remote_file_not_found() {
|
||||
return DiskError::FileNotFound;
|
||||
}
|
||||
if error.is_remote_volume_not_found() {
|
||||
return DiskError::VolumeNotFound;
|
||||
}
|
||||
}
|
||||
match e.downcast::<DiskError>() {
|
||||
Ok(disk_error) => disk_error,
|
||||
Err(io_error) => DiskError::Io(io_error),
|
||||
@@ -1044,6 +1052,19 @@ mod tests {
|
||||
assert!(!DiskError::FileNotFound.is_internode_http_status(429));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_internode_missing_errors_preserve_disk_error_types() {
|
||||
let file_missing = DiskError::from(rustfs_rio::new_test_remote_file_not_found_http_io_error());
|
||||
let volume_missing = DiskError::from(rustfs_rio::new_test_remote_volume_not_found_http_io_error());
|
||||
let unmarked_server_error = DiskError::from(rustfs_rio::new_test_internode_http_io_error(
|
||||
rustfs_rio::InternodeHttpErrorKind::HttpStatus(http::StatusCode::INTERNAL_SERVER_ERROR),
|
||||
));
|
||||
|
||||
assert_eq!(file_missing, DiskError::FileNotFound);
|
||||
assert_eq!(volume_missing, DiskError::VolumeNotFound);
|
||||
assert!(matches!(unmarked_server_error, DiskError::Io(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_metacache_output_stream_closed_classification_survives_clone() {
|
||||
let disk_error = DiskError::metacache_output_stream_closed();
|
||||
|
||||
@@ -6686,6 +6686,50 @@ impl LocalDisk {
|
||||
}
|
||||
}
|
||||
|
||||
/// Batch positioned reads for local EC shard files in a single `spawn_blocking`.
|
||||
///
|
||||
/// Collapses per-shard blocking-pool round-trips that dominate warm GET
|
||||
/// fan-out on single-node multi-disk topologies.
|
||||
#[cfg(unix)]
|
||||
pub(crate) async fn batch_shard_pread(requests: Vec<(std::path::PathBuf, usize, usize)>) -> Vec<Result<Bytes>> {
|
||||
let n = requests.len();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
use std::os::unix::fs::FileExt;
|
||||
|
||||
let mut results = Vec::with_capacity(n);
|
||||
for (file_path, offset, length) in requests {
|
||||
let r = (|| -> Result<Bytes> {
|
||||
let meta = std::fs::metadata(&file_path).map_err(DiskError::from)?;
|
||||
let end = offset.checked_add(length).ok_or(DiskError::FileCorrupt)?;
|
||||
if meta.len() < u64::try_from(end).unwrap_or(u64::MAX) {
|
||||
return Err(DiskError::FileCorrupt);
|
||||
}
|
||||
|
||||
let file = std::fs::File::open(&file_path).map_err(DiskError::from)?;
|
||||
let mut buf = vec![0u8; length];
|
||||
let mut total = 0usize;
|
||||
while total < length {
|
||||
let nbytes = file
|
||||
.read_at(&mut buf[total..], u64::try_from(offset + total).unwrap_or(u64::MAX))
|
||||
.map_err(DiskError::from)?;
|
||||
if nbytes == 0 {
|
||||
return Err(DiskError::FileCorrupt);
|
||||
}
|
||||
total += nbytes;
|
||||
}
|
||||
Ok(Bytes::from(buf))
|
||||
})();
|
||||
results.push(r);
|
||||
}
|
||||
results
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|e| {
|
||||
let msg = format!("spawn_blocking join: {e}");
|
||||
(0..n).map(|_| Err(DiskError::other(msg.clone()))).collect()
|
||||
})
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl DiskAPI for LocalDisk {
|
||||
fn to_string(&self) -> String {
|
||||
@@ -8112,7 +8156,9 @@ impl DiskAPI for LocalDisk {
|
||||
}
|
||||
|
||||
Ok(RenameDataResp {
|
||||
old_data_dir: rollback_data_dir,
|
||||
old_data_dir: has_old_data_dir,
|
||||
rollback_data_dir,
|
||||
cleanup_data_dir: has_old_data_dir,
|
||||
sign: version_signature,
|
||||
old_current_size,
|
||||
})
|
||||
@@ -8303,8 +8349,9 @@ impl DiskAPI for LocalDisk {
|
||||
let _ = remove_file_if_exists(backup_path);
|
||||
}
|
||||
|
||||
Ok::<(Option<Uuid>, Option<Vec<u8>>, Option<OldCurrentSize>), std::io::Error>((
|
||||
Ok::<(Option<Uuid>, Option<Uuid>, Option<Vec<u8>>, Option<OldCurrentSize>), std::io::Error>((
|
||||
rollback_data_dir,
|
||||
old_data_dir,
|
||||
version_signature,
|
||||
old_current_size,
|
||||
))
|
||||
@@ -8319,7 +8366,7 @@ impl DiskAPI for LocalDisk {
|
||||
// invalidate itself, so it is done here. Inline objects carry their
|
||||
// data in xl.meta rather than separate part inodes, so this is mostly
|
||||
// defensive, but it keeps the inline and streaming branches consistent.
|
||||
let (old_data_dir, version_signature, old_current_size) = match inline_commit {
|
||||
let (old_data_dir, cleanup_data_dir, version_signature, old_current_size) = match inline_commit {
|
||||
Ok(committed) => committed,
|
||||
Err(err) => {
|
||||
for part_path in &invalidate_part_paths {
|
||||
@@ -8352,7 +8399,9 @@ impl DiskAPI for LocalDisk {
|
||||
}
|
||||
|
||||
Ok(RenameDataResp {
|
||||
old_data_dir,
|
||||
old_data_dir: cleanup_data_dir,
|
||||
rollback_data_dir: old_data_dir,
|
||||
cleanup_data_dir,
|
||||
sign: version_signature,
|
||||
old_current_size,
|
||||
})
|
||||
@@ -9374,6 +9423,53 @@ mod test {
|
||||
assert!(!rollback_dir.is_nil());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn inline_overwrite_does_not_report_rollback_dir_for_cleanup() {
|
||||
let dir = tempfile::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 bucket = "bucket";
|
||||
let object = "parent";
|
||||
let tmp_object = "tmp-write";
|
||||
let version_id = Uuid::nil();
|
||||
|
||||
ensure_test_volume(&disk, bucket).await;
|
||||
ensure_test_volume(&disk, RUSTFS_META_TMP_BUCKET).await;
|
||||
fs::create_dir_all(dir.path().join(bucket).join(object))
|
||||
.await
|
||||
.expect("destination object directory should be created");
|
||||
fs::write(
|
||||
dir.path().join(bucket).join(object).join(STORAGE_FORMAT_FILE),
|
||||
test_meta(test_file_info(object, version_id, None, Some(Bytes::from_static(b"old")))),
|
||||
)
|
||||
.await
|
||||
.expect("old inline metadata should be written");
|
||||
fs::create_dir_all(dir.path().join(RUSTFS_META_TMP_BUCKET).join(tmp_object))
|
||||
.await
|
||||
.expect("staging object directory should be created");
|
||||
|
||||
let response = disk
|
||||
.rename_data(
|
||||
RUSTFS_META_TMP_BUCKET,
|
||||
tmp_object,
|
||||
test_file_info(object, version_id, None, Some(Bytes::from_static(b"new"))),
|
||||
bucket,
|
||||
object,
|
||||
)
|
||||
.await
|
||||
.expect("inline overwrite should commit");
|
||||
|
||||
assert_eq!(response.old_data_dir, None);
|
||||
assert_eq!(
|
||||
response.rollback_data_dir,
|
||||
Some(inline_metadata_rollback_dir(version_id, &FileMeta::new()))
|
||||
);
|
||||
assert_eq!(
|
||||
response.cleanup_data_dir, None,
|
||||
"synthetic rollback state must not be recursively reclaimed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_inline_rollback_backup_falls_back_when_hardlink_fails() {
|
||||
let dir = tempfile::tempdir().expect("temp dir should be created");
|
||||
@@ -17903,4 +17999,45 @@ mod test {
|
||||
(reads * shard_mib) as f64 / wall,
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn test_batch_shard_pread_basic() {
|
||||
use tempfile::tempdir;
|
||||
|
||||
let dir = tempdir().unwrap();
|
||||
let payloads: &[&[u8]] = &[b"aaaaaa", b"bbbbbb", b"cccccc"];
|
||||
let mut requests = Vec::new();
|
||||
for (i, payload) in payloads.iter().enumerate() {
|
||||
let p = dir.path().join(format!("shard-{i}.bin"));
|
||||
std::fs::write(&p, payload).unwrap();
|
||||
requests.push((p, 0usize, payload.len()));
|
||||
}
|
||||
|
||||
let results = batch_shard_pread(requests).await;
|
||||
assert_eq!(results.len(), payloads.len());
|
||||
for (result, expected) in results.iter().zip(payloads.iter()) {
|
||||
assert_eq!(result.as_ref().unwrap().as_ref(), *expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn test_batch_shard_pread_partial_errors() {
|
||||
use tempfile::tempdir;
|
||||
|
||||
let dir = tempdir().unwrap();
|
||||
let good_path = dir.path().join("good.bin");
|
||||
std::fs::write(&good_path, b"good data").unwrap();
|
||||
let missing_path = dir.path().join("does-not-exist.bin");
|
||||
|
||||
let requests = vec![(good_path, 0usize, 9usize), (missing_path, 0usize, 4usize)];
|
||||
|
||||
let results = batch_shard_pread(requests).await;
|
||||
assert_eq!(results.len(), 2);
|
||||
assert!(results[0].is_ok());
|
||||
assert_eq!(results[0].as_ref().unwrap().as_ref(), b"good data");
|
||||
assert!(results[1].is_err());
|
||||
assert!(matches!(results[1].as_ref().unwrap_err(), DiskError::Io(_)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -682,6 +682,15 @@ impl Disk {
|
||||
Disk::Remote(remote_disk) => remote_disk.enable_health_check(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the absolute filesystem path for a (volume, path) pair if this
|
||||
/// disk is local, or `None` if it is a remote disk.
|
||||
pub fn get_object_path_if_local(&self, volume: &str, path: &str) -> Option<crate::disk::error::Result<std::path::PathBuf>> {
|
||||
match self {
|
||||
Disk::Local(w) => Some(w.get_object_path_if_local(volume, path)),
|
||||
Disk::Remote(_) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn new_disk(ep: &Endpoint, opt: &DiskOption) -> Result<DiskStore> {
|
||||
@@ -1071,7 +1080,16 @@ pub enum OldCurrentSize {
|
||||
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
pub struct RenameDataResp {
|
||||
/// Dereferenced erasure data directory retained for older coordinators.
|
||||
pub old_data_dir: Option<Uuid>,
|
||||
/// Directory containing rollback metadata. This may be synthetic and must
|
||||
/// not be used for post-commit data cleanup.
|
||||
#[serde(default)]
|
||||
pub rollback_data_dir: Option<Uuid>,
|
||||
/// Dereferenced erasure data directory that is safe to reclaim after the
|
||||
/// rename commits. Older peers omit this field, so missing means no cleanup.
|
||||
#[serde(default)]
|
||||
pub cleanup_data_dir: Option<Uuid>,
|
||||
pub sign: Option<Vec<u8>>,
|
||||
/// `None` means unknown — the disk could not determine the previous
|
||||
/// current version (pre-#1009 peer on the wire, or an existing dst
|
||||
@@ -1468,11 +1486,15 @@ mod tests {
|
||||
|
||||
let resp = RenameDataResp {
|
||||
old_data_dir: Some(uuid),
|
||||
rollback_data_dir: Some(uuid),
|
||||
cleanup_data_dir: Some(uuid),
|
||||
sign: Some(signature.clone()),
|
||||
old_current_size: Some(OldCurrentSize::Present(42)),
|
||||
};
|
||||
|
||||
assert_eq!(resp.old_data_dir, Some(uuid));
|
||||
assert_eq!(resp.rollback_data_dir, Some(uuid));
|
||||
assert_eq!(resp.cleanup_data_dir, Some(uuid));
|
||||
assert_eq!(resp.sign, Some(signature));
|
||||
assert_eq!(resp.old_current_size, Some(OldCurrentSize::Present(42)));
|
||||
}
|
||||
@@ -1484,6 +1506,8 @@ mod tests {
|
||||
for old_current_size in [None, Some(OldCurrentSize::Absent), Some(OldCurrentSize::Present(1337))] {
|
||||
let resp = RenameDataResp {
|
||||
old_data_dir: Some(Uuid::new_v4()),
|
||||
rollback_data_dir: Some(Uuid::new_v4()),
|
||||
cleanup_data_dir: Some(Uuid::new_v4()),
|
||||
sign: Some(vec![0x01, 0x02, 0x03]),
|
||||
old_current_size,
|
||||
};
|
||||
@@ -1492,6 +1516,8 @@ mod tests {
|
||||
let decoded: RenameDataResp = rmp_serde::decode::from_slice(&encoded).expect("named msgpack should decode");
|
||||
|
||||
assert_eq!(decoded.old_data_dir, resp.old_data_dir);
|
||||
assert_eq!(decoded.rollback_data_dir, resp.rollback_data_dir);
|
||||
assert_eq!(decoded.cleanup_data_dir, resp.cleanup_data_dir);
|
||||
assert_eq!(decoded.sign, resp.sign);
|
||||
assert_eq!(decoded.old_current_size, resp.old_current_size);
|
||||
}
|
||||
@@ -1517,6 +1543,8 @@ mod tests {
|
||||
let decoded: RenameDataResp = rmp_serde::decode::from_slice(&encoded).expect("legacy payload should decode");
|
||||
|
||||
assert_eq!(decoded.old_data_dir, legacy.old_data_dir);
|
||||
assert_eq!(decoded.rollback_data_dir, None);
|
||||
assert_eq!(decoded.cleanup_data_dir, None);
|
||||
assert_eq!(decoded.sign, legacy.sign);
|
||||
assert_eq!(decoded.old_current_size, None);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
use crate::bucket::metadata_sys::ObjectLockConfigState;
|
||||
use crate::disk::endpoint::Endpoint;
|
||||
use crate::disk::format::FormatV3;
|
||||
use crate::disk::{DiskAPI, DiskOption, DiskStore, WalkDirOptions, new_disk};
|
||||
use crate::error::Error;
|
||||
use crate::io_support::rio::HashReader;
|
||||
use crate::object_api::{BLOCK_SIZE_V2, ObjectOptions, PutObjReader};
|
||||
use crate::object_api::{BLOCK_SIZE_V2, ObjectLockConfigSnapshot, ObjectOptions, PutObjReader};
|
||||
use crate::set_disk::SetDisks;
|
||||
use crate::storage_api_contracts::bucket::{BucketOperations as _, MakeBucketOptions};
|
||||
use crate::storage_api_contracts::object::{ObjectIO as _, ObjectOperations as _};
|
||||
@@ -357,6 +358,7 @@ async fn blackbox_delete_marker_hides_object_body_without_erasing_prior_version_
|
||||
let opts = ObjectOptions {
|
||||
no_lock: true,
|
||||
version_suspended: true,
|
||||
object_lock_config_snapshot: Some(Arc::new(ObjectLockConfigSnapshot::new(ObjectLockConfigState::ConfirmedAbsent))),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
@@ -624,6 +626,9 @@ mod old_current_size_backfill {
|
||||
ObjectOptions {
|
||||
no_lock: true,
|
||||
versioned: true,
|
||||
object_lock_config_snapshot: Some(Arc::new(ObjectLockConfigSnapshot::new(
|
||||
ObjectLockConfigState::ConfirmedAbsent,
|
||||
))),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
|
||||
@@ -483,10 +483,20 @@ fn instrument_raw_shard_writer(writer: FileWriter, is_local: bool) -> FileWriter
|
||||
}
|
||||
|
||||
fn bitrot_encoded_range(offset: usize, length: usize, shard_size: usize, checksum_algo: HashAlgorithm) -> (usize, usize) {
|
||||
(
|
||||
offset.div_ceil(shard_size) * checksum_algo.size() + offset,
|
||||
length.div_ceil(shard_size) * checksum_algo.size() + length,
|
||||
)
|
||||
adjust_shard_read_params(offset, length, shard_size, &checksum_algo)
|
||||
}
|
||||
|
||||
/// Adjusts a raw (offset, length) pair to account for per-shard checksum overhead.
|
||||
/// Returns (adjusted_offset, adjusted_length).
|
||||
pub(crate) fn adjust_shard_read_params(
|
||||
offset: usize,
|
||||
length: usize,
|
||||
shard_size: usize,
|
||||
checksum_algo: &HashAlgorithm,
|
||||
) -> (usize, usize) {
|
||||
let adj_len = length.div_ceil(shard_size) * checksum_algo.size() + length;
|
||||
let adj_off = offset.div_ceil(shard_size) * checksum_algo.size() + offset;
|
||||
(adj_off, adj_len)
|
||||
}
|
||||
|
||||
/// Create a BitrotReader from either inline data or disk file stream
|
||||
|
||||
@@ -1744,9 +1744,11 @@ mod test {
|
||||
|
||||
#[tokio::test]
|
||||
async fn system_resolver_negative_result_reaches_the_dns_allowlist() {
|
||||
let err = get_host_ip(Host::Domain("rustfs-startup-negative.invalid"))
|
||||
.await
|
||||
.expect_err("the reserved .invalid domain must not resolve");
|
||||
let Err(err) = get_host_ip(Host::Domain("rustfs-startup-negative.invalid")).await else {
|
||||
// Some corporate and ISP resolvers synthesize an address for
|
||||
// unknown names, including the reserved .invalid suffix.
|
||||
return;
|
||||
};
|
||||
assert!(
|
||||
is_retryable_dns_error(&err),
|
||||
"system resolver error kind {:?} and message {err:?} must retain retry provenance",
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
|
||||
use std::slice::Iter;
|
||||
|
||||
use tracing::warn;
|
||||
|
||||
use crate::bucket::utils::is_meta_bucketname;
|
||||
use crate::disk::DiskInfo;
|
||||
use crate::error::{Error, Result};
|
||||
@@ -135,13 +137,31 @@ pub(crate) async fn build_server_pools_available_space(
|
||||
continue;
|
||||
}
|
||||
|
||||
if !is_meta_bucketname(bucket) && !has_space_for(zinfo, size).await.unwrap_or_default() {
|
||||
server_pools[i] = PoolAvailableSpace {
|
||||
index: i,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
continue;
|
||||
if !is_meta_bucketname(bucket) {
|
||||
match has_space_for(zinfo, size).await {
|
||||
Ok(false) => {
|
||||
server_pools[i] = PoolAvailableSpace {
|
||||
index: i,
|
||||
..Default::default()
|
||||
};
|
||||
continue;
|
||||
}
|
||||
Err(err) => {
|
||||
// Not enough online disks to reliably determine space
|
||||
// (e.g. transient disk_info failures during decommission
|
||||
// I/O). Do NOT exclude the pool — compute available
|
||||
// space from whatever disks responded. The actual write
|
||||
// will enforce its own quorum; a premature zero here
|
||||
// causes spurious "Disk full" errors when only the
|
||||
// decommission source pool is suspended.
|
||||
warn!(
|
||||
pool_index = i,
|
||||
error = %err,
|
||||
"Pool space check incomplete; falling back to online-disk estimate"
|
||||
);
|
||||
}
|
||||
Ok(true) => {}
|
||||
}
|
||||
}
|
||||
|
||||
let mut available = 0;
|
||||
@@ -270,4 +290,55 @@ mod tests {
|
||||
assert_eq!(spaces.0[0].available, 2);
|
||||
assert_eq!(spaces.0[0].max_used_pct, 90);
|
||||
}
|
||||
|
||||
/// When some disks are offline (disk_info returned None), `has_space_for`
|
||||
/// returns an error instead of a definitive "no space". The pool must NOT
|
||||
/// be excluded — available space should be estimated from the disks that
|
||||
/// did respond. This covers the decommission scenario where the active
|
||||
/// pool is the only write target but some of its disks transiently fail
|
||||
/// their disk_info check under heavy migration I/O.
|
||||
#[tokio::test]
|
||||
async fn build_server_pools_available_space_estimates_from_online_disks_when_some_offline() {
|
||||
// 4-disk set, only2 online — has_space_for returns Err (need >=2).
|
||||
let infos = vec![vec![
|
||||
Some(disk_info(1_000, 200, 800)),
|
||||
None,
|
||||
None,
|
||||
Some(disk_info(1_000, 300, 700)),
|
||||
]];
|
||||
|
||||
let spaces = build_server_pools_available_space("bucket-a", 64, &[1], &infos).await;
|
||||
|
||||
assert_eq!(spaces.0.len(), 1);
|
||||
// available = (800 + 700) * n_sets(1) = 1500
|
||||
assert_eq!(spaces.0[0].available, 1_500);
|
||||
assert_eq!(spaces.0[0].max_used_pct, 30);
|
||||
}
|
||||
|
||||
/// In a 2-pool decommission scenario, pool 0 is empty (suspended) and
|
||||
/// pool 1 has some offline disks but still reports available space.
|
||||
/// `get_available_pool_idx` must find pool 1.
|
||||
#[tokio::test]
|
||||
async fn build_server_pools_available_space_active_pool_with_partial_disk_failure_during_decommission() {
|
||||
// Pool 0: suspended → empty zinfo (handled by get_server_pools_available_space)
|
||||
// Pool 1: 4-disk set, 2 online (has_space_for returns Err)
|
||||
let infos = vec![
|
||||
Vec::new(), // pool 0: suspended, empty
|
||||
vec![
|
||||
Some(disk_info(2_000, 500, 1_500)),
|
||||
None,
|
||||
None,
|
||||
Some(disk_info(2_000, 600, 1_400)),
|
||||
],
|
||||
];
|
||||
|
||||
let spaces = build_server_pools_available_space("bucket-a", 64, &[1, 1], &infos).await;
|
||||
|
||||
assert_eq!(spaces.0.len(), 2);
|
||||
// Pool 0: empty zinfo → available=0
|
||||
assert_eq!(spaces.0[0].available, 0);
|
||||
// Pool 1: (1500 + 1400) * 1 = 2900
|
||||
assert_eq!(spaces.0[1].available, 2_900);
|
||||
assert_eq!(spaces.total_available(), 2_900);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,43 +20,193 @@ use crate::storage_api_contracts::{
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct DeleteLockFence {
|
||||
#[derive(Clone)]
|
||||
pub struct NamespaceLockFence {
|
||||
signals: Arc<Vec<Arc<rustfs_lock::distributed_lock::LockLostSignal>>>,
|
||||
#[cfg(test)]
|
||||
forced_lost: bool,
|
||||
forced_lost: Arc<std::sync::atomic::AtomicBool>,
|
||||
}
|
||||
|
||||
impl Debug for DeleteLockFence {
|
||||
impl Debug for NamespaceLockFence {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("DeleteLockFence")
|
||||
f.debug_struct("NamespaceLockFence")
|
||||
.field("signal_count", &self.signals.len())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl DeleteLockFence {
|
||||
pub(crate) fn new(signals: Vec<Arc<rustfs_lock::distributed_lock::LockLostSignal>>) -> Self {
|
||||
impl NamespaceLockFence {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
signals: Arc::new(signals),
|
||||
signals: Arc::default(),
|
||||
#[cfg(test)]
|
||||
forced_lost: false,
|
||||
forced_lost: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn is_lock_lost(&self) -> bool {
|
||||
#[cfg(test)]
|
||||
if self.forced_lost {
|
||||
if self.forced_lost.load(std::sync::atomic::Ordering::Acquire) {
|
||||
return true;
|
||||
}
|
||||
self.signals.iter().any(|signal| signal.is_lost())
|
||||
}
|
||||
|
||||
pub(crate) fn add_signal(&mut self, signal: Arc<rustfs_lock::distributed_lock::LockLostSignal>) {
|
||||
Arc::make_mut(&mut self.signals).push(signal);
|
||||
}
|
||||
|
||||
fn extend(&mut self, other: &Self) {
|
||||
if Arc::ptr_eq(&self.signals, &other.signals) {
|
||||
return;
|
||||
}
|
||||
Arc::make_mut(&mut self.signals).extend(other.signals.iter().cloned());
|
||||
#[cfg(test)]
|
||||
if other.forced_lost.load(std::sync::atomic::Ordering::Acquire) {
|
||||
self.forced_lost.store(true, std::sync::atomic::Ordering::Release);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn lost_for_test() -> Self {
|
||||
let fence = Self::new();
|
||||
fence.forced_lost.store(true, std::sync::atomic::Ordering::Release);
|
||||
fence
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn loss_handle_for_test() -> (Self, Arc<std::sync::atomic::AtomicBool>) {
|
||||
let fence = Self::new();
|
||||
(fence.clone(), Arc::clone(&fence.forced_lost))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ObjectLockConfigSnapshot {
|
||||
store_id: Option<Uuid>,
|
||||
bucket: Option<String>,
|
||||
bucket_incarnation_id: Option<Uuid>,
|
||||
config_revision: Option<OffsetDateTime>,
|
||||
state: crate::bucket::metadata_sys::ObjectLockConfigState,
|
||||
lifecycle_fence: NamespaceLockFence,
|
||||
_lifecycle_guard: Option<rustfs_lock::NamespaceLockGuard>,
|
||||
metadata_transaction_guard: Option<rustfs_lock::NamespaceLockGuard>,
|
||||
}
|
||||
|
||||
impl ObjectLockConfigSnapshot {
|
||||
pub(crate) fn new(state: crate::bucket::metadata_sys::ObjectLockConfigState) -> Self {
|
||||
Self {
|
||||
signals: Arc::default(),
|
||||
forced_lost: true,
|
||||
store_id: None,
|
||||
bucket: None,
|
||||
bucket_incarnation_id: None,
|
||||
config_revision: None,
|
||||
state,
|
||||
lifecycle_fence: NamespaceLockFence::new(),
|
||||
_lifecycle_guard: None,
|
||||
metadata_transaction_guard: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn for_store_bucket(
|
||||
store_id: Uuid,
|
||||
bucket: &str,
|
||||
bucket_incarnation_id: Uuid,
|
||||
config_revision: OffsetDateTime,
|
||||
state: crate::bucket::metadata_sys::ObjectLockConfigState,
|
||||
) -> Self {
|
||||
Self {
|
||||
store_id: Some(store_id),
|
||||
bucket: Some(bucket.to_string()),
|
||||
bucket_incarnation_id: Some(bucket_incarnation_id),
|
||||
config_revision: Some(config_revision),
|
||||
state,
|
||||
lifecycle_fence: NamespaceLockFence::new(),
|
||||
_lifecycle_guard: None,
|
||||
metadata_transaction_guard: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn for_guarded_store_bucket(
|
||||
store_id: Uuid,
|
||||
bucket: &str,
|
||||
bucket_incarnation_id: Uuid,
|
||||
config_revision: OffsetDateTime,
|
||||
state: crate::bucket::metadata_sys::ObjectLockConfigState,
|
||||
lifecycle_guard: rustfs_lock::NamespaceLockGuard,
|
||||
metadata_transaction_guard: rustfs_lock::NamespaceLockGuard,
|
||||
) -> Self {
|
||||
let mut lifecycle_fence = NamespaceLockFence::new();
|
||||
if let Some(signal) = lifecycle_guard.lock_lost_signal() {
|
||||
lifecycle_fence.add_signal(signal);
|
||||
}
|
||||
Self {
|
||||
store_id: Some(store_id),
|
||||
bucket: Some(bucket.to_string()),
|
||||
bucket_incarnation_id: Some(bucket_incarnation_id),
|
||||
config_revision: Some(config_revision),
|
||||
state,
|
||||
lifecycle_fence,
|
||||
_lifecycle_guard: Some(lifecycle_guard),
|
||||
metadata_transaction_guard: Some(metadata_transaction_guard),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn for_store_bucket_under_lifecycle_fence(
|
||||
store_id: Uuid,
|
||||
bucket: &str,
|
||||
bucket_incarnation_id: Uuid,
|
||||
config_revision: OffsetDateTime,
|
||||
state: crate::bucket::metadata_sys::ObjectLockConfigState,
|
||||
lifecycle_fence: NamespaceLockFence,
|
||||
metadata_transaction_guard: rustfs_lock::NamespaceLockGuard,
|
||||
) -> Self {
|
||||
Self {
|
||||
store_id: Some(store_id),
|
||||
bucket: Some(bucket.to_string()),
|
||||
bucket_incarnation_id: Some(bucket_incarnation_id),
|
||||
config_revision: Some(config_revision),
|
||||
state,
|
||||
lifecycle_fence,
|
||||
_lifecycle_guard: None,
|
||||
metadata_transaction_guard: Some(metadata_transaction_guard),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn is_for_store_bucket(
|
||||
&self,
|
||||
store_id: Uuid,
|
||||
bucket: &str,
|
||||
bucket_incarnation_id: Uuid,
|
||||
config_revision: OffsetDateTime,
|
||||
) -> bool {
|
||||
self.store_id == Some(store_id)
|
||||
&& self.bucket.as_deref() == Some(bucket)
|
||||
&& self.bucket_incarnation_id == Some(bucket_incarnation_id)
|
||||
&& self.config_revision == Some(config_revision)
|
||||
}
|
||||
|
||||
pub fn state(&self) -> &crate::bucket::metadata_sys::ObjectLockConfigState {
|
||||
&self.state
|
||||
}
|
||||
|
||||
pub(crate) fn is_valid_for_destructive_put(&self, store_id: Uuid, bucket: &str, bucket_incarnation_id: Uuid) -> bool {
|
||||
self.store_id == Some(store_id)
|
||||
&& self.bucket.as_deref() == Some(bucket)
|
||||
&& self.bucket_incarnation_id == Some(bucket_incarnation_id)
|
||||
&& self.config_revision.is_some()
|
||||
&& !self.lifecycle_fence.is_lock_lost()
|
||||
&& self
|
||||
.metadata_transaction_guard
|
||||
.as_ref()
|
||||
.is_some_and(|guard| !guard.is_lock_lost())
|
||||
}
|
||||
|
||||
pub(crate) fn add_lock_fences(&self, opts: &mut ObjectOptions) {
|
||||
opts.bucket_lifecycle_lock_fence
|
||||
.get_or_insert_with(NamespaceLockFence::new)
|
||||
.extend(&self.lifecycle_fence);
|
||||
if let Some(guard) = self.metadata_transaction_guard.as_ref() {
|
||||
opts.add_namespace_lock_guard(guard);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -73,6 +223,8 @@ pub struct ObjectOptions {
|
||||
pub version_id: Option<String>,
|
||||
/// RustFS-only compare-and-set condition checked under the object write lock.
|
||||
pub expected_current_version_id: Option<String>,
|
||||
/// Persisted bucket incarnation observed before authorization.
|
||||
pub expected_bucket_incarnation_id: Option<Uuid>,
|
||||
pub no_lock: bool,
|
||||
/// True when an upper layer already holds the object read lock before
|
||||
/// forwarding a no_lock read to the set layer.
|
||||
@@ -96,7 +248,10 @@ pub struct ObjectOptions {
|
||||
|
||||
pub delete_replication: Option<ReplicationState>,
|
||||
pub delete_replication_config_snapshot: Option<Arc<DeleteReplicationConfigSnapshot>>,
|
||||
pub delete_lock_fence: Option<DeleteLockFence>,
|
||||
pub namespace_lock_fence: Option<NamespaceLockFence>,
|
||||
/// Proves an upper layer holds the bucket lifecycle sentinel. A separate
|
||||
/// fence avoids recursively acquiring the read lock behind a queued writer.
|
||||
pub bucket_lifecycle_lock_fence: Option<NamespaceLockFence>,
|
||||
pub replication_request: bool,
|
||||
pub delete_marker: bool,
|
||||
pub synthetic_version_id: bool,
|
||||
@@ -108,6 +263,9 @@ pub struct ObjectOptions {
|
||||
pub eval_metadata: Option<HashMap<String, String>>,
|
||||
pub object_lock_retention: Option<ObjectLockRetentionOptions>,
|
||||
pub object_lock_delete: Option<crate::storage_api_contracts::object::ObjectLockDeleteOptions>,
|
||||
/// Authoritative bucket Object Lock snapshot installed inside `ECStore`
|
||||
/// before a destructive commit reaches the set layer.
|
||||
pub object_lock_config_snapshot: Option<Arc<ObjectLockConfigSnapshot>>,
|
||||
|
||||
pub want_checksum: Option<Checksum>,
|
||||
pub skip_verify_bitrot: bool,
|
||||
@@ -118,6 +276,33 @@ pub struct ObjectOptions {
|
||||
}
|
||||
|
||||
impl ObjectOptions {
|
||||
pub(crate) fn overwrites_existing_version(&self) -> bool {
|
||||
self.version_id.is_some() || !self.versioned || self.version_suspended
|
||||
}
|
||||
|
||||
pub(crate) fn add_namespace_lock_lost_signal(&mut self, signal: Arc<rustfs_lock::distributed_lock::LockLostSignal>) {
|
||||
self.namespace_lock_fence
|
||||
.get_or_insert_with(NamespaceLockFence::new)
|
||||
.add_signal(signal);
|
||||
}
|
||||
|
||||
pub(crate) fn ensure_namespace_lock_fence(&mut self) {
|
||||
self.namespace_lock_fence.get_or_insert_with(NamespaceLockFence::new);
|
||||
}
|
||||
|
||||
pub fn add_namespace_lock_guard(&mut self, guard: &rustfs_lock::NamespaceLockGuard) {
|
||||
if let Some(signal) = guard.lock_lost_signal() {
|
||||
self.add_namespace_lock_lost_signal(signal);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_bucket_lifecycle_lock_guard(&mut self, guard: &rustfs_lock::NamespaceLockGuard) {
|
||||
let fence = self.bucket_lifecycle_lock_fence.get_or_insert_with(NamespaceLockFence::new);
|
||||
if let Some(signal) = guard.lock_lost_signal() {
|
||||
fence.add_signal(signal);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_delete_replication_state(&mut self, dsc: ReplicateDecision) {
|
||||
let mut rs = ReplicationState {
|
||||
replicate_decision_str: dsc.to_string(),
|
||||
@@ -565,8 +750,17 @@ impl ObjectInfo {
|
||||
delimiter: Option<String>,
|
||||
after_version_marker: Option<VersionMarker>,
|
||||
) -> Vec<ObjectInfo> {
|
||||
Self::from_meta_cache_entries_sorted_versions_with_purge(entries, bucket, prefix, delimiter, after_version_marker, false)
|
||||
.await
|
||||
Self::from_meta_cache_entries_sorted_versions_with_purge(
|
||||
entries,
|
||||
bucket,
|
||||
prefix,
|
||||
delimiter,
|
||||
after_version_marker,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.0
|
||||
}
|
||||
|
||||
pub(crate) async fn from_meta_cache_entries_sorted_versions_for_lifecycle(
|
||||
@@ -576,8 +770,40 @@ impl ObjectInfo {
|
||||
delimiter: Option<String>,
|
||||
after_version_marker: Option<VersionMarker>,
|
||||
) -> Vec<ObjectInfo> {
|
||||
Self::from_meta_cache_entries_sorted_versions_with_purge(entries, bucket, prefix, delimiter, after_version_marker, true)
|
||||
.await
|
||||
Self::from_meta_cache_entries_sorted_versions_with_purge(
|
||||
entries,
|
||||
bucket,
|
||||
prefix,
|
||||
delimiter,
|
||||
after_version_marker,
|
||||
true,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.0
|
||||
}
|
||||
|
||||
pub(crate) async fn from_meta_cache_entries_sorted_versions_for_recursive_delete(
|
||||
entries: &MetaCacheEntriesSorted,
|
||||
bucket: &str,
|
||||
prefix: &str,
|
||||
delimiter: Option<String>,
|
||||
after_version_marker: Option<VersionMarker>,
|
||||
) -> Result<Vec<ObjectInfo>> {
|
||||
let (objects, error) = Self::from_meta_cache_entries_sorted_versions_with_purge(
|
||||
entries,
|
||||
bucket,
|
||||
prefix,
|
||||
delimiter,
|
||||
after_version_marker,
|
||||
true,
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
match error {
|
||||
Some(error) => Err(error),
|
||||
None => Ok(objects),
|
||||
}
|
||||
}
|
||||
|
||||
async fn from_meta_cache_entries_sorted_versions_with_purge(
|
||||
@@ -587,7 +813,8 @@ impl ObjectInfo {
|
||||
delimiter: Option<String>,
|
||||
after_version_marker: Option<VersionMarker>,
|
||||
include_version_purge: bool,
|
||||
) -> Vec<ObjectInfo> {
|
||||
fail_on_decode_error: bool,
|
||||
) -> (Vec<ObjectInfo>, Option<Error>) {
|
||||
let vcfg = get_versioning_config(bucket).await.ok();
|
||||
let mut objects = Vec::with_capacity(entries.entries().len());
|
||||
let mut prev_prefix = "";
|
||||
@@ -623,6 +850,9 @@ impl ObjectInfo {
|
||||
let file_infos = match entry.file_info_versions(bucket) {
|
||||
Ok(res) => res,
|
||||
Err(err) => {
|
||||
if fail_on_decode_error {
|
||||
return (objects, Some(err.into()));
|
||||
}
|
||||
warn!("file_info_versions err {:?}", err);
|
||||
continue;
|
||||
}
|
||||
@@ -674,7 +904,7 @@ impl ObjectInfo {
|
||||
}
|
||||
}
|
||||
|
||||
objects
|
||||
(objects, None)
|
||||
}
|
||||
|
||||
pub async fn from_meta_cache_entries_sorted_infos(
|
||||
@@ -762,6 +992,10 @@ impl ObjectInfo {
|
||||
}
|
||||
|
||||
pub fn replication_state(&self) -> ReplicationState {
|
||||
// Derived from the durable internal keys, not from the wire form: the
|
||||
// state's positional encoding skips this map.
|
||||
let (target_delete_marker_version_ids, target_delete_marker_version_ids_corrupt) =
|
||||
rustfs_utils::http::target_delete_marker_versions(&self.user_defined);
|
||||
ReplicationState {
|
||||
replication_status_internal: self.replication_status_internal.clone(),
|
||||
version_purge_status_internal: self.version_purge_status_internal.clone(),
|
||||
@@ -779,6 +1013,8 @@ impl ObjectInfo {
|
||||
.map(|arn| (arn, v.clone()))
|
||||
})
|
||||
.collect(),
|
||||
target_delete_marker_version_ids,
|
||||
target_delete_marker_version_ids_corrupt,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
@@ -830,6 +1066,25 @@ fn versions_after_marker(file_infos: &rustfs_filemeta::FileInfoVersions, marker:
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn object_lock_config_snapshot_is_bound_to_store_bucket_and_incarnation() {
|
||||
let store_id = Uuid::new_v4();
|
||||
let incarnation_id = Uuid::new_v4();
|
||||
let snapshot = ObjectLockConfigSnapshot::for_store_bucket(
|
||||
store_id,
|
||||
"source-bucket",
|
||||
incarnation_id,
|
||||
OffsetDateTime::UNIX_EPOCH,
|
||||
crate::bucket::metadata_sys::ObjectLockConfigState::ConfirmedAbsent,
|
||||
);
|
||||
|
||||
assert!(snapshot.is_for_store_bucket(store_id, "source-bucket", incarnation_id, OffsetDateTime::UNIX_EPOCH));
|
||||
assert!(!snapshot.is_for_store_bucket(Uuid::new_v4(), "source-bucket", incarnation_id, OffsetDateTime::UNIX_EPOCH));
|
||||
assert!(!snapshot.is_for_store_bucket(store_id, "other-bucket", incarnation_id, OffsetDateTime::UNIX_EPOCH));
|
||||
assert!(!snapshot.is_for_store_bucket(store_id, "source-bucket", Uuid::new_v4(), OffsetDateTime::UNIX_EPOCH));
|
||||
assert!(!snapshot.is_for_store_bucket(store_id, "source-bucket", incarnation_id, OffsetDateTime::now_utc()));
|
||||
}
|
||||
use rustfs_filemeta::{FileInfo, FileMeta, MetaCacheEntry, TRANSITION_COMPLETE};
|
||||
|
||||
fn inline_fast_path_object(size: i64, versioned: bool) -> ObjectInfo {
|
||||
@@ -945,10 +1200,11 @@ mod tests {
|
||||
#[test]
|
||||
fn object_info_replication_helpers_parse_target_status_and_reset_headers() {
|
||||
let reset_key = rustfs_utils::http::internal_key_rustfs("replication-reset-arn:target-a");
|
||||
let user_defined = HashMap::from([(reset_key, "reset-id".to_string())]);
|
||||
let object = ObjectInfo {
|
||||
replication_status_internal: Some("arn:target-a=COMPLETED;arn:target-b=FAILED;".to_string()),
|
||||
version_purge_status_internal: Some("arn:target-a=PENDING;".to_string()),
|
||||
user_defined: Arc::new(HashMap::from([(reset_key, "reset-id".to_string())])),
|
||||
user_defined: Arc::new(user_defined),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
|
||||
@@ -118,7 +118,7 @@ impl ECStore {
|
||||
&bucket,
|
||||
version,
|
||||
bucket_configs.lifecycle_config.as_ref(),
|
||||
bucket_configs.lock_retention.clone(),
|
||||
bucket_configs.object_lock_config.as_ref(),
|
||||
true,
|
||||
&crate::bucket::lifecycle::bucket_lifecycle_audit::LcEventSrc::Rebal,
|
||||
)
|
||||
@@ -384,7 +384,7 @@ impl ECStore {
|
||||
);
|
||||
|
||||
let pool = clone_arc_by_index(self.pools.as_slice(), pool_index, "invalid rebalance pool index")?;
|
||||
let bucket_configs = Arc::new(load_rebalance_bucket_configs(&bucket).await?);
|
||||
let bucket_configs = Arc::new(load_rebalance_bucket_configs(self, &bucket).await?);
|
||||
|
||||
let mut jobs = Vec::new();
|
||||
let entry_error = Arc::new(tokio::sync::Mutex::new(None::<Error>));
|
||||
|
||||
@@ -34,20 +34,19 @@ use super::migration::{
|
||||
rebalance_delete_marker_opts,
|
||||
};
|
||||
use super::worker::{
|
||||
ensure_rebalance_listing_disks_available, is_transient_rebalance_error, load_rebalance_bucket_configs,
|
||||
parse_rebalance_max_attempts, rebalance_listing_retry_delay, rebalance_migration_retry_delay,
|
||||
resolve_load_rebalance_stats_update_result, resolve_rebalance_bucket_error, resolve_rebalance_bucket_result,
|
||||
resolve_rebalance_entry_cleanup_delete_result, resolve_rebalance_file_info_versions_result,
|
||||
resolve_rebalance_meta_load_result, resolve_rebalance_meta_save_result, resolve_rebalance_migrate_result_error,
|
||||
resolve_rebalance_optional_bucket_config_result, resolve_rebalance_save_task_result, resolve_rebalance_stats_update_result,
|
||||
resolve_rebalance_terminal_error, resolve_rebalance_worker_result, send_rebalance_done_signal,
|
||||
should_cleanup_rebalance_source_entry, should_count_rebalance_version_complete, should_defer_rebalance_entry_failure,
|
||||
should_retry_rebalance_listing, should_skip_rebalance_delete_marker, wait_rebalance_entry_tasks,
|
||||
wait_rebalance_listing_retry, with_rebalance_entry_context,
|
||||
ensure_rebalance_listing_disks_available, is_transient_rebalance_error, parse_rebalance_max_attempts,
|
||||
rebalance_listing_retry_delay, rebalance_migration_retry_delay, resolve_load_rebalance_stats_update_result,
|
||||
resolve_rebalance_bucket_error, resolve_rebalance_bucket_result, resolve_rebalance_entry_cleanup_delete_result,
|
||||
resolve_rebalance_file_info_versions_result, resolve_rebalance_meta_load_result, resolve_rebalance_meta_save_result,
|
||||
resolve_rebalance_migrate_result_error, resolve_rebalance_optional_bucket_config_result, resolve_rebalance_save_task_result,
|
||||
resolve_rebalance_stats_update_result, resolve_rebalance_terminal_error, resolve_rebalance_worker_result,
|
||||
send_rebalance_done_signal, should_cleanup_rebalance_source_entry, should_count_rebalance_version_complete,
|
||||
should_defer_rebalance_entry_failure, should_retry_rebalance_listing, should_skip_rebalance_delete_marker,
|
||||
wait_rebalance_entry_tasks, wait_rebalance_listing_retry, with_rebalance_entry_context,
|
||||
};
|
||||
use super::{
|
||||
DiskStat, GetObjectReader, ObjectInfo, ObjectOptions, RebalSaveOpt, RebalStatus, RebalanceBucketOutcome,
|
||||
RebalanceCleanupWarnings, RebalanceEntryOutcome, RebalanceInfo, RebalanceMeta, RebalanceStats,
|
||||
DiskStat, GetObjectReader, ObjectInfo, ObjectOptions, RebalSaveOpt, RebalStatus, RebalanceBucketConfigs,
|
||||
RebalanceBucketOutcome, RebalanceCleanupWarnings, RebalanceEntryOutcome, RebalanceInfo, RebalanceMeta, RebalanceStats,
|
||||
};
|
||||
use crate::bucket::replication::{ReplicationState, ReplicationStatusType, replication_state_to_filemeta};
|
||||
use crate::data_movement;
|
||||
@@ -2097,13 +2096,11 @@ fn test_resolve_rebalance_optional_bucket_config_result_wraps_other_errors() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_load_rebalance_bucket_configs_skips_meta_bucket_lookup() {
|
||||
let configs = load_rebalance_bucket_configs(RUSTFS_META_BUCKET)
|
||||
.await
|
||||
.expect("meta bucket config loading should short-circuit");
|
||||
#[test]
|
||||
fn test_rebalance_meta_bucket_configs_are_empty() {
|
||||
let configs = RebalanceBucketConfigs::default();
|
||||
assert!(configs.lifecycle_config.is_none());
|
||||
assert!(configs.lock_retention.is_none());
|
||||
assert!(configs.object_lock_config.is_none());
|
||||
assert!(configs.replication_config.is_none());
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ pub type RStats = Vec<Arc<RebalanceStats>>;
|
||||
#[derive(Debug, Default)]
|
||||
pub(super) struct RebalanceBucketConfigs {
|
||||
pub(super) lifecycle_config: Option<s3s::dto::BucketLifecycleConfiguration>,
|
||||
pub(super) lock_retention: Option<s3s::dto::DefaultRetention>,
|
||||
pub(super) object_lock_config: Option<s3s::dto::ObjectLockConfiguration>,
|
||||
pub(super) replication_config: Option<(s3s::dto::ReplicationConfiguration, OffsetDateTime)>,
|
||||
}
|
||||
|
||||
|
||||
@@ -10,8 +10,8 @@ use crate::disk::error::DiskError;
|
||||
use crate::error::{
|
||||
Error, is_err_object_not_found, is_err_operation_canceled, is_err_version_not_found, is_network_or_host_down,
|
||||
};
|
||||
use crate::runtime::sources as runtime_sources;
|
||||
use crate::set_disk::{SetDisks, get_lock_acquire_timeout};
|
||||
use crate::store::ECStore;
|
||||
use rand::RngExt as _;
|
||||
use rustfs_filemeta::{MetaCacheEntries, MetaCacheEntry, MetadataResolutionParams};
|
||||
use std::sync::Arc;
|
||||
@@ -376,7 +376,7 @@ pub(super) fn resolve_rebalance_optional_bucket_config_result<T>(
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn load_rebalance_bucket_configs(bucket: &str) -> Result<RebalanceBucketConfigs> {
|
||||
pub(super) async fn load_rebalance_bucket_configs(api: &ECStore, bucket: &str) -> Result<RebalanceBucketConfigs> {
|
||||
if bucket == crate::disk::RUSTFS_META_BUCKET {
|
||||
return Ok(RebalanceBucketConfigs::default());
|
||||
}
|
||||
@@ -387,9 +387,10 @@ pub(super) async fn load_rebalance_bucket_configs(bucket: &str) -> Result<Rebala
|
||||
crate::bucket::versioning_sys::BucketVersioningSys::get(bucket).await,
|
||||
)?;
|
||||
|
||||
let expiry_configs = crate::bucket::lifecycle::get_expiry_configs(api, bucket).await?;
|
||||
Ok(RebalanceBucketConfigs {
|
||||
lifecycle_config: runtime_sources::bucket_lifecycle_config(bucket).await,
|
||||
lock_retention: crate::bucket::object_lock::objectlock_sys::BucketObjectLockSys::get(bucket).await,
|
||||
lifecycle_config: expiry_configs.lifecycle.map(|config| (*config).clone()),
|
||||
object_lock_config: expiry_configs.object_lock.map(|config| (*config).clone()),
|
||||
replication_config: resolve_rebalance_optional_bucket_config_result(
|
||||
bucket,
|
||||
"replication",
|
||||
|
||||
@@ -54,8 +54,8 @@ use crate::disk::{
|
||||
use crate::erasure::coding::BitrotReader;
|
||||
use crate::io_support::bitrot::ShardReader;
|
||||
use crate::io_support::bitrot::{
|
||||
BitrotReaderStageMetrics, DeferredReaderStripeHandle, create_bitrot_reader_with_stage_metrics,
|
||||
create_deferred_bitrot_reader_with_stripe_handle, object_mmap_read_enabled,
|
||||
BitrotReaderStageMetrics, DeferredReaderStripeHandle, adjust_shard_read_params, create_bitrot_reader_with_stage_metrics,
|
||||
create_deferred_bitrot_reader_with_stripe_handle, object_mmap_read_enabled, object_mmap_read_max_length,
|
||||
};
|
||||
use crate::set_disk::shard_source::ShardReadCost;
|
||||
use futures::stream::{FuturesUnordered, StreamExt};
|
||||
@@ -1406,6 +1406,102 @@ pub(in crate::set_disk) fn record_bitrot_reader_setup_strategy(
|
||||
}
|
||||
}
|
||||
|
||||
/// When all online shards are local and mmap-read is enabled, materialize
|
||||
/// shard bytes with one `batch_shard_pread` instead of per-shard
|
||||
/// `spawn_blocking` via `open_disk_reader`.
|
||||
#[cfg(unix)]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn try_create_bitrot_readers_via_batch_pread(
|
||||
files: &[FileInfo],
|
||||
disks: &[Option<DiskStore>],
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
part_number: usize,
|
||||
read_offset: usize,
|
||||
read_length: usize,
|
||||
shard_size: usize,
|
||||
checksum_algo: HashAlgorithm,
|
||||
skip_verify_bitrot: bool,
|
||||
) -> Option<BitrotReaderSetup> {
|
||||
use crate::disk::local::batch_shard_pread;
|
||||
use std::io::Cursor;
|
||||
|
||||
let (adj_off, adj_len) = adjust_shard_read_params(read_offset, read_length, shard_size, &checksum_algo);
|
||||
if adj_len > object_mmap_read_max_length() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut batch_items: Vec<(usize, std::path::PathBuf, usize, usize)> = Vec::new();
|
||||
for (idx, disk_op) in disks.iter().enumerate() {
|
||||
if files.get(idx).is_some_and(|fi| fi.data.is_some()) {
|
||||
return None;
|
||||
}
|
||||
if let Some(disk) = disk_op.as_ref() {
|
||||
let data_dir = files[idx].data_dir.unwrap_or_default();
|
||||
let path_str = format!("{object}/{data_dir}/part.{part_number}");
|
||||
match disk.get_object_path_if_local(bucket, &path_str) {
|
||||
Some(Ok(p)) => batch_items.push((idx, p, adj_off, adj_len)),
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if batch_items.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let requests: Vec<_> = batch_items.iter().map(|(_, p, off, len)| (p.clone(), *off, *len)).collect();
|
||||
let batch_results = batch_shard_pread(requests).await;
|
||||
|
||||
let mut setup = BitrotReaderSetup::new(disks.len());
|
||||
for (i, (idx, _, _, _)) in batch_items.iter().enumerate() {
|
||||
setup.mark_scheduled(*idx);
|
||||
match &batch_results[i] {
|
||||
Ok(bytes) => {
|
||||
let reader = BitrotReader::new(
|
||||
ShardReader::InMemory(Cursor::new(bytes.clone())),
|
||||
shard_size,
|
||||
checksum_algo.clone(),
|
||||
skip_verify_bitrot,
|
||||
);
|
||||
setup.apply_reader_result(*idx, Ok(Some(reader)));
|
||||
}
|
||||
Err(e) => {
|
||||
setup.apply_reader_result(*idx, Err(e.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (idx, disk_op) in disks.iter().enumerate() {
|
||||
if setup.scheduled[idx] {
|
||||
continue;
|
||||
}
|
||||
setup.mark_scheduled(idx);
|
||||
if disk_op.is_none() {
|
||||
setup.apply_reader_result(idx, Ok(None));
|
||||
}
|
||||
}
|
||||
|
||||
Some(setup)
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn try_create_bitrot_readers_via_batch_pread(
|
||||
_files: &[FileInfo],
|
||||
_disks: &[Option<DiskStore>],
|
||||
_bucket: &str,
|
||||
_object: &str,
|
||||
_part_number: usize,
|
||||
_read_offset: usize,
|
||||
_read_length: usize,
|
||||
_shard_size: usize,
|
||||
_checksum_algo: HashAlgorithm,
|
||||
_skip_verify_bitrot: bool,
|
||||
) -> Option<BitrotReaderSetup> {
|
||||
None
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(in crate::set_disk) async fn create_bitrot_readers_until_quorum_all_shards(
|
||||
files: &[FileInfo],
|
||||
@@ -1564,6 +1660,44 @@ pub(in crate::set_disk) async fn create_bitrot_readers_until_quorum_with_prefere
|
||||
attribution: Option<BitrotReaderSetupAttribution>,
|
||||
) -> BitrotReaderSetup {
|
||||
let strategy = get_bitrot_reader_setup_strategy(mode, prefer_data_blocks_first);
|
||||
|
||||
if use_mmap_read
|
||||
&& let Some(mut setup) = try_create_bitrot_readers_via_batch_pread(
|
||||
files,
|
||||
disks,
|
||||
bucket,
|
||||
object,
|
||||
part_number,
|
||||
read_offset,
|
||||
read_length,
|
||||
shard_size,
|
||||
checksum_algo.clone(),
|
||||
skip_verify_bitrot,
|
||||
)
|
||||
.await
|
||||
{
|
||||
record_bitrot_reader_setup_strategy(strategy, mode, attribution);
|
||||
fill_deferred_bitrot_readers(
|
||||
&mut setup,
|
||||
files,
|
||||
disks,
|
||||
bucket,
|
||||
object,
|
||||
part_number,
|
||||
read_offset,
|
||||
read_length,
|
||||
shard_size,
|
||||
checksum_algo,
|
||||
skip_verify_bitrot,
|
||||
use_mmap_read,
|
||||
data_shards,
|
||||
parity_shards,
|
||||
mode,
|
||||
);
|
||||
record_bitrot_reader_setup_fanout(strategy, mode, &setup, attribution);
|
||||
return setup;
|
||||
}
|
||||
|
||||
if strategy == BitrotReaderSetupStrategy::AllShards {
|
||||
return create_bitrot_readers_until_quorum_all_shards(
|
||||
files,
|
||||
@@ -2703,6 +2837,7 @@ impl SetDisks {
|
||||
|
||||
let mut disk_versions = vec![None; disks.len()];
|
||||
let mut data_dirs = vec![None; disks.len()];
|
||||
let mut cleanup_data_dirs = vec![None; disks.len()];
|
||||
let mut old_current_sizes = vec![None; disks.len()];
|
||||
|
||||
let results = join_all(futures).await;
|
||||
@@ -2710,7 +2845,8 @@ impl SetDisks {
|
||||
for (idx, result) in results.iter().enumerate() {
|
||||
match result.as_ref().map_err(|_| DiskError::Unexpected)? {
|
||||
Ok(res) => {
|
||||
data_dirs[idx] = res.old_data_dir;
|
||||
data_dirs[idx] = res.rollback_data_dir.or(res.old_data_dir);
|
||||
cleanup_data_dirs[idx] = res.cleanup_data_dir;
|
||||
disk_versions[idx].clone_from(&res.sign);
|
||||
old_current_sizes[idx] = res.old_current_size;
|
||||
errs.push(None);
|
||||
@@ -2814,7 +2950,7 @@ impl SetDisks {
|
||||
return Err(ret_err);
|
||||
}
|
||||
|
||||
let data_dir = Self::reduce_common_data_dir(&data_dirs, write_quorum);
|
||||
let data_dir = Self::reduce_common_data_dir(&cleanup_data_dirs, write_quorum);
|
||||
let convergence = Self::classify_rename_convergence(&disk_versions, &errs);
|
||||
let old_current_size = Self::reduce_common_old_current_size(&old_current_sizes, write_quorum);
|
||||
let online_disks = Self::eval_disks(disks, &errs);
|
||||
@@ -2822,7 +2958,7 @@ impl SetDisks {
|
||||
disks
|
||||
.iter()
|
||||
.zip(errs.iter())
|
||||
.zip(data_dirs.iter())
|
||||
.zip(cleanup_data_dirs.iter())
|
||||
.map(|((disk, err), old_data_dir)| {
|
||||
if err.is_none() && *old_data_dir == Some(data_dir) {
|
||||
disk.clone()
|
||||
@@ -3791,6 +3927,29 @@ impl SetDisks {
|
||||
Ok(m)
|
||||
}
|
||||
|
||||
fn reduce_delete_prefix_results(results: Vec<disk::error::Result<()>>, write_quorum: usize) -> disk::error::Result<()> {
|
||||
let has_existing_volume = results
|
||||
.iter()
|
||||
.any(|result| matches!(result, Ok(()) | Err(DiskError::FileNotFound)));
|
||||
let volume_not_found_count = results
|
||||
.iter()
|
||||
.filter(|result| matches!(result, Err(DiskError::VolumeNotFound)))
|
||||
.count();
|
||||
let errs = results
|
||||
.into_iter()
|
||||
.map(|result| result.err().filter(|err| !DiskError::is_err_object_not_found(err)))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if let Some(err) = reduce_write_quorum_errs(&errs, OBJECT_OP_IGNORED_ERRS, write_quorum) {
|
||||
return Err(err);
|
||||
}
|
||||
if !has_existing_volume && volume_not_found_count >= write_quorum {
|
||||
return Err(DiskError::VolumeNotFound);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(in crate::set_disk) async fn delete_prefix(&self, bucket: &str, prefix: &str) -> disk::error::Result<()> {
|
||||
let disks = self.get_disks_internal().await;
|
||||
let write_quorum = disks.len() / 2 + 1;
|
||||
@@ -3813,18 +3972,12 @@ impl SetDisks {
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
Ok(())
|
||||
Err(DiskError::DiskNotFound)
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let errs = join_all(futures).await.into_iter().map(|v| v.err()).collect::<Vec<_>>();
|
||||
|
||||
if let Some(err) = reduce_write_quorum_errs(&errs, OBJECT_OP_IGNORED_ERRS, write_quorum) {
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
Self::reduce_delete_prefix_results(join_all(futures).await, write_quorum)
|
||||
}
|
||||
|
||||
/// Scan a single disk's copy of `prefix` and decide whether it is an orphan
|
||||
@@ -5744,16 +5897,147 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_prefix_removes_present_disks_and_ignores_missing_disk_slots() {
|
||||
async fn delete_prefix_succeeds_when_present_disks_reach_quorum() {
|
||||
let bucket = "delete-prefix-bucket";
|
||||
let (_dir, disk) = read_multiple_test_disk(bucket, &[("prefix/object.txt", b"payload".as_slice())]).await;
|
||||
let set = io_primitives_test_set(vec![Some(disk.clone()), None], 1).await;
|
||||
let (_dir1, disk1) = read_multiple_test_disk(bucket, &[("prefix/object.txt", b"one".as_slice())]).await;
|
||||
let (_dir2, disk2) = read_multiple_test_disk(bucket, &[("prefix/object.txt", b"two".as_slice())]).await;
|
||||
let (_dir3, disk3) = read_multiple_test_disk(bucket, &[("prefix/object.txt", b"three".as_slice())]).await;
|
||||
let set = io_primitives_test_set(vec![Some(disk1.clone()), Some(disk2.clone()), Some(disk3.clone()), None], 2).await;
|
||||
|
||||
set.delete_prefix(bucket, "prefix")
|
||||
.await
|
||||
.expect("missing disk slots should not block prefix deletion");
|
||||
.expect("three successful disks should meet a four-disk write quorum");
|
||||
|
||||
assert!(matches!(disk.read_all(bucket, "prefix/object.txt").await, Err(DiskError::FileNotFound)));
|
||||
for disk in [disk1, disk2, disk3] {
|
||||
assert!(matches!(disk.read_all(bucket, "prefix/object.txt").await, Err(DiskError::FileNotFound)));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_prefix_counts_confirmed_absence_toward_quorum() {
|
||||
let bucket = "delete-prefix-confirmed-absence";
|
||||
let (_dir1, disk1) = read_multiple_test_disk(bucket, &[("prefix/object.txt", b"one".as_slice())]).await;
|
||||
let (_dir2, disk2) = read_multiple_test_disk(bucket, &[("prefix/object.txt", b"two".as_slice())]).await;
|
||||
let (_dir3, disk3) = read_multiple_test_disk(bucket, &[]).await;
|
||||
let (_dir4, disk4) = read_multiple_test_disk(bucket, &[]).await;
|
||||
disk3
|
||||
.delete_volume(bucket, true)
|
||||
.await
|
||||
.expect("third disk bucket should be absent");
|
||||
disk4
|
||||
.delete_volume(bucket, true)
|
||||
.await
|
||||
.expect("fourth disk bucket should be absent");
|
||||
let set = io_primitives_test_set(vec![Some(disk1.clone()), Some(disk2.clone()), Some(disk3), Some(disk4)], 2).await;
|
||||
|
||||
set.delete_prefix(bucket, "prefix")
|
||||
.await
|
||||
.expect("successful deletes and confirmed absence should jointly meet quorum");
|
||||
|
||||
for disk in [disk1, disk2] {
|
||||
assert!(matches!(disk.read_all(bucket, "prefix/object.txt").await, Err(DiskError::FileNotFound)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_prefix_result_reduction_preserves_existing_volume_evidence() {
|
||||
assert_eq!(
|
||||
SetDisks::reduce_delete_prefix_results(
|
||||
vec![
|
||||
Err(DiskError::FileNotFound),
|
||||
Err(DiskError::FileNotFound),
|
||||
Err(DiskError::FileNotFound),
|
||||
Err(DiskError::DiskNotFound),
|
||||
],
|
||||
3,
|
||||
),
|
||||
Ok(())
|
||||
);
|
||||
assert_eq!(
|
||||
SetDisks::reduce_delete_prefix_results(
|
||||
vec![
|
||||
Err(DiskError::FileNotFound),
|
||||
Err(DiskError::VolumeNotFound),
|
||||
Err(DiskError::VolumeNotFound),
|
||||
Err(DiskError::VolumeNotFound),
|
||||
],
|
||||
3,
|
||||
),
|
||||
Ok(())
|
||||
);
|
||||
assert_eq!(
|
||||
SetDisks::reduce_delete_prefix_results(
|
||||
vec![
|
||||
Ok(()),
|
||||
Err(DiskError::VolumeNotFound),
|
||||
Err(DiskError::VolumeNotFound),
|
||||
Err(DiskError::VolumeNotFound),
|
||||
],
|
||||
3,
|
||||
),
|
||||
Ok(())
|
||||
);
|
||||
assert_eq!(
|
||||
SetDisks::reduce_delete_prefix_results(
|
||||
vec![
|
||||
Err(DiskError::VolumeNotFound),
|
||||
Err(DiskError::VolumeNotFound),
|
||||
Err(DiskError::VolumeNotFound),
|
||||
Err(DiskError::VolumeNotFound),
|
||||
],
|
||||
3,
|
||||
),
|
||||
Err(DiskError::VolumeNotFound)
|
||||
);
|
||||
assert_eq!(
|
||||
SetDisks::reduce_delete_prefix_results(
|
||||
vec![Ok(()), Ok(()), Err(DiskError::DiskNotFound), Err(DiskError::DiskNotFound)],
|
||||
3,
|
||||
),
|
||||
Err(DiskError::ErasureWriteQuorum)
|
||||
);
|
||||
assert_eq!(
|
||||
SetDisks::reduce_delete_prefix_results(
|
||||
vec![
|
||||
Ok(()),
|
||||
Err(DiskError::FileAccessDenied),
|
||||
Err(DiskError::FileAccessDenied),
|
||||
Err(DiskError::FileAccessDenied),
|
||||
],
|
||||
3,
|
||||
),
|
||||
Err(DiskError::FileAccessDenied)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_prefix_fails_at_quorum_minus_one() {
|
||||
let bucket = "delete-prefix-quorum-minus-one";
|
||||
let (_dir1, disk1) = read_multiple_test_disk(bucket, &[("prefix/object.txt", b"one".as_slice())]).await;
|
||||
let (_dir2, disk2) = read_multiple_test_disk(bucket, &[("prefix/object.txt", b"two".as_slice())]).await;
|
||||
let set = io_primitives_test_set(vec![Some(disk1.clone()), Some(disk2.clone()), None, None], 2).await;
|
||||
|
||||
let err = set
|
||||
.delete_prefix(bucket, "prefix")
|
||||
.await
|
||||
.expect_err("two successful disks must not meet a four-disk write quorum");
|
||||
|
||||
assert_eq!(err, DiskError::ErasureWriteQuorum);
|
||||
for disk in [disk1, disk2] {
|
||||
assert!(matches!(disk.read_all(bucket, "prefix/object.txt").await, Err(DiskError::FileNotFound)));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_prefix_fails_when_all_disk_slots_are_missing() {
|
||||
let set = io_primitives_test_set(vec![None, None, None, None], 2).await;
|
||||
|
||||
let err = set
|
||||
.delete_prefix("delete-prefix-offline", "prefix")
|
||||
.await
|
||||
.expect_err("an entirely offline set must not report a successful deletion");
|
||||
|
||||
assert_eq!(err, DiskError::ErasureWriteQuorum);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -538,6 +538,8 @@ impl SetDisks {
|
||||
|| suffix.eq_ignore_ascii_case(http::SUFFIX_REPLICATION_TIMESTAMP)
|
||||
|| suffix.eq_ignore_ascii_case(http::SUFFIX_PURGESTATUS)
|
||||
|| Self::starts_with_ignore_ascii_case(suffix, http::SUFFIX_REPLICATION_RESET_ARN_PREFIX)
|
||||
// Raw compatibility keys are normalized and hashed separately below.
|
||||
|| Self::starts_with_ignore_ascii_case(suffix, http::SUFFIX_REPLICATION_DELETE_MARKER_VERSION_ARN_PREFIX)
|
||||
}
|
||||
|
||||
fn update_hash_quorum_metadata_map(hasher: &mut Sha256, entries: &HashMap<String, String>) {
|
||||
@@ -562,6 +564,22 @@ impl SetDisks {
|
||||
key
|
||||
}
|
||||
|
||||
/// Hash the per-target delete-marker versions through their normalized form
|
||||
/// so the dual internal prefixes carrying the same mapping share one
|
||||
/// identity, while a genuine disagreement between disks still changes the
|
||||
/// hash and surfaces as a quorum difference.
|
||||
fn update_hash_target_delete_marker_versions(hasher: &mut Sha256, metadata: &HashMap<String, String>) {
|
||||
let (versions, corrupt) = http::target_delete_marker_versions(metadata);
|
||||
hasher.update([u8::from(corrupt)]);
|
||||
let mut versions = versions.iter().collect::<Vec<_>>();
|
||||
versions.sort_by(|left, right| left.0.cmp(right.0));
|
||||
hasher.update(versions.len().to_le_bytes());
|
||||
for (arn, version_id) in versions {
|
||||
Self::update_hash_str(hasher, arn);
|
||||
Self::update_hash_str(hasher, version_id);
|
||||
}
|
||||
}
|
||||
|
||||
fn update_file_info_quorum_hash(hasher: &mut Sha256, meta: &FileInfo) {
|
||||
hasher.update(meta.size.to_le_bytes());
|
||||
hasher.update([u8::from(meta.deleted), u8::from(meta.mark_deleted)]);
|
||||
@@ -592,6 +610,7 @@ impl SetDisks {
|
||||
Self::update_hash_optional_bytes(hasher, meta.checksum.as_ref());
|
||||
|
||||
Self::update_hash_quorum_metadata_map(hasher, &meta.metadata);
|
||||
Self::update_hash_target_delete_marker_versions(hasher, &meta.metadata);
|
||||
|
||||
hasher.update(meta.parts.len().to_le_bytes());
|
||||
for part in meta.parts.iter() {
|
||||
@@ -1414,4 +1433,39 @@ mod tests {
|
||||
assert!(fallback_disks.iter().any(Option::is_none));
|
||||
assert!(fallback_parts.iter().any(|part| !part.is_valid()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_delete_marker_version_metadata_is_included_in_quorum_hash() {
|
||||
let suffix = "replication-delete-marker-version-arn:rustfs:replication::target:bucket";
|
||||
assert!(SetDisks::is_replication_quorum_metadata_key(&format!(
|
||||
"{}{}",
|
||||
http::RUSTFS_INTERNAL_PREFIX,
|
||||
suffix
|
||||
)));
|
||||
assert!(SetDisks::is_replication_quorum_metadata_key(&format!(
|
||||
"{}{}",
|
||||
http::MINIO_INTERNAL_PREFIX,
|
||||
suffix
|
||||
)));
|
||||
assert!(!SetDisks::is_replication_quorum_metadata_key("x-rustfs-internal-unrelated"));
|
||||
|
||||
let mut left = metadata_quorum_test_fileinfo(OffsetDateTime::now_utc(), 1);
|
||||
let mut right = left.clone();
|
||||
left.metadata
|
||||
.insert(format!("{}{}", http::RUSTFS_INTERNAL_PREFIX, suffix), "target-version-a".to_string());
|
||||
right
|
||||
.metadata
|
||||
.insert(format!("{}{}", http::MINIO_INTERNAL_PREFIX, suffix), "target-version-b".to_string());
|
||||
assert_ne!(SetDisks::file_info_quorum_hash(&left), SetDisks::file_info_quorum_hash(&right));
|
||||
|
||||
let mut dual_prefixed = left.clone();
|
||||
dual_prefixed
|
||||
.metadata
|
||||
.insert(format!("{}{}", http::MINIO_INTERNAL_PREFIX, suffix), "target-version-a".to_string());
|
||||
assert_eq!(
|
||||
SetDisks::file_info_quorum_hash(&left),
|
||||
SetDisks::file_info_quorum_hash(&dual_prefixed),
|
||||
"compatible prefixes carrying the same mapping must share one identity"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+421
-704
File diff suppressed because it is too large
Load Diff
@@ -155,6 +155,60 @@ fn injected_dangling_check_parts_error(bucket: &str, object: &str, disk_index: u
|
||||
.cloned()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
struct DanglingDeleteFailure {
|
||||
key: DanglingDeleteFailureKey,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
type DanglingDeleteFailureKey = (String, String, usize);
|
||||
|
||||
#[cfg(test)]
|
||||
type DanglingDeleteFailures = HashMap<DanglingDeleteFailureKey, DiskError>;
|
||||
|
||||
#[cfg(test)]
|
||||
fn dangling_delete_failures() -> &'static std::sync::Mutex<DanglingDeleteFailures> {
|
||||
static FAILURES: std::sync::OnceLock<std::sync::Mutex<DanglingDeleteFailures>> = std::sync::OnceLock::new();
|
||||
FAILURES.get_or_init(|| std::sync::Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl DanglingDeleteFailure {
|
||||
fn install(bucket: &str, object: &str, disk_index: usize, error: DiskError) -> Self {
|
||||
let key = (bucket.to_string(), object.to_string(), disk_index);
|
||||
let previous = dangling_delete_failures()
|
||||
.lock()
|
||||
.expect("dangling delete failure registry should not poison")
|
||||
.insert(key.clone(), error);
|
||||
assert!(previous.is_none(), "dangling delete failure already installed");
|
||||
Self { key }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl Drop for DanglingDeleteFailure {
|
||||
fn drop(&mut self) {
|
||||
dangling_delete_failures()
|
||||
.lock()
|
||||
.expect("dangling delete failure registry should not poison")
|
||||
.remove(&self.key);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn injected_dangling_delete_error(bucket: &str, object: &str, disk_index: usize) -> Option<DiskError> {
|
||||
dangling_delete_failures()
|
||||
.lock()
|
||||
.expect("dangling delete failure registry should not poison")
|
||||
.get(&(bucket.to_string(), object.to_string(), disk_index))
|
||||
.cloned()
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
fn injected_dangling_delete_error(_bucket: &str, _object: &str, _disk_index: usize) -> Option<DiskError> {
|
||||
None
|
||||
}
|
||||
|
||||
fn first_unhealthy_part_summary(
|
||||
data_errs_by_part: &HashMap<usize, Vec<usize>>,
|
||||
parts: &[ObjectPartInfo],
|
||||
@@ -1181,30 +1235,37 @@ impl SetDisks {
|
||||
|
||||
let errs = stat_all_dirs(&disks, bucket, object).await;
|
||||
let dangling_object = is_object_dir_dangling(&errs);
|
||||
if dangling_object && !dry_run && remove {
|
||||
let delete_errs = if dangling_object && !dry_run && remove {
|
||||
let mut futures = Vec::with_capacity(disks.len());
|
||||
for disk in disks.iter().flatten() {
|
||||
for (disk_index, disk) in disks.iter().enumerate() {
|
||||
let disk = disk.clone();
|
||||
let bucket = bucket.to_string();
|
||||
let object = object.to_string();
|
||||
futures.push(tokio::spawn(async move {
|
||||
let _ = disk
|
||||
.delete(
|
||||
&bucket,
|
||||
&object,
|
||||
futures.push(async move {
|
||||
let Some(disk) = disk else {
|
||||
return (disk_index, Some(DiskError::DiskNotFound));
|
||||
};
|
||||
if let Some(error) = injected_dangling_delete_error(bucket, object, disk_index) {
|
||||
return (disk_index, Some(error));
|
||||
}
|
||||
(
|
||||
disk_index,
|
||||
disk.delete(
|
||||
bucket,
|
||||
object,
|
||||
DeleteOptions {
|
||||
recursive: false,
|
||||
immediate: false,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}));
|
||||
.await
|
||||
.err(),
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
// ignore errors
|
||||
let _ = join_all(futures).await;
|
||||
}
|
||||
Some(join_all(futures).await)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
for (err, drive) in errs.iter().zip(self.set_endpoints.iter()) {
|
||||
let endpoint = drive.to_string();
|
||||
@@ -1229,6 +1290,28 @@ impl SetDisks {
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(delete_errs) = delete_errs {
|
||||
let mut delete_failure = None;
|
||||
for (index, err) in delete_errs {
|
||||
match err {
|
||||
None | Some(DiskError::FileNotFound) => {
|
||||
result.after.drives[index].state = DriveState::Missing.to_string();
|
||||
}
|
||||
Some(err) => {
|
||||
result.after.drives[index].state = if matches!(&err, DiskError::DiskNotFound) {
|
||||
DriveState::Offline.to_string()
|
||||
} else {
|
||||
DriveState::Corrupt.to_string()
|
||||
};
|
||||
if delete_failure.is_none() {
|
||||
delete_failure = Some(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return Ok((result, Some(delete_failure.unwrap_or(DiskError::FileNotFound))));
|
||||
}
|
||||
|
||||
if dangling_object || DiskError::is_all_not_found(&errs) {
|
||||
return Ok((result, Some(DiskError::FileNotFound)));
|
||||
}
|
||||
@@ -1553,7 +1636,7 @@ impl crate::storage_api_contracts::heal::HealOperations for SetDisks {
|
||||
|
||||
#[cfg(test)]
|
||||
mod heal_result_report_tests {
|
||||
use super::{DanglingCheckPartsFailure, DanglingDeleteSafety, SetDisks};
|
||||
use super::{DanglingCheckPartsFailure, DanglingDeleteFailure, DanglingDeleteSafety, SetDisks};
|
||||
use super::{HEAL_RENAME_INCOMPLETE, HealRenameFailureScope};
|
||||
use crate::disk::endpoint::Endpoint;
|
||||
use crate::disk::error::DiskError;
|
||||
@@ -2020,6 +2103,67 @@ mod heal_result_report_tests {
|
||||
assert_eq!(result.before.drives[3].state, DriveState::Ok.to_string());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dangling_object_dir_delete_preserves_results_and_propagates_failure() {
|
||||
let bucket = "bucket-dangling-dir-delete";
|
||||
let object = "dangling__XLDIR__";
|
||||
let mut temp_dirs = Vec::new();
|
||||
let mut endpoints = Vec::new();
|
||||
let mut disks = Vec::new();
|
||||
for _ in 0..8 {
|
||||
let (temp_dir, endpoint, disk) = real_disk().await;
|
||||
disk.make_volume(bucket).await.expect("test bucket should be created");
|
||||
temp_dirs.push(temp_dir);
|
||||
endpoints.push(endpoint);
|
||||
disks.push(Some(disk));
|
||||
}
|
||||
disks[0] = None;
|
||||
let set = set_disks_with(disks, endpoints, 4).await;
|
||||
for disk_index in [1, 2] {
|
||||
tokio::fs::create_dir_all(temp_dirs[disk_index].path().join(bucket).join(object))
|
||||
.await
|
||||
.expect("dangling object directory should be created");
|
||||
}
|
||||
|
||||
let _delete_failure = DanglingDeleteFailure::install(bucket, object, 2, DiskError::DiskAccessDenied);
|
||||
let _file_missing = DanglingDeleteFailure::install(bucket, object, 3, DiskError::FileNotFound);
|
||||
let _version_missing = DanglingDeleteFailure::install(bucket, object, 4, DiskError::FileVersionNotFound);
|
||||
let _path_missing = DanglingDeleteFailure::install(bucket, object, 5, DiskError::PathNotFound);
|
||||
let _volume_missing = DanglingDeleteFailure::install(bucket, object, 6, DiskError::VolumeNotFound);
|
||||
let _disk_missing = DanglingDeleteFailure::install(bucket, object, 7, DiskError::DiskNotFound);
|
||||
|
||||
let (result, err) = set
|
||||
.heal_object_dir_locked(bucket, object, false, true)
|
||||
.await
|
||||
.expect("dangling directory heal should report its per-disk delete results");
|
||||
|
||||
assert_eq!(err, Some(DiskError::DiskNotFound));
|
||||
assert_eq!(result.before.drives.len(), 8);
|
||||
assert_eq!(result.after.drives.len(), 8);
|
||||
assert_eq!(result.before.drives[0].state, DriveState::Offline.to_string());
|
||||
assert_eq!(result.after.drives[0].state, DriveState::Offline.to_string());
|
||||
assert_eq!(result.before.drives[1].state, DriveState::Ok.to_string());
|
||||
assert_eq!(result.after.drives[1].state, DriveState::Missing.to_string());
|
||||
assert_eq!(result.before.drives[2].state, DriveState::Ok.to_string());
|
||||
assert_eq!(result.after.drives[2].state, DriveState::Corrupt.to_string());
|
||||
assert_eq!(result.before.drives[3].state, DriveState::Missing.to_string());
|
||||
assert_eq!(result.after.drives[3].state, DriveState::Missing.to_string());
|
||||
for disk_index in [4, 5, 6] {
|
||||
assert_eq!(result.before.drives[disk_index].state, DriveState::Missing.to_string());
|
||||
assert_eq!(result.after.drives[disk_index].state, DriveState::Corrupt.to_string());
|
||||
}
|
||||
assert_eq!(result.before.drives[7].state, DriveState::Missing.to_string());
|
||||
assert_eq!(result.after.drives[7].state, DriveState::Offline.to_string());
|
||||
assert!(
|
||||
!temp_dirs[1].path().join(bucket).join(object).exists(),
|
||||
"successful delete must remove the dangling directory"
|
||||
);
|
||||
assert!(
|
||||
temp_dirs[2].path().join(bucket).join(object).is_dir(),
|
||||
"failed delete must leave the dangling directory for retry"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dangling_delete_guard_preserves_conflicting_identities_without_writing_metadata() {
|
||||
let bucket = "bucket-delete-guard-conflict";
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
//! per-version `SetDisks::heal_object`.
|
||||
|
||||
use super::super::*;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
use std::time::Duration;
|
||||
@@ -63,6 +64,7 @@ struct HealWalkCollector {
|
||||
batch_objects: usize,
|
||||
version_budget: usize,
|
||||
objects: Mutex<Vec<HealWalkObject>>,
|
||||
decode_error: Mutex<Option<DiskError>>,
|
||||
version_total: AtomicUsize,
|
||||
truncated: AtomicBool,
|
||||
cancel: CancellationToken,
|
||||
@@ -76,6 +78,22 @@ impl HealWalkCollector {
|
||||
})
|
||||
}
|
||||
|
||||
fn record_decode_error(&self, error: rustfs_filemeta::Error) {
|
||||
if let Ok(mut first_error) = self.decode_error.lock()
|
||||
&& first_error.is_none()
|
||||
{
|
||||
*first_error = Some(error.into());
|
||||
}
|
||||
self.cancel.cancel();
|
||||
}
|
||||
|
||||
fn take_decode_error(&self) -> disk::error::Result<Option<DiskError>> {
|
||||
self.decode_error.lock().map(|mut error| error.take()).map_err(|_| {
|
||||
self.cancel.cancel();
|
||||
DiskError::FileCorrupt
|
||||
})
|
||||
}
|
||||
|
||||
/// Expand one resolved entry into its versions and record it. Cancels the
|
||||
/// walk once EITHER page bound (distinct object names OR expanded versions)
|
||||
/// is met — always at a sorted object-key boundary so a heavily-versioned
|
||||
@@ -91,7 +109,7 @@ impl HealWalkCollector {
|
||||
let fiv = match entry.file_info_versions_with_free_versions(&self.bucket) {
|
||||
Ok(fiv) => fiv,
|
||||
Err(err) => {
|
||||
debug!(entry = %entry.name, error = ?err, "heal disk-walk skipped entry with unreadable versions");
|
||||
self.record_decode_error(err);
|
||||
return;
|
||||
}
|
||||
};
|
||||
@@ -130,6 +148,62 @@ impl HealWalkCollector {
|
||||
self.cancel.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
/// Collect versions from ALL partial entries across disks, deduplicate by
|
||||
/// `(name, version_id)`, and record a single merged object. This ensures
|
||||
/// that a version present on only a minority of disks (e.g. stale data on a
|
||||
/// returning node that was deleted on the quorum) is surfaced for healing.
|
||||
fn ingest_merged(&self, entries: &MetaCacheEntries) {
|
||||
let mut name = String::new();
|
||||
let mut seen = HashSet::new();
|
||||
let mut versions = Vec::new();
|
||||
|
||||
for entry in entries.0.iter().flatten() {
|
||||
if entry.is_dir() || entry.name.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if name.is_empty() {
|
||||
name = entry.name.clone();
|
||||
}
|
||||
let fiv = match entry.file_info_versions_with_free_versions(&self.bucket) {
|
||||
Ok(fiv) => fiv,
|
||||
Err(err) => {
|
||||
debug!(entry = %entry.name, error = ?err, "heal disk-walk merged skipped entry with unreadable versions");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
for fi in fiv.versions.iter().chain(fiv.free_versions.iter()) {
|
||||
let vid = fi.version_id.filter(|u| !u.is_nil()).map(|u| u.to_string());
|
||||
if seen.insert(vid.clone()) {
|
||||
versions.push(HealWalkVersion {
|
||||
name: entry.name.clone(),
|
||||
version_id: vid,
|
||||
is_delete_marker: fi.deleted,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if versions.is_empty() || name.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let added = versions.len();
|
||||
let (objs_len, ver_total) = {
|
||||
let Ok(mut objects) = self.lock_objects() else {
|
||||
return;
|
||||
};
|
||||
objects.push(HealWalkObject { name, versions });
|
||||
let objs_len = objects.len();
|
||||
let ver_total = self.version_total.fetch_add(added, Ordering::SeqCst) + added;
|
||||
(objs_len, ver_total)
|
||||
};
|
||||
|
||||
if objs_len >= self.batch_objects || ver_total >= self.version_budget {
|
||||
self.truncated.store(true, Ordering::SeqCst);
|
||||
self.cancel.cancel();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Finalize a collected disk-walk page: compute the resume cursor and apply
|
||||
@@ -191,6 +265,7 @@ impl SetDisks {
|
||||
batch_objects,
|
||||
version_budget: version_budget.max(1),
|
||||
objects: Mutex::new(Vec::new()),
|
||||
decode_error: Mutex::new(None),
|
||||
version_total: AtomicUsize::new(0),
|
||||
truncated: AtomicBool::new(false),
|
||||
cancel: CancellationToken::new(),
|
||||
@@ -198,7 +273,6 @@ impl SetDisks {
|
||||
|
||||
let agreed_collector = collector.clone();
|
||||
let partial_collector = collector.clone();
|
||||
let partial_bucket = bucket.to_string();
|
||||
|
||||
let filter_prefix = if prefix.is_empty() { None } else { Some(prefix.to_string()) };
|
||||
|
||||
@@ -223,15 +297,13 @@ impl SetDisks {
|
||||
})),
|
||||
partial: Some(Box::new(move |entries: MetaCacheEntries, _errs: &[Option<DiskError>]| {
|
||||
let collector = partial_collector.clone();
|
||||
let bucket = partial_bucket.clone();
|
||||
Box::pin(async move {
|
||||
// objQuorum = 1: take the cross-disk union of every version on
|
||||
// any disk. Fall back to the first present entry if the merge
|
||||
// somehow yields nothing.
|
||||
let entry = entries.resolve_union(&bucket).or_else(|| entries.first_found().0);
|
||||
if let Some(entry) = entry {
|
||||
collector.ingest(entry);
|
||||
}
|
||||
// Collect versions from ALL entries across disks, not just the
|
||||
// winner of resolve_union. When a returning node carries a stale
|
||||
// version that was deleted on the quorum, resolve_union would
|
||||
// pick only one entry and lose the stale version, preventing
|
||||
// its cleanup during heal.
|
||||
collector.ingest_merged(&entries);
|
||||
})
|
||||
})),
|
||||
finished: None,
|
||||
@@ -240,7 +312,12 @@ impl SetDisks {
|
||||
|
||||
// Drive the walk. A tolerated missing-path / not-found is treated as an
|
||||
// empty page rather than an error (nothing to heal on this prefix).
|
||||
match list_path_raw(collector.cancel.clone(), opts).await {
|
||||
let walk_result = list_path_raw(collector.cancel.clone(), opts).await;
|
||||
if let Some(err) = collector.take_decode_error()? {
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
match walk_result {
|
||||
Ok(()) => {}
|
||||
Err(DiskError::FileNotFound) | Err(DiskError::VolumeNotFound) => {
|
||||
debug!(bucket, prefix, "heal disk-walk treated missing path as empty page");
|
||||
@@ -260,6 +337,52 @@ impl SetDisks {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::set_disk::ops::object::hermetic_set_disks_support::hermetic_set_disks_isolated;
|
||||
use rustfs_filemeta::{ChecksumAlgo, ErasureAlgo, FileMetaVersion, MetaObject, VersionType};
|
||||
use time::OffsetDateTime;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn test_collector() -> Arc<HealWalkCollector> {
|
||||
Arc::new(HealWalkCollector {
|
||||
bucket: "bucket".to_string(),
|
||||
batch_objects: 2,
|
||||
version_budget: 2,
|
||||
objects: Mutex::new(Vec::new()),
|
||||
decode_error: Mutex::new(None),
|
||||
version_total: AtomicUsize::new(0),
|
||||
truncated: AtomicBool::new(false),
|
||||
cancel: CancellationToken::new(),
|
||||
})
|
||||
}
|
||||
|
||||
fn crc_valid_semantically_corrupt_entry(name: &str) -> MetaCacheEntry {
|
||||
let mut metadata = FileMeta::new();
|
||||
metadata
|
||||
.add_version_filemata(FileMetaVersion {
|
||||
version_type: VersionType::Object,
|
||||
object: Some(MetaObject {
|
||||
version_id: Some(Uuid::new_v4()),
|
||||
erasure_algorithm: ErasureAlgo::ReedSolomon,
|
||||
erasure_m: 2,
|
||||
erasure_n: 2,
|
||||
erasure_block_size: 1 << 20,
|
||||
bitrot_checksum_algo: ChecksumAlgo::HighwayHash,
|
||||
part_numbers: vec![1, 2],
|
||||
part_sizes: vec![10],
|
||||
part_actual_sizes: vec![10, 20],
|
||||
mod_time: Some(OffsetDateTime::now_utc()),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
.expect("corrupt test version should be accepted before semantic decoding");
|
||||
|
||||
MetaCacheEntry {
|
||||
name: name.to_string(),
|
||||
metadata: metadata.marshal_msg().expect("test metadata should encode with a valid CRC"),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn version(name: &str, id: &str, dm: bool) -> HealWalkVersion {
|
||||
HealWalkVersion {
|
||||
@@ -323,17 +446,80 @@ mod tests {
|
||||
assert!(!truncated);
|
||||
}
|
||||
|
||||
/// `ingest_merged` must surface versions from ALL entries, not just the
|
||||
/// winner of `resolve_union`. This covers the stale-object-after-reconnect
|
||||
/// scenario (issue #5029): a returning node carries a data version that was
|
||||
/// deleted on the quorum; `resolve_union` picks one entry and loses the
|
||||
/// other, but `ingest_merged` must see both.
|
||||
#[test]
|
||||
fn poisoned_collector_state_cancels_the_walk() {
|
||||
fn ingest_merged_surfaces_versions_from_divergent_entries() {
|
||||
use rustfs_filemeta::{FileInfo, FileMeta, MetaCacheEntries, MetaCacheEntry};
|
||||
use time::OffsetDateTime;
|
||||
use uuid::Uuid;
|
||||
|
||||
let t0 = OffsetDateTime::from_unix_timestamp(1_705_312_300).expect("valid timestamp");
|
||||
let t1 = OffsetDateTime::from_unix_timestamp(1_705_312_400).expect("valid timestamp");
|
||||
|
||||
// Node3: stale data version (V1) — only present on one disk.
|
||||
let make_entry = |name: &str, version_u128: u128, deleted: bool, ts: OffsetDateTime| -> MetaCacheEntry {
|
||||
let mut fi = FileInfo::new(name, 4, 2);
|
||||
fi.volume = "bucket".to_string();
|
||||
fi.name = name.to_string();
|
||||
fi.version_id = Some(Uuid::from_u128(version_u128));
|
||||
fi.versioned = true;
|
||||
fi.deleted = deleted;
|
||||
fi.size = if deleted { 0 } else { 100 };
|
||||
fi.mod_time = Some(ts);
|
||||
fi.metadata = [("etag".to_string(), format!("etag-{version_u128:x}"))].into();
|
||||
|
||||
let mut meta = FileMeta::new();
|
||||
meta.add_version(fi).expect("test metadata should accept version");
|
||||
let encoded = meta.marshal_msg().expect("test metadata should marshal");
|
||||
|
||||
MetaCacheEntry {
|
||||
name: name.to_string(),
|
||||
metadata: encoded,
|
||||
cached: Some(meta),
|
||||
reusable: false,
|
||||
}
|
||||
};
|
||||
|
||||
let stale_entry = make_entry("a.txt", 0xBEEF, false, t0); // data version on returning node
|
||||
let delete_entry = make_entry("a.txt", 0xCAFE, true, t1); // delete marker on quorum nodes
|
||||
|
||||
let collector = Arc::new(HealWalkCollector {
|
||||
bucket: "bucket".to_string(),
|
||||
batch_objects: 2,
|
||||
version_budget: 2,
|
||||
batch_objects: 1000,
|
||||
version_budget: 10_000,
|
||||
objects: Mutex::new(Vec::new()),
|
||||
version_total: AtomicUsize::new(0),
|
||||
decode_error: Mutex::new(None),
|
||||
truncated: AtomicBool::new(false),
|
||||
cancel: CancellationToken::new(),
|
||||
});
|
||||
|
||||
// Simulate the partial callback: two entries with different versions.
|
||||
let entries = MetaCacheEntries(vec![Some(stale_entry), Some(delete_entry.clone()), Some(delete_entry)]);
|
||||
collector.ingest_merged(&entries);
|
||||
|
||||
let objects = collector.lock_objects().expect("mutex should not be poisoned");
|
||||
assert_eq!(objects.len(), 1, "both entries share the same object name");
|
||||
let versions = &objects[0].versions;
|
||||
let version_ids: std::collections::HashSet<Option<String>> = versions.iter().map(|v| v.version_id.clone()).collect();
|
||||
assert!(
|
||||
version_ids.contains(&Some("00000000-0000-0000-0000-00000000beef".to_string())),
|
||||
"ingest_merged must surface the stale data version from the returning node: {version_ids:?}"
|
||||
);
|
||||
assert!(
|
||||
version_ids.contains(&Some("00000000-0000-0000-0000-00000000cafe".to_string())),
|
||||
"ingest_merged must also surface the delete marker from the quorum: {version_ids:?}"
|
||||
);
|
||||
assert_eq!(versions.len(), 2, "exactly two unique versions must be collected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn poisoned_collector_state_cancels_the_walk() {
|
||||
let collector = test_collector();
|
||||
let poison_target = Arc::clone(&collector);
|
||||
let _ = std::thread::spawn(move || {
|
||||
let _guard = poison_target.objects.lock().expect("fresh mutex should lock");
|
||||
@@ -346,4 +532,45 @@ mod tests {
|
||||
assert!(collector.cancel.is_cancelled(), "a poisoned page collector must cancel its walk");
|
||||
assert!(collector.objects.lock().is_err(), "poisoned state must remain fail-closed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn semantic_decode_failure_records_error_and_cancels_walk() {
|
||||
let collector = test_collector();
|
||||
let entry = crc_valid_semantically_corrupt_entry("corrupt-object");
|
||||
|
||||
collector.ingest(entry);
|
||||
|
||||
let error = collector
|
||||
.take_decode_error()
|
||||
.expect("decode error state should remain readable")
|
||||
.expect("semantic metadata corruption must be recorded");
|
||||
assert_eq!(error, DiskError::FileCorrupt);
|
||||
assert!(collector.cancel.is_cancelled(), "semantic metadata corruption must cancel the disk walk");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn heal_walk_returns_crc_valid_semantic_decode_failure() {
|
||||
let bucket = "bucket";
|
||||
let object = "corrupt-object";
|
||||
let (temp_dirs, disks, set_disks) = hermetic_set_disks_isolated(1).await;
|
||||
disks[0].make_volume(bucket).await.expect("test bucket should be created");
|
||||
|
||||
let object_dir = temp_dirs[0].path().join(bucket).join(object);
|
||||
tokio::fs::create_dir_all(&object_dir)
|
||||
.await
|
||||
.expect("test object directory should be created");
|
||||
tokio::fs::write(
|
||||
object_dir.join(crate::disk::STORAGE_FORMAT_FILE),
|
||||
crc_valid_semantically_corrupt_entry(object).metadata,
|
||||
)
|
||||
.await
|
||||
.expect("corrupt test metadata should be written");
|
||||
|
||||
let error = set_disks
|
||||
.heal_walk_versions_page(bucket, "", None, 2, 2)
|
||||
.await
|
||||
.expect_err("semantic metadata corruption must fail the heal disk walk");
|
||||
|
||||
assert_eq!(error, DiskError::FileCorrupt);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,6 +162,86 @@ fn map_upload_id_metadata_error(bucket: &str, object: &str, upload_id: &str, err
|
||||
err.into()
|
||||
}
|
||||
|
||||
fn multipart_bucket_incarnation_id(metadata: &HashMap<String, String>) -> Result<Option<Uuid>> {
|
||||
let Some(value) = rustfs_utils::http::metadata_compat::get_consistent_str(metadata, SUFFIX_BUCKET_INCARNATION_ID) else {
|
||||
if rustfs_utils::http::metadata_compat::contains_key_str(metadata, SUFFIX_BUCKET_INCARNATION_ID) {
|
||||
return Err(Error::other("invalid multipart bucket incarnation metadata"));
|
||||
}
|
||||
return Ok(None);
|
||||
};
|
||||
let incarnation = Uuid::parse_str(value).map_err(|_| Error::other("invalid multipart bucket incarnation metadata"))?;
|
||||
if incarnation.is_nil() {
|
||||
return Err(Error::other("invalid multipart bucket incarnation metadata"));
|
||||
}
|
||||
Ok(Some(incarnation))
|
||||
}
|
||||
|
||||
fn multipart_bucket_incarnation_matches(metadata: &HashMap<String, String>, expected: Uuid) -> bool {
|
||||
matches!(multipart_bucket_incarnation_id(metadata), Ok(Some(actual)) if actual == expected)
|
||||
}
|
||||
|
||||
fn validate_multipart_bucket_incarnation(
|
||||
metadata: &HashMap<String, String>,
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
upload_id: &str,
|
||||
expected: Option<Uuid>,
|
||||
upload_initiated: Option<OffsetDateTime>,
|
||||
bucket_created: Option<OffsetDateTime>,
|
||||
) -> Result<()> {
|
||||
let Some(expected) = expected else {
|
||||
return Ok(());
|
||||
};
|
||||
match multipart_bucket_incarnation_id(metadata) {
|
||||
Ok(Some(actual)) if actual == expected => return Ok(()),
|
||||
Ok(None)
|
||||
if matches!(
|
||||
(upload_initiated, bucket_created),
|
||||
(Some(upload_initiated), Some(bucket_created)) if upload_initiated >= bucket_created
|
||||
) =>
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Err(StorageError::InvalidUploadID(bucket.to_owned(), object.to_owned(), upload_id.to_owned()))
|
||||
}
|
||||
|
||||
async fn ensure_multipart_bucket_incarnation(
|
||||
ctx: &crate::runtime::instance::InstanceContext,
|
||||
fi: &FileInfo,
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
upload_id: &str,
|
||||
expected: Option<Uuid>,
|
||||
) -> Result<()> {
|
||||
let bucket_created = if expected.is_some() && matches!(multipart_bucket_incarnation_id(&fi.metadata), Ok(None)) {
|
||||
Some(metadata_sys::created_at_in(ctx, bucket).await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
validate_multipart_bucket_incarnation(&fi.metadata, bucket, object, upload_id, expected, fi.mod_time, bucket_created)
|
||||
}
|
||||
|
||||
fn ensure_multipart_bucket_lifecycle_lock_held(bucket: &str, object: &str, opts: &ObjectOptions) -> Result<()> {
|
||||
let Some(fence) = opts.bucket_lifecycle_lock_fence.as_ref() else {
|
||||
if opts.expected_bucket_incarnation_id.is_some() && !crate::bucket::utils::is_meta_bucketname(bucket) {
|
||||
return Err(Error::other("multipart bucket lifecycle lock fence is missing"));
|
||||
}
|
||||
return Ok(());
|
||||
};
|
||||
if fence.is_lock_lost() {
|
||||
return Err(StorageError::NamespaceLockQuorumUnavailable {
|
||||
mode: "multipart_bucket_generation",
|
||||
bucket: bucket.to_string(),
|
||||
object: object.to_string(),
|
||||
required: 1,
|
||||
achieved: 0,
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn empty_upload_fallback_possible(successful_responses: usize, errs: &[Option<DiskError>]) -> bool {
|
||||
successful_responses == 0
|
||||
&& errs.iter().any(|err| matches!(err, Some(DiskError::FileNotFound)))
|
||||
@@ -451,6 +531,200 @@ impl SetDisks {
|
||||
|
||||
Ok((fi, parts_metadata))
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) async fn list_multipart_uploads_for_incarnation(
|
||||
&self,
|
||||
bucket: &str,
|
||||
prefix: &str,
|
||||
key_marker: Option<String>,
|
||||
upload_id_marker: Option<String>,
|
||||
delimiter: Option<String>,
|
||||
max_uploads: usize,
|
||||
expected_incarnation_id: Option<Uuid>,
|
||||
) -> Result<ListMultipartsInfo> {
|
||||
let disks = self.disks.read().await.clone();
|
||||
if disks.is_empty() {
|
||||
return Err(Error::ErasureReadQuorum);
|
||||
}
|
||||
let discovery_quorum = if self.default_parity_count == 0 {
|
||||
disks.len()
|
||||
} else {
|
||||
(disks.len() / 2).max(1)
|
||||
};
|
||||
let mut discovery_errors = (0..disks.len()).map(|_| Some(DiskError::DiskNotFound)).collect::<Vec<_>>();
|
||||
let mut candidate_counts = HashMap::<String, usize>::new();
|
||||
let mut discovery_tasks = JoinSet::new();
|
||||
for (index, disk) in disks.iter().enumerate() {
|
||||
let disk = disk.clone();
|
||||
let bucket = bucket.to_string();
|
||||
discovery_tasks.spawn(async move {
|
||||
let result = match disk {
|
||||
Some(disk) => multipart_upload_paths_on_disk(disk, &bucket).await,
|
||||
None => Err(DiskError::DiskNotFound),
|
||||
};
|
||||
(index, result)
|
||||
});
|
||||
}
|
||||
|
||||
while let Some(task_result) = discovery_tasks.join_next().await {
|
||||
let Ok((index, result)) = task_result else {
|
||||
continue;
|
||||
};
|
||||
match result {
|
||||
Ok(paths) => {
|
||||
discovery_errors[index] = None;
|
||||
for path in paths {
|
||||
*candidate_counts.entry(path).or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
Err(err) => discovery_errors[index] = Some(err),
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(err) = reduce_read_quorum_errs(&discovery_errors, OBJECT_OP_IGNORED_ERRS, discovery_quorum) {
|
||||
return Err(to_object_err(err.into(), vec![bucket, prefix]));
|
||||
}
|
||||
|
||||
let candidate_paths = candidate_counts
|
||||
.into_iter()
|
||||
.filter_map(|(path, count)| (count >= discovery_quorum).then_some(path))
|
||||
.collect::<Vec<_>>();
|
||||
let listed_uploads = stream::iter(candidate_paths)
|
||||
.map(|upload_path| {
|
||||
let disks = &disks;
|
||||
async move {
|
||||
let (sha_dir, raw_upload_id) = upload_path
|
||||
.rsplit_once('/')
|
||||
.filter(|(sha_dir, upload_id)| !sha_dir.is_empty() && !upload_id.is_empty())
|
||||
.ok_or(DiskError::CorruptedFormat)?;
|
||||
let (parts_metadata, errs) = Self::read_all_fileinfo(
|
||||
disks,
|
||||
bucket,
|
||||
RUSTFS_META_MULTIPART_BUCKET,
|
||||
&upload_path,
|
||||
"",
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
let missing_metadata = errs
|
||||
.iter()
|
||||
.filter(|err| matches!(err, Some(DiskError::FileNotFound | DiskError::VolumeNotFound)))
|
||||
.count();
|
||||
if missing_metadata >= discovery_quorum {
|
||||
if expected_incarnation_id.is_some() {
|
||||
return Ok(None);
|
||||
}
|
||||
// Completion moves the authoritative upload metadata into the
|
||||
// committed object before it removes the staging directory. A
|
||||
// crash in that window intentionally leaves a reclaimable
|
||||
// upload directory whose object name can still be proven for
|
||||
// an exact-key listing by matching the namespace hash.
|
||||
if !prefix.is_empty() && sha_dir == Self::get_multipart_sha_dir(bucket, prefix) {
|
||||
let initiated = raw_upload_id
|
||||
.rsplit_once('x')
|
||||
.and_then(|(_, timestamp)| timestamp.parse::<i128>().ok())
|
||||
.and_then(|timestamp| OffsetDateTime::from_unix_timestamp_nanos(timestamp).ok());
|
||||
return Ok(Some(MultipartInfo {
|
||||
bucket: bucket.to_owned(),
|
||||
object: prefix.to_owned(),
|
||||
upload_id: runtime_sources::deployment_upload_id(raw_upload_id),
|
||||
initiated,
|
||||
..Default::default()
|
||||
}));
|
||||
}
|
||||
return Ok(None);
|
||||
}
|
||||
let (read_quorum, _) = Self::object_quorum_from_meta(&parts_metadata, &errs, self.default_parity_count)?;
|
||||
let read_quorum = usize::try_from(read_quorum).map_err(|_| DiskError::ErasureReadQuorum)?;
|
||||
if let Some(err) = reduce_read_quorum_errs(&errs, OBJECT_OP_IGNORED_ERRS, read_quorum) {
|
||||
return Err(err);
|
||||
}
|
||||
let (_, mod_time, etag) = Self::list_online_disks(disks, &parts_metadata, &errs, read_quorum);
|
||||
let file_info = Self::pick_valid_fileinfo(&parts_metadata, mod_time, etag, read_quorum)?;
|
||||
if expected_incarnation_id
|
||||
.is_some_and(|expected| !multipart_bucket_incarnation_matches(&file_info.metadata, expected))
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let object = match (
|
||||
file_info.metadata.get(RUSTFS_MULTIPART_BUCKET_KEY),
|
||||
file_info.metadata.get(RUSTFS_MULTIPART_OBJECT_KEY),
|
||||
) {
|
||||
(Some(stored_bucket), Some(object)) if stored_bucket == bucket && !object.is_empty() => object.clone(),
|
||||
_ => return Err(DiskError::CorruptedFormat),
|
||||
};
|
||||
if !object.starts_with(prefix) {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let initiated = raw_upload_id
|
||||
.rsplit_once('x')
|
||||
.and_then(|(_, timestamp)| timestamp.parse::<i128>().ok())
|
||||
.and_then(|timestamp| OffsetDateTime::from_unix_timestamp_nanos(timestamp).ok())
|
||||
.or(file_info.mod_time);
|
||||
|
||||
Ok(Some(MultipartInfo {
|
||||
bucket: bucket.to_owned(),
|
||||
object,
|
||||
upload_id: runtime_sources::deployment_upload_id(raw_upload_id),
|
||||
initiated,
|
||||
..Default::default()
|
||||
}))
|
||||
}
|
||||
})
|
||||
.buffer_unordered(MULTIPART_LIST_IO_CONCURRENCY)
|
||||
.collect::<Vec<disk::error::Result<Option<MultipartInfo>>>>()
|
||||
.await;
|
||||
|
||||
let mut uploads = Vec::with_capacity(listed_uploads.len());
|
||||
for result in listed_uploads {
|
||||
if let Some(upload) = result.map_err(Error::from)? {
|
||||
uploads.push(upload);
|
||||
}
|
||||
}
|
||||
|
||||
let mut common_prefixes = HashSet::new();
|
||||
let mut unfolded_uploads = Vec::with_capacity(uploads.len());
|
||||
let delimiter_value = delimiter.as_deref().filter(|delimiter| !delimiter.is_empty());
|
||||
for upload in uploads {
|
||||
let Some(delimiter) = delimiter_value else {
|
||||
unfolded_uploads.push(upload);
|
||||
continue;
|
||||
};
|
||||
let suffix = upload.object.strip_prefix(prefix).ok_or(DiskError::CorruptedFormat)?;
|
||||
if let Some((common_prefix, _)) = suffix.split_once(delimiter) {
|
||||
common_prefixes.insert(format!("{prefix}{common_prefix}{delimiter}"));
|
||||
} else {
|
||||
unfolded_uploads.push(upload);
|
||||
}
|
||||
}
|
||||
|
||||
let page = paginate_multipart_listing(
|
||||
unfolded_uploads,
|
||||
common_prefixes.into_iter().collect(),
|
||||
key_marker.as_deref(),
|
||||
key_marker.as_ref().and(upload_id_marker.as_deref()),
|
||||
max_uploads,
|
||||
false,
|
||||
);
|
||||
|
||||
Ok(ListMultipartsInfo {
|
||||
key_marker: key_marker.to_owned(),
|
||||
upload_id_marker: upload_id_marker.to_owned(),
|
||||
next_key_marker: page.next_key_marker,
|
||||
next_upload_id_marker: page.next_upload_id_marker,
|
||||
max_uploads,
|
||||
is_truncated: page.is_truncated,
|
||||
uploads: page.uploads,
|
||||
common_prefixes: page.common_prefixes,
|
||||
prefix: prefix.to_owned(),
|
||||
delimiter: delimiter.to_owned(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
@@ -498,6 +772,8 @@ impl crate::storage_api_contracts::multipart::MultipartOperations for SetDisks {
|
||||
let upload_id_path = Self::get_upload_id_dir(bucket, object, upload_id);
|
||||
|
||||
let (fi, _) = self.check_upload_id_exists(bucket, object, upload_id, true).await?;
|
||||
ensure_multipart_bucket_incarnation(&self.ctx, &fi, bucket, object, upload_id, opts.expected_bucket_incarnation_id)
|
||||
.await?;
|
||||
|
||||
let write_quorum = fi.write_quorum(self.default_write_quorum());
|
||||
|
||||
@@ -747,7 +1023,16 @@ impl crate::storage_api_contracts::multipart::MultipartOperations for SetDisks {
|
||||
.await?,
|
||||
)
|
||||
};
|
||||
self.check_upload_id_exists(bucket, object, upload_id, false).await?;
|
||||
let (commit_fi, _) = self.check_upload_id_exists(bucket, object, upload_id, false).await?;
|
||||
ensure_multipart_bucket_incarnation(
|
||||
&self.ctx,
|
||||
&commit_fi,
|
||||
bucket,
|
||||
object,
|
||||
upload_id,
|
||||
opts.expected_bucket_incarnation_id,
|
||||
)
|
||||
.await?;
|
||||
#[cfg(test)]
|
||||
pause_multipart_commit(bucket, object, MultipartCommitPause::PutPartBeforeLockLost).await;
|
||||
if _upload_commit_guard.as_ref().is_some_and(|guard| guard.is_lock_lost()) {
|
||||
@@ -759,6 +1044,7 @@ impl crate::storage_api_contracts::multipart::MultipartOperations for SetDisks {
|
||||
achieved: 0,
|
||||
});
|
||||
}
|
||||
ensure_multipart_bucket_lifecycle_lock_held(bucket, object, opts)?;
|
||||
|
||||
let _ = self
|
||||
.rename_part(
|
||||
@@ -820,6 +1106,8 @@ impl crate::storage_api_contracts::multipart::MultipartOperations for SetDisks {
|
||||
.acquire_multipart_upload_read_lock("list_object_parts", bucket, object, upload_id, opts)
|
||||
.await?;
|
||||
let (fi, _) = self.check_upload_id_exists(bucket, object, upload_id, false).await?;
|
||||
ensure_multipart_bucket_incarnation(&self.ctx, &fi, bucket, object, upload_id, opts.expected_bucket_incarnation_id)
|
||||
.await?;
|
||||
|
||||
let upload_id_path = Self::get_upload_id_dir(bucket, object, upload_id);
|
||||
|
||||
@@ -927,6 +1215,7 @@ impl crate::storage_api_contracts::multipart::MultipartOperations for SetDisks {
|
||||
ret.next_part_number_marker = ret.parts.last().map(|v| v.part_num).unwrap_or_default();
|
||||
}
|
||||
|
||||
ensure_multipart_bucket_lifecycle_lock_held(bucket, object, opts)?;
|
||||
Ok(ret)
|
||||
}
|
||||
|
||||
@@ -940,179 +1229,44 @@ impl crate::storage_api_contracts::multipart::MultipartOperations for SetDisks {
|
||||
delimiter: Option<String>,
|
||||
max_uploads: usize,
|
||||
) -> Result<ListMultipartsInfo> {
|
||||
let disks = self.disks.read().await.clone();
|
||||
if disks.is_empty() {
|
||||
return Err(Error::ErasureReadQuorum);
|
||||
}
|
||||
let discovery_quorum = if self.default_parity_count == 0 {
|
||||
disks.len()
|
||||
let bucket_lifecycle_guard = if crate::bucket::utils::is_meta_bucketname(bucket) {
|
||||
None
|
||||
} else {
|
||||
(disks.len() / 2).max(1)
|
||||
Some(
|
||||
metadata_sys::object_store_in(&self.ctx)
|
||||
.await?
|
||||
.acquire_bucket_lifecycle_read_lock(bucket)
|
||||
.await?,
|
||||
)
|
||||
};
|
||||
let mut discovery_errors = (0..disks.len()).map(|_| Some(DiskError::DiskNotFound)).collect::<Vec<_>>();
|
||||
let mut candidate_counts = HashMap::<String, usize>::new();
|
||||
let mut discovery_tasks = JoinSet::new();
|
||||
for (index, disk) in disks.iter().enumerate() {
|
||||
let disk = disk.clone();
|
||||
let bucket = bucket.to_string();
|
||||
discovery_tasks.spawn(async move {
|
||||
let result = match disk {
|
||||
Some(disk) => multipart_upload_paths_on_disk(disk, &bucket).await,
|
||||
None => Err(DiskError::DiskNotFound),
|
||||
};
|
||||
(index, result)
|
||||
let expected_incarnation_id = if bucket_lifecycle_guard.is_some() {
|
||||
Some(metadata_sys::get_bucket_incarnation_id_in(&self.ctx, bucket).await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let result = self
|
||||
.list_multipart_uploads_for_incarnation(
|
||||
bucket,
|
||||
prefix,
|
||||
key_marker,
|
||||
upload_id_marker,
|
||||
delimiter,
|
||||
max_uploads,
|
||||
expected_incarnation_id,
|
||||
)
|
||||
.await;
|
||||
|
||||
if bucket_lifecycle_guard.as_ref().is_some_and(|guard| guard.is_lock_lost()) {
|
||||
return Err(StorageError::NamespaceLockQuorumUnavailable {
|
||||
mode: "multipart_bucket_generation",
|
||||
bucket: bucket.to_string(),
|
||||
object: prefix.to_string(),
|
||||
required: 1,
|
||||
achieved: 0,
|
||||
});
|
||||
}
|
||||
|
||||
while let Some(task_result) = discovery_tasks.join_next().await {
|
||||
let Ok((index, result)) = task_result else {
|
||||
continue;
|
||||
};
|
||||
match result {
|
||||
Ok(paths) => {
|
||||
discovery_errors[index] = None;
|
||||
for path in paths {
|
||||
*candidate_counts.entry(path).or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
Err(err) => discovery_errors[index] = Some(err),
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(err) = reduce_read_quorum_errs(&discovery_errors, OBJECT_OP_IGNORED_ERRS, discovery_quorum) {
|
||||
return Err(to_object_err(err.into(), vec![bucket, prefix]));
|
||||
}
|
||||
|
||||
let candidate_paths = candidate_counts
|
||||
.into_iter()
|
||||
.filter_map(|(path, count)| (count >= discovery_quorum).then_some(path))
|
||||
.collect::<Vec<_>>();
|
||||
let listed_uploads = stream::iter(candidate_paths)
|
||||
.map(|upload_path| {
|
||||
let disks = &disks;
|
||||
async move {
|
||||
let (sha_dir, raw_upload_id) = upload_path
|
||||
.rsplit_once('/')
|
||||
.filter(|(sha_dir, upload_id)| !sha_dir.is_empty() && !upload_id.is_empty())
|
||||
.ok_or(DiskError::CorruptedFormat)?;
|
||||
let (parts_metadata, errs) = Self::read_all_fileinfo(
|
||||
disks,
|
||||
bucket,
|
||||
RUSTFS_META_MULTIPART_BUCKET,
|
||||
&upload_path,
|
||||
"",
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
let missing_metadata = errs
|
||||
.iter()
|
||||
.filter(|err| matches!(err, Some(DiskError::FileNotFound | DiskError::VolumeNotFound)))
|
||||
.count();
|
||||
if missing_metadata >= discovery_quorum {
|
||||
// Completion moves the authoritative upload metadata into the
|
||||
// committed object before it removes the staging directory. A
|
||||
// crash in that window intentionally leaves a reclaimable
|
||||
// upload directory whose object name can still be proven for
|
||||
// an exact-key listing by matching the namespace hash.
|
||||
if !prefix.is_empty() && sha_dir == Self::get_multipart_sha_dir(bucket, prefix) {
|
||||
let initiated = raw_upload_id
|
||||
.rsplit_once('x')
|
||||
.and_then(|(_, timestamp)| timestamp.parse::<i128>().ok())
|
||||
.and_then(|timestamp| OffsetDateTime::from_unix_timestamp_nanos(timestamp).ok());
|
||||
return Ok(Some(MultipartInfo {
|
||||
bucket: bucket.to_owned(),
|
||||
object: prefix.to_owned(),
|
||||
upload_id: runtime_sources::deployment_upload_id(raw_upload_id),
|
||||
initiated,
|
||||
..Default::default()
|
||||
}));
|
||||
}
|
||||
return Ok(None);
|
||||
}
|
||||
let (read_quorum, _) = Self::object_quorum_from_meta(&parts_metadata, &errs, self.default_parity_count)?;
|
||||
let read_quorum = usize::try_from(read_quorum).map_err(|_| DiskError::ErasureReadQuorum)?;
|
||||
if let Some(err) = reduce_read_quorum_errs(&errs, OBJECT_OP_IGNORED_ERRS, read_quorum) {
|
||||
return Err(err);
|
||||
}
|
||||
let (_, mod_time, etag) = Self::list_online_disks(disks, &parts_metadata, &errs, read_quorum);
|
||||
let file_info = Self::pick_valid_fileinfo(&parts_metadata, mod_time, etag, read_quorum)?;
|
||||
|
||||
let object = match (
|
||||
file_info.metadata.get(RUSTFS_MULTIPART_BUCKET_KEY),
|
||||
file_info.metadata.get(RUSTFS_MULTIPART_OBJECT_KEY),
|
||||
) {
|
||||
(Some(stored_bucket), Some(object)) if stored_bucket == bucket && !object.is_empty() => object.clone(),
|
||||
_ => return Err(DiskError::CorruptedFormat),
|
||||
};
|
||||
if !object.starts_with(prefix) {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let initiated = raw_upload_id
|
||||
.rsplit_once('x')
|
||||
.and_then(|(_, timestamp)| timestamp.parse::<i128>().ok())
|
||||
.and_then(|timestamp| OffsetDateTime::from_unix_timestamp_nanos(timestamp).ok())
|
||||
.or(file_info.mod_time);
|
||||
|
||||
Ok(Some(MultipartInfo {
|
||||
bucket: bucket.to_owned(),
|
||||
object,
|
||||
upload_id: runtime_sources::deployment_upload_id(raw_upload_id),
|
||||
initiated,
|
||||
..Default::default()
|
||||
}))
|
||||
}
|
||||
})
|
||||
.buffer_unordered(MULTIPART_LIST_IO_CONCURRENCY)
|
||||
.collect::<Vec<disk::error::Result<Option<MultipartInfo>>>>()
|
||||
.await;
|
||||
|
||||
let mut uploads = Vec::with_capacity(listed_uploads.len());
|
||||
for result in listed_uploads {
|
||||
if let Some(upload) = result.map_err(Error::from)? {
|
||||
uploads.push(upload);
|
||||
}
|
||||
}
|
||||
|
||||
let mut common_prefixes = HashSet::new();
|
||||
let mut unfolded_uploads = Vec::with_capacity(uploads.len());
|
||||
let delimiter_value = delimiter.as_deref().filter(|delimiter| !delimiter.is_empty());
|
||||
for upload in uploads {
|
||||
let Some(delimiter) = delimiter_value else {
|
||||
unfolded_uploads.push(upload);
|
||||
continue;
|
||||
};
|
||||
let suffix = upload.object.strip_prefix(prefix).ok_or(DiskError::CorruptedFormat)?;
|
||||
if let Some((common_prefix, _)) = suffix.split_once(delimiter) {
|
||||
common_prefixes.insert(format!("{prefix}{common_prefix}{delimiter}"));
|
||||
} else {
|
||||
unfolded_uploads.push(upload);
|
||||
}
|
||||
}
|
||||
|
||||
let page = paginate_multipart_listing(
|
||||
unfolded_uploads,
|
||||
common_prefixes.into_iter().collect(),
|
||||
key_marker.as_deref(),
|
||||
key_marker.as_ref().and(upload_id_marker.as_deref()),
|
||||
max_uploads,
|
||||
false,
|
||||
);
|
||||
|
||||
Ok(ListMultipartsInfo {
|
||||
key_marker: key_marker.to_owned(),
|
||||
upload_id_marker: upload_id_marker.to_owned(),
|
||||
next_key_marker: page.next_key_marker,
|
||||
next_upload_id_marker: page.next_upload_id_marker,
|
||||
max_uploads,
|
||||
is_truncated: page.is_truncated,
|
||||
uploads: page.uploads,
|
||||
common_prefixes: page.common_prefixes,
|
||||
prefix: prefix.to_owned(),
|
||||
delimiter: delimiter.to_owned(),
|
||||
})
|
||||
result
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(self))]
|
||||
@@ -1225,6 +1379,9 @@ impl crate::storage_api_contracts::multipart::MultipartOperations for SetDisks {
|
||||
|
||||
user_defined.insert(RUSTFS_MULTIPART_BUCKET_KEY.to_string(), bucket.to_string());
|
||||
user_defined.insert(RUSTFS_MULTIPART_OBJECT_KEY.to_string(), object.to_string());
|
||||
if let Some(incarnation_id) = opts.expected_bucket_incarnation_id {
|
||||
insert_str(&mut user_defined, SUFFIX_BUCKET_INCARNATION_ID, incarnation_id.to_string());
|
||||
}
|
||||
|
||||
let (shuffle_disks, mut parts_metadatas) = Self::shuffle_disks_and_parts_metadata(&disks, &parts_metadata, &fi);
|
||||
let mod_time = opts.mod_time.unwrap_or_else(OffsetDateTime::now_utc);
|
||||
@@ -1243,6 +1400,7 @@ impl crate::storage_api_contracts::multipart::MultipartOperations for SetDisks {
|
||||
|
||||
let upload_path = Self::get_upload_id_dir(bucket, object, upload_uuid.as_str());
|
||||
|
||||
ensure_multipart_bucket_lifecycle_lock_held(bucket, object, opts)?;
|
||||
Self::write_unique_file_info(
|
||||
&shuffle_disks,
|
||||
bucket,
|
||||
@@ -1278,6 +1436,9 @@ impl crate::storage_api_contracts::multipart::MultipartOperations for SetDisks {
|
||||
.check_upload_id_exists(bucket, object, upload_id, false)
|
||||
.await
|
||||
.map_err(|e| to_object_err(e, vec![bucket, object, upload_id]))?;
|
||||
ensure_multipart_bucket_incarnation(&self.ctx, &fi, bucket, object, upload_id, opts.expected_bucket_incarnation_id)
|
||||
.await?;
|
||||
ensure_multipart_bucket_lifecycle_lock_held(bucket, object, opts)?;
|
||||
|
||||
Ok(MultipartInfo {
|
||||
bucket: bucket.to_owned(),
|
||||
@@ -1297,6 +1458,9 @@ impl crate::storage_api_contracts::multipart::MultipartOperations for SetDisks {
|
||||
.acquire_multipart_upload_write_lock("abort_multipart_upload", bucket, object, upload_id, opts)
|
||||
.await?;
|
||||
let (fi, _) = self.check_upload_id_exists(bucket, object, upload_id, true).await?;
|
||||
ensure_multipart_bucket_incarnation(&self.ctx, &fi, bucket, object, upload_id, opts.expected_bucket_incarnation_id)
|
||||
.await?;
|
||||
ensure_multipart_bucket_lifecycle_lock_held(bucket, object, opts)?;
|
||||
let upload_id_path = Self::get_upload_id_dir(bucket, object, upload_id);
|
||||
|
||||
self.delete_all_with_quorum(
|
||||
@@ -1348,6 +1512,8 @@ impl crate::storage_api_contracts::multipart::MultipartOperations for SetDisks {
|
||||
|
||||
let expected_restore_operation_id = restore_commit_operation_id_from_metadata(&opts.user_defined)?;
|
||||
let (mut fi, files_metas) = self.check_upload_id_exists(bucket, object, upload_id, true).await?;
|
||||
ensure_multipart_bucket_incarnation(&self.ctx, &fi, bucket, object, upload_id, opts.expected_bucket_incarnation_id)
|
||||
.await?;
|
||||
let has_layout_candidate = range_seek_rollout_enabled
|
||||
&& fi
|
||||
.data_dir
|
||||
@@ -1786,6 +1952,7 @@ impl crate::storage_api_contracts::multipart::MultipartOperations for SetDisks {
|
||||
achieved: 0,
|
||||
});
|
||||
}
|
||||
ensure_multipart_bucket_lifecycle_lock_held(bucket, object, opts)?;
|
||||
|
||||
self.require_current_restore_operation_id(
|
||||
bucket,
|
||||
@@ -1979,6 +2146,107 @@ mod tests {
|
||||
use tempfile::TempDir;
|
||||
use tokio::sync::{Notify, RwLock};
|
||||
|
||||
#[test]
|
||||
fn multipart_bucket_incarnation_metadata_is_consistent_and_non_nil() {
|
||||
let incarnation = Uuid::new_v4();
|
||||
let mut metadata = HashMap::new();
|
||||
insert_str(&mut metadata, SUFFIX_BUCKET_INCARNATION_ID, incarnation.to_string());
|
||||
assert_eq!(multipart_bucket_incarnation_id(&metadata).unwrap(), Some(incarnation));
|
||||
|
||||
metadata.insert("x-minio-internal-bucket-incarnation-id".to_string(), Uuid::new_v4().to_string());
|
||||
assert!(multipart_bucket_incarnation_id(&metadata).is_err());
|
||||
|
||||
let mut nil_metadata = HashMap::new();
|
||||
insert_str(&mut nil_metadata, SUFFIX_BUCKET_INCARNATION_ID, Uuid::nil().to_string());
|
||||
assert!(multipart_bucket_incarnation_id(&nil_metadata).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multipart_bucket_incarnation_gate_accepts_only_current_or_same_lifetime_legacy_uploads() {
|
||||
let expected = Uuid::new_v4();
|
||||
let stale = Uuid::new_v4();
|
||||
let bucket_created = OffsetDateTime::now_utc();
|
||||
let upload_initiated = bucket_created + time::Duration::seconds(1);
|
||||
let mut current_metadata = HashMap::new();
|
||||
insert_str(&mut current_metadata, SUFFIX_BUCKET_INCARNATION_ID, expected.to_string());
|
||||
assert!(multipart_bucket_incarnation_matches(¤t_metadata, expected));
|
||||
validate_multipart_bucket_incarnation(¤t_metadata, "bucket", "object", "upload", Some(expected), None, None)
|
||||
.expect("a matching stamped upload should pass");
|
||||
|
||||
let missing_metadata = HashMap::new();
|
||||
assert!(!multipart_bucket_incarnation_matches(&missing_metadata, expected));
|
||||
validate_multipart_bucket_incarnation(
|
||||
&missing_metadata,
|
||||
"bucket",
|
||||
"object",
|
||||
"upload",
|
||||
Some(expected),
|
||||
Some(upload_initiated),
|
||||
Some(bucket_created),
|
||||
)
|
||||
.expect("a legacy upload initiated during the current bucket lifetime should pass");
|
||||
assert!(matches!(
|
||||
validate_multipart_bucket_incarnation(
|
||||
&missing_metadata,
|
||||
"bucket",
|
||||
"object",
|
||||
"upload",
|
||||
Some(expected),
|
||||
Some(bucket_created - time::Duration::seconds(1)),
|
||||
Some(bucket_created),
|
||||
),
|
||||
Err(StorageError::InvalidUploadID(..))
|
||||
));
|
||||
assert!(matches!(
|
||||
validate_multipart_bucket_incarnation(
|
||||
&missing_metadata,
|
||||
"bucket",
|
||||
"object",
|
||||
"upload",
|
||||
Some(expected),
|
||||
None,
|
||||
Some(bucket_created),
|
||||
),
|
||||
Err(StorageError::InvalidUploadID(..))
|
||||
));
|
||||
|
||||
let mut stale_metadata = HashMap::new();
|
||||
insert_str(&mut stale_metadata, SUFFIX_BUCKET_INCARNATION_ID, stale.to_string());
|
||||
assert!(!multipart_bucket_incarnation_matches(&stale_metadata, expected));
|
||||
assert!(matches!(
|
||||
validate_multipart_bucket_incarnation(
|
||||
&stale_metadata,
|
||||
"bucket",
|
||||
"object",
|
||||
"upload",
|
||||
Some(expected),
|
||||
Some(upload_initiated),
|
||||
Some(bucket_created),
|
||||
),
|
||||
Err(StorageError::InvalidUploadID(..))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multipart_commit_rejects_missing_or_lost_bucket_lifecycle_fence() {
|
||||
let expected = Uuid::new_v4();
|
||||
let missing = ObjectOptions {
|
||||
expected_bucket_incarnation_id: Some(expected),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(ensure_multipart_bucket_lifecycle_lock_held("bucket", "object", &missing).is_err());
|
||||
|
||||
let lost = ObjectOptions {
|
||||
expected_bucket_incarnation_id: Some(expected),
|
||||
bucket_lifecycle_lock_fence: Some(NamespaceLockFence::lost_for_test()),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(matches!(
|
||||
ensure_multipart_bucket_lifecycle_lock_held("bucket", "object", &lost),
|
||||
Err(StorageError::NamespaceLockQuorumUnavailable { .. })
|
||||
));
|
||||
}
|
||||
|
||||
struct SetupTypeGuard {
|
||||
previous: SetupType,
|
||||
}
|
||||
@@ -3721,7 +3989,7 @@ mod tests {
|
||||
// A single page must never return more than max_uploads entries.
|
||||
let max_uploads = 2usize;
|
||||
let page = set_disks
|
||||
.list_multipart_uploads(bucket, object, None, None, None, max_uploads)
|
||||
.list_multipart_uploads_for_incarnation(bucket, object, None, None, None, max_uploads, None)
|
||||
.await
|
||||
.expect("list should succeed");
|
||||
assert_eq!(
|
||||
@@ -3738,7 +4006,7 @@ mod tests {
|
||||
|
||||
// Exact boundary: max_uploads == total must not falsely report truncation.
|
||||
let exact = set_disks
|
||||
.list_multipart_uploads(bucket, object, None, None, None, total)
|
||||
.list_multipart_uploads_for_incarnation(bucket, object, None, None, None, total, None)
|
||||
.await
|
||||
.expect("list should succeed");
|
||||
assert_eq!(exact.uploads.len(), total, "exact boundary must return every upload");
|
||||
@@ -3756,7 +4024,15 @@ mod tests {
|
||||
let mut pages = 0usize;
|
||||
loop {
|
||||
let page = set_disks
|
||||
.list_multipart_uploads(bucket, object, key_marker.clone(), upload_id_marker.clone(), None, 1)
|
||||
.list_multipart_uploads_for_incarnation(
|
||||
bucket,
|
||||
object,
|
||||
key_marker.clone(),
|
||||
upload_id_marker.clone(),
|
||||
None,
|
||||
1,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("list should succeed");
|
||||
assert!(page.uploads.len() <= 1, "max_uploads=1 must never return more than one upload");
|
||||
@@ -3802,7 +4078,7 @@ mod tests {
|
||||
expected.sort();
|
||||
|
||||
let all = set_disks
|
||||
.list_multipart_uploads(bucket, "", None, None, None, 1000)
|
||||
.list_multipart_uploads_for_incarnation(bucket, "", None, None, None, 1000, None)
|
||||
.await
|
||||
.expect("bucket-wide multipart listing should succeed");
|
||||
let listed = all
|
||||
@@ -3814,14 +4090,14 @@ mod tests {
|
||||
assert!(!all.is_truncated);
|
||||
|
||||
let logs = set_disks
|
||||
.list_multipart_uploads(bucket, "logs/", None, None, None, 1000)
|
||||
.list_multipart_uploads_for_incarnation(bucket, "logs/", None, None, None, 1000, None)
|
||||
.await
|
||||
.expect("prefix multipart listing should succeed");
|
||||
assert_eq!(logs.uploads.len(), 3);
|
||||
assert!(logs.uploads.iter().all(|upload| upload.object.starts_with("logs/")));
|
||||
|
||||
let exact = set_disks
|
||||
.list_multipart_uploads(bucket, "logs/a.bin", None, None, None, 1000)
|
||||
.list_multipart_uploads_for_incarnation(bucket, "logs/a.bin", None, None, None, 1000, None)
|
||||
.await
|
||||
.expect("exact-key multipart listing should remain supported");
|
||||
assert_eq!(exact.uploads.len(), 2);
|
||||
@@ -3851,7 +4127,15 @@ mod tests {
|
||||
let mut listed = Vec::new();
|
||||
for _ in 0..expected.len() {
|
||||
let page = set_disks
|
||||
.list_multipart_uploads(bucket, "logs/", key_marker.clone(), upload_id_marker.clone(), None, 1)
|
||||
.list_multipart_uploads_for_incarnation(
|
||||
bucket,
|
||||
"logs/",
|
||||
key_marker.clone(),
|
||||
upload_id_marker.clone(),
|
||||
None,
|
||||
1,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("multipart page should succeed");
|
||||
assert_eq!(page.uploads.len(), 1);
|
||||
@@ -3868,7 +4152,7 @@ mod tests {
|
||||
assert_eq!(listed, expected);
|
||||
|
||||
let key_only = set_disks
|
||||
.list_multipart_uploads(bucket, "logs/", Some("logs/a.bin".to_string()), None, None, 1000)
|
||||
.list_multipart_uploads_for_incarnation(bucket, "logs/", Some("logs/a.bin".to_string()), None, None, 1000, None)
|
||||
.await
|
||||
.expect("key-only marker should succeed");
|
||||
assert_eq!(
|
||||
@@ -3881,7 +4165,7 @@ mod tests {
|
||||
);
|
||||
|
||||
let upload_only = set_disks
|
||||
.list_multipart_uploads(bucket, "logs/", None, Some(expected[0].1.clone()), None, 1000)
|
||||
.list_multipart_uploads_for_incarnation(bucket, "logs/", None, Some(expected[0].1.clone()), None, 1000, None)
|
||||
.await
|
||||
.expect("an upload marker without a key marker should be ignored");
|
||||
assert_eq!(upload_only.uploads.len(), expected.len());
|
||||
@@ -3909,7 +4193,7 @@ mod tests {
|
||||
}
|
||||
|
||||
let first = set_disks
|
||||
.list_multipart_uploads(bucket, "logs/", None, None, Some("/".to_string()), 2)
|
||||
.list_multipart_uploads_for_incarnation(bucket, "logs/", None, None, Some("/".to_string()), 2, None)
|
||||
.await
|
||||
.expect("delimiter multipart listing should succeed");
|
||||
assert_eq!(first.uploads.len(), 1);
|
||||
@@ -3920,13 +4204,14 @@ mod tests {
|
||||
assert!(first.next_upload_id_marker.is_none());
|
||||
|
||||
let second = set_disks
|
||||
.list_multipart_uploads(
|
||||
.list_multipart_uploads_for_incarnation(
|
||||
bucket,
|
||||
"logs/",
|
||||
first.next_key_marker,
|
||||
first.next_upload_id_marker,
|
||||
Some("/".to_string()),
|
||||
2,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("delimiter continuation should succeed");
|
||||
@@ -3936,7 +4221,7 @@ mod tests {
|
||||
assert!(!second.is_truncated);
|
||||
|
||||
let exact_boundary = set_disks
|
||||
.list_multipart_uploads(bucket, "logs/", None, None, Some("/".to_string()), 4)
|
||||
.list_multipart_uploads_for_incarnation(bucket, "logs/", None, None, Some("/".to_string()), 4, None)
|
||||
.await
|
||||
.expect("delimiter exact boundary should succeed");
|
||||
assert_eq!(exact_boundary.uploads.len(), 2);
|
||||
@@ -3944,6 +4229,48 @@ mod tests {
|
||||
assert!(!exact_boundary.is_truncated);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_multipart_uploads_hides_uploads_from_another_incarnation() {
|
||||
let (_temp_dirs, disk_stores, set_disks) = hermetic_set_disks(4).await;
|
||||
let bucket = "multipart-incarnation-list-bucket";
|
||||
let object = "object";
|
||||
for disk in &disk_stores {
|
||||
disk.make_volume(bucket).await.expect("bucket volume should be created");
|
||||
}
|
||||
|
||||
let incarnation = Uuid::new_v4();
|
||||
let (fence, _loss_handle) = NamespaceLockFence::loss_handle_for_test();
|
||||
let current = set_disks
|
||||
.new_multipart_upload(
|
||||
bucket,
|
||||
object,
|
||||
&ObjectOptions {
|
||||
expected_bucket_incarnation_id: Some(incarnation),
|
||||
bucket_lifecycle_lock_fence: Some(fence),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("current multipart upload should be created");
|
||||
set_disks
|
||||
.new_multipart_upload(bucket, object, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("legacy multipart upload should be created");
|
||||
|
||||
let unscoped = set_disks
|
||||
.list_multipart_uploads_for_incarnation(bucket, object, None, None, None, 1000, None)
|
||||
.await
|
||||
.expect("unscoped multipart listing should succeed");
|
||||
assert_eq!(unscoped.uploads.len(), 2);
|
||||
|
||||
let scoped = set_disks
|
||||
.list_multipart_uploads_for_incarnation(bucket, object, None, None, None, 1000, Some(incarnation))
|
||||
.await
|
||||
.expect("incarnation-scoped multipart listing should succeed");
|
||||
assert_eq!(scoped.uploads.len(), 1);
|
||||
assert_eq!(scoped.uploads[0].upload_id, current.upload_id);
|
||||
}
|
||||
|
||||
/// Recursively collect every file named `file_name` under the multipart
|
||||
/// staging bucket on a single disk. Used to observe whether a failed commit
|
||||
/// left the per-part metadata intact for a retry.
|
||||
@@ -4425,7 +4752,7 @@ mod tests {
|
||||
|
||||
async fn upload_is_listed(set_disks: &Arc<SetDisks>, bucket: &str, object: &str, upload_id: &str) -> bool {
|
||||
let page = set_disks
|
||||
.list_multipart_uploads(bucket, object, None, None, None, 1000)
|
||||
.list_multipart_uploads_for_incarnation(bucket, object, None, None, None, 1000, None)
|
||||
.await
|
||||
.expect("listing multipart uploads should succeed");
|
||||
page.uploads.iter().any(|u| u.upload_id == upload_id)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -37,7 +37,10 @@ impl RestoreCleanupIdentity {
|
||||
}
|
||||
|
||||
fn matches_file_info(&self, fi: &FileInfo, expected_etag: &str) -> bool {
|
||||
self.version_id == fi.version_id
|
||||
// Normalize the nil version on both sides: a versioning-suspended object
|
||||
// is `Some(Uuid::nil())` on one and `None` on the other, so a raw compare
|
||||
// reports every suspended restore as "changed before finalization".
|
||||
self.version_id.filter(|version_id| !version_id.is_nil()) == fi.version_id.filter(|version_id| !version_id.is_nil())
|
||||
&& self.data_dir == fi.data_dir
|
||||
&& self.mod_time == fi.mod_time
|
||||
&& self.size == fi.size
|
||||
|
||||
@@ -9,7 +9,8 @@ pub(crate) mod admin {
|
||||
|
||||
pub(crate) mod bucket {
|
||||
pub(crate) use rustfs_storage_api::{
|
||||
BucketInfo, BucketOperations, BucketOptions, DeleteBucketOptions, MakeBucketOptions, SRBucketDeleteOp,
|
||||
BUCKET_LIFECYCLE_LOCK_OBJECT, BucketInfo, BucketOperations, BucketOptions, DeleteBucketOptions, MakeBucketOptions,
|
||||
SRBucketDeleteOp,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ use crate::bucket::{
|
||||
use crate::error::is_err_bucket_not_found;
|
||||
use crate::runtime::sources as runtime_sources;
|
||||
use crate::set_disk::get_lock_acquire_timeout;
|
||||
use crate::storage_api_contracts::bucket::SRBucketDeleteOp;
|
||||
use crate::storage_api_contracts::bucket::{BUCKET_LIFECYCLE_LOCK_OBJECT, SRBucketDeleteOp};
|
||||
use crate::storage_api_contracts::namespace::NamespaceLocking as _;
|
||||
use futures::stream::{self, StreamExt};
|
||||
use std::collections::BTreeMap;
|
||||
@@ -113,6 +113,25 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
async fn await_bucket_lifecycle_operation<T, F>(
|
||||
lifecycle_guard: Option<&rustfs_lock::NamespaceLockGuard>,
|
||||
namespace_guard: Option<&rustfs_lock::NamespaceLockGuard>,
|
||||
bucket: &str,
|
||||
operation: &'static str,
|
||||
future: F,
|
||||
) -> Result<T>
|
||||
where
|
||||
F: Future<Output = Result<T>>,
|
||||
{
|
||||
await_bucket_namespace_operation(
|
||||
lifecycle_guard,
|
||||
bucket,
|
||||
operation,
|
||||
await_bucket_namespace_operation(namespace_guard, bucket, operation, future),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn run_bucket_usage_cleanup<F>(guard: Option<&rustfs_lock::NamespaceLockGuard>, bucket: &str, future: F) -> Result<()>
|
||||
where
|
||||
F: Future<Output = Result<()>>,
|
||||
@@ -131,6 +150,50 @@ where
|
||||
}
|
||||
|
||||
impl ECStore {
|
||||
pub async fn bucket_incarnation_id(&self, bucket: &str) -> Result<Uuid> {
|
||||
metadata_sys::get_cached_bucket_incarnation_id_in(&self.ctx, bucket).await
|
||||
}
|
||||
|
||||
pub async fn bucket_incarnation_id_from_disk(&self, bucket: &str) -> Result<Uuid> {
|
||||
metadata_sys::get_bucket_incarnation_id_in(&self.ctx, bucket).await
|
||||
}
|
||||
|
||||
/// The object commit path acquires this sentinel before bucket metadata and
|
||||
/// exact-object namespace locks.
|
||||
pub(crate) async fn acquire_bucket_lifecycle_read_lock(&self, bucket: &str) -> Result<rustfs_lock::NamespaceLockGuard> {
|
||||
let lock = self.new_ns_lock(bucket, BUCKET_LIFECYCLE_LOCK_OBJECT).await?;
|
||||
lock.get_read_lock(get_lock_acquire_timeout()).await.map_err(|err| match err {
|
||||
rustfs_lock::error::LockError::QuorumNotReached { required, achieved } => {
|
||||
StorageError::NamespaceLockQuorumUnavailable {
|
||||
mode: "bucket_lifecycle_read",
|
||||
bucket: bucket.to_string(),
|
||||
object: BUCKET_LIFECYCLE_LOCK_OBJECT.to_string(),
|
||||
required,
|
||||
achieved,
|
||||
}
|
||||
}
|
||||
other => StorageError::Lock(other),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn acquire_bucket_lifecycle_write_lock(&self, bucket: &str) -> Result<rustfs_lock::NamespaceLockGuard> {
|
||||
let lock = self.new_ns_lock(bucket, BUCKET_LIFECYCLE_LOCK_OBJECT).await?;
|
||||
lock.get_write_lock(get_lock_acquire_timeout())
|
||||
.await
|
||||
.map_err(|err| match err {
|
||||
rustfs_lock::error::LockError::QuorumNotReached { required, achieved } => {
|
||||
StorageError::NamespaceLockQuorumUnavailable {
|
||||
mode: "bucket_lifecycle_write",
|
||||
bucket: bucket.to_string(),
|
||||
object: BUCKET_LIFECYCLE_LOCK_OBJECT.to_string(),
|
||||
required,
|
||||
achieved,
|
||||
}
|
||||
}
|
||||
other => StorageError::Lock(other),
|
||||
})
|
||||
}
|
||||
|
||||
async fn mark_bucket_deleted(&self, bucket: &str) -> Result<()> {
|
||||
let marker_volume = bucket_deleted_marker_volume(bucket);
|
||||
|
||||
@@ -204,25 +267,39 @@ impl ECStore {
|
||||
}
|
||||
}
|
||||
|
||||
async fn rollback_failed_bucket_creation(&self, bucket: &str, guard: Option<&rustfs_lock::NamespaceLockGuard>) {
|
||||
async fn rollback_failed_bucket_creation(
|
||||
&self,
|
||||
bucket: &str,
|
||||
lifecycle_guard: Option<&rustfs_lock::NamespaceLockGuard>,
|
||||
namespace_guard: Option<&rustfs_lock::NamespaceLockGuard>,
|
||||
) {
|
||||
let rollback_opts = DeleteBucketOptions {
|
||||
no_lock: true,
|
||||
no_recreate: true,
|
||||
..Default::default()
|
||||
};
|
||||
if let Err(err) = await_bucket_namespace_operation(guard, bucket, "failed bucket creation rollback", async {
|
||||
self.peer_sys
|
||||
.delete_bucket(bucket, &rollback_opts)
|
||||
.await
|
||||
.map_err(|rollback_err| to_object_err(rollback_err.into(), vec![bucket]))
|
||||
})
|
||||
.await
|
||||
if let Err(err) =
|
||||
await_bucket_lifecycle_operation(lifecycle_guard, namespace_guard, bucket, "failed bucket creation rollback", async {
|
||||
self.peer_sys
|
||||
.delete_bucket(bucket, &rollback_opts)
|
||||
.await
|
||||
.map_err(|rollback_err| to_object_err(rollback_err.into(), vec![bucket]))
|
||||
})
|
||||
.await
|
||||
{
|
||||
warn!(
|
||||
bucket = %bucket,
|
||||
error = ?err,
|
||||
"failed bucket creation rollback did not remove every physical bucket volume"
|
||||
);
|
||||
return;
|
||||
}
|
||||
if let Err(err) = self.cleanup_deleted_bucket_metadata(bucket, false, namespace_guard).await {
|
||||
warn!(
|
||||
bucket = %bucket,
|
||||
error = ?err,
|
||||
"failed bucket creation rollback left internal bucket metadata"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,40 +311,112 @@ impl ECStore {
|
||||
return Err(StorageError::BucketNameInvalid(err.to_string()));
|
||||
}
|
||||
|
||||
// Lock order: bucket lifecycle -> metadata transaction -> exact bucket namespace.
|
||||
let bucket_lifecycle_guard = if !opts.no_lock {
|
||||
Some(self.acquire_bucket_lifecycle_write_lock(bucket).await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let metadata_transaction_guard = if !opts.no_lock && !is_meta_bucketname(bucket) {
|
||||
Some(metadata_sys::acquire_bucket_metadata_transaction_lock_in(&self.ctx, bucket).await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let ns_guard = if !opts.no_lock {
|
||||
let ns_lock = self.new_ns_lock(bucket, bucket).await?;
|
||||
Some(
|
||||
ns_lock
|
||||
.get_write_lock(get_lock_acquire_timeout())
|
||||
.await
|
||||
.map_err(|e| match e {
|
||||
rustfs_lock::error::LockError::QuorumNotReached { required, achieved } => {
|
||||
StorageError::NamespaceLockQuorumUnavailable {
|
||||
mode: "write",
|
||||
bucket: bucket.to_string(),
|
||||
object: bucket.to_string(),
|
||||
required,
|
||||
achieved,
|
||||
await_bucket_namespace_operation(
|
||||
bucket_lifecycle_guard.as_ref(),
|
||||
bucket,
|
||||
"bucket namespace lock acquisition",
|
||||
async {
|
||||
ns_lock.get_write_lock(get_lock_acquire_timeout()).await.map_err(|e| match e {
|
||||
rustfs_lock::error::LockError::QuorumNotReached { required, achieved } => {
|
||||
StorageError::NamespaceLockQuorumUnavailable {
|
||||
mode: "write",
|
||||
bucket: bucket.to_string(),
|
||||
object: bucket.to_string(),
|
||||
required,
|
||||
achieved,
|
||||
}
|
||||
}
|
||||
}
|
||||
other => StorageError::Lock(other),
|
||||
})?,
|
||||
other => StorageError::Lock(other),
|
||||
})
|
||||
},
|
||||
)
|
||||
.await?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let confirmed_missing = match self.peer_sys.get_bucket_info(bucket, &BucketOptions::default()).await {
|
||||
Ok(_) => false,
|
||||
let existing_bucket_info = match self.peer_sys.get_bucket_info(bucket, &BucketOptions::default()).await {
|
||||
Ok(info) => Some(info),
|
||||
Err(err) => {
|
||||
let err: StorageError = err.into();
|
||||
if is_err_bucket_not_found(&err) {
|
||||
true
|
||||
None
|
||||
} else {
|
||||
return Err(to_object_err(err, vec![bucket]));
|
||||
}
|
||||
}
|
||||
};
|
||||
let confirmed_missing = existing_bucket_info.is_none();
|
||||
let existing_metadata = if opts.force_create && !confirmed_missing && !is_meta_bucketname(bucket) {
|
||||
let (mut metadata, persisted) = metadata_sys::get_config_from_disk_with_presence_in(&self.ctx, bucket).await?;
|
||||
if !persisted {
|
||||
metadata = BucketMetadata::new(bucket);
|
||||
metadata.created = existing_bucket_info
|
||||
.as_ref()
|
||||
.and_then(|info| info.created)
|
||||
.unwrap_or(OffsetDateTime::UNIX_EPOCH);
|
||||
} else if !metadata.bucket_incarnation_sidecar && !metadata.bucket_incarnation_id.is_nil() {
|
||||
return Err(Error::other(format!(
|
||||
"bucket incarnation sidecar is missing for new-format metadata: {bucket}"
|
||||
)));
|
||||
} else if !metadata.bucket_incarnation_sidecar {
|
||||
metadata.bucket_incarnation_id = Uuid::new_v4();
|
||||
}
|
||||
Some(metadata)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let mut meta = existing_metadata.unwrap_or_else(|| BucketMetadata::new(bucket));
|
||||
let existing_incarnation_is_authoritative = meta.bucket_incarnation_sidecar;
|
||||
if confirmed_missing || is_meta_bucketname(bucket) {
|
||||
meta.set_created(opts.created_at);
|
||||
|
||||
if opts.versioning_enabled {
|
||||
meta.versioning_config_xml =
|
||||
crate::bucket::utils::serialize::<VersioningConfiguration>(&ENABLED_VERSIONING_CONFIG)?;
|
||||
}
|
||||
}
|
||||
|
||||
// Object Lock enable is one-way, and it must apply to an existing bucket
|
||||
// too. Site replication replays make-with-versioning carrying the
|
||||
// source's lockEnabled against a destination bucket that already exists;
|
||||
// gating this on `confirmed_missing` returned success while leaving the
|
||||
// replica unlocked, so replicated versions could be deleted without the
|
||||
// retention the source enforces.
|
||||
let lock_newly_enabled = opts.lock_enabled && !meta.lock_enabled;
|
||||
if opts.lock_enabled {
|
||||
meta.lock_enabled = true;
|
||||
meta.object_lock_config_xml =
|
||||
crate::bucket::utils::serialize::<ObjectLockConfiguration>(&ENABLED_OBJECT_LOCK_CONFIG)?;
|
||||
meta.versioning_config_xml = crate::bucket::utils::serialize::<VersioningConfiguration>(&ENABLED_VERSIONING_CONFIG)?;
|
||||
}
|
||||
|
||||
let metadata_persisted_before_physical = confirmed_missing && !is_meta_bucketname(bucket) && opts.lock_enabled;
|
||||
if metadata_persisted_before_physical {
|
||||
metadata_sys::set_new_bucket_metadata_in(&self.ctx, meta.clone()).await?;
|
||||
if bucket_lifecycle_guard.as_ref().is_some_and(|guard| guard.is_lock_lost())
|
||||
|| metadata_transaction_guard.as_ref().is_some_and(|guard| guard.is_lock_lost())
|
||||
|| ns_guard.as_ref().is_some_and(|guard| guard.is_lock_lost())
|
||||
{
|
||||
return Err(Error::other(format!("bucket metadata creation intent lock was lost: {bucket}")));
|
||||
}
|
||||
}
|
||||
|
||||
if confirmed_missing && !is_meta_bucketname(bucket) {
|
||||
// Fence every scanner cycle that could have observed the namespace
|
||||
@@ -277,12 +426,23 @@ impl ECStore {
|
||||
self.cleanup_bucket_usage(bucket, ns_guard.as_ref()).await?;
|
||||
}
|
||||
|
||||
if let Err(err) = await_bucket_namespace_operation(ns_guard.as_ref(), bucket, "physical bucket creation", async {
|
||||
self.peer_sys
|
||||
.make_bucket(bucket, opts)
|
||||
.await
|
||||
.map_err(|err| to_object_err(err.into(), vec![bucket]))
|
||||
})
|
||||
if let Err(err) = await_bucket_lifecycle_operation(
|
||||
bucket_lifecycle_guard.as_ref(),
|
||||
ns_guard.as_ref(),
|
||||
bucket,
|
||||
"physical bucket creation",
|
||||
await_bucket_namespace_operation(
|
||||
metadata_transaction_guard.as_ref(),
|
||||
bucket,
|
||||
"bucket creation metadata transaction",
|
||||
async {
|
||||
self.peer_sys
|
||||
.make_bucket(bucket, opts)
|
||||
.await
|
||||
.map_err(|err| to_object_err(err.into(), vec![bucket]))
|
||||
},
|
||||
),
|
||||
)
|
||||
.await
|
||||
{
|
||||
if is_err_bucket_exists(&err)
|
||||
@@ -300,32 +460,38 @@ impl ECStore {
|
||||
}
|
||||
if !is_err_bucket_exists(&err) && ns_guard.as_ref().is_none_or(|guard| !guard.is_lock_lost()) {
|
||||
error!("make bucket failed: {err}");
|
||||
self.rollback_failed_bucket_creation(bucket, ns_guard.as_ref()).await;
|
||||
self.rollback_failed_bucket_creation(bucket, bucket_lifecycle_guard.as_ref(), ns_guard.as_ref())
|
||||
.await;
|
||||
}
|
||||
return Err(err);
|
||||
};
|
||||
|
||||
let mut meta = BucketMetadata::new(bucket);
|
||||
|
||||
meta.set_created(opts.created_at);
|
||||
|
||||
if opts.lock_enabled {
|
||||
meta.object_lock_config_xml =
|
||||
crate::bucket::utils::serialize::<ObjectLockConfiguration>(&ENABLED_OBJECT_LOCK_CONFIG)?;
|
||||
meta.versioning_config_xml = crate::bucket::utils::serialize::<VersioningConfiguration>(&ENABLED_VERSIONING_CONFIG)?;
|
||||
let metadata_result = async {
|
||||
if metadata_persisted_before_physical {
|
||||
return Ok(());
|
||||
}
|
||||
if is_meta_bucketname(bucket) {
|
||||
metadata_sys::set_bucket_metadata_in(&self.ctx, meta).await
|
||||
} else if existing_incarnation_is_authoritative && !lock_newly_enabled {
|
||||
metadata_sys::cache_bucket_metadata_in(&self.ctx, meta).await
|
||||
} else {
|
||||
metadata_sys::set_new_bucket_metadata_in(&self.ctx, meta).await
|
||||
}
|
||||
}
|
||||
|
||||
if opts.versioning_enabled {
|
||||
meta.versioning_config_xml = crate::bucket::utils::serialize::<VersioningConfiguration>(&ENABLED_VERSIONING_CONFIG)?;
|
||||
.await;
|
||||
let metadata_lock_lost = bucket_lifecycle_guard.as_ref().is_some_and(|guard| guard.is_lock_lost())
|
||||
|| metadata_transaction_guard.as_ref().is_some_and(|guard| guard.is_lock_lost())
|
||||
|| ns_guard.as_ref().is_some_and(|guard| guard.is_lock_lost());
|
||||
if let Err(err) = metadata_result {
|
||||
if !metadata_lock_lost {
|
||||
self.rollback_failed_bucket_creation(bucket, bucket_lifecycle_guard.as_ref(), ns_guard.as_ref())
|
||||
.await;
|
||||
}
|
||||
return Err(err);
|
||||
}
|
||||
if metadata_lock_lost {
|
||||
return Err(Error::other(format!("bucket metadata initialization lock was lost: {bucket}")));
|
||||
}
|
||||
|
||||
await_bucket_namespace_operation(
|
||||
ns_guard.as_ref(),
|
||||
bucket,
|
||||
"bucket metadata initialization",
|
||||
metadata_sys::set_bucket_metadata_in(&self.ctx, meta),
|
||||
)
|
||||
.await?;
|
||||
|
||||
if confirmed_missing && !is_meta_bucketname(bucket) {
|
||||
// A scanner may have sampled the first fence before the bucket
|
||||
@@ -454,24 +620,34 @@ impl ECStore {
|
||||
return Err(StorageError::BucketNameInvalid(err.to_string()));
|
||||
}
|
||||
|
||||
let bucket_lifecycle_guard = if !opts.no_lock {
|
||||
Some(self.acquire_bucket_lifecycle_write_lock(bucket).await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let ns_guard = if !opts.no_lock {
|
||||
let ns_lock = self.new_ns_lock(bucket, bucket).await?;
|
||||
Some(
|
||||
ns_lock
|
||||
.get_write_lock(get_lock_acquire_timeout())
|
||||
.await
|
||||
.map_err(|e| match e {
|
||||
rustfs_lock::error::LockError::QuorumNotReached { required, achieved } => {
|
||||
StorageError::NamespaceLockQuorumUnavailable {
|
||||
mode: "write",
|
||||
bucket: bucket.to_string(),
|
||||
object: bucket.to_string(),
|
||||
required,
|
||||
achieved,
|
||||
await_bucket_namespace_operation(
|
||||
bucket_lifecycle_guard.as_ref(),
|
||||
bucket,
|
||||
"bucket namespace lock acquisition",
|
||||
async {
|
||||
ns_lock.get_write_lock(get_lock_acquire_timeout()).await.map_err(|e| match e {
|
||||
rustfs_lock::error::LockError::QuorumNotReached { required, achieved } => {
|
||||
StorageError::NamespaceLockQuorumUnavailable {
|
||||
mode: "write",
|
||||
bucket: bucket.to_string(),
|
||||
object: bucket.to_string(),
|
||||
required,
|
||||
achieved,
|
||||
}
|
||||
}
|
||||
}
|
||||
other => StorageError::Lock(other),
|
||||
})?,
|
||||
other => StorageError::Lock(other),
|
||||
})
|
||||
},
|
||||
)
|
||||
.await?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
@@ -519,7 +695,8 @@ impl ECStore {
|
||||
}
|
||||
|
||||
if sr_mark_delete {
|
||||
await_bucket_namespace_operation(
|
||||
await_bucket_lifecycle_operation(
|
||||
bucket_lifecycle_guard.as_ref(),
|
||||
ns_guard.as_ref(),
|
||||
bucket,
|
||||
"bucket delete marker creation",
|
||||
@@ -528,12 +705,17 @@ impl ECStore {
|
||||
.await?;
|
||||
}
|
||||
|
||||
let delete_result = run_physical_bucket_deletion(ns_guard.as_ref(), bucket, async {
|
||||
self.peer_sys
|
||||
.delete_bucket(bucket, &delete_opts)
|
||||
.await
|
||||
.map_err(|err| to_object_err(err.into(), vec![bucket]))
|
||||
})
|
||||
let delete_result = await_bucket_namespace_operation(
|
||||
bucket_lifecycle_guard.as_ref(),
|
||||
bucket,
|
||||
"physical bucket deletion",
|
||||
run_physical_bucket_deletion(ns_guard.as_ref(), bucket, async {
|
||||
self.peer_sys
|
||||
.delete_bucket(bucket, &delete_opts)
|
||||
.await
|
||||
.map_err(|err| to_object_err(err.into(), vec![bucket]))
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
if let Err(err) = delete_result
|
||||
&& (!sr_delete || !is_err_strict_volume_not_found(&err))
|
||||
@@ -591,7 +773,6 @@ mod tests {
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::{Duration, SystemTime};
|
||||
use time::OffsetDateTime;
|
||||
use tokio::io::AsyncReadExt;
|
||||
use tokio::sync::{Notify, OnceCell};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use uuid::Uuid;
|
||||
@@ -1229,6 +1410,86 @@ mod tests {
|
||||
assert!(!any_disk_path_exists(&disk_paths, bucket_deleted_marker_volume(&bucket)).await);
|
||||
}
|
||||
|
||||
/// Site replication replays make-with-versioning carrying the source's
|
||||
/// `lockEnabled` against a destination bucket that already exists. Gating the
|
||||
/// lock enable on `confirmed_missing` returned success while leaving the
|
||||
/// replica unlocked, so replicated versions could be deleted without the
|
||||
/// retention the source enforces.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn force_create_enables_object_lock_on_an_existing_bucket() {
|
||||
let (_disk_paths, ecstore) = setup_bucket_delete_test_env().await;
|
||||
let bucket = format!("bucket-force-lock-{}", Uuid::new_v4().simple());
|
||||
|
||||
ecstore
|
||||
.make_bucket(&bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("plain bucket should be created");
|
||||
assert!(
|
||||
!metadata_sys::get_in(&ecstore.ctx, &bucket)
|
||||
.await
|
||||
.expect("metadata should load")
|
||||
.lock_enabled,
|
||||
"test setup: the bucket must start unlocked"
|
||||
);
|
||||
|
||||
ecstore
|
||||
.make_bucket(
|
||||
&bucket,
|
||||
&MakeBucketOptions {
|
||||
force_create: true,
|
||||
lock_enabled: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("force create with lock_enabled should succeed on an existing bucket");
|
||||
|
||||
let meta = metadata_sys::get_in(&ecstore.ctx, &bucket)
|
||||
.await
|
||||
.expect("metadata should load after the lock enable");
|
||||
assert!(meta.lock_enabled, "Object Lock must be enabled on the existing bucket");
|
||||
assert!(
|
||||
!meta.object_lock_config_xml.is_empty(),
|
||||
"the Object Lock configuration must be persisted, not just the flag"
|
||||
);
|
||||
assert!(
|
||||
!meta.versioning_config_xml.is_empty(),
|
||||
"Object Lock requires versioning, so that must be persisted too"
|
||||
);
|
||||
}
|
||||
|
||||
/// `DeleteBucket`'s emptiness check is a raw disk scan (`has_xlmeta_files`),
|
||||
/// not an S3-level listing, so "the client drained the bucket" and "the
|
||||
/// bucket is deletable" are two different contracts. Nothing pinned the
|
||||
/// second one, which is how the s3-tests lane ended up failing 219 cases on
|
||||
/// `nuke_prefixed_buckets` while every test body passed.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn bucket_delete_succeeds_after_the_last_object_version_is_deleted() {
|
||||
let (disk_paths, ecstore) = setup_bucket_delete_test_env().await;
|
||||
let bucket = format!("bucket-delete-after-drain-{}", Uuid::new_v4().simple());
|
||||
let object = "object.txt";
|
||||
|
||||
create_bucket_with_object(&ecstore, &bucket, object).await;
|
||||
|
||||
ecstore
|
||||
.delete_object(&bucket, object, ObjectOptions::default())
|
||||
.await
|
||||
.expect("client delete of the only object should succeed");
|
||||
|
||||
assert!(
|
||||
!any_disk_has_object_metadata(&disk_paths, &bucket).await,
|
||||
"deleting the last version must not leave xl.meta on disk: DeleteBucket scans the raw \
|
||||
bucket directory, so residue here is reported to clients as BucketNotEmpty"
|
||||
);
|
||||
|
||||
ecstore
|
||||
.delete_bucket(&bucket, &DeleteBucketOptions::default())
|
||||
.await
|
||||
.expect("DeleteBucket must succeed once the client has drained the bucket");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn bucket_delete_default_s3_delete_still_rejects_non_empty_bucket() {
|
||||
@@ -1262,11 +1523,11 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn bucket_delete_preserves_put_committed_after_empty_scan() {
|
||||
async fn bucket_delete_fences_put_started_after_empty_scan() {
|
||||
let (_, ecstore) = setup_bucket_delete_test_env().await;
|
||||
let bucket = format!("bucket-delete-empty-scan-race-{}", Uuid::new_v4().simple());
|
||||
let object = "committed-after-empty-scan";
|
||||
let payload = b"object committed after DeleteBucket empty scan".to_vec();
|
||||
let payload = b"object started after DeleteBucket empty scan".to_vec();
|
||||
|
||||
ecstore
|
||||
.make_bucket(&bucket, &MakeBucketOptions::default())
|
||||
@@ -1283,30 +1544,29 @@ mod tests {
|
||||
});
|
||||
barrier.wait_until_paused().await;
|
||||
|
||||
let mut put_reader = PutObjReader::from_vec(payload.clone());
|
||||
ecstore
|
||||
.put_object(&bucket, object, &mut put_reader, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("PUT should commit while DeleteBucket is paused after its empty scan");
|
||||
let put_store = ecstore.clone();
|
||||
let put_bucket = bucket.clone();
|
||||
let mut put = tokio::spawn(async move {
|
||||
let mut put_reader = PutObjReader::from_vec(payload);
|
||||
put_store
|
||||
.put_object(&put_bucket, object, &mut put_reader, &ObjectOptions::default())
|
||||
.await
|
||||
});
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_millis(100), &mut put).await.is_err(),
|
||||
"PUT must wait behind the DeleteBucket lifecycle fence"
|
||||
);
|
||||
|
||||
barrier.release();
|
||||
let err = delete
|
||||
delete
|
||||
.await
|
||||
.expect("DeleteBucket task should join")
|
||||
.expect_err("DeleteBucket must reject a PUT committed after its empty scan");
|
||||
assert!(matches!(err, StorageError::BucketNotEmpty(name) if name == bucket));
|
||||
|
||||
let mut reader = ecstore
|
||||
.get_object_reader(&bucket, object, None, http::HeaderMap::new(), &ObjectOptions::default())
|
||||
.expect("DeleteBucket should commit while holding the lifecycle fence");
|
||||
let err = put
|
||||
.await
|
||||
.expect("committed object should remain readable after DeleteBucket fails");
|
||||
let mut restored = Vec::new();
|
||||
reader
|
||||
.stream
|
||||
.read_to_end(&mut restored)
|
||||
.await
|
||||
.expect("object body should remain readable");
|
||||
assert_eq!(restored, payload);
|
||||
.expect("PUT task should join")
|
||||
.expect_err("PUT must not recreate an object in the deleted bucket");
|
||||
assert!(matches!(err, StorageError::BucketNotFound(name) if name == bucket));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1441,8 +1701,11 @@ mod tests {
|
||||
crate::data_usage::store_data_usage_in_backend(snapshot, ecstore.clone())
|
||||
.await
|
||||
.expect("the usage fixture should be stored");
|
||||
crate::bucket::metadata::save_bucket_incarnation(ecstore.clone(), &bucket, Uuid::new_v4())
|
||||
.await
|
||||
.expect("partial create should have an incarnation sidecar");
|
||||
|
||||
ecstore.rollback_failed_bucket_creation(&bucket, None).await;
|
||||
ecstore.rollback_failed_bucket_creation(&bucket, None, None).await;
|
||||
|
||||
assert!(
|
||||
ecstore
|
||||
@@ -1452,6 +1715,13 @@ mod tests {
|
||||
.is_err(),
|
||||
"failed-create rollback should remove the partial physical bucket"
|
||||
);
|
||||
assert!(
|
||||
crate::bucket::metadata::load_bucket_incarnation(ecstore.clone(), &bucket)
|
||||
.await
|
||||
.expect("rollback sidecar lookup should succeed")
|
||||
.is_none(),
|
||||
"failed-create rollback must remove the orphan incarnation sidecar"
|
||||
);
|
||||
let persisted = crate::data_usage::load_data_usage_from_backend(ecstore.clone())
|
||||
.await
|
||||
.expect("the usage snapshot should remain readable");
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,7 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use crate::cluster::rpc::client::is_network_like_disk_error;
|
||||
use crate::config::storageclass;
|
||||
use crate::disk::error_reduce::{count_errs, reduce_write_quorum_errs};
|
||||
use crate::disk::{self, DiskAPI};
|
||||
@@ -91,7 +92,12 @@ pub(crate) async fn connect_load_init_formats_with_instance_ctx(
|
||||
|
||||
check_disk_fatal_errs(&errs)?;
|
||||
|
||||
let all_unformatted = should_init_erasure_disks(&errs);
|
||||
// Treat transient network errors (connection refused, timeout, etc.) as
|
||||
// equivalent to UnformattedDisk for the bootstrap decision. During
|
||||
// fresh-cluster startup a remote peer that cannot be reached is
|
||||
// indistinguishable from an unformatted disk — the peer may simply not
|
||||
// have started its gRPC server yet.
|
||||
let all_unformatted = errs.iter().all(is_unformatted_or_transient_network);
|
||||
let formats_present = formats.iter().flatten().count();
|
||||
let mut format_quorum = (formats_present > 0).then(|| select_format_erasure_in_quorum(&formats, 0));
|
||||
if format_quorum.as_ref().is_none_or(Result::is_err)
|
||||
@@ -144,7 +150,7 @@ pub(crate) async fn connect_load_init_formats_with_instance_ctx(
|
||||
should_init_erasure_disks(&errs)
|
||||
);
|
||||
|
||||
let unformatted = quorum_unformatted_disks(&errs);
|
||||
let unformatted = errs.iter().filter(|e| is_unformatted_or_transient_network(e)).count() > (errs.len() / 2);
|
||||
if unformatted && !first_disk {
|
||||
return Err(Error::NotFirstDisk);
|
||||
}
|
||||
@@ -248,14 +254,19 @@ async fn retain_format_quorum_members(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn quorum_unformatted_disks(errs: &[Option<DiskError>]) -> bool {
|
||||
count_errs(errs, &DiskError::UnformattedDisk) > (errs.len() / 2)
|
||||
}
|
||||
|
||||
pub fn should_init_erasure_disks(errs: &[Option<DiskError>]) -> bool {
|
||||
count_errs(errs, &DiskError::UnformattedDisk) == errs.len()
|
||||
}
|
||||
|
||||
/// Returns `true` if the error represents a disk that is either unformatted
|
||||
/// or unreachable due to a transient network failure. During fresh-cluster
|
||||
/// bootstrap a remote peer that cannot be reached is indistinguishable from
|
||||
/// an unformatted disk — the peer may simply not have started its gRPC
|
||||
/// server yet.
|
||||
fn is_unformatted_or_transient_network(err: &Option<DiskError>) -> bool {
|
||||
matches!(err, Some(DiskError::UnformattedDisk)) || err.as_ref().is_some_and(is_network_like_disk_error)
|
||||
}
|
||||
|
||||
pub fn check_disk_fatal_errs(errs: &[Option<DiskError>]) -> disk::error::Result<()> {
|
||||
if count_errs(errs, &DiskError::UnsupportedDisk) == errs.len() {
|
||||
return Err(DiskError::UnsupportedDisk);
|
||||
@@ -1612,6 +1623,24 @@ mod tests {
|
||||
let (formats, _) = load_format_erasure_all(&disks, false).await;
|
||||
assert!(formats.iter().all(Option::is_none), "a legacy split vote must not write RustFS formats");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_unformatted_or_transient_network_classifies_errors() {
|
||||
// UnformattedDisk is recognized.
|
||||
assert!(is_unformatted_or_transient_network(&Some(DiskError::UnformattedDisk)));
|
||||
// None (healthy disk) is not.
|
||||
assert!(!is_unformatted_or_transient_network(&None));
|
||||
// Transient network errors are recognized.
|
||||
assert!(is_unformatted_or_transient_network(&Some(DiskError::Io(std::io::Error::new(
|
||||
std::io::ErrorKind::ConnectionRefused,
|
||||
"connection refused"
|
||||
)))));
|
||||
assert!(is_unformatted_or_transient_network(&Some(DiskError::Timeout)));
|
||||
// Non-network errors are not.
|
||||
assert!(!is_unformatted_or_transient_network(&Some(DiskError::FileNotFound)));
|
||||
assert!(!is_unformatted_or_transient_network(&Some(DiskError::CorruptedFormat)));
|
||||
assert!(!is_unformatted_or_transient_network(&Some(DiskError::DiskFull)));
|
||||
}
|
||||
}
|
||||
|
||||
// #[derive(Debug, PartialEq, thiserror::Error)]
|
||||
|
||||
@@ -3716,7 +3716,7 @@ impl ECStore {
|
||||
}
|
||||
|
||||
// Optimization: use get for single object lookup with exact prefix
|
||||
if !opts.prefix.is_empty() && max_keys == 1 && opts.marker.is_none() {
|
||||
if !opts.prefix.is_empty() && max_keys == 1 && opts.marker.is_none() && !incl_deleted {
|
||||
match self
|
||||
.get_object_info(
|
||||
&opts.bucket,
|
||||
@@ -3728,17 +3728,16 @@ impl ECStore {
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(res) => {
|
||||
Ok(res) if !res.delete_marker => {
|
||||
return Ok(ListObjectsInfo {
|
||||
objects: vec![res],
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
Err(err) => {
|
||||
if is_err_bucket_not_found(&err) {
|
||||
return Err(err);
|
||||
}
|
||||
Err(err) if is_err_bucket_not_found(&err) => {
|
||||
return Err(err);
|
||||
}
|
||||
_ => {}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -4333,7 +4332,7 @@ impl ECStore {
|
||||
filter_prefix: Some(filter_prefix.clone()),
|
||||
forward_to: opts.marker.clone(),
|
||||
per_disk_limit: bounded_usize_to_i32(opts.limit),
|
||||
skip_total_timeout: false,
|
||||
skip_total_timeout: opts.walkdir_timeout.is_none(),
|
||||
walkdir_timeout: opts.walkdir_timeout,
|
||||
walkdir_stall_timeout: opts.walkdir_stall_timeout,
|
||||
},
|
||||
@@ -4356,6 +4355,12 @@ impl ECStore {
|
||||
forward_to: opts.marker.clone(),
|
||||
min_disks: raw_min_disks,
|
||||
per_disk_limit: bounded_usize_to_i32(opts.limit),
|
||||
// Skip the total walkdir timeout for listing operations.
|
||||
// Large buckets (millions of objects) can take longer than
|
||||
// the default 5s walkdir timeout to produce the first page
|
||||
// of results. The stall timeout still protects against
|
||||
// drives that stop making forward progress.
|
||||
skip_walkdir_total_timeout: opts.walkdir_timeout.is_none(),
|
||||
walkdir_timeout: opts.walkdir_timeout,
|
||||
walkdir_stall_timeout: opts.walkdir_stall_timeout,
|
||||
agreed: Some(Box::new(move |entry: MetaCacheEntry| {
|
||||
@@ -5053,7 +5058,7 @@ impl Sets {
|
||||
// (notably `forward_past`) — see backlog#1047.
|
||||
opts.parse_marker();
|
||||
|
||||
if !opts.prefix.is_empty() && max_keys == 1 && opts.marker.is_none() {
|
||||
if !opts.prefix.is_empty() && max_keys == 1 && opts.marker.is_none() && !incl_deleted {
|
||||
match self
|
||||
.get_object_info(
|
||||
&opts.bucket,
|
||||
@@ -5065,17 +5070,16 @@ impl Sets {
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(res) => {
|
||||
Ok(res) if !res.delete_marker => {
|
||||
return Ok(ListObjectsInfo {
|
||||
objects: vec![res],
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
Err(err) => {
|
||||
if is_err_bucket_not_found(&err) {
|
||||
return Err(err);
|
||||
}
|
||||
Err(err) if is_err_bucket_not_found(&err) => {
|
||||
return Err(err);
|
||||
}
|
||||
_ => {}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5554,7 +5558,7 @@ impl Sets {
|
||||
filter_prefix: Some(filter_prefix.clone()),
|
||||
forward_to: opts.marker.clone(),
|
||||
per_disk_limit: bounded_usize_to_i32(opts.limit),
|
||||
skip_total_timeout: false,
|
||||
skip_total_timeout: opts.walkdir_timeout.is_none(),
|
||||
walkdir_timeout: opts.walkdir_timeout,
|
||||
walkdir_stall_timeout: opts.walkdir_stall_timeout,
|
||||
},
|
||||
@@ -5577,6 +5581,12 @@ impl Sets {
|
||||
forward_to: opts.marker.clone(),
|
||||
min_disks: raw_min_disks,
|
||||
per_disk_limit: bounded_usize_to_i32(opts.limit),
|
||||
// Skip the total walkdir timeout for listing operations.
|
||||
// Large buckets (millions of objects) can take longer than
|
||||
// the default 5s walkdir timeout to produce the first page
|
||||
// of results. The stall timeout still protects against
|
||||
// drives that stop making forward progress.
|
||||
skip_walkdir_total_timeout: opts.walkdir_timeout.is_none(),
|
||||
walkdir_timeout: opts.walkdir_timeout,
|
||||
walkdir_stall_timeout: opts.walkdir_stall_timeout,
|
||||
agreed: Some(Box::new(move |entry: MetaCacheEntry| {
|
||||
@@ -5819,6 +5829,83 @@ impl Sets {
|
||||
}
|
||||
|
||||
impl SetDisks {
|
||||
pub(crate) async fn inner_list_object_versions_for_recursive_delete(
|
||||
self: Arc<Self>,
|
||||
bucket: &str,
|
||||
prefix: &str,
|
||||
marker: Option<String>,
|
||||
version_marker: Option<String>,
|
||||
max_keys: i32,
|
||||
) -> Result<ListObjectVersionsInfo> {
|
||||
let max_keys = normalize_max_keys(max_keys);
|
||||
if marker.is_none() && version_marker.is_some() {
|
||||
return Err(StorageError::NotImplemented);
|
||||
}
|
||||
|
||||
let has_version_marker = version_marker.is_some();
|
||||
let version_marker = version_marker.map(parse_version_marker).transpose()?;
|
||||
let effective_max_keys = if max_keys <= 0 { 0 } else { max_keys_plus_one(max_keys, true) };
|
||||
let mut opts = ListPathOptions {
|
||||
bucket: bucket.to_owned(),
|
||||
prefix: prefix.to_owned(),
|
||||
limit: effective_max_keys,
|
||||
marker,
|
||||
incl_deleted: true,
|
||||
ask_disks: list_objects_quorum_from_env(),
|
||||
versioned: true,
|
||||
include_marker: has_version_marker,
|
||||
..Default::default()
|
||||
};
|
||||
opts.parse_marker();
|
||||
|
||||
let mut list_result = self
|
||||
.list_path_result(&opts)
|
||||
.await
|
||||
.unwrap_or_else(|err| MetaCacheEntriesSortedResult {
|
||||
err: Some(err.into()),
|
||||
..Default::default()
|
||||
});
|
||||
let next_cache_id = list_result.entries.as_ref().and_then(|entries| entries.list_id.clone());
|
||||
let disk_has_more = list_result.err.is_none();
|
||||
if let Some(err) = list_result.err.take()
|
||||
&& err != rustfs_filemeta::Error::Unexpected
|
||||
{
|
||||
return Err(to_object_err(err.into(), vec![bucket, prefix]));
|
||||
}
|
||||
if let Some(result) = list_result.entries.as_mut()
|
||||
&& !has_version_marker
|
||||
{
|
||||
result.forward_past(opts.marker.clone());
|
||||
}
|
||||
let version_marker = version_marker_for_entries(list_result.entries.as_ref(), opts.marker.as_deref(), version_marker);
|
||||
let last_scanned_key = last_scanned_entry_name(list_result.entries.as_ref());
|
||||
let entries = list_result.entries.unwrap_or_default();
|
||||
let get_objects = ObjectInfo::from_meta_cache_entries_sorted_versions_for_recursive_delete(
|
||||
&entries,
|
||||
bucket,
|
||||
prefix,
|
||||
None,
|
||||
version_marker,
|
||||
)
|
||||
.await?;
|
||||
let (objects, prefixes, is_truncated, next_marker, next_version_idmarker) = list_objects_paginate(
|
||||
get_objects,
|
||||
&None,
|
||||
max_keys,
|
||||
disk_has_more,
|
||||
next_cache_id.as_deref(),
|
||||
true,
|
||||
last_scanned_key.as_deref(),
|
||||
);
|
||||
Ok(ListObjectVersionsInfo {
|
||||
is_truncated,
|
||||
next_marker,
|
||||
next_version_idmarker,
|
||||
objects,
|
||||
prefixes,
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn inner_list_objects_v2(
|
||||
self: Arc<Self>,
|
||||
|
||||
@@ -656,8 +656,18 @@ impl crate::storage_api_contracts::multipart::MultipartOperations for ECStore {
|
||||
delimiter: Option<String>,
|
||||
max_uploads: usize,
|
||||
) -> Result<ListMultipartsInfo> {
|
||||
self.handle_list_multipart_uploads(bucket, prefix, key_marker, upload_id_marker, delimiter, max_uploads)
|
||||
.await
|
||||
self.handle_list_multipart_uploads(
|
||||
bucket,
|
||||
multipart::MultipartUploadListRequest {
|
||||
prefix: prefix.to_string(),
|
||||
key_marker,
|
||||
upload_id_marker,
|
||||
delimiter,
|
||||
max_uploads,
|
||||
expected_incarnation_id: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[instrument(skip(self))]
|
||||
|
||||
@@ -16,8 +16,21 @@ use super::*;
|
||||
use crate::multipart_listing::paginate_multipart_listing;
|
||||
use crate::set_disk::get_lock_acquire_timeout;
|
||||
use crate::storage_api_contracts::multipart::MultipartOperations as _;
|
||||
use futures::{StreamExt, stream};
|
||||
use std::collections::HashSet;
|
||||
|
||||
const MULTIPART_LIST_SET_CONCURRENCY: usize = 4;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(super) struct MultipartUploadListRequest {
|
||||
pub(super) prefix: String,
|
||||
pub(super) key_marker: Option<String>,
|
||||
pub(super) upload_id_marker: Option<String>,
|
||||
pub(super) delimiter: Option<String>,
|
||||
pub(super) max_uploads: usize,
|
||||
pub(super) expected_incarnation_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
fn map_multipart_namespace_lock_error(
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
@@ -36,7 +49,137 @@ fn map_multipart_namespace_lock_error(
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_multipart_bucket_lifecycle_guard_held(
|
||||
guard: Option<&rustfs_lock::NamespaceLockGuard>,
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
) -> Result<()> {
|
||||
if guard.is_some_and(rustfs_lock::NamespaceLockGuard::is_lock_lost) {
|
||||
return Err(StorageError::NamespaceLockQuorumUnavailable {
|
||||
mode: "multipart_bucket_generation",
|
||||
bucket: bucket.to_string(),
|
||||
object: object.to_string(),
|
||||
required: 1,
|
||||
achieved: 0,
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn list_pool_multipart_uploads_for_incarnation(
|
||||
pool: &crate::core::sets::Sets,
|
||||
bucket: &str,
|
||||
request: &MultipartUploadListRequest,
|
||||
) -> Result<ListMultipartsInfo> {
|
||||
let per_set_limit = request.max_uploads.saturating_add(1);
|
||||
let results = stream::iter(pool.disk_set.iter().cloned())
|
||||
.map(|set| {
|
||||
let request = request.clone();
|
||||
async move {
|
||||
set.list_multipart_uploads_for_incarnation(
|
||||
bucket,
|
||||
&request.prefix,
|
||||
request.key_marker,
|
||||
request.upload_id_marker,
|
||||
request.delimiter,
|
||||
per_set_limit,
|
||||
request.expected_incarnation_id,
|
||||
)
|
||||
.await
|
||||
}
|
||||
})
|
||||
.buffer_unordered(MULTIPART_LIST_SET_CONCURRENCY)
|
||||
.collect::<Vec<_>>()
|
||||
.await;
|
||||
|
||||
let mut uploads = Vec::new();
|
||||
let mut common_prefixes = HashSet::new();
|
||||
let mut source_truncated = false;
|
||||
for result in results {
|
||||
let page = result?;
|
||||
uploads.extend(page.uploads);
|
||||
common_prefixes.extend(page.common_prefixes);
|
||||
source_truncated |= page.is_truncated;
|
||||
}
|
||||
|
||||
let page = paginate_multipart_listing(
|
||||
uploads,
|
||||
common_prefixes.into_iter().collect(),
|
||||
request.key_marker.as_deref(),
|
||||
request.key_marker.as_ref().and(request.upload_id_marker.as_deref()),
|
||||
request.max_uploads,
|
||||
source_truncated,
|
||||
);
|
||||
|
||||
Ok(ListMultipartsInfo {
|
||||
key_marker: request.key_marker.clone(),
|
||||
upload_id_marker: request.upload_id_marker.clone(),
|
||||
next_key_marker: page.next_key_marker,
|
||||
next_upload_id_marker: page.next_upload_id_marker,
|
||||
max_uploads: request.max_uploads,
|
||||
is_truncated: page.is_truncated,
|
||||
uploads: page.uploads,
|
||||
common_prefixes: page.common_prefixes,
|
||||
prefix: request.prefix.clone(),
|
||||
delimiter: request.delimiter.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
impl ECStore {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn list_multipart_uploads_for_bucket_incarnation(
|
||||
&self,
|
||||
bucket: &str,
|
||||
prefix: &str,
|
||||
key_marker: Option<String>,
|
||||
upload_id_marker: Option<String>,
|
||||
delimiter: Option<String>,
|
||||
max_uploads: usize,
|
||||
expected_incarnation_id: Uuid,
|
||||
) -> Result<ListMultipartsInfo> {
|
||||
self.handle_list_multipart_uploads(
|
||||
bucket,
|
||||
MultipartUploadListRequest {
|
||||
prefix: prefix.to_string(),
|
||||
key_marker,
|
||||
upload_id_marker,
|
||||
delimiter,
|
||||
max_uploads,
|
||||
expected_incarnation_id: Some(expected_incarnation_id),
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Multipart lock order is bucket lifecycle, generation validation, then
|
||||
/// object/upload locks in the selected set.
|
||||
async fn guard_multipart_bucket_incarnation(
|
||||
&self,
|
||||
bucket: &str,
|
||||
opts: &ObjectOptions,
|
||||
) -> Result<(ObjectOptions, Option<rustfs_lock::NamespaceLockGuard>)> {
|
||||
let mut opts = opts.clone();
|
||||
if is_meta_bucketname(bucket) {
|
||||
return Ok((opts, None));
|
||||
}
|
||||
if opts.expected_bucket_incarnation_id.is_none() {
|
||||
opts.expected_bucket_incarnation_id = Some(self.bucket_incarnation_id(bucket).await?);
|
||||
}
|
||||
let guard = if opts.bucket_lifecycle_lock_fence.is_some() {
|
||||
None
|
||||
} else {
|
||||
Some(self.acquire_bucket_lifecycle_read_lock(bucket).await?)
|
||||
};
|
||||
if let Some(guard) = guard.as_ref() {
|
||||
opts.add_bucket_lifecycle_lock_guard(guard);
|
||||
}
|
||||
let current = crate::bucket::metadata_sys::get_bucket_incarnation_id_in(&self.ctx, bucket).await?;
|
||||
if opts.expected_bucket_incarnation_id != Some(current) {
|
||||
return Err(StorageError::BucketNotFound(bucket.to_string()));
|
||||
}
|
||||
Ok((opts, guard))
|
||||
}
|
||||
|
||||
async fn acquire_list_parts_read_lock(
|
||||
&self,
|
||||
bucket: &str,
|
||||
@@ -66,6 +209,8 @@ impl ECStore {
|
||||
opts: &ObjectOptions,
|
||||
) -> Result<ListPartsInfo> {
|
||||
check_list_parts_args(bucket, object, upload_id)?;
|
||||
let (opts, _bucket_lifecycle_guard) = self.guard_multipart_bucket_incarnation(bucket, opts).await?;
|
||||
let opts = &opts;
|
||||
|
||||
let _object_lock_guard = self.acquire_list_parts_read_lock(bucket, object, opts).await?;
|
||||
|
||||
@@ -100,22 +245,38 @@ impl ECStore {
|
||||
pub(super) async fn handle_list_multipart_uploads(
|
||||
&self,
|
||||
bucket: &str,
|
||||
prefix: &str,
|
||||
key_marker: Option<String>,
|
||||
upload_id_marker: Option<String>,
|
||||
delimiter: Option<String>,
|
||||
max_uploads: usize,
|
||||
request: MultipartUploadListRequest,
|
||||
) -> Result<ListMultipartsInfo> {
|
||||
check_list_multipart_args(bucket, prefix, &key_marker, &upload_id_marker, &delimiter)?;
|
||||
check_list_multipart_args(
|
||||
bucket,
|
||||
&request.prefix,
|
||||
&request.key_marker,
|
||||
&request.upload_id_marker,
|
||||
&request.delimiter,
|
||||
)?;
|
||||
let guard_opts = ObjectOptions {
|
||||
expected_bucket_incarnation_id: request.expected_incarnation_id,
|
||||
..Default::default()
|
||||
};
|
||||
let (opts, bucket_lifecycle_guard) = self.guard_multipart_bucket_incarnation(bucket, &guard_opts).await?;
|
||||
let expected_incarnation_id = opts.expected_bucket_incarnation_id;
|
||||
|
||||
if prefix.is_empty() {
|
||||
if request.prefix.is_empty() {
|
||||
// TODO: return from cache
|
||||
}
|
||||
|
||||
if self.single_pool() {
|
||||
return self.pools[0]
|
||||
.list_multipart_uploads(bucket, prefix, key_marker, upload_id_marker, delimiter, max_uploads)
|
||||
.await;
|
||||
let result = list_pool_multipart_uploads_for_incarnation(
|
||||
&self.pools[0],
|
||||
bucket,
|
||||
&MultipartUploadListRequest {
|
||||
expected_incarnation_id,
|
||||
..request.clone()
|
||||
},
|
||||
)
|
||||
.await;
|
||||
ensure_multipart_bucket_lifecycle_guard_held(bucket_lifecycle_guard.as_ref(), bucket, &request.prefix)?;
|
||||
return result;
|
||||
}
|
||||
|
||||
let mut uploads = Vec::new();
|
||||
@@ -126,16 +287,15 @@ impl ECStore {
|
||||
if self.is_suspended(pool.pool_idx).await {
|
||||
continue;
|
||||
}
|
||||
let res = pool
|
||||
.list_multipart_uploads(
|
||||
bucket,
|
||||
prefix,
|
||||
key_marker.clone(),
|
||||
upload_id_marker.clone(),
|
||||
delimiter.clone(),
|
||||
max_uploads,
|
||||
)
|
||||
.await?;
|
||||
let res = list_pool_multipart_uploads_for_incarnation(
|
||||
pool,
|
||||
bucket,
|
||||
&MultipartUploadListRequest {
|
||||
expected_incarnation_id,
|
||||
..request.clone()
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
uploads.extend(res.uploads);
|
||||
common_prefixes.extend(res.common_prefixes);
|
||||
source_truncated |= res.is_truncated;
|
||||
@@ -145,19 +305,21 @@ impl ECStore {
|
||||
// unordered across pools and may exceed the global cap. Re-sort, re-cap,
|
||||
// and derive the truncation markers so a bucket whose uploads span pools
|
||||
// pages correctly instead of being silently reported complete.
|
||||
let page = merge_multipart_upload_pages(uploads, common_prefixes.into_iter().collect(), max_uploads, source_truncated);
|
||||
let page =
|
||||
merge_multipart_upload_pages(uploads, common_prefixes.into_iter().collect(), request.max_uploads, source_truncated);
|
||||
ensure_multipart_bucket_lifecycle_guard_held(bucket_lifecycle_guard.as_ref(), bucket, &request.prefix)?;
|
||||
|
||||
Ok(ListMultipartsInfo {
|
||||
key_marker,
|
||||
upload_id_marker,
|
||||
key_marker: request.key_marker,
|
||||
upload_id_marker: request.upload_id_marker,
|
||||
next_key_marker: page.next_key_marker,
|
||||
next_upload_id_marker: page.next_upload_id_marker,
|
||||
max_uploads,
|
||||
max_uploads: request.max_uploads,
|
||||
is_truncated: page.is_truncated,
|
||||
uploads: page.uploads,
|
||||
common_prefixes: page.common_prefixes,
|
||||
prefix: prefix.to_owned(),
|
||||
delimiter: delimiter.to_owned(),
|
||||
prefix: request.prefix,
|
||||
delimiter: request.delimiter,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -180,6 +342,8 @@ impl ECStore {
|
||||
opts: &ObjectOptions,
|
||||
) -> Result<(MultipartUploadResult, usize)> {
|
||||
check_new_multipart_args(bucket, object)?;
|
||||
let (opts, _bucket_lifecycle_guard) = self.guard_multipart_bucket_incarnation(bucket, opts).await?;
|
||||
let opts = &opts;
|
||||
|
||||
if self.single_pool() {
|
||||
return self.pools[0]
|
||||
@@ -192,9 +356,19 @@ impl ECStore {
|
||||
if self.is_suspended(idx).await || self.is_pool_rebalancing(idx).await {
|
||||
continue;
|
||||
}
|
||||
let res = pool
|
||||
.list_multipart_uploads(bucket, object, None, None, None, MAX_UPLOADS_LIST)
|
||||
.await?;
|
||||
let res = list_pool_multipart_uploads_for_incarnation(
|
||||
pool,
|
||||
bucket,
|
||||
&MultipartUploadListRequest {
|
||||
prefix: object.to_string(),
|
||||
key_marker: None,
|
||||
upload_id_marker: None,
|
||||
delimiter: None,
|
||||
max_uploads: MAX_UPLOADS_LIST,
|
||||
expected_incarnation_id: opts.expected_bucket_incarnation_id,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
if !res.uploads.is_empty() {
|
||||
let res = self.pools[idx].new_multipart_upload(bucket, object, opts).await?;
|
||||
@@ -249,6 +423,8 @@ impl ECStore {
|
||||
opts: &ObjectOptions,
|
||||
) -> Result<PartInfo> {
|
||||
check_put_object_part_args(bucket, object, upload_id)?;
|
||||
let (opts, _bucket_lifecycle_guard) = self.guard_multipart_bucket_incarnation(bucket, opts).await?;
|
||||
let opts = &opts;
|
||||
|
||||
if self.single_pool() {
|
||||
return self.pools[0]
|
||||
@@ -289,6 +465,8 @@ impl ECStore {
|
||||
opts: &ObjectOptions,
|
||||
) -> Result<MultipartInfo> {
|
||||
check_list_parts_args(bucket, object, upload_id)?;
|
||||
let (opts, _bucket_lifecycle_guard) = self.guard_multipart_bucket_incarnation(bucket, opts).await?;
|
||||
let opts = &opts;
|
||||
if self.single_pool() {
|
||||
return self.pools[0].get_multipart_info(bucket, object, upload_id, opts).await;
|
||||
}
|
||||
@@ -322,6 +500,8 @@ impl ECStore {
|
||||
opts: &ObjectOptions,
|
||||
) -> Result<()> {
|
||||
check_abort_multipart_args(bucket, object, upload_id)?;
|
||||
let (opts, _bucket_lifecycle_guard) = self.guard_multipart_bucket_incarnation(bucket, opts).await?;
|
||||
let opts = &opts;
|
||||
|
||||
// TODO: defer DeleteUploadID
|
||||
|
||||
@@ -360,6 +540,8 @@ impl ECStore {
|
||||
opts: &ObjectOptions,
|
||||
) -> Result<ObjectInfo> {
|
||||
check_complete_multipart_args(bucket, object, upload_id)?;
|
||||
let (opts, _bucket_lifecycle_guard) = self.guard_multipart_bucket_incarnation(bucket, opts).await?;
|
||||
let opts = &opts;
|
||||
|
||||
if self.single_pool() {
|
||||
return self.pools[0]
|
||||
|
||||
@@ -23,9 +23,16 @@ use crate::bucket::lifecycle::{
|
||||
Jentry, attach_tier_delete_source, transitioned_delete_journal_entry_for_source, transitioned_force_delete_journal_entry,
|
||||
},
|
||||
};
|
||||
use crate::bucket::metadata_sys::{
|
||||
acquire_bucket_metadata_transaction_read_lock_in, get_bucket_incarnation_id_in, get_cached_bucket_incarnation_id_in,
|
||||
get_object_lock_config_and_incarnation_from_disk_in,
|
||||
};
|
||||
use crate::bucket::object_lock::objectlock_sys::{
|
||||
check_object_lock_for_deletion_with_state, ensure_recursive_force_delete_allowed_for_state,
|
||||
};
|
||||
use crate::bucket::replication::ReplicationObjectBridge;
|
||||
use crate::disk::OldCurrentSize;
|
||||
use crate::object_api::DeleteLockFence;
|
||||
use crate::object_api::{NamespaceLockFence, ObjectLockConfigSnapshot};
|
||||
use crate::set_disk::{
|
||||
get_lock_acquire_timeout, get_object_lock_diag_slow_acquire_threshold, get_object_lock_diag_slow_hold_threshold,
|
||||
is_lock_optimization_enabled, is_object_lock_diag_enabled,
|
||||
@@ -43,6 +50,10 @@ use std::{
|
||||
};
|
||||
use tokio::io::{AsyncRead, ReadBuf};
|
||||
|
||||
#[cfg(not(test))]
|
||||
const RECURSIVE_DELETE_VERSION_SCAN_PAGE_SIZE: i32 = 1000;
|
||||
#[cfg(test)]
|
||||
const RECURSIVE_DELETE_VERSION_SCAN_PAGE_SIZE: i32 = 2;
|
||||
const FORCE_DELETE_LIST_PAGE_SIZE: i32 = 1_000;
|
||||
|
||||
fn build_tier_delete_journal_entry(
|
||||
@@ -365,6 +376,13 @@ impl RestoreAcceptGuard {
|
||||
pub fn is_lock_lost(&self) -> bool {
|
||||
self.0.guard.is_lock_lost()
|
||||
}
|
||||
|
||||
pub fn add_namespace_lock_fence(&self, opts: &mut ObjectOptions) {
|
||||
opts.ensure_namespace_lock_fence();
|
||||
if let Some(signal) = self.0.lock_lost_signal() {
|
||||
opts.add_namespace_lock_lost_signal(signal);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ObjectLockDiagGuard {
|
||||
@@ -485,6 +503,77 @@ fn should_create_delete_marker_for_missing_object(opts: &ObjectOptions) -> bool
|
||||
opts.versioned && opts.version_id.is_none() && !opts.delete_marker && !opts.data_movement
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
struct DeleteAfterObjectLockSnapshotBarrierState {
|
||||
bucket: String,
|
||||
arrived: tokio::sync::Notify,
|
||||
release: tokio::sync::Notify,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) struct DeleteAfterObjectLockSnapshotBarrier {
|
||||
state: Arc<DeleteAfterObjectLockSnapshotBarrierState>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
static DELETE_AFTER_OBJECT_LOCK_SNAPSHOT_BARRIER: std::sync::OnceLock<
|
||||
std::sync::Mutex<Option<Arc<DeleteAfterObjectLockSnapshotBarrierState>>>,
|
||||
> = std::sync::OnceLock::new();
|
||||
|
||||
#[cfg(test)]
|
||||
impl DeleteAfterObjectLockSnapshotBarrier {
|
||||
pub(crate) fn install(bucket: &str) -> Self {
|
||||
let state = Arc::new(DeleteAfterObjectLockSnapshotBarrierState {
|
||||
bucket: bucket.to_string(),
|
||||
arrived: tokio::sync::Notify::new(),
|
||||
release: tokio::sync::Notify::new(),
|
||||
});
|
||||
let mut slot = DELETE_AFTER_OBJECT_LOCK_SNAPSHOT_BARRIER
|
||||
.get_or_init(|| std::sync::Mutex::new(None))
|
||||
.lock()
|
||||
.expect("delete snapshot barrier mutex should not poison");
|
||||
assert!(slot.is_none(), "delete snapshot barrier must not already be installed");
|
||||
*slot = Some(Arc::clone(&state));
|
||||
Self { state }
|
||||
}
|
||||
|
||||
pub(crate) async fn wait_until_paused(&self) {
|
||||
self.state.arrived.notified().await;
|
||||
}
|
||||
|
||||
pub(crate) fn release(&self) {
|
||||
self.state.release.notify_one();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl Drop for DeleteAfterObjectLockSnapshotBarrier {
|
||||
fn drop(&mut self) {
|
||||
self.state.release.notify_one();
|
||||
if let Some(slot) = DELETE_AFTER_OBJECT_LOCK_SNAPSHOT_BARRIER.get() {
|
||||
let mut slot = slot.lock().expect("delete snapshot barrier mutex should not poison");
|
||||
if slot.as_ref().is_some_and(|installed| Arc::ptr_eq(installed, &self.state)) {
|
||||
*slot = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
async fn pause_delete_after_object_lock_snapshot(bucket: &str) {
|
||||
let state = DELETE_AFTER_OBJECT_LOCK_SNAPSHOT_BARRIER
|
||||
.get_or_init(|| std::sync::Mutex::new(None))
|
||||
.lock()
|
||||
.expect("delete snapshot barrier mutex should not poison")
|
||||
.as_ref()
|
||||
.filter(|state| state.bucket == bucket)
|
||||
.cloned();
|
||||
if let Some(state) = state {
|
||||
state.arrived.notify_one();
|
||||
state.release.notified().await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a delete-time lookup miss on a directory key should trigger an orphan
|
||||
/// empty-directory tree purge (issue #4189).
|
||||
///
|
||||
@@ -643,6 +732,54 @@ fn sorted_unique_delete_object_names(objects: &[ObjectToDelete]) -> Vec<&str> {
|
||||
}
|
||||
|
||||
impl ECStore {
|
||||
/// Captures Object Lock state once for a batch of PUTs to the same bucket.
|
||||
/// `handle_put_object` only reuses the token for the same store, bucket,
|
||||
/// bucket incarnation, and Object Lock configuration revision.
|
||||
pub async fn object_lock_config_snapshot(&self, bucket: &str) -> Result<Arc<ObjectLockConfigSnapshot>> {
|
||||
check_valid_bucket_name(bucket)?;
|
||||
let lifecycle_guard = self.acquire_bucket_lifecycle_read_lock(bucket).await?;
|
||||
let metadata_guard = acquire_bucket_metadata_transaction_read_lock_in(&self.ctx, bucket).await?;
|
||||
let (state, bucket_incarnation_id, config_revision) =
|
||||
get_object_lock_config_and_incarnation_from_disk_in(&self.ctx, bucket).await?;
|
||||
if lifecycle_guard.is_lock_lost() || metadata_guard.is_lock_lost() {
|
||||
return Err(Error::other("bucket lifecycle lock was lost while loading the Object Lock snapshot"));
|
||||
}
|
||||
Ok(Arc::new(ObjectLockConfigSnapshot::for_guarded_store_bucket(
|
||||
self.id,
|
||||
bucket,
|
||||
bucket_incarnation_id,
|
||||
config_revision,
|
||||
state,
|
||||
lifecycle_guard,
|
||||
metadata_guard,
|
||||
)))
|
||||
}
|
||||
|
||||
async fn object_lock_config_snapshot_under_lifecycle_fence(
|
||||
&self,
|
||||
bucket: &str,
|
||||
lifecycle_fence: &NamespaceLockFence,
|
||||
) -> Result<Arc<ObjectLockConfigSnapshot>> {
|
||||
if lifecycle_fence.is_lock_lost() {
|
||||
return Err(Error::other("bucket lifecycle lock was lost before loading the Object Lock snapshot"));
|
||||
}
|
||||
let metadata_guard = acquire_bucket_metadata_transaction_read_lock_in(&self.ctx, bucket).await?;
|
||||
let (state, bucket_incarnation_id, config_revision) =
|
||||
get_object_lock_config_and_incarnation_from_disk_in(&self.ctx, bucket).await?;
|
||||
if lifecycle_fence.is_lock_lost() || metadata_guard.is_lock_lost() {
|
||||
return Err(Error::other("bucket lock was lost while loading the Object Lock snapshot"));
|
||||
}
|
||||
Ok(Arc::new(ObjectLockConfigSnapshot::for_store_bucket_under_lifecycle_fence(
|
||||
self.id,
|
||||
bucket,
|
||||
bucket_incarnation_id,
|
||||
config_revision,
|
||||
state,
|
||||
lifecycle_fence.clone(),
|
||||
metadata_guard,
|
||||
)))
|
||||
}
|
||||
|
||||
/// Resolves a GET's object identity without constructing its body reader.
|
||||
///
|
||||
/// This is an additive two-stage counterpart to `get_object_reader`. The
|
||||
@@ -748,6 +885,10 @@ impl ECStore {
|
||||
}
|
||||
|
||||
let guard = self.acquire_object_write_lock(op, bucket, object).await?;
|
||||
if let Some(signal) = guard.lock_lost_signal() {
|
||||
opts.add_namespace_lock_lost_signal(signal);
|
||||
}
|
||||
opts.ensure_namespace_lock_fence();
|
||||
opts.no_lock = true;
|
||||
|
||||
Ok(Some(guard))
|
||||
@@ -788,9 +929,10 @@ impl ECStore {
|
||||
guards.push(self.acquire_object_write_lock("delete_objects", bucket, object).await?);
|
||||
}
|
||||
opts.no_lock = true;
|
||||
opts.delete_lock_fence = Some(DeleteLockFence::new(
|
||||
guards.iter().filter_map(ObjectLockDiagGuard::lock_lost_signal).collect(),
|
||||
));
|
||||
for signal in guards.iter().filter_map(ObjectLockDiagGuard::lock_lost_signal) {
|
||||
opts.add_namespace_lock_lost_signal(signal);
|
||||
}
|
||||
opts.ensure_namespace_lock_fence();
|
||||
|
||||
Ok(guards)
|
||||
}
|
||||
@@ -1057,17 +1199,40 @@ impl ECStore {
|
||||
check_put_object_args(bucket, object)?;
|
||||
|
||||
let object = encode_dir_object(object);
|
||||
let mut opts = opts.clone();
|
||||
if !is_meta_bucketname(bucket) && opts.expected_bucket_incarnation_id.is_none() {
|
||||
opts.expected_bucket_incarnation_id = Some(self.bucket_incarnation_id(bucket).await?);
|
||||
}
|
||||
if opts.overwrites_existing_version() && !is_meta_bucketname(bucket) {
|
||||
let expected_incarnation_id = opts
|
||||
.expected_bucket_incarnation_id
|
||||
.ok_or_else(|| Error::other("destructive PUT is missing its bucket incarnation"))?;
|
||||
if opts.object_lock_config_snapshot.is_none() {
|
||||
opts.object_lock_config_snapshot = Some(self.object_lock_config_snapshot(bucket).await?);
|
||||
}
|
||||
let snapshot = match opts.object_lock_config_snapshot.as_ref() {
|
||||
Some(snapshot) if snapshot.is_valid_for_destructive_put(self.id, bucket, expected_incarnation_id) => {
|
||||
Arc::clone(snapshot)
|
||||
}
|
||||
_ => {
|
||||
return Err(Error::other(
|
||||
"Object Lock snapshot does not hold valid target bucket generation and configuration fences",
|
||||
));
|
||||
}
|
||||
};
|
||||
snapshot.add_lock_fences(&mut opts);
|
||||
}
|
||||
|
||||
// Keep PUT atomic-read friendly: SetDisks takes the object write lock only
|
||||
// around precondition checks and the final rename/commit.
|
||||
if self.single_pool() {
|
||||
return self.pools[0]
|
||||
.put_object_with_old_current_size(bucket, object.as_str(), data, opts)
|
||||
.put_object_with_old_current_size(bucket, object.as_str(), data, &opts)
|
||||
.await;
|
||||
}
|
||||
|
||||
let idx = if opts.data_movement && opts.version_id.is_some() {
|
||||
self.select_data_movement_pool_idx(bucket, &object, data.size(), opts, false)
|
||||
self.select_data_movement_pool_idx(bucket, &object, data.size(), &opts, false)
|
||||
.await?
|
||||
} else if opts.no_lock {
|
||||
self.get_pool_idx_no_lock(bucket, &object, data.size()).await?
|
||||
@@ -1084,7 +1249,7 @@ impl ECStore {
|
||||
}
|
||||
|
||||
self.pools[idx]
|
||||
.put_object_with_old_current_size(bucket, &object, data, opts)
|
||||
.put_object_with_old_current_size(bucket, &object, data, &opts)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -1130,6 +1295,60 @@ impl ECStore {
|
||||
let cp_src_dst_same = path_join_buf(&[src_bucket, &src_object]) == path_join_buf(&[dst_bucket, &dst_object]);
|
||||
|
||||
let mut dst_opts = dst_opts.clone();
|
||||
if !is_meta_bucketname(dst_bucket) && dst_opts.expected_bucket_incarnation_id.is_none() {
|
||||
dst_opts.expected_bucket_incarnation_id = Some(self.bucket_incarnation_id(dst_bucket).await?);
|
||||
}
|
||||
let _bucket_lifecycle_guard = if is_meta_bucketname(dst_bucket) || dst_opts.bucket_lifecycle_lock_fence.is_some() {
|
||||
None
|
||||
} else {
|
||||
Some(self.acquire_bucket_lifecycle_read_lock(dst_bucket).await?)
|
||||
};
|
||||
let current_bucket_incarnation_id = if let Some(guard) = _bucket_lifecycle_guard.as_ref() {
|
||||
dst_opts.add_bucket_lifecycle_lock_guard(guard);
|
||||
let current_incarnation_id = get_bucket_incarnation_id_in(&self.ctx, dst_bucket).await?;
|
||||
if dst_opts
|
||||
.expected_bucket_incarnation_id
|
||||
.is_some_and(|expected| expected != current_incarnation_id)
|
||||
{
|
||||
return Err(StorageError::BucketNotFound(dst_bucket.to_string()));
|
||||
}
|
||||
Some(current_incarnation_id)
|
||||
} else {
|
||||
dst_opts.expected_bucket_incarnation_id
|
||||
};
|
||||
if dst_opts
|
||||
.bucket_lifecycle_lock_fence
|
||||
.as_ref()
|
||||
.is_some_and(NamespaceLockFence::is_lock_lost)
|
||||
{
|
||||
return Err(StorageError::NamespaceLockQuorumUnavailable {
|
||||
mode: "copy_object_bucket_generation",
|
||||
bucket: dst_bucket.to_string(),
|
||||
object: dst_object.clone(),
|
||||
required: 1,
|
||||
achieved: 0,
|
||||
});
|
||||
}
|
||||
if dst_opts.overwrites_existing_version() && !is_meta_bucketname(dst_bucket) {
|
||||
let incarnation_id =
|
||||
current_bucket_incarnation_id.ok_or_else(|| Error::other("copy is missing its bucket incarnation snapshot"))?;
|
||||
let lifecycle_fence = dst_opts
|
||||
.bucket_lifecycle_lock_fence
|
||||
.as_ref()
|
||||
.ok_or_else(|| Error::other("copy is missing its bucket lifecycle fence"))?;
|
||||
let snapshot = match dst_opts.object_lock_config_snapshot.as_ref() {
|
||||
Some(snapshot) => Arc::clone(snapshot),
|
||||
None => {
|
||||
self.object_lock_config_snapshot_under_lifecycle_fence(dst_bucket, lifecycle_fence)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
if !snapshot.is_valid_for_destructive_put(self.id, dst_bucket, incarnation_id) {
|
||||
return Err(Error::other("copy Object Lock snapshot does not match the target bucket generation"));
|
||||
}
|
||||
snapshot.add_lock_fences(&mut dst_opts);
|
||||
dst_opts.object_lock_config_snapshot = Some(snapshot);
|
||||
}
|
||||
let _dst_lock_guard = if cp_src_dst_same && dst_opts.expected_current_version_id.is_none() {
|
||||
self.acquire_object_write_lock_if_needed("copy_object", dst_bucket, &dst_object, &mut dst_opts)
|
||||
.await?
|
||||
@@ -1176,6 +1395,10 @@ impl ECStore {
|
||||
mod_time: dst_opts.mod_time,
|
||||
http_preconditions: dst_opts.http_preconditions.clone(),
|
||||
expected_current_version_id: dst_opts.expected_current_version_id.clone(),
|
||||
expected_bucket_incarnation_id: dst_opts.expected_bucket_incarnation_id,
|
||||
namespace_lock_fence: dst_opts.namespace_lock_fence.clone(),
|
||||
bucket_lifecycle_lock_fence: dst_opts.bucket_lifecycle_lock_fence.clone(),
|
||||
object_lock_config_snapshot: dst_opts.object_lock_config_snapshot.clone(),
|
||||
..Default::default()
|
||||
};
|
||||
return if let Some(reader) = src_info.put_object_reader.as_mut() {
|
||||
@@ -1206,6 +1429,10 @@ impl ECStore {
|
||||
mod_time: dst_opts.mod_time,
|
||||
http_preconditions: dst_opts.http_preconditions.clone(),
|
||||
expected_current_version_id: dst_opts.expected_current_version_id.clone(),
|
||||
expected_bucket_incarnation_id: dst_opts.expected_bucket_incarnation_id,
|
||||
namespace_lock_fence: dst_opts.namespace_lock_fence.clone(),
|
||||
bucket_lifecycle_lock_fence: dst_opts.bucket_lifecycle_lock_fence.clone(),
|
||||
object_lock_config_snapshot: dst_opts.object_lock_config_snapshot.clone(),
|
||||
..Default::default()
|
||||
};
|
||||
return self.pools[pool_idx]
|
||||
@@ -1233,6 +1460,10 @@ impl ECStore {
|
||||
mod_time: dst_opts.mod_time,
|
||||
http_preconditions: dst_opts.http_preconditions.clone(),
|
||||
expected_current_version_id: dst_opts.expected_current_version_id.clone(),
|
||||
expected_bucket_incarnation_id: dst_opts.expected_bucket_incarnation_id,
|
||||
namespace_lock_fence: dst_opts.namespace_lock_fence.clone(),
|
||||
bucket_lifecycle_lock_fence: dst_opts.bucket_lifecycle_lock_fence.clone(),
|
||||
object_lock_config_snapshot: dst_opts.object_lock_config_snapshot.clone(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
@@ -1322,6 +1553,13 @@ impl ECStore {
|
||||
) -> Result<ObjectInfo> {
|
||||
check_del_obj_args(bucket, object)?;
|
||||
|
||||
let _bucket_lifecycle_guard = if is_meta_bucketname(bucket) {
|
||||
None
|
||||
} else if opts.delete_prefix {
|
||||
Some(self.acquire_bucket_lifecycle_write_lock(bucket).await?)
|
||||
} else {
|
||||
Some(self.acquire_bucket_lifecycle_read_lock(bucket).await?)
|
||||
};
|
||||
let object = if opts.delete_prefix && !opts.delete_prefix_object {
|
||||
object.to_owned()
|
||||
} else {
|
||||
@@ -1330,10 +1568,94 @@ impl ECStore {
|
||||
let object = object.as_str();
|
||||
let mut opts = opts;
|
||||
opts.tier_delete_journal_api = tier_journal_api.clone();
|
||||
if let Some(guard) = _bucket_lifecycle_guard.as_ref() {
|
||||
opts.add_bucket_lifecycle_lock_guard(guard);
|
||||
}
|
||||
|
||||
if !is_meta_bucketname(bucket) {
|
||||
get_cached_bucket_incarnation_id_in(&self.ctx, bucket).await?;
|
||||
}
|
||||
let _object_lock_metadata_guard = if !is_meta_bucketname(bucket) {
|
||||
Some(acquire_bucket_metadata_transaction_read_lock_in(&self.ctx, bucket).await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(guard) = _object_lock_metadata_guard.as_ref() {
|
||||
opts.add_namespace_lock_guard(guard);
|
||||
}
|
||||
let current_bucket_incarnation_id = if _object_lock_metadata_guard.is_some() {
|
||||
let (state, incarnation_id, config_revision) =
|
||||
get_object_lock_config_and_incarnation_from_disk_in(&self.ctx, bucket).await?;
|
||||
opts.object_lock_config_snapshot = Some(Arc::new(ObjectLockConfigSnapshot::for_store_bucket(
|
||||
self.id,
|
||||
bucket,
|
||||
incarnation_id,
|
||||
config_revision,
|
||||
state,
|
||||
)));
|
||||
Some(incarnation_id)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let (Some(expected), Some(current)) = (opts.expected_bucket_incarnation_id, current_bucket_incarnation_id)
|
||||
&& expected != current
|
||||
{
|
||||
return Err(StorageError::BucketNotFound(bucket.to_string()));
|
||||
}
|
||||
#[cfg(test)]
|
||||
if current_bucket_incarnation_id.is_some() {
|
||||
pause_delete_after_object_lock_snapshot(bucket).await;
|
||||
}
|
||||
|
||||
if opts.delete_prefix && !opts.delete_prefix_object {
|
||||
// Prefix deletes cover multiple object keys; an exact lock on the prefix string
|
||||
// would not protect child objects.
|
||||
if !is_meta_bucketname(bucket) {
|
||||
let state = opts
|
||||
.object_lock_config_snapshot
|
||||
.as_deref()
|
||||
.ok_or_else(|| Error::other("recursive delete is missing its Object Lock configuration snapshot"))?
|
||||
.state();
|
||||
ensure_recursive_force_delete_allowed_for_state(bucket, state)?;
|
||||
let bypass_governance = opts
|
||||
.object_lock_delete
|
||||
.as_ref()
|
||||
.is_some_and(|delete_opts| delete_opts.bypass_governance);
|
||||
for pool in &self.pools {
|
||||
for set in &pool.disk_set {
|
||||
let mut marker = None;
|
||||
let mut version_marker = None;
|
||||
loop {
|
||||
let page = set
|
||||
.clone()
|
||||
.inner_list_object_versions_for_recursive_delete(
|
||||
bucket,
|
||||
object,
|
||||
marker.clone(),
|
||||
version_marker.clone(),
|
||||
RECURSIVE_DELETE_VERSION_SCAN_PAGE_SIZE,
|
||||
)
|
||||
.await?;
|
||||
for object_info in &page.objects {
|
||||
if check_object_lock_for_deletion_with_state(state, object_info, bypass_governance)?.is_some() {
|
||||
return Err(StorageError::PrefixAccessDenied(bucket.to_string(), object_info.name.clone()));
|
||||
}
|
||||
}
|
||||
if !page.is_truncated {
|
||||
break;
|
||||
}
|
||||
let next_marker = page.next_marker.ok_or_else(|| {
|
||||
Error::other("recursive delete version scan did not return a continuation marker")
|
||||
})?;
|
||||
if marker.as_ref() == Some(&next_marker) && version_marker == page.next_version_idmarker {
|
||||
return Err(Error::other("recursive delete version scan did not advance"));
|
||||
}
|
||||
marker = Some(next_marker);
|
||||
version_marker = page.next_version_idmarker;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
delete_prefix_with_tier_delete_journal(self, bucket, object, &opts, tier_journal_api.as_ref()).await?;
|
||||
return Ok(ObjectInfo::default());
|
||||
}
|
||||
@@ -1344,7 +1666,6 @@ impl ECStore {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if opts.delete_prefix {
|
||||
delete_prefix_with_tier_delete_journal(self, bucket, object, &opts, tier_journal_api.as_ref()).await?;
|
||||
return Ok(ObjectInfo::default());
|
||||
@@ -1531,6 +1852,17 @@ impl ECStore {
|
||||
|
||||
let mut opts = opts;
|
||||
opts.tier_delete_journal_api = tier_journal_api;
|
||||
let _bucket_lifecycle_guard = if is_meta_bucketname(bucket) {
|
||||
None
|
||||
} else {
|
||||
match self.acquire_bucket_lifecycle_read_lock(bucket).await {
|
||||
Ok(guard) => Some(guard),
|
||||
Err(err) => return return_batch_delete_lock_error(objects.as_slice(), err),
|
||||
}
|
||||
};
|
||||
if let Some(guard) = _bucket_lifecycle_guard.as_ref() {
|
||||
opts.add_bucket_lifecycle_lock_guard(guard);
|
||||
}
|
||||
if opts.delete_replication_config_snapshot.is_none() {
|
||||
match ReplicationObjectBridge::delete_request_config_in(&self.ctx, bucket).await {
|
||||
Ok(snapshot) => opts.delete_replication_config_snapshot = Some(Arc::new(snapshot)),
|
||||
@@ -1541,7 +1873,48 @@ impl ECStore {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !is_meta_bucketname(bucket)
|
||||
&& let Err(err) = get_cached_bucket_incarnation_id_in(&self.ctx, bucket).await
|
||||
{
|
||||
return return_batch_delete_lock_error(objects.as_slice(), err);
|
||||
}
|
||||
let _object_lock_metadata_guard = if is_meta_bucketname(bucket) {
|
||||
None
|
||||
} else {
|
||||
Some(match acquire_bucket_metadata_transaction_read_lock_in(&self.ctx, bucket).await {
|
||||
Ok(guard) => guard,
|
||||
Err(err) => return return_batch_delete_lock_error(objects.as_slice(), err),
|
||||
})
|
||||
};
|
||||
if let Some(guard) = _object_lock_metadata_guard.as_ref() {
|
||||
opts.add_namespace_lock_guard(guard);
|
||||
}
|
||||
let current_bucket_incarnation_id = if _object_lock_metadata_guard.is_some() {
|
||||
let (state, incarnation_id, config_revision) =
|
||||
match get_object_lock_config_and_incarnation_from_disk_in(&self.ctx, bucket).await {
|
||||
Ok(snapshot) => snapshot,
|
||||
Err(err) => return return_batch_delete_lock_error(objects.as_slice(), err),
|
||||
};
|
||||
opts.object_lock_config_snapshot = Some(Arc::new(ObjectLockConfigSnapshot::for_store_bucket(
|
||||
self.id,
|
||||
bucket,
|
||||
incarnation_id,
|
||||
config_revision,
|
||||
state,
|
||||
)));
|
||||
Some(incarnation_id)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let (Some(expected), Some(current)) = (opts.expected_bucket_incarnation_id, current_bucket_incarnation_id)
|
||||
&& expected != current
|
||||
{
|
||||
return return_batch_delete_lock_error(objects.as_slice(), StorageError::BucketNotFound(bucket.to_string()));
|
||||
}
|
||||
#[cfg(test)]
|
||||
if current_bucket_incarnation_id.is_some() {
|
||||
pause_delete_after_object_lock_snapshot(bucket).await;
|
||||
}
|
||||
let _object_lock_guards = match self.acquire_delete_objects_write_locks(bucket, &objects, &mut opts).await {
|
||||
Ok(guards) => guards,
|
||||
Err(err) => return return_batch_delete_lock_error(objects.as_slice(), err),
|
||||
@@ -1747,6 +2120,45 @@ impl ECStore {
|
||||
opts: &ObjectOptions,
|
||||
) -> Result<()> {
|
||||
let object = encode_dir_object(object);
|
||||
let mut opts = transition_restore_pool_opts(opts);
|
||||
if !is_meta_bucketname(bucket) && opts.expected_bucket_incarnation_id.is_none() {
|
||||
opts.expected_bucket_incarnation_id = Some(self.bucket_incarnation_id(bucket).await?);
|
||||
}
|
||||
let bucket_lifecycle_guard = if is_meta_bucketname(bucket) {
|
||||
None
|
||||
} else {
|
||||
Some(self.acquire_bucket_lifecycle_read_lock(bucket).await?)
|
||||
};
|
||||
if let Some(guard) = bucket_lifecycle_guard.as_ref() {
|
||||
opts.add_bucket_lifecycle_lock_guard(guard);
|
||||
}
|
||||
if !is_meta_bucketname(bucket) {
|
||||
let current_incarnation_id = get_bucket_incarnation_id_in(&self.ctx, bucket).await?;
|
||||
if opts.expected_bucket_incarnation_id != Some(current_incarnation_id) {
|
||||
return Err(StorageError::BucketNotFound(bucket.to_string()));
|
||||
}
|
||||
}
|
||||
if opts.overwrites_existing_version() && !is_meta_bucketname(bucket) {
|
||||
let expected_incarnation_id = opts
|
||||
.expected_bucket_incarnation_id
|
||||
.ok_or_else(|| Error::other("restore is missing its bucket incarnation snapshot"))?;
|
||||
let lifecycle_fence = opts
|
||||
.bucket_lifecycle_lock_fence
|
||||
.as_ref()
|
||||
.ok_or_else(|| Error::other("restore is missing its bucket lifecycle fence"))?;
|
||||
let snapshot = match opts.object_lock_config_snapshot.as_ref() {
|
||||
Some(snapshot) => Arc::clone(snapshot),
|
||||
None => {
|
||||
self.object_lock_config_snapshot_under_lifecycle_fence(bucket, lifecycle_fence)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
if !snapshot.is_valid_for_destructive_put(self.id, bucket, expected_incarnation_id) {
|
||||
return Err(Error::other("restore Object Lock snapshot does not match the target bucket generation"));
|
||||
}
|
||||
snapshot.add_lock_fences(&mut opts);
|
||||
opts.object_lock_config_snapshot = Some(snapshot);
|
||||
}
|
||||
// Deliberately NOT holding the object write lock across the tier
|
||||
// copy-back (backlog#1304): non-SELECT restore-vs-restore is
|
||||
// serialized by the accept path's compare-and-set of the ongoing flag
|
||||
@@ -1762,10 +2174,12 @@ impl ECStore {
|
||||
// (#4877) blocked HEAD/get_object_info for the whole copy-back and
|
||||
// self-deadlocked on the inner commits.
|
||||
if self.single_pool() {
|
||||
return self.pools[0].clone().restore_transitioned_object(bucket, &object, opts).await;
|
||||
return self.pools[0]
|
||||
.clone()
|
||||
.restore_transitioned_object(bucket, &object, &opts)
|
||||
.await;
|
||||
}
|
||||
|
||||
let opts = transition_restore_pool_opts(opts);
|
||||
let (_, idx) = self
|
||||
.get_latest_accessible_object_info_with_idx(bucket, object.as_str(), &opts)
|
||||
.await?;
|
||||
@@ -1784,18 +2198,38 @@ impl ECStore {
|
||||
opts: &ObjectOptions,
|
||||
) -> Result<ObjectInfo> {
|
||||
let object = encode_dir_object(object);
|
||||
if self.single_pool() {
|
||||
return self.pools[0].put_object_metadata(bucket, object.as_str(), opts).await;
|
||||
}
|
||||
|
||||
let mut opts = opts.clone();
|
||||
opts.metadata_chg = true;
|
||||
let bucket_lifecycle_guard = if is_meta_bucketname(bucket) {
|
||||
None
|
||||
} else {
|
||||
let guard = self.acquire_bucket_lifecycle_read_lock(bucket).await?;
|
||||
let current_incarnation_id = get_bucket_incarnation_id_in(&self.ctx, bucket).await?;
|
||||
if opts
|
||||
.expected_bucket_incarnation_id
|
||||
.is_some_and(|expected| expected != current_incarnation_id)
|
||||
{
|
||||
return Err(StorageError::BucketNotFound(bucket.to_string()));
|
||||
}
|
||||
opts.expected_bucket_incarnation_id = Some(current_incarnation_id);
|
||||
opts.add_bucket_lifecycle_lock_guard(&guard);
|
||||
if guard.is_lock_lost() {
|
||||
return Err(Error::other("bucket lifecycle lock was lost before the metadata update"));
|
||||
}
|
||||
Some(guard)
|
||||
};
|
||||
|
||||
if self.single_pool() {
|
||||
return self.pools[0].put_object_metadata(bucket, object.as_str(), &opts).await;
|
||||
}
|
||||
|
||||
let (_, idx) = self
|
||||
.get_latest_accessible_object_info_with_idx(bucket, object.as_str(), &opts)
|
||||
.await?;
|
||||
|
||||
self.pools[idx].put_object_metadata(bucket, object.as_str(), &opts).await
|
||||
let result = self.pools[idx].put_object_metadata(bucket, object.as_str(), &opts).await;
|
||||
drop(bucket_lifecycle_guard);
|
||||
result
|
||||
}
|
||||
|
||||
#[instrument(skip(self))]
|
||||
@@ -2885,7 +3319,10 @@ mod tests {
|
||||
|
||||
assert_eq!(guards.len(), 2, "duplicate object names should share one namespace lock");
|
||||
assert!(opts.no_lock, "set layer should not reacquire locks already held by ECStore");
|
||||
assert!(opts.delete_lock_fence.is_some(), "set layer must receive the outer write-lock loss fence");
|
||||
assert!(
|
||||
opts.namespace_lock_fence.is_some(),
|
||||
"set layer must receive the outer write-lock loss fence"
|
||||
);
|
||||
|
||||
let alpha_lock = store
|
||||
.handle_new_ns_lock("bucket", "alpha")
|
||||
|
||||
@@ -202,13 +202,35 @@ impl ECStore {
|
||||
}
|
||||
|
||||
pub(super) async fn delete_prefix(&self, bucket: &str, object: &str, opts: &ObjectOptions) -> Result<()> {
|
||||
let mut first_error = None;
|
||||
let mut first_volume_error = None;
|
||||
let mut has_success = false;
|
||||
for pool in self.pools.iter() {
|
||||
let mut opts = opts.clone();
|
||||
opts.delete_prefix = true;
|
||||
pool.delete_object(bucket, object, opts).await?;
|
||||
match pool.delete_object(bucket, object, opts).await {
|
||||
Ok(_) => has_success = true,
|
||||
Err(err) if is_err_strict_volume_not_found(&err) => {
|
||||
if first_volume_error.is_none() {
|
||||
first_volume_error = Some(err);
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
if first_error.is_none() {
|
||||
first_error = Some(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
match first_error {
|
||||
Some(err) => Err(err),
|
||||
None if has_success => Ok(()),
|
||||
None => match first_volume_error {
|
||||
Some(err) => Err(err),
|
||||
None => Ok(()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn get_available_pool_idx(&self, bucket: &str, object: &str, size: i64) -> Option<usize> {
|
||||
@@ -748,9 +770,148 @@ impl ECStore {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::storageclass::{CLASS_RRS, CLASS_STANDARD, lookup_config_for_pools_without_env};
|
||||
use crate::disk::error::DiskError;
|
||||
use crate::layout::endpoint::Endpoint;
|
||||
use crate::layout::endpoints::{EndpointServerPools, Endpoints, PoolEndpoints};
|
||||
use crate::storage_api_contracts::bucket::MakeBucketOptions;
|
||||
use arc_swap::ArcSwap;
|
||||
use rustfs_config::server_config::KVS;
|
||||
use std::sync::Arc;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_prefix_attempts_later_pools_after_an_earlier_pool_error() {
|
||||
let temp_dir = tempfile::tempdir().expect("multi-pool delete test directory should be created");
|
||||
let mut pools = Vec::with_capacity(2);
|
||||
for (pool_index, drives_per_set) in [2, 4].into_iter().enumerate() {
|
||||
let mut endpoints = Vec::with_capacity(drives_per_set);
|
||||
for disk_index in 0..drives_per_set {
|
||||
let disk_path = temp_dir.path().join(format!("pool{pool_index}-disk{disk_index}"));
|
||||
tokio::fs::create_dir_all(&disk_path)
|
||||
.await
|
||||
.expect("multi-pool delete test disk should be created");
|
||||
let mut endpoint =
|
||||
Endpoint::try_from(disk_path.to_str().expect("disk path should be utf8")).expect("endpoint should parse");
|
||||
endpoint.set_pool_index(pool_index);
|
||||
endpoint.set_set_index(0);
|
||||
endpoint.set_disk_index(disk_index);
|
||||
endpoints.push(endpoint);
|
||||
}
|
||||
pools.push(PoolEndpoints {
|
||||
legacy: false,
|
||||
set_count: 1,
|
||||
drives_per_set,
|
||||
endpoints: Endpoints::from(endpoints),
|
||||
cmd_line: format!("delete-prefix-pool-{pool_index}"),
|
||||
platform: "test".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let endpoint_pools = EndpointServerPools(pools);
|
||||
let instance_ctx = Arc::new(crate::runtime::instance::InstanceContext::new());
|
||||
crate::store::init_local_disks_with_instance_ctx(&instance_ctx, endpoint_pools.clone())
|
||||
.await
|
||||
.expect("multi-pool local disks should initialize");
|
||||
let shutdown = CancellationToken::new();
|
||||
let store = ECStore::new_with_instance_ctx(
|
||||
"127.0.0.1:0".parse().expect("test address should parse"),
|
||||
endpoint_pools,
|
||||
shutdown.clone(),
|
||||
instance_ctx,
|
||||
)
|
||||
.await
|
||||
.expect("multi-pool store should initialize");
|
||||
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
|
||||
let bucket = format!("delete-prefix-{}", Uuid::new_v4().simple());
|
||||
store
|
||||
.make_bucket(&bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("bucket should be created in both pools");
|
||||
|
||||
let first_pool_disks = store.pools[0].disk_set[0].disks.read().await.clone();
|
||||
for disk in first_pool_disks.iter().flatten() {
|
||||
disk.write_all(&bucket, "blocked", bytes::Bytes::from_static(b"not-a-directory"))
|
||||
.await
|
||||
.expect("first pool should contain a blocking parent file");
|
||||
}
|
||||
let later_pool_disks = store.pools[1].disk_set[0].disks.read().await.clone();
|
||||
let later_data_disk = later_pool_disks[0].clone().expect("later pool should have its first disk");
|
||||
later_data_disk
|
||||
.write_all(&bucket, "blocked/prefix/object", bytes::Bytes::from_static(b"data"))
|
||||
.await
|
||||
.expect("later pool should contain the prefix on its available disk");
|
||||
*store.pools[1].disk_set[0].disks.write().await = vec![Some(later_data_disk.clone()), None, None, None];
|
||||
|
||||
let err = store
|
||||
.delete_prefix(&bucket, "blocked/prefix", &ObjectOptions::default())
|
||||
.await
|
||||
.expect_err("the first pool's hard error must be returned");
|
||||
|
||||
assert!(
|
||||
matches!(err, StorageError::PrefixAccessDenied(ref error_bucket, ref error_prefix)
|
||||
if error_bucket == &bucket && error_prefix == "blocked/prefix"),
|
||||
"unexpected multi-pool delete error: {err:?}"
|
||||
);
|
||||
assert!(matches!(
|
||||
later_data_disk.read_all(&bucket, "blocked/prefix/object").await,
|
||||
Err(DiskError::FileNotFound)
|
||||
));
|
||||
|
||||
*store.pools[1].disk_set[0].disks.write().await = later_pool_disks.clone();
|
||||
for disk in first_pool_disks.iter().flatten() {
|
||||
disk.write_all(&bucket, "second-blocked", bytes::Bytes::from_static(b"not-a-directory"))
|
||||
.await
|
||||
.expect("first pool should contain a second blocking parent file");
|
||||
}
|
||||
for disk in later_pool_disks.iter().flatten() {
|
||||
disk.write_all(&bucket, "second-blocked/prefix/object", bytes::Bytes::from_static(b"data"))
|
||||
.await
|
||||
.expect("later pool should contain the second prefix");
|
||||
}
|
||||
let err = store
|
||||
.delete_prefix(&bucket, "second-blocked/prefix", &ObjectOptions::default())
|
||||
.await
|
||||
.expect_err("a successful later pool must not override the first pool's hard error");
|
||||
assert!(
|
||||
matches!(err, StorageError::PrefixAccessDenied(ref error_bucket, ref error_prefix)
|
||||
if error_bucket == &bucket && error_prefix == "second-blocked/prefix"),
|
||||
"unexpected hard-error plus success result: {err:?}"
|
||||
);
|
||||
for disk in later_pool_disks.iter().flatten() {
|
||||
assert!(matches!(
|
||||
disk.read_all(&bucket, "second-blocked/prefix/object").await,
|
||||
Err(DiskError::FileNotFound)
|
||||
));
|
||||
}
|
||||
|
||||
for disk in later_pool_disks.iter().flatten() {
|
||||
disk.delete_volume(&bucket, true)
|
||||
.await
|
||||
.expect("the bucket should be absent from the later pool");
|
||||
}
|
||||
let healthy_object = "healthy/prefix/object";
|
||||
for disk in first_pool_disks.iter().flatten() {
|
||||
disk.write_all(&bucket, healthy_object, bytes::Bytes::from_static(b"data"))
|
||||
.await
|
||||
.expect("the first pool should contain the healthy prefix");
|
||||
}
|
||||
store
|
||||
.delete_prefix(&bucket, "healthy/prefix", &ObjectOptions::default())
|
||||
.await
|
||||
.expect("one successful pool should make a partially missing bucket idempotent");
|
||||
for disk in first_pool_disks.iter().flatten() {
|
||||
assert!(matches!(disk.read_all(&bucket, healthy_object).await, Err(DiskError::FileNotFound)));
|
||||
}
|
||||
|
||||
let missing_bucket = format!("delete-prefix-missing-{}", Uuid::new_v4().simple());
|
||||
let err = store
|
||||
.delete_prefix(&missing_bucket, "missing/prefix", &ObjectOptions::default())
|
||||
.await
|
||||
.expect_err("a bucket missing from every pool must remain an error");
|
||||
assert_eq!(err, StorageError::BucketNotFound(missing_bucket));
|
||||
|
||||
shutdown.cancel();
|
||||
}
|
||||
|
||||
fn assert_backend_layout_empty(info: &rustfs_madmin::BackendInfo) {
|
||||
assert!(info.standard_sc_parities.is_empty());
|
||||
|
||||
@@ -54,6 +54,7 @@ arc-swap.workspace = true
|
||||
criterion = { workspace = true, features = ["html_reports"] }
|
||||
tempfile = { workspace = true }
|
||||
proptest = "1"
|
||||
serde_json.workspace = true
|
||||
|
||||
[[bench]]
|
||||
name = "xl_meta_bench"
|
||||
|
||||
@@ -22,6 +22,8 @@ use rustfs_utils::http::{
|
||||
};
|
||||
use s3s::dto::{RestoreStatus, Timestamp};
|
||||
use s3s::header::X_AMZ_RESTORE;
|
||||
use serde::de::{self, MapAccess, SeqAccess, Visitor, value::MapAccessDeserializer};
|
||||
use serde::ser::SerializeMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
|
||||
@@ -229,7 +231,7 @@ pub enum TransitionVersionState {
|
||||
Exact,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, PartialEq, Clone, Default)]
|
||||
#[derive(Debug, PartialEq, Clone, Default)]
|
||||
pub struct FileInfo {
|
||||
pub volume: String,
|
||||
pub name: String,
|
||||
@@ -240,9 +242,7 @@ pub struct FileInfo {
|
||||
pub transitioned_objname: String,
|
||||
pub transition_tier: String,
|
||||
pub transition_version_id: Option<Uuid>,
|
||||
#[serde(default)]
|
||||
pub transition_version: Option<String>,
|
||||
#[serde(default)]
|
||||
pub transition_version_state: TransitionVersionState,
|
||||
pub expire_restored: bool,
|
||||
pub data_dir: Option<Uuid>,
|
||||
@@ -271,6 +271,246 @@ pub struct FileInfo {
|
||||
pub uses_legacy_checksum: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(remote = "FileInfo")]
|
||||
struct FileInfoMapDef {
|
||||
volume: String,
|
||||
name: String,
|
||||
version_id: Option<Uuid>,
|
||||
is_latest: bool,
|
||||
deleted: bool,
|
||||
transition_status: String,
|
||||
transitioned_objname: String,
|
||||
transition_tier: String,
|
||||
transition_version_id: Option<Uuid>,
|
||||
#[serde(default)]
|
||||
transition_version: Option<String>,
|
||||
#[serde(default)]
|
||||
transition_version_state: TransitionVersionState,
|
||||
expire_restored: bool,
|
||||
data_dir: Option<Uuid>,
|
||||
mod_time: Option<OffsetDateTime>,
|
||||
size: i64,
|
||||
mode: Option<u32>,
|
||||
written_by_version: Option<u64>,
|
||||
metadata: HashMap<String, String>,
|
||||
parts: Vec<ObjectPartInfo>,
|
||||
erasure: ErasureInfo,
|
||||
mark_deleted: bool,
|
||||
replication_state_internal: Option<ReplicationState>,
|
||||
data: Option<Bytes>,
|
||||
num_versions: usize,
|
||||
successor_mod_time: Option<OffsetDateTime>,
|
||||
fresh: bool,
|
||||
idx: usize,
|
||||
checksum: Option<Bytes>,
|
||||
versioned: bool,
|
||||
uses_legacy_checksum: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum TransitionVersionOrExpireRestored {
|
||||
TransitionVersion(Option<String>),
|
||||
ExpireRestored(bool),
|
||||
}
|
||||
|
||||
const FILE_INFO_FIELDS: &[&str] = &[
|
||||
"volume",
|
||||
"name",
|
||||
"version_id",
|
||||
"is_latest",
|
||||
"deleted",
|
||||
"transition_status",
|
||||
"transitioned_objname",
|
||||
"transition_tier",
|
||||
"transition_version_id",
|
||||
"transition_version",
|
||||
"transition_version_state",
|
||||
"expire_restored",
|
||||
"data_dir",
|
||||
"mod_time",
|
||||
"size",
|
||||
"mode",
|
||||
"written_by_version",
|
||||
"metadata",
|
||||
"parts",
|
||||
"erasure",
|
||||
"mark_deleted",
|
||||
"replication_state_internal",
|
||||
"data",
|
||||
"num_versions",
|
||||
"successor_mod_time",
|
||||
"fresh",
|
||||
"idx",
|
||||
"checksum",
|
||||
"versioned",
|
||||
"uses_legacy_checksum",
|
||||
];
|
||||
|
||||
impl Serialize for FileInfo {
|
||||
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
let mut map = serializer.serialize_map(Some(FILE_INFO_FIELDS.len()))?;
|
||||
map.serialize_entry("volume", &self.volume)?;
|
||||
map.serialize_entry("name", &self.name)?;
|
||||
map.serialize_entry("version_id", &self.version_id)?;
|
||||
map.serialize_entry("is_latest", &self.is_latest)?;
|
||||
map.serialize_entry("deleted", &self.deleted)?;
|
||||
map.serialize_entry("transition_status", &self.transition_status)?;
|
||||
map.serialize_entry("transitioned_objname", &self.transitioned_objname)?;
|
||||
map.serialize_entry("transition_tier", &self.transition_tier)?;
|
||||
map.serialize_entry("transition_version_id", &self.transition_version_id)?;
|
||||
map.serialize_entry("transition_version", &self.transition_version)?;
|
||||
map.serialize_entry("transition_version_state", &self.transition_version_state)?;
|
||||
map.serialize_entry("expire_restored", &self.expire_restored)?;
|
||||
map.serialize_entry("data_dir", &self.data_dir)?;
|
||||
map.serialize_entry("mod_time", &self.mod_time)?;
|
||||
map.serialize_entry("size", &self.size)?;
|
||||
map.serialize_entry("mode", &self.mode)?;
|
||||
map.serialize_entry("written_by_version", &self.written_by_version)?;
|
||||
map.serialize_entry("metadata", &self.metadata)?;
|
||||
map.serialize_entry("parts", &self.parts)?;
|
||||
map.serialize_entry("erasure", &self.erasure)?;
|
||||
map.serialize_entry("mark_deleted", &self.mark_deleted)?;
|
||||
map.serialize_entry("replication_state_internal", &self.replication_state_internal)?;
|
||||
map.serialize_entry("data", &self.data)?;
|
||||
map.serialize_entry("num_versions", &self.num_versions)?;
|
||||
map.serialize_entry("successor_mod_time", &self.successor_mod_time)?;
|
||||
map.serialize_entry("fresh", &self.fresh)?;
|
||||
map.serialize_entry("idx", &self.idx)?;
|
||||
map.serialize_entry("checksum", &self.checksum)?;
|
||||
map.serialize_entry("versioned", &self.versioned)?;
|
||||
map.serialize_entry("uses_legacy_checksum", &self.uses_legacy_checksum)?;
|
||||
map.end()
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for FileInfo {
|
||||
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
struct FileInfoVisitor;
|
||||
|
||||
impl<'de> Visitor<'de> for FileInfoVisitor {
|
||||
type Value = FileInfo;
|
||||
|
||||
fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter.write_str("a FileInfo map or supported positional array")
|
||||
}
|
||||
|
||||
fn visit_map<A>(self, map: A) -> std::result::Result<Self::Value, A::Error>
|
||||
where
|
||||
A: MapAccess<'de>,
|
||||
{
|
||||
FileInfoMapDef::deserialize(MapAccessDeserializer::new(map))
|
||||
}
|
||||
|
||||
fn visit_seq<A>(self, mut seq: A) -> std::result::Result<Self::Value, A::Error>
|
||||
where
|
||||
A: SeqAccess<'de>,
|
||||
{
|
||||
// RUSTFS_COMPAT_TODO(rustfs-5509): beta.11 and beta.12 wrote incompatible positional arrays. Remove after every supported direct-upgrade release writes named maps and retained RPC payloads cannot contain either array.
|
||||
let declared_len = seq.size_hint();
|
||||
if let Some(len) = declared_len
|
||||
&& len != 28
|
||||
&& len != 30
|
||||
{
|
||||
return Err(de::Error::invalid_length(len, &self));
|
||||
}
|
||||
|
||||
macro_rules! next_field {
|
||||
($field:literal) => {
|
||||
seq.next_element()?
|
||||
.ok_or_else(|| de::Error::missing_field($field))?
|
||||
};
|
||||
}
|
||||
|
||||
let volume = next_field!("volume");
|
||||
let name = next_field!("name");
|
||||
let version_id = next_field!("version_id");
|
||||
let is_latest = next_field!("is_latest");
|
||||
let deleted = next_field!("deleted");
|
||||
let transition_status = next_field!("transition_status");
|
||||
let transitioned_objname = next_field!("transitioned_objname");
|
||||
let transition_tier = next_field!("transition_tier");
|
||||
let transition_version_id = next_field!("transition_version_id");
|
||||
let transition_or_expire = next_field!("transition_version or expire_restored");
|
||||
let (transition_version, transition_version_state, expire_restored) = match transition_or_expire {
|
||||
TransitionVersionOrExpireRestored::TransitionVersion(transition_version) => (
|
||||
transition_version,
|
||||
next_field!("transition_version_state"),
|
||||
next_field!("expire_restored"),
|
||||
),
|
||||
TransitionVersionOrExpireRestored::ExpireRestored(expire_restored) => {
|
||||
(None, TransitionVersionState::Unknown, expire_restored)
|
||||
}
|
||||
};
|
||||
let data_dir = next_field!("data_dir");
|
||||
let mod_time = next_field!("mod_time");
|
||||
let size = next_field!("size");
|
||||
let mode = next_field!("mode");
|
||||
let written_by_version = next_field!("written_by_version");
|
||||
let metadata = next_field!("metadata");
|
||||
let parts = next_field!("parts");
|
||||
let erasure = next_field!("erasure");
|
||||
let mark_deleted = next_field!("mark_deleted");
|
||||
let replication_state_internal = next_field!("replication_state_internal");
|
||||
let data = next_field!("data");
|
||||
let num_versions = next_field!("num_versions");
|
||||
let successor_mod_time = next_field!("successor_mod_time");
|
||||
let fresh = next_field!("fresh");
|
||||
let idx = next_field!("idx");
|
||||
let checksum = next_field!("checksum");
|
||||
let versioned = next_field!("versioned");
|
||||
let uses_legacy_checksum = next_field!("uses_legacy_checksum");
|
||||
|
||||
if seq.next_element::<de::IgnoredAny>()?.is_some() {
|
||||
return Err(de::Error::invalid_length(declared_len.unwrap_or(29), &self));
|
||||
}
|
||||
|
||||
Ok(FileInfo {
|
||||
volume,
|
||||
name,
|
||||
version_id,
|
||||
is_latest,
|
||||
deleted,
|
||||
transition_status,
|
||||
transitioned_objname,
|
||||
transition_tier,
|
||||
transition_version_id,
|
||||
transition_version,
|
||||
transition_version_state,
|
||||
expire_restored,
|
||||
data_dir,
|
||||
mod_time,
|
||||
size,
|
||||
mode,
|
||||
written_by_version,
|
||||
metadata,
|
||||
parts,
|
||||
erasure,
|
||||
mark_deleted,
|
||||
replication_state_internal,
|
||||
data,
|
||||
num_versions,
|
||||
successor_mod_time,
|
||||
fresh,
|
||||
idx,
|
||||
checksum,
|
||||
versioned,
|
||||
uses_legacy_checksum,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
deserializer.deserialize_struct("FileInfo", FILE_INFO_FIELDS, FileInfoVisitor)
|
||||
}
|
||||
}
|
||||
|
||||
/// Selects the validation policy for a trusted operation boundary.
|
||||
///
|
||||
/// This mode is deliberately caller-selected and is never inferred from
|
||||
@@ -1800,6 +2040,325 @@ mod tests {
|
||||
})
|
||||
}
|
||||
|
||||
fn positional_compat_file_info() -> FileInfo {
|
||||
FileInfo {
|
||||
volume: "bucket".to_string(),
|
||||
name: "object".to_string(),
|
||||
transition_version_id: Some(Uuid::from_u128(1)),
|
||||
transition_version: Some(Uuid::from_u128(1).to_string()),
|
||||
transition_version_state: TransitionVersionState::Exact,
|
||||
expire_restored: true,
|
||||
size: -1,
|
||||
fresh: true,
|
||||
idx: 7,
|
||||
versioned: true,
|
||||
uses_legacy_checksum: true,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum HistoricalFileInfoLayout {
|
||||
Beta11,
|
||||
Beta12,
|
||||
}
|
||||
|
||||
fn encode_historical_file_info(value: &FileInfo, layout: HistoricalFileInfoLayout) -> Vec<u8> {
|
||||
let mut encoded = Vec::new();
|
||||
let field_count = match layout {
|
||||
HistoricalFileInfoLayout::Beta11 => 28,
|
||||
HistoricalFileInfoLayout::Beta12 => 30,
|
||||
};
|
||||
rmp::encode::write_array_len(&mut encoded, field_count).expect("historical FileInfo array header should encode");
|
||||
|
||||
macro_rules! encode_fields {
|
||||
($($field:expr),+ $(,)?) => {
|
||||
$($field
|
||||
.serialize(&mut Serializer::new(&mut encoded))
|
||||
.expect("historical FileInfo field should encode");)+
|
||||
};
|
||||
}
|
||||
|
||||
encode_fields!(
|
||||
&value.volume,
|
||||
&value.name,
|
||||
&value.version_id,
|
||||
&value.is_latest,
|
||||
&value.deleted,
|
||||
&value.transition_status,
|
||||
&value.transitioned_objname,
|
||||
&value.transition_tier,
|
||||
&value.transition_version_id,
|
||||
);
|
||||
if matches!(layout, HistoricalFileInfoLayout::Beta12) {
|
||||
encode_fields!(&value.transition_version, &value.transition_version_state);
|
||||
}
|
||||
encode_fields!(
|
||||
&value.expire_restored,
|
||||
&value.data_dir,
|
||||
&value.mod_time,
|
||||
&value.size,
|
||||
&value.mode,
|
||||
&value.written_by_version,
|
||||
&value.metadata,
|
||||
&value.parts,
|
||||
&value.erasure,
|
||||
&value.mark_deleted,
|
||||
&value.replication_state_internal,
|
||||
&value.data,
|
||||
&value.num_versions,
|
||||
&value.successor_mod_time,
|
||||
&value.fresh,
|
||||
&value.idx,
|
||||
&value.checksum,
|
||||
&value.versioned,
|
||||
&value.uses_legacy_checksum,
|
||||
);
|
||||
encoded
|
||||
}
|
||||
|
||||
fn field_order_file_info() -> FileInfo {
|
||||
let transition_version_id = Uuid::from_u128(12);
|
||||
FileInfo {
|
||||
volume: "volume-sentinel".to_string(),
|
||||
name: "name-sentinel".to_string(),
|
||||
version_id: Some(Uuid::from_u128(11)),
|
||||
is_latest: true,
|
||||
deleted: false,
|
||||
transition_status: "transition-status".to_string(),
|
||||
transitioned_objname: "transitioned-object".to_string(),
|
||||
transition_tier: "transition-tier".to_string(),
|
||||
transition_version_id: Some(transition_version_id),
|
||||
transition_version: Some(transition_version_id.to_string()),
|
||||
transition_version_state: TransitionVersionState::Exact,
|
||||
expire_restored: true,
|
||||
data_dir: Some(Uuid::from_u128(13)),
|
||||
mod_time: Some(OffsetDateTime::UNIX_EPOCH + time::Duration::seconds(14)),
|
||||
size: 15,
|
||||
mode: Some(16),
|
||||
written_by_version: Some(17),
|
||||
metadata: [("metadata-key".to_string(), "metadata-value".to_string())]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
parts: vec![ObjectPartInfo {
|
||||
etag: "part-etag".to_string(),
|
||||
number: 18,
|
||||
size: 19,
|
||||
actual_size: 20,
|
||||
mod_time: Some(OffsetDateTime::UNIX_EPOCH + time::Duration::seconds(21)),
|
||||
index: Some(Bytes::from_static(b"part-index")),
|
||||
checksums: Some(
|
||||
[("part-checksum".to_string(), "checksum-value".to_string())]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
),
|
||||
error: Some("part-error".to_string()),
|
||||
}],
|
||||
erasure: ErasureInfo {
|
||||
algorithm: "erasure-algorithm".to_string(),
|
||||
data_blocks: 2,
|
||||
parity_blocks: 1,
|
||||
block_size: 1024,
|
||||
index: 3,
|
||||
distribution: vec![2, 1, 3],
|
||||
checksums: vec![ChecksumInfo {
|
||||
part_number: 22,
|
||||
algorithm: HashAlgorithm::SHA256,
|
||||
hash: Bytes::from_static(b"erasure-hash"),
|
||||
}],
|
||||
},
|
||||
mark_deleted: false,
|
||||
replication_state_internal: Some(ReplicationState {
|
||||
replicate_decision_str: "replication-decision".to_string(),
|
||||
delete_marker: true,
|
||||
..Default::default()
|
||||
}),
|
||||
data: Some(Bytes::from_static(b"inline-data")),
|
||||
num_versions: 23,
|
||||
successor_mod_time: Some(OffsetDateTime::UNIX_EPOCH + time::Duration::seconds(24)),
|
||||
fresh: true,
|
||||
idx: 25,
|
||||
checksum: Some(Bytes::from_static(b"combined-checksum")),
|
||||
versioned: false,
|
||||
uses_legacy_checksum: true,
|
||||
}
|
||||
}
|
||||
|
||||
const BETA11_FILEINFO_FIXTURE: &[u8] = &[
|
||||
220, 0, 28, 166, 98, 117, 99, 107, 101, 116, 166, 111, 98, 106, 101, 99, 116, 192, 194, 194, 160, 160, 160, 196, 16, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 195, 192, 192, 255, 192, 192, 128, 144, 151, 160, 0, 0, 0, 0, 144, 144, 194,
|
||||
192, 192, 0, 192, 195, 7, 192, 195, 195,
|
||||
];
|
||||
|
||||
const BETA12_FILEINFO_FIXTURE: &[u8] = &[
|
||||
220, 0, 30, 166, 98, 117, 99, 107, 101, 116, 166, 111, 98, 106, 101, 99, 116, 192, 194, 194, 160, 160, 160, 196, 16, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 217, 36, 48, 48, 48, 48, 48, 48, 48, 48, 45, 48, 48, 48, 48, 45, 48, 48, 48,
|
||||
48, 45, 48, 48, 48, 48, 45, 48, 48, 48, 48, 48, 48, 48, 48, 48, 48, 48, 49, 165, 101, 120, 97, 99, 116, 195, 192, 192,
|
||||
255, 192, 192, 128, 144, 151, 160, 0, 0, 0, 0, 144, 144, 194, 192, 192, 0, 192, 195, 7, 192, 195, 195,
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn fileinfo_decodes_beta11_positional_layout() {
|
||||
let expected = positional_compat_file_info();
|
||||
let decoded = FileInfo::unmarshal(BETA11_FILEINFO_FIXTURE).expect("beta.11 positional FileInfo should decode");
|
||||
|
||||
assert_eq!(decoded.volume, expected.volume);
|
||||
assert_eq!(decoded.expire_restored, expected.expire_restored);
|
||||
assert_eq!(decoded.idx, expected.idx);
|
||||
assert_eq!(decoded.transition_version, None);
|
||||
assert_eq!(decoded.transition_version_state, TransitionVersionState::Unknown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fileinfo_decodes_beta12_positional_layout() {
|
||||
let expected = positional_compat_file_info();
|
||||
let decoded = FileInfo::unmarshal(BETA12_FILEINFO_FIXTURE).expect("beta.12 positional FileInfo should decode");
|
||||
|
||||
assert_eq!(decoded, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fileinfo_historical_layouts_preserve_all_field_positions() {
|
||||
let expected = field_order_file_info();
|
||||
let beta12 = encode_historical_file_info(&expected, HistoricalFileInfoLayout::Beta12);
|
||||
assert_eq!(FileInfo::unmarshal(&beta12).expect("beta.12 full-field FileInfo should decode"), expected);
|
||||
|
||||
let beta11 = encode_historical_file_info(&expected, HistoricalFileInfoLayout::Beta11);
|
||||
let mut beta11_expected = expected;
|
||||
beta11_expected.transition_version = None;
|
||||
beta11_expected.transition_version_state = TransitionVersionState::Unknown;
|
||||
assert_eq!(
|
||||
FileInfo::unmarshal(&beta11).expect("beta.11 full-field FileInfo should decode"),
|
||||
beta11_expected
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fileinfo_rejects_unsupported_positional_lengths() {
|
||||
let mut fields_29 = BETA11_FILEINFO_FIXTURE.to_vec();
|
||||
fields_29[2] = 29;
|
||||
fields_29.push(0xc0);
|
||||
let error = FileInfo::unmarshal(&fields_29).expect_err("29-field FileInfo must fail closed");
|
||||
assert!(matches!(error, Error::RmpSerdeDecode(message) if message.contains("invalid length 29")));
|
||||
|
||||
let mut fields_31 = BETA12_FILEINFO_FIXTURE.to_vec();
|
||||
fields_31[2] = 31;
|
||||
fields_31.push(0xc0);
|
||||
let error = FileInfo::unmarshal(&fields_31).expect_err("31-field FileInfo must fail closed");
|
||||
assert!(matches!(error, Error::RmpSerdeDecode(message) if message.contains("invalid length 31")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fileinfo_rejects_truncated_historical_layouts() {
|
||||
let beta11 = &BETA11_FILEINFO_FIXTURE[..BETA11_FILEINFO_FIXTURE.len() - 1];
|
||||
assert!(matches!(FileInfo::unmarshal(beta11), Err(Error::RmpSerdeDecode(_))));
|
||||
|
||||
let state_offset = BETA12_FILEINFO_FIXTURE
|
||||
.windows(6)
|
||||
.position(|window| window == [0xa5, b'e', b'x', b'a', b'c', b't'])
|
||||
.expect("beta.12 fixture should contain the exact transition state");
|
||||
assert!(matches!(
|
||||
FileInfo::unmarshal(&BETA12_FILEINFO_FIXTURE[..state_offset]),
|
||||
Err(Error::RmpSerdeDecode(_))
|
||||
));
|
||||
}
|
||||
|
||||
fn wrap_historical_file_info(fixture: &[u8]) -> Vec<u8> {
|
||||
let mut encoded = Vec::new();
|
||||
rmp::encode::write_array_len(&mut encoded, 5).expect("FileInfoVersions array header should encode");
|
||||
"bucket"
|
||||
.serialize(&mut Serializer::new(&mut encoded))
|
||||
.expect("FileInfoVersions volume should encode");
|
||||
"object"
|
||||
.serialize(&mut Serializer::new(&mut encoded))
|
||||
.expect("FileInfoVersions name should encode");
|
||||
Option::<OffsetDateTime>::None
|
||||
.serialize(&mut Serializer::new(&mut encoded))
|
||||
.expect("FileInfoVersions mod time should encode");
|
||||
rmp::encode::write_array_len(&mut encoded, 1).expect("FileInfoVersions versions header should encode");
|
||||
encoded.extend_from_slice(fixture);
|
||||
rmp::encode::write_array_len(&mut encoded, 0).expect("FileInfoVersions free versions header should encode");
|
||||
encoded
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fileinfo_versions_decodes_nested_historical_layouts() {
|
||||
for fixture in [BETA11_FILEINFO_FIXTURE, BETA12_FILEINFO_FIXTURE] {
|
||||
let encoded = wrap_historical_file_info(fixture);
|
||||
let decoded: FileInfoVersions =
|
||||
rmp_serde::from_slice(&encoded).expect("nested historical FileInfo should decode through FileInfoVersions");
|
||||
assert_eq!(decoded.versions.len(), 1);
|
||||
assert_eq!(decoded.versions[0].name, "object");
|
||||
assert!(decoded.free_versions.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Beta11MapProbe {
|
||||
volume: String,
|
||||
expire_restored: bool,
|
||||
idx: usize,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Beta12MapProbe {
|
||||
transition_version: Option<String>,
|
||||
transition_version_state: TransitionVersionState,
|
||||
expire_restored: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Beta11NestedMapProbe {
|
||||
file_info: Beta11MapProbe,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct NestedFileInfo<'a> {
|
||||
file_info: &'a FileInfo,
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fileinfo_serializes_as_map_readable_by_beta11_and_beta12_shapes() {
|
||||
let expected = positional_compat_file_info();
|
||||
let encoded = expected.marshal_msg().expect("current FileInfo map should encode");
|
||||
let mut cursor = encoded.as_slice();
|
||||
let field_count = usize::try_from(rmp::decode::read_map_len(&mut cursor).expect("FileInfo should start with a map"))
|
||||
.expect("FileInfo map field count should fit usize");
|
||||
assert_eq!(field_count, FILE_INFO_FIELDS.len());
|
||||
|
||||
let beta11: Beta11MapProbe = rmp_serde::from_slice(&encoded).expect("beta.11 field shape should read current map");
|
||||
assert_eq!(beta11.volume, expected.volume);
|
||||
assert_eq!(beta11.expire_restored, expected.expire_restored);
|
||||
assert_eq!(beta11.idx, expected.idx);
|
||||
|
||||
let beta12: Beta12MapProbe = rmp_serde::from_slice(&encoded).expect("beta.12 field shape should read current map");
|
||||
assert_eq!(beta12.transition_version, expected.transition_version);
|
||||
assert_eq!(beta12.transition_version_state, expected.transition_version_state);
|
||||
assert_eq!(beta12.expire_restored, expected.expire_restored);
|
||||
|
||||
let nested = NestedFileInfo { file_info: &expected };
|
||||
let nested_encoded = rmp_serde::to_vec(&nested).expect("nested FileInfo should encode");
|
||||
let nested_beta11: Beta11NestedMapProbe =
|
||||
rmp_serde::from_slice(&nested_encoded).expect("beta.11 field shape should read nested current map");
|
||||
assert_eq!(nested_beta11.file_info.volume, expected.volume);
|
||||
assert_eq!(nested_beta11.file_info.expire_restored, expected.expire_restored);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fileinfo_deserializes_beta11_map_without_transition_fields() {
|
||||
let expected = positional_compat_file_info();
|
||||
let mut value = serde_json::to_value(&expected).expect("current FileInfo should serialize to JSON");
|
||||
let object = value.as_object_mut().expect("FileInfo JSON should be an object");
|
||||
object.remove("transition_version");
|
||||
object.remove("transition_version_state");
|
||||
|
||||
let decoded: FileInfo = serde_json::from_value(value).expect("beta.11 FileInfo JSON should decode");
|
||||
assert_eq!(decoded.volume, expected.volume);
|
||||
assert_eq!(decoded.expire_restored, expected.expire_restored);
|
||||
assert_eq!(decoded.transition_version, None);
|
||||
assert_eq!(decoded.transition_version_state, TransitionVersionState::Unknown);
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#[test]
|
||||
fn fileinfo_msgpack_round_trips(value in file_info_strategy()) {
|
||||
|
||||
@@ -12,6 +12,9 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use crate::replication::{
|
||||
MAX_REPLICATION_TARGET_ARN_LEN, MAX_REPLICATION_TARGET_VERSION_ENTRIES, MAX_REPLICATION_TARGET_VERSION_ID_LEN,
|
||||
};
|
||||
use crate::{
|
||||
ErasureAlgo, ErasureInfo, Error, FileInfo, FileInfoVersions, InlineData, NULL_VERSION_ID, ObjectPartInfo, RawFileInfo,
|
||||
ReplicationState, ReplicationStatusType, Result, VersionPurgeStatusType, is_restored_object_on_disk,
|
||||
@@ -25,13 +28,14 @@ use rustfs_utils::http::headers::{
|
||||
};
|
||||
use rustfs_utils::http::{
|
||||
AMZ_BUCKET_REPLICATION_STATUS, MINIO_INTERNAL_PREFIX, RUSTFS_INTERNAL_PREFIX, SUFFIX_CRC, SUFFIX_DATA_MOV, SUFFIX_HEALING,
|
||||
SUFFIX_PURGESTATUS, SUFFIX_REPLICA_STATUS, SUFFIX_REPLICA_TIMESTAMP, SUFFIX_REPLICATION_RESET, SUFFIX_REPLICATION_STATUS,
|
||||
SUFFIX_REPLICATION_TIMESTAMP, SUFFIX_RESTORE_OPERATION_ID, contains_key_str, has_internal_suffix, insert_bytes,
|
||||
is_internal_key, remove_bytes,
|
||||
SUFFIX_PURGESTATUS, SUFFIX_REPLICA_STATUS, SUFFIX_REPLICA_TIMESTAMP, SUFFIX_REPLICATION_DELETE_MARKER_VERSION_ARN_PREFIX,
|
||||
SUFFIX_REPLICATION_RESET, SUFFIX_REPLICATION_STATUS, SUFFIX_REPLICATION_TIMESTAMP, SUFFIX_RESTORE_OPERATION_ID,
|
||||
contains_key_str, has_internal_suffix, insert_bytes, is_internal_key, remove_bytes,
|
||||
};
|
||||
use s3s::header::X_AMZ_RESTORE;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::cmp::Ordering;
|
||||
use std::collections::BTreeMap;
|
||||
use std::convert::TryFrom;
|
||||
use std::hash::Hasher;
|
||||
use std::io::{Read, Write};
|
||||
@@ -139,6 +143,58 @@ fn cmp_shallow_versions_for_order(a: &FileMetaShallowVersion, b: &FileMetaShallo
|
||||
/// the reset state, and a rustfs-only key was invisible to MinIO-compatible
|
||||
/// readers (backlog#799 B16). Normalize every entry to the canonical
|
||||
/// `replication-reset-<arn>` suffix and write both prefixes.
|
||||
fn valid_target_delete_marker_version(arn: &str, version_id: &str) -> bool {
|
||||
arn.starts_with("arn:")
|
||||
&& arn.len() <= MAX_REPLICATION_TARGET_ARN_LEN
|
||||
&& !version_id.is_empty()
|
||||
&& version_id.len() <= MAX_REPLICATION_TARGET_VERSION_ID_LEN
|
||||
}
|
||||
|
||||
/// Merge-only, never destructive.
|
||||
///
|
||||
/// `ReplicationState::target_delete_marker_version_ids` is skipped by the
|
||||
/// positional `FileInfo` wire form, so a delete that arrives over internode RPC
|
||||
/// carries an empty map. Treating that as authoritative would let a remote disk
|
||||
/// erase an exact target version that the local disk still holds. The key is
|
||||
/// included in the quorum hash, so such a divergence does surface — but as a
|
||||
/// quorum failure on an otherwise healthy object, which is not a state worth
|
||||
/// reaching. Merge the RPC metadata carrier instead, and only ever insert.
|
||||
fn persist_target_delete_marker_versions(
|
||||
meta_sys: &mut HashMap<String, Vec<u8>>,
|
||||
versions: &HashMap<String, String>,
|
||||
transport_metadata: &HashMap<String, String>,
|
||||
) {
|
||||
let mut bounded = BTreeMap::new();
|
||||
// A corrupt carrier means the dual internal prefixes disagreed. Do not merge
|
||||
// anything derived from it: this helper only ever inserts, so declining to
|
||||
// merge leaves whatever durable keys the object already carries untouched,
|
||||
// which is strictly safer than committing a mapping we cannot trust.
|
||||
let (transport_versions, transport_corrupt) = rustfs_utils::http::target_delete_marker_versions(transport_metadata);
|
||||
if transport_corrupt {
|
||||
warn!("delete-marker target version transport metadata is inconsistent; leaving the persisted mapping unchanged");
|
||||
return;
|
||||
}
|
||||
for (arn, version_id) in transport_versions.iter().chain(versions.iter()) {
|
||||
if !valid_target_delete_marker_version(arn, version_id)
|
||||
|| (bounded.len() >= MAX_REPLICATION_TARGET_VERSION_ENTRIES
|
||||
&& bounded.last_key_value().is_some_and(|(largest, _)| arn >= *largest))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
bounded.insert(arn, version_id);
|
||||
if bounded.len() > MAX_REPLICATION_TARGET_VERSION_ENTRIES {
|
||||
bounded.pop_last();
|
||||
}
|
||||
}
|
||||
for (arn, version_id) in bounded {
|
||||
insert_bytes(
|
||||
meta_sys,
|
||||
&format!("{SUFFIX_REPLICATION_DELETE_MARKER_VERSION_ARN_PREFIX}{arn}"),
|
||||
version_id.as_bytes().to_vec(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn persist_reset_statuses(meta_sys: &mut HashMap<String, Vec<u8>>, reset_statuses_map: &HashMap<String, String>) {
|
||||
for (k, v) in reset_statuses_map {
|
||||
let suffix = k
|
||||
@@ -521,6 +577,11 @@ impl FileMeta {
|
||||
&& let Some(state) = fi.replication_state_internal.as_ref()
|
||||
{
|
||||
persist_reset_statuses(&mut delete_marker.meta_sys, &state.reset_statuses_map);
|
||||
persist_target_delete_marker_versions(
|
||||
&mut delete_marker.meta_sys,
|
||||
&state.target_delete_marker_version_ids,
|
||||
&fi.metadata,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -597,6 +658,11 @@ impl FileMeta {
|
||||
|
||||
if let Some(state) = fi.replication_state_internal.as_ref() {
|
||||
persist_reset_statuses(&mut delete_marker.meta_sys, &state.reset_statuses_map);
|
||||
persist_target_delete_marker_versions(
|
||||
&mut delete_marker.meta_sys,
|
||||
&state.target_delete_marker_version_ids,
|
||||
&fi.metadata,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1278,6 +1344,90 @@ mod test {
|
||||
/// persisted under both internal prefixes, never as a bare ARN. A bare-ARN
|
||||
/// key (produced by `ObjectInfo::replication_state`) has no internal prefix,
|
||||
/// so read-back — which only recognizes prefixed keys — silently dropped it.
|
||||
#[test]
|
||||
fn persist_target_delete_marker_versions_uses_bounded_dual_prefixed_keys() {
|
||||
let arn = "arn:rustfs:replication::target:bucket";
|
||||
let version_id = "opaque-target-version";
|
||||
let suffix = format!("{SUFFIX_REPLICATION_DELETE_MARKER_VERSION_ARN_PREFIX}{arn}");
|
||||
let versions = HashMap::from([
|
||||
(arn.to_string(), version_id.to_string()),
|
||||
("not-an-arn".to_string(), "ignored".to_string()),
|
||||
("arn:too-long".to_string(), "x".repeat(MAX_REPLICATION_TARGET_VERSION_ID_LEN + 1)),
|
||||
]);
|
||||
let mut meta_sys = HashMap::new();
|
||||
|
||||
persist_target_delete_marker_versions(&mut meta_sys, &versions, &HashMap::new());
|
||||
|
||||
assert_eq!(
|
||||
meta_sys.get(&format!("{RUSTFS_INTERNAL_PREFIX}{suffix}")).map(Vec::as_slice),
|
||||
Some(version_id.as_bytes())
|
||||
);
|
||||
assert_eq!(
|
||||
meta_sys.get(&format!("{MINIO_INTERNAL_PREFIX}{suffix}")).map(Vec::as_slice),
|
||||
Some(version_id.as_bytes())
|
||||
);
|
||||
assert_eq!(meta_sys.len(), 2, "invalid or oversized mappings must not expand xl.meta");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persist_target_delete_marker_versions_caps_target_count() {
|
||||
let versions = (0..=MAX_REPLICATION_TARGET_VERSION_ENTRIES)
|
||||
.map(|index| (format!("arn:rustfs:replication::target:{index:04}"), format!("version-{index}")))
|
||||
.collect();
|
||||
let mut meta_sys = HashMap::new();
|
||||
|
||||
persist_target_delete_marker_versions(&mut meta_sys, &versions, &HashMap::new());
|
||||
|
||||
assert_eq!(meta_sys.len(), MAX_REPLICATION_TARGET_VERSION_ENTRIES * 2);
|
||||
let excluded_suffix = format!(
|
||||
"{SUFFIX_REPLICATION_DELETE_MARKER_VERSION_ARN_PREFIX}arn:rustfs:replication::target:{MAX_REPLICATION_TARGET_VERSION_ENTRIES:04}"
|
||||
);
|
||||
assert!(!meta_sys.contains_key(&format!("{RUSTFS_INTERNAL_PREFIX}{excluded_suffix}")));
|
||||
assert!(!meta_sys.contains_key(&format!("{MINIO_INTERNAL_PREFIX}{excluded_suffix}")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persist_target_delete_marker_versions_preserves_existing_targets_on_empty_update() {
|
||||
let stale_suffix = format!("{SUFFIX_REPLICATION_DELETE_MARKER_VERSION_ARN_PREFIX}arn:rustfs:replication::target:stale");
|
||||
let mut meta_sys = HashMap::from([
|
||||
(format!("{RUSTFS_INTERNAL_PREFIX}{stale_suffix}"), b"stale-rustfs".to_vec()),
|
||||
(format!("{MINIO_INTERNAL_PREFIX}{stale_suffix}"), b"stale-minio".to_vec()),
|
||||
("unrelated".to_string(), b"kept".to_vec()),
|
||||
]);
|
||||
|
||||
persist_target_delete_marker_versions(&mut meta_sys, &HashMap::new(), &HashMap::new());
|
||||
|
||||
assert_eq!(
|
||||
meta_sys,
|
||||
HashMap::from([
|
||||
(format!("{RUSTFS_INTERNAL_PREFIX}{stale_suffix}"), b"stale-rustfs".to_vec()),
|
||||
(format!("{MINIO_INTERNAL_PREFIX}{stale_suffix}"), b"stale-minio".to_vec()),
|
||||
("unrelated".to_string(), b"kept".to_vec()),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persist_target_delete_marker_versions_reads_rpc_transport_metadata() {
|
||||
let arn = "arn:rustfs:replication::target:remote";
|
||||
let version_id = "opaque-remote-version";
|
||||
let suffix = format!("{SUFFIX_REPLICATION_DELETE_MARKER_VERSION_ARN_PREFIX}{arn}");
|
||||
let mut transport_metadata = HashMap::new();
|
||||
rustfs_utils::http::insert_str(&mut transport_metadata, &suffix, version_id.to_string());
|
||||
let mut meta_sys = HashMap::new();
|
||||
|
||||
persist_target_delete_marker_versions(&mut meta_sys, &HashMap::new(), &transport_metadata);
|
||||
|
||||
assert_eq!(
|
||||
meta_sys.get(&format!("{RUSTFS_INTERNAL_PREFIX}{suffix}")).map(Vec::as_slice),
|
||||
Some(version_id.as_bytes())
|
||||
);
|
||||
assert_eq!(
|
||||
meta_sys.get(&format!("{MINIO_INTERNAL_PREFIX}{suffix}")).map(Vec::as_slice),
|
||||
Some(version_id.as_bytes())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persist_reset_statuses_normalizes_to_dual_prefixed_keys() {
|
||||
let arn = "arn:rustfs:replication::target:bucket";
|
||||
|
||||
@@ -33,7 +33,7 @@ use rustfs_utils::http::{
|
||||
SUFFIX_TIER_FV_MARKER, SUFFIX_TRANSITION_STATUS, SUFFIX_TRANSITION_TIER, SUFFIX_TRANSITION_TIER_DESTINATION_ID,
|
||||
SUFFIX_TRANSITIONED_OBJECTNAME, SUFFIX_TRANSITIONED_VERSION_ID, SUFFIX_TRANSITIONED_VERSION_STATE, contains_key_bytes,
|
||||
get_bytes, get_consistent_bytes, get_str, has_internal_suffix, insert_bytes, is_internal_key, remove_bytes,
|
||||
strip_internal_prefix,
|
||||
strip_internal_prefix, target_delete_marker_versions,
|
||||
};
|
||||
|
||||
const MSGPACK_EXT8: u8 = 0xc7;
|
||||
@@ -2725,6 +2725,15 @@ fn get_internal_replication_state(metadata: &HashMap<String, String>) -> Option<
|
||||
}
|
||||
}
|
||||
|
||||
// Re-derive the per-target delete-marker versions from the durable keys.
|
||||
// `ReplicationState` skips this map on the wire, so the metadata is the only
|
||||
// authority. `corrupt` means the dual internal prefixes disagreed: surface it
|
||||
// rather than guessing, so callers fail closed instead of purging the wrong
|
||||
// target version.
|
||||
(rs.target_delete_marker_version_ids, rs.target_delete_marker_version_ids_corrupt) = target_delete_marker_versions(metadata);
|
||||
has |= !rs.target_delete_marker_version_ids.is_empty();
|
||||
has |= rs.target_delete_marker_version_ids_corrupt;
|
||||
|
||||
if has { Some(rs) } else { None }
|
||||
}
|
||||
|
||||
@@ -4725,6 +4734,47 @@ mod tests {
|
||||
assert!(dm.mod_time.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_delete_marker_version_metadata_is_forward_and_backward_compatible() {
|
||||
let arn = "arn:rustfs:replication:us-east-1:target:bucket";
|
||||
let suffix = format!("{}{arn}", rustfs_utils::http::SUFFIX_REPLICATION_DELETE_MARKER_VERSION_ARN_PREFIX);
|
||||
let mut metadata = HashMap::from([(format!("{RUSTFS_INTERNAL_PREFIX}replication-status"), format!("{arn}=COMPLETED;"))]);
|
||||
|
||||
let old = get_internal_replication_state(&metadata).expect("legacy replication metadata should parse");
|
||||
assert!(
|
||||
old.target_delete_marker_version_ids.is_empty(),
|
||||
"a new reader must treat the missing legacy field as empty"
|
||||
);
|
||||
|
||||
metadata.insert(format!("{}{suffix}", rustfs_utils::http::MINIO_INTERNAL_PREFIX), "target-id".to_string());
|
||||
metadata.insert(format!("{RUSTFS_INTERNAL_PREFIX}{suffix}"), "target-id".to_string());
|
||||
let current = get_internal_replication_state(&metadata).expect("new replication metadata should parse");
|
||||
assert_eq!(
|
||||
current.target_delete_marker_version_ids.get(arn).map(String::as_str),
|
||||
Some("target-id"),
|
||||
"matching dual-prefix values must remain readable"
|
||||
);
|
||||
assert_eq!(
|
||||
current.targets.get(arn),
|
||||
Some(&ReplicationStatusType::Completed),
|
||||
"the added metadata key must not disturb fields understood by old nodes"
|
||||
);
|
||||
|
||||
metadata.insert(
|
||||
format!("{}{suffix}", rustfs_utils::http::MINIO_INTERNAL_PREFIX),
|
||||
"conflicting-id".to_string(),
|
||||
);
|
||||
let conflicted = get_internal_replication_state(&metadata).expect("replication status should still parse");
|
||||
assert!(
|
||||
conflicted.target_delete_marker_version_ids.is_empty(),
|
||||
"a destructive version ID must fail closed when the dual prefixes disagree"
|
||||
);
|
||||
assert!(
|
||||
conflicted.target_delete_marker_version_ids_corrupt,
|
||||
"a dual-prefix conflict must remain distinguishable from missing legacy metadata"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_internal_replication_state_keeps_canonical_reset_key() {
|
||||
// The reset status is stored on disk under the full internal key; parsing
|
||||
|
||||
@@ -227,6 +227,13 @@ impl From<&str> for ReplicationType {
|
||||
}
|
||||
|
||||
/// ReplicationState represents internal replication state
|
||||
/// Bounds on the per-target delete-marker version map. The map is rebuilt from
|
||||
/// attacker-influenced object metadata, so cap the entry count and both string
|
||||
/// lengths rather than trusting what was persisted.
|
||||
pub(crate) const MAX_REPLICATION_TARGET_VERSION_ENTRIES: usize = 1_000;
|
||||
pub(crate) const MAX_REPLICATION_TARGET_ARN_LEN: usize = 1_024;
|
||||
pub(crate) const MAX_REPLICATION_TARGET_VERSION_ID_LEN: usize = 1_024;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
|
||||
pub struct ReplicationState {
|
||||
pub replica_timestamp: Option<OffsetDateTime>,
|
||||
@@ -239,6 +246,16 @@ pub struct ReplicationState {
|
||||
pub targets: HashMap<String, ReplicationStatusType>,
|
||||
pub purge_targets: HashMap<String, VersionPurgeStatusType>,
|
||||
pub reset_statuses_map: HashMap<String, String>,
|
||||
/// Exact version id the delete marker got on each replication target, keyed
|
||||
/// by target ARN. Skipped by serde: `ReplicationState` has a positional wire
|
||||
/// form, so this travels in the object's internal metadata instead and is
|
||||
/// re-derived on read. See `persist_target_delete_marker_versions`.
|
||||
#[serde(skip)]
|
||||
pub target_delete_marker_version_ids: HashMap<String, String>,
|
||||
/// Set when the persisted keys disagreed across the dual internal prefixes,
|
||||
/// so callers fail closed instead of purging the wrong target version.
|
||||
#[serde(skip)]
|
||||
pub target_delete_marker_version_ids_corrupt: bool,
|
||||
}
|
||||
|
||||
impl ReplicationState {
|
||||
@@ -311,6 +328,7 @@ impl ReplicationState {
|
||||
arn: arn.to_string(),
|
||||
prev_replication_status: self.targets.get(arn).cloned().unwrap_or_default(),
|
||||
version_purge_status: self.purge_targets.get(arn).cloned().unwrap_or_default(),
|
||||
target_delete_marker_version_id: self.target_delete_marker_version_ids.get(arn).cloned(),
|
||||
resync_timestamp,
|
||||
..Default::default()
|
||||
}
|
||||
@@ -414,6 +432,8 @@ pub struct ReplicatedTargetInfo {
|
||||
pub endpoint: String,
|
||||
pub secure: bool,
|
||||
pub error: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub target_delete_marker_version_id: Option<String>,
|
||||
}
|
||||
|
||||
impl ReplicatedTargetInfo {
|
||||
@@ -926,6 +946,36 @@ pub fn get_replication_state(rinfos: &ReplicatedInfos, prev_state: &ReplicationS
|
||||
reset_statuses_map.insert(key, value);
|
||||
}
|
||||
|
||||
// Carry the previously recorded per-target delete-marker versions forward,
|
||||
// dropping anything that no longer satisfies the bounds, then fold in the
|
||||
// versions this round's targets reported. A map that has already grown past
|
||||
// the cap is discarded rather than trusted.
|
||||
let mut target_delete_marker_version_ids = prev_state.target_delete_marker_version_ids.clone();
|
||||
target_delete_marker_version_ids.retain(|arn, version_id| {
|
||||
!arn.is_empty()
|
||||
&& arn.len() <= MAX_REPLICATION_TARGET_ARN_LEN
|
||||
&& !version_id.is_empty()
|
||||
&& version_id.len() <= MAX_REPLICATION_TARGET_VERSION_ID_LEN
|
||||
});
|
||||
if target_delete_marker_version_ids.len() > MAX_REPLICATION_TARGET_VERSION_ENTRIES {
|
||||
target_delete_marker_version_ids.clear();
|
||||
}
|
||||
for target in &rinfos.targets {
|
||||
let Some(version_id) = target.target_delete_marker_version_id.as_ref() else {
|
||||
continue;
|
||||
};
|
||||
if (!target_delete_marker_version_ids.contains_key(&target.arn)
|
||||
&& target_delete_marker_version_ids.len() >= MAX_REPLICATION_TARGET_VERSION_ENTRIES)
|
||||
|| target.arn.is_empty()
|
||||
|| target.arn.len() > MAX_REPLICATION_TARGET_ARN_LEN
|
||||
|| version_id.is_empty()
|
||||
|| version_id.len() > MAX_REPLICATION_TARGET_VERSION_ID_LEN
|
||||
{
|
||||
continue;
|
||||
}
|
||||
target_delete_marker_version_ids.insert(target.arn.clone(), version_id.clone());
|
||||
}
|
||||
|
||||
ReplicationState {
|
||||
replicate_decision_str: prev_state.replicate_decision_str.clone(),
|
||||
reset_statuses_map,
|
||||
@@ -936,6 +986,8 @@ pub fn get_replication_state(rinfos: &ReplicatedInfos, prev_state: &ReplicationS
|
||||
replication_timestamp: rinfos.replication_timestamp,
|
||||
purge_targets,
|
||||
version_purge_status_internal: vpurge_statuses,
|
||||
target_delete_marker_version_ids,
|
||||
target_delete_marker_version_ids_corrupt: prev_state.target_delete_marker_version_ids_corrupt,
|
||||
|
||||
..Default::default()
|
||||
}
|
||||
|
||||
@@ -107,13 +107,21 @@ impl Error {
|
||||
Error::TaskTimeout | Error::TransientSkip { .. } => true,
|
||||
Error::Storage(err) => {
|
||||
err.is_quorum_error()
|
||||
|| matches!(err, EcstoreError::SlowDown | EcstoreError::OperationCanceled | EcstoreError::Lock(_))
|
||||
|| matches!(
|
||||
err,
|
||||
EcstoreError::DiskNotFound
|
||||
| EcstoreError::VolumeNotFound
|
||||
| EcstoreError::SlowDown
|
||||
| EcstoreError::OperationCanceled
|
||||
| EcstoreError::Lock(_)
|
||||
)
|
||||
|| is_recoverable_heal_error_message(&err.to_string())
|
||||
}
|
||||
Error::Disk(err) => {
|
||||
matches!(
|
||||
err,
|
||||
DiskError::ErasureReadQuorum
|
||||
DiskError::DiskNotFound
|
||||
| DiskError::ErasureReadQuorum
|
||||
| DiskError::ErasureWriteQuorum
|
||||
| DiskError::Timeout
|
||||
| DiskError::SourceStalled
|
||||
@@ -159,7 +167,7 @@ impl From<Error> for std::io::Error {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::Error;
|
||||
use crate::heal::EcstoreError;
|
||||
use crate::heal::{DiskError, EcstoreError};
|
||||
|
||||
#[test]
|
||||
fn incomplete_target_rename_is_recoverable() {
|
||||
@@ -173,4 +181,11 @@ mod tests {
|
||||
assert!(task_error.is_recoverable_heal());
|
||||
assert!(storage_error.is_recoverable_heal());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offline_disk_errors_are_recoverable() {
|
||||
assert!(Error::Disk(DiskError::DiskNotFound).is_recoverable_heal());
|
||||
assert!(Error::Storage(EcstoreError::DiskNotFound).is_recoverable_heal());
|
||||
assert!(Error::Storage(EcstoreError::VolumeNotFound).is_recoverable_heal());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -623,6 +623,7 @@ impl HealChannelProcessor {
|
||||
update_parity: request.update_parity.unwrap_or(true),
|
||||
recursive,
|
||||
dry_run: request.dry_run.unwrap_or(false),
|
||||
no_lock: request.no_lock.unwrap_or(false),
|
||||
timeout: request.timeout_seconds.map(std::time::Duration::from_secs),
|
||||
pool_index: request.pool_index,
|
||||
set_index: request.set_index,
|
||||
@@ -852,6 +853,7 @@ mod tests {
|
||||
update_parity: None,
|
||||
recursive: None,
|
||||
dry_run: None,
|
||||
no_lock: None,
|
||||
timeout_seconds: None,
|
||||
pool_index: None,
|
||||
set_index: None,
|
||||
@@ -883,6 +885,7 @@ mod tests {
|
||||
update_parity: Some(true),
|
||||
recursive: Some(true),
|
||||
dry_run: Some(false),
|
||||
no_lock: None,
|
||||
timeout_seconds: None,
|
||||
pool_index: None,
|
||||
set_index: None,
|
||||
@@ -914,6 +917,7 @@ mod tests {
|
||||
update_parity: Some(true),
|
||||
recursive: Some(false),
|
||||
dry_run: Some(false),
|
||||
no_lock: Some(true),
|
||||
timeout_seconds: Some(300),
|
||||
pool_index: Some(0),
|
||||
set_index: Some(1),
|
||||
@@ -928,6 +932,7 @@ mod tests {
|
||||
assert_eq!(heal_request.options.scan_mode, HealScanMode::Deep);
|
||||
assert!(heal_request.options.remove_corrupted);
|
||||
assert!(heal_request.options.recreate_missing);
|
||||
assert!(heal_request.options.no_lock);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -948,6 +953,7 @@ mod tests {
|
||||
update_parity: None,
|
||||
recursive: Some(false),
|
||||
dry_run: None,
|
||||
no_lock: None,
|
||||
timeout_seconds: None,
|
||||
pool_index: None,
|
||||
set_index: None,
|
||||
@@ -984,6 +990,7 @@ mod tests {
|
||||
update_parity: None,
|
||||
recursive: Some(false),
|
||||
dry_run: None,
|
||||
no_lock: None,
|
||||
timeout_seconds: None,
|
||||
pool_index: None,
|
||||
set_index: None,
|
||||
@@ -1022,6 +1029,7 @@ mod tests {
|
||||
update_parity: None,
|
||||
recursive: Some(false),
|
||||
dry_run: None,
|
||||
no_lock: None,
|
||||
timeout_seconds: None,
|
||||
pool_index: None,
|
||||
set_index: None,
|
||||
@@ -1053,6 +1061,7 @@ mod tests {
|
||||
update_parity: Some(true),
|
||||
recursive: Some(true),
|
||||
dry_run: Some(false),
|
||||
no_lock: None,
|
||||
timeout_seconds: None,
|
||||
pool_index: None,
|
||||
set_index: None,
|
||||
@@ -1087,6 +1096,7 @@ mod tests {
|
||||
update_parity: None,
|
||||
recursive: None,
|
||||
dry_run: None,
|
||||
no_lock: None,
|
||||
timeout_seconds: None,
|
||||
pool_index: None,
|
||||
set_index: None,
|
||||
@@ -1117,6 +1127,7 @@ mod tests {
|
||||
update_parity: None,
|
||||
recursive: None,
|
||||
dry_run: None,
|
||||
no_lock: None,
|
||||
timeout_seconds: None,
|
||||
pool_index: None,
|
||||
set_index: None,
|
||||
@@ -1154,6 +1165,7 @@ mod tests {
|
||||
update_parity: None,
|
||||
recursive: None,
|
||||
dry_run: None,
|
||||
no_lock: None,
|
||||
timeout_seconds: None,
|
||||
pool_index: None,
|
||||
set_index: None,
|
||||
@@ -1184,6 +1196,7 @@ mod tests {
|
||||
update_parity: Some(false),
|
||||
recursive: None,
|
||||
dry_run: None,
|
||||
no_lock: None,
|
||||
timeout_seconds: None,
|
||||
pool_index: None,
|
||||
set_index: None,
|
||||
@@ -1216,6 +1229,7 @@ mod tests {
|
||||
update_parity: None,
|
||||
recursive: None,
|
||||
dry_run: None,
|
||||
no_lock: None,
|
||||
timeout_seconds: None,
|
||||
pool_index: None,
|
||||
set_index: None,
|
||||
@@ -1252,6 +1266,7 @@ mod tests {
|
||||
update_parity: None,
|
||||
recursive: None,
|
||||
dry_run: None,
|
||||
no_lock: None,
|
||||
timeout_seconds: None,
|
||||
pool_index: None,
|
||||
set_index: None,
|
||||
@@ -1529,6 +1544,7 @@ mod tests {
|
||||
update_parity: None,
|
||||
recursive: None,
|
||||
dry_run: None,
|
||||
no_lock: None,
|
||||
timeout_seconds: None,
|
||||
pool_index: None,
|
||||
set_index: None,
|
||||
|
||||
@@ -43,6 +43,34 @@ enum HealObjectOutcome {
|
||||
Failed,
|
||||
}
|
||||
|
||||
struct PageConcurrencyGuard {
|
||||
in_flight: Arc<AtomicUsize>,
|
||||
set_label: String,
|
||||
}
|
||||
|
||||
impl PageConcurrencyGuard {
|
||||
fn new(in_flight: Arc<AtomicUsize>, set_label: String) -> Self {
|
||||
let current = in_flight.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
gauge!(
|
||||
"rustfs_heal_page_concurrency_current",
|
||||
"set" => set_label.clone()
|
||||
)
|
||||
.set(current as f64);
|
||||
Self { in_flight, set_label }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for PageConcurrencyGuard {
|
||||
fn drop(&mut self) {
|
||||
let current = self.in_flight.fetch_sub(1, Ordering::SeqCst) - 1;
|
||||
gauge!(
|
||||
"rustfs_heal_page_concurrency_current",
|
||||
"set" => self.set_label.clone()
|
||||
)
|
||||
.set(current as f64);
|
||||
}
|
||||
}
|
||||
|
||||
const LOG_COMPONENT_HEAL: &str = "heal";
|
||||
const LOG_SUBSYSTEM_ERASURE_HEALER: &str = "erasure_healer";
|
||||
const EVENT_HEAL_ERASURE_RESUME_STATE: &str = "heal_erasure_resume_state";
|
||||
@@ -183,35 +211,13 @@ impl ErasureSetHealer {
|
||||
.execute_heal_with_resume(buckets, set_disk_id, &resume_manager, &checkpoint_manager)
|
||||
.await;
|
||||
|
||||
// 4. cleanup resume state
|
||||
if result.is_ok() {
|
||||
if let Err(e) = resume_manager.cleanup().await {
|
||||
warn!(
|
||||
target: "rustfs::heal::erasure_healer",
|
||||
event = EVENT_HEAL_ERASURE_RESUME_STATE,
|
||||
component = LOG_COMPONENT_HEAL,
|
||||
subsystem = LOG_SUBSYSTEM_ERASURE_HEALER,
|
||||
set_disk_id,
|
||||
state = "resume_cleanup_failed",
|
||||
error = %e,
|
||||
"Erasure set resume cleanup failed"
|
||||
);
|
||||
}
|
||||
if let Err(e) = checkpoint_manager.cleanup().await {
|
||||
warn!(
|
||||
target: "rustfs::heal::erasure_healer",
|
||||
event = EVENT_HEAL_ERASURE_RESUME_STATE,
|
||||
component = LOG_COMPONENT_HEAL,
|
||||
subsystem = LOG_SUBSYSTEM_ERASURE_HEALER,
|
||||
set_disk_id,
|
||||
state = "checkpoint_cleanup_failed",
|
||||
error = %e,
|
||||
"Erasure set checkpoint cleanup failed"
|
||||
);
|
||||
}
|
||||
}
|
||||
result?;
|
||||
|
||||
result
|
||||
// The healing marker is cleared by the caller only after both cleanup
|
||||
// operations succeed. Cleanup is idempotent, so a retry is safe.
|
||||
checkpoint_manager.cleanup().await?;
|
||||
resume_manager.cleanup().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// get or create task id
|
||||
@@ -223,7 +229,10 @@ impl ErasureSetHealer {
|
||||
match ResumeManager::load_from_disk(self.disk.clone(), &task_id).await {
|
||||
Ok(manager) => {
|
||||
let state = manager.get_state().await;
|
||||
if state.set_disk_id == set_disk_id && ResumeUtils::can_resume_task(&self.disk, &task_id).await {
|
||||
if !state.completed
|
||||
&& state.set_disk_id == set_disk_id
|
||||
&& ResumeUtils::can_resume_task(&self.disk, &task_id).await
|
||||
{
|
||||
debug!(
|
||||
target: "rustfs::heal::erasure_healer",
|
||||
event = EVENT_HEAL_ERASURE_RESUME_STATE,
|
||||
@@ -295,6 +304,21 @@ impl ErasureSetHealer {
|
||||
CheckpointManager::new(self.disk.clone(), task_id.to_string()).await?
|
||||
};
|
||||
|
||||
let state = resume_manager.get_state().await;
|
||||
if state.retry_count > 0
|
||||
&& state.completed_buckets.is_empty()
|
||||
&& state.resume_cursor.is_none()
|
||||
&& state.processed_objects == 0
|
||||
&& state.successful_objects == 0
|
||||
&& state.failed_objects == 0
|
||||
&& state.skipped_objects == 0
|
||||
{
|
||||
// schedule_retry persists the authoritative resume reset before
|
||||
// resetting the checkpoint. Reapply the checkpoint reset after
|
||||
// a crash in that window so stale positions cannot skip work.
|
||||
checkpoint_manager.reset_for_retry().await?;
|
||||
}
|
||||
|
||||
Ok((resume_manager, checkpoint_manager))
|
||||
} else {
|
||||
debug!(
|
||||
@@ -358,6 +382,7 @@ impl ErasureSetHealer {
|
||||
let mut successful_objects = state.successful_objects;
|
||||
let mut failed_objects = state.failed_objects;
|
||||
let mut skipped_objects = state.skipped_objects;
|
||||
let mut failed_buckets = 0u64;
|
||||
|
||||
// 4. process remaining buckets
|
||||
for (bucket_idx, bucket) in buckets.iter().enumerate().skip(current_bucket_index) {
|
||||
@@ -385,6 +410,10 @@ impl ErasureSetHealer {
|
||||
)
|
||||
.await;
|
||||
|
||||
if matches!(bucket_result, Err(Error::TaskCancelled | Error::TaskTimeout)) {
|
||||
return bucket_result;
|
||||
}
|
||||
|
||||
// update checkpoint position
|
||||
checkpoint_manager.update_position(bucket_idx, current_object_index).await?;
|
||||
|
||||
@@ -422,7 +451,9 @@ impl ErasureSetHealer {
|
||||
"Erasure set bucket completed"
|
||||
);
|
||||
}
|
||||
Err(err @ Error::TaskCancelled) | Err(err @ Error::TaskTimeout) => return Err(err),
|
||||
Err(e) => {
|
||||
failed_buckets = failed_buckets.saturating_add(1);
|
||||
error!(
|
||||
target: "rustfs::heal::erasure_healer",
|
||||
event = EVENT_HEAL_ERASURE_BUCKET_STATE,
|
||||
@@ -453,7 +484,7 @@ impl ErasureSetHealer {
|
||||
// skip may be because the disk is still down, so these are deferred to a
|
||||
// later heal cycle via the same bounded-retry mechanism as failures —
|
||||
// never hot-retried in place here.
|
||||
if failed_objects > 0 || skipped_objects > 0 {
|
||||
if failed_objects > 0 || skipped_objects > 0 || failed_buckets > 0 {
|
||||
if resume_manager.schedule_retry().await? {
|
||||
// Both persistence layers must be reset together: schedule_retry
|
||||
// rewinds the resume state (cursor + counters), and the
|
||||
@@ -471,13 +502,14 @@ impl ErasureSetHealer {
|
||||
component = LOG_COMPONENT_HEAL,
|
||||
subsystem = LOG_SUBSYSTEM_ERASURE_HEALER,
|
||||
set_disk_id,
|
||||
failed_buckets,
|
||||
failed_objects,
|
||||
skipped_objects,
|
||||
state = "retry_scheduled",
|
||||
"Erasure set heal pass finished with unhealed versions; scheduled full re-heal retry"
|
||||
);
|
||||
return Err(Error::other(format!(
|
||||
"Erasure set heal incomplete: {failed_objects} failed, {skipped_objects} skipped object(s); retry scheduled"
|
||||
"Erasure set heal incomplete: {failed_buckets} bucket(s) failed, {failed_objects} object(s) failed, {skipped_objects} object(s) skipped; retry scheduled"
|
||||
)));
|
||||
}
|
||||
|
||||
@@ -491,15 +523,16 @@ impl ErasureSetHealer {
|
||||
component = LOG_COMPONENT_HEAL,
|
||||
subsystem = LOG_SUBSYSTEM_ERASURE_HEALER,
|
||||
set_disk_id,
|
||||
failed_buckets,
|
||||
failed_objects,
|
||||
skipped_objects,
|
||||
state = "failed_after_retries",
|
||||
"Erasure set heal exhausted retries with unrecovered versions"
|
||||
);
|
||||
let _ = resume_manager.cleanup().await;
|
||||
let _ = checkpoint_manager.cleanup().await;
|
||||
checkpoint_manager.cleanup().await?;
|
||||
resume_manager.cleanup().await?;
|
||||
return Err(Error::other(format!(
|
||||
"Erasure set heal exhausted retries with {failed_objects} failed, {skipped_objects} skipped object(s)"
|
||||
"Erasure set heal exhausted retries with {failed_buckets} bucket(s) failed, {failed_objects} object(s) failed, {skipped_objects} object(s) skipped"
|
||||
)));
|
||||
}
|
||||
|
||||
@@ -646,12 +679,7 @@ impl ErasureSetHealer {
|
||||
Err(err) => return (dedup_key, object_name, version_id, Err(err)),
|
||||
};
|
||||
|
||||
let current_in_flight = in_flight.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
gauge!(
|
||||
"rustfs_heal_page_concurrency_current",
|
||||
"set" => set_label.clone()
|
||||
)
|
||||
.set(current_in_flight as f64);
|
||||
let _in_flight_guard = PageConcurrencyGuard::new(in_flight, set_label);
|
||||
|
||||
// Always go through heal_object. Genuine absence flows through
|
||||
// heal_object -> FileVersionNotFound/FileNotFound ->
|
||||
@@ -677,13 +705,6 @@ impl ErasureSetHealer {
|
||||
}
|
||||
};
|
||||
|
||||
let current = in_flight.fetch_sub(1, Ordering::SeqCst) - 1;
|
||||
gauge!(
|
||||
"rustfs_heal_page_concurrency_current",
|
||||
"set" => set_label.clone()
|
||||
)
|
||||
.set(current as f64);
|
||||
|
||||
(dedup_key, object_name, version_id, result)
|
||||
});
|
||||
}
|
||||
@@ -723,14 +744,7 @@ impl ErasureSetHealer {
|
||||
"Erasure set missing object treated as ok"
|
||||
);
|
||||
}
|
||||
Err(Error::TaskCancelled) => {
|
||||
gauge!(
|
||||
"rustfs_heal_page_concurrency_current",
|
||||
"set" => set_disk_id.to_string()
|
||||
)
|
||||
.set(0.0);
|
||||
return Err(Error::TaskCancelled);
|
||||
}
|
||||
Err(err @ Error::TaskCancelled) | Err(err @ Error::TaskTimeout) => return Err(err),
|
||||
Err(Error::TransientSkip { message }) => {
|
||||
*skipped_objects += 1;
|
||||
checkpoint_manager.add_skipped_object(key).await?;
|
||||
@@ -783,12 +797,6 @@ impl ErasureSetHealer {
|
||||
let next_cursor = if is_truncated { next_token.clone() } else { None };
|
||||
resume_manager.set_resume_cursor(next_cursor.clone()).await?;
|
||||
checkpoint_manager.complete_page(bucket_index, *current_object_index).await?;
|
||||
gauge!(
|
||||
"rustfs_heal_page_concurrency_current",
|
||||
"set" => set_disk_id.to_string()
|
||||
)
|
||||
.set(0.0);
|
||||
|
||||
// Check if there are more pages
|
||||
if !is_truncated {
|
||||
break;
|
||||
@@ -835,8 +843,30 @@ impl ErasureSetHealer {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::ErasureSetHealer;
|
||||
use super::{ErasureSetHealer, PageConcurrencyGuard};
|
||||
use rustfs_common::heal_channel::{HealRequestSource, HealScanMode};
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
};
|
||||
|
||||
#[tokio::test]
|
||||
async fn dropping_pending_page_heal_releases_concurrency_slot() {
|
||||
let in_flight = Arc::new(AtomicUsize::new(0));
|
||||
let mut pending_heal = Box::pin({
|
||||
let in_flight = in_flight.clone();
|
||||
async move {
|
||||
let _guard = PageConcurrencyGuard::new(in_flight, "pool_0_set_0".to_string());
|
||||
std::future::pending::<()>().await;
|
||||
}
|
||||
});
|
||||
|
||||
assert!(futures::poll!(pending_heal.as_mut()).is_pending());
|
||||
assert_eq!(in_flight.load(Ordering::SeqCst), 1);
|
||||
|
||||
drop(pending_heal);
|
||||
assert_eq!(in_flight.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heal_page_object_concurrency_uses_default_when_env_is_unset() {
|
||||
@@ -979,7 +1009,7 @@ mod resume_loop_tests {
|
||||
//! handling) — not merely a mock's own output.
|
||||
use super::ErasureSetHealer;
|
||||
use crate::heal::progress::HealProgress;
|
||||
use crate::heal::resume::{CheckpointManager, ResumeManager, compose_key};
|
||||
use crate::heal::resume::{CheckpointManager, RESUME_CHECKPOINT_FILE, ResumeDeleteFailure, ResumeManager, compose_key};
|
||||
use crate::heal::storage::{DiskStatus, HealListItem, HealObjectInfo, HealStorageAPI};
|
||||
use crate::heal::storage_api::status::BucketInfo;
|
||||
use crate::heal::{
|
||||
@@ -989,6 +1019,7 @@ mod resume_loop_tests {
|
||||
use rustfs_common::heal_channel::{HealOpts, HealRequestSource};
|
||||
use rustfs_madmin::heal_commands::HealResultItem;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tempfile::TempDir;
|
||||
use tokio::sync::RwLock;
|
||||
@@ -1017,6 +1048,7 @@ mod resume_loop_tests {
|
||||
/// A transient infrastructure condition (offline disk / unmet quorum):
|
||||
/// the version must be recorded as skipped and retried on a later pass.
|
||||
Transient,
|
||||
Timeout,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -1027,6 +1059,7 @@ mod resume_loop_tests {
|
||||
outcomes: Mutex<HashMap<String, HealOutcome>>,
|
||||
/// every heal_object call recorded as (name, version_id)
|
||||
heal_calls: Mutex<Vec<(String, Option<String>)>>,
|
||||
fail_listing: AtomicBool,
|
||||
}
|
||||
|
||||
impl FakeStorage {
|
||||
@@ -1039,6 +1072,9 @@ mod resume_loop_tests {
|
||||
fn calls(&self) -> Vec<(String, Option<String>)> {
|
||||
self.heal_calls.lock().unwrap().clone()
|
||||
}
|
||||
fn fail_listing(&self) {
|
||||
self.fail_listing.store(true, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
@@ -1108,6 +1144,7 @@ mod resume_loop_tests {
|
||||
Ok((HealResultItem::default(), Some(Error::Storage(EcstoreError::FileVersionNotFound))))
|
||||
}
|
||||
HealOutcome::Transient => Ok((HealResultItem::default(), Some(Error::Storage(EcstoreError::DiskNotFound)))),
|
||||
HealOutcome::Timeout => Err(Error::TaskTimeout),
|
||||
}
|
||||
}
|
||||
async fn heal_bucket(&self, _b: &str, _o: &HealOpts) -> Result<HealResultItem> {
|
||||
@@ -1125,6 +1162,9 @@ mod resume_loop_tests {
|
||||
_prefix: &str,
|
||||
continuation_token: Option<&str>,
|
||||
) -> Result<(Vec<HealListItem>, Option<String>, bool)> {
|
||||
if self.fail_listing.load(Ordering::SeqCst) {
|
||||
return Err(Error::other("injected listing failure"));
|
||||
}
|
||||
let key = continuation_token.map(str::to_string);
|
||||
let page = self.pages.lock().unwrap().get(&key).cloned();
|
||||
match page {
|
||||
@@ -1233,6 +1273,126 @@ mod resume_loop_tests {
|
||||
assert_eq!(env.resume.resume_cursor().await, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn object_timeout_aborts_the_bucket_page_immediately() {
|
||||
let env = make_env().await;
|
||||
env.storage.set_page(
|
||||
None,
|
||||
Page {
|
||||
items: vec![item("timed-out", None, false)],
|
||||
next: None,
|
||||
truncated: false,
|
||||
},
|
||||
);
|
||||
env.storage.set_outcome("timed-out", None, HealOutcome::Timeout);
|
||||
|
||||
let (processed, successful, failed, skipped, result) = run(&env).await;
|
||||
|
||||
assert!(matches!(result, Err(Error::TaskTimeout)));
|
||||
assert_eq!(processed, 0);
|
||||
assert_eq!(successful, 0);
|
||||
assert_eq!(failed, 0);
|
||||
assert_eq!(skipped, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bucket_listing_failure_does_not_mark_set_completed() {
|
||||
let env = make_env().await;
|
||||
env.storage.fail_listing();
|
||||
|
||||
let result = env
|
||||
.healer
|
||||
.execute_heal_with_resume(&["b".to_string()], "pool_0_set_0", &env.resume, &env.checkpoint)
|
||||
.await;
|
||||
|
||||
assert!(result.is_err(), "a bucket listing failure must fail the set heal pass");
|
||||
let state = env.resume.get_state().await;
|
||||
assert!(!state.completed, "a failed bucket must not mark the set completed");
|
||||
assert_eq!(state.retry_count, 1, "the failed bucket must schedule a bounded retry");
|
||||
assert!(state.completed_buckets.is_empty(), "the failed bucket must remain resumable");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn completed_resume_state_is_not_selected_for_a_new_heal() {
|
||||
let env = make_env().await;
|
||||
env.resume
|
||||
.mark_completed()
|
||||
.await
|
||||
.expect("completed resume state should persist");
|
||||
|
||||
let task_id = env
|
||||
.healer
|
||||
.get_or_create_task_id("pool_0_set_0")
|
||||
.await
|
||||
.expect("new heal should allocate a task id");
|
||||
|
||||
assert_ne!(task_id, "task", "a completed resume state must not suppress a new heal");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cleanup_failure_keeps_erasure_set_heal_incomplete() {
|
||||
let env = make_env().await;
|
||||
let checkpoint_path = format!("{BUCKET_META_PREFIX}/task_{RESUME_CHECKPOINT_FILE}");
|
||||
let _failure = ResumeDeleteFailure::install(checkpoint_path, crate::heal::DiskError::DiskAccessDenied);
|
||||
|
||||
let error = env
|
||||
.healer
|
||||
.heal_erasure_set(&["b".to_string()], "pool_0_set_0")
|
||||
.await
|
||||
.expect_err("checkpoint cleanup failure must fail the erasure-set heal");
|
||||
|
||||
assert!(matches!(error, Error::Disk(crate::heal::DiskError::DiskAccessDenied)));
|
||||
let state = ResumeManager::load_from_disk(env.healer.disk.clone(), "task")
|
||||
.await
|
||||
.expect("completed state must remain discoverable after cleanup failure")
|
||||
.get_state()
|
||||
.await;
|
||||
assert!(state.completed, "successful data heal must be persisted before cleanup is attempted");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn retry_resume_repairs_checkpoint_after_crash_between_resets() {
|
||||
let env = make_env().await;
|
||||
env.resume
|
||||
.update_progress(3, 1, 1, 1)
|
||||
.await
|
||||
.expect("dirty resume progress should persist");
|
||||
env.resume
|
||||
.complete_bucket("b")
|
||||
.await
|
||||
.expect("dirty completed bucket should persist");
|
||||
env.resume
|
||||
.set_resume_cursor(Some("stale-cursor".to_string()))
|
||||
.await
|
||||
.expect("dirty resume cursor should persist");
|
||||
env.checkpoint
|
||||
.add_skipped_object(compose_key("stale-object", None))
|
||||
.await
|
||||
.expect("dirty checkpoint object should be recorded");
|
||||
env.checkpoint
|
||||
.update_position(4, 9)
|
||||
.await
|
||||
.expect("dirty checkpoint position should persist");
|
||||
|
||||
assert!(
|
||||
env.resume.schedule_retry().await.expect("resume retry reset should persist"),
|
||||
"retry budget should remain"
|
||||
);
|
||||
|
||||
let (_, checkpoint) = env
|
||||
.healer
|
||||
.initialize_resume_state("task", "pool_0_set_0", &["b".to_string()])
|
||||
.await
|
||||
.expect("resume initialization should repair a stale checkpoint");
|
||||
let checkpoint = checkpoint.get_checkpoint().await;
|
||||
|
||||
assert_eq!(checkpoint.current_bucket_index, 0);
|
||||
assert_eq!(checkpoint.current_object_index, 0);
|
||||
assert!(checkpoint.processed_objects.is_empty());
|
||||
assert!(checkpoint.failed_objects.is_empty());
|
||||
assert!(checkpoint.skipped_objects.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resume_across_page_boundary_no_drop_no_double() {
|
||||
let env = make_env().await;
|
||||
|
||||
@@ -2495,7 +2495,8 @@ impl HealManager {
|
||||
queue.pop_next()
|
||||
};
|
||||
|
||||
if let Some(request) = selected_request {
|
||||
if let Some(mut request) = selected_request {
|
||||
request.options.timeout.get_or_insert(config.task_timeout);
|
||||
let task_priority = request.priority;
|
||||
let task_type_label = heal_request_type_label(&request).to_string();
|
||||
let task_set_label = heal_request_set_metric_label(&request);
|
||||
@@ -4925,6 +4926,75 @@ mod tests {
|
||||
assert_eq!(manager.get_queue_length().await, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn configured_task_timeout_applies_only_when_request_timeout_is_absent() {
|
||||
let storage: Arc<dyn HealStorageAPI> = Arc::new(MockStorage);
|
||||
let manager = HealManager::new(
|
||||
storage,
|
||||
Some(HealConfig {
|
||||
max_concurrent_heals: 1,
|
||||
task_timeout: Duration::ZERO,
|
||||
..HealConfig::default()
|
||||
}),
|
||||
);
|
||||
|
||||
let mut defaulted = bucket_request("defaulted-timeout", HealPriority::Normal, HealRequestSource::Admin);
|
||||
defaulted.options.timeout = None;
|
||||
let defaulted_id = defaulted.id.clone();
|
||||
manager
|
||||
.submit_heal_request(defaulted)
|
||||
.await
|
||||
.expect("request without timeout should be queued");
|
||||
process_manager_queue_once(&manager).await;
|
||||
let defaulted_status = tokio::time::timeout(Duration::from_secs(1), async {
|
||||
loop {
|
||||
if let Ok(status @ HealTaskStatus::Retrying { .. }) = manager.get_task_status(&defaulted_id).await {
|
||||
break status;
|
||||
}
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("configured timeout should finish the task");
|
||||
assert!(matches!(defaulted_status, HealTaskStatus::Retrying { .. }));
|
||||
assert_eq!(
|
||||
manager
|
||||
.retrying_heals
|
||||
.lock()
|
||||
.await
|
||||
.get(&defaulted_id)
|
||||
.expect("timed out task should retain its retry request")
|
||||
.request
|
||||
.options
|
||||
.timeout,
|
||||
Some(Duration::ZERO)
|
||||
);
|
||||
manager
|
||||
.cancel_task(&defaulted_id)
|
||||
.await
|
||||
.expect("retrying timeout task should be cancelled");
|
||||
|
||||
let mut explicit = bucket_request("explicit-timeout", HealPriority::Normal, HealRequestSource::Admin);
|
||||
explicit.options.timeout = Some(Duration::from_secs(60));
|
||||
let explicit_id = explicit.id.clone();
|
||||
manager
|
||||
.submit_heal_request(explicit)
|
||||
.await
|
||||
.expect("request with explicit timeout should be queued");
|
||||
process_manager_queue_once(&manager).await;
|
||||
let explicit_status = tokio::time::timeout(Duration::from_secs(1), async {
|
||||
loop {
|
||||
if let Ok(status @ HealTaskStatus::Failed { .. }) = manager.get_task_status(&explicit_id).await {
|
||||
break status;
|
||||
}
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("explicit timeout request should finish without using the zero default");
|
||||
assert!(matches!(explicit_status, HealTaskStatus::Failed { .. }));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_force_start_bypasses_duplicate_and_full_admission() {
|
||||
let storage: Arc<dyn HealStorageAPI> = Arc::new(MockStorage);
|
||||
|
||||
+145
-21
@@ -14,6 +14,8 @@
|
||||
|
||||
use crate::{Error, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
#[cfg(test)]
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, Mutex};
|
||||
@@ -32,7 +34,7 @@ const EVENT_HEAL_CHECKPOINT_STATE: &str = "heal_checkpoint_state";
|
||||
/// resume state file constants
|
||||
const RESUME_STATE_FILE: &str = "ahm_resume_state.json";
|
||||
const RESUME_PROGRESS_FILE: &str = "ahm_progress.json";
|
||||
const RESUME_CHECKPOINT_FILE: &str = "ahm_checkpoint.json";
|
||||
pub(super) const RESUME_CHECKPOINT_FILE: &str = "ahm_checkpoint.json";
|
||||
|
||||
/// Current on-disk schema version for `ResumeState`. Snapshots written by an
|
||||
/// older schema (which tracked latest-only object names and a positional
|
||||
@@ -92,6 +94,64 @@ fn path_to_str(path: &Path) -> Result<&str> {
|
||||
.ok_or_else(|| Error::other(format!("Invalid UTF-8 path: {path:?}")))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) struct ResumeDeleteFailure {
|
||||
path: String,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn resume_delete_failures() -> &'static Mutex<HashMap<String, DiskError>> {
|
||||
static FAILURES: std::sync::OnceLock<Mutex<HashMap<String, DiskError>>> = std::sync::OnceLock::new();
|
||||
FAILURES.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl ResumeDeleteFailure {
|
||||
pub(super) fn install(path: String, error: DiskError) -> Self {
|
||||
let previous = resume_delete_failures()
|
||||
.lock()
|
||||
.expect("resume delete failure registry should not poison")
|
||||
.insert(path.clone(), error);
|
||||
assert!(previous.is_none(), "resume delete failure already installed");
|
||||
Self { path }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl Drop for ResumeDeleteFailure {
|
||||
fn drop(&mut self) {
|
||||
resume_delete_failures()
|
||||
.lock()
|
||||
.expect("resume delete failure registry should not poison")
|
||||
.remove(&self.path);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn injected_resume_delete_error(path: &str) -> Option<DiskError> {
|
||||
resume_delete_failures()
|
||||
.lock()
|
||||
.expect("resume delete failure registry should not poison")
|
||||
.get(path)
|
||||
.cloned()
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
fn injected_resume_delete_error(_path: &str) -> Option<DiskError> {
|
||||
None
|
||||
}
|
||||
|
||||
async fn delete_resume_file(disk: &DiskStore, path: &Path) -> Result<()> {
|
||||
let path_str = path_to_str(path)?;
|
||||
if let Some(err) = injected_resume_delete_error(path_str) {
|
||||
return Err(err.into());
|
||||
}
|
||||
match disk.delete(RUSTFS_META_BUCKET, path_str, Default::default()).await {
|
||||
Ok(()) | Err(DiskError::FileNotFound | DiskError::VolumeNotFound) => Ok(()),
|
||||
Err(err) => Err(err.into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// resume state
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ResumeState {
|
||||
@@ -407,7 +467,7 @@ impl ResumeManager {
|
||||
let mut state = self.state.write().await;
|
||||
state.mark_completed();
|
||||
drop(state);
|
||||
self.save_state_throttled().await
|
||||
self.save_state().await
|
||||
}
|
||||
|
||||
/// set error message
|
||||
@@ -444,24 +504,14 @@ impl ResumeManager {
|
||||
|
||||
/// cleanup resume state
|
||||
pub async fn cleanup(&self) -> Result<()> {
|
||||
let state = self.state.read().await;
|
||||
let task_id = &state.task_id;
|
||||
let task_id = self.state.read().await.task_id.clone();
|
||||
|
||||
// delete state files
|
||||
let state_file = Path::new(BUCKET_META_PREFIX).join(format!("{task_id}_{RESUME_STATE_FILE}"));
|
||||
let progress_file = Path::new(BUCKET_META_PREFIX).join(format!("{task_id}_{RESUME_PROGRESS_FILE}"));
|
||||
let checkpoint_file = Path::new(BUCKET_META_PREFIX).join(format!("{task_id}_{RESUME_CHECKPOINT_FILE}"));
|
||||
|
||||
// ignore delete errors, files may not exist
|
||||
if let Ok(path_str) = path_to_str(&state_file) {
|
||||
let _ = self.disk.delete(RUSTFS_META_BUCKET, path_str, Default::default()).await;
|
||||
}
|
||||
if let Ok(path_str) = path_to_str(&progress_file) {
|
||||
let _ = self.disk.delete(RUSTFS_META_BUCKET, path_str, Default::default()).await;
|
||||
}
|
||||
if let Ok(path_str) = path_to_str(&checkpoint_file) {
|
||||
let _ = self.disk.delete(RUSTFS_META_BUCKET, path_str, Default::default()).await;
|
||||
}
|
||||
delete_resume_file(&self.disk, &progress_file).await?;
|
||||
// Delete the state file last so a partial cleanup remains discoverable.
|
||||
delete_resume_file(&self.disk, &state_file).await?;
|
||||
|
||||
debug!(
|
||||
target: "rustfs::heal::resume",
|
||||
@@ -763,13 +813,10 @@ impl CheckpointManager {
|
||||
|
||||
/// cleanup checkpoint
|
||||
pub async fn cleanup(&self) -> Result<()> {
|
||||
let checkpoint = self.checkpoint.read().await;
|
||||
let task_id = &checkpoint.task_id;
|
||||
let task_id = self.checkpoint.read().await.task_id.clone();
|
||||
|
||||
let checkpoint_file = Path::new(BUCKET_META_PREFIX).join(format!("{task_id}_{RESUME_CHECKPOINT_FILE}"));
|
||||
if let Ok(path_str) = path_to_str(&checkpoint_file) {
|
||||
let _ = self.disk.delete(RUSTFS_META_BUCKET, path_str, Default::default()).await;
|
||||
}
|
||||
delete_resume_file(&self.disk, &checkpoint_file).await?;
|
||||
|
||||
debug!(
|
||||
target: "rustfs::heal::resume",
|
||||
@@ -1211,6 +1258,83 @@ mod tests {
|
||||
assert!(!throttle.record(), "counter must reset after a save");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn completion_persists_immediately_and_cleanup_propagates_delete_errors() {
|
||||
use super::super::{DiskOption, Endpoint, new_disk};
|
||||
use tempfile::TempDir;
|
||||
|
||||
let temp_dir = TempDir::new().expect("create resume persistence test directory");
|
||||
let endpoint = Endpoint::try_from(temp_dir.path().to_string_lossy().as_ref()).expect("create test disk endpoint");
|
||||
let disk = new_disk(
|
||||
&endpoint,
|
||||
&DiskOption {
|
||||
cleanup: false,
|
||||
health_check: false,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("create resume persistence test disk");
|
||||
match disk.make_volume(RUSTFS_META_BUCKET).await {
|
||||
Ok(()) | Err(DiskError::VolumeExists) => {}
|
||||
Err(err) => panic!("create metadata volume for resume persistence test: {err}"),
|
||||
}
|
||||
|
||||
let task_id = "completion-persistence".to_string();
|
||||
let manager = ResumeManager::new(
|
||||
disk.clone(),
|
||||
task_id.clone(),
|
||||
"erasure_set".to_string(),
|
||||
"pool_0_set_0".to_string(),
|
||||
vec!["bucket".to_string()],
|
||||
)
|
||||
.await
|
||||
.expect("create resume manager");
|
||||
manager
|
||||
.update_progress(1, 1, 0, 0)
|
||||
.await
|
||||
.expect("buffer progress below the persistence threshold");
|
||||
manager.mark_completed().await.expect("persist completed resume state");
|
||||
|
||||
let persisted = ResumeManager::load_from_disk(disk.clone(), &task_id)
|
||||
.await
|
||||
.expect("reload completed resume state")
|
||||
.get_state()
|
||||
.await;
|
||||
assert!(persisted.completed, "completion must be persisted without waiting for the throttle");
|
||||
assert_eq!(persisted.processed_objects, 1, "the completion write must include buffered progress");
|
||||
|
||||
let state_path = format!("{BUCKET_META_PREFIX}/{task_id}_{RESUME_STATE_FILE}");
|
||||
let failure = ResumeDeleteFailure::install(state_path, DiskError::DiskAccessDenied);
|
||||
let error = manager
|
||||
.cleanup()
|
||||
.await
|
||||
.expect_err("resume cleanup must propagate a real delete failure");
|
||||
assert!(matches!(error, Error::Disk(DiskError::DiskAccessDenied)));
|
||||
drop(failure);
|
||||
manager.cleanup().await.expect("resume cleanup must be retryable");
|
||||
manager
|
||||
.cleanup()
|
||||
.await
|
||||
.expect("missing resume files must be idempotent success");
|
||||
|
||||
let checkpoint = CheckpointManager::new(disk.clone(), task_id.clone())
|
||||
.await
|
||||
.expect("create checkpoint manager");
|
||||
let checkpoint_path = format!("{BUCKET_META_PREFIX}/{task_id}_{RESUME_CHECKPOINT_FILE}");
|
||||
let failure = ResumeDeleteFailure::install(checkpoint_path, DiskError::DiskAccessDenied);
|
||||
let error = checkpoint
|
||||
.cleanup()
|
||||
.await
|
||||
.expect_err("checkpoint cleanup must propagate a real delete failure");
|
||||
assert!(matches!(error, Error::Disk(DiskError::DiskAccessDenied)));
|
||||
drop(failure);
|
||||
checkpoint.cleanup().await.expect("checkpoint cleanup must be retryable");
|
||||
checkpoint
|
||||
.cleanup()
|
||||
.await
|
||||
.expect("missing checkpoint must be idempotent success");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resume_utils() {
|
||||
let task_id1 = ResumeUtils::generate_task_id();
|
||||
|
||||
+138
-17
@@ -144,6 +144,9 @@ pub struct HealOptions {
|
||||
pub recursive: bool,
|
||||
/// Whether to dry run
|
||||
pub dry_run: bool,
|
||||
/// Whether to skip namespace locking
|
||||
#[serde(default)]
|
||||
pub no_lock: bool,
|
||||
/// Timeout
|
||||
pub timeout: Option<Duration>,
|
||||
/// pool index
|
||||
@@ -161,7 +164,8 @@ impl Default for HealOptions {
|
||||
update_parity: true,
|
||||
recursive: false,
|
||||
dry_run: false,
|
||||
timeout: Some(Duration::from_secs(300)), // 5 minutes default timeout
|
||||
no_lock: false,
|
||||
timeout: None,
|
||||
pool_index: None,
|
||||
set_index: None,
|
||||
}
|
||||
@@ -896,7 +900,7 @@ impl HealTask {
|
||||
recreate: self.options.recreate_missing,
|
||||
scan_mode: self.options.scan_mode,
|
||||
update_parity: self.options.update_parity,
|
||||
no_lock: false,
|
||||
no_lock: self.options.no_lock,
|
||||
pool: self.options.pool_index,
|
||||
set: self.options.set_index,
|
||||
};
|
||||
@@ -1104,7 +1108,7 @@ impl HealTask {
|
||||
recreate: true,
|
||||
scan_mode: HealScanMode::Deep,
|
||||
update_parity: true,
|
||||
no_lock: false,
|
||||
no_lock: self.options.no_lock,
|
||||
pool: None,
|
||||
set: None,
|
||||
};
|
||||
@@ -1260,7 +1264,7 @@ impl HealTask {
|
||||
recreate: self.options.recreate_missing,
|
||||
scan_mode: self.options.scan_mode,
|
||||
update_parity: self.options.update_parity,
|
||||
no_lock: false,
|
||||
no_lock: self.options.no_lock,
|
||||
pool: self.options.pool_index,
|
||||
set: self.options.set_index,
|
||||
};
|
||||
@@ -1426,7 +1430,7 @@ impl HealTask {
|
||||
recreate: self.options.recreate_missing,
|
||||
scan_mode: self.options.scan_mode,
|
||||
update_parity: self.options.update_parity,
|
||||
no_lock: false,
|
||||
no_lock: self.options.no_lock,
|
||||
pool: self.options.pool_index,
|
||||
set: self.options.set_index,
|
||||
};
|
||||
@@ -1680,7 +1684,7 @@ impl HealTask {
|
||||
recreate: false,
|
||||
scan_mode: HealScanMode::Deep,
|
||||
update_parity: false,
|
||||
no_lock: false,
|
||||
no_lock: self.options.no_lock,
|
||||
pool: self.options.pool_index,
|
||||
set: self.options.set_index,
|
||||
};
|
||||
@@ -1809,7 +1813,7 @@ impl HealTask {
|
||||
recreate: self.options.recreate_missing,
|
||||
scan_mode: HealScanMode::Deep,
|
||||
update_parity: true,
|
||||
no_lock: false,
|
||||
no_lock: self.options.no_lock,
|
||||
pool: None,
|
||||
set: None,
|
||||
};
|
||||
@@ -1973,7 +1977,7 @@ impl HealTask {
|
||||
recreate: true,
|
||||
scan_mode: HealScanMode::Deep,
|
||||
update_parity: true,
|
||||
no_lock: false,
|
||||
no_lock: self.options.no_lock,
|
||||
pool: None,
|
||||
set: None,
|
||||
};
|
||||
@@ -2214,7 +2218,7 @@ impl HealTask {
|
||||
recreate: self.options.recreate_missing,
|
||||
scan_mode: self.options.scan_mode,
|
||||
update_parity: self.options.update_parity,
|
||||
no_lock: false,
|
||||
no_lock: self.options.no_lock,
|
||||
pool: self.options.pool_index,
|
||||
set: self.options.set_index,
|
||||
};
|
||||
@@ -2230,10 +2234,6 @@ impl HealTask {
|
||||
self.record_result_item(result).await;
|
||||
}
|
||||
Err(err) => {
|
||||
// Check if error is due to cancellation or timeout
|
||||
if matches!(err, Error::TaskCancelled | Error::TaskTimeout) {
|
||||
return Err(err);
|
||||
}
|
||||
warn!(
|
||||
target: "rustfs::heal::task",
|
||||
event = EVENT_HEAL_ERASURE_SET_RESULT,
|
||||
@@ -2246,6 +2246,7 @@ impl HealTask {
|
||||
error = %err,
|
||||
"Heal erasure set bucket prepass failed"
|
||||
);
|
||||
return Err(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2268,7 +2269,7 @@ impl HealTask {
|
||||
recreate: self.options.recreate_missing,
|
||||
scan_mode: self.options.scan_mode,
|
||||
update_parity: self.options.update_parity,
|
||||
no_lock: false,
|
||||
no_lock: self.options.no_lock,
|
||||
pool: self.options.pool_index,
|
||||
set: self.options.set_index,
|
||||
};
|
||||
@@ -2297,7 +2298,9 @@ impl HealTask {
|
||||
stage = "execute_resumable_heal",
|
||||
"Heal erasure set stage entered"
|
||||
);
|
||||
let result = erasure_healer.heal_erasure_set(&buckets, &set_disk_id).await;
|
||||
let result = self
|
||||
.await_with_control(erasure_healer.heal_erasure_set(&buckets, &set_disk_id))
|
||||
.await;
|
||||
|
||||
// Keep the markers on failure: the resume state also persists, and the
|
||||
// next run of this set heal re-marks and eventually clears them.
|
||||
@@ -2360,12 +2363,13 @@ impl std::fmt::Debug for HealTask {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::{DiskStore, Endpoint};
|
||||
use super::super::{DiskOption, DiskStore, Endpoint, HealDiskExt as _, new_disk};
|
||||
use super::*;
|
||||
use crate::heal::storage::{DiskStatus, HealListItem, HealObjectInfo};
|
||||
use rustfs_madmin::heal_commands::HealResultItem;
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::sync::Mutex;
|
||||
use tempfile::TempDir;
|
||||
|
||||
use super::super::storage_api::status::BucketInfo;
|
||||
#[derive(Default)]
|
||||
@@ -2388,6 +2392,8 @@ mod tests {
|
||||
listed_buckets: Mutex<Option<Vec<String>>>,
|
||||
bucket_heal_errors: Mutex<HashMap<String, VecDeque<&'static str>>>,
|
||||
bucket_heal_calls: Mutex<Vec<String>>,
|
||||
block_heal_object: Mutex<bool>,
|
||||
resume_disk: Mutex<Option<DiskStore>>,
|
||||
}
|
||||
|
||||
/// Build a latest, non-delete-marker heal list item with no version id.
|
||||
@@ -2520,6 +2526,10 @@ mod tests {
|
||||
.unwrap()
|
||||
.push(version_id.map(ToString::to_string));
|
||||
self.object_heal_opts.lock().unwrap().push(*opts);
|
||||
let block_heal_object = *self.block_heal_object.lock().unwrap();
|
||||
if block_heal_object {
|
||||
std::future::pending::<()>().await;
|
||||
}
|
||||
if let Some(outcome) = self
|
||||
.heal_object_outcomes
|
||||
.lock()
|
||||
@@ -2635,10 +2645,35 @@ mod tests {
|
||||
}
|
||||
|
||||
async fn get_disk_for_resume(&self, _set_disk_id: &str) -> Result<DiskStore> {
|
||||
Err(Error::other("not implemented in tests"))
|
||||
self.resume_disk
|
||||
.lock()
|
||||
.unwrap()
|
||||
.clone()
|
||||
.ok_or_else(|| Error::other("not implemented in tests"))
|
||||
}
|
||||
}
|
||||
|
||||
async fn make_resume_disk(temp: &TempDir) -> DiskStore {
|
||||
let disk_path = temp.path().join("test_disk");
|
||||
std::fs::create_dir_all(&disk_path).expect("test disk directory should be created");
|
||||
let endpoint = Endpoint::try_from(disk_path.to_string_lossy().as_ref()).expect("test disk endpoint should be valid");
|
||||
let disk = new_disk(
|
||||
&endpoint,
|
||||
&DiskOption {
|
||||
cleanup: false,
|
||||
health_check: false,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("test disk should initialize");
|
||||
let metadata_volume = disk.make_volume(RUSTFS_META_BUCKET).await;
|
||||
assert!(
|
||||
matches!(metadata_volume, Ok(()) | Err(DiskError::VolumeExists)),
|
||||
"metadata volume should exist: {metadata_volume:?}"
|
||||
);
|
||||
disk
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_recursive_bucket_heal_visits_objects() {
|
||||
let storage = Arc::new(MockStorage::default());
|
||||
@@ -2932,6 +2967,7 @@ mod tests {
|
||||
remove_corrupted: true,
|
||||
recreate_missing: true,
|
||||
scan_mode: HealScanMode::Deep,
|
||||
no_lock: true,
|
||||
timeout: None,
|
||||
..Default::default()
|
||||
},
|
||||
@@ -2948,12 +2984,14 @@ mod tests {
|
||||
assert!(!bucket_opts[0].remove);
|
||||
assert!(bucket_opts[0].recreate);
|
||||
assert_eq!(bucket_opts[0].scan_mode, HealScanMode::Deep);
|
||||
assert!(bucket_opts[0].no_lock);
|
||||
|
||||
let object_opts = storage.object_heal_opts.lock().unwrap();
|
||||
assert_eq!(object_opts.len(), 2);
|
||||
assert!(object_opts.iter().all(|opts| opts.remove));
|
||||
assert!(object_opts.iter().all(|opts| opts.recreate));
|
||||
assert!(object_opts.iter().all(|opts| opts.scan_mode == HealScanMode::Deep));
|
||||
assert!(object_opts.iter().all(|opts| opts.no_lock));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -3584,4 +3622,87 @@ mod tests {
|
||||
"erasure-set heal should continue past NoHealRequired format result, got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn erasure_set_bucket_prepass_failure_stops_before_object_heal() {
|
||||
let temp = TempDir::new().expect("temporary directory should be created");
|
||||
let disk = make_resume_disk(&temp).await;
|
||||
let storage = Arc::new(MockStorage {
|
||||
bucket_heal_errors: Mutex::new(HashMap::from([(
|
||||
"bucket-a".to_string(),
|
||||
VecDeque::from(["injected bucket prepass failure"]),
|
||||
)])),
|
||||
resume_disk: Mutex::new(Some(disk)),
|
||||
..Default::default()
|
||||
});
|
||||
let request = HealRequest::new(
|
||||
HealType::ErasureSet {
|
||||
buckets: vec!["bucket-a".to_string()],
|
||||
set_disk_id: "pool_0_set_0".to_string(),
|
||||
},
|
||||
HealOptions {
|
||||
timeout: None,
|
||||
..Default::default()
|
||||
},
|
||||
HealPriority::Normal,
|
||||
);
|
||||
let task = HealTask::from_request(request, storage.clone());
|
||||
|
||||
let error = task
|
||||
.heal_erasure_set(vec!["bucket-a".to_string()], "pool_0_set_0".to_string())
|
||||
.await
|
||||
.expect_err("bucket prepass failure must stop the erasure-set heal");
|
||||
|
||||
assert!(error.to_string().contains("injected bucket prepass failure"));
|
||||
assert_eq!(storage.bucket_heal_calls.lock().unwrap().as_slice(), ["bucket-a".to_string()]);
|
||||
assert!(storage.object_heal_opts.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resumable_erasure_set_execution_is_cancelled_while_object_heal_is_pending() {
|
||||
let temp = TempDir::new().expect("temporary directory should be created");
|
||||
let disk = make_resume_disk(&temp).await;
|
||||
let storage = Arc::new(MockStorage {
|
||||
block_heal_object: Mutex::new(true),
|
||||
resume_disk: Mutex::new(Some(disk)),
|
||||
..Default::default()
|
||||
});
|
||||
let request = HealRequest::new(
|
||||
HealType::ErasureSet {
|
||||
buckets: vec!["bucket-a".to_string()],
|
||||
set_disk_id: "pool_0_set_0".to_string(),
|
||||
},
|
||||
HealOptions {
|
||||
no_lock: true,
|
||||
timeout: None,
|
||||
..Default::default()
|
||||
},
|
||||
HealPriority::Normal,
|
||||
);
|
||||
let task = Arc::new(HealTask::from_request(request, storage.clone()));
|
||||
let execution = tokio::spawn({
|
||||
let task = task.clone();
|
||||
async move { task.execute().await }
|
||||
});
|
||||
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
loop {
|
||||
if !storage.object_heal_opts.lock().unwrap().is_empty() {
|
||||
break;
|
||||
}
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("resumable object heal should start");
|
||||
task.cancel().await.expect("task cancellation should succeed");
|
||||
|
||||
let result = tokio::time::timeout(Duration::from_secs(1), execution)
|
||||
.await
|
||||
.expect("cancellation should interrupt the pending resumable heal")
|
||||
.expect("task execution should join");
|
||||
assert!(matches!(result, Err(Error::TaskCancelled)));
|
||||
assert!(storage.bucket_heal_opts.lock().unwrap().iter().all(|opts| opts.no_lock));
|
||||
assert!(storage.object_heal_opts.lock().unwrap().iter().all(|opts| opts.no_lock));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -306,6 +306,7 @@ mod serial_tests {
|
||||
recreate_missing: false,
|
||||
scan_mode: HealScanMode::Normal,
|
||||
update_parity: false,
|
||||
no_lock: false,
|
||||
timeout: Some(Duration::from_secs(300)),
|
||||
pool_index: None,
|
||||
set_index: None,
|
||||
|
||||
@@ -64,10 +64,13 @@ impl FederatedAuthorization {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut source = Vec::with_capacity(8 + subject.len() + issuer.len());
|
||||
let subject_len = u64::try_from(subject.len()).ok()?;
|
||||
let issuer_len = u64::try_from(issuer.len()).ok()?;
|
||||
let mut source = Vec::with_capacity(23 + subject.len() + issuer.len());
|
||||
source.extend_from_slice(b"openid:");
|
||||
source.extend_from_slice(&subject_len.to_be_bytes());
|
||||
source.extend_from_slice(subject.as_bytes());
|
||||
source.push(b':');
|
||||
source.extend_from_slice(&issuer_len.to_be_bytes());
|
||||
source.extend_from_slice(issuer.as_bytes());
|
||||
let digest = HashAlgorithm::SHA256.hash_encode(&source);
|
||||
Some(base64_simd::URL_SAFE_NO_PAD.encode_to_string(digest.as_ref()))
|
||||
@@ -145,7 +148,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oidc_virtual_parent_matches_minio_and_is_issuer_scoped() {
|
||||
fn oidc_virtual_parent_is_issuer_scoped() {
|
||||
let first = authorization(Vec::new(), Vec::new());
|
||||
let mut second = first.clone();
|
||||
second
|
||||
@@ -155,7 +158,7 @@ mod tests {
|
||||
|
||||
assert_eq!(
|
||||
first.oidc_virtual_parent().as_deref(),
|
||||
Some("TwyekekG2eMes0qk9Tgh7KXEitwGi1z2W1f2KccrXGA")
|
||||
Some("HwDfWftzOy4jiuS3WjKytC_Sg_A2hKhrRAFtBDhoBr0")
|
||||
);
|
||||
assert!(!rustfs_policy::auth::contains_reserved_chars(
|
||||
first.oidc_virtual_parent().as_deref().expect("virtual parent")
|
||||
@@ -163,6 +166,24 @@ mod tests {
|
||||
assert_ne!(first.oidc_virtual_parent(), second.oidc_virtual_parent());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oidc_virtual_parent_length_delimits_identity_parts() {
|
||||
let mut first = authorization(Vec::new(), Vec::new());
|
||||
first.claims.sub = "subject".to_string();
|
||||
first.claims.raw.insert(
|
||||
"iss".to_string(),
|
||||
Value::String("https://issuer.example/path:https://other.example".to_string()),
|
||||
);
|
||||
let mut second = authorization(Vec::new(), Vec::new());
|
||||
second.claims.sub = "subject:https://issuer.example/path".to_string();
|
||||
second
|
||||
.claims
|
||||
.raw
|
||||
.insert("iss".to_string(), Value::String("https://other.example".to_string()));
|
||||
|
||||
assert_ne!(first.oidc_virtual_parent(), second.oidc_virtual_parent());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oidc_virtual_parent_requires_verified_identity_parts() {
|
||||
let mut missing_issuer = authorization(Vec::new(), Vec::new());
|
||||
|
||||
@@ -168,6 +168,15 @@ pub async fn build_iam_sys(ecstore: Arc<IamStore>) -> Result<Arc<IamSys<ObjectSt
|
||||
Ok(Arc::new(IamSys::new(cache_manager)))
|
||||
}
|
||||
|
||||
/// Build an IAM system for an application context and publish the first one
|
||||
/// as the ambient compatibility default.
|
||||
#[instrument(skip(ecstore))]
|
||||
pub async fn init_iam_sys_for_context(ecstore: Arc<IamStore>) -> Result<Arc<IamSys<ObjectStore>>> {
|
||||
let iam_instance = build_iam_sys(ecstore).await?;
|
||||
let _ = IAM_SYS.set(iam_instance.clone());
|
||||
Ok(iam_instance)
|
||||
}
|
||||
|
||||
#[instrument(skip(ecstore))]
|
||||
pub async fn init_iam_sys(ecstore: Arc<IamStore>) -> Result<Arc<IamSys<ObjectStore>>> {
|
||||
if let Some(existing) = IAM_SYS.get() {
|
||||
|
||||
+40
-2
@@ -161,7 +161,8 @@ impl PreparedIamAuth {
|
||||
/// conditions for the provided request args.
|
||||
pub async fn needs_existing_object_tag_for_args(&self, args: &Args<'_>) -> bool {
|
||||
match &self.mode {
|
||||
PreparedIamMode::Opa | PreparedIamMode::Owner | PreparedIamMode::Deny => false,
|
||||
PreparedIamMode::Opa => true,
|
||||
PreparedIamMode::Owner | PreparedIamMode::Deny => false,
|
||||
PreparedIamMode::Regular { combined_policy } => {
|
||||
policy_needs_existing_object_tag_for_args(combined_policy, args).await
|
||||
}
|
||||
@@ -1099,7 +1100,7 @@ impl<T: Store> IamSys<T> {
|
||||
match Self::policy_plugin_state().await {
|
||||
PolicyPluginState::Ready(_) => {
|
||||
return PreparedIamAuth {
|
||||
needs_existing_object_tag: false,
|
||||
needs_existing_object_tag: true,
|
||||
mode: PreparedIamMode::Opa,
|
||||
};
|
||||
}
|
||||
@@ -1766,6 +1767,7 @@ mod tests {
|
||||
use rustfs_policy::policy::action::{Action, AdminAction, S3Action, StsAction};
|
||||
use rustfs_policy::policy::policy_uses_existing_object_tag_conditions;
|
||||
use serde_json::Value;
|
||||
use serial_test::serial;
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
sync::{Arc, Mutex},
|
||||
@@ -1797,6 +1799,42 @@ mod tests {
|
||||
assert!(prepared.combined_policy_for_view().is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_prepare_auth_requests_existing_object_tags_in_opa_mode() {
|
||||
let store = StsTestMockStore::new(false);
|
||||
let iam_sys = IamSys::new(IamCache::new(store).await.expect("initialize IAM cache"));
|
||||
let previous_state = IamSys::<StsTestMockStore>::policy_plugin_state().await;
|
||||
IamSys::<StsTestMockStore>::set_policy_plugin_client(opa::AuthZPlugin::new(opa::Args {
|
||||
url: "http://127.0.0.1:8181/v1/data/rustfs/authz/allow".to_string(),
|
||||
auth_token: String::new(),
|
||||
}))
|
||||
.await;
|
||||
|
||||
let claims = HashMap::new();
|
||||
let groups = None;
|
||||
let conditions = HashMap::new();
|
||||
let args = Args {
|
||||
account: "opa-tag-test-user",
|
||||
groups: &groups,
|
||||
action: Action::S3Action(S3Action::GetObjectAction),
|
||||
bucket: "bucket",
|
||||
conditions: &conditions,
|
||||
is_owner: false,
|
||||
object: "tagged-object",
|
||||
claims: &claims,
|
||||
deny_only: false,
|
||||
};
|
||||
|
||||
let prepared = iam_sys.prepare_auth(&args).await;
|
||||
let needs_initial_tags = prepared.needs_existing_object_tag;
|
||||
let needs_secondary_tags = prepared.needs_existing_object_tag_for_args(&args).await;
|
||||
*get_policy_plugin_state().write().await = previous_state;
|
||||
|
||||
assert!(needs_initial_tags, "OPA mode must request existing object tags before evaluation");
|
||||
assert!(needs_secondary_tags, "OPA mode must request existing object tags for secondary actions");
|
||||
}
|
||||
|
||||
const CUSTOM_STS_CLAIM_POLICY: &str = "custom-sts-claim-getobject";
|
||||
const CUSTOM_STS_CLAIM_BUCKET: &str = "claim-bucket";
|
||||
const CUSTOM_STS_CLAIM_POLICY_JSON: &str = r#"{
|
||||
|
||||
@@ -26,6 +26,41 @@ NO_PROXY=127.0.0.1,localhost HTTP_PROXY= HTTPS_PROXY= http_proxy= https_proxy= \
|
||||
cargo test --package e2e_test test_local_kms_end_to_end -- --nocapture --test-threads=1
|
||||
```
|
||||
|
||||
### Black-box behavior suite and the Vault lane
|
||||
|
||||
`crates/kms/tests/behavior_*.rs` drive the crate through its public entry
|
||||
points only. By default they run against the Local and Static backends.
|
||||
|
||||
Setting `RUSTFS_KMS_VAULT_TOKEN` adds the Vault KV2 and Vault Transit backends
|
||||
to every `for_each_backend` spec, against a live server
|
||||
(`RUSTFS_KMS_VAULT_ADDR`, default `http://127.0.0.1:8200`):
|
||||
|
||||
```bash
|
||||
NO_PROXY=127.0.0.1,localhost HTTP_PROXY= HTTPS_PROXY= http_proxy= https_proxy= \
|
||||
RUSTFS_KMS_VAULT_TOKEN=<dev-token> cargo test -p rustfs-kms
|
||||
```
|
||||
|
||||
The server needs a KV v2 engine at `secret/` and a Transit engine at
|
||||
`transit/`, matching the crate's config defaults.
|
||||
|
||||
**Run the Vault lane whenever you touch rotation or versioning.** `rotate` and
|
||||
`versioning` are advertised only by the Vault backends, so without it every
|
||||
capability-gated branch for them takes the `UnsupportedCapability` side and
|
||||
`behavior_rotation.rs` never asserts the working half — a rotation that dropped
|
||||
prior key versions would go green.
|
||||
|
||||
The lane creates real keys under unique names (`behavior-kv2-*`,
|
||||
`behavior-transit-*`) and does not remove them, so a dev Vault accumulates them
|
||||
across runs. Clear them out periodically — against a dev server only:
|
||||
|
||||
```bash
|
||||
vault list -format=json transit/keys | jq -r '.[] | select(startswith("behavior-transit-"))' | while read -r k; do vault write "transit/keys/$k/config" deletion_allowed=true >/dev/null && vault delete "transit/keys/$k"; done
|
||||
```
|
||||
|
||||
```bash
|
||||
vault list -format=json secret/metadata/rustfs/kms/keys | jq -r '.[] | select(startswith("behavior-kv2-"))' | xargs -I{} vault kv metadata delete secret/rustfs/kms/keys/{}
|
||||
```
|
||||
|
||||
## Local Key Export for SSE-S3 Migration Tests
|
||||
|
||||
Use the read-only `local_kms_key_decrypt` example to export an AES-256 Local
|
||||
|
||||
@@ -1450,7 +1450,23 @@ impl LocalKmsClient {
|
||||
let key_info = self.describe_key(&request.key_id, context).await?;
|
||||
ensure_key_status_permits(&request.key_id, &key_info.status, StateGatedOperation::Encrypt)?;
|
||||
|
||||
let (ciphertext, _nonce) = self.encrypt_with_master_key(&request.key_id, &request.plaintext).await?;
|
||||
let (encrypted_key, nonce) = self.encrypt_with_master_key(&request.key_id, &request.plaintext).await?;
|
||||
|
||||
// The ciphertext must be the same envelope `decrypt` parses: the nonce
|
||||
// and the bound context live in it, so handing back the bare AES-GCM
|
||||
// output would make every `encrypt` result permanently unopenable.
|
||||
let envelope = DataKeyEnvelope {
|
||||
key_id: uuid::Uuid::new_v4().to_string(),
|
||||
master_key_id: request.key_id.clone(),
|
||||
key_spec: key_info.algorithm.clone(),
|
||||
encrypted_key,
|
||||
nonce,
|
||||
encryption_context: request.encryption_context.clone(),
|
||||
created_at: Zoned::now(),
|
||||
// Local rotation is rejected, so the key has a single material version.
|
||||
master_key_version: None,
|
||||
};
|
||||
let ciphertext = serde_json::to_vec(&envelope)?;
|
||||
|
||||
Ok(EncryptResponse {
|
||||
ciphertext,
|
||||
@@ -1466,6 +1482,13 @@ impl LocalKmsClient {
|
||||
// Parse the data key envelope from ciphertext
|
||||
let envelope: DataKeyEnvelope = serde_json::from_slice(&request.ciphertext)?;
|
||||
|
||||
// NOTE: this comparison is an authorization check, not a cryptographic
|
||||
// binding. `DekCrypto` seals only the plaintext, so `encryption_context`
|
||||
// rides in the envelope unauthenticated: anyone able to rewrite the
|
||||
// stored envelope can rewrite this field and present a matching context.
|
||||
// The Static and Vault Transit backends do bind it (as AEAD AAD and as
|
||||
// the Transit KDF context respectively); closing the gap here needs a
|
||||
// versioned envelope, since existing ciphertext was sealed without AAD.
|
||||
// Verify encryption context matches
|
||||
// Check that all keys in envelope.encryption_context are present in request.encryption_context
|
||||
// and their values match. This ensures the context used for decryption matches what was used for encryption.
|
||||
@@ -1849,10 +1872,14 @@ impl KmsBackend for LocalKmsBackend {
|
||||
async fn decrypt(&self, request: DecryptRequest) -> Result<DecryptResponse> {
|
||||
let plaintext = self.client.decrypt(&request, None).await?;
|
||||
|
||||
// For simplicity, return basic response - in real implementation would extract more info from ciphertext
|
||||
// The envelope that was just opened names the master key that opened it.
|
||||
// Reporting "unknown" left every caller unable to tell which key was
|
||||
// actually used, which is what audit and key-rotation checks read.
|
||||
let envelope: DataKeyEnvelope = serde_json::from_slice(&request.ciphertext)?;
|
||||
|
||||
Ok(DecryptResponse {
|
||||
plaintext,
|
||||
key_id: "unknown".to_string(), // Would be extracted from ciphertext metadata
|
||||
key_id: envelope.master_key_id,
|
||||
encryption_algorithm: Some("AES-256-GCM".to_string()),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
|
||||
use crate::backends::{BackendCapabilities, KmsBackend, empty_key_page, list_keys_page_size};
|
||||
use crate::config::{BackendConfig, KmsConfig};
|
||||
use crate::encryption::DataKeyEnvelope;
|
||||
use crate::encryption::{DataKeyEnvelope, context_aad};
|
||||
use crate::error::{KmsError, Result};
|
||||
use crate::types::*;
|
||||
use aes_gcm::{
|
||||
@@ -36,7 +36,7 @@ use aes_gcm::{
|
||||
use async_trait::async_trait;
|
||||
use jiff::Zoned;
|
||||
use rand::RngExt;
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::collections::HashMap;
|
||||
use tracing::debug;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
@@ -45,11 +45,6 @@ const NONCE_SIZE: usize = 12;
|
||||
/// AES-256 key size in bytes.
|
||||
const KEY_SIZE: usize = 32;
|
||||
|
||||
fn context_aad(context: &HashMap<String, String>) -> Result<Vec<u8>> {
|
||||
let canonical: BTreeMap<&str, &str> = context.iter().map(|(key, value)| (key.as_str(), value.as_str())).collect();
|
||||
serde_json::to_vec(&canonical).map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Static single-key KMS backend.
|
||||
///
|
||||
/// Uses a pre-configured AES-256 key to derive data encryption keys. This is a
|
||||
@@ -113,8 +108,18 @@ impl StaticKmsBackend {
|
||||
let mut nonce_bytes = [0u8; NONCE_SIZE];
|
||||
rand::rng().fill(&mut nonce_bytes[..]);
|
||||
|
||||
// Generate 32 random bytes as plaintext DEK
|
||||
let mut plaintext = [0u8; KEY_SIZE];
|
||||
// The requested spec decides the DEK length; a caller that asked for
|
||||
// AES_128 and silently got 256 bits would build objects whose recorded
|
||||
// spec does not match their key material.
|
||||
// Lengths track `KeySpec::key_size`; the request carries the spec as a
|
||||
// string, so the mapping is repeated here rather than shared.
|
||||
let key_length = match request.key_spec.as_str() {
|
||||
"AES_256" | "ChaCha20" => 32,
|
||||
"AES_128" => 16,
|
||||
_ => return Err(KmsError::unsupported_algorithm(&request.key_spec)),
|
||||
};
|
||||
|
||||
let mut plaintext = vec![0u8; key_length];
|
||||
rand::rng().fill(&mut plaintext[..]);
|
||||
|
||||
// Encrypt DEK with AES-256-GCM using the static key directly
|
||||
@@ -127,7 +132,7 @@ impl StaticKmsBackend {
|
||||
.encrypt(
|
||||
&nonce,
|
||||
Payload {
|
||||
msg: plaintext.as_ref(),
|
||||
msg: plaintext.as_slice(),
|
||||
aad: &aad,
|
||||
},
|
||||
)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user