Compare commits

...

29 Commits

Author SHA1 Message Date
overtrue fedc966612 Capture concurrent TLS read failure progress 2026-10-28 05:36:17 +08:00
overtrue 80f80afc62 Merge commit 'f96c94f95056697be2e0257320306592946642c5' into overtrue/scanner-heal-identity-scope 2026-10-28 03:00:56 +08:00
Chris An f96c94f950 fix(listing): treat key markers as literal keys (#8978)
* fix(listing): treat version markers as literal keys

* fix(listing): keep start-after and V1 markers literal

* docs(listing): state cursor compatibility removal condition

* fix(listing): resume protected table pages from raw keys

* test(listing): keep protected pagination coverage in app layer
2026-10-28 02:59:46 +08:00
Chris An 5b5d68a444 fix(decommission): defer copies that time out on a namespace lock (#9006)
A node killed mid-copy leaves its namespace lock on the peers until the
lock lease expires. When the restarted leader resumes the decommission
and reaches that key inside the lease, every in-place copy attempt times
out acquiring the lock, and the version was counted as a failed object,
so the pool decommission could no longer complete.

A lock acquisition timeout leaves the source in place, like a version
copy that lost drive quorum, so defer the entry to the end of the round
instead of counting it as failed. A replay that still times out pauses
the decommission through the existing durable-pause path, which is
relaunched automatically.

This is the intermittent failure of the distributed e2e case
scanner_heal_topology_decommission_kill_before_source_cleanup_resumes_without_loss.
2026-10-28 02:59:20 +08:00
Henry Guo 0fb650a408 fix(table-catalog): reload peers after table bucket toggle (#8994)
* fix(table-catalog): reload peers after table bucket toggle

* test(table-catalog): cover bucket toggles across cached peers
2026-10-28 01:57:55 +08:00
Hauser b9f01162b2 feat(tools): inspect BTF scalar member layouts (#9003)
Report selected integer offsets and widths with strict member and payload validation. Cross-check compiler fixtures with bpftool and independent C offsetof/sizeof results.

Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-10-28 01:37:31 +08:00
Chris An 5ab1d5191e fix(heal): pace and parallelize replacement recovery (#9005) 2026-10-28 01:10:44 +08:00
overtrue f691431400 Merge remote-tracking branch 'origin/main' into overtrue/scanner-heal-identity-scope 2026-10-28 01:07:54 +08:00
Chris An acebee4ea3 fix(ecstore): select the latest version at read quorum like MinIO (#8982)
* fix(ecstore): select the latest version at read quorum like MinIO

GET and HEAD escalated latest-version selection to write quorum when
enough metadata answered and skipped a newer version below write quorum
on degraded reads. LIST and heal already pick the newer side of a split
write at read quorum, so such keys listed but answered 503 until heal.

Select with MinIO's getObjectFileInfo rule: the most common mod time
that reaches the object's data blocks wins, ties go to the newer time,
and one content identity at that time must reach the same quorum. Two
identities tied at the selected time fail instead of resolving by hash
order, and the selected identity's version-stack shape follows disk
order.

* fix(ecstore): resolve selection ties by disk order and guard writes

Review of the read-quorum selection found three gaps:

- Rejecting a same-time half split made explicit-version reads of a
  partially applied tagging update fail and blocked the retry that
  repairs it. Resolve ties to the identity on the lowest-index disk,
  for reads and heal alike, instead of rejecting or using map order.
- Delete markers and empty versions can lower the common read quorum
  below the selected version's data blocks, so HEAD could report a
  version GET cannot decode. Require that many copies, as MinIO's
  getLatestFileInfo does.
- A selection can now return exactly read-quorum disks, and partial
  metadata writes are not rolled back. Refuse an update before writing
  when its target disks cannot reach write quorum.

* fix(ecstore): count identity copies in the data-blocks guard

The first-candidate shortcut counts byte-identical copies, so copies
that differ only in identity-neutral fields such as replication status
were missing from the data-blocks check. When the shortcut's count
falls short, count every copy of the selected content identity before
refusing the version.

* fix(ecstore): keep commit-proof selection for system-bucket reads

Pool metadata's PREPARE/COMMIT recovery and identity epochs treat an
ordinary read of pool.bin as proof that the record committed. Under the
read-quorum rule, a COMMIT that reached only half of a 2+2 set was served
as committed, and the pool_meta_native_physical_* integration tests
failed.

Route reads through select_fileinfo_for_read: S3 buckets keep MinIO's
read-quorum rule, while is_meta_bucketname reads use the previous
algorithm unchanged as select_committed_fileinfo, so a split stays
unreadable until native recovery resolves it.
2026-10-28 00:47:50 +08:00
overtrue e776986d62 Merge remote-tracking branch 'origin/main' into overtrue/scanner-heal-identity-scope 2026-10-28 00:11:34 +08:00
Chris An 268a5519f1 test(ecstore): stabilize metadata fanout lifecycle checks (#8999) 2026-10-28 00:07:05 +08:00
overtrue fe8b36cd79 Merge remote-tracking branch 'origin/main' into overtrue/scanner-heal-identity-scope 2026-10-27 23:53:55 +08:00
overtrue 536f5bf96c test(scanner): simplify identity case types 2026-10-27 23:53:55 +08:00
Chris An e452d837dd docs(scanner): clarify incomplete census diagnostics (#9002) 2026-10-27 23:33:28 +08:00
Chris An 1451f3189b fix(listing): leave delete recovery out of list requests (#8980)
* fix(listing): leave delete recovery out of list requests

* test(listing): expect residue preservation across walks
2026-10-27 23:04:22 +08:00
overtrue 7739a37978 fix(scanner): distinguish missing heal identity fields 2026-10-27 22:39:38 +08:00
hector 59bc6d7c94 ci: build linux-aarch64 releases on Ubicloud ARM (#8996) 2026-10-27 21:18:05 +08:00
Ramakrishna Chilaka a56104206e perf(ecstore): reduce multipart source fencing directory and read I/O (#8986)
- Resolve existing directories below base on Linux in a single openat2(BENEATH | NO_SYMLINKS) syscall in open_existing_directory_below_base_std instead of opening and closing every ancestor component.
- Reuse the pinned source directory guard across sibling metadata reads and control probes in validate_source_mutation, load_source, OrdinaryMultipartDeleteCheck::validate, and prepare_multipart_commit_staging.
- Read normal multipart source heads (<= 64 KiB) in a single pass in load_source while preserving constant-memory streaming observation for large prepared histories.
- Record root_missing during load_source, reuse loaded.original in restore_original, and coalesce finish source directory fsync into persist_with_source_sync.
- Reuse the opened commit directory in prepare_rename_commit_guard and LinuxCommitDirectories::validate when source_parent == destination_parent.
2026-10-27 20:23:32 +08:00
Hauser be485e6cab test(scanner): add memory acceptance runner and budget probes (#8988)
* test(scanner): add memory acceptance runner and budget probes

Add owned S3 workload phases with process, endpoint, cgroup and binary
identity checks for scanner memory and scanner-disabled request bursts.
Keep scale, restart, heap-attribution and overall-acceptance limits explicit.

Characterize healthy and seeded coverage-gap cache retention through the
real folder walker and production cache codec without changing runtime code.

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* fix(scanner): bind acceptance capacity to effective storage

Validate the serving process's complete local disk inventory before load
writes and during runtime sampling. Resolve each disk inside the owned
storage root, reject nested mounts and filesystem mismatches, and bind
storage observations to phase identity for container and native runs.

Cover the unused-mount bypass, CLI-overridden environment, native paths,
symlink escapes, named volumes and stable multi-disk inventory ordering.

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* fix(scanner): fence status observations before workload writes

Close each status API window with a second storage-bound process sample
before Phase entry can authorize bucket creation or PUT requests.

Add a fail-before regression for a process change during the initial status
request, asserting no ownership or workload writes occur.

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

---------

Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-10-27 16:45:37 +08:00
Hauser fbd94e129d test(e2e): validate late multipart part transaction packets (#8989)
Retain original prepare and commit-settlement RPCs beyond failed replacement and Abort cleanup, with and without bucket recreation. Require typed refusal or a physically verified idempotent result, unchanged fresh-upload bytes and complete object reads.

Extend scoped proxy observations without rewriting signed frames or trailers, and preserve registered test membership and existing budgets.

Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-10-27 13:55:21 +08:00
Hauser cf5a9e0ba3 docs: define the dirty-journal metadata source contract (#8984)
* docs: define the dirty-journal metadata source contract

Specify the single node-owned journal scope: verified bootstrap, physical fencing and tail completion, reader-first replay, exact new responsibility settlement and retained legacy unknowns. Explicitly distinguish the proposed contract from the existing asynchronous hint implementation.

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* docs: require receiving peers to revalidate source claims

Keep user metadata, decoded records and authenticated transport distinct from storage-verified dirty-journal authority.

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

---------

Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-10-27 13:54:19 +08:00
Hauser 254592717a feat(tools): add read-only BTF prototype inspection (#8985)
* feat(tools): add read-only BTF prototype inspection

Expose bounded declared prototype headers through a standard-library CLI, preserve explicit void and failure semantics, and wire local contracts plus an independent compiler/bpftool reference into CI.

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* test(tools): retain native reference failure diagnostics

Clear checkout credentials and capture bounded native stderr alongside stdout, preserving exact exit status and owned child cleanup. Keep the first reference failure unresolved rather than guessing its missing diagnostics.

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* ci(tools): select a concrete bpftool file parser

The hosted PATH wrapper can exist without tools for its Azure kernel. Select an executable ELF by availability/version before comparing the fixed fixture, installing generic tools only when needed. Data comparison errors remain fatal and never trigger tool switching.

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

---------

Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-10-27 12:51:57 +08:00
Hauser aba9e2e9e6 test(e2e): cover partial EC:4 node recovery (#8983)
Verify a sixteen-drive set rejects reads and writes after two nodes stop,
then resumes complete S3 reads and new writes when only one node returns.
Keep the fourth node stopped throughout checks from all three live
coordinators and verify the original 12+4 shard geometry on disk.

Let the existing GET recovery probe retry the compatible SlowDownRead
response only with HTTP 503, retaining fatal status and byte checks.

Register the case in both platform selections and document the boundary
between process recovery and physical disk reattachment acceptance.

Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-10-27 12:38:38 +08:00
Ramakrishna Chilaka 924700a24f docs(agents): add ecstore syscall and blocking-dispatch perf guardrails (#8987)
- Document rules in crates/ecstore/AGENTS.md and the adversarial performance lens against per-disk spawn_blocking hopping, parent-chain openat amplification, un-sized read_to_end buffers, and redundant state reloads under the same lock.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: d85aaf47-2a4b-4c2c-97a3-dbe8c7717e2a
2026-10-27 11:52:20 +08:00
Chris An b8eef694c7 fix(heal): group newly attached replacement disks (#8973)
fix(heal): batch replacement targets before freezing recovery scope
2026-10-27 11:35:15 +08:00
hector 26d55bb04e ci: remove never-green Performance A/B workflow (#8979) 2026-10-27 11:09:32 +08:00
Hauser c9f7e48a44 fix(observability): correlate physical namespace lock releases (#8974)
* fix(observability): correlate physical namespace lock releases

Map logical namespace guards to their actual granted peer lock IDs and local physical guard identities. Record physical completion only after successful manager release and registry removal, and record validated positive remote unlock responses without changing release behavior.

Exercise real distributed ownership, asynchronous cleanup, duplicate and disabled guards, stale-owner retirement, and explicit release followed by Drop. Document the required diagnostic filters and the remaining filesystem-tail boundaries.

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* fix(ecstore): remove unused lock manager test import

The diagnostic regression uses inherent manager methods and does not need the LockManager trait import. Remove it to pass strict warning checks.

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

---------

Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-10-27 10:10:17 +08:00
Hauser 11e1662c90 fix(scanner): reduce memory retention and redundant scan work (#8971)
* fix(scanner): compact completed subtrees during checkpoint sweeps

Release completed object entries while preserving resumable frontier nodes,
including heal rewalks and coverage gaps. Borrow child links during usage
aggregation and remove duplicate update-cache subtree copies.

Add real-disk restart and allocation-retention regressions for #8961.

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* perf(scanner): avoid unused progress trees and traversal copies

Bind progress caches to actual subscribers and keep durable scanner metadata
in one authoritative cache. Remove mirror-only heal synchronization, use one
tagged folder queue, and consume child ownership when deleting subtrees.

Preserve observer output, restart coverage and failed-object retry semantics.

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* perf(scanner): compact completed leaf entries in place

Keep completed-folder eligibility and frontier handling in one compaction
boundary. Borrow leaf statistics and update the cached sampling flag without
cloning, deleting, or reinserting the entry; aggregate only non-leaf nodes.

Pin allocation identity, complete metadata, failure guards and caller state
with a regression that fails under the previous compaction implementation.

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

---------

Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-10-27 10:01:46 +08:00
Chris An 7ea3aa2a93 fix(listing): keep the V1 NextMarker escape and document optimal limits (#8976)
A cursor for a key that literally ends in a legacy-shaped tag is issued
wrapped in one extra `[rustfs_cache:v2,return:]`, because the listing layer
still strips one legacy tag from incoming markers. ListObjects (V1)
unwrapped it in `calculate_next_marker`; the client then sent the bare key
back as `marker`, the listing layer stripped the key's own tail, and the
next page restarted at the stem and returned the same key forever. Keep the
wrapper in V1 NextMarker and cover the V1 resume path (marker sent back as
StartAfter) on a real store.

Document that sampled list quorum modes can also omit committed objects
whose copies mostly sit outside the sample, and recommend `strict` for
backup, sync, replication and lifecycle tooling.
2026-10-27 08:16:34 +08:00
108 changed files with 8435 additions and 1659 deletions
@@ -6,7 +6,17 @@
- Route every new sync/flush through the durability-mode and bucket override
gates; mode `none` must not pay the new fsync.
- Keep blocking filesystem/CPU work off async runtime threads, but do not split
one small operation into many `spawn_blocking` round trips.
one small operation into many `spawn_blocking` round trips; coalesce `open`,
`read`/`write`, `fsync`, and `close` into one blocking closure per disk.
- Check for per-disk syscall amplification (`disks × parts/files`): pin a
directory once (`openat2` on Linux) and reuse the handle across sibling reads
instead of re-walking ancestor components per file or re-opening identical
`source_parent == destination_parent` directories.
- After `fstat`/`file.metadata()`, require `Vec::with_capacity(len + 1)` before
`read_to_end` so small metadata reads do not trigger 6–7 exponential-growth
`read()` syscalls.
- Avoid redundant `store.load()` or `root_missing()` round-trips under the same
exclusive lock when the state or original bytes were just loaded or persisted.
- Measure lock hold time across I/O and compare acquisition order for ABBA.
- Keep cleanup, extra stat/rename, and diagnostics out of the PUT commit critical
section when they need not be there.
+2 -2
View File
@@ -1,2 +1,2 @@
sha256-linux=14becc458b9eb24add510d94ee6cd04ba1fb19907d598effe1524e5242400d10
sha256-darwin=14becc458b9eb24add510d94ee6cd04ba1fb19907d598effe1524e5242400d10
sha256-linux=eb33067d90ee66c0a97e8f3658e46c152e47f897d2fbfdc6cb5ad93cfa55414b
sha256-darwin=eb33067d90ee66c0a97e8f3658e46c152e47f897d2fbfdc6cb5ad93cfa55414b
+1 -1
View File
@@ -229,7 +229,7 @@
{
"invariant": "list-objects-shared-corpus-orphan-cleanup-requires-authoritative-evidence",
"suite": "rustfs-ecstore",
"name": "store::list_objects::differential_tests::list_objects_shared_corpus_orphan_cleanup_requires_authoritative_evidence"
"name": "store::list_objects::differential_tests::list_objects_shared_corpus_orphan_listing_preserves_inputs"
},
{
"invariant": "list-objects-shared-corpus-consumer-drop-joins-real-disk-producer",
+1
View File
@@ -52,6 +52,7 @@ script-tests: ## Run shell script tests
./scripts/check_embedded_secrets.sh --self-test
$(RUSTFS_PYTHON_BIN) ./scripts/check_test_wiring.py --self-test
$(RUSTFS_PYTHON_BIN) ./scripts/test_e2e_binary.py
$(RUSTFS_PYTHON_BIN) ./scripts/test_inspect_btf_prototypes.py
$(RUSTFS_PYTHON_BIN) ./scripts/test_release_build_workflow.py
$(RUSTFS_PYTHON_BIN) ./scripts/test_release_glibc.py
$(RUSTFS_PYTHON_BIN) ./scripts/test_upgrade_workflow.py
@@ -74,7 +74,6 @@ Notes:
- `.github/workflows/e2e-s3tests.yml` (weekly full s3-tests sweep)
- `.github/workflows/mint.yml` (weekly multi-SDK mint run)
- `.github/workflows/fuzz.yml` (nightly fuzz corpus jobs)
- `.github/workflows/performance-ab.yml` (nightly warp A/B gate)
- **Pending:** the ci-7 e2e nightly-full workflow does not exist yet
(backlog#1149 ci-7). When it lands, wire it up with the snippet above.
-1
View File
@@ -18,7 +18,6 @@
"never_ran_grace_until": "2026-09-08T00:00:00Z"
},
{ "workflow": ".github/workflows/nightly-gnu.yml", "max_age_hours": 36 },
{ "workflow": ".github/workflows/performance-ab.yml", "max_age_hours": 36 },
{
"workflow": ".github/workflows/runner-hygiene.yml",
"max_age_hours": 792,
+4 -4
View File
@@ -7,7 +7,7 @@
This repository is public. For a public repository, GitHub's standard hosted runners (`ubuntu-latest`, `windows-latest`, `macos-latest`) cost nothing and have no minute cap, whatever plan the organisation is on. GitHub bills only two things: larger hosted runners, which have custom labels and are not used here, and standard runners in private repositories.
Every other label in this repository is capacity RustFS pays for by the hour whether or not a job is running well:
Every other label in this repository is capacity RustFS pays for. ARC pods and lab machines accrue cost while the machine exists. A Ubicloud label is metered for the minutes a job holds its VM:
| Label | Paid by | What it is | Use it for |
|---|---|---|---|
@@ -15,7 +15,7 @@ Every other label in this repository is capacity RustFS pays for by the hour whe
| `windows-latest`, `macos-latest` | nobody | GitHub-hosted standard runners | Windows and macOS targets |
| `sm-standard-2` | RustFS | ARC pod, Ubuntu 24.04, minimal image: no `gh`, no C toolchain; Docker only on the `dind-` label | only a job that a hosted runner cannot run (see the placement rule) |
| `sm-standard-4` | RustFS | ARC pod, Ubuntu 24.04, 14 CPUs and 28 GB (the label is not the core count), large disk | workspace-scale Cargo builds |
| `sm-standard-4-arm` | RustFS | ARC pod, aarch64; reached through the `build.yml` matrix, so `actionlint.yaml` does not list it | native aarch64 release builds |
| `ubicloud-standard-16-arm` | RustFS | Ubicloud VM, alias of `ubicloud-standard-16-arm-ubuntu-2404`: Ubuntu 24.04 arm64, 16 vCPU, 48 GB, 300 GB disk. Reached through the `build.yml` matrix, so `actionlint.yaml` does not list it | native aarch64 release builds (`linux-aarch64-musl`, `linux-aarch64-gnu`) |
| `dind-sm-standard-2` | RustFS | ARC pod with Docker-in-Docker | image builds that need `buildx` and a registry push |
| `smoke-testing`, `pf-testing` | RustFS | long-lived lab machines | the packaged functional chain and performance suites |
@@ -40,7 +40,7 @@ A hosted runner is also the only choice for a few things the pods cannot do: `mo
- Every `shared-key` has exactly one writer. `cache-warm.yml` owns `ci-dev`, `ci-feat-rio`, `ci-feat-proto` and `ci-uring`; every other `./.github/actions/setup` call states `cache-save-if` explicitly, and `scripts/security/check_cache_save_if.sh` fails the build when one does not.
- All readers of a key run on the same image as its writer. Keys that live on hosted runners say so in their name (`ci-uring` is warmed on `ubuntu-latest`; `ci-e2e-distributed-hosted`, `e2e-upgrade-server-hosted`, `e2e-upgrade-tests-hosted`).
- Moving a job between pools therefore means renaming its key. The new key is empty until a run from `main` (a scheduled run, or a manual dispatch of that workflow from `main`) saves it; until then that lane does a cold build.
- Moving a job between pools therefore means renaming its key. The new key is empty until a run from `main` (a scheduled run, or a manual dispatch of that workflow from `main`) saves it; until then that lane does a cold build. The `build.yml` aarch64 release legs append `-ubicloud-arm` for this reason. `cache-warm.yml` does not write those keys.
- `CARGO_BUILD_JOBS` and every other `CARGO*`, `CC*`, `CFLAGS*`, `CXX*`, `CMAKE*` and `RUST*` variable is hashed into the key. `ci.yml` sets `CARGO_BUILD_JOBS` at step level for that reason; promoting it to job level rotates the key for every lane that shares it.
## Where self-hosted minutes leak
@@ -62,7 +62,7 @@ gh api "repos/rustfs/rustfs/actions/runs/<run_id>/jobs?per_page=100" \
--jq '.jobs[] | "\(.conclusion)\t\(.labels | join(","))\t\(.started_at)\t\(.completed_at)\t\(.name)"'
```
Only rows whose label starts with `sm-`, `dind-`, `smoke-` or `pf-` cost anything. At the October 2026 review, a full pull-request run of `ci.yml` spent about 150 self-hosted minutes, a third of it in jobs that never compiled; treat that as the scale of what a placement mistake costs, not as a current figure.
Only rows whose label starts with `sm-`, `dind-`, `ubicloud-`, `smoke-` or `pf-` cost anything. At the October 2026 review, a full pull-request run of `ci.yml` spent about 150 self-hosted minutes, a third of it in jobs that never compiled; treat that as the scale of what a placement mistake costs, not as a current figure.
## Checklist for a new or moved job
-5
View File
@@ -29,7 +29,6 @@ on:
- 'scripts/test_package_service_scripts.sh'
- 'scripts/test_package_versions.sh'
- 'scripts/test_docker_workflow.py'
- 'scripts/security/check_performance_ab_workflow.sh'
- 'scripts/security/check_preview_release_workflow.sh'
- 'scripts/security/check_tier_artifact_workflow.sh'
- 'scripts/security/check_workflow_pins.sh'
@@ -48,7 +47,6 @@ on:
- 'scripts/test_package_service_scripts.sh'
- 'scripts/test_package_versions.sh'
- 'scripts/test_docker_workflow.py'
- 'scripts/security/check_performance_ab_workflow.sh'
- 'scripts/security/check_preview_release_workflow.sh'
- 'scripts/security/check_tier_artifact_workflow.sh'
- 'scripts/security/check_workflow_pins.sh'
@@ -155,9 +153,6 @@ 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
- name: Check tier evidence workflow isolation
run: ./scripts/security/check_tier_artifact_workflow.sh
@@ -0,0 +1,75 @@
name: BTF prototype reference
on:
pull_request:
paths:
- "scripts/inspect_btf_prototypes.py"
- "scripts/test_inspect_btf_prototypes.py"
- "scripts/fixtures/btf_prototypes.c"
- ".github/workflows/btf-prototype-reference.yml"
workflow_dispatch:
permissions:
contents: read
concurrency:
group: btf-prototype-reference-${{ github.ref }}
cancel-in-progress: true
jobs:
reference:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
- name: Check prototype and CLI contracts
run: python3 -B scripts/test_inspect_btf_prototypes.py
- name: Install only missing native reference tools
run: |
select_bpftool() {
local candidate
local candidates=()
if command -v bpftool >/dev/null; then
candidates+=("$(command -v bpftool)")
fi
for candidate in /usr/lib/linux-tools-*/bpftool /usr/lib/linux-tools/*/bpftool; do
if [[ -x "$candidate" ]]; then
candidates+=("$candidate")
fi
done
while IFS= read -r candidate; do
if [[ -x "$candidate" ]] && file -Lb "$candidate" | grep -q 'ELF' \
&& "$candidate" version >/dev/null 2>&1; then
printf '%s\n' "$candidate"
return 0
fi
done < <(printf '%s\n' "${candidates[@]}" | sort -Vr)
return 1
}
packages=()
command -v clang >/dev/null || packages+=(clang)
command -v llvm-objcopy >/dev/null || packages+=(llvm)
command -v gcc >/dev/null || packages+=(gcc)
select_bpftool >/dev/null || packages+=(linux-tools-generic)
if (( ${#packages[@]} )); then
sudo apt-get update
sudo apt-get install --no-install-recommends -y "${packages[@]}"
fi
# File parsing needs a working binary, not a host-kernel wrapper.
selected_bpftool="$(select_bpftool)"
"$selected_bpftool" version
printf 'BPFTOOL=%s\n' "$selected_bpftool" >> "$GITHUB_ENV"
- name: Compare declarations and layouts with independent references
run: |
clang -target bpf -O2 -g -c scripts/fixtures/btf_prototypes.c -o "$RUNNER_TEMP/prototypes.o"
llvm-objcopy --dump-section ".BTF=$RUNNER_TEMP/prototypes.btf" "$RUNNER_TEMP/prototypes.o"
gcc -std=c11 -Wall -Wextra -Werror -DRUSTFS_BTF_LAYOUT_REFERENCE \
scripts/fixtures/btf_prototypes.c -o "$RUNNER_TEMP/layout-reference"
"$RUNNER_TEMP/layout-reference" > "$RUNNER_TEMP/layout-reference.json"
python3 -B scripts/test_inspect_btf_prototypes.py --native-reference "$RUNNER_TEMP/prototypes.btf" \
--layout-reference "$RUNNER_TEMP/layout-reference.json"
+17 -7
View File
@@ -181,12 +181,18 @@ jobs:
selected="all"
fi
# linux-aarch64-* need a real aarch64 host (placement rule: hardware)
# so the packaged binary and console smoke execute the artifact.
# ubicloud-standard-16-arm is that host: Ubuntu 24.04 arm64, alias of
# ubicloud-standard-16-arm-ubuntu-2404 (16 vCPU, 48 GB). linux-x86_64-* stay on the
# ARC sm-standard-4 pool. GNU rows keep cross=true so cargo-zigbuild
# pins the glibc 2.31 floor on the same architecture; musl stays a
# native cargo build.
all='{"include":[
{"target_id":"linux-x86_64-musl","os":"sm-standard-4","target":"x86_64-unknown-linux-musl","cross":false,"platform":"linux","rustflags":"","features":"","glibc":""},
{"target_id":"linux-aarch64-musl","os":"sm-standard-4-arm","target":"aarch64-unknown-linux-musl","cross":false,"platform":"linux","rustflags":"","features":"","glibc":""},
{"target_id":"linux-aarch64-musl","os":"ubicloud-standard-16-arm","target":"aarch64-unknown-linux-musl","cross":false,"platform":"linux","rustflags":"","features":"","glibc":""},
{"target_id":"linux-x86_64-gnu","os":"sm-standard-4","target":"x86_64-unknown-linux-gnu","cross":true,"platform":"linux","rustflags":"","features":"pyroscope","glibc":"2.31"},
{"target_id":"linux-aarch64-gnu","os":"sm-standard-4-arm","target":"aarch64-unknown-linux-gnu","cross":true,"platform":"linux","rustflags":"","features":"pyroscope","glibc":"2.31"},
{"target_id":"linux-aarch64-gnu","os":"ubicloud-standard-16-arm","target":"aarch64-unknown-linux-gnu","cross":true,"platform":"linux","rustflags":"","features":"pyroscope","glibc":"2.31"},
{"target_id":"macos-aarch64","os":"macos-latest","target":"aarch64-apple-darwin","cross":false,"platform":"macos","rustflags":"","features":"pyroscope","glibc":""},
{"target_id":"windows-x86_64","os":"windows-latest","target":"x86_64-pc-windows-msvc","cross":false,"platform":"windows","rustflags":"","features":"","glibc":""}
]}'
@@ -244,7 +250,11 @@ jobs:
with:
rust-version: stable
target: ${{ matrix.target }}
cache-shared-key: build-${{ matrix.target }}${{ matrix.glibc && format('-glibc-{0}', matrix.glibc) || '' }}
# rust-cache keys include OS and architecture but not the runner image.
# The Ubicloud aarch64 legs therefore use their own suffix so they
# cannot restore a cache written on a different arm64 image.
# x86_64, macOS, and Windows keys stay unsuffixed.
cache-shared-key: build-${{ matrix.target }}${{ matrix.glibc && format('-glibc-{0}', matrix.glibc) || '' }}${{ matrix.os == 'ubicloud-standard-16-arm' && '-ubicloud-arm' || '' }}
# main only. A cache saved on refs/tags/X is scoped to that tag: no
# other tag, no main run and no PR can restore it, so every release
# cycle wrote up to 12 entries of 1-2GB (preview tag plus final tag,
@@ -764,9 +774,9 @@ jobs:
exit 0
fi
# The self-hosted Linux runners do not ship the aws CLI (GitHub-hosted
# images did). Install the official CLI bundle on demand so R2 uploads
# survive a fresh runner without relying on a distro package.
# ARC pods (the x86_64 legs) do not ship the aws CLI. Ubicloud arm64
# images follow the GitHub runner image and often do. Install the
# official CLI bundle when it is missing so R2 uploads work on either.
if ! command -v aws >/dev/null 2>&1; then
echo "aws CLI not found on runner; installing..."
if command -v apt-get >/dev/null 2>&1; then
+1 -1
View File
@@ -45,7 +45,7 @@ on:
# 07:00 UTC Sunday — staggered clear of the other Sunday crons: ci (00:00),
# build (01:00), e2e-s3tests (02:00), audit (03:00), nix-flake-update
# (05:00), mint (06:00), and the daily fuzz (02:00), minio-interop (03:17),
# e2e-replication-nightly (04:00) and performance-ab (06:00) lanes.
# and e2e-replication-nightly (04:00) lanes.
- cron: "43 7 * * 0"
concurrency:
@@ -38,8 +38,8 @@ name: e2e-nightly
on:
workflow_dispatch:
schedule:
# 04:00 UTC nightly — staggered clear of fuzz/e2e-s3tests (02:00),
# stale (01:30) and performance-ab (06:00).
# 04:00 UTC nightly — staggered clear of fuzz/e2e-s3tests (02:00)
# and stale (01:30).
- cron: "29 4 * * *"
# Only alert-on-failure needs more than read access; it declares its own
-384
View File
@@ -1,384 +0,0 @@
# Copyright 2024 RustFS Team
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# Warp A/B budget gate for the hotpath series (rustfs/backlog#935 HP-14, item 4).
#
# Two entry points, honestly scoped:
# * schedule (nightly, on main): post-merge detection — catches a regression
# within 24h of landing, not before merge.
# * 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).
name: Performance A/B
on:
schedule:
- cron: "31 6 * * *" # 06:31 UTC nightly, against main
workflow_dispatch:
inputs:
duration:
description: "warp duration per round"
required: false
default: "12s"
type: string
allow_regression:
description: "Pass the gate despite a FAIL (deliberate tradeoff)"
required: false
default: false
type: boolean
permissions:
actions: read
contents: read
env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1
jobs:
warp-ab:
if: github.repository == 'rustfs/rustfs'
name: Warp A/B budget gate
runs-on: sm-standard-2
# A normal nightly restores the last successful binary and builds only the
# candidate; daily access keeps that cache warm. A cache miss may build both
# and needs room for the A/B run plus artifact and cache publication.
timeout-minutes: 180
steps:
- name: Checkout repository
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
fetch-depth: 0 # baseline may be an earlier successful scheduled head
- name: Setup Rust environment
uses: ./.github/actions/setup
with:
rust-version: stable
cache-shared-key: warp-ab-${{ hashFiles('**/Cargo.lock') }}
cache-save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Install warp
run: |
set -euo pipefail
WARP_VERSION="1.0.0"
curl -fsSL "https://github.com/minio/warp/releases/download/v${WARP_VERSION}/warp_Linux_x86_64.tar.gz" \
| sudo tar -xz -C /usr/local/bin warp
warp --version
- name: Decide exemption
id: exempt
env:
INPUT_ALLOW_REGRESSION: ${{ github.event.inputs.allow_regression }}
run: |
allow="false"
if [[ "$INPUT_ALLOW_REGRESSION" == "true" ]]; then
allow="true"
fi
echo "allow_regression=$allow" >> "$GITHUB_OUTPUT"
# A failed regression run must keep comparing against the last known-good
# scheduled head. Otherwise the next nightly would absorb the regression
# into its baseline and turn green without a fix.
- name: Find last successful scheduled baseline
id: scheduled_baseline
if: github.event_name == 'schedule'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
result-encoding: string
script: |
const { data } = await github.rest.actions.listWorkflowRuns({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: "performance-ab.yml",
event: "schedule",
status: "success",
per_page: 1,
});
return data.workflow_runs[0]?.head_sha ?? "";
# Manual runs compare a selected ref with current main. Scheduled runs
# compare current main with the last successful scheduled head. With no
# history, the first run measures the candidate against itself and seeds
# that head only if the complete rig succeeds.
- name: Resolve baseline / candidate commits
id: commits
env:
SCHEDULED_BASELINE_SHA: ${{ steps.scheduled_baseline.outputs.result }}
run: |
set -euo pipefail
candidate_sha="$(git rev-parse HEAD)"
if [[ "${{ github.event_name }}" == "schedule" ]]; then
baseline_sha="${SCHEDULED_BASELINE_SHA:-$candidate_sha}"
else
baseline_sha="$(git rev-parse origin/main)"
fi
git cat-file -e "${baseline_sha}^{commit}"
if ! git merge-base --is-ancestor "$baseline_sha" "$candidate_sha"; then
echo "::error::baseline $baseline_sha is not an ancestor of candidate $candidate_sha; update the selected ref before comparing" >&2
exit 1
fi
echo "baseline_sha=$baseline_sha" >> "$GITHUB_OUTPUT"
echo "candidate_sha=$candidate_sha" >> "$GITHUB_OUTPUT"
echo "baseline commit: $baseline_sha"
echo "candidate commit: $candidate_sha"
# Exact-key restore of the candidate binary saved by its successful
# scheduled run. A miss leaves cache-hit unset and falls back to a source
# build of that known-good head.
- name: Restore cached baseline binary
id: baseline_cache
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: baseline-bin/rustfs
key: rustfs-baseline-${{ steps.commits.outputs.baseline_sha }}
# Self-heal: on a nightly/dispatch run where the candidate commit IS the
# baseline commit, a cache miss would make the rig build the same commit
# twice (~65min per side with the post-#4806 LTO profile — no job budget
# fits that). Build it once here, reuse it for both phases, and save it
# back to the cache so the next run hits.
- name: Build baseline on cache miss (same-commit self-heal)
id: selfheal
if: >-
steps.baseline_cache.outputs.cache-hit != 'true' &&
steps.commits.outputs.baseline_sha == steps.commits.outputs.candidate_sha
run: |
set -euo pipefail
cargo build --release --bin rustfs
mkdir -p baseline-bin
cp target/release/rustfs baseline-bin/rustfs
echo "built=true" >> "$GITHUB_OUTPUT"
- name: Build baseline on cache miss (different candidate)
id: baseline_build
if: >-
steps.baseline_cache.outputs.cache-hit != 'true' &&
steps.commits.outputs.baseline_sha != steps.commits.outputs.candidate_sha
run: |
set -euo pipefail
baseline_root="$RUNNER_TEMP/rustfs-baseline-${{ github.run_id }}"
baseline_target="$RUNNER_TEMP/rustfs-baseline-target-${{ github.run_id }}"
git worktree add --detach "$baseline_root" "${{ steps.commits.outputs.baseline_sha }}"
cargo build --release --manifest-path "$baseline_root/Cargo.toml" --bin rustfs --target-dir "$baseline_target"
mkdir -p baseline-bin
cp "$baseline_target/release/rustfs" baseline-bin/rustfs
git worktree remove --force "$baseline_root"
echo "built=true" >> "$GITHUB_OUTPUT"
- name: Build candidate binary
id: candidate_build
if: steps.commits.outputs.baseline_sha != steps.commits.outputs.candidate_sha
run: |
set -euo pipefail
cargo build --release --bin rustfs
mkdir -p candidate-bin
cp target/release/rustfs candidate-bin/rustfs
echo "built=true" >> "$GITHUB_OUTPUT"
- name: Save self-healed baseline to cache
if: steps.selfheal.outputs.built == 'true' || steps.baseline_build.outputs.built == 'true'
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: baseline-bin/rustfs
key: rustfs-baseline-${{ steps.commits.outputs.baseline_sha }}
- name: Run warp A/B and gate
id: ab
env:
INPUT_DURATION: ${{ github.event.inputs.duration }}
run: |
set -euo pipefail
# The formal runner executes A1 baseline -> B1 candidate -> B2 candidate
# -> A2 baseline for each workload and drive-sync cell. It requires three
# rounds per leg to emit tail latency and error-rate evidence.
# --health-timeout 180 outlasts the server's own 120s startup-readiness
# budget, which the rig's previous 60s health poll undershot (the first
# two nightly failures). perf-6 recalibrates these once the noise study
# lands.
duration="${INPUT_DURATION:-12s}"
baseline_sha="${{ steps.commits.outputs.baseline_sha }}"
candidate_sha="${{ steps.commits.outputs.candidate_sha }}"
baseline_hit="${{ steps.baseline_cache.outputs.cache-hit }}"
selfheal_built="${{ steps.selfheal.outputs.built }}"
baseline_built="${{ steps.baseline_build.outputs.built }}"
candidate_built="${{ steps.candidate_build.outputs.built }}"
args=(--duration "$duration" --rounds 3 --cooldown 5 --health-timeout 180 --baseline-revision "$baseline_sha" --candidate-revision "$candidate_sha")
if [[ "$baseline_hit" == "true" || "$selfheal_built" == "true" || "$baseline_built" == "true" ]]; then
chmod +x baseline-bin/rustfs
base_bin="$PWD/baseline-bin/rustfs"
args+=(--baseline-bin "$base_bin")
if [[ "$baseline_hit" == "true" ]]; then
base_src="actions-cache (rustfs-baseline-$baseline_sha)"
elif [[ "$selfheal_built" == "true" ]]; then
base_src="source build (cache self-heal, saved as rustfs-baseline-$baseline_sha)"
else
base_src="isolated baseline source build (saved as rustfs-baseline-$baseline_sha)"
fi
if [[ "$candidate_sha" == "$baseline_sha" ]]; then
# No commits landed since the last successful baseline, so reuse
# the one binary for both phases and measure only rig drift.
args+=(--candidate-bin "$base_bin")
cand_src="same binary as baseline (same commit)"
elif [[ "$candidate_built" == "true" ]]; then
chmod +x candidate-bin/rustfs
args+=(--candidate-bin "$PWD/candidate-bin/rustfs")
cand_src="source build of the checked-out ref"
else
echo "::error::candidate binary was not built" >&2
exit 2
fi
else
echo "::error::baseline binary was not restored or built" >&2
exit 2
fi
echo "baseline binary: $base_src"
echo "candidate binary: $cand_src"
if [[ "${{ steps.exempt.outputs.allow_regression }}" == "true" ]]; then
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 step summary is written.
set +e
bash scripts/run_hotpath_warp_abba.sh "${args[@]}"
echo "status=$?" >> "$GITHUB_OUTPUT"
set -e
# Locate the newest run dir + gate.md for the summary/comment/artifact
# steps. On a startup failure there is no gate.md, but the run dir still
# holds server-logs/ for diagnosis.
# Run dirs are UTC-timestamp names (no special chars); ls is safe here.
# shellcheck disable=SC2012
run_dir="$(ls -td target/hotpath-abba/*/ 2>/dev/null | head -n1 || true)"
echo "run_dir=${run_dir%/}" >> "$GITHUB_OUTPUT"
# shellcheck disable=SC2012
gate_md="$(ls -t target/hotpath-abba/*/candidate_gate.md 2>/dev/null | head -n1 || true)"
echo "gate_md=$gate_md" >> "$GITHUB_OUTPUT"
- name: Upload A/B results
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: hotpath-warp-ab-${{ github.run_number }}
# Includes per-cell median_summary.csv / baseline_compare.csv, both gates,
# and server-logs/ (rustfs.log + startup env per phase) so a failed run
# is diagnosable. Short retention: this is churny nightly debug data.
path: target/hotpath-abba/
if-no-files-found: warn
retention-days: 14
- name: Write gate summary
if: always()
run: |
set -euo pipefail
status="${{ steps.ab.outputs.status }}"
gate_md="${{ steps.ab.outputs.gate_md }}"
run_dir="${{ steps.ab.outputs.run_dir }}"
{
echo "## Hotpath warp A/B — run ${{ github.run_number }}"
echo
if [[ "$status" == "0" ]]; then
echo "Rig/gate exit: \`0\` (pass or warn)."
else
echo "Rig/gate exit: \`${status:-unknown}\` — **FAILED**."
fi
echo
if [[ -n "$gate_md" && -f "$gate_md" ]]; then
cat "$gate_md"
else
echo "No \`gate.md\` produced — the rig failed **before** the gate"
echo "(most likely server startup / health). Failing phase(s) below;"
echo "full logs in the \`hotpath-warp-ab-${{ github.run_number }}\` artifact."
if [[ -n "$run_dir" && -d "$run_dir/server-logs" ]]; then
for f in "$run_dir"/server-logs/*.log; do
[[ -f "$f" ]] || continue
echo
echo "<details><summary>$(basename "$f")</summary>"
echo
echo '```'
tail -n 30 "$f"
echo '```'
echo "</details>"
done
fi
fi
} >> "$GITHUB_STEP_SUMMARY"
- name: Stage successful candidate baseline
if: >-
steps.ab.outputs.status == '0' &&
steps.commits.outputs.baseline_sha != steps.commits.outputs.candidate_sha
run: |
set -euo pipefail
cp candidate-bin/rustfs baseline-bin/rustfs
- name: Cache successful candidate baseline
if: >-
steps.ab.outputs.status == '0' &&
steps.commits.outputs.baseline_sha != steps.commits.outputs.candidate_sha
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: baseline-bin/rustfs
key: rustfs-baseline-${{ steps.commits.outputs.candidate_sha }}
# Scheduled failure alerting is handled by the alert-on-failure job below
# (perf-2 consuming ci-8's schedule-failure-issue composite action).
- name: Enforce gate
if: always()
run: |
status="${{ steps.ab.outputs.status }}"
if [[ -z "$status" ]]; then
echo "::error::warp A/B setup failed before the rig ran. Check the first failed workflow step." >&2
exit 1
fi
if [[ "$status" != "0" ]]; then
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."
alert-on-failure:
name: Alert on scheduled failure
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); 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.
if: >-
github.repository == 'rustfs/rustfs' && (
always() && github.event_name == 'schedule' &&
(contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled'))
)
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
issues: write
steps:
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
- name: Open or update failure-tracking issue
uses: ./.github/actions/schedule-failure-issue
with:
github-token: ${{ secrets.BACKLOG_ISSUE_TOKEN }}
@@ -16,8 +16,8 @@
#
# Forces a job failure and then runs .github/actions/schedule-failure-issue
# through the exact `needs` + `always() && contains(needs.*.result,
# 'failure')` wiring used by the real consumers (e2e-s3tests, mint, fuzz,
# performance-ab). Dispatch it twice to verify both the issue-creation and
# 'failure')` wiring used by the real consumers (e2e-s3tests, mint, fuzz).
# Dispatch it twice to verify both the issue-creation and
# the dedupe-comment paths, then close the resulting
# "[scheduled-failure] Schedule Failure Alert Drill" issue in rustfs/backlog
# (the default target repository). Requires the BACKLOG_ISSUE_TOKEN secret
@@ -28,7 +28,6 @@ on:
- "mint"
- "minio-interop"
- "Nightly GNU Build"
- "Performance A/B"
- "Runner Hygiene"
types: [completed]
+132 -3
View File
@@ -13,18 +13,20 @@
// limitations under the License.
use super::harness::{
DistCluster, DistLayout, TestResult, assert_object_bytes, payload_for, put_object, retrying_get_equals, unique_bucket,
wait_for_ready, wait_until,
DistCluster, DistLayout, TestResult, assert_object_bytes, cluster_admin_ok, get_object_bytes, payload_for, put_object,
retrying_get_equals, retrying_put, s3_probe_client, s3_probe_error_code, unique_bucket, wait_for_ready, wait_until,
};
use crate::chaos::{census_object_version_on_disk, signed_admin_post};
use crate::common::{build_test_s3_config, init_logging};
use crate::fault_proxy::FaultMode;
use aws_sdk_s3::Client;
use aws_sdk_s3::error::SdkError;
use aws_sdk_s3::operation::{get_object::GetObjectError, put_object::PutObjectError};
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::{Barrier, mpsc};
use tokio::time::timeout;
use tokio::time::{Instant, timeout};
#[tokio::test]
async fn kill_and_restart_node_preserves_objects() -> TestResult {
@@ -44,6 +46,133 @@ async fn kill_and_restart_node_preserves_objects() -> TestResult {
Ok(())
}
#[tokio::test]
async fn ec4_partial_node_return_restores_read_write_quorum() -> TestResult {
init_logging();
let mut dist = DistCluster::start_with_env(
DistLayout::FourByFour,
&[
("RUSTFS_ERASURE_SET_DRIVE_COUNT", "16"),
("RUSTFS_STORAGE_CLASS_STANDARD", "EC:4"),
],
)
.await?;
let storage = cluster_admin_ok(&dist.cluster, http::Method::GET, "/rustfs/admin/v3/storageinfo", None).await?;
let storage: serde_json::Value = serde_json::from_str(&storage)?;
let storage: rustfs_madmin::StorageInfo = serde_json::from_value(storage["info"].clone())?;
assert_eq!(storage.disks.len(), 16);
assert_eq!(storage.backend.total_sets, vec![1]);
assert_eq!(storage.backend.drives_per_set, vec![16]);
assert_eq!(storage.backend.standard_sc_parity, Some(4));
let bucket = unique_bucket("partial-ec4");
dist.create_bucket(&bucket).await?;
let client = s3_probe_client(&dist.client(0)?);
let baseline = payload_for("partial-recovery/baseline", 8 * 1024 * 1024);
put_object(&client, &bucket, "baseline.bin", baseline.clone()).await?;
wait_until(
Duration::from_secs(30),
|| async {
for node in &dist.cluster.nodes {
for directory in &node.data_dirs {
let census = census_object_version_on_disk(std::path::Path::new(directory), &bucket, "baseline.bin", None)?;
if !census.is_complete() {
return Ok(false);
}
assert_eq!(census.data_blocks, Some(12), "the object must require twelve data shards");
assert_eq!(census.parity_blocks, Some(4), "the object must have four parity shards");
assert!(!census.expected_part_numbers.is_empty(), "the baseline must use physical shards");
}
}
Ok(true)
},
"the baseline is complete on all sixteen EC:4 drives",
)
.await?;
dist.cluster.stop_node(2)?;
dist.cluster.stop_node(3)?;
let read_error = timeout(Duration::from_secs(60), get_object_bytes(&client, &bucket, "baseline.bin"))
.await?
.expect_err("eight unavailable drives must prevent the baseline GET");
assert!(
matches!(
s3_probe_error_code(read_error.as_ref()),
Some("SlowDown" | "SlowDownRead" | "ServiceUnavailable")
),
"quorum loss must produce an availability error: {read_error:?}"
);
assert_eq!(
read_error
.downcast_ref::<SdkError<GetObjectError>>()
.and_then(SdkError::raw_response)
.map(|response| response.status().as_u16()),
Some(503),
"read quorum loss must remain a service availability failure"
);
let write_error = timeout(Duration::from_secs(60), put_object(&client, &bucket, "unavailable.bin", baseline.clone()))
.await?
.expect_err("eight unavailable drives must prevent a successful PUT");
assert!(
matches!(s3_probe_error_code(write_error.as_ref()), Some("SlowDown" | "ServiceUnavailable")),
"quorum loss must produce an availability error: {write_error:?}"
);
assert_eq!(
write_error
.downcast_ref::<SdkError<PutObjectError>>()
.and_then(SdkError::raw_response)
.map(|response| response.status().as_u16()),
Some(503),
"write quorum loss must remain a service availability failure"
);
// Recover only four drives. The remaining node must stay stopped while
// every live coordinator serves old data and acknowledges new writes.
dist.cluster.start_node_process(2).await?;
let recovery_deadline = Instant::now() + Duration::from_secs(90);
let mut recovered_objects = Vec::new();
for node in 0..3 {
assert!(dist.cluster.nodes[3].process.is_none(), "the fourth node must remain unavailable");
let client = s3_probe_client(&dist.client(node)?);
retrying_get_equals(
&client,
&bucket,
"baseline.bin",
&baseline,
recovery_deadline.saturating_duration_since(Instant::now()),
)
.await?;
let key = format!("partial-{node}.bin");
let body = payload_for(&key, 1024 * 1024);
retrying_put(
&client,
&bucket,
&key,
body.clone(),
recovery_deadline.saturating_duration_since(Instant::now()),
)
.await?;
recovered_objects.push((key, body));
}
for node in 0..3 {
assert!(dist.cluster.nodes[3].process.is_none(), "reads must complete before all drives return");
let client = s3_probe_client(&dist.client(node)?);
assert_object_bytes(&client, &bucket, "baseline.bin", &baseline).await?;
for (key, body) in &recovered_objects {
assert_object_bytes(&client, &bucket, key, body).await?;
}
}
dist.cluster.start_node(3).await?;
wait_for_ready(&dist.cluster).await?;
let client = s3_probe_client(&dist.client(3)?);
assert_object_bytes(&client, &bucket, "baseline.bin", &baseline).await?;
for (key, body) in &recovered_objects {
assert_object_bytes(&client, &bucket, key, body).await?;
}
Ok(())
}
#[tokio::test]
async fn full_cluster_restart_preserves_objects() -> TestResult {
init_logging();
+66 -5
View File
@@ -1181,11 +1181,16 @@ pub(crate) async fn retrying_get_equals(
)
.into()),
Err(error) => {
if matches!(s3_probe_error_code(error.as_ref()), Some("SlowDown" | "ServiceUnavailable")) {
Ok(false)
} else {
Err(error)
}
let retryable = match s3_probe_error_code(error.as_ref()) {
Some("SlowDown" | "ServiceUnavailable") => true,
// GET preserves this compatibility code for a lost data quorum.
Some("SlowDownRead") => error
.downcast_ref::<SdkError<GetObjectError>>()
.and_then(SdkError::raw_response)
.is_some_and(|response| response.status().as_u16() == 503),
_ => false,
};
if retryable { Ok(false) } else { Err(error) }
}
}
},
@@ -1324,6 +1329,62 @@ mod retry_tests {
Ok(())
}
#[tokio::test(start_paused = true)]
async fn retrying_get_allows_read_quorum_recovery() -> TestResult {
use aws_sdk_s3::config::Region;
use aws_smithy_http_client::test_util::{ReplayEvent, StaticReplayClient};
use aws_smithy_types::body::SdkBody;
let endpoint = "http://read-quorum.example.invalid";
let event = |status: u16, body: &'static str| {
ReplayEvent::new(
http::Request::builder()
.method(Method::GET)
.uri(format!("{endpoint}/retry-probe/key?x-id=GetObject"))
.body(SdkBody::empty())
.expect("build replay request"),
http::Response::builder()
.status(status)
.header("content-type", "application/xml")
.header("content-length", body.len())
.body(SdkBody::from(body))
.expect("build replay response"),
)
};
for status in [503, 403, 500] {
let transport = StaticReplayClient::new(vec![
event(status, "<Error><Code>SlowDownRead</Code></Error>"),
event(200, "expected bytes"),
]);
let client = Client::from_conf(
aws_sdk_s3::Config::builder()
.behavior_version_latest()
.credentials_provider(Credentials::new(FAKE_ACCESS_KEY, FAKE_SECRET_KEY, None, None, "read-quorum-test"))
.region(Region::new("us-east-1"))
.endpoint_url(endpoint)
.force_path_style(true)
.retry_config(RetryConfig::disabled())
.http_client(transport.clone())
.build(),
);
let result = retrying_get_equals(&client, "retry-probe", "key", b"expected bytes", Duration::from_secs(5)).await;
if status == 503 {
result?;
assert_eq!(
transport.actual_requests().count(),
2,
"the probe must retry the read-quorum error exactly once"
);
transport.assert_requests_match(&[]);
} else {
let error = result.expect_err("the compatibility code must not hide a non-503 failure");
assert_eq!(s3_probe_error_code(error.as_ref()), Some("SlowDownRead"));
assert_eq!(transport.actual_requests().count(), 1, "non-availability errors must fail immediately");
}
}
Ok(())
}
#[tokio::test]
async fn object_polls_distinguish_committed_and_replicated_keys() -> TestResult {
let target = FakeS3Target::start().await?;
@@ -3,13 +3,14 @@
use super::harness::{DistCluster, DistLayout, TestResult, unique_bucket};
use crate::tls_gen;
use aws_sdk_s3::operation::RequestId;
use aws_sdk_s3::{Client, config::retry::RetryConfig, primitives::ByteStream};
use rand::{Rng, SeedableRng, rngs::StdRng};
use sha2::{Digest, Sha256};
use std::io::{Read, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, Instant};
use std::sync::{Arc, Mutex, TryLockError};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use tokio::task::JoinSet;
const MIB: u64 = 1024 * 1024;
@@ -28,6 +29,285 @@ struct ObjectFixture {
reads: Vec<ExpectedRead>,
}
#[derive(Clone)]
struct ConcurrentReadStream {
stage: &'static str,
started_ms: Option<u64>,
headers_ms: Option<u64>,
body_started_ms: Option<u64>,
completed_ms: Option<u64>,
received: u64,
last_progress_ms: Option<u64>,
request_id: Option<uuid::Uuid>,
terminal: Option<&'static str>,
terminal_ms: Option<u64>,
}
impl Default for ConcurrentReadStream {
fn default() -> Self {
Self {
stage: "queued",
started_ms: None,
headers_ms: None,
body_started_ms: None,
completed_ms: None,
received: 0,
last_progress_ms: None,
request_id: None,
terminal: None,
terminal_ms: None,
}
}
}
struct ConcurrentReadProgress {
started: Instant,
started_clock: ClockSample,
expected: u64,
streams: Vec<Mutex<ConcurrentReadStream>>,
}
#[derive(Clone, Copy)]
struct ClockSample {
unix_ms: Option<u64>,
before_ms: Option<u64>,
after_ms: Option<u64>,
}
impl ClockSample {
fn capture(started: Instant) -> Self {
let before_ms = elapsed_ms(started);
Self::capture_after(started, before_ms)
}
fn capture_after(started: Instant, before_ms: Option<u64>) -> Self {
let unix_ms = unix_ms_at(SystemTime::now());
let after_ms = elapsed_ms(started);
Self {
unix_ms,
before_ms,
after_ms,
}
}
fn json(self) -> serde_json::Value {
serde_json::json!({
"unix_ms": self.unix_ms,
"monotonic_before_ms": self.before_ms,
"monotonic_after_ms": self.after_ms,
})
}
}
impl ConcurrentReadProgress {
fn new(expected: u64, count: usize) -> Self {
let started = Instant::now();
Self {
started,
started_clock: ClockSample::capture(started),
expected,
streams: (0..count).map(|_| Mutex::new(ConcurrentReadStream::default())).collect(),
}
}
fn elapsed_ms(&self) -> Option<u64> {
elapsed_ms(self.started)
}
fn start(&self, node: usize) {
let mut stream = self.streams[node].lock().unwrap_or_else(std::sync::PoisonError::into_inner);
stream.started_ms = self.elapsed_ms();
stream.stage = "waiting_headers";
}
fn response(&self, node: usize, request_id: Option<&str>, has_headers: bool) {
let mut stream = self.streams[node].lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(request_id) = request_id.and_then(|id| uuid::Uuid::parse_str(id).ok()) {
stream.request_id = Some(request_id);
}
if has_headers {
stream.headers_ms = self.elapsed_ms();
stream.stage = "headers_received";
}
}
fn body(&self, node: usize) {
let mut stream = self.streams[node].lock().unwrap_or_else(std::sync::PoisonError::into_inner);
stream.body_started_ms = self.elapsed_ms();
stream.stage = "reading_body";
}
fn progress(&self, node: usize, received: u64) {
let mut stream = self.streams[node].lock().unwrap_or_else(std::sync::PoisonError::into_inner);
stream.last_progress_ms = self.elapsed_ms();
stream.received = received;
}
fn complete(&self, node: usize) {
let mut stream = self.streams[node].lock().unwrap_or_else(std::sync::PoisonError::into_inner);
stream.completed_ms = self.elapsed_ms();
stream.stage = "complete";
}
fn terminal(&self, node: usize, terminal: &'static str) {
let mut stream = self.streams[node].lock().unwrap_or_else(std::sync::PoisonError::into_inner);
stream.terminal = Some(terminal);
stream.terminal_ms = self.elapsed_ms();
}
fn failure_snapshot(&self, failed_node: Option<usize>, kind: &'static str) -> serde_json::Value {
let streams: Vec<_> = self
.streams
.iter()
.enumerate()
.map(|(node, stream)| {
// Avoid waiting on another task while retaining the first error.
let mut snapshot = None;
let mut unavailable = "busy";
for _ in 0..8 {
let before_ms = self.elapsed_ms();
match stream.try_lock() {
Ok(guard) => {
let stream = guard.clone();
drop(guard);
snapshot = Some((stream, ClockSample::capture_after(self.started, before_ms)));
break;
}
Err(TryLockError::WouldBlock) => std::hint::spin_loop(),
Err(TryLockError::Poisoned(_)) => {
unavailable = "poisoned";
break;
}
}
}
let Some((stream, sampled_clock)) = snapshot else {
return serde_json::json!({
"node": node,
"snapshot": unavailable,
"sampled_clock": ClockSample::capture(self.started).json(),
"last_stage": "unknown",
"terminal": "unknown",
"request_id": null,
"received_bytes": null,
"last_progress_ms": null,
});
};
serde_json::json!({
"node": node,
"snapshot": "available",
"sampled_clock": sampled_clock.json(),
"last_stage": stream.stage,
"request_id": stream.request_id.map(|id| id.to_string()),
"started_ms": stream.started_ms,
"headers_ms": stream.headers_ms,
"body_started_ms": stream.body_started_ms,
"completed_ms": stream.completed_ms,
"received_bytes": stream.received,
"last_progress_ms": stream.last_progress_ms,
"terminal": stream.terminal.unwrap_or("unknown"),
"terminal_ms": stream.terminal_ms,
})
})
.collect();
serde_json::json!({
"kind": kind,
"failed_node": failed_node,
"elapsed_ms": self.elapsed_ms(),
"stage_start_clock_sample": self.started_clock.json(),
"clock_scope": "client_unsynchronized",
"expected_bytes": self.expected,
"streams": streams,
})
}
fn report_failure(&self, failed_node: Option<usize>, kind: &'static str) {
eprintln!("TLS acceptance: concurrent GET failure: {}", self.failure_snapshot(failed_node, kind));
}
}
fn elapsed_ms(started: Instant) -> Option<u64> {
u64::try_from(started.elapsed().as_millis()).ok()
}
fn unix_ms_at(time: SystemTime) -> Option<u64> {
time.duration_since(UNIX_EPOCH)
.ok()
.and_then(|duration| u64::try_from(duration.as_millis()).ok())
}
fn verify_concurrent_read_failure_snapshot_evidence() {
let progress = ConcurrentReadProgress::new(100, 4);
progress.start(0);
progress.response(0, Some("LEAK-invalid-request-id"), true);
progress.body(0);
progress.progress(0, 100);
progress.complete(0);
progress.terminal(0, "success");
let valid_id = "b8172a15-a428-4b42-9cef-1f4a7b96874e";
progress.start(1);
progress.response(1, Some(valid_id), true);
progress.body(1);
progress.progress(1, 41);
progress.terminal(1, "failed");
progress.start(3);
let _busy = progress.streams[2].lock().expect("hold one stream during the snapshot");
let snapshot = progress.failure_snapshot(Some(1), "read_failed");
assert_eq!(snapshot["clock_scope"], "client_unsynchronized");
assert_eq!(snapshot["failed_node"], 1);
let start_clock = &snapshot["stage_start_clock_sample"];
assert!(
start_clock["monotonic_before_ms"].as_u64().expect("start sample lower bound")
<= start_clock["monotonic_after_ms"].as_u64().expect("start sample upper bound")
);
assert!(!snapshot.to_string().contains("LEAK-invalid-request-id"));
let streams = snapshot["streams"].as_array().expect("per-stream snapshots");
assert_eq!(streams.len(), 4);
for (stream, terminal, expected_bytes) in [(&streams[0], "success", 100), (&streams[1], "failed", 41)] {
assert_eq!(stream["snapshot"], "available");
assert_eq!(stream["terminal"], terminal);
assert_eq!(stream["received_bytes"], expected_bytes);
let times = [
"started_ms",
"headers_ms",
"body_started_ms",
"last_progress_ms",
"terminal_ms",
]
.map(|field| stream[field].as_u64().expect("observed stream time"));
assert!(times.windows(2).all(|pair| pair[0] <= pair[1]));
let clock = &stream["sampled_clock"];
let before = clock["monotonic_before_ms"].as_u64().expect("sample lower bound");
let after = clock["monotonic_after_ms"].as_u64().expect("sample upper bound");
assert!(times[4] <= after && before <= after);
}
assert_eq!(streams[0]["last_stage"], "complete");
assert!(streams[0]["request_id"].is_null());
assert!(
streams[0]["completed_ms"].as_u64().expect("completion time")
<= streams[0]["terminal_ms"].as_u64().expect("terminal time")
);
assert_eq!(streams[1]["last_stage"], "reading_body");
assert!(streams[1]["completed_ms"].is_null());
assert_eq!(streams[1]["request_id"], valid_id);
assert_eq!(streams[2]["snapshot"], "busy");
assert_eq!(streams[2]["last_stage"], "unknown");
assert_eq!(streams[2]["terminal"], "unknown");
assert!(streams[2]["received_bytes"].is_null());
assert!(streams[2]["sampled_clock"]["monotonic_before_ms"].is_number());
assert_eq!(streams[3]["last_stage"], "waiting_headers");
assert_eq!(streams[3]["terminal"], "unknown");
assert!(streams[3]["request_id"].is_null());
assert!(streams[3]["last_progress_ms"].is_null());
let before_epoch = UNIX_EPOCH
.checked_sub(Duration::from_millis(1))
.expect("represent pre-epoch time");
assert!(unix_ms_at(before_epoch).is_none());
}
fn fixture(directory: &Path, length: u64) -> TestResult<ObjectFixture> {
let key = format!("object-{length}");
let path = directory.join(&key);
@@ -85,16 +365,28 @@ async fn verified_get(
object: &ObjectFixture,
expected: &ExpectedRead,
concurrent: bool,
progress: Option<&ConcurrentReadProgress>,
) -> TestResult<serde_json::Value> {
let started = Instant::now();
let mut received = 0u64;
let mut stage = "response headers";
if let Some(progress) = progress {
progress.start(node);
}
tokio::time::timeout(READ_TIMEOUT, async {
let mut request = client.get_object().bucket(bucket).key(&object.key);
if let Some((start, end)) = expected.range {
request = request.range(format!("bytes={start}-{end}"));
}
let mut output = request.send().await?;
let response = request.send().await;
if let Some(progress) = progress {
let has_headers = match &response {
Ok(_) => true,
Err(error) => error.raw_response().is_some(),
};
progress.response(node, response.request_id(), has_headers);
}
let mut output = response?;
assert_eq!(output.content_length(), Some(i64::try_from(expected.length)?));
if let Some((start, end)) = expected.range {
assert_eq!(output.content_range(), Some(format!("bytes {start}-{end}/{}", object.length).as_str()));
@@ -102,21 +394,32 @@ async fn verified_get(
assert_eq!(output.content_range(), None);
}
stage = "response body";
if let Some(progress) = progress {
progress.body(node);
}
let mut digest = Sha256::new();
while let Some(chunk) = output.body.next().await {
let chunk = chunk.inspect_err(|error| {
let chunk = chunk.inspect_err(|_| {
eprintln!(
"TLS acceptance: GET body failed: node={node}, key={}, range={:?}, concurrent={concurrent}, received={received}/{}, elapsed={:?}, error={error}",
"TLS acceptance: GET body failed: node={node}, key={}, range={:?}, concurrent={concurrent}, received={received}/{}, elapsed={:?}",
object.key, expected.range, expected.length, started.elapsed()
);
})?;
received += u64::try_from(chunk.len())?;
if !chunk.is_empty()
&& let Some(progress) = progress
{
progress.progress(node, received);
}
assert!(received <= expected.length, "node {node}: GET exceeded the requested length");
digest.update(&chunk);
}
let actual_sha256 = hex_simd::encode_to_string(digest.finalize(), hex_simd::AsciiCase::Lower);
assert_eq!(received, expected.length, "node {node}: truncated GET {}", object.key);
assert_eq!(actual_sha256, expected.sha256, "node {node}: corrupt GET {}", object.key);
if let Some(progress) = progress {
progress.complete(node);
}
eprintln!(
"TLS acceptance: GET node={node}, key={}, range={:?}, concurrent={concurrent}, bytes={received}, elapsed={:?}",
object.key, expected.range, started.elapsed()
@@ -143,6 +446,7 @@ async fn verified_get(
/// tiny objects and count that as a large distributed-read pass.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn four_node_tls_large_and_range_gets_preserve_bytes() -> TestResult {
verify_concurrent_read_failure_snapshot_evidence();
let mode = std::env::var("RUSTFS_E2E_INTERNODE_HTTP_MODE").unwrap_or_else(|_| "http1".into());
let protocol = match mode.as_str() {
"http1" => "http1",
@@ -238,7 +542,7 @@ async fn four_node_tls_large_and_range_gets_preserve_bytes() -> TestResult {
if length == 32 * 1024 {
let alternate = dist.client_with_credentials(0, &dist.cluster.access_key, &dist.cluster.secret_key)?;
let alternate = Client::from_conf(alternate.config().to_builder().retry_config(RetryConfig::disabled()).build());
results.push(verified_get(&alternate, 0, &bucket, &object, &object.reads[0], false).await?);
results.push(verified_get(&alternate, 0, &bucket, &object, &object.reads[0], false, None).await?);
let invalid = dist.client_with_credentials(0, "invalid-tls-user", "invalid-tls-secret-password")?;
let invalid = Client::from_conf(invalid.config().to_builder().retry_config(RetryConfig::disabled()).build());
let denied = invalid
@@ -250,21 +554,45 @@ async fn four_node_tls_large_and_range_gets_preserve_bytes() -> TestResult {
}
for expected in &object.reads {
for (node, client) in clients.iter().enumerate() {
results.push(verified_get(client, node, &bucket, &object, expected, false).await?);
results.push(verified_get(client, node, &bucket, &object, expected, false, None).await?);
}
}
// Each reader owns a Tokio task and its cooperative budget. Polling
// every body and synchronous digest in one parent task can let an
// always-ready reader starve its siblings and their deadlines.
let mut readers = JoinSet::new();
let progress = Arc::new(ConcurrentReadProgress::new(object.reads[0].length, clients.len()));
let mut task_nodes = Vec::with_capacity(clients.len());
for (node, client) in clients.iter().enumerate() {
let client = client.clone();
let bucket = bucket.clone();
let object = Arc::clone(&object);
readers.spawn(async move { verified_get(&client, node, &bucket, &object, &object.reads[0], true).await });
let progress = Arc::clone(&progress);
let handle = readers.spawn(async move {
let result = verified_get(&client, node, &bucket, &object, &object.reads[0], true, Some(&progress)).await;
progress.terminal(node, if result.is_ok() { "success" } else { "failed" });
(node, result)
});
task_nodes.push((handle.id(), node));
}
while let Some(result) = readers.join_next().await {
results.push(result??);
match result {
Ok((_, Ok(result))) => results.push(result),
Ok((node, Err(error))) => {
readers.abort_all();
progress.report_failure(Some(node), "read_failed");
return Err(error);
}
Err(error) => {
let node = task_nodes.iter().find(|(id, _)| *id == error.id()).map(|(_, node)| *node);
readers.abort_all();
if let Some(node) = node {
progress.terminal(node, if error.is_panic() { "panicked" } else { "cancelled" });
}
progress.report_failure(node, "task_failed");
return Err(error.into());
}
}
}
tokio::fs::remove_file(&object.path).await?;
}
+1
View File
@@ -40,5 +40,6 @@ mod scanner_heal_topology_fixture;
mod scanner_heal_topology_test;
mod scanner_heal_topology_tier_test;
mod site_replication_test;
mod table_catalog_test;
mod upgrade_test;
mod versioning_test;
@@ -1638,6 +1638,191 @@ async fn late_complete_packets_cannot_publish_after_abort_and_bucket_recreation(
"refused late packets must not recreate staging"
);
}
late_part_packets_preserve_retired_and_fresh_uploads().await?;
Ok(())
}
async fn late_part_packets_preserve_retired_and_fresh_uploads() -> TestResult {
for (method, settles) in [("PreparePartTransaction", false), ("SettlePartTransaction", true)] {
for recreate_bucket in [false, true] {
let (cluster, proxies, clients, bucket) = fixture(1, "EC:2").await?;
let key = "late-part-transaction";
let previous = payload_for("committed-before-late-part", 4096);
clients[0]
.put_object()
.bucket(&bucket)
.key(key)
.body(ByteStream::from(previous.clone()))
.send()
.await?;
let upload = clients[0].create_multipart_upload().bucket(&bucket).key(key).send().await?;
let upload_id = upload.upload_id().ok_or("missing original upload ID")?;
clients[0]
.upload_part()
.bucket(&bucket)
.key(key)
.upload_id(upload_id)
.part_number(1)
.body(ByteStream::from(vec![0x41; 9 * 1024 * 1024]))
.send()
.await?;
let path = upload_path_scope(&bucket, key, upload_id)?;
let hash = sha256_hex(format!("{bucket}/{key}").as_bytes());
let roots = cluster
.nodes
.iter()
.flat_map(|node| &node.data_dirs)
.map(|root| Path::new(root).join(RUSTFS_META_MULTIPART_BUCKET).join(&path))
.collect::<Vec<_>>();
assert_eq!(roots.len(), 4);
let original = multipart_sources(&cluster, Some(&hash))?;
assert_eq!(original.len(), 4);
assert!(
original.values().all(|source| source
.files
.keys()
.any(|path| path.file_name().and_then(|name| name.to_str()) == Some("part.1"))),
"all selected peers originally own the acknowledged part"
);
let scope = upload_scope(upload_id)?;
// Retain only the original attempt; recovery of the same upload
// must be able to issue fresh settlement requests through the gate.
let pauses = proxies[1..3]
.iter()
.map(|proxy| proxy.pause_late(&[method], 1, &scope))
.collect::<Result<Vec<_>, _>>()?;
let replacement = clients[0]
.upload_part()
.bucket(&bucket)
.key(key)
.upload_id(upload_id)
.part_number(1)
.body(ByteStream::from(vec![0x62; 8 * 1024 * 1024]));
let caller = tokio::spawn(async move { replacement.send().await });
wait_for_faults(&pauses, &[method]).await?;
let failure = tokio::time::timeout(DEADLINE, caller)
.await??
.expect_err("two retained peers cannot acknowledge part replacement");
assert_unavailable(&failure, "storage_write_quorum", None);
assert!(pauses.iter().all(|pause| pause.hits() == 1 && pause.responses() == 0));
let expected_actions = if settles { (1, 0) } else { (0, 0) };
assert!(
pauses.iter().all(|pause| pause.retained_settle_actions() == expected_actions),
"settlement capture must be a real Commit, not Rollback"
);
abort_after_fault(&clients[0], &bucket, key, upload_id, false).await?;
assert!(
roots.iter().all(|root| !root.exists()),
"ordinary Abort physically retires all original roots"
);
assert!(multipart_sources(&cluster, Some(&hash))?.is_empty());
for client in &clients {
assert_object_bytes_after_fault(client, &bucket, key, &previous).await?;
}
let protected = if recreate_bucket {
clients[0].delete_object().bucket(&bucket).key(key).send().await?;
clients[0].delete_bucket().bucket(&bucket).send().await?;
clients[1].create_bucket().bucket(&bucket).send().await?;
let fresh = payload_for("recreated-before-late-part", 4096);
clients[1]
.put_object()
.bucket(&bucket)
.key(key)
.body(ByteStream::from(fresh.clone()))
.send()
.await?;
fresh
} else {
previous
};
let fresh = clients[2].create_multipart_upload().bucket(&bucket).key(key).send().await?;
let fresh_id = fresh.upload_id().ok_or("missing fresh upload ID")?;
let fresh_body = payload_for("fresh-active-part", 4096);
let fresh_part = clients[2]
.upload_part()
.bucket(&bucket)
.key(key)
.upload_id(fresh_id)
.part_number(1)
.body(ByteStream::from(fresh_body.clone()))
.send()
.await?;
let before = multipart_sources(&cluster, Some(&hash))?;
assert_eq!(before.len(), 4, "the new upload exists on every original participant");
for pause in &pauses {
pause.release();
}
wait_until(
DEADLINE,
|| async {
Ok(pauses.iter().all(|pause| {
pause.responses() == pause.hits()
&& if settles {
pause.successful_mutation_replies() == pause.hits() && pause.refused_mutations() == 0
} else {
pause.refused_mutations() == pause.hits() && pause.successful_mutation_replies() == 0
}
}))
},
"every original part packet returns its typed authenticated terminal outcome",
)
.await?;
assert!(roots.iter().all(|root| !root.exists()), "late part packets cannot recreate retired roots");
assert!(
multipart_sources(&cluster, Some(&hash))? == before,
"late packets cannot change a fresh upload's shard or control bytes"
);
for client in &clients {
assert_object_bytes_after_fault(client, &bucket, key, &protected).await?;
let listed = client
.list_parts()
.bucket(&bucket)
.key(key)
.upload_id(fresh_id)
.send()
.await?;
assert_eq!(listed.parts().len(), 1);
assert_eq!(listed.parts()[0].e_tag(), fresh_part.e_tag());
let missing = client
.list_parts()
.bucket(&bucket)
.key(key)
.upload_id(upload_id)
.send()
.await
.expect_err("old upload remains retired");
assert_eq!(missing.raw_response().expect("old upload service response").status().as_u16(), 404);
assert_eq!(missing.as_service_error().and_then(|error| error.meta().code()), Some("NoSuchUpload"));
}
clients[2]
.complete_multipart_upload()
.bucket(&bucket)
.key(key)
.upload_id(fresh_id)
.multipart_upload(
CompletedMultipartUpload::builder()
.parts(
CompletedPart::builder()
.part_number(1)
.e_tag(fresh_part.e_tag().ok_or("missing fresh part ETag")?)
.build(),
)
.build(),
)
.send()
.await?;
for client in &clients {
assert_object_bytes_after_fault(client, &bucket, key, &fresh_body).await?;
}
assert!(multipart_sources(&cluster, Some(&hash))?.is_empty());
assert_no_uploads_after_fault(&clients, &bucket).await?;
eprintln!(
"MPU late part: method={method}, recreate_bucket={recreate_bucket}, retired_roots=4, delivered=2, refused={}, successful_replies={}, fresh_sources_unchanged=4, verified_readers=4",
pauses.iter().map(RpcPause::refused_mutations).sum::<usize>(),
pauses.iter().map(RpcPause::successful_mutation_replies).sum::<usize>()
);
}
}
Ok(())
}
@@ -0,0 +1,140 @@
// Copyright 2026 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use super::harness::{DistCluster, DistLayout, TestResult, get_object_bytes, put_object, unique_bucket};
use crate::common::{init_logging, signed_request};
use http::{Method, StatusCode};
use serde_json::{Value, json};
async fn catalog_request(
dist: &DistCluster,
node: usize,
method: Method,
path: &str,
body: Option<Value>,
expected: StatusCode,
) -> TestResult<Value> {
let response = signed_request(
method.clone(),
&format!("{}{path}", dist.cluster.nodes[node].url),
&dist.cluster.access_key,
&dist.cluster.secret_key,
body.map(|value| serde_json::to_vec(&value)).transpose()?,
Some("application/json"),
)
.await?;
let status = response.status();
let text = response.text().await?;
assert_eq!(status, expected, "node {node}: {method} {path}: {text}");
if text.is_empty() {
Ok(Value::Null)
} else {
Ok(serde_json::from_str(&text)?)
}
}
async fn assert_bucket_state(dist: &DistCluster, bucket: &str, enabled: bool) -> TestResult {
for node in 0..dist.cluster.nodes.len() {
let response =
catalog_request(dist, node, Method::GET, &format!("/iceberg/v1/buckets/{bucket}"), None, StatusCode::OK).await?;
assert_eq!(response["enabled"], enabled, "node {node}: {response}");
assert_eq!(response["table-bucket"], bucket);
let expected = if enabled { StatusCode::OK } else { StatusCode::BAD_REQUEST };
catalog_request(dist, node, Method::GET, &format!("/iceberg/v1/{bucket}/namespaces"), None, expected).await?;
assert_eq!(
get_object_bytes(&dist.client(node)?, bucket, "ordinary.txt").await?,
b"ordinary bucket object",
"node {node}: toggling table support changed an ordinary object"
);
}
Ok(())
}
async fn assert_table_state(dist: &DistCluster, table_path: &str, created: &Value, pointer: &Value) -> TestResult {
for node in 0..dist.cluster.nodes.len() {
let loaded = catalog_request(dist, node, Method::GET, table_path, None, StatusCode::OK).await?;
assert_eq!(loaded["metadata-location"], created["metadata-location"], "node {node}");
assert_eq!(loaded["metadata"], created["metadata"], "node {node}");
let actual_pointer =
catalog_request(dist, node, Method::GET, &format!("{table_path}/metadata-location"), None, StatusCode::OK).await?;
assert_eq!(&actual_pointer, pointer, "node {node}: catalog pointer changed");
}
Ok(())
}
#[tokio::test]
async fn table_bucket_toggles_reload_cached_metadata_on_every_peer() -> TestResult {
init_logging();
let dist = DistCluster::start_with_env(DistLayout::FourByFour, &[("RUSTFS_TABLE_CATALOG_BACKING", "object")]).await?;
let bucket = unique_bucket("table-peer-reload");
dist.create_bucket(&bucket).await?;
put_object(&dist.client(0)?, &bucket, "ordinary.txt", b"ordinary bucket object".to_vec()).await?;
let bucket_path = format!("/iceberg/v1/buckets/{bucket}");
// Warm each node's false cache before changing the marker. Do not poll:
// successful toggles must notify peers before their response is returned.
assert_bucket_state(&dist, &bucket, false).await?;
for (node, method, enabled) in [(0, Method::PUT, true), (1, Method::DELETE, false), (2, Method::PUT, true)] {
let response = catalog_request(&dist, node, method, &bucket_path, None, StatusCode::OK).await?;
assert_eq!(response["enabled"], enabled);
assert_bucket_state(&dist, &bucket, enabled).await?;
}
let namespaces = format!("/iceberg/v1/{bucket}/namespaces");
catalog_request(&dist, 3, Method::POST, &namespaces, Some(json!({"namespace": ["lab"]})), StatusCode::OK).await?;
let tables = format!("{namespaces}/lab/tables");
let created = catalog_request(
&dist,
0,
Method::POST,
&tables,
Some(json!({
"name": "events",
"schema": {
"type": "struct",
"fields": [{"id": 1, "name": "id", "required": false, "type": "long"}]
},
"stage-create": false
})),
StatusCode::OK,
)
.await?;
let uuid = created["metadata"]["table-uuid"]
.as_str()
.ok_or("create response omitted table UUID")?;
assert!(!uuid::Uuid::parse_str(uuid)?.is_nil());
assert!(
created["metadata-location"]
.as_str()
.is_some_and(|location| location.starts_with(&format!("s3://{bucket}/")))
);
let table_path = format!("{tables}/events");
let pointer =
catalog_request(&dist, 3, Method::GET, &format!("{table_path}/metadata-location"), None, StatusCode::OK).await?;
assert_eq!(pointer["metadata-location"], created["metadata-location"]);
assert!(pointer["version-token"].as_str().is_some_and(|token| !token.is_empty()));
assert!(pointer["generation"].as_u64().is_some());
assert_eq!(pointer["warehouse-location"], created["metadata"]["location"]);
assert_table_state(&dist, &table_path, &created, &pointer).await?;
// A rejected toggle must preserve both peer protections and catalog state.
catalog_request(&dist, 1, Method::DELETE, &bucket_path, None, StatusCode::CONFLICT).await?;
assert_bucket_state(&dist, &bucket, true).await?;
assert_table_state(&dist, &table_path, &created, &pointer).await?;
catalog_request(&dist, 2, Method::DELETE, &table_path, None, StatusCode::NO_CONTENT).await?;
catalog_request(&dist, 3, Method::DELETE, &format!("{namespaces}/lab"), None, StatusCode::NO_CONTENT).await?;
// The cluster fixture owns all remaining catalog objects and data roots.
Ok(())
}
+175 -11
View File
@@ -27,8 +27,8 @@ use hyper_util::{
};
use prost::Message as _;
use rustfs_protos::proto_gen::node_service::{
BatchGenerallyLockRequest, DeleteRequest, GenerallyLockRequest, ReadVersionRequest, RecoverMultipartSourceRequest,
RenameDataResponse,
BatchGenerallyLockRequest, DeleteRequest, GenerallyLockRequest, PreparePartTransactionResponse, ReadVersionRequest,
RecoverMultipartSourceRequest, RenameDataResponse, SettlePartTransactionRequest, SettlePartTransactionResponse,
};
use std::{
collections::HashSet,
@@ -56,6 +56,35 @@ enum Disposition {
Discard,
}
#[derive(Clone, Copy)]
enum MutationReply {
None,
Rename,
PreparePart,
SettlePart,
}
impl MutationReply {
fn outcome(self, bytes: &[u8]) -> io::Result<Option<(bool, bool)>> {
let result = match self {
Self::None => return Ok(None),
Self::Rename => {
let reply = RenameDataResponse::decode(unary_payload(bytes)?).map_err(io::Error::other)?;
(reply.success, reply.error.is_some())
}
Self::PreparePart => {
let reply = PreparePartTransactionResponse::decode(unary_payload(bytes)?).map_err(io::Error::other)?;
(reply.success, reply.error.is_some())
}
Self::SettlePart => {
let reply = SettlePartTransactionResponse::decode(unary_payload(bytes)?).map_err(io::Error::other)?;
(reply.success, reply.error.is_some())
}
};
Ok(Some(result))
}
}
struct PauseState {
methods: Vec<&'static str>,
scope: Option<Vec<u8>>,
@@ -64,6 +93,9 @@ struct PauseState {
limit: usize,
retain_after_disconnect: bool,
refused_mutations: AtomicUsize,
successful_mutation_replies: AtomicUsize,
settle_commits: AtomicUsize,
settle_rollbacks: AtomicUsize,
responses: AtomicUsize,
matched: AtomicUsize,
blocked: AtomicUsize,
@@ -122,6 +154,17 @@ impl RpcPause {
self.state.refused_mutations.load(Ordering::SeqCst)
}
pub(crate) fn successful_mutation_replies(&self) -> usize {
self.state.successful_mutation_replies.load(Ordering::SeqCst)
}
pub(crate) fn retained_settle_actions(&self) -> (usize, usize) {
(
self.state.settle_commits.load(Ordering::SeqCst),
self.state.settle_rollbacks.load(Ordering::SeqCst),
)
}
pub(crate) fn unsupported_source_inspection_routes(&self) -> HashSet<String> {
self.state
.unsupported_inspection_routes
@@ -394,6 +437,9 @@ impl RpcFaultProxy {
limit,
retain_after_disconnect,
refused_mutations: AtomicUsize::new(0),
successful_mutation_replies: AtomicUsize::new(0),
settle_commits: AtomicUsize::new(0),
settle_rollbacks: AtomicUsize::new(0),
responses: AtomicUsize::new(0),
matched: AtomicUsize::new(0),
blocked: AtomicUsize::new(0),
@@ -509,6 +555,7 @@ async fn forward_request(
if let Some(method) = method.filter(|name| pause.methods.contains(name)) {
let mut route = None;
let mut lock_owners = Vec::new();
let mut settle_action = None;
let matches = if let Some(scope) = pause.scope.as_deref() {
// Inspect bounded control routing while preserving the exact
// signed bytes and trailers forwarded to the backend.
@@ -522,12 +569,19 @@ async fn forward_request(
inspection = Some((pause.clone(), disk));
}
let mut matches = scope_matches(method, &bytes, scope)?;
if matches && method == "SettlePartTransaction" {
settle_action = Some(
SettlePartTransactionRequest::decode(unary_payload(&bytes)?)
.map_err(io::Error::other)?
.rollback,
);
}
if matches && (pause.record_lock_owners || pause.lock_owner.is_some()) {
lock_owners = scoped_lock_owners(method, &bytes, scope)?;
if let Some(owner) = &pause.lock_owner {
matches = lock_owners.iter().any(|candidate| candidate == owner);
if !matches && pause.record_lock_owners {
observed = Some((pause.clone(), None, std::mem::take(&mut lock_owners), false));
observed = Some((pause.clone(), None, std::mem::take(&mut lock_owners), MutationReply::None));
}
}
}
@@ -549,6 +603,14 @@ async fn forward_request(
true
};
if matches && pause.matched.fetch_add(1, Ordering::SeqCst) < pause.limit {
if let Some(rollback) = settle_action {
let counter = if rollback {
&pause.settle_rollbacks
} else {
&pause.settle_commits
};
counter.fetch_add(1, Ordering::SeqCst);
}
let mut released = pause.release.subscribe();
pause.blocked.fetch_add(1, Ordering::SeqCst);
pause.reached.notify_one();
@@ -568,10 +630,14 @@ async fn forward_request(
pause,
route,
lock_owners,
matches!(
method,
"RenameData" | "RenameMultipartData" | "RenameDataAtIncarnation" | "RenameDataWithQuotaFence"
),
match method {
"RenameData" | "RenameMultipartData" | "RenameDataAtIncarnation" | "RenameDataWithQuotaFence" => {
MutationReply::Rename
}
"PreparePartTransaction" => MutationReply::PreparePart,
"SettlePartTransaction" => MutationReply::SettlePart,
_ => MutationReply::None,
},
));
}
}
@@ -604,7 +670,7 @@ async fn forward_request(
.expect("inspection response routes")
.insert(disk);
}
if let Some((pause, route, lock_owners, rename)) = observed {
if let Some((pause, route, lock_owners, mutation_reply)) = observed {
pause.responses.fetch_add(1, Ordering::SeqCst);
if response.status().is_success() && response.headers().get("grpc-status").is_none_or(|status| status == "0") {
pause.forwarded.fetch_add(1, Ordering::SeqCst);
@@ -626,10 +692,14 @@ async fn forward_request(
.headers
.get("grpc-status")
.or_else(|| trailers.as_ref()?.get("grpc-status"));
if rename && parts.status.is_success() && status.is_none_or(|status| status == "0") {
let result = RenameDataResponse::decode(unary_payload(&bytes)?).map_err(io::Error::other)?;
if !result.success && result.error.is_some() {
if parts.status.is_success()
&& status.is_some_and(|status| status == "0")
&& let Some((success, has_error)) = mutation_reply.outcome(&bytes)?
{
if !success && has_error {
pause.refused_mutations.fetch_add(1, Ordering::SeqCst);
} else if success && !has_error {
pause.successful_mutation_replies.fetch_add(1, Ordering::SeqCst);
}
}
let body = Full::new(bytes)
@@ -676,6 +746,10 @@ mod tests {
}
async fn backend_with_status(status: Option<&'static str>) -> io::Result<Backend> {
backend_with_reply(status, None).await
}
async fn backend_with_reply(status: Option<&'static str>, reply: Option<Bytes>) -> io::Result<Backend> {
let listener = TcpListener::bind("127.0.0.1:0").await?;
let address = listener.local_addr()?;
let received = Arc::new(AtomicUsize::new(0));
@@ -687,9 +761,11 @@ mod tests {
accepted = listener.accept() => {
let Ok((socket, _)) = accepted else { break };
let received = received_task.clone();
let reply = reply.clone();
children.spawn(async move {
let service = service_fn(move |request: Request<Incoming>| {
let received = received.clone();
let reply = reply.clone();
async move {
received.fetch_add(1, Ordering::SeqCst);
let authority = request.headers().get(http::header::HOST).cloned().or_else(|| {
@@ -698,6 +774,7 @@ mod tests {
})
});
let body = request.into_body().collect().await?.to_bytes();
let body = reply.unwrap_or(body);
let mut trailers = http::HeaderMap::new();
trailers.insert("grpc-status", http::HeaderValue::from_static(status.unwrap_or("0")));
trailers.insert("x-proof", http::HeaderValue::from_static("unchanged-proof"));
@@ -1434,6 +1511,93 @@ mod tests {
.await
.expect("scoped discard deadline");
assert_late_packet_ownership().await;
assert_late_part_reply_refusal().await;
}
async fn assert_late_part_reply_refusal() {
use rustfs_protos::proto_gen::node_service::{Error, PreparePartTransactionRequest};
tokio::time::timeout(Duration::from_secs(10), async {
// method, success, error, gRPC status, rollback, expected successes/refusals
for (method, success, has_error, status, rollback, expected) in [
("PreparePartTransaction", false, true, None, false, (0, 1)),
("PreparePartTransaction", true, false, None, false, (1, 0)),
("PreparePartTransaction", false, false, None, false, (0, 0)),
("SettlePartTransaction", true, false, None, false, (1, 0)),
("SettlePartTransaction", false, true, None, true, (0, 1)),
("SettlePartTransaction", true, true, None, false, (0, 0)),
("SettlePartTransaction", false, true, Some("16"), false, (0, 0)),
("PreparePartTransaction", false, true, Some("16"), false, (0, 0)),
("RenameMultipartData", false, true, None, false, (0, 1)),
] {
let error = has_error.then(|| Error {
code: 1,
error_info: "retired source".to_owned(),
});
let reply = match method {
"PreparePartTransaction" => unary_frame(&PreparePartTransactionResponse { success, error }),
"SettlePartTransaction" => unary_frame(&SettlePartTransactionResponse { success, error }),
_ => unary_frame(&RenameDataResponse {
success,
error,
..Default::default()
}),
};
// An unknown field must survive typed inspection byte-for-byte.
let mut framed = reply.to_vec();
framed.extend_from_slice(&[0xfa, 0x03, 0x03, b'x', b'y', b'z']);
let payload_len = u32::try_from(framed.len() - 5).expect("small reply");
framed[1..5].copy_from_slice(&payload_len.to_be_bytes());
let reply = Bytes::from(framed);
let backend = backend_with_reply(status, Some(reply.clone()))
.await
.expect("typed reply backend");
let proxy = RpcFaultProxy::start_on("127.0.0.1:0".parse().expect("loopback"), backend.address)
.await
.expect("proxy");
let pause = proxy.pause_late(&[method], 1, "late-upload").expect("part gate");
let request = match method {
"PreparePartTransaction" => unary_frame(&PreparePartTransactionRequest {
disk: "disk".to_owned(),
dst_path: "late-upload/part.1".to_owned(),
..Default::default()
}),
"SettlePartTransaction" => unary_frame(&SettlePartTransactionRequest {
disk: "disk".to_owned(),
path: "late-upload/part.1".to_owned(),
rollback,
..Default::default()
}),
_ => Bytes::from_static(b"late-upload"),
};
let address = proxy.local_addr();
let caller = tokio::spawn(async move {
let client: ProxyClient = Client::builder(TokioExecutor::new()).http2_only(true).build_http();
let response = call_body(&client, address, method, request).await.expect("retained reply");
response.into_body().collect().await.expect("typed response frames")
});
pause.wait_for_hits(1).await.expect("actual retained request");
pause.release();
let response = caller.await.expect("caller task");
let trailers = response.trailers().expect("reply trailers");
assert_eq!(trailers["x-proof"], "unchanged-proof");
assert_eq!(trailers["grpc-status"], status.unwrap_or("0"));
assert_eq!(response.to_bytes(), reply);
assert_eq!(pause.responses(), 1);
assert_eq!(
(pause.successful_mutation_replies(), pause.refused_mutations()),
expected,
"typed {method} outcome"
);
let actions = if method == "SettlePartTransaction" {
if rollback { (0, 1) } else { (1, 0) }
} else {
(0, 0)
};
assert_eq!(pause.retained_settle_actions(), actions);
}
})
.await
.expect("part reply deadline");
}
async fn assert_late_packet_ownership() {
tokio::time::timeout(Duration::from_secs(10), async {
+3
View File
@@ -12,6 +12,9 @@ Applies to `crates/ecstore/`.
- Be careful with allocations and locking in hot paths.
- Keep network and disk operations async-friendly; avoid introducing unnecessary blocking.
- Coalesce related `open`, `read`/`write`, `fsync`, and `close` filesystem steps into a single `blocking_queue` or `run_blocking_namespace_operation` closure per disk rather than chaining multiple `tokio::fs` or `spawn_blocking` dispatches.
- Pin a parent directory once (`openat2(BENEATH | NO_SYMLINKS)` on Linux) and reuse the pinned guard across sibling metadata reads (`read_optional_in_pinned`, `part.N.meta` loops) instead of re-walking ancestor components per file or re-opening when `source_parent == destination_parent`.
- After `file.metadata()`, pre-size read buffers (`Vec::with_capacity(len + 1)`) before `read_to_end` to avoid `std::io::read_to_end`'s 32-byte exponential growth (`6–7` `read()` syscalls per small metadata file).
- Benchmark-sensitive changes should include measurable rationale.
## Allocation Discipline in Hot Paths
@@ -41,6 +41,10 @@ use tonic::service::interceptor::InterceptedService;
use tonic::{Request, Response};
use tracing::{debug, info, warn};
const EVENT_NAMESPACE_LOCK_RELEASE: &str = "namespace_lock_release";
const LOG_COMPONENT_ECSTORE: &str = "ecstore";
const LOG_SUBSYSTEM_REMOTE_LOCK: &str = "remote_lock";
fn attach_lock_mutation_body_digest<T: rustfs_protos::CanonicalMutationBody>(request: &mut Request<T>) -> std::io::Result<()> {
set_tonic_rolling_mutation_body_digest(request)
}
@@ -233,6 +237,20 @@ pub struct RemoteClient {
impl RemoteClient {
const ONLINE_CHECK_RESOURCE: &'static str = "health-lock-online";
fn record_release_ack(&self, lock_id: &LockId) {
tracing::trace!(
target: "rustfs_ecstore::object_lock_diag",
event = EVENT_NAMESPACE_LOCK_RELEASE,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_REMOTE_LOCK,
state = "acknowledged",
peer_kind = "remote",
lock_id = %lock_id,
peer = %self.addr,
"Remote namespace lock release acknowledged"
);
}
pub fn new(endpoint: String) -> Self {
Self { addr: endpoint }
}
@@ -1204,8 +1222,6 @@ impl LockClient for RemoteClient {
}
async fn release(&self, lock_id: &LockId) -> Result<bool> {
info!("remote release for {}", lock_id);
let unlock_request = Self::create_unlock_request(lock_id);
let request_string = serde_json::to_string(&unlock_request)
.map_err(|e| LockError::internal(format!("Failed to serialize request: {e}")))?;
@@ -1226,6 +1242,9 @@ impl LockClient for RemoteClient {
if let Some(error_info) = resp.error_info {
return Err(LockError::internal(error_info));
}
if resp.success {
self.record_release_ack(lock_id);
}
Ok(resp.success)
}
@@ -1261,7 +1280,13 @@ impl LockClient for RemoteClient {
Ok(lock_ids
.iter()
.enumerate()
.map(|(idx, _)| resp.results.get(idx).map(|result| result.success).unwrap_or(false))
.map(|(idx, lock_id)| {
let result = resp.results.get(idx);
if result.is_some_and(|result| result.success && result.error_info.is_none()) {
self.record_release_ack(lock_id);
}
result.map(|result| result.success).unwrap_or(false)
})
.collect())
}
+112 -16
View File
@@ -3720,6 +3720,46 @@ fn is_decommission_copy_quorum_deferral(err: &Error) -> bool {
data_movement::data_movement_stage_source_as::<DecommissionCopyQuorumFailure>(err).is_some()
}
/// The storage error of a version copy that kept timing out on a namespace
/// lock through its in-place retries, kept typed so the entry failure policy
/// defers it.
#[derive(Debug, thiserror::Error)]
#[error("{0}")]
struct DecommissionCopyLockTimeout(#[source] Error);
fn is_decommission_copy_lock_timeout_error(err: &Error) -> bool {
let err = data_movement::data_movement_stage_source(err).unwrap_or(err);
matches!(err, Error::Lock(rustfs_lock::LockError::Timeout { .. }))
}
/// A version copy that timed out acquiring a namespace lock leaves the source
/// in place; the next attempt copies the version again or finds an equivalent
/// committed target. The lock is held by another mutation of the key or, when
/// this node restarted mid-copy, by its previous incarnation until that lock's
/// lease expires on the peers. Either clears by waiting, so the entry is
/// deferred instead of being counted as a permanent per-object failure.
fn decommission_copy_lock_timeout_deferral(stage: &str, bucket: &str, object: &str, err: Error) -> Error {
data_movement::data_movement_context_error(
format!("decommission {stage} timed out on a namespace lock for {bucket}/{object}: {err}"),
DecommissionCopyLockTimeout(err),
)
}
fn is_decommission_copy_lock_timeout_deferral(err: &Error) -> bool {
data_movement::data_movement_stage_source_as::<DecommissionCopyLockTimeout>(err).is_some()
}
/// The deferral of a version copy whose in-place retries ended on `error`, if
/// that failure belongs to the pools at that moment rather than to the object.
fn take_decommission_copy_deferral(stage: &str, bucket: &str, object: &str, error: &mut Option<Error>) -> Option<Error> {
if let Some(err) = error.take_if(|err| is_decommission_copy_quorum_error(err)) {
return Some(decommission_copy_quorum_deferral(stage, bucket, object, err));
}
error
.take_if(|err| is_decommission_copy_lock_timeout_error(err))
.map(|err| decommission_copy_lock_timeout_deferral(stage, bucket, object, err))
}
fn resolve_decommission_entry_reload_result(result: Result<()>, bucket: &str, object_name: &str) -> Result<()> {
result.map_err(|err| Error::other(format!("decommission reload_pool_meta failed for {bucket}/{object_name}: {err}")))
}
@@ -5782,11 +5822,14 @@ enum DecommissionEntryOutcome {
/// entry is done and must not be counted as a failure or escalated. A quorum
/// failure of the source cleanup is deferred too: the copied source is still
/// in place and the next attempt cleans it up again. So is a version copy that
/// lost drive quorum through its in-place retries: the source still holds the
/// version and the next attempt copies it again. Every other failure stays
/// terminal.
/// lost drive quorum or timed out on a namespace lock through its in-place
/// retries: the source still holds the version and the next attempt copies it
/// again. Every other failure stays terminal.
fn resolve_decommission_entry_failure(err: Error) -> Result<DecommissionEntryOutcome> {
if is_decommission_source_cleanup_retryable_error(&err) || is_decommission_copy_quorum_deferral(&err) {
if is_decommission_source_cleanup_retryable_error(&err)
|| is_decommission_copy_quorum_deferral(&err)
|| is_decommission_copy_lock_timeout_deferral(&err)
{
return Ok(DecommissionEntryOutcome::Deferred(err));
}
match classify_decommission_capacity_failure(&err) {
@@ -18150,13 +18193,11 @@ impl ECStore {
}
}
if failure && let Some(err) = error.take_if(|err| is_decommission_copy_quorum_error(err)) {
return Err(decommission_copy_quorum_deferral(
"delete_marker_copy",
bucket.as_str(),
version.name.as_str(),
err,
));
if failure
&& let Some(err) =
take_decommission_copy_deferral("delete_marker_copy", bucket.as_str(), version.name.as_str(), &mut error)
{
return Err(err);
}
if ignore {
@@ -18542,14 +18583,14 @@ impl ECStore {
if failure
&& !source_read_failed
&& let Some(err) = error.take_if(|err| is_decommission_copy_quorum_error(err))
{
return Err(decommission_copy_quorum_deferral(
&& let Some(err) = take_decommission_copy_deferral(
DECOMMISSION_STAGE_MIGRATE_OBJECT,
bucket.as_str(),
version.name.as_str(),
err,
));
&mut error,
)
{
return Err(err);
}
if ignore {
@@ -28296,6 +28337,61 @@ mod tests {
));
}
/// A restarted node's previous incarnation keeps its namespace locks on the
/// peers until their lease expires, so a resumed copy of a key that was in
/// flight at the crash times out acquiring the lock. That copy never
/// committed: it is deferred, never counted as a failed object.
#[test]
fn decommission_copy_lock_timeout_is_deferred_not_failed() {
let timeout = || {
Error::Lock(rustfs_lock::LockError::timeout(
"bucket-a/object-a@latest",
std::time::Duration::from_secs(5),
))
};
let prepare = data_movement::data_movement_stage_error_for_test(
"decommission_object",
"prepare_put_object",
"bucket-a",
"object-a",
timeout(),
);
for err in [timeout(), prepare] {
assert!(is_decommission_copy_lock_timeout_error(&err), "{err}");
assert!(!is_decommission_copy_quorum_error(&err), "{err}");
let mut error = Some(err);
let deferred = take_decommission_copy_deferral("migrate_object", "bucket-a", "object-a", &mut error)
.expect("a lock timeout copy must be deferred");
assert!(error.is_none());
assert!(is_decommission_copy_lock_timeout_deferral(&deferred));
assert!(!is_decommission_copy_quorum_deferral(&deferred));
let failure = data_movement::data_movement_stage_source_as::<DecommissionCopyLockTimeout>(&deferred)
.expect("the deferred error must keep its lock-timeout origin");
assert!(is_decommission_copy_lock_timeout_error(&failure.0));
assert!(matches!(
resolve_decommission_entry_failure(deferred),
Ok(DecommissionEntryOutcome::Deferred(_))
));
}
let mut quorum = Some(Error::ErasureWriteQuorum);
let deferred = take_decommission_copy_deferral("migrate_object", "bucket-a", "object-a", &mut quorum)
.expect("a lost drive quorum copy stays deferred");
assert!(is_decommission_copy_quorum_deferral(&deferred));
for other in [
Error::Lock(rustfs_lock::LockError::internal("lock backend failed")),
Error::other("Lock acquisition timeout for resource 'bucket-a/object-a@latest' after 5s"),
] {
let mut error = Some(other);
assert!(
take_decommission_copy_deferral("migrate_object", "bucket-a", "object-a", &mut error).is_none(),
"{error:?}"
);
assert!(error.is_some(), "a per-object failure keeps its error");
}
}
#[test]
fn decommission_target_capacity_error_accepts_wrapped_capacity_errors() {
let disk_full = Error::other(format!("decommission_object: put_object failed for bucket/object: {}", Error::DiskFull));
+87
View File
@@ -2480,6 +2480,93 @@ mod decommission_lock_order_tests {
});
}
/// A node killed mid-copy leaves its namespace lock on the peers until the
/// lock lease expires, so the resumed decommission times out acquiring the
/// lock of the key it was copying through every in-place retry. The entry
/// is deferred and copied once the lease is gone; it is never counted as a
/// failed object, which would fail the whole decommission.
#[test]
#[serial_test::serial]
fn decommission_defers_a_copy_that_timed_out_on_a_stale_namespace_lock() {
run_large_stack_async_test("decommission-copy-lock-timeout", || async {
let (_temp_dirs, store, _other_store) = test_three_pool_stores_with_isolated_node_contexts(None).await;
let bucket = test_bucket("copy-lock-timeout");
let object = "external/005.bin";
let payload = vec![0x3c; 2048];
store
.make_bucket(&bucket, &MakeBucketOptions::default())
.await
.expect("create the bucket");
put_source_object(&store, &bucket, object, payload.clone()).await;
// The stale lease outlives every in-place attempt of the first
// pass and has expired by the deferred replay.
let stale_lease_calls = crate::core::pools::DECOMMISSION_VERSION_COPY_ATTEMPTS;
let copy_calls = Arc::new(AtomicUsize::new(0));
let _fault = DecommissionTestFaultGuard::install_with_error(
Arc::new({
let copy_calls = Arc::clone(&copy_calls);
let bucket = bucket.clone();
move |stage, entry_bucket, entry_object, _, _| {
stage == "before_target_commit"
&& entry_bucket == bucket
&& entry_object == object
&& copy_calls.fetch_add(1, Ordering::AcqRel) < stale_lease_calls
}
}),
Some(Arc::new(|_, bucket, object| {
// The shape the target write reports when the lock domain
// still holds the previous incarnation's lock.
data_movement::data_movement_stage_error_for_test(
"decommission_object",
"prepare_put_object",
bucket,
object,
crate::error::Error::Lock(rustfs_lock::LockError::timeout(
format!("{bucket}/{object}@latest"),
Duration::from_secs(5),
)),
)
})),
);
start_transitioned_entry_decommission(&store, &bucket).await;
spawn_decommission_routine(&store, 1)
.await
.expect("the decommission routine task should join")
.expect("the decommission routine should return");
assert_eq!(
copy_calls.load(Ordering::Acquire),
stale_lease_calls + 1,
"the first pass exhausts its in-place retries and the replay copies the object once"
);
let local = store.pool_meta.read().await;
let info = local.pools[0]
.decommission
.as_ref()
.expect("the decommission state should remain present");
assert_eq!(
info.items_decommission_failed, 0,
"a lock held until its lease expires is not a permanent failure: {info:?}"
);
assert!(
info.complete && !info.failed && !info.canceled && info.capacity_blocked_reason.is_none(),
"the replayed entry must complete the decommission: {info:?}"
);
drop(local);
assert!(exact_versions(&store, 0, &bucket, object).await.is_empty(), "the source is cleaned up");
let mut reader = store
.get_object_reader(&bucket, object, None, HeaderMap::new(), &ObjectOptions::default())
.await
.expect("the migrated object stays readable");
let mut body = Vec::new();
reader.stream.read_to_end(&mut body).await.expect("read the migrated object");
assert_eq!(body, payload);
});
}
/// Seed `object` in pool 0 with three 2 KiB data versions and a newest
/// delete marker; returns the data versions oldest first.
async fn seed_versioned_entry_with_marker(
+16 -16
View File
@@ -28,7 +28,7 @@ use crate::{
error::{Error, classify_system_path_failure_reason},
object_api::{ObjectInfo, ObjectOptions, PutObjReader},
runtime::sources as runtime_sources,
store::{ECStore, list_objects::list_marker_key},
store::ECStore,
};
pub use local_snapshot::{LocalUsageSnapshot, read_snapshot as read_local_snapshot, snapshot_path};
use rustfs_data_usage::{
@@ -1966,8 +1966,8 @@ fn advance_version_listing_cursor(
let next_version_marker = next_version_marker
.filter(|next_version_marker| !next_version_marker.is_empty())
.ok_or_else(|| Error::other(format!("bucket {bucket} version listing was truncated without a version marker")))?;
let current_key_marker = marker.as_deref().map(list_marker_key);
let next_key_marker = list_marker_key(&next_marker);
let current_key_marker = marker.as_deref();
let next_key_marker = next_marker.as_str();
if current_key_marker == Some(next_key_marker) && version_marker.as_deref() == Some(next_version_marker.as_str()) {
return Err(Error::other(format!(
"bucket {bucket} version listing returned a repeated continuation marker"
@@ -5167,20 +5167,20 @@ mod tests {
);
assert!(ensure_truncated_version_page_has_entries("bucket-a", 0).is_err());
ensure_truncated_version_page_has_entries("bucket-a", 1).expect("a truncated page with an entry can advance");
}
let mut tagged_marker = Some("object-a[rustfs_cache:v2,id:old]".to_string());
let mut tagged_version_marker = Some("version-a".to_string());
assert!(
advance_version_listing_cursor(
"bucket-a",
&mut tagged_marker,
&mut tagged_version_marker,
Some("object-a[rustfs_cache:v2,id:new]".to_string()),
Some("version-a".to_string()),
)
.is_err(),
"different cache tags for the same logical marker must not count as progress"
);
#[test]
fn version_listing_cursor_compares_literal_cache_shaped_keys() {
let mut marker = Some("object-a".to_string());
let mut version_marker = Some("version-a".to_string());
advance_version_listing_cursor(
"bucket-a",
&mut marker,
&mut version_marker,
Some("object-a[rustfs_cache:v2,id:old]".to_string()),
Some("version-a".to_string()),
)
.expect("a distinct literal key advances the cursor");
}
#[test]
+249 -2
View File
@@ -19,6 +19,7 @@ use tracing::Span;
use uuid::Uuid;
const EVENT_OBJECT_NAMESPACE_LOCK: &str = "object_namespace_lock";
const EVENT_OBJECT_NAMESPACE_LOCK_PEER: &str = "object_namespace_lock_peer";
const LOG_COMPONENT_ECSTORE: &str = "ecstore";
const LOG_SUBSYSTEM_OBJECT_LOCK: &str = "object_lock";
@@ -56,6 +57,7 @@ struct AttemptContext {
requested_version_id: Option<String>,
namespace: String,
owner: String,
lock_id: Option<String>,
mode: &'static str,
timeout: Duration,
started_at: Instant,
@@ -85,6 +87,7 @@ impl ObjectLockAttempt {
requested_version_id: requested_version_id.map(str::to_owned),
namespace: lock.namespace().to_owned(),
owner: lock.owner().to_owned(),
lock_id: None,
mode,
timeout,
started_at: Instant::now(),
@@ -105,9 +108,29 @@ impl ObjectLockAttempt {
context.finished = true;
context.trace("disabled");
}
Ok(_) => {
Ok(guard) => {
context.lock_id = guard.lock_id().map(ToString::to_string);
context.acquired_at = Some(Instant::now());
context.trace("acquired");
if let NamespaceLockGuard::Standard(guard) = guard {
for (peer_index, peer_lock_id) in guard.granted_lock_ids().enumerate() {
context.parent.in_scope(|| {
tracing::trace!(
target: "rustfs_ecstore::object_lock_diag",
parent: &context.parent,
event = EVENT_OBJECT_NAMESPACE_LOCK_PEER,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_OBJECT_LOCK,
state = "granted",
lock_attempt_id = %context.id,
lock_id = context.lock_id.as_deref(),
peer_lock_id = %peer_lock_id,
peer_index,
"Object namespace peer owner granted"
);
});
}
}
}
Err(error) => {
context.finished = true;
@@ -171,6 +194,7 @@ impl AttemptContext {
mode = self.mode,
owner = %self.owner,
lock_attempt_id = %self.id,
lock_id = self.lock_id.as_deref(),
acquire_ms = duration_ms(acquired_at.duration_since(self.started_at)),
hold_ms = duration_ms(now.duration_since(acquired_at)),
timeout_ms = duration_ms(self.timeout),
@@ -235,15 +259,238 @@ pub(crate) mod tests {
}
pub(crate) fn rows(&self) -> Vec<serde_json::Value> {
self.all_rows()
.into_iter()
.filter(|row| row["fields"]["event"] == EVENT_OBJECT_NAMESPACE_LOCK)
.collect()
}
fn all_rows(&self) -> Vec<serde_json::Value> {
let bytes = self.0.lock().expect("lock diagnostic capture mutex");
let text = std::str::from_utf8(&bytes).expect("UTF-8 diagnostic events");
text.lines()
.map(|line| serde_json::from_str::<serde_json::Value>(line).expect("JSON diagnostic event"))
.filter(|row| row["fields"]["event"] == EVENT_OBJECT_NAMESPACE_LOCK)
.collect()
}
}
#[test]
#[serial_test::serial]
fn object_lock_diagnostics_join_guard_identity_to_physical_release() {
use rustfs_lock::{
DisabledLockManager, FastObjectLockManager, GlobalLockManager, LocalClient, LockClient, LockRequest, LockType,
NamespaceLock, ObjectKey,
};
temp_env::with_vars([(rustfs_config::ENV_OBJECT_LOCK_DIAG_ENABLE, Some("true"))], || {
let capture = CapturedLockEvents::default();
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("diagnostic release runtime");
tracing::subscriber::with_default(capture.subscriber(), || {
runtime.block_on(async {
let clients = (0..4)
.map(|_| Arc::new(LocalClient::with_manager(Arc::new(GlobalLockManager::new()))) as Arc<dyn LockClient>)
.collect::<Vec<_>>();
let lock = NamespaceLockWrapper::new(
NamespaceLock::with_clients_and_quorum("diagnostics".to_owned(), clients.clone(), 4),
ObjectKey::new("bucket", "object"),
"coordinator".to_owned(),
);
let request = tracing::info_span!("request", request_id = "original-multipart-request");
let mut attempt = request.in_scope(|| {
ObjectLockAttempt::start(
"abort_multipart_upload",
"bucket",
"object",
None,
&lock,
"write",
Duration::from_secs(5),
)
});
let result = lock.get_write_lock(Duration::from_secs(5)).await;
attempt.observe(&result);
let guard = result.expect("four real namespace owners");
let id = guard.lock_id().expect("distributed guard identity").clone();
let peer_ids = match &guard {
NamespaceLockGuard::Standard(guard) => {
assert_eq!(guard.granted_lock_ids().count(), 4);
guard.granted_lock_ids().cloned().collect::<std::collections::HashSet<_>>()
}
NamespaceLockGuard::Fast(_) => panic!("real distributed namespace required"),
};
let rows = capture.rows();
assert_eq!(rows.last().expect("acquired event")["fields"]["lock_id"], id.to_string());
let rows = capture.all_rows();
let mappings = rows
.iter()
.filter(|row| row["fields"]["event"] == EVENT_OBJECT_NAMESPACE_LOCK_PEER)
.collect::<Vec<_>>();
assert_eq!(mappings.len(), 4, "every granted peer must map to its logical guard");
assert!(mappings.iter().all(|row| {
row["fields"]["lock_id"] == id.to_string()
&& peer_ids
.iter()
.any(|peer_id| row["fields"]["peer_lock_id"] == peer_id.to_string())
}));
for client in &clients {
let mut owned = 0;
for peer_id in &peer_ids {
owned += usize::from(
client
.check_status(peer_id)
.await
.expect("actual peer owner status")
.is_some(),
);
}
assert_eq!(owned, 1, "each client must hold exactly one actual granted identity");
}
drop((guard, attempt));
assert!(
capture.all_rows().iter().all(|row| row["fields"]["state"] != "released"),
"enqueue is not release acknowledgement"
);
tokio::time::timeout(Duration::from_secs(5), async {
while capture
.all_rows()
.iter()
.filter(|row| {
row["fields"]["event"] == "namespace_lock_release" && row["fields"]["state"] == "released"
})
.count()
< 4
{
tokio::task::yield_now().await;
}
})
.await
.expect("all physical release callbacks finish");
for client in &clients {
for peer_id in &peer_ids {
assert!(
client
.check_status(peer_id)
.await
.expect("released peer owner status")
.is_none()
);
assert!(!client.release(peer_id).await.expect("duplicate release is absent"));
}
}
let disabled = LocalClient::with_manager(Arc::new(GlobalLockManager::Disabled(DisabledLockManager::new())));
let disabled_request =
LockRequest::new(ObjectKey::new("bucket", "disabled"), LockType::Exclusive, "disabled-owner".to_owned());
assert!(
disabled
.acquire_lock(&disabled_request)
.await
.expect("disabled acquire bypass")
.success
);
assert!(
disabled
.release(&disabled_request.lock_id)
.await
.expect("disabled release bypass")
);
let rows = capture.all_rows();
let releases = rows
.iter()
.filter(|row| row["fields"]["event"] == "namespace_lock_release" && row["fields"]["state"] == "released")
.collect::<Vec<_>>();
assert_eq!(
releases.len(),
4,
"duplicates and disabled guards cannot acknowledge another physical release"
);
let physical_mappings = rows
.iter()
.filter(|row| row["fields"]["event"] == "namespace_lock_release" && row["fields"]["state"] == "mapped")
.collect::<Vec<_>>();
assert_eq!(physical_mappings.len(), 4);
assert!(releases.iter().all(|row| physical_mappings.iter().any(|mapped| {
mapped["fields"]["physical_guard_id"] == row["fields"]["physical_guard_id"]
&& mapped["fields"]["process_id"] == row["fields"]["process_id"]
&& peer_ids
.iter()
.any(|peer_id| mapped["fields"]["lock_id"] == peer_id.to_string())
})));
let dropped = capture.rows().pop().expect("guard drop event");
assert_eq!(dropped["fields"]["lock_id"], id.to_string());
assert!(
dropped["spans"]
.as_array()
.expect("retained request span")
.iter()
.any(|span| span["request_id"] == "original-multipart-request")
);
let manager = Arc::new(FastObjectLockManager::new());
let stale_client = LocalClient::with_manager(Arc::new(GlobalLockManager::Enabled(manager.clone())));
let stale_request = LockRequest::new(
ObjectKey::new("bucket", "externally-released"),
LockType::Exclusive,
"stale-owner".to_owned(),
);
assert!(
stale_client
.acquire_lock(&stale_request)
.await
.expect("real stale owner")
.success
);
assert_eq!(manager.force_unlock(&stale_request.resource), 1);
let mut successor = manager
.acquire_write_lock(stale_request.resource.clone(), "successor-owner")
.await
.expect("real successor guard");
assert!(
stale_client
.release(&stale_request.lock_id)
.await
.expect("retire stale client record")
);
assert_eq!(
manager
.get_lock_info(&stale_request.resource)
.expect("successor remains locked")
.owner
.as_ref(),
"successor-owner"
);
assert_eq!(
capture
.all_rows()
.iter()
.filter(
|row| row["fields"]["event"] == "namespace_lock_release" && row["fields"]["state"] == "released"
)
.count(),
4,
"a stale guard's failed physical release must not produce an ACK"
);
let successor_id = successor.guard_id();
assert!(successor.release(), "the actual successor release succeeds");
assert!(!successor.release(), "a duplicate explicit release cannot complete again");
drop(successor);
assert_eq!(
capture
.all_rows()
.iter()
.filter(|row| row["fields"]["event"] == "namespace_lock_release"
&& row["fields"]["state"] == "released"
&& row["fields"]["physical_guard_id"] == successor_id)
.count(),
1,
"explicit release followed by Drop records one physical completion"
);
});
});
});
}
fn namespace() -> NamespaceLockWrapper {
let manager = rustfs_lock::GlobalLockManager::Enabled(Arc::new(rustfs_lock::fast_lock::FastObjectLockManager::new()));
NamespaceLockWrapper::new(
+15 -16
View File
@@ -10009,9 +10009,7 @@ impl LocalDisk {
} {
meta.name.push_str(SLASH_SEPARATOR);
// Conservative listings verify physical prefixes. Never-versioned
// buckets use the bounded fast path, which only has to rule out
// the data dirs a deleted version leaves behind; an empty listing
// of such a prefix then reclaims committed residue.
// buckets first rule out the data dirs a deleted version leaves behind.
let listable = if opts.recursive || opts.incl_deleted {
true
} else if opts.skip_hidden_prefix_check {
@@ -10165,17 +10163,19 @@ impl LocalDisk {
/// Reads stay small on genuine prefixes: each directory is sampled with one
/// bounded batch, the sampled children are probed depth-first, and the
/// directory is only read in full once every sampled child proved to hold
/// nothing listable. A total read budget makes a residue tree larger than
/// the budget surface and be hidden one level down instead, so the cost on
/// a genuine prefix never exceeds its depth in small directory reads. A
/// directory that vanishes mid-probe holds nothing listable.
/// nothing listable. If the read budget is exhausted, verify the subtree
/// before exposing the prefix. A directory that vanishes mid-probe holds
/// nothing listable.
async fn directory_is_delete_residue(&self, bucket: &str, dir_name: &str, stall: Option<Duration>) -> Result<bool> {
let dir_name = dir_name.trim_end_matches(SLASH_SEPARATOR);
let mut reads = 0usize;
let mut pending = vec![ResidueProbeStep::Sample(dir_name.to_owned())];
while let Some(step) = pending.pop() {
if reads >= DELETE_RESIDUE_PROBE_READ_BUDGET {
return Ok(false);
return self
.directory_has_listing_entry(bucket, dir_name, false, stall)
.await
.map(|listable| !listable);
}
reads += 1;
@@ -25245,11 +25245,13 @@ mod test {
)
.await;
// A prefix with more residue directories than the probe budget stays
// visible rather than costing an unbounded walk.
// A prefix larger than the fast-probe budget still needs a complete
// read-only check before it can appear in a delimiter listing.
for hour in 0..(DELETE_RESIDUE_PROBE_READ_BUDGET + 1) {
write_residue(&bucket_dir.join(format!("bulk/2026/08/28/{hour:02}/a.parquet")), false).await;
write_residue(&bucket_dir.join(format!("bulk-live/2026/08/28/{hour:02}/a.parquet")), false).await;
}
write_object(&bucket_dir.join("bulk-live/live.parquet"), "bulk-live/live.parquet").await;
// Exactly at the budget: `edge/` with N object dirs costs one sampled
// read of `edge`, two reads per object dir (its own and its data dir),
// and one remainder read of `edge` once its 8-entry sample was full.
@@ -25330,10 +25332,8 @@ mod test {
vec!["logs/default/2026/08/28/23/".to_owned()]
);
assert_eq!(scan_names(&disk, bucket, "logs/").await, vec!["logs/default/".to_owned()]);
assert_eq!(scan_names(&disk, bucket, "bulk/2026/08/").await, vec!["bulk/2026/08/28/".to_owned()]);
// Listed directly, every object dir is probed on its own and hidden;
// the budget only bites where the whole tree hangs off one entry, so
// the root listing below shows `edge-show/` but not `edge-hide/`.
assert_eq!(scan_names(&disk, bucket, "bulk/2026/08/").await, Vec::<String>::new());
assert_eq!(scan_names(&disk, bucket, "bulk-live/").await, vec!["bulk-live/live.parquet".to_owned()]);
assert_eq!(scan_names(&disk, bucket, "edge-hide/").await, Vec::<String>::new());
assert_eq!(scan_names(&disk, bucket, "edge-show/").await, Vec::<String>::new());
assert_eq!(scan_names(&disk, bucket, "stray/").await, vec!["stray/2026/".to_owned()]);
@@ -25341,8 +25341,7 @@ mod test {
assert_eq!(
scan_names(&disk, bucket, "").await,
vec![
"bulk/".to_owned(),
"edge-show/".to_owned(),
"bulk-live/".to_owned(),
"logs/".to_owned(),
"odd/".to_owned(),
"stray/".to_owned(),
@@ -6,6 +6,7 @@ use super::{
LocalDisk,
multipart_source_state::{
LoadedSource, SourceHead, SourceState, SourceStore, decode, encode, load_source, raw_sha, read_optional,
read_optional_in_pinned,
},
};
use crate::disk::{
@@ -127,20 +128,6 @@ pub(super) fn validate_retired_head(
}
impl SourceStore {
pub async fn root_missing(&self) -> Result<bool> {
let source = self.source.clone();
let base = self.base.clone();
let root = self.publication_root.clone();
os::run_blocking_namespace_operation(self.lease.clone(), move || {
match os::pin_multipart_source_directory(&source, &base, &root) {
Ok(_) => Ok(false),
Err(error) if error.kind() == ErrorKind::NotFound => Ok(true),
Err(error) => Err(error),
}
})
.await
.map_err(DiskError::from)
}
fn verify_abort_cohort(&self, certificate: &MultipartAbortCertificate) -> Result<()> {
certificate.verify_with_authority(&self.authority)?;
if certificate.disk_ids != self.disk_ids
@@ -265,7 +252,7 @@ impl SourceStore {
let certificate = self.read_abort_certificate(&loaded).await?;
Ok(MultipartSourceInspection {
disk_id: self.disk_id,
root_missing: self.root_missing().await?,
root_missing: loaded.root_missing,
abort_round: loaded
.state
.as_ref()
@@ -620,12 +607,17 @@ pub(super) struct OrdinaryMultipartDeleteCheck {
}
impl OrdinaryMultipartDeleteCheck {
pub(super) fn validate(&self) -> Result<()> {
if let Some(raw) = read_optional(&self.source.join(STATE), &self.base, &self.root)? {
let pinned = match os::pin_multipart_source_directory(&self.source, &self.base, &self.root) {
Ok(pinned) => pinned,
Err(error) if error.kind() == ErrorKind::NotFound => return Ok(()),
Err(error) => return Err(error.into()),
};
if let Some(raw) = read_optional_in_pinned(&pinned, &self.source, STATE, &self.base, &self.root)? {
let state: SourceState = decode(&raw)?;
if state.abort.is_some() || state.phase == MultipartSourcePhase::Aborting {
return Err(DiskError::ConditionalDeleteConflict);
}
} else if os::multipart_source_has_native_controls(&self.source, &self.base, &self.root)? {
} else if os::multipart_source_has_native_controls_in_pinned(&self.source, &pinned)? {
return Err(DiskError::ConditionalDeleteConflict);
}
Ok(())
@@ -170,7 +170,7 @@ impl LocalDisk {
recovery: loaded.state.as_ref().and_then(|state| state.recovery.clone()),
negative_certificate,
ready_certificate,
root_missing: store.root_missing().await?,
root_missing: loaded.root_missing,
abort_round: loaded
.state
.as_ref()
@@ -106,6 +106,7 @@ impl SourceHead {
}
pub(super) struct LoadedSource {
pub root_missing: bool,
pub state: Option<SourceState>,
pub head: Option<SourceHead>,
pub original: Option<Bytes>,
@@ -224,11 +225,31 @@ pub(super) fn read_optional(path: &Path, base: &Path, root: &os::PublicationRoot
Err(error) => Err(error.into()),
}
}
pub(super) fn read_optional_in_pinned(
pinned: &os::ExistingBaseDirectoryGuard,
parent: &Path,
name: &str,
base: &Path,
root: &os::PublicationRoot,
) -> Result<Option<Bytes>> {
match os::read_multipart_source_metadata_in_pinned(pinned, parent, std::ffi::OsStr::new(name), base, root) {
Ok(bytes) => Ok(Some(Bytes::from(bytes))),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(error) => Err(error.into()),
}
}
/// Canonical source mutations are admitted under SourceRW at the syscall,
/// not on the earlier async caller that may have timed out.
pub(super) fn validate_source_mutation(source: &Path, base: &Path, root: &os::PublicationRoot, initial: bool) -> Result<()> {
let state = read_optional(&source.join(STATE), base, root)?;
let head = read_optional(&source.join(STORAGE_FORMAT_FILE), base, root)?;
let pinned = match os::pin_multipart_source_directory(source, base, root) {
Ok(pinned) => pinned,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return if initial { Ok(()) } else { Err(DiskError::FileNotFound) };
}
Err(error) => return Err(error.into()),
};
let state = read_optional_in_pinned(&pinned, source, STATE, base, root)?;
let head = read_optional_in_pinned(&pinned, source, STORAGE_FORMAT_FILE, base, root)?;
let Some(head) = head else {
return if initial && state.is_none() {
Ok(())
@@ -348,6 +369,9 @@ impl SourceStore {
.map_err(DiskError::from)?
}
pub async fn persist(&self, state: &SourceState) -> Result<()> {
self.persist_with_source_sync(state, false).await
}
async fn persist_with_source_sync(&self, state: &SourceState, sync_source_before: bool) -> Result<()> {
state.validate(self.disk_id, &self.path)?;
let raw = encode(state)?;
let source = self.source.clone();
@@ -365,6 +389,9 @@ impl SourceStore {
});
os::run_blocking_namespace_operation(self.lease.clone(), move || {
let _pin = os::pin_multipart_source_directory(&source, &base, &root)?;
if sync_source_before {
os::fsync_dir_std(&source)?;
}
if let Some(path) = terminal {
os::replace_multipart_source_metadata(&path, &raw, &base, &root)?;
}
@@ -373,6 +400,7 @@ impl SourceStore {
.await
.map_err(DiskError::from)
}
#[cfg(test)]
pub async fn original(&self, state: &SourceState) -> Result<Bytes> {
let path = original_path(&self.source, state.backup_owner);
let base = self.base.clone();
@@ -490,7 +518,7 @@ impl SourceStore {
if encode(actual)? != encode(state)? {
return Err(DiskError::ConditionalDeleteConflict);
}
let raw = self.original(state).await?;
let raw = loaded.original.as_ref().ok_or(DiskError::FileCorrupt)?;
let allowed = match loaded.head.as_ref() {
None => {
state.phase == MultipartSourcePhase::Recovering
@@ -551,7 +579,7 @@ impl SourceStore {
if !allowed {
return Err(DiskError::ConditionalDeleteConflict);
}
let mut info = file_info(&raw, RUSTFS_META_MULTIPART_BUCKET, &self.path, state.lineage.original_version_id)?;
let mut info = file_info(raw, RUSTFS_META_MULTIPART_BUCKET, &self.path, state.lineage.original_version_id)?;
strip_native_controls(&mut info.metadata);
mark_native_source(&mut info);
set_source_status(
@@ -772,13 +800,32 @@ pub(super) fn load_source(
physical_slot: usize,
disk_ids: &[Uuid],
) -> Result<LoadedSource> {
let state_raw = read_optional(&source.join(STATE), base, root)?;
let pinned = match os::pin_multipart_source_directory(source, base, root) {
Ok(pinned) => pinned,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Ok(LoadedSource {
root_missing: true,
state: None,
head: None,
original: None,
info: None,
snapshot: None,
head_matches_state: false,
head_matches_activation: false,
});
}
Err(error) => return Err(error.into()),
};
let state_raw = read_optional_in_pinned(&pinned, source, STATE, base, root)?;
let state: Option<SourceState> = state_raw.as_deref().map(decode).transpose()?;
if state.is_none() && os::multipart_source_has_native_controls(source, base, root)? {
if state.is_none() && os::multipart_source_has_native_controls_in_pinned(source, &pinned)? {
return Err(DiskError::ConditionalDeleteConflict);
}
let head_path = source.join(STORAGE_FORMAT_FILE);
let head = if let Some(state) = state.as_ref() {
let head = if let Some(state) = state
.as_ref()
.filter(|state| state.prepared_length.is_some_and(|len| len > 64 * 1024))
{
let head_path = source.join(STORAGE_FORMAT_FILE);
let maximum = state
.prepared_length
.unwrap_or(0)
@@ -789,7 +836,8 @@ pub(super) fn load_source(
let raw = if is_proxy {
None
} else {
let raw = read_optional(&head_path, base, root)?.ok_or(DiskError::FileCorrupt)?;
let raw = read_optional_in_pinned(&pinned, source, STORAGE_FORMAT_FILE, base, root)?
.ok_or(DiskError::FileCorrupt)?;
if raw_sha(&raw) != sha256 {
return Err(DiskError::FileCorrupt);
}
@@ -801,10 +849,17 @@ pub(super) fn load_source(
Err(error) => return Err(error.into()),
}
} else {
read_optional(&head_path, base, root)?.map(|raw| SourceHead {
sha256: raw_sha(&raw),
length: raw.len() as u64,
raw: Some(raw),
read_optional_in_pinned(&pinned, source, STORAGE_FORMAT_FILE, base, root)?.map(|raw| {
let sha256 = raw_sha(&raw);
let length = raw.len() as u64;
let is_proxy = state
.as_ref()
.is_some_and(|state| Some(sha256) == state.prepared_sha256 && Some(length) == state.prepared_length);
SourceHead {
sha256,
length,
raw: (!is_proxy).then_some(raw),
}
})
};
let mut original = None;
@@ -950,6 +1005,7 @@ pub(super) fn load_source(
head_matches_state = true;
}
Ok(LoadedSource {
root_missing: false,
state,
head,
original,
@@ -1587,10 +1643,8 @@ impl NativeMultipartPublication {
}
}
self.store.sync_target_with_lease(&self.publication, target_lease).await?;
let source = self.store.source.clone();
os::run_blocking_namespace_operation(self.store.lease.clone(), move || os::fsync_dir_std(&source)).await?;
state.advance(MultipartSourcePhase::ConsumedUnsealed)?;
self.store.persist(&state).await?;
self.store.persist_with_source_sync(&state, true).await?;
*self.state.lock() = state;
let loaded = self.store.load().await?;
let snapshot = loaded.snapshot.ok_or(DiskError::FileCorrupt)?;
@@ -411,15 +411,23 @@ impl LocalDisk {
}
let _attempt = os::mkdir_all_below_existing_base_std(&private_object, &base, &publication_root)?;
if incoming.data.is_none() && !incoming.is_remote() {
let source_data_dir = source_object.join(source_dir.to_string());
let pinned_source_data = (!incoming.parts.is_empty())
.then(|| os::pin_multipart_source_directory(&source_data_dir, &base, &publication_root))
.transpose()?;
let mut names = Vec::with_capacity(incoming.parts.len().saturating_mul(3));
for part in &incoming.parts {
if cancelled() {
return Err(std::io::Error::new(std::io::ErrorKind::Interrupted, "multipart staging cancelled"));
}
let path = source_object
.join(source_dir.to_string())
.join(format!("part.{}.meta", part.number));
let bytes = os::read_multipart_source_metadata(&path, &base, &publication_root)?;
let meta_name = format!("part.{}.meta", part.number);
let bytes = os::read_multipart_source_metadata_in_pinned(
pinned_source_data.as_ref().expect("pinned source data directory"),
&source_data_dir,
std::ffi::OsStr::new(&meta_name),
&base,
&publication_root,
)?;
let actual = rustfs_filemeta::ObjectPartInfo::unmarshal(&bytes).map_err(std::io::Error::other)?;
let normalized = i64::try_from(actual.size)
.map_err(std::io::Error::other)?
@@ -441,13 +449,13 @@ impl LocalDisk {
// Preserve the native completed-MPU directory format. This
// immutable, validated generation record is distinct from
// the upload's mutable top-level xl.meta.
names.push(format!("part.{}.meta", part.number));
names.push(meta_name);
if let Some(integrity) = &part.integrity {
names.push(integrity.file_name());
}
}
os::hardlink_multipart_source_files(
&source_object.join(source_dir.to_string()),
&source_data_dir,
&private_object.join(final_dir.to_string()),
&names,
&base,
+97 -29
View File
@@ -3372,6 +3372,29 @@ fn open_existing_directory_below_base_std(
use rustix::fs::{Mode, OFlags, openat};
let relative = existing_base_directory_suffix(directory, base)?;
#[cfg(target_os = "linux")]
{
use rustix::fs::{ResolveFlags, openat2};
use std::os::fd::AsFd;
let flags = OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC;
let owned_base;
let base_fd = if base == publication_root.io_path.as_path() || base == publication_root.path.as_path() {
publication_root.directory.as_fd()
} else {
owned_base = std::os::fd::OwnedFd::from(publication_root.open_directory(base)?);
owned_base.as_fd()
};
let target = if relative.as_os_str().is_empty() {
Path::new(".")
} else {
relative
};
match openat2(base_fd, target, flags, Mode::empty(), ResolveFlags::BENEATH | ResolveFlags::NO_SYMLINKS) {
Ok(leaf) => return Ok(vec![leaf]),
Err(rustix::io::Errno::NOSYS) => {}
Err(err) => return Err(err.into()),
}
}
#[cfg(target_os = "linux")]
let base = publication_root.open_directory(base)?.into();
#[cfg(not(target_os = "linux"))]
let base = {
@@ -4925,12 +4948,16 @@ impl LinuxCommitDirectories {
use std::os::unix::fs::MetadataExt;
// A pinned directory may have been unlinked or replaced. Do not report a
// successful publication into a detached object tree.
for (path, directory) in [
(&self.source_path, &self.source),
(&self.destination_path, self.prepare_destination()?),
] {
let current = self.root.open_directory(path)?.metadata()?;
if (current.dev(), current.ino()) != (directory.device, directory.inode) {
let current = self.root.open_directory(&self.source_path)?.metadata()?;
if (current.dev(), current.ino()) != (self.source.device, self.source.inode) {
return Err(io::Error::new(io::ErrorKind::InvalidData, "commit directory identity changed"));
}
let destination = self.prepare_destination()?;
if self.source_path != self.destination_path
|| (self.source.device, self.source.inode) != (destination.device, destination.inode)
{
let current = self.root.open_directory(&self.destination_path)?.metadata()?;
if (current.dev(), current.ino()) != (destination.device, destination.inode) {
return Err(io::Error::new(io::ErrorKind::InvalidData, "commit directory identity changed"));
}
}
@@ -5223,12 +5250,20 @@ pub(crate) fn prepare_rename_commit_guard(
existing_base_directory_suffix(destination_parent, destination_base)?;
let source = LinuxCommitDirectory::new(publication_root.open_directory(source_parent)?)?;
let destination = std::sync::OnceLock::new();
match publication_root.open_directory(destination_parent) {
Ok(directory) => {
let _ = destination.set(LinuxCommitDirectory::new(directory)?);
if source_parent == destination_parent {
let _ = destination.set(LinuxCommitDirectory {
file: Arc::clone(&source.file),
device: source.device,
inode: source.inode,
});
} else {
match publication_root.open_directory(destination_parent) {
Ok(directory) => {
let _ = destination.set(LinuxCommitDirectory::new(directory)?);
}
Err(err) if err.kind() == io::ErrorKind::NotFound => {}
Err(err) => return Err(err),
}
Err(err) if err.kind() == io::ErrorKind::NotFound => {}
Err(err) => return Err(err),
}
Ok(RenameCommitGuard {
directories: Arc::new(LinuxCommitDirectories {
@@ -6088,12 +6123,20 @@ fn existing_base_directory_suffix<'a>(dir_path: &'a Path, base_dir: &Path) -> io
/// Missing state never downgrades surviving native control records to legacy.
/// Inspect only this pinned upload root; parts and proof trees are not traversed.
#[cfg(test)]
pub(crate) fn multipart_source_has_native_controls(source: &Path, base: &Path, root: &PublicationRoot) -> io::Result<bool> {
let _pin = match pin_multipart_source_directory(source, base, root) {
let pin = match pin_multipart_source_directory(source, base, root) {
Ok(pin) => pin,
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(false),
Err(error) => return Err(error),
};
multipart_source_has_native_controls_in_pinned(source, &pin)
}
pub(crate) fn multipart_source_has_native_controls_in_pinned(
source: &Path,
_pin: &ExistingBaseDirectoryGuard,
) -> io::Result<bool> {
let mut entries = std::fs::read_dir(source)?;
for _ in 0..READ_DIR_PROBE_RAW_LIMIT {
let Some(entry) = entries.next() else {
@@ -6397,15 +6440,10 @@ pub(crate) fn sync_multipart_owned_record_parents(
if !directory.starts_with(source) {
return Err(io::Error::new(io::ErrorKind::InvalidInput, "native record barrier escaped source"));
}
let pinned = pin_multipart_source_directory(directory, base, publication_root)?;
let mut pinned = pin_multipart_source_directory(directory, base, publication_root)?;
#[cfg(unix)]
{
let opened = std::fs::File::from(
pinned
.last()
.ok_or_else(|| io::Error::other("empty native directory guard"))?
.try_clone()?,
);
let opened = std::fs::File::from(pinned.pop().ok_or_else(|| io::Error::other("empty native directory guard"))?);
fsync_directory_handle_std(directory, Some(&opened))?;
}
#[cfg(not(unix))]
@@ -6517,6 +6555,16 @@ pub(crate) fn read_multipart_source_metadata(
read_multipart_metadata(path, base, publication_root, Some(16 * 1024 * 1024))
}
pub(crate) fn read_multipart_source_metadata_in_pinned(
pinned: &ExistingBaseDirectoryGuard,
parent: &Path,
name: &std::ffi::OsStr,
base: &Path,
publication_root: &PublicationRoot,
) -> io::Result<Vec<u8>> {
read_multipart_metadata_in_pinned(pinned, parent, name, base, publication_root, Some(16 * 1024 * 1024))
}
/// Target FI histories have no source-control size limit. Keep the same
/// pinned-parent and no-follow checks when inspecting every referenced DD.
pub(crate) fn read_multipart_reclaim_target_metadata(
@@ -6558,8 +6606,10 @@ fn read_multipart_metadata_bytes(
Ok(bytes)
}
fn read_multipart_metadata(
path: &Path,
fn read_multipart_metadata_in_pinned(
pinned: &ExistingBaseDirectoryGuard,
parent: &Path,
name: &std::ffi::OsStr,
base: &Path,
publication_root: &PublicationRoot,
maximum: Option<u64>,
@@ -6567,16 +6617,10 @@ fn read_multipart_metadata(
#[cfg(unix)]
{
use rustix::fs::{Mode, OFlags, openat};
let parent = path
.parent()
.ok_or_else(|| io::Error::other("multipart metadata has no parent"))?;
let guards = open_existing_directory_below_base_std(parent, base, publication_root)?;
let directory = guards
let _ = (parent, base, publication_root);
let directory = pinned
.last()
.ok_or_else(|| io::Error::other("missing multipart source directory"))?;
let name = path
.file_name()
.ok_or_else(|| io::Error::other("multipart metadata has no name"))?;
let mut file = std::fs::File::from(openat(
directory,
name,
@@ -6589,6 +6633,30 @@ fn read_multipart_metadata(
}
read_multipart_metadata_bytes(&mut file, Some(metadata.len()), maximum)
}
#[cfg(not(unix))]
{
let _ = pinned;
read_multipart_metadata(&parent.join(name), base, publication_root, maximum)
}
}
fn read_multipart_metadata(
path: &Path,
base: &Path,
publication_root: &PublicationRoot,
maximum: Option<u64>,
) -> io::Result<Vec<u8>> {
#[cfg(unix)]
{
let parent = path
.parent()
.ok_or_else(|| io::Error::other("multipart metadata has no parent"))?;
let name = path
.file_name()
.ok_or_else(|| io::Error::other("multipart metadata has no name"))?;
let guards = open_existing_directory_below_base_std(parent, base, publication_root)?;
read_multipart_metadata_in_pinned(&guards, parent, name, base, publication_root, maximum)
}
#[cfg(windows)]
{
use windows_sys::{
@@ -5752,6 +5752,19 @@ impl SetDisks {
return Ok(());
}
// A metadata update that misses write quorum is not rolled back, so
// refuse it before writing when the target disks cannot reach quorum
// even if every write succeeds; otherwise the change a caller reports
// as failed would be served and spread by heal.
let write_quorum = fi.write_quorum(self.default_write_quorum());
let reachable = disks
.iter()
.map(|disk| disk.is_none().then_some(DiskError::DiskNotFound))
.collect::<Vec<_>>();
if let Some(err) = reduce_write_quorum_errs(&reachable, OBJECT_OP_IGNORED_ERRS, write_quorum) {
return Err(err);
}
self.invalidate_get_object_metadata_cache(bucket, object).await;
let mut futures = Vec::with_capacity(disks.len());
@@ -5781,7 +5794,7 @@ impl SetDisks {
}
}
if let Some(err) = reduce_write_quorum_errs(&errs, OBJECT_OP_IGNORED_ERRS, fi.write_quorum(self.default_write_quorum())) {
if let Some(err) = reduce_write_quorum_errs(&errs, OBJECT_OP_IGNORED_ERRS, write_quorum) {
return Err(err);
}
@@ -6338,40 +6351,6 @@ impl SetDisks {
Ok(purged)
}
/// Reclaim metadata-less directory trees discovered by a recursive empty
/// bucket listing. The initial directory read fails closed if any slot is
/// unavailable; per-prefix purges then reuse the full cross-disk scan.
pub(crate) async fn purge_orphan_dir_objects_in_bucket(&self, bucket: &str) -> bool {
let disks = self.get_disks_internal().await;
if disks.is_empty() || disks.iter().any(Option::is_none) {
return false;
}
let mut prefixes = HashSet::new();
for disk in disks.iter().flatten() {
let entries = match disk.list_dir("", bucket, "", 0).await {
Ok(entries) => entries,
Err(_) => return false,
};
prefixes.extend(
entries
.into_iter()
.filter(|entry| entry.ends_with(SLASH_SEPARATOR) && is_safe_orphan_dir_entry(entry)),
);
}
let mut prefixes = prefixes.into_iter().collect::<Vec<_>>();
prefixes.sort_unstable();
let mut purged = false;
for prefix in prefixes {
match self.purge_orphan_dir_object(bucket, &prefix).await {
Ok(prefix_purged) => purged |= prefix_purged,
Err(_) => return false,
}
}
purged
}
/// Orphan cleanup must not infer a complete scan from only the online
/// subset of a set's disks.
pub(crate) async fn orphan_purge_has_complete_disk_set(&self) -> bool {
@@ -9474,7 +9453,7 @@ mod tests {
"lifecycle-inline-data-get-fanout-bucket",
"lifecycle-inline-data-get-object",
GET_OBJECT_PATH_LEGACY_DUPLEX,
None,
false,
);
}
@@ -9485,7 +9464,7 @@ mod tests {
"lifecycle-bounded-inline-data-get-fanout-bucket",
"lifecycle-bounded-inline-data-get-object",
GET_OBJECT_PATH_LEGACY_DUPLEX,
Some("true"),
true,
);
}
@@ -9496,7 +9475,7 @@ mod tests {
RUSTFS_META_BUCKET,
"buckets/.usage-cache/lifecycle-inline-data-get-object",
GET_OBJECT_PATH_INTERNAL_META,
None,
false,
);
}
@@ -9597,7 +9576,7 @@ mod tests {
bucket: &'static str,
object_prefix: &str,
expected_path: &'static str,
bounded_fanout_env: Option<&'static str>,
bounded_fanout: bool,
) {
const DISKS: usize = 4;
let object = object_with_initial_data_shards(bucket, object_prefix, 2, 3);
@@ -9617,16 +9596,26 @@ mod tests {
[
("RUSTFS_GET_METADATA_EARLY_STOP_ENABLE", Some("true")),
("RUSTFS_GET_METADATA_DATA_READ_EARLY_STOP_ENABLE", Some("true")),
("RUSTFS_GET_METADATA_EARLY_STOP_BOUNDED_FANOUT", bounded_fanout_env),
(
"RUSTFS_GET_METADATA_EARLY_STOP_BOUNDED_FANOUT",
Some(if bounded_fanout { "true" } else { "false" }),
),
],
async {
let barrier_disk = if bounded_fanout_env.is_some() {
bounded_spare_disk_index(bucket, &object, 2, 2)
} else {
3
};
let barrier_disk = bounded_spare_disk_index(bucket, &object, 2, 2);
let barrier =
rename_fanout_barrier::arm(&object, barrier_disk, rename_fanout_barrier::PHASE_READ_VERSION);
// Keep one initial bounded read pending so its hedge is scheduled;
// eager fanout needs all quorum reads parked until the spare starts.
let quorum_disks = if bounded_fanout {
vec![bounded_metadata_fanout_order(bucket, &object, DISKS, 2)[0]]
} else {
(0..DISKS).filter(|&index| index != barrier_disk).collect()
};
let quorum_barriers = quorum_disks
.into_iter()
.map(|index| rename_fanout_barrier::arm(&object, index, rename_fanout_barrier::PHASE_READ_VERSION))
.collect::<Vec<_>>();
let disks_for_read = disks.clone();
let object_for_read = object.clone();
let mut read = tokio::spawn(async move {
@@ -9648,6 +9637,9 @@ mod tests {
tokio::time::timeout(BARRIER_PAUSE_GUARD, barrier.wait_until_paused())
.await
.expect("metadata fanout should pause the spare metadata task");
for quorum_barrier in &quorum_barriers {
quorum_barrier.release();
}
let (parts_metadata, errs, diagnostics) = tokio::time::timeout(BARRIER_PAUSE_GUARD, &mut read)
.await
.expect("verified inline quorum should abort the paused eager fanout")
@@ -597,10 +597,8 @@ mod tests {
.iter()
.map(|slot| slot.file_info().expect("every disk must have arrived").clone())
.collect::<Vec<_>>();
let (_, selected, selection_quorum) =
SetDisks::select_valid_fileinfo(&vec![None; 4], &completed_metadata, &vec![None; 4], "", 2, 3)
.expect("three matching B disks must select the current identity");
assert_eq!(selection_quorum, 3);
let (_, selected) = SetDisks::select_valid_fileinfo(&vec![None; 4], &completed_metadata, &vec![None; 4], 2)
.expect("three matching B disks must select the current identity");
assert_eq!(selected.version_id, expected_current.version_id);
assert_eq!(selected.mod_time, expected_current.mod_time);
assert_eq!(selected.data_dir, expected_current.data_dir);
+108 -24
View File
@@ -665,14 +665,20 @@ impl SetDisks {
read_quorum
}
pub(super) fn select_valid_fileinfo(
/// The commit-proof rule kept for internal metadata in the system bucket.
/// Pool metadata's PREPARE/COMMIT recovery and identity epochs treat an
/// ordinary read as proof that a record committed: when enough copies
/// answer to prove write quorum, the latest version must hold it, and a
/// degraded read never serves a newer version below write quorum. A split
/// write therefore stays unreadable until native recovery resolves it.
fn select_committed_fileinfo(
disks: &[Option<DiskStore>],
parts_metadata: &[FileInfo],
errs: &[Option<DiskError>],
version_id: &str,
read_quorum: usize,
write_quorum: usize,
) -> disk::error::Result<(Vec<Option<DiskStore>>, FileInfo, usize)> {
) -> disk::error::Result<(Vec<Option<DiskStore>>, FileInfo)> {
let selection_quorum =
Self::latest_fileinfo_selection_quorum(version_id, parts_metadata, errs, read_quorum, write_quorum);
let (usable_metadata, has_read_error) = Self::usable_fileinfo_count(parts_metadata, errs);
@@ -683,14 +689,71 @@ impl SetDisks {
&& usable_metadata >= write_quorum
&& selection_quorum == read_quorum
{
let (online_disks, fi) = Self::pick_degraded_latest_fileinfo(disks, parts_metadata, errs, read_quorum, write_quorum)?;
return Ok((online_disks, fi, read_quorum));
return Self::pick_degraded_latest_fileinfo(disks, parts_metadata, errs, read_quorum, write_quorum);
}
let (online_disks, mod_time, etag) = Self::list_online_disks(disks, parts_metadata, errs, selection_quorum);
let fi = Self::pick_valid_fileinfo(parts_metadata, mod_time, etag, selection_quorum)?;
Ok((online_disks, fi, selection_quorum))
Ok((online_disks, fi))
}
/// Select the version a read of `bucket` serves: S3 buckets use MinIO's
/// read-quorum rule (`select_valid_fileinfo`); the system bucket keeps the
/// commit-proof rule (`select_committed_fileinfo`).
#[allow(clippy::too_many_arguments)]
pub(super) fn select_fileinfo_for_read(
bucket: &str,
disks: &[Option<DiskStore>],
parts_metadata: &[FileInfo],
errs: &[Option<DiskError>],
version_id: &str,
read_quorum: usize,
write_quorum: usize,
) -> disk::error::Result<(Vec<Option<DiskStore>>, FileInfo)> {
if crate::bucket::utils::is_meta_bucketname(bucket) {
Self::select_committed_fileinfo(disks, parts_metadata, errs, version_id, read_quorum, write_quorum)
} else {
Self::select_valid_fileinfo(disks, parts_metadata, errs, read_quorum)
}
}
/// Select the version an S3 read serves with MinIO's rule (`getObjectFileInfo`
/// -> `listOnlineDisks` -> `pickValidFileInfo`): the modification time
/// shared by the most copies wins when it reaches `read_quorum`, a tie
/// goes to the newer time, and one content identity at that time must
/// itself reach `read_quorum`. Heal picks the latest version the same
/// way, so a read returns the version heal converges the set to; a write
/// that failed after reaching exactly `read_quorum` copies is readable
/// instead of failing until heal runs, as listing already shows it.
///
/// `read_quorum` follows the parity most copies agree on, which delete
/// markers and empty versions can lower below the selected version's own
/// data blocks; like MinIO's `getLatestFileInfo`, a version with local
/// shards also needs that many copies, or HEAD would report a version
/// GET cannot decode.
pub(super) fn select_valid_fileinfo(
disks: &[Option<DiskStore>],
parts_metadata: &[FileInfo],
errs: &[Option<DiskError>],
read_quorum: usize,
) -> disk::error::Result<(Vec<Option<DiskStore>>, FileInfo)> {
let (online_disks, mod_time, etag) = Self::list_online_disks(disks, parts_metadata, errs, read_quorum);
let (fi, copies) = Self::find_file_info_in_quorum_counted(parts_metadata, &mod_time, &etag, read_quorum)?;
if !fi.deleted && !fi.is_remote() && fi.size > 0 && copies < fi.erasure.data_blocks {
// The shortcut counts byte-identical copies only; count every copy
// of the content identity before refusing the version.
let identity = Self::file_info_quorum_hash(&fi);
let copies = parts_metadata
.iter()
.filter(|meta| file_info_is_valid_for_metadata(meta) && Self::file_info_quorum_hash(meta) == identity)
.count();
if copies < fi.erasure.data_blocks {
return Err(DiskError::ErasureReadQuorum);
}
}
Ok((online_disks, fi))
}
pub(super) fn pick_valid_fileinfo(
@@ -1025,6 +1088,23 @@ impl SetDisks {
etag: &Option<String>,
quorum: usize,
) -> disk::error::Result<FileInfo> {
Self::find_file_info_in_quorum_counted(metas, mod_time, etag, quorum).map(|(fi, _)| fi)
}
/// `find_file_info_in_quorum`, also returning how many copies carry the
/// selected content identity.
///
/// Two identities can share the top count only in a data = parity set
/// split exactly in half at one modification time, such as a metadata
/// update that failed after reaching half the drives. The identity held
/// by the lowest-index disk wins, so every read and heal picks the same
/// side and a retried metadata write can repair the split.
fn find_file_info_in_quorum_counted(
metas: &[FileInfo],
mod_time: &Option<OffsetDateTime>,
etag: &Option<String>,
quorum: usize,
) -> disk::error::Result<(FileInfo, usize)> {
if quorum < 1 {
warn!("find_file_info_in_quorum: quorum < 1");
return Err(DiskError::ErasureReadQuorum);
@@ -1053,7 +1133,7 @@ impl SetDisks {
if same_count >= quorum && same_count > total_valid - same_count {
let mut fi = first.clone();
fi.is_latest = fi.successor_mod_time.is_none();
return Ok(fi);
return Ok((fi, same_count));
}
}
@@ -1106,15 +1186,12 @@ impl SetDisks {
*count_map.entry(hash).or_insert(0) += 1;
}
let mut max_val = None;
let mut max_count = 0;
for (&val, &count) in &count_map {
if count > max_count {
max_val = Some(val);
max_count = count;
}
}
let max_count = count_map.values().copied().max().unwrap_or(0);
let max_val = meta_hashes
.iter()
.flatten()
.find(|hash| count_map.get(*hash) == Some(&max_count))
.copied();
if max_count < quorum {
warn!(
@@ -1155,17 +1232,24 @@ impl SetDisks {
if found {
let mut fi = found_fi.expect("operation should succeed");
for (val, &count) in &valid_obj_map {
if count >= quorum {
fi.successor_mod_time = val.successor_mod_time;
fi.num_versions = val.num_versions;
fi.is_latest = val.successor_mod_time.is_none();
break;
}
// Scan in disk order so that two version-stack shapes with equal
// counts resolve the same way on every read.
let quorum_props = metas
.iter()
.zip(meta_hashes.iter())
.filter(|(meta, hash)| hash.is_some() && **hash == max_val && file_info_is_valid_for_metadata(meta))
.map(|(meta, _)| ObjProps {
successor_mod_time: meta.successor_mod_time,
num_versions: meta.num_versions,
})
.find(|props| valid_obj_map.get(props).is_some_and(|&count| count >= quorum));
if let Some(props) = quorum_props {
fi.successor_mod_time = props.successor_mod_time;
fi.num_versions = props.num_versions;
fi.is_latest = props.successor_mod_time.is_none();
}
return Ok(fi);
return Ok((fi, max_count));
}
warn!("find_file_info_in_quorum: fileinfo not found");
+279 -110
View File
@@ -129,7 +129,7 @@ use rustfs_filemeta::{
};
use rustfs_heal_contracts::heal_channel::{
DriveState, HealAdmissionResult, HealChannelPriority, HealItemType, HealOpts, HealRequestSource, HealScanMode,
send_heal_replacement_disk, send_heal_request_with_admission,
send_heal_request_with_admission,
};
use rustfs_io_metrics::{
record_object_lock_diag_acquire_duration, record_object_lock_diag_enabled, record_object_lock_diag_hold_duration,
@@ -1790,7 +1790,7 @@ mod prepared_get_object_metadata_tests {
assert!(original.is_complete());
let (original_metadata, original_errors, _) = original.into_legacy();
assert!(original_errors.iter().all(Option::is_none));
let (_, generation_a, _) = SetDisks::select_valid_fileinfo(&disks, &original_metadata, &original_errors, "", 2, 3)
let (_, generation_a) = SetDisks::select_valid_fileinfo(&disks, &original_metadata, &original_errors, 2)
.expect("generation A must have real metadata quorum");
assert!(!generation_a.inline_data(), "fixture must use non-inline shard files");
let order = bounded_metadata_fanout_order(bucket, &object, 4, 2);
@@ -1851,8 +1851,8 @@ mod prepared_get_object_metadata_tests {
assert!(replacement.is_complete());
let (replacement_metadata, replacement_errors, _) = replacement.into_legacy();
assert!(replacement_errors.iter().all(Option::is_none));
let (online, generation_b, _) =
SetDisks::select_valid_fileinfo(&disks, &replacement_metadata, &replacement_errors, "", 2, 3)
let (online, generation_b) =
SetDisks::select_valid_fileinfo(&disks, &replacement_metadata, &replacement_errors, 2)
.expect("replacement must have healthy real metadata quorum");
assert_eq!(online.iter().flatten().count(), 4);
assert_ne!(generation_a.data_dir, generation_b.data_dir);
@@ -6829,7 +6829,7 @@ impl SetDisks {
let (metadata, errors) = Self::read_all_fileinfo(&disks, "", bucket, object, version_id, false, false, false).await?;
// Match delete_object_version_with_purge, independently of data parity or online inventory size.
let quorum = self.set_drive_count / 2 + 1;
let (_, marker, _) = Self::select_valid_fileinfo(&disks, &metadata, &errors, version_id, quorum, quorum)
let (_, marker) = Self::select_valid_fileinfo(&disks, &metadata, &errors, quorum)
.map_err(|error| to_object_err(error.into(), vec![bucket, object, version_id]))?;
if !marker.deleted || marker.tier_free_version() || marker.version_id.unwrap_or_else(Uuid::nil) != requested_version {
return Err(Error::DataMovementOverwriteErr(
@@ -10475,13 +10475,6 @@ mod tests {
assert!(!purged, "an incomplete disk scan must not claim the tree is an orphan");
assert!(prefix_dir.join("nested/leaf").exists(), "online disk contents must remain untouched");
let bucket_purged = set.purge_orphan_dir_objects_in_bucket("bucket").await;
assert!(!bucket_purged, "bucket-wide cleanup must fail closed with an unavailable disk slot");
assert!(
prefix_dir.join("nested/leaf").exists(),
"bucket-wide cleanup must preserve the online tree"
);
}
// Cross-disk safety: if any drive still holds object data under the prefix, refuse
@@ -11976,41 +11969,121 @@ mod tests {
}
#[test]
fn test_latest_fileinfo_selection_quorum_requires_write_quorum_when_full_metadata_is_available() {
fn test_latest_fileinfo_selection_breaks_same_time_tie_in_disk_order() {
let mod_time = OffsetDateTime::now_utc();
let data_dir = Uuid::new_v4();
let metas = vec![
quorum_test_fileinfo(mod_time, data_dir, "part-etag-a", 1),
quorum_test_fileinfo(mod_time, data_dir, "part-etag-a", 2),
quorum_test_fileinfo(mod_time, data_dir, "part-etag-b", 3),
quorum_test_fileinfo(mod_time, data_dir, "part-etag-b", 4),
];
let tagged = |index, tag: &str| {
let mut fi = quorum_test_fileinfo(mod_time, data_dir, "part-etag", index);
fi.metadata.insert("x-amz-tagging".to_string(), tag.to_string());
fi
};
let errs = vec![None, None, None, None];
let quorum = SetDisks::latest_fileinfo_selection_quorum("", &metas, &errs, 2, 3);
// A tagging write that failed after reaching half the drives.
for (metas, expected) in [
(vec![tagged(1, "a=1"), tagged(2, "a=1"), tagged(3, "a=2"), tagged(4, "a=2")], "a=1"),
(vec![tagged(1, "a=2"), tagged(2, "a=1"), tagged(3, "a=1"), tagged(4, "a=2")], "a=2"),
] {
// Each call hashes into fresh maps; a map-order choice would flip.
for _ in 0..32 {
let (_, selected) = SetDisks::select_valid_fileinfo(&vec![None; metas.len()], &metas, &errs, 2)
.expect("a same-time half split serves the side on the lowest-index disk");
let healed =
SetDisks::find_file_info_in_quorum(&metas, &Some(mod_time), &None, 2).expect("heal resolves the same split");
assert_eq!(quorum, 3);
assert_eq!(selected.metadata.get("x-amz-tagging").map(String::as_str), Some(expected));
assert_eq!(healed.metadata.get("x-amz-tagging").map(String::as_str), Some(expected));
}
}
}
#[test]
fn test_latest_fileinfo_selection_quorum_preserves_read_quorum_for_version_or_degraded_reads() {
let mod_time = OffsetDateTime::now_utc();
let data_dir = Uuid::new_v4();
let metas = vec![
quorum_test_fileinfo(mod_time, data_dir, "part-etag-a", 1),
quorum_test_fileinfo(mod_time, data_dir, "part-etag-a", 2),
FileInfo::default(),
FileInfo::default(),
];
let degraded_errs = vec![None, None, Some(DiskError::DiskNotFound), Some(DiskError::DiskNotFound)];
let clean_errs = vec![None, None, None, None];
fn test_latest_fileinfo_selection_requires_the_versions_own_data_blocks() {
let old_mod_time = OffsetDateTime::now_utc();
let new_mod_time = old_mod_time + time::Duration::seconds(1);
let old = |index| quorum_test_fileinfo(old_mod_time, Uuid::from_u128(1), "part-etag-old", index);
let new = |index| {
let mut fi = quorum_test_fileinfo(new_mod_time, Uuid::from_u128(2), "part-etag-new", index);
fi.erasure.data_blocks = 3;
fi.erasure.parity_blocks = 1;
fi
};
let errs = vec![None, None, None, None];
assert_eq!(SetDisks::latest_fileinfo_selection_quorum("", &metas, &degraded_errs, 2, 3), 2);
assert_eq!(SetDisks::latest_fileinfo_selection_quorum("version-id", &metas, &clean_errs, 2, 3), 2);
// Read quorum two comes from the 2+2 copies; the newer 3+1 version
// wins the time tie but cannot be decoded from two shards.
let metas = vec![old(1), old(2), new(3), new(4)];
let result = SetDisks::select_valid_fileinfo(&vec![None; metas.len()], &metas, &errs, 2);
assert!(matches!(result, Err(DiskError::ErasureReadQuorum)));
let metas = vec![old(1), new(2), new(3), new(4)];
let (_, selected) = SetDisks::select_valid_fileinfo(&vec![None; metas.len()], &metas, &errs, 2)
.expect("three copies decode a 3+1 version");
assert_eq!(selected.mod_time, Some(new_mod_time));
// Replication-status drift does not change the content identity, so
// the drifted copy still counts toward the version's data blocks.
let replication_status_key = format!(
"{}{}",
rustfs_utils::http::RUSTFS_INTERNAL_PREFIX,
rustfs_utils::http::SUFFIX_REPLICATION_STATUS
);
let mut drifted = new(4);
drifted
.metadata
.insert(replication_status_key, "target-a=PENDING;".to_string());
let metas = vec![old(1), new(2), new(3), drifted];
let (_, selected) = SetDisks::select_valid_fileinfo(&vec![None; metas.len()], &metas, &errs, 2)
.expect("a replication-status drift still leaves three copies of the version");
assert_eq!(selected.mod_time, Some(new_mod_time));
}
#[tokio::test]
async fn update_object_meta_below_write_quorum_writes_nothing() {
use crate::storage_api_contracts::bucket::{BucketOperations as _, MakeBucketOptions};
use crate::storage_api_contracts::object::ObjectIO as _;
let (_dirs, set) = crate::ecstore_validation_blackbox::make_local_set_disks(4, 2).await;
let (bucket, object) = ("metadata-write-quorum", "object");
set.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("bucket should be created");
set.put_object(
bucket,
object,
&mut crate::object_api::PutObjReader::from_vec(b"body".to_vec()),
&ObjectOptions::default(),
)
.await
.expect("object should commit on every drive");
let disks = set.get_disks_internal().await;
let (metadata, _) = SetDisks::read_all_fileinfo(&disks, "", bucket, object, "", false, false, false)
.await
.expect("committed metadata should be readable");
let mut update = metadata[0].clone();
update.metadata.insert("x-amz-meta-probe".to_string(), "written".to_string());
// A read that selected one side of a 2+2 split returns two disks.
let mut targets = disks.clone();
targets[2] = None;
targets[3] = None;
let err = set
.update_object_meta(bucket, object, update, &targets)
.await
.expect_err("two of four disks cannot reach write quorum");
assert_eq!(err, DiskError::ErasureWriteQuorum);
let (after, _) = SetDisks::read_all_fileinfo(&disks, "", bucket, object, "", false, false, false)
.await
.expect("metadata should stay readable");
assert!(
after.iter().all(|fi| !fi.metadata.contains_key("x-amz-meta-probe")),
"a refused update must not reach any disk"
);
}
#[test]
fn test_latest_fileinfo_selection_quorum_keeps_read_quorum_for_partial_overwrite_with_read_error() {
fn test_latest_fileinfo_selection_keeps_older_majority_over_partial_overwrite() {
let old_mod_time = OffsetDateTime::now_utc();
let new_mod_time = old_mod_time + time::Duration::seconds(1);
let old_data_dir = Uuid::new_v4();
@@ -12023,26 +12096,15 @@ mod tests {
];
let errs = vec![None, None, None, Some(DiskError::DiskNotFound)];
let quorum = SetDisks::latest_fileinfo_selection_quorum("", &metas, &errs, 2, 3);
let (online_disks, mod_time, etag) = SetDisks::list_online_disks(&vec![None; metas.len()], &metas, &errs, quorum);
let fi = SetDisks::pick_valid_fileinfo(&metas, mod_time, etag, quorum)
.expect("old metadata should remain readable with read quorum");
assert_eq!(quorum, 2);
assert_eq!(online_disks.len(), metas.len());
assert_eq!(fi.data_dir, Some(old_data_dir));
assert_eq!(fi.parts[0].etag, "part-etag-old");
let (_, selected, selected_quorum) = SetDisks::select_valid_fileinfo(&vec![None; metas.len()], &metas, &errs, "", 2, 3)
let (_, selected) = SetDisks::select_valid_fileinfo(&vec![None; metas.len()], &metas, &errs, 2)
.expect("old metadata should remain selectable with read quorum");
assert_eq!(selected_quorum, 2);
assert_eq!(selected.data_dir, Some(old_data_dir));
assert_eq!(selected.parts[0].etag, "part-etag-old");
assert!(selected.is_latest);
}
#[test]
fn test_latest_fileinfo_selection_rejects_partial_latest_read_quorum_with_read_error() {
fn test_latest_fileinfo_selection_serves_newer_read_quorum_with_read_error() {
let old_mod_time = OffsetDateTime::now_utc();
let new_mod_time = old_mod_time + time::Duration::seconds(1);
let old_data_dir = Uuid::new_v4();
@@ -12055,9 +12117,120 @@ mod tests {
];
let errs = vec![None, None, None, Some(DiskError::DiskNotFound)];
let result = SetDisks::select_valid_fileinfo(&vec![None; metas.len()], &metas, &errs, "", 2, 3);
// The new version may hold write quorum through the missing disk; the
// old one cannot, and heal converges the set to the new version.
let (_, selected) = SetDisks::select_valid_fileinfo(&vec![None; metas.len()], &metas, &errs, 2)
.expect("the newer version reaches read quorum");
assert!(matches!(result, Err(DiskError::ErasureReadQuorum)));
assert_eq!(selected.data_dir, Some(new_data_dir));
assert_eq!(selected.mod_time, Some(new_mod_time));
}
#[test]
fn test_latest_fileinfo_selection_serves_newer_side_of_complete_split() {
let old_mod_time = OffsetDateTime::now_utc();
let new_mod_time = old_mod_time + time::Duration::seconds(1);
let old_data_dir = Uuid::new_v4();
let new_data_dir = Uuid::new_v4();
let old = |index| quorum_test_fileinfo(old_mod_time, old_data_dir, "part-etag-old", index);
let new = |index| quorum_test_fileinfo(new_mod_time, new_data_dir, "part-etag-new", index);
let errs = vec![None, None, None, None];
for metas in [vec![old(1), old(2), new(3), new(4)], vec![new(1), old(2), new(3), old(4)]] {
let (_, selected) = SetDisks::select_valid_fileinfo(&vec![None; metas.len()], &metas, &errs, 2)
.expect("a 2+2 split with every disk answering serves the newer version");
assert_eq!(selected.data_dir, Some(new_data_dir));
assert_eq!(selected.parts[0].etag, "part-etag-new");
assert!(selected.is_latest);
}
}
#[test]
fn test_system_bucket_reads_keep_the_commit_proof_rule() {
let old_mod_time = OffsetDateTime::now_utc();
let new_mod_time = old_mod_time + time::Duration::seconds(1);
let old_data_dir = Uuid::new_v4();
let new_data_dir = Uuid::new_v4();
let old = |index| quorum_test_fileinfo(old_mod_time, old_data_dir, "part-etag-old", index);
let new = |index| quorum_test_fileinfo(new_mod_time, new_data_dir, "part-etag-new", index);
// A PREPARE/COMMIT split of pool metadata must stay unreadable so that
// native recovery, not an ordinary read, decides whether it committed.
for (metas, errs) in [
(vec![old(1), old(2), new(3), new(4)], vec![None, None, None, None]),
(
vec![new(1), new(2), old(3), FileInfo::default()],
vec![None, None, None, Some(DiskError::DiskNotFound)],
),
] {
let disks = vec![None; metas.len()];
let (_, served) = SetDisks::select_fileinfo_for_read("bucket", &disks, &metas, &errs, "", 2, 3)
.expect("an S3 read serves the newer side at read quorum");
assert_eq!(served.data_dir, Some(new_data_dir));
let internal = SetDisks::select_fileinfo_for_read(crate::disk::RUSTFS_META_BUCKET, &disks, &metas, &errs, "", 2, 3);
assert!(matches!(internal, Err(DiskError::ErasureReadQuorum)));
}
let committed = vec![new(1), new(2), new(3), old(4)];
let (_, internal) = SetDisks::select_fileinfo_for_read(
crate::disk::RUSTFS_META_BUCKET,
&vec![None; committed.len()],
&committed,
&[None, None, None, None],
"",
2,
3,
)
.expect("a write-quorum version stays readable in the system bucket");
assert_eq!(internal.data_dir, Some(new_data_dir));
}
#[test]
fn test_latest_fileinfo_selection_picks_version_stack_shape_in_disk_order() {
let mod_time = OffsetDateTime::now_utc();
let data_dir = Uuid::new_v4();
let successor = mod_time + time::Duration::seconds(1);
let shape = |index, successor_mod_time: Option<OffsetDateTime>, num_versions| {
let mut fi = quorum_test_fileinfo(mod_time, data_dir, "part-etag", index);
fi.successor_mod_time = successor_mod_time;
fi.is_latest = successor_mod_time.is_none();
fi.num_versions = num_versions;
fi
};
let errs = vec![None, None, None, None];
for (metas, expected) in [
(
vec![
shape(1, Some(successor), 2),
shape(2, None, 1),
shape(3, Some(successor), 2),
shape(4, None, 1),
],
Some(successor),
),
(
vec![
shape(1, None, 1),
shape(2, Some(successor), 2),
shape(3, None, 1),
shape(4, Some(successor), 2),
],
None,
),
] {
// Each call hashes into fresh maps; a map-order choice would flip.
for _ in 0..32 {
let (_, selected) = SetDisks::select_valid_fileinfo(&vec![None; metas.len()], &metas, &errs, 2)
.expect("one content identity reaches read quorum");
assert_eq!(selected.successor_mod_time, expected);
assert_eq!(selected.is_latest, expected.is_none());
assert_eq!(selected.num_versions, if expected.is_none() { 1 } else { 2 });
}
}
}
#[test]
@@ -12075,11 +12248,9 @@ mod tests {
let metas = vec![first, second, FileInfo::default(), FileInfo::default()];
let errs = vec![None, None, Some(DiskError::DiskNotFound), Some(DiskError::DiskNotFound)];
let (_, mut selected, selected_quorum) =
SetDisks::select_valid_fileinfo(&vec![None; metas.len()], &metas, &errs, "", 2, 3)
.expect("read quorum should remain enough when no competing latest is visible");
let (_, mut selected) = SetDisks::select_valid_fileinfo(&vec![None; metas.len()], &metas, &errs, 2)
.expect("read quorum should remain enough when no competing latest is visible");
assert_eq!(selected_quorum, 2);
assert_eq!(selected.data_dir, Some(data_dir));
assert_eq!(selected.parts[0].etag, "part-etag-old");
assert!(selected.parts[0].checksums.is_none());
@@ -12106,7 +12277,7 @@ mod tests {
let metas = vec![first, second, FileInfo::default(), FileInfo::default()];
let errs = vec![None, None, Some(DiskError::DiskNotFound), Some(DiskError::DiskNotFound)];
let (_, mut selected, _) = SetDisks::select_valid_fileinfo(&vec![None; metas.len()], &metas, &errs, "", 2, 3)
let (_, mut selected) = SetDisks::select_valid_fileinfo(&vec![None; metas.len()], &metas, &errs, 2)
.expect("winner selection should defer sidecar decoding");
let err = SetDisks::hydrate_selected_fileinfo_part_checksums(&mut selected)
.expect_err("a malformed degraded winner must fail closed when checksums are requested");
@@ -12173,10 +12344,9 @@ mod tests {
let metas = vec![latest_meta, stale_stack_meta, newer_stack_meta, FileInfo::default()];
let errs = vec![None, None, None, Some(DiskError::DiskNotFound)];
let (_, selected, selected_quorum) = SetDisks::select_valid_fileinfo(&vec![None; metas.len()], &metas, &errs, "", 2, 3)
let (_, selected) = SetDisks::select_valid_fileinfo(&vec![None; metas.len()], &metas, &errs, 2)
.expect("same object version should stay readable despite derived version stack drift");
assert_eq!(selected_quorum, 3);
assert_eq!(selected.data_dir, Some(data_dir));
assert_eq!(selected.parts[0].etag, "part-etag");
assert_eq!(selected.mod_time, Some(mod_time));
@@ -12269,81 +12439,77 @@ mod tests {
let metas = vec![meta_a, meta_b, meta_c, FileInfo::default()];
let errs = vec![None, None, None, Some(DiskError::DiskNotFound)];
let (_, selected, selected_quorum) = SetDisks::select_valid_fileinfo(&vec![None; metas.len()], &metas, &errs, "", 2, 3)
let (_, selected) = SetDisks::select_valid_fileinfo(&vec![None; metas.len()], &metas, &errs, 2)
.expect("replication status drift should not split readable object identity");
assert_eq!(selected_quorum, 3);
assert_eq!(selected.data_dir, Some(data_dir));
assert_eq!(selected.parts[0].etag, "part-etag");
}
#[test]
fn test_latest_fileinfo_selection_rejects_same_modtime_metadata_split_without_write_quorum() {
fn test_latest_fileinfo_selection_serves_same_time_metadata_majority() {
let mod_time = OffsetDateTime::now_utc();
let data_dir = Uuid::new_v4();
let mut old_meta_a = quorum_test_fileinfo(mod_time, data_dir, "part-etag", 1);
let mut old_meta_b = quorum_test_fileinfo(mod_time, data_dir, "part-etag", 2);
let mut partial_meta = quorum_test_fileinfo(mod_time, data_dir, "part-etag", 3);
old_meta_a.metadata.insert("x-amz-meta-color".to_string(), "blue".to_string());
old_meta_b.metadata.insert("x-amz-meta-color".to_string(), "blue".to_string());
partial_meta
.metadata
.insert("x-amz-meta-color".to_string(), "red".to_string());
let metas = vec![old_meta_a, old_meta_b, partial_meta, FileInfo::default()];
let colored = |index, color: &str| {
let mut fi = quorum_test_fileinfo(mod_time, data_dir, "part-etag", index);
fi.metadata.insert("x-amz-meta-color".to_string(), color.to_string());
fi
};
let errs = vec![None, None, None, Some(DiskError::DiskNotFound)];
let quorum = SetDisks::latest_fileinfo_selection_quorum("", &metas, &errs, 2, 3);
let result = SetDisks::select_valid_fileinfo(&vec![None; metas.len()], &metas, &errs, "", 2, 3);
// With one disk missing, only the two-copy side can hold write quorum,
// whichever disk order the copies arrive in.
for (metas, expected) in [
(
vec![colored(1, "blue"), colored(2, "blue"), colored(3, "red"), FileInfo::default()],
"blue",
),
(vec![colored(1, "blue"), colored(2, "red"), colored(3, "red"), FileInfo::default()], "red"),
] {
let (_, selected) = SetDisks::select_valid_fileinfo(&vec![None; metas.len()], &metas, &errs, 2)
.expect("the read-quorum side of a same-time metadata split is served");
assert_eq!(quorum, 2);
assert!(matches!(result, Err(DiskError::ErasureReadQuorum)));
assert_eq!(selected.metadata.get("x-amz-meta-color").map(String::as_str), Some(expected));
}
}
#[test]
fn test_latest_fileinfo_selection_rejects_same_modtime_partial_metadata_read_quorum() {
fn test_latest_fileinfo_selection_serves_same_time_transition_majority() {
let mod_time = OffsetDateTime::now_utc();
let data_dir = Uuid::new_v4();
let mut old_meta = quorum_test_fileinfo(mod_time, data_dir, "part-etag", 1);
let mut partial_meta_a = quorum_test_fileinfo(mod_time, data_dir, "part-etag", 2);
let mut partial_meta_b = quorum_test_fileinfo(mod_time, data_dir, "part-etag", 3);
old_meta.metadata.insert("x-amz-meta-color".to_string(), "blue".to_string());
partial_meta_a
.metadata
.insert("x-amz-meta-color".to_string(), "red".to_string());
partial_meta_b
.metadata
.insert("x-amz-meta-color".to_string(), "red".to_string());
let metas = vec![old_meta, partial_meta_a, partial_meta_b, FileInfo::default()];
let local = |index| quorum_test_fileinfo(mod_time, data_dir, "part-etag", index);
let remote = |index| {
let mut fi = quorum_test_fileinfo(mod_time, data_dir, "part-etag", index);
fi.transition_status = TRANSITION_COMPLETE.to_string();
fi.transition_tier = "WARM".to_string();
fi.transitioned_objname = "remote/object".to_string();
fi.transition_version_id = Some(Uuid::from_u128(7));
fi
};
let errs = vec![None, None, None, Some(DiskError::DiskNotFound)];
let result = SetDisks::select_valid_fileinfo(&vec![None; metas.len()], &metas, &errs, "", 2, 3);
for (metas, expect_remote) in [
(vec![local(1), local(2), remote(3), FileInfo::default()], false),
(vec![local(1), remote(2), remote(3), FileInfo::default()], true),
] {
let (_, selected) = SetDisks::select_valid_fileinfo(&vec![None; metas.len()], &metas, &errs, 2)
.expect("the read-quorum side of a same-time transition split is served");
assert!(matches!(result, Err(DiskError::ErasureReadQuorum)));
assert_eq!(selected.is_remote(), expect_remote);
}
for (tied, expect_remote) in [
(vec![local(1), local(2), remote(3), remote(4)], false),
(vec![remote(1), local(2), local(3), remote(4)], true),
] {
let (_, selected) = SetDisks::select_valid_fileinfo(&vec![None; tied.len()], &tied, &[None, None, None, None], 2)
.expect("a same-time half split serves the side on the lowest-index disk");
assert_eq!(selected.is_remote(), expect_remote);
}
}
#[test]
fn test_latest_fileinfo_selection_rejects_same_modtime_transition_split_without_write_quorum() {
let mod_time = OffsetDateTime::now_utc();
let data_dir = Uuid::new_v4();
let old_meta_a = quorum_test_fileinfo(mod_time, data_dir, "part-etag", 1);
let old_meta_b = quorum_test_fileinfo(mod_time, data_dir, "part-etag", 2);
let mut partial_meta = quorum_test_fileinfo(mod_time, data_dir, "part-etag", 3);
partial_meta.transition_status = TRANSITION_COMPLETE.to_string();
partial_meta.transition_tier = "WARM".to_string();
partial_meta.transitioned_objname = "remote/object".to_string();
partial_meta.transition_version_id = Some(Uuid::new_v4());
let metas = vec![old_meta_a, old_meta_b, partial_meta, FileInfo::default()];
let errs = vec![None, None, None, Some(DiskError::DiskNotFound)];
let quorum = SetDisks::latest_fileinfo_selection_quorum("", &metas, &errs, 2, 3);
let result = SetDisks::select_valid_fileinfo(&vec![None; metas.len()], &metas, &errs, "", 2, 3);
assert_eq!(quorum, 2);
assert!(matches!(result, Err(DiskError::ErasureReadQuorum)));
}
#[test]
fn test_latest_fileinfo_selection_quorum_uses_write_quorum_for_degraded_committed_identity() {
fn test_latest_fileinfo_selection_serves_degraded_committed_identity() {
let mod_time = OffsetDateTime::now_utc();
let data_dir = Uuid::new_v4();
let metas = vec![
@@ -12354,7 +12520,10 @@ mod tests {
];
let errs = vec![None, None, None, Some(DiskError::DiskNotFound)];
assert_eq!(SetDisks::latest_fileinfo_selection_quorum("", &metas, &errs, 2, 3), 3);
let (_, selected) = SetDisks::select_valid_fileinfo(&vec![None; metas.len()], &metas, &errs, 2)
.expect("a committed identity stays readable with one disk missing");
assert_eq!(selected.parts[0].etag, "part-etag-a");
}
#[test]
+228 -30
View File
@@ -21,19 +21,22 @@
use super::super::{
Arc, DiskError, DiskInfo, DiskInfoOptions, DiskOption, DiskStore, Endpoint, Error, FormatV3, HealChannelPriority, LockResult,
NamespaceLock, NamespaceLockWrapper, ObjectKey, Result, SetDisks, StorageError, debug, disk, info, load_format_erasure,
send_heal_replacement_disk, warn,
NamespaceLock, NamespaceLockWrapper, ObjectKey, Result, SetDisks, StorageError, debug, disk, load_format_erasure, warn,
};
use crate::disk::health_state::DriveMembershipSnapshot;
use crate::disk::{DiskAPI, new_disk};
use crate::layout::disks_layout::MAX_ERASURE_SET_DRIVE_COUNT;
use crate::runtime::sources as runtime_sources;
use futures::{StreamExt, stream};
use futures::{StreamExt, stream, stream::FuturesUnordered};
use rand::prelude::SliceRandom;
use rustfs_heal_contracts::heal_channel::{create_auto_replacement_disks_request, send_heal_request};
use rustfs_utils::path::object_name_disk_identity;
#[cfg(test)]
use uuid::Uuid;
// Bound batching when another reconnect is still pending.
const REPLACEMENT_BATCH_WAIT: std::time::Duration = std::time::Duration::from_secs(1);
#[async_trait::async_trait]
impl crate::storage_api_contracts::namespace::NamespaceLocking for SetDisks {
type Error = Error;
@@ -329,33 +332,82 @@ impl SetDisks {
async fn connect_disks_with_active_monitoring(&self, active_monitoring: bool) {
let disks = self.get_disks_internal().await;
// A slow failed endpoint must not hold back other returning drives.
// Keep the fanout within one set and release the inventory lock before I/O.
stream::iter(disks.into_iter().enumerate())
.for_each_concurrent(MAX_ERASURE_SET_DRIVE_COUNT, |(i, opdisk)| async move {
// Reconnect drives concurrently without holding the inventory lock.
let mut reconnects = stream::iter(disks.into_iter().enumerate())
.map(|(i, opdisk)| async move {
if let Some(disk) = opdisk {
if !active_monitoring
&& disk.get_disk_location().set_idx.is_some()
&& let disk::Disk::Local(local) = disk.as_ref()
&& local.can_skip_background_probe().await
{
return;
return None;
}
if disk.is_online().await && disk.get_disk_location().set_idx.is_some() {
return;
return None;
}
let _ = disk.close().await;
}
if let Some(endpoint) = self.set_endpoints.get(i) {
self.renew_disk(endpoint).await;
}
let endpoint = self.set_endpoints.get(i)?;
self.reconnect_disk(endpoint).await.then(|| endpoint.to_string())
})
.await;
.buffer_unordered(MAX_ERASURE_SET_DRIVE_COUNT);
let mut submissions = FuturesUnordered::new();
let mut batch = Vec::new();
let mut deadline = None;
let mut reconnects_done = false;
loop {
if reconnects_done && submissions.is_empty() {
break;
}
tokio::select! {
result = reconnects.next(), if !reconnects_done => match result {
Some(Some(endpoint)) => {
batch.push(endpoint);
deadline.get_or_insert_with(|| tokio::time::Instant::now() + REPLACEMENT_BATCH_WAIT);
}
Some(None) => {}
None => {
reconnects_done = true;
if !batch.is_empty() {
submissions.push(self.submit_replacement_heal(std::mem::take(&mut batch)));
}
deadline = None;
}
},
_ = tokio::time::sleep_until(deadline.unwrap_or_else(tokio::time::Instant::now)), if deadline.is_some() => {
submissions.push(self.submit_replacement_heal(std::mem::take(&mut batch)));
deadline = None;
}
_ = submissions.next(), if !submissions.is_empty() => {}
}
}
}
async fn submit_replacement_heal(&self, replacements: Vec<String>) {
if replacements.is_empty() {
return;
}
// Queue admission deduplicates by set, so publish ready targets together.
let request = create_auto_replacement_disks_request(
self.pool_index,
self.set_index,
replacements,
Some(HealChannelPriority::Normal),
);
let _ = send_heal_request(request).await;
}
pub async fn renew_disk(&self, ep: &Endpoint) {
if self.reconnect_disk(ep).await {
self.submit_replacement_heal(vec![ep.to_string()]).await;
}
}
/// Return whether this reconnect attached a verified unformatted target.
async fn reconnect_disk(&self, ep: &Endpoint) -> bool {
debug!("renew_disk: start {:?}", ep);
let previous_disk = {
@@ -375,28 +427,17 @@ impl SetDisks {
Err(e) => {
warn!("renew_disk: connect_endpoint err {:?}", &e);
if !matches!(e, DiskError::UnformattedDisk | DiskError::Io(_)) {
return;
return false;
}
let attached = match self.attach_unformatted_replacement_disk(ep).await {
Ok(attached) => attached,
Err(err) => {
warn!(endpoint = %ep, error = ?err, "renew_disk: unformatted replacement probe failed");
return;
return false;
}
};
if !attached {
return;
}
info!("renew_disk attached unformatted replacement and will trigger heal_disk, {:?}", ep);
let (Ok(pool_index), Ok(set_index)) = (usize::try_from(ep.pool_idx), usize::try_from(ep.set_idx)) else {
warn!("renew_disk: replacement target has invalid pool or set index, {:?}", ep);
return;
};
let _ =
send_heal_replacement_disk(pool_index, set_index, ep.to_string(), Some(HealChannelPriority::Normal)).await;
return;
return attached;
}
};
@@ -404,7 +445,7 @@ impl SetDisks {
Ok(res) => res,
Err(e) => {
warn!("renew_disk: find_disk_index err {:?}", e);
return;
return false;
}
};
@@ -422,7 +463,7 @@ impl SetDisks {
expected_set_index = self.set_index,
"renew_disk rejected a drive whose endpoint and format do not identify the same topology slot"
);
return;
return false;
}
let _ = new_disk.set_disk_id(Some(fm.erasure.this)).await;
@@ -442,7 +483,7 @@ impl SetDisks {
new_disk.enable_health_check();
*slot = Some(new_disk);
}
return;
return false;
}
new_disk.enable_health_check();
@@ -464,6 +505,7 @@ impl SetDisks {
let mut disk_lock = self.disks.write().await;
disk_lock[disk_idx] = Some(new_disk);
false
}
/// Attach a replacement target only after proving that the exact local slot
@@ -1102,6 +1144,162 @@ mod tests {
drop(temp_dirs);
}
#[tokio::test]
async fn reconnect_sweep_collects_all_blank_targets_and_excludes_original_disks() {
use rustfs_heal_contracts::heal_channel::{HealAdmissionResult, HealChannelCommand, init_heal_channel};
const CHILD: &str = "RUSTFS_TEST_GROUPED_RECONNECT_CHILD";
if std::env::var_os(CHILD).is_none() {
// The heal sender is process-global; keep this receiver isolated.
let output = std::process::Command::new(std::env::current_exe().expect("test executable"))
.args([
"--exact",
"set_disk::ops::locking::tests::reconnect_sweep_collects_all_blank_targets_and_excludes_original_disks",
"--nocapture",
])
.env(CHILD, "1")
.output()
.expect("isolated reconnect test should run");
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(output.status.success(), "isolated reconnect test failed: {stdout} {stderr}");
assert!(stdout.contains("1 passed"), "child must execute the reconnect test: {stdout}");
return;
}
let mut heal_rx = init_heal_channel().expect("isolated heal receiver");
let disk_count = 4;
let format = FormatV3::new(1, disk_count);
let mut directories = Vec::new();
let mut endpoints = Vec::new();
let mut disks = Vec::new();
for disk_idx in 0..2 {
let (directory, endpoint, disk) = make_formatted_local_disk(disk_idx, &format).await;
directories.push(directory);
endpoints.push(endpoint);
disks.push(Some(disk));
}
for disk_idx in 2..disk_count {
let directory = tempfile::tempdir().expect("replacement directory");
let mut endpoint =
Endpoint::try_from(directory.path().to_str().expect("UTF-8 replacement path")).expect("replacement endpoint");
endpoint.set_pool_index(0);
endpoint.set_set_index(0);
endpoint.set_disk_index(disk_idx);
directories.push(directory);
endpoints.push(endpoint);
disks.push(None);
}
let mut ready_targets = endpoints[2..].iter().map(ToString::to_string).collect::<Vec<_>>();
ready_targets.sort_unstable();
// Disk 1 returns with its format and data; disks 2 and 3 are blank.
disks[1]
.take()
.expect("original disk handle")
.close()
.await
.expect("close original disk");
let retained_data = directories[1].path().join("retained-data");
tokio::fs::write(&retained_data, b"original data")
.await
.expect("write original data");
let set = Arc::new(
SetDisks::new(
"reconnect-sweep".to_string(),
Arc::new(RwLock::new(disks)),
disk_count,
2,
0,
0,
endpoints.clone(),
format,
Vec::new(),
)
.await,
);
// Block disk 0's format check, then make it a late blank replacement.
let slow = set.get_disks_internal().await[0].clone().expect("original disk");
assert!(slow.is_online().await, "initialize original disk identity");
let disk::Disk::Local(wrapper) = slow.as_ref() else {
panic!("fixture must use a local disk");
};
let slow_local = wrapper.get_disk();
let mut slow_format_guard = slow_local.format_info.write().await;
slow_format_guard.last_check = None;
std::fs::remove_file(
directories[0]
.path()
.join(disk::RUSTFS_META_BUCKET)
.join(disk::FORMAT_CONFIG_FILE),
)
.expect("make the blocked disk a blank replacement");
let sweep = tokio::spawn({
let set = set.clone();
async move { set.connect_disks_with_active_monitoring(true).await }
});
tokio::time::timeout(std::time::Duration::from_secs(10), async {
loop {
let attached = set.get_disks_internal().await;
if attached[1..].iter().all(Option::is_some) {
break;
}
tokio::task::yield_now().await;
}
})
.await
.expect("other drives must reconnect while the first drive is blocked");
let command = tokio::time::timeout(std::time::Duration::from_secs(10), heal_rx.recv())
.await
.expect("ready targets must be submitted while a drive is blocked")
.expect("heal receiver should remain open");
let HealChannelCommand::Start { request, response_tx } = command else {
panic!("expected a replacement heal request");
};
assert_eq!(request.heal_endpoints, ready_targets, "ready blank disks must share one request");
assert!(!sweep.is_finished(), "the blocked drive must still be pending");
drop(slow_format_guard);
// Do not acknowledge the first request yet: a delayed admission
// response must not stop the late drive reconnecting and submitting.
let late_command = tokio::time::timeout(std::time::Duration::from_secs(10), heal_rx.recv())
.await
.expect("late target must be submitted while the first response is pending")
.expect("heal receiver should remain open");
let HealChannelCommand::Start {
request: late_request,
response_tx: late_response,
} = late_command
else {
panic!("expected a late replacement heal request");
};
assert_eq!(late_request.heal_endpoints, [endpoints[0].to_string()]);
response_tx
.send(Ok(HealAdmissionResult::Accepted))
.expect("acknowledge ready targets");
late_response
.send(Ok(HealAdmissionResult::Accepted))
.expect("acknowledge late target");
sweep.await.expect("reconnect sweep should finish");
assert!(heal_rx.try_recv().is_err(), "no duplicate request should be sent");
let attached = set.get_disks_internal().await;
for (index, disk) in attached.iter().enumerate() {
let disk = disk.as_ref().expect("each verified endpoint should be attached");
let format = load_format_erasure(disk, false).await;
if index == 1 {
assert!(format.is_ok(), "original disk must retain its format");
} else {
assert_eq!(format.expect_err("replacement must remain unformatted"), DiskError::UnformattedDisk);
assert!(!disk.health_check_enabled_for_test(), "format must precede replacement health checks");
}
disk.close().await.expect("close test disk");
}
assert_eq!(tokio::fs::read(&retained_data).await.expect("read original data"), b"original data");
let local_map = runtime_sources::local_disk_map_handle();
let mut local_map = local_map.write().await;
for endpoint in endpoints {
local_map.remove(&endpoint.to_string());
}
}
#[tokio::test]
async fn renew_disk_attaches_only_a_verified_local_unformatted_replacement() {
let disk_count = 4;
+5 -4
View File
@@ -9320,8 +9320,8 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
};
let src_version_id = src_opts.version_id.as_deref().unwrap_or_default();
let (online_disks, mut fi, _) =
Self::select_valid_fileinfo(&disks, &metas, &errs, src_version_id, read_quorum, write_quorum)
let (online_disks, mut fi) =
Self::select_fileinfo_for_read(src_bucket, &disks, &metas, &errs, src_version_id, read_quorum, write_quorum)
.map_err(|e| to_object_err(e.into(), vec![src_bucket, src_object]))?;
if fi.deleted {
@@ -11182,8 +11182,9 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
.map_err(|_| to_object_err(DiskError::ErasureWriteQuorum.into(), vec![bucket, object]))?;
let version_id = opts.version_id.as_deref().unwrap_or_default();
let (online_disks, mut fi, _) = Self::select_valid_fileinfo(&disks, &metas, &errs, version_id, read_quorum, write_quorum)
.map_err(|e| to_object_err(e.into(), vec![bucket, object]))?;
let (online_disks, mut fi) =
Self::select_fileinfo_for_read(bucket, &disks, &metas, &errs, version_id, read_quorum, write_quorum)
.map_err(|e| to_object_err(e.into(), vec![bucket, object]))?;
if fi.deleted {
return Err(to_object_err(Error::MethodNotAllowed, vec![bucket, object]));
+14 -14
View File
@@ -966,8 +966,9 @@ impl SetDisks {
return Err(to_object_err(err.into(), vec![bucket, object, &vid]));
}
let (op_online_disks, mut fi, fileinfo_selection_quorum) =
match Self::select_valid_fileinfo(&disks, &parts_metadata, &errs, vid.as_str(), read_quorum, write_quorum) {
let (op_online_disks, mut fi) =
match Self::select_fileinfo_for_read(bucket, &disks, &parts_metadata, &errs, vid.as_str(), read_quorum, write_quorum)
{
Ok(selection) => selection,
Err(err) => {
let err = StorageError::from(err);
@@ -987,7 +988,7 @@ impl SetDisks {
rustfs_utils::http::remove_str(&mut metadata.metadata, rustfs_utils::http::SUFFIX_PART_CHECKSUMS);
}
}
metadata_fanout_diagnostics.record_quorum_candidate_latency(metadata_metrics_path, fileinfo_selection_quorum);
metadata_fanout_diagnostics.record_quorum_candidate_latency(metadata_metrics_path, read_quorum);
let needs_late_metadata_fanout = !metadata_fanout_complete
&& allow_early_stop
&& non_inline_data_read_early_stop_allowed(read_data, bucket, object)
@@ -1014,7 +1015,6 @@ impl SetDisks {
&& allow_early_stop
&& metadata_fanout_diagnostics.valid_responses() >= read_quorum
&& errs.iter().all(Option::is_none)
&& fileinfo_selection_quorum >= read_quorum
&& op_online_disks.iter().filter(|disk| disk.is_some()).count() >= read_quorum))
{
#[cfg(test)]
@@ -2465,8 +2465,8 @@ impl SetDisks {
return Err(to_object_err(err.into(), vec![bucket, object]));
}
let (mut online_disks, full_fi, _) =
SetDisks::select_valid_fileinfo(fallback_disks, &parts_metadata, &errs, "", read_quorum, write_quorum)?;
let (mut online_disks, full_fi) =
SetDisks::select_fileinfo_for_read(bucket, fallback_disks, &parts_metadata, &errs, "", read_quorum, write_quorum)?;
if !late_metadata_read_identity_matches(expected, &full_fi) {
return Err(to_object_err(DiskError::ErasureReadQuorum.into(), vec![bucket, object]));
}
@@ -4052,8 +4052,7 @@ mod metadata_cache_tests {
assert!(original.is_complete());
let (baseline, errors, _) = original.into_legacy();
assert!(errors.iter().all(Option::is_none), "original real shards must all be readable");
let (_, selected, _) =
SetDisks::select_valid_fileinfo(&disks, &baseline, &errors, "", 2, 3).expect("original metadata quorum");
let (_, selected) = SetDisks::select_valid_fileinfo(&disks, &baseline, &errors, 2).expect("original metadata quorum");
assert!(!selected.inline_data(), "late fixture must retain real non-inline shard files");
let expected = LateMetadataIdentity::from_file_info(&selected);
let (_, _, original_online) =
@@ -4086,7 +4085,7 @@ mod metadata_cache_tests {
.expect("read changed real disk metadata");
assert!(changed.is_complete(), "late fixture must reach complete metadata observation");
let (metadata, errors, _) = changed.into_legacy();
let (online, selected, _) = SetDisks::select_valid_fileinfo(&disks, &metadata, &errors, "", 2, 3)
let (online, selected) = SetDisks::select_valid_fileinfo(&disks, &metadata, &errors, 2)
.expect("changed metadata still reaches selection quorum");
assert_eq!(online.iter().flatten().count(), 4, "the late identity guard must decide this failure");
if changed_generation {
@@ -5135,7 +5134,7 @@ mod metadata_cache_tests {
// This seam prepares a real, independently validated D1/P1/P2 quorum.
// The hedge test separately exercises its production publication timing.
let errs: Vec<Option<DiskError>> = (0..4).map(|_| None).collect();
let (online_disks, fi, _) = SetDisks::select_valid_fileinfo(&disks, &metadata, &errs, "", 2, 3)
let (online_disks, fi) = SetDisks::select_valid_fileinfo(&disks, &metadata, &errs, 2)
.expect("three real members satisfy latest-generation selection");
assert_eq!(online_disks.iter().flatten().count(), 3);
assert_eq!(fi.metadata.get("etag"), committed.etag.as_ref());
@@ -6240,16 +6239,17 @@ mod tests {
let parts_metadata = vec![first.clone(), second, third, FileInfo::default()];
let errs = vec![None, None, None, None];
let (read_quorum, write_quorum) = SetDisks::object_quorum_from_meta(&parts_metadata, &errs, 2)
let (read_quorum, _) = SetDisks::object_quorum_from_meta(&parts_metadata, &errs, 2)
.expect("partial early-stop metadata should preserve read quorum");
let read_quorum = usize::try_from(read_quorum).expect("read quorum should be non-negative");
let write_quorum = usize::try_from(write_quorum).expect("write quorum should be non-negative");
let selection_quorum = SetDisks::latest_fileinfo_selection_quorum("", &parts_metadata, &errs, read_quorum, write_quorum);
let (_, latest) = SetDisks::select_valid_fileinfo(&vec![None; parts_metadata.len()], &parts_metadata, &errs, read_quorum)
.expect("partial early-stop metadata should select the candidate");
let selected = SetDisks::pick_valid_fileinfo(&parts_metadata, None, Some("etag-1".to_string()), read_quorum)
.expect("partial early-stop metadata should preserve selected FileInfo");
assert_eq!(read_quorum, 2);
assert_eq!(selection_quorum, 3);
assert_eq!(latest.name, first.name);
assert_eq!(latest.get_etag(), first.get_etag());
assert_eq!(selected.name, first.name);
assert_eq!(selected.get_etag(), first.get_etag());
}
+466 -226
View File
@@ -22,9 +22,7 @@ fn to_filemeta_err(err: Error) -> rustfs_filemeta::Error {
}
use crate::bucket::lifecycle::bucket_lifecycle_ops::free_version_remote_tuple_matches;
use crate::bucket::metadata_sys::{
get_versioning_config, has_authoritative_never_versioned_state, has_authoritative_never_versioned_state_in,
};
use crate::bucket::metadata_sys::{get_versioning_config, has_authoritative_never_versioned_state};
use crate::bucket::utils::{check_list_objs_args, has_bad_path_component};
use crate::bucket::versioning::VersioningApi;
use crate::cache_value::metacache_set::{
@@ -373,49 +371,6 @@ async fn can_skip_hidden_prefix_check(options: &ListPathOptions) -> bool {
.unwrap_or(false)
}
/// Whether an empty listing of `prefix` is the proof that lets the caller
/// reclaim delete residue under it. Any first page that scanned the whole
/// prefix without finding an object or a sub-prefix qualifies, with or without
/// a delimiter: that is the shape clients issue when they stat, browse, or
/// recursively remove a phantom folder. The purge itself re-verifies every
/// directory on every disk before deleting anything.
fn should_purge_empty_directory_listing(
prefix: &str,
marker: Option<&str>,
max_keys: i32,
incl_deleted: bool,
result: &ListObjectsInfo,
) -> bool {
// list_path rejects leading slashes, so an empty result cannot justify cleanup.
!prefix.is_empty()
&& !prefix.starts_with(SLASH_SEPARATOR)
&& prefix.ends_with(SLASH_SEPARATOR)
&& marker.is_none()
&& max_keys > 0
&& !incl_deleted
&& !result.is_truncated
&& result.objects.is_empty()
&& result.prefixes.is_empty()
}
fn should_purge_empty_recursive_bucket_listing(
prefix: &str,
delimiter: Option<&str>,
marker: Option<&str>,
max_keys: i32,
incl_deleted: bool,
result: &ListObjectsInfo,
) -> bool {
prefix.is_empty()
&& delimiter.is_none()
&& marker.is_none()
&& max_keys > 0
&& !incl_deleted
&& !result.is_truncated
&& result.objects.is_empty()
&& result.prefixes.is_empty()
}
const MARKER_TAG_VERSION: &str = "v2";
const LEGACY_MARKER_TAG_VERSIONS: &[&str] = &["v1", MARKER_TAG_VERSION];
const LIST_CACHE_MARKER_PREFIX: &str = "[rustfs_cache:";
@@ -552,18 +507,6 @@ impl ListContinuationV2 {
}
}
/// Returns the key part of a marker that may still carry the legacy cursor tag.
/// The data-usage scanner compares successive version-listing markers through
/// it so two differently tagged markers for the same key never count as progress.
// RUSTFS_COMPAT_TODO(listing-cursor-legacy-tag): markers tagged by 1.0.x servers. Remove after every supported
// direct-upgrade release issues plain key markers; callers can then compare markers directly.
pub(crate) fn list_marker_key(marker: &str) -> &str {
marker
.rfind(LIST_CACHE_MARKER_PREFIX)
.filter(|start_idx| marker[*start_idx..].ends_with(']'))
.map_or(marker, |start_idx| &marker[..start_idx])
}
fn normalize_list_quorum(value: &str) -> &'static str {
let value = value.trim();
if value.eq_ignore_ascii_case("disk") {
@@ -631,8 +574,7 @@ fn versioned_prefix_page_boundary_key<'a>(
/// old-readable escape until legacy tag parsing can be removed.
// RUSTFS_COMPAT_TODO(listing-cursor-legacy-tag): Remove after the minimum upgrade source stops issuing tagged cursors.
fn escape_list_marker(marker: String) -> String {
// Both the legacy request parser and scanner cursor comparison recognize
// cache-shaped suffixes. One old-readable outer tag protects the literal key.
// One old-readable outer tag protects a literal cache-shaped key.
if marker
.rfind(LIST_CACHE_MARKER_PREFIX)
.is_some_and(|start| marker[start..].contains(']'))
@@ -669,9 +611,9 @@ fn build_list_versions_next_marker(objects: &[ObjectInfo], prefixes: &[String])
.unwrap_or_else(|| "null".to_string()),
)
};
(Some(escape_list_marker(last.name.clone())), version_marker)
(Some(last.name.clone()), version_marker)
} else if let Some(last_prefix) = prefixes.last() {
(Some(escape_list_marker(last_prefix.clone())), None)
(Some(last_prefix.clone()), None)
} else {
(None, None)
}
@@ -734,7 +676,7 @@ fn list_objects_paginate(
&& let Some(last_scanned) = last_scanned_key
{
is_truncated = true;
next_marker = Some(escape_list_marker(last_scanned.to_owned()));
next_marker = Some(last_scanned.to_owned());
} else if should_truncate {
is_truncated = true;
if include_version_id {
@@ -802,7 +744,7 @@ where
pagination.last_scanned_key,
);
if is_truncated && let Some(raw_key) = prefix_at_page_boundary {
next_marker = Some(escape_list_marker(raw_key));
next_marker = Some(raw_key);
next_version_idmarker = None;
}
@@ -894,10 +836,8 @@ impl ListPathOptions {
/// Releases up to 1.0.x appended `[rustfs_cache:v2,id:...,src:walker,gen:live]`
/// to every internal continuation marker and exposed it through the V2
/// continuation token and the ListObjectVersions `NextKeyMarker`. Current
/// servers emit plain keys, but a client that paginates across the upgrade
/// still sends the tagged form back as `continuation-token`, `key-marker`
/// or `marker`, and the opt-in index provider still carries its `src`/`gen`
/// cursor fields in the same tag.
/// servers still accept the tagged form in V2 continuation tokens and
/// legacy object-list markers. Version-listing key markers are literal keys.
///
/// Newly issued cursors protect cache-shaped literal keys with one legacy
/// outer tag. Parse only at the listing entry point; parsing the resulting
@@ -953,7 +893,9 @@ impl ECStore {
) -> Result<ListObjectsV2Info> {
let marker = {
if continuation_token.is_none() {
start_after
// StartAfter is a literal object key, even if it resembles a
// legacy cursor. Protect it from the compatibility parser.
start_after.map(escape_list_marker)
} else {
continuation_token.clone()
}
@@ -1022,39 +964,15 @@ impl ECStore {
let get_objects =
ObjectInfo::from_meta_cache_entries_sorted_infos(&page.entries, bucket, prefix, delimiter.clone()).await;
let (objects, prefixes, is_truncated, next_marker, next_version_idmarker) =
let (objects, prefixes, is_truncated, next_marker, _) =
list_objects_paginate(get_objects, &delimiter, max_keys, disk_has_more, false, last_scanned_key.as_deref());
let _ = next_version_idmarker;
let result = ListObjectsInfo {
Ok(ListObjectsInfo {
is_truncated,
next_marker,
objects,
prefixes,
};
let purge_exact_prefix =
should_purge_empty_directory_listing(prefix, opts.marker.as_deref(), max_keys, incl_deleted, &result);
let purge_empty_bucket = should_purge_empty_recursive_bucket_listing(
prefix,
delimiter.as_deref(),
opts.marker.as_deref(),
max_keys,
incl_deleted,
&result,
);
if (purge_exact_prefix || purge_empty_bucket)
&& has_authoritative_never_versioned_state_in(&self.ctx, bucket)
.await
.unwrap_or(false)
{
if purge_exact_prefix {
self.purge_orphan_dir_object(bucket, prefix).await;
} else {
self.purge_orphan_dir_objects_in_bucket(bucket).await;
}
}
Ok(result)
})
}
pub async fn inner_list_object_versions(
@@ -1198,7 +1116,7 @@ impl ECStore {
};
let effective_max_keys = list_versions_scan_limit(max_keys, has_version_marker);
let mut opts = ListPathOptions {
let opts = ListPathOptions {
bucket: bucket.to_owned(),
caller: if include_version_purge {
ListObjectsGatherCaller::Lifecycle
@@ -1215,10 +1133,6 @@ impl ECStore {
include_marker: has_version_marker,
..Default::default()
};
// Strip a legacy cursor tag before any name comparison (notably
// `forward_past`), see backlog#1047.
opts.parse_marker();
let mut page = self
.list_path(&opts)
.await
@@ -2416,7 +2330,7 @@ impl SetDisks {
let has_version_marker = version_marker.is_some();
let version_marker = version_marker.map(parse_version_marker).transpose()?;
let effective_max_keys = list_versions_scan_limit(max_keys, has_version_marker);
let mut opts = ListPathOptions {
let opts = ListPathOptions {
bucket: bucket.to_owned(),
prefix: prefix.to_owned(),
limit: effective_max_keys,
@@ -2427,8 +2341,6 @@ impl SetDisks {
include_marker: has_version_marker,
..Default::default()
};
opts.parse_marker();
let mut page = self
.list_path_result(&opts)
.await
@@ -2900,13 +2812,12 @@ mod lifecycle_exact_tests;
#[cfg(test)]
mod test {
use super::{
ENV_API_LIST_OBJECTS_QUORUM, GatherResultsState, ListObjectsInfo, ListPathOptions, ListSourceMode, MAX_OBJECT_LIST,
RUSTFS_META_BUCKET, VersionMarker, gather_results, list_marker_key, list_merged_entry_channel,
list_metadata_resolution_params, list_objects_paginate, list_objects_quorum_from_env, max_keys_plus_one,
merge_entry_channels, normalize_list_quorum, observe_scanner_namespace_mutations, parse_version_marker,
recursive_walk_list_path_options, reset_scanner_namespace_mutation_generation_for_test,
scanner_namespace_mutation_generation, send_or_cancel, should_purge_empty_directory_listing, version_marker_for_entries,
walk_result_from_set_errors,
ENV_API_LIST_OBJECTS_QUORUM, GatherResultsState, ListPathOptions, ListSourceMode, MAX_OBJECT_LIST, RUSTFS_META_BUCKET,
VersionMarker, gather_results, list_merged_entry_channel, list_metadata_resolution_params, list_objects_paginate,
list_objects_quorum_from_env, max_keys_plus_one, merge_entry_channels, normalize_list_quorum,
observe_scanner_namespace_mutations, parse_version_marker, recursive_walk_list_path_options,
reset_scanner_namespace_mutation_generation_for_test, scanner_namespace_mutation_generation, send_or_cancel,
version_marker_for_entries, walk_result_from_set_errors,
};
use crate::bucket::replication::ReplicationState;
use crate::cache_value::metacache_set::ListPathRawOutcome;
@@ -4411,7 +4322,10 @@ mod test {
list_objects_paginate(vec![plain_object(key), plain_object("zz")], &None, 1, false, include_version_id, None);
assert!(truncated);
let marker = marker.expect("truncated page needs a cursor");
assert_eq!(list_marker_key(&marker), key, "scanner must compare the complete literal key");
if include_version_id {
assert_eq!(marker, key, "version markers must be literal keys");
continue;
}
let mut opts = ListPathOptions {
marker: Some(marker),
..Default::default()
@@ -4456,6 +4370,7 @@ mod test {
.expect("seed literal object");
}
let mut marker = None;
let mut v1_marker = None;
let mut key_marker = None;
let mut version_marker = None;
for (index, key) in keys.into_iter().enumerate() {
@@ -4468,6 +4383,16 @@ mod test {
assert_eq!(page.is_truncated, index + 1 < keys.len());
marker = page.next_continuation_token;
// V1 sends the literal NextMarker back as StartAfter.
let v1_page = store
.clone()
.inner_list_objects_v2(bucket, "", None, None, 1, false, v1_marker, false)
.await
.expect("list next object through the V1 marker path");
assert_eq!(v1_page.objects.iter().map(|object| object.name.as_str()).collect::<Vec<_>>(), [key]);
assert_eq!(v1_page.is_truncated, index + 1 < keys.len());
v1_marker = v1_page.is_truncated.then(|| key.to_owned());
let versions = store
.clone()
.inner_list_object_versions(bucket, "", key_marker, version_marker, None, 1)
@@ -4475,11 +4400,44 @@ mod test {
.expect("list next version");
assert_eq!(versions.objects.iter().map(|object| object.name.as_str()).collect::<Vec<_>>(), [key]);
assert_eq!(versions.is_truncated, index + 1 < keys.len());
if versions.is_truncated {
assert_eq!(versions.next_marker.as_deref(), Some(key), "NextKeyMarker must be the literal key");
}
key_marker = versions.next_marker;
version_marker = versions.next_version_idmarker;
}
assert!(marker.is_none());
assert!(v1_marker.is_none());
assert!(key_marker.is_none());
let literal = "b[rustfs_cache:v1,return:]";
let after_literal = store
.clone()
.inner_list_objects_v2(bucket, "", None, None, 1, false, Some(literal.to_owned()), false)
.await
.expect("start after a caller-supplied literal key");
assert_eq!(after_literal.objects[0].name, "c[rustfs_cache:v2,id:x]suffix");
let resumed = store
.clone()
.inner_list_object_versions(bucket, "", Some(literal.to_owned()), None, None, 1)
.await
.expect("resume from a caller-supplied literal key marker");
assert_eq!(resumed.objects[0].name, "c[rustfs_cache:v2,id:x]suffix");
let resumed_version = store
.clone()
.inner_list_object_versions(bucket, "", Some(literal.to_owned()), Some("null".to_owned()), None, 1)
.await
.expect("resume after the literal key's null version");
assert_eq!(resumed_version.objects[0].name, "c[rustfs_cache:v2,id:x]suffix");
let recursive_delete_page = store.pools[0].disk_set[0]
.clone()
.inner_list_object_versions_for_recursive_delete(bucket, "", Some(literal.to_owned()), None, 1)
.await
.expect("resume recursive-delete validation from the literal key");
assert_eq!(recursive_delete_page.objects[0].name, "c[rustfs_cache:v2,id:x]suffix");
}
#[tokio::test]
@@ -4645,6 +4603,32 @@ mod test {
assert!(next_version_idmarker.is_none());
}
#[test]
fn versioned_delimiter_boundaries_preserve_cache_shaped_raw_keys() {
let delimiter = Some("-".to_owned());
let raw_key = "data-0002[rustfs_cache:v2,return:]";
let (_, _, truncated, marker, version_marker) =
list_objects_paginate(vec![folded_prefix_object("data-")], &delimiter, 2, true, true, Some(raw_key));
assert!(truncated);
assert_eq!(marker.as_deref(), Some(raw_key));
assert!(version_marker.is_none());
let (_, _, truncated, marker, version_marker) = super::list_objects_paginate_versions(
vec![folded_prefix_object("data-")],
super::VersionedListPagination {
delimiter: &delimiter,
max_keys: 1,
disk_has_more: true,
marker: None,
last_scanned_key: Some(raw_key),
raw_keys: std::iter::once(raw_key),
},
);
assert!(truncated);
assert_eq!(marker.as_deref(), Some(raw_key));
assert!(version_marker.is_none());
}
// End-to-end simulation of versioned pagination with `max_keys=1`, where the
// next page resumes via a bare key marker (`forward_past` keeps `name > marker`
// only, matching `inner_list_object_versions_with_projection` without a version
@@ -4935,77 +4919,114 @@ mod test {
assert_eq!(scanner_namespace_mutation_generation(), 2);
}
#[test]
fn empty_directory_listing_purge_requires_complete_first_page_of_prefix() {
let empty = ListObjectsInfo::default();
assert!(should_purge_empty_directory_listing("ghost/", None, 1, false, &empty));
assert!(should_purge_empty_directory_listing("ghost/", None, 1000, false, &empty));
assert!(!should_purge_empty_directory_listing("ghost", None, 1, false, &empty));
assert!(!should_purge_empty_directory_listing("ghost/", Some("marker"), 1, false, &empty));
assert!(!should_purge_empty_directory_listing("ghost/", None, 0, false, &empty));
assert!(!should_purge_empty_directory_listing("ghost/", None, 1, true, &empty));
#[tokio::test]
async fn listing_preserves_valid_legacy_residue_until_explicit_delete() {
use crate::bucket::metadata_sys::{init_bucket_metadata_sys, test_support::isolated_store_over_temp_disks};
use crate::storage_api_contracts::bucket::{BucketOperations as _, DeleteBucketOptions, MakeBucketOptions};
let mut live = ListObjectsInfo::default();
live.objects.push(ObjectInfo::default());
assert!(!should_purge_empty_directory_listing("ghost/", None, 1, false, &live));
use crate::storage_api_contracts::object::{ObjectIO as _, ObjectOperations as _};
let mut prefixed = ListObjectsInfo::default();
prefixed.prefixes.push("ghost/child/".to_owned());
assert!(!should_purge_empty_directory_listing("ghost/", None, 1, false, &prefixed));
let (dirs, store) = isolated_store_over_temp_disks().await;
let bucket = "stage-e-exact-delete-legacy";
init_bucket_metadata_sys(store.clone(), Vec::new()).await;
store
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("bucket should be created with authoritative metadata");
let transaction = uuid::Uuid::new_v4();
let mut backups = Vec::new();
for object in ["repro/obj-00001.bin", "repro/obj-00002.bin"] {
let mut reader = crate::object_api::PutObjReader::from_vec(vec![0x62; 1024 * 1024]);
store
.put_object(
bucket,
object,
&mut reader,
&crate::object_api::ObjectOptions {
write_completion: crate::object_api::WriteCompletion::TailDrained,
..Default::default()
},
)
.await
.expect("create genuine per-drive metadata and shards");
for dir in &dirs {
let object_dir = dir.path().join(bucket).join(object);
let mut entries = tokio::fs::read_dir(&object_dir).await.expect("read actual object root");
let mut marked = 0;
while let Some(entry) = entries.next_entry().await.expect("read object entry") {
if uuid::Uuid::parse_str(&entry.file_name().to_string_lossy()).is_ok() {
tokio::fs::write(
entry
.path()
.join(format!("{}{}", crate::disk::local::DELETE_DATA_DIR_MARKER_PREFIX, transaction)),
[],
)
.await
.expect("mark actual data dir as historical delete residue");
marked += 1;
}
}
assert_eq!(marked, 1, "the real shard must have exactly one data directory");
let backup = object_dir.join(transaction.to_string());
tokio::fs::create_dir_all(&backup).await.expect("create legacy rollback root");
tokio::fs::rename(
object_dir.join(crate::disk::STORAGE_FORMAT_FILE),
backup.join(crate::disk::STORAGE_FORMAT_FILE_BACKUP),
)
.await
.expect("retain the actual valid metadata as a historical backup");
let backup = backup.join(crate::disk::STORAGE_FORMAT_FILE_BACKUP);
let bytes = tokio::fs::read(&backup).await.expect("read the historical backup");
backups.push((backup, bytes));
}
}
let truncated = ListObjectsInfo {
is_truncated: true,
..Default::default()
};
assert!(!should_purge_empty_directory_listing("ghost/", None, 1, false, &truncated));
}
let delimited = store
.clone()
.list_objects_generic(bucket, "", None, Some("/".to_owned()), 1000, false)
.await
.expect("delimiter listing should succeed");
assert!(delimited.objects.is_empty());
assert!(delimited.prefixes.is_empty(), "residue must not appear as a common prefix");
#[test]
fn empty_directory_listing_does_not_purge_rejected_leading_slash_prefixes() {
let empty = ListObjectsInfo::default();
for prefix in ["/", "//", "/ghost/"] {
let result = store
.clone()
.list_objects_generic(bucket, "", None, None, 1000, false)
.await
.expect("recursive bucket listing should succeed");
assert!(result.objects.is_empty());
let exact_prefix = store
.clone()
.list_objects_generic(bucket, "repro/", None, None, 1000, false)
.await
.expect("recursive prefix listing should succeed");
assert!(exact_prefix.objects.is_empty());
for (path, bytes) in backups {
assert_eq!(tokio::fs::read(path).await.expect("LIST must preserve backup"), bytes);
}
for dir in &dirs {
assert!(
!should_purge_empty_directory_listing(prefix, None, 1000, false, &empty),
"rejected prefix {prefix:?} must not initiate orphan cleanup"
dir.path().join(bucket).join("repro").exists(),
"LIST must preserve the historical residue"
);
}
assert!(should_purge_empty_directory_listing("ghost/", None, 1000, false, &empty));
}
#[test]
fn recursive_bucket_orphan_purge_requires_an_empty_complete_root_scan() {
let empty = ListObjectsInfo::default();
assert!(super::should_purge_empty_recursive_bucket_listing("", None, None, 1, false, &empty));
assert!(!super::should_purge_empty_recursive_bucket_listing(
"ghost/", None, None, 1, false, &empty
));
assert!(!super::should_purge_empty_recursive_bucket_listing("", Some("/"), None, 1, false, &empty));
assert!(!super::should_purge_empty_recursive_bucket_listing(
"",
None,
Some("marker"),
1,
false,
&empty
));
assert!(!super::should_purge_empty_recursive_bucket_listing("", None, None, 0, false, &empty));
assert!(!super::should_purge_empty_recursive_bucket_listing("", None, None, 1, true, &empty));
let live = ListObjectsInfo {
objects: vec![ObjectInfo::default()],
..Default::default()
};
assert!(!super::should_purge_empty_recursive_bucket_listing("", None, None, 1, false, &live));
let incomplete = ListObjectsInfo {
is_truncated: true,
..Default::default()
};
assert!(!super::should_purge_empty_recursive_bucket_listing("", None, None, 1, false, &incomplete));
for object in ["repro/obj-00001.bin", "repro/obj-00002.bin"] {
store
.delete_object(bucket, object, crate::object_api::ObjectOptions::default())
.await
.expect("fresh exact-key DELETE should reclaim eligible legacy residue");
for dir in &dirs {
assert!(!dir.path().join(bucket).join(object).exists(), "exact DELETE must reclaim the root");
}
}
store
.delete_bucket(bucket, &DeleteBucketOptions::default())
.await
.expect("the bucket is empty once the residue is reclaimed");
}
#[tokio::test]
async fn empty_recursive_listing_purges_committed_delete_residue() {
async fn empty_recursive_listing_preserves_committed_delete_residue() {
use crate::bucket::metadata_sys::{init_bucket_metadata_sys, test_support::isolated_store_over_temp_disks};
use crate::storage_api_contracts::bucket::{BucketOperations as _, MakeBucketOptions};
@@ -5048,20 +5069,73 @@ mod test {
assert!(result.objects.is_empty());
assert!(result.prefixes.is_empty());
for dir in &dirs {
let residue = dir.path().join(bucket).join("ghost/nested/object").join(data_dir.to_string());
assert_eq!(tokio::fs::read(residue.join("part.1")).await.expect("LIST preserves part"), b"stale");
assert!(
!dir.path().join(bucket).join("ghost").exists(),
"the empty listing should reclaim its committed delete residue"
residue
.join(format!("{}{}", crate::disk::local::DELETE_DATA_DIR_MARKER_PREFIX, transaction))
.exists()
);
}
}
// #6898: what an abandoned DeleteObjects left on every disk (rollback
// backup plus marked data dir) no longer blocks DeleteBucket after an empty
// recursive listing of the bucket.
#[tokio::test]
async fn empty_recursive_listing_purges_stranded_delete_rollback_backups() {
async fn large_preserved_residue_does_not_create_a_common_prefix() {
use crate::bucket::metadata_sys::{init_bucket_metadata_sys, test_support::isolated_store_over_temp_disks};
use crate::storage_api_contracts::bucket::{BucketOperations as _, MakeBucketOptions};
let (dirs, store) = isolated_store_over_temp_disks().await;
let bucket = "large-listing-residue";
init_bucket_metadata_sys(store.clone(), Vec::new()).await;
store
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("create bucket");
for object in 0..20 {
let data_dir = uuid::Uuid::new_v4();
let transaction = uuid::Uuid::new_v4();
for dir in &dirs {
let residue = dir
.path()
.join(bucket)
.join(format!("ghost/{object:02}"))
.join(data_dir.to_string());
tokio::fs::create_dir_all(&residue).await.expect("create residue");
tokio::fs::write(residue.join("part.1"), b"stale")
.await
.expect("write residue part");
tokio::fs::write(
residue.join(format!("{}{}", crate::disk::local::DELETE_DATA_DIR_MARKER_PREFIX, transaction)),
[],
)
.await
.expect("write residue marker");
}
}
let recursive = store
.clone()
.list_objects_generic(bucket, "", None, None, 1000, false)
.await
.expect("list recursively");
assert!(recursive.objects.is_empty());
let delimited = store
.list_objects_generic(bucket, "", None, Some("/".to_owned()), 1000, false)
.await
.expect("list with delimiter");
assert!(delimited.objects.is_empty());
assert!(delimited.prefixes.is_empty(), "residue must not produce a common prefix");
for dir in &dirs {
assert!(dir.path().join(bucket).join("ghost/00").exists(), "listing must not remove residue");
}
}
// A corrupt historical backup must survive LIST and a failed exact DELETE.
#[tokio::test]
async fn empty_recursive_listing_preserves_corrupt_delete_rollback_backups() {
use crate::bucket::metadata_sys::{init_bucket_metadata_sys, test_support::isolated_store_over_temp_disks};
use crate::storage_api_contracts::bucket::{BucketOperations as _, DeleteBucketOptions, MakeBucketOptions};
use crate::storage_api_contracts::object::ObjectOperations as _;
let (dirs, store) = isolated_store_over_temp_disks().await;
let bucket = "listing-purge-rollback-backup";
@@ -5105,21 +5179,38 @@ mod test {
.expect("recursive bucket listing should succeed");
assert!(result.objects.is_empty());
for dir in &dirs {
assert!(
!dir.path().join(bucket).join("repro").exists(),
"the empty listing should reclaim the stranded delete residue"
let backup = dir
.path()
.join(bucket)
.join("repro/obj-00001.bin")
.join(transaction.to_string())
.join(crate::disk::STORAGE_FORMAT_FILE_BACKUP);
assert_eq!(tokio::fs::read(backup).await.expect("LIST preserves backup"), b"rollback metadata");
}
let err = store
.delete_object(bucket, "repro/obj-00001.bin", crate::object_api::ObjectOptions::default())
.await
.expect_err("corrupt metadata must not be reclaimed");
assert!(matches!(err, StorageError::FileCorrupt));
assert!(store.delete_bucket(bucket, &DeleteBucketOptions::default()).await.is_err());
for dir in &dirs {
let backup = dir
.path()
.join(bucket)
.join("repro/obj-00001.bin")
.join(transaction.to_string())
.join(crate::disk::STORAGE_FORMAT_FILE_BACKUP);
assert_eq!(
tokio::fs::read(backup).await.expect("failed cleanup preserves backup"),
b"rollback metadata"
);
}
store
.delete_bucket(bucket, &DeleteBucketOptions::default())
.await
.expect("the bucket is empty once the residue is reclaimed");
}
#[tokio::test]
async fn empty_recursive_bucket_listing_purges_orphan_directory_prefixes() {
async fn empty_recursive_bucket_listing_preserves_empty_directory_prefixes() {
use crate::bucket::metadata_sys::{init_bucket_metadata_sys, test_support::isolated_store_over_temp_disks};
use crate::storage_api_contracts::bucket::{BucketOperations as _, MakeBucketOptions};
use crate::storage_api_contracts::bucket::{BucketOperations as _, DeleteBucketOptions, MakeBucketOptions};
let (dirs, store) = isolated_store_over_temp_disks().await;
let bucket = "recursive-bucket-orphan-purge";
@@ -5145,10 +5236,14 @@ mod test {
assert!(result.prefixes.is_empty(), "a delimiter-less listing has no CommonPrefixes");
for dir in &dirs {
assert!(
!dir.path().join(bucket).join("ghost").exists(),
"an empty recursive bucket scan should reclaim its metadata-less orphan prefix"
dir.path().join(bucket).join("ghost/nested/leaf").exists(),
"LIST must preserve empty directories"
);
}
store
.delete_bucket(bucket, &DeleteBucketOptions::default())
.await
.expect("bucket deletion removes empty directories");
let versioned_bucket = "recursive-bucket-versioned-orphan";
store
@@ -5300,7 +5395,7 @@ mod test {
}
#[tokio::test]
async fn empty_delimiter_listing_hides_and_purges_committed_delete_residue() {
async fn empty_delimiter_listing_hides_and_preserves_committed_delete_residue() {
use crate::bucket::metadata_sys::{init_bucket_metadata_sys, test_support::isolated_store_over_temp_disks};
use crate::storage_api_contracts::bucket::{BucketOperations as _, MakeBucketOptions};
@@ -5345,15 +5440,22 @@ mod test {
assert!(result.objects.is_empty());
assert!(result.prefixes.is_empty(), "delete residue must not surface as a prefix");
for dir in &dirs {
let residue = dir
.path()
.join(bucket)
.join("metrics/2026/object.parquet")
.join(data_dir.to_string());
assert_eq!(tokio::fs::read(residue.join("part.1")).await.expect("LIST preserves part"), b"stale");
assert!(
!dir.path().join(bucket).join("metrics").join("2026").exists(),
"the empty delimiter listing should reclaim the committed delete residue under it"
residue
.join(format!("{}{}", crate::disk::local::DELETE_DATA_DIR_MARKER_PREFIX, transaction))
.exists()
);
}
}
#[tokio::test]
async fn empty_delimiter_listing_of_residue_ancestor_hides_and_purges_whole_tree() {
async fn empty_delimiter_listing_of_residue_ancestor_hides_and_preserves_whole_tree() {
use crate::bucket::metadata_sys::{init_bucket_metadata_sys, test_support::isolated_store_over_temp_disks};
use crate::storage_api_contracts::bucket::{BucketOperations as _, MakeBucketOptions};
@@ -5386,9 +5488,7 @@ mod test {
.expect("committed delete marker should be written");
}
// Browsing an ancestor of the deleted key must not show the empty
// date folders, and the empty result reclaims the whole residue tree
// in one pass instead of one level per listing.
// Browsing an ancestor must not expose the deleted key's date folders.
let result = store
.clone()
.list_objects_generic(bucket, "metrics/", None, Some("/".to_owned()), 1000, false)
@@ -5397,15 +5497,22 @@ mod test {
assert!(result.objects.is_empty());
assert!(result.prefixes.is_empty(), "ancestors of delete residue must not surface as prefixes");
for dir in &dirs {
let residue = dir
.path()
.join(bucket)
.join("metrics/kubelet/2026/08/28/23/74992556388248657933757.parquet")
.join(data_dir.to_string());
assert_eq!(tokio::fs::read(residue.join("part.1")).await.expect("LIST preserves part"), b"stale");
assert!(
!dir.path().join(bucket).join("metrics").exists(),
"the empty delimiter listing should reclaim the committed delete residue tree under it"
residue
.join(format!("{}{}", crate::disk::local::DELETE_DATA_DIR_MARKER_PREFIX, transaction))
.exists()
);
}
}
#[tokio::test]
async fn empty_delimiter_listing_of_mixed_residue_ancestor_reclaims_only_committed_subtree() {
async fn empty_delimiter_listing_of_mixed_residue_ancestor_preserves_every_subtree() {
use crate::bucket::metadata_sys::{init_bucket_metadata_sys, test_support::isolated_store_over_temp_disks};
use crate::storage_api_contracts::bucket::{BucketOperations as _, MakeBucketOptions};
@@ -5478,9 +5585,18 @@ mod test {
assert!(result.prefixes.is_empty(), "residue subtrees may not surface as prefixes");
for dir in &dirs {
let metrics = dir.path().join(bucket).join("metrics");
assert!(
!metrics.join("cpu").exists(),
"the committed subtree must be reclaimed even though a sibling subtree is blocked"
let committed = metrics.join("cpu/2026/08/28/22/a.parquet").join(committed_dir.to_string());
assert_eq!(
tokio::fs::read(committed.join("part.1"))
.await
.expect("LIST preserves committed part"),
b"stale"
);
assert_eq!(
tokio::fs::read(committed.join(crate::disk::STORAGE_FORMAT_FILE_BACKUP))
.await
.expect("LIST preserves backup"),
b"rollback metadata"
);
assert!(
metrics
@@ -5488,7 +5604,7 @@ mod test {
.join(unmarked_dir.to_string())
.join("part.1")
.exists(),
"residue without a committed marker must survive the purge"
"residue without a committed marker must survive the listing"
);
assert_eq!(
tokio::fs::read(
@@ -6405,6 +6521,147 @@ mod test {
}
}
#[tokio::test]
async fn latest_get_follows_read_quorum_on_torn_writes() {
use crate::storage_api_contracts::bucket::{BucketOperations as _, MakeBucketOptions};
use crate::storage_api_contracts::object::ObjectOperations as _;
// HEAD and GET serve the version with the most copies once it reaches
// the object's data blocks, and a tie goes to the newer version, as in
// MinIO's getObjectFileInfo and as heal converges the set. GET used to
// demand write quorum whenever enough metadata answered, so a 2+2 or
// 8+8 split, or a newer side that could still hold write quorum
// through an offline drive, listed but answered 503.
let opts = ObjectOptions {
suppress_read_repair: true,
..Default::default()
};
for (drives, parity) in [(4_usize, 2_usize), (16, 8), (16, 4)] {
let data = drives - parity;
let bucket = format!("torn-write-get-{drives}-{parity}");
let (dirs, set) = crate::ecstore_validation_blackbox::make_local_set_disks(drives, parity).await;
set.make_bucket(&bucket, &MakeBucketOptions::default())
.await
.expect("bucket should be created");
// Two committed generations of one key give every drive its own
// inline shard of each generation; the cases copy them per drive.
let mut generations = Vec::new();
for body in [b"old generation".as_slice(), b"new generation".as_slice()] {
let info = set
.put_object(
&bucket,
"template",
&mut PutObjReader::from_vec(body.to_vec()),
&ObjectOptions {
no_lock: true,
..Default::default()
},
)
.await
.expect("generation PUT should commit on every drive");
let mut metadata = Vec::with_capacity(drives);
for dir in &dirs {
metadata.push(
tokio::fs::read(dir.path().join(&bucket).join("template").join(STORAGE_FORMAT_FILE))
.await
.expect("generation metadata should carry the inline shard"),
);
}
generations.push((body, info.etag.expect("PUT should report an etag"), metadata));
}
let (new_body, new_etag, new) = generations.pop().expect("new generation");
let (old_body, old_etag, old) = generations.pop().expect("old generation");
// New copies occupy the first drives, so the offline drive always
// removes one new copy. Each phase reads its own keys so the GET
// metadata cache never answers for the other phase.
let mut cases = Vec::new();
for offline in [false, true] {
for copies in 1..drives {
for overwrite in [false, true] {
let name = format!(
"{}-{}-{copies:02}",
if offline { "offline" } else { "online" },
if overwrite { "overwrite" } else { "new" }
);
for slot in 0..drives {
let bytes = if slot < copies {
&new[slot]
} else if overwrite {
&old[slot]
} else {
continue;
};
let path = dirs[slot].path().join(&bucket).join(&name);
tokio::fs::create_dir_all(&path).await.expect("create object directory");
tokio::fs::write(path.join(STORAGE_FORMAT_FILE), bytes)
.await
.expect("write generation metadata");
}
cases.push((offline, name, copies, overwrite));
}
}
}
for offline in [false, true] {
let parked = if offline { set.disks.write().await[0].take() } else { None };
for (_, name, copies, overwrite) in cases.iter().filter(|case| case.0 == offline) {
let new_copies = copies - usize::from(offline);
let old_copies = if *overwrite { drives - copies } else { 0 };
let expected = if new_copies >= data && new_copies >= old_copies {
Some((&new_etag, new_body))
} else if old_copies >= data {
Some((&old_etag, old_body))
} else {
None
};
let context = format!("{drives} drives EC {data}+{parity}, {name}: {new_copies} new, {old_copies} old");
let head = set.get_object_info(&bucket, name, &opts).await;
let get = set.get_object_reader(&bucket, name, None, HeaderMap::new(), &opts).await;
match (expected, head, get) {
(Some((etag, body)), Ok(info), Ok(mut reader)) => {
assert_eq!(info.etag.as_ref(), Some(etag), "{context}: HEAD");
assert_eq!(reader.object_info.etag.as_ref(), Some(etag), "{context}: GET");
let mut streamed = Vec::new();
reader
.stream
.read_to_end(&mut streamed)
.await
.unwrap_or_else(|err| panic!("{context}: GET body: {err:?}"));
assert_eq!(streamed, body, "{context}: GET body");
}
(Some((etag, _)), head, get) => panic!(
"{context}: expected {etag}, HEAD {:?}, GET {:?}",
head.map(|info| info.etag),
get.map(|reader| reader.object_info.etag)
),
(None, head, get) => {
for (method, result) in [
("HEAD", head.map(|info| info.etag)),
("GET", get.map(|reader| reader.object_info.etag)),
] {
let err = result.expect_err(&format!("{context}: {method} expected 404 or unavailable"));
assert!(
matches!(
err,
StorageError::ObjectNotFound(..)
| StorageError::FileNotFound
| StorageError::ErasureReadQuorum
| StorageError::InsufficientReadQuorum(..)
),
"{context}: {method} expected 404 or unavailable, got {err:?}"
);
}
}
}
}
if offline {
set.disks.write().await[0] = parked;
}
}
}
}
#[tokio::test]
async fn latest_listing_and_get_agree_on_hermetic_set_states() {
#[derive(Clone, Copy)]
@@ -6477,6 +6734,10 @@ mod test {
("d-torn-two", [Some(Generation::New), Some(Generation::New), None, None]),
("e-torn-one", [Some(Generation::New), None, None, None]),
("f-stale-one", [None, None, None, Some(Generation::Old)]),
(
"fa-overwrite-two-of-three",
[Some(Generation::New), Some(Generation::New), Some(Generation::Old), None],
),
(
"g-overwrite-torn-one",
[
@@ -6521,6 +6782,7 @@ mod test {
("b-copied", &new_etag),
("c-split", &new_etag),
("d-torn-two", &new_etag),
("fa-overwrite-two-of-three", &new_etag),
("g-overwrite-torn-one", &old_etag),
]
.into_iter()
@@ -6547,12 +6809,6 @@ mod test {
"{name}: GET must serve the version LIST selected"
);
}
// Known divergence: when every drive answers, GET of the latest
// version demands write quorum (three of four), so a 2+2 split
// answers 503 although LIST shows the newer generation, as
// MinIO's listing does. F.0's dangling classification does not
// cover a split; a GET change is a separate decision.
(Some(_), Err(StorageError::InsufficientReadQuorum(..))) if name == "c-split" => {}
(Some(_), Err(err)) => assert!(
matches!(
err,
@@ -6755,22 +7011,6 @@ mod test {
assert_eq!(parsed.set_idx, Some(7));
}
#[test]
fn list_marker_key_strips_only_trailing_cache_tag() {
assert_eq!(
list_marker_key("photos/2026/image.jpg[rustfs_cache:v2,id:list-cache-id,src:walker,gen:live]"),
"photos/2026/image.jpg"
);
assert_eq!(
list_marker_key("photos/[rustfs_cache:v2]/image.jpg"),
"photos/[rustfs_cache:v2]/image.jpg"
);
assert_eq!(
list_marker_key("photos/image.jpg[rustfs_cache:v2,id:list-cache-id"),
"photos/image.jpg[rustfs_cache:v2,id:list-cache-id"
);
}
#[test]
fn list_path_marker_parser_return_tag_forces_cache_refresh() {
let mut parsed = ListPathOptions {
@@ -755,7 +755,7 @@ async fn list_objects_shared_corpus_versions_delete_markers_and_free_versions()
}
#[tokio::test]
async fn list_objects_shared_corpus_orphan_cleanup_requires_authoritative_evidence() {
async fn list_objects_shared_corpus_orphan_listing_preserves_inputs() {
for authoritative in [false, true] {
let fixture = Fixture::new(
&format!("list-corpus-orphan-{authoritative}"),
@@ -774,10 +774,9 @@ async fn list_objects_shared_corpus_orphan_cleanup_requires_authoritative_eviden
let result = fixture.page("ghost/", None, None, 1).await;
assert!(result.objects.is_empty() && result.prefixes.is_empty() && !result.is_truncated);
for dir in &fixture.dirs {
assert_eq!(
dir.path().join(&fixture.bucket).join("ghost").exists(),
!authoritative,
"unknown metadata must fail closed"
assert!(
dir.path().join(&fixture.bucket).join("ghost/nested/leaf").exists(),
"LIST must preserve orphan input"
);
assert!(
dir.path()
@@ -785,7 +784,7 @@ async fn list_objects_shared_corpus_orphan_cleanup_requires_authoritative_eviden
.join("legal/child")
.join(STORAGE_FORMAT_FILE)
.exists(),
"purge must preserve legal siblings"
"LIST must preserve legal siblings"
);
}
fixture.assert_walk("legal/", None, None, 1).await;
@@ -796,7 +795,7 @@ async fn list_objects_shared_corpus_orphan_cleanup_requires_authoritative_eviden
.join("legal/child")
.join(STORAGE_FORMAT_FILE)
.exists(),
"an orphan in the same subtree must not cause the legitimate child to be purged"
"an orphan in the same subtree must not affect a legitimate child"
);
}
if authoritative {
@@ -811,17 +810,15 @@ async fn list_objects_shared_corpus_orphan_cleanup_requires_authoritative_eviden
.bucket_metadata_sys()
.expect("authoritative fixture has a metadata system");
let guard = metadata_sys.write().await;
assert!(
timeout(Duration::from_millis(100), fixture.page("timed-out/", None, None, 1))
.await
.is_err(),
"blocked authoritative evidence must time out before cleanup can classify the prefix as empty"
);
let result = timeout(Duration::from_secs(2), fixture.page("timed-out/", None, None, 1))
.await
.expect("LIST must not wait for cleanup authorization");
assert!(result.objects.is_empty() && result.prefixes.is_empty() && !result.is_truncated);
drop(guard);
for dir in &fixture.dirs {
assert!(
dir.path().join(&fixture.bucket).join("timed-out").exists(),
"a timed-out evidence check must preserve its orphan input"
"LIST must preserve its orphan input while metadata is locked"
);
}
}
-21
View File
@@ -5139,27 +5139,6 @@ impl ECStore {
purged
}
pub(super) async fn purge_orphan_dir_objects_in_bucket(&self, bucket: &str) -> bool {
if is_meta_bucketname(bucket) {
return false;
}
for pool in self.pools.iter() {
for set in pool.disk_set.iter() {
if !set.orphan_purge_has_complete_disk_set().await {
return false;
}
}
}
let mut purged = false;
for pool in self.pools.iter() {
for set in pool.disk_set.iter() {
purged |= set.purge_orphan_dir_objects_in_bucket(bucket).await;
}
}
purged
}
/// Delete one object (or a prefix) on behalf of a caller that may be
/// dropped mid-way, such as an S3 request whose client disconnects.
///
+31 -7
View File
@@ -683,14 +683,17 @@ fn create_auto_heal_disk_request(set_disk_id: String, priority: Option<HealChann
}
}
fn create_auto_replacement_disk_request(
/// Build one automatic replacement request for the verified targets of a set.
pub fn create_auto_replacement_disks_request(
pool_index: usize,
set_index: usize,
replacement_endpoint: String,
mut replacement_endpoints: Vec<String>,
priority: Option<HealChannelPriority>,
) -> HealChannelRequest {
replacement_endpoints.sort_unstable();
replacement_endpoints.dedup();
let mut request = create_auto_heal_disk_request(format!("pool_{pool_index}_set_{set_index}"), priority);
request.heal_endpoints = vec![replacement_endpoint];
request.heal_endpoints = replacement_endpoints;
request.pool_index = Some(pool_index);
request.set_index = Some(set_index);
request
@@ -711,10 +714,10 @@ pub async fn send_heal_replacement_disk(
replacement_endpoint: String,
priority: Option<HealChannelPriority>,
) -> Result<(), String> {
send_heal_request(create_auto_replacement_disk_request(
send_heal_request(create_auto_replacement_disks_request(
pool_index,
set_index,
replacement_endpoint,
vec![replacement_endpoint],
priority,
))
.await
@@ -726,8 +729,12 @@ mod auto_heal_disk_request_tests {
#[test]
fn replacement_disk_request_carries_its_exact_endpoint() {
let request =
create_auto_replacement_disk_request(2, 3, "http://node2:9000/drive3".to_string(), Some(HealChannelPriority::Normal));
let request = create_auto_replacement_disks_request(
2,
3,
vec!["http://node2:9000/drive3".to_string()],
Some(HealChannelPriority::Normal),
);
assert_eq!(request.disk.as_deref(), Some("pool_2_set_3"));
assert_eq!(request.heal_endpoints, ["http://node2:9000/drive3"]);
@@ -736,6 +743,23 @@ mod auto_heal_disk_request_tests {
assert_eq!(request.source, HealRequestSource::AutoHeal);
}
#[test]
fn replacement_disk_request_keeps_every_distinct_target() {
let request = create_auto_replacement_disks_request(
2,
3,
vec!["drive-b".to_string(), "drive-a".to_string(), "drive-b".to_string()],
Some(HealChannelPriority::Normal),
);
assert_eq!(request.disk.as_deref(), Some("pool_2_set_3"));
assert_eq!(request.heal_endpoints, ["drive-a", "drive-b"]);
assert_eq!(request.pool_index, Some(2));
assert_eq!(request.set_index, Some(3));
assert_eq!(request.priority, HealChannelPriority::Normal);
assert_eq!(request.source, HealRequestSource::AutoHeal);
}
#[test]
fn legacy_auto_heal_disk_request_has_no_replacement_endpoint() {
let request = create_auto_heal_disk_request("pool_2_set_3".to_string(), None);
+2
View File
@@ -19,6 +19,7 @@ use thiserror::Error;
use super::heal::{DiskError, EcstoreError};
const HEAL_DANGLING_DELETE_GRACE_MESSAGE: &str = "dangling object deletion deferred by heal grace window";
pub(crate) const MRF_MISSING_PROOF_PREFIX: &str = "Missing exact storage proof for durable MRF repair ";
/// Custom error type for heal operations
/// This enum defines various error variants that can occur during
@@ -221,6 +222,7 @@ pub(crate) fn classify_heal_error(error: &Error) -> HealErrorClass {
Error::Storage(error) => classify_storage_error(error),
Error::Disk(error) => classify_disk_error(error),
Error::Io(error) => classify_io_error(error),
Error::TaskExecutionFailed { message } if message.starts_with(MRF_MISSING_PROOF_PREFIX) => HealErrorClass::Permanent,
Error::TaskExecutionFailed { message } | Error::Other(message) => {
classify_error_display(message).unwrap_or(HealErrorClass::Permanent)
}
+497 -92
View File
@@ -58,6 +58,8 @@ fn result_object_size_u64(result: &HealResultItem) -> u64 {
const NEW_VERSION_SKIP_GRACE_SECS: u64 = 60;
const NANOS_PER_SECOND: i128 = 1_000_000_000;
const DEFAULT_REPLACEMENT_PAGE_OBJECT_CONCURRENCY: usize = 2;
const REPLACEMENT_OBJECT_MAX_RETRIES: u32 = 2;
fn should_skip_new_version(mod_time_unix_nanos: Option<i128>, started_at_secs: u64) -> bool {
let Some(mod_time_unix_nanos) = mod_time_unix_nanos else {
@@ -177,7 +179,9 @@ mod mainline_pacing_tests {
.await
.expect("existing object owns capacity");
let provider = Arc::new(TestPressure::new(WorkloadClass::ForegroundRead, 0));
let pacer = Arc::new(MainlinePacer::new(provider.clone(), 80, 80, Duration::from_millis(250)).expect("pacer"));
let pacer = Arc::new(
MainlinePacer::new(provider.clone(), 80, 80, Duration::from_millis(250), HealRequestSource::Admin).expect("pacer"),
);
let cancel = CancellationToken::new();
let waiting = tokio::spawn({
let semaphore = semaphore.clone();
@@ -216,7 +220,7 @@ mod mainline_pacing_tests {
let occupied = semaphore.clone().acquire_owned().await.expect("queue both waiters");
let provider = Arc::new(TestPressure::new(WorkloadClass::ForegroundRead, 0));
let pause = Duration::from_millis(250);
let pacer = MainlinePacer::new(provider.clone(), 80, 80, pause).expect("pacer");
let pacer = MainlinePacer::new(provider.clone(), 80, 80, pause, HealRequestSource::Admin).expect("pacer");
let cancel = CancellationToken::new();
let mut first = Box::pin(acquire_page_permit(semaphore.clone(), Some(&pacer), &cancel));
let mut second = Box::pin(acquire_page_permit(semaphore.clone(), Some(&pacer), &cancel));
@@ -304,9 +308,21 @@ impl ErasureSetHealer {
}
}
fn effective_heal_page_object_concurrency_for_source(source: HealRequestSource, scan_mode: HealScanMode) -> usize {
fn effective_heal_page_object_concurrency_for_source(
source: HealRequestSource,
scan_mode: HealScanMode,
replacement: bool,
) -> usize {
if matches!(source, HealRequestSource::AutoHeal) {
1
if !replacement || matches!(scan_mode, HealScanMode::Deep) || !Self::page_parallel_enabled() {
1
} else {
rustfs_utils::get_env_usize(
rustfs_config::ENV_HEAL_PAGE_OBJECT_CONCURRENCY,
DEFAULT_REPLACEMENT_PAGE_OBJECT_CONCURRENCY,
)
.clamp(1, Semaphore::MAX_PERMITS)
}
} else {
Self::effective_heal_page_object_concurrency_for_scan_mode(scan_mode)
}
@@ -1467,7 +1483,16 @@ impl ErasureSetHealer {
// transient cache id that changes between identical pages.
let mut previous_page_last: Option<(String, Option<String>)> = None;
let page_concurrency_limit =
Self::effective_heal_page_object_concurrency_for_source(self.source, self.heal_opts.scan_mode);
if self.source == HealRequestSource::AutoHeal && self.replacement_task_id.is_some() && self.heal_opts.no_lock {
// Versions of the same object must not race without its namespace lock.
1
} else {
Self::effective_heal_page_object_concurrency_for_source(
self.source,
self.heal_opts.scan_mode,
self.replacement_task_id.is_some(),
)
};
let in_flight = Arc::new(AtomicUsize::new(0));
// Per-bucket sample caps for per-object warn! lines: a flapping rebuild
// disk can fail/skip hundreds of thousands of versions in one sweep, so
@@ -1689,6 +1714,7 @@ impl ErasureSetHealer {
let semaphore = semaphore.clone();
let target_endpoints = self.target_endpoints.clone();
let replacement_commit_evidence_required = self.replacement_task_id.is_some();
let retry_replacement = replacement_commit_evidence_required && self.source == HealRequestSource::AutoHeal;
let mainline_pacer = self.mainline_pacer.clone();
let admin_identity = self.admin_identity(bucket, &object_name, version_id.as_deref(), incarnation);
@@ -1698,107 +1724,125 @@ impl ErasureSetHealer {
.as_ref()
.map(|_| (dedup_key.clone(), object_name.clone(), version_id.clone()));
let work = async move {
let permit = acquire_page_permit(semaphore, mainline_pacer.as_deref(), &cancel_token).await;
let mut retries = 0;
let mut admin_identity = admin_identity;
let mut admin_task_id = admin_task_id;
loop {
let permit = acquire_page_permit(semaphore.clone(), mainline_pacer.as_deref(), &cancel_token).await;
let _permit = match permit {
Ok(permit) => permit,
Err(err) => return (dedup_key, object_name, version_id, (0, Err(err)), None),
};
let permit = match permit {
Ok(permit) => permit,
Err(err) => return (dedup_key, object_name, version_id, (0, Err(err)), None),
};
let _in_flight_guard = PageConcurrencyGuard::new(in_flight, set_label);
let in_flight_guard = PageConcurrencyGuard::new(in_flight.clone(), set_label.clone());
if let (Some(identity), Some(task_id)) = (admin_identity, admin_task_id) {
let (result, canonical) =
admin::heal_object(storage.as_ref(), &heal_opts, identity, &task_id, &cancel_token).await;
return (dedup_key, object_name, version_id, result, canonical);
}
if let (Some(identity), Some(task_id)) = (admin_identity.take(), admin_task_id.take()) {
let (result, canonical) =
admin::heal_object(storage.as_ref(), &heal_opts, identity, &task_id, &cancel_token).await;
return (dedup_key, object_name, version_id, result, canonical);
}
// Always go through heal_object. Genuine absence flows through
// heal_object -> FileVersionNotFound/FileNotFound ->
// classify_heal_object_error -> Absent, so gone versions are
// recorded as skipped-ok rather than failed. The delete-marker
// vs data path is chosen internally in ops/heal.rs.
let result = if cancel_token.is_cancelled() {
(0, Err(Error::TaskCancelled))
} else {
match storage
.heal_object(&bucket_name, &object_name, version_id.as_deref(), &heal_opts)
.await
{
Ok((result, None)) if target_outcomes_complete(&result, &target_endpoints) => {
let object_size = result_object_size_u64(&result);
if !replacement_commit_evidence_required {
(object_size, Ok(true))
} else {
match storage
.replacement_targets_have_version(
&bucket_name,
&object_name,
version_id.as_deref(),
&heal_opts,
&target_endpoints,
)
.await
{
Ok(true) => (object_size, Ok(true)),
Ok(false) => (
object_size,
Err(Error::transient_skip(format!(
"Skipped heal for {bucket_name}/{object_name} because replacement target readback did not confirm the committed version"
))),
),
Err(err) => (
object_size,
Err(Error::transient_skip(format!(
"Skipped heal for {bucket_name}/{object_name} because replacement target readback failed: {err}"
))),
),
// Always go through heal_object. Genuine absence flows through
// heal_object -> FileVersionNotFound/FileNotFound ->
// classify_heal_object_error -> Absent, so gone versions are
// recorded as skipped-ok rather than failed. The delete-marker
// vs data path is chosen internally in ops/heal.rs.
let result = if cancel_token.is_cancelled() {
(0, Err(Error::TaskCancelled))
} else {
match storage
.heal_object(&bucket_name, &object_name, version_id.as_deref(), &heal_opts)
.await
{
Ok((result, None)) if target_outcomes_complete(&result, &target_endpoints) => {
let object_size = result_object_size_u64(&result);
if !replacement_commit_evidence_required {
(object_size, Ok(true))
} else {
match storage
.replacement_targets_have_version(
&bucket_name,
&object_name,
version_id.as_deref(),
&heal_opts,
&target_endpoints,
)
.await
{
Ok(true) => (object_size, Ok(true)),
Ok(false) => (
object_size,
Err(Error::transient_skip(format!(
"Skipped heal for {bucket_name}/{object_name} because replacement target readback did not confirm the committed version"
))),
),
Err(err) => (
object_size,
Err(Error::transient_skip(format!(
"Skipped heal for {bucket_name}/{object_name} because replacement target readback failed: {err}"
))),
),
}
}
}
}
Ok((result, None)) if !target_endpoints.is_empty() => (
result_object_size_u64(&result),
Err(Error::transient_skip(format!(
"Skipped heal for {bucket_name}/{object_name} because a replacement target was not committed"
))),
),
Ok((result, None)) => (result_object_size_u64(&result), Ok(true)),
Ok((result, Some(err))) if is_missing_object_dir_heal_result(&object_name, &err) => {
(result_object_size_u64(&result), Ok(false))
}
Ok((result, Some(err))) => {
let object_size = result_object_size_u64(&result);
match Self::classify_heal_object_error(&err) {
HealObjectOutcome::Absent => (object_size, Ok(false)),
Ok((result, None)) if !target_endpoints.is_empty() => (
result_object_size_u64(&result),
Err(Error::transient_skip(format!(
"Skipped heal for {bucket_name}/{object_name} because a replacement target was not committed"
))),
),
Ok((result, None)) => (result_object_size_u64(&result), Ok(true)),
Ok((result, Some(err))) if is_missing_object_dir_heal_result(&object_name, &err) => {
(result_object_size_u64(&result), Ok(false))
}
Ok((result, Some(err))) => {
let object_size = result_object_size_u64(&result);
match Self::classify_heal_object_error(&err) {
HealObjectOutcome::Absent => (object_size, Ok(false)),
HealObjectOutcome::DanglingDeleteDeferred { retry_not_before } => {
(object_size, Err(Error::DanglingDeleteDeferred { retry_not_before }))
}
HealObjectOutcome::Transient => (
object_size,
Err(Error::transient_skip(format!(
"Skipped heal for {bucket_name}/{object_name} due to transient error: {err}"
))),
),
HealObjectOutcome::Failed => (object_size, Err(err)),
}
}
Err(err) => match Self::classify_heal_object_error(&err) {
HealObjectOutcome::Absent => (0, Ok(false)),
HealObjectOutcome::DanglingDeleteDeferred { retry_not_before } => {
(object_size, Err(Error::DanglingDeleteDeferred { retry_not_before }))
(0, Err(Error::DanglingDeleteDeferred { retry_not_before }))
}
HealObjectOutcome::Transient => (
object_size,
0,
Err(Error::transient_skip(format!(
"Skipped heal for {bucket_name}/{object_name} due to transient error: {err}"
))),
),
HealObjectOutcome::Failed => (object_size, Err(err)),
}
HealObjectOutcome::Failed => (0, Err(err)),
},
}
Err(err) => match Self::classify_heal_object_error(&err) {
HealObjectOutcome::Absent => (0, Ok(false)),
HealObjectOutcome::DanglingDeleteDeferred { retry_not_before } => {
(0, Err(Error::DanglingDeleteDeferred { retry_not_before }))
}
HealObjectOutcome::Transient => (
0,
Err(Error::transient_skip(format!(
"Skipped heal for {bucket_name}/{object_name} due to transient error: {err}"
))),
),
HealObjectOutcome::Failed => (0, Err(err)),
},
}
};
};
(dedup_key, object_name, version_id, result, None)
drop(in_flight_guard);
drop(permit);
if retry_replacement
&& retries < REPLACEMENT_OBJECT_MAX_RETRIES
&& matches!(&result.1, Err(Error::TransientSkip { .. }))
{
retries += 1;
tokio::select! {
_ = cancel_token.cancelled() => return (dedup_key, object_name, version_id, (0, Err(Error::TaskCancelled)), None),
_ = tokio::time::sleep(Duration::from_millis(250 * u64::from(retries))) => {}
}
continue;
}
return (dedup_key, object_name, version_id, result, None);
}
};
page_tasks.push(async move {
if let (Some(execution), Some((key, object, version))) = (execution, failure_identity) {
@@ -2129,6 +2173,7 @@ mod tests {
ErasureSetHealer::effective_heal_page_object_concurrency_for_source(
HealRequestSource::AutoHeal,
HealScanMode::Normal,
false,
),
1
);
@@ -2136,6 +2181,73 @@ mod tests {
);
}
#[test]
fn automatic_replacement_uses_a_conservative_default_and_respects_serial_controls() {
temp_env::with_vars(
[
(rustfs_config::ENV_HEAL_PAGE_PARALLEL_ENABLE, Some("true")),
(rustfs_config::ENV_HEAL_PAGE_OBJECT_CONCURRENCY, None),
],
|| {
assert_eq!(
ErasureSetHealer::effective_heal_page_object_concurrency_for_source(
HealRequestSource::AutoHeal,
HealScanMode::Normal,
true,
),
2
);
assert_eq!(
ErasureSetHealer::effective_heal_page_object_concurrency_for_source(
HealRequestSource::AutoHeal,
HealScanMode::Deep,
true,
),
1
);
},
);
temp_env::with_vars(
[
(rustfs_config::ENV_HEAL_PAGE_PARALLEL_ENABLE, Some("false")),
(rustfs_config::ENV_HEAL_PAGE_OBJECT_CONCURRENCY, Some("8")),
],
|| {
assert_eq!(
ErasureSetHealer::effective_heal_page_object_concurrency_for_source(
HealRequestSource::AutoHeal,
HealScanMode::Normal,
true,
),
1
);
},
);
}
#[test]
fn automatic_replacement_concurrency_fits_the_semaphore() {
let maximum = usize::MAX.to_string();
for (value, expected) in [("0", 1), (maximum.as_str(), tokio::sync::Semaphore::MAX_PERMITS)] {
temp_env::with_vars(
[
(rustfs_config::ENV_HEAL_PAGE_PARALLEL_ENABLE, Some("true")),
(rustfs_config::ENV_HEAL_PAGE_OBJECT_CONCURRENCY, Some(value)),
],
|| {
assert_eq!(
ErasureSetHealer::effective_heal_page_object_concurrency_for_source(
HealRequestSource::AutoHeal,
HealScanMode::Normal,
true,
),
expected
);
},
);
}
}
#[test]
fn non_auto_normal_scan_page_object_concurrency_uses_effective_limit() {
temp_env::with_vars(
@@ -2148,6 +2260,7 @@ mod tests {
ErasureSetHealer::effective_heal_page_object_concurrency_for_source(
HealRequestSource::Admin,
HealScanMode::Normal,
false,
),
11
);
@@ -2520,6 +2633,8 @@ mod resume_loop_tests {
lifecycle_expired: Mutex<HashSet<String>>,
/// every heal_object call recorded as (name, version_id)
heal_calls: Mutex<Vec<(String, Option<String>)>>,
heal_gate: Mutex<Option<Arc<tokio::sync::Semaphore>>>,
heal_started: tokio::sync::Notify,
list_include_lifecycle_object_info: Mutex<Vec<bool>>,
replacement_target_identity_sequences: Mutex<VecDeque<Vec<ReplacementTargetIdentity>>>,
replacement_targets_unready: AtomicBool,
@@ -2704,6 +2819,11 @@ mod resume_loop_tests {
.lock()
.unwrap()
.push((object.to_string(), version_id.map(str::to_string)));
self.heal_started.notify_one();
let heal_gate = self.heal_gate.lock().unwrap().clone();
if let Some(gate) = heal_gate {
gate.acquire().await.unwrap().forget();
}
let key = compose_key(object, version_id);
let outcome = {
let mut outcomes = self.outcomes.lock().unwrap();
@@ -3898,6 +4018,287 @@ mod resume_loop_tests {
assert_eq!(unique.len(), 50, "no version healed twice");
}
#[tokio::test]
async fn auto_replacement_runs_two_object_repairs_within_the_configured_limit() {
temp_env::async_with_vars(
[
(rustfs_config::ENV_HEAL_PAGE_PARALLEL_ENABLE, Some("true")),
(rustfs_config::ENV_HEAL_PAGE_OBJECT_CONCURRENCY, Some("2")),
],
async {
let mut env = make_env().await;
env.healer.source = HealRequestSource::AutoHeal;
env.healer = env
.healer
.with_replacement_targets(vec!["replacement-a".to_string()], Some(env.task_id.clone()));
let gate = Arc::new(tokio::sync::Semaphore::new(0));
*env.storage.heal_gate.lock().unwrap() = Some(gate.clone());
let objects = ["a", "b", "c"];
for name in objects {
env.storage
.set_result(name, None, replacement_target_ok_result("replacement-a", name));
}
env.storage.set_page(
None,
Page {
items: objects.into_iter().map(|name| item(name, None, false)).collect(),
next: None,
truncated: false,
},
);
let observe = async {
let two_started = tokio::time::timeout(std::time::Duration::from_secs(2), async {
loop {
let notified = env.storage.heal_started.notified();
if env.storage.calls().len() >= 2 {
break;
}
notified.await;
}
})
.await
.is_ok();
let started = env.storage.calls().len();
gate.add_permits(objects.len());
assert!(two_started, "automatic replacement should start two repairs before either finishes");
assert_eq!(started, 2, "configured concurrency must bound in-flight repairs");
};
let ((processed, successful, failed, skipped, result), ()) = tokio::join!(run(&env), observe);
result.expect("all replacement objects must be verified");
assert_eq!((processed, successful, failed, skipped), (3, 3, 0, 0));
assert_eq!(env.storage.calls().len(), 3);
},
)
.await;
}
#[tokio::test]
async fn auto_replacement_without_object_locks_stays_serial() {
temp_env::async_with_vars(
[
(rustfs_config::ENV_HEAL_PAGE_PARALLEL_ENABLE, Some("true")),
(rustfs_config::ENV_HEAL_PAGE_OBJECT_CONCURRENCY, Some("2")),
],
async {
let mut env = make_env().await;
env.healer.source = HealRequestSource::AutoHeal;
env.healer.heal_opts.no_lock = true;
env.healer = env
.healer
.with_replacement_targets(vec!["replacement-a".to_string()], Some(env.task_id.clone()));
let gate = Arc::new(tokio::sync::Semaphore::new(0));
*env.storage.heal_gate.lock().unwrap() = Some(gate.clone());
for version in ["v1", "v2"] {
env.storage
.set_result("object", Some(version), replacement_target_ok_result("replacement-a", "object"));
}
env.storage.set_page(
None,
Page {
items: vec![item("object", Some("v1"), false), item("object", Some("v2"), false)],
next: None,
truncated: false,
},
);
let observe = async {
let first_started = tokio::time::timeout(std::time::Duration::from_secs(5), async {
loop {
let notified = env.storage.heal_started.notified();
if !env.storage.calls().is_empty() {
break;
}
notified.await;
}
})
.await
.is_ok();
let two_started = tokio::time::timeout(std::time::Duration::from_secs(2), async {
loop {
let notified = env.storage.heal_started.notified();
if env.storage.calls().len() >= 2 {
break;
}
notified.await;
}
})
.await
.is_ok();
gate.add_permits(2);
assert!(first_started, "the first object repair must start");
assert!(!two_started, "unlocked object repairs must not overlap");
};
let ((processed, successful, failed, skipped, result), ()) = tokio::join!(run(&env), observe);
result.expect("the serial replacement must finish");
assert_eq!((processed, successful, failed, skipped), (2, 2, 0, 0));
},
)
.await;
}
#[tokio::test]
async fn auto_replacement_retries_a_transient_object_before_rewinding_the_scan() {
let mut env = make_env().await;
env.healer.source = HealRequestSource::AutoHeal;
env.healer = env
.healer
.with_replacement_targets(vec!["replacement-a".to_string()], Some(env.task_id.clone()));
env.storage.set_page(
None,
Page {
items: vec![item("object", Some("version"), false)],
next: None,
truncated: false,
},
);
env.storage
.set_outcome("object", Some("version"), HealOutcome::RpcCancelled(1));
env.storage
.set_result("object", Some("version"), replacement_target_ok_result("replacement-a", "object"));
let (processed, successful, failed, skipped, result) = run(&env).await;
result.expect("the transient repair should succeed during the current pass");
assert_eq!((processed, successful, failed, skipped), (1, 1, 0, 0));
assert_eq!(env.storage.calls().len(), 2);
}
#[tokio::test]
async fn auto_replacement_releases_capacity_during_a_bounded_retry() {
temp_env::async_with_vars(
[
(rustfs_config::ENV_HEAL_PAGE_PARALLEL_ENABLE, Some("true")),
(rustfs_config::ENV_HEAL_PAGE_OBJECT_CONCURRENCY, Some("1")),
],
async {
let mut env = make_env().await;
env.healer.source = HealRequestSource::AutoHeal;
env.healer = env
.healer
.with_replacement_targets(vec!["replacement-a".to_string()], Some(env.task_id.clone()));
env.storage.set_page(
None,
Page {
items: vec![item("retry", None, false), item("ready", None, false)],
next: None,
truncated: false,
},
);
env.storage.set_outcome("retry", None, HealOutcome::Transient);
env.storage
.set_result("ready", None, replacement_target_ok_result("replacement-a", "ready"));
let (processed, successful, failed, skipped, result) = run(&env).await;
result.expect("a failed object is retained as a transient skip");
assert_eq!((processed, successful, failed, skipped), (2, 1, 0, 1));
let calls = env.storage.calls();
let retries: Vec<_> = calls.iter().enumerate().filter(|(_, call)| call.0 == "retry").collect();
let ready = calls.iter().position(|call| call.0 == "ready").expect("ready object ran");
assert_eq!(retries.len(), 3, "only two retries are allowed");
assert!(ready < retries[1].0, "the backoff must release the only page permit");
},
)
.await;
}
#[tokio::test]
async fn auto_replacement_cancellation_interrupts_object_retry() {
let mut env = make_env().await;
env.healer.source = HealRequestSource::AutoHeal;
env.healer = env
.healer
.with_replacement_targets(vec!["replacement-a".to_string()], Some(env.task_id.clone()));
env.storage.set_page(
None,
Page {
items: vec![item("retry", None, false)],
next: None,
truncated: false,
},
);
env.storage.set_outcome("retry", None, HealOutcome::Transient);
let cancel = env.healer.cancel_token.clone();
let observe = async {
loop {
let notified = env.storage.heal_started.notified();
if !env.storage.calls().is_empty() {
break;
}
notified.await;
}
cancel.cancel();
};
let ((_, successful, _, _, result), ()) = tokio::join!(run(&env), observe);
assert!(matches!(result, Err(Error::TaskCancelled)));
assert_eq!(successful, 0);
assert_eq!(env.storage.calls().len(), 1);
assert!(env.checkpoint.get_checkpoint().await.processed_objects.is_empty());
}
#[tokio::test]
async fn auto_replacement_cancel_retains_workers_until_inflight_repairs_settle() {
temp_env::async_with_vars(
[
(rustfs_config::ENV_HEAL_PAGE_PARALLEL_ENABLE, Some("true")),
(rustfs_config::ENV_HEAL_PAGE_OBJECT_CONCURRENCY, Some("2")),
],
async {
let mut env = make_env().await;
env.healer.source = HealRequestSource::AutoHeal;
let execution = crate::heal::storage::ReplacementExecution::for_test(Vec::new(), Vec::new());
env.healer = env
.healer
.with_replacement_targets(vec!["replacement-a".to_string()], Some(env.task_id.clone()))
.with_replacement_execution(Some(execution.clone()));
let gate = Arc::new(tokio::sync::Semaphore::new(0));
*env.storage.heal_gate.lock().unwrap() = Some(gate.clone());
for name in ["a", "b", "c"] {
env.storage
.set_result(name, None, replacement_target_ok_result("replacement-a", name));
}
env.storage.set_page(
None,
Page {
items: ["a", "b", "c"].into_iter().map(|name| item(name, None, false)).collect(),
next: None,
truncated: false,
},
);
let cancel = env.healer.cancel_token.clone();
let observe = async {
tokio::time::timeout(std::time::Duration::from_secs(5), async {
loop {
let notified = env.storage.heal_started.notified();
if env.storage.calls().len() >= 2 {
break;
}
notified.await;
}
})
.await
.expect("two repairs must start");
cancel.cancel();
};
let ((_, successful, _, _, result), ()) = tokio::join!(run(&env), observe);
assert!(matches!(result, Err(Error::TaskCancelled)));
assert_eq!(successful, 0);
assert_eq!(env.storage.calls().len(), 2, "queued work must not start after cancellation");
assert!(env.checkpoint.get_checkpoint().await.processed_objects.is_empty());
assert!(Arc::strong_count(&execution) >= 4, "in-flight workers must retain execution ownership");
gate.add_permits(2);
tokio::time::timeout(std::time::Duration::from_secs(5), async {
while Arc::strong_count(&execution) > 2 {
tokio::task::yield_now().await;
}
})
.await
.expect("workers must release execution ownership after their storage work settles");
},
)
.await;
}
#[tokio::test]
async fn test_resume_after_version_deleted_midheal_no_skip() {
let env = make_env().await;
@@ -4312,7 +4713,11 @@ mod resume_loop_tests {
assert!(!state.completed, "readback errors must leave the replacement task incomplete");
assert_eq!(state.retry_count, 1, "readback errors must arm the bounded retry path");
assert!(env.checkpoint.get_checkpoint().await.processed_objects.is_empty());
assert_eq!(env.storage.calls(), vec![("object".to_string(), Some("dm-v1".to_string()))]);
assert_eq!(
env.storage.calls(),
vec![("object".to_string(), Some("dm-v1".to_string())); 3],
"unverified readback must exhaust only the bounded object retries"
);
}
#[tokio::test]
+84 -3
View File
@@ -825,6 +825,21 @@ fn admin_heal_options_compatible(left: &HealOptions, right: &HealOptions) -> boo
&& left.no_lock == right.no_lock
}
fn replacement_request_compatible(
request: &HealRequest,
owner_type: &HealType,
owner_options: &HealOptions,
owner_source: HealRequestSource,
) -> bool {
request.source == HealRequestSource::AutoHeal
&& owner_source == HealRequestSource::AutoHeal
&& matches!(request.heal_type, HealType::ErasureSet { .. })
&& request.heal_type == *owner_type
&& request.options.pool_index == owner_options.pool_index
&& request.options.set_index == owner_options.set_index
&& admin_heal_options_compatible(&request.options, owner_options)
}
fn admin_overlap_rejection(
request: &HealRequest,
owner_type: &HealType,
@@ -1276,7 +1291,9 @@ impl HealManager {
request: HealRequest,
config: &HealConfig,
context: &'static str,
replacement_targets_frozen: bool,
) -> HealAdmissionDecision {
let replacement_targets_frozen = replacement_targets_frozen || request.retry_attempts != 0;
let queue_len = queue.len();
publish_heal_queue_length(queue);
let queue_capacity = config.queue_size;
@@ -1284,6 +1301,25 @@ impl HealManager {
let best_effort_reserve = best_effort_queue_reserve(queue_capacity);
let best_effort_source_reserves = best_effort_source_reserves(queue_capacity);
// Scanner and retry admission bypass submit_heal_request's duplicate
// checks. Keep their target ownership checks ahead of queue capacity.
if !request.force_start
&& request.source == HealRequestSource::AutoHeal
&& !request.heal_endpoints.is_empty()
&& let Some(owner_id) = queue
.queued_request_id_for_dedup_key(&PriorityHealQueue::make_dedup_key(&request))
.map(ToOwned::to_owned)
{
let mut admission = Self::duplicate_admission_for_request(&request, config);
if admission == HealAdmissionResult::Merged
&& !queue.merge_replacement_targets(&owner_id, &request, true, replacement_targets_frozen)
{
admission = HealAdmissionResult::Dropped(HealAdmissionDropReason::AlreadyRunning);
}
Self::record_admission_metric(request.source, admission, context);
return HealAdmissionDecision::new(admission);
}
if queue_len >= queue_capacity && !request.force_start {
if Self::can_displace_queued_work(&request)
&& queue.can_displace_lower_priority_where(request.priority, Self::queued_request_can_be_displaced)
@@ -1443,7 +1479,7 @@ impl HealManager {
let priority = request.priority;
let source = request.source;
match queue.push(request) {
match queue.push_with_replacement_scope(request, replacement_targets_frozen) {
QueuePushOutcome::Accepted => {
publish_heal_queue_length(queue);
Self::record_admission_metric(source, HealAdmissionResult::Accepted, context);
@@ -2260,7 +2296,7 @@ impl HealManager {
// HS-06: under the minio_error overlap policy an exact duplicate
// admin start reports the typed AlreadyRunning rejection instead
// of the silent merge (MinIO's ErrHealAlreadyRunning).
let admission = if (request.source == HealRequestSource::Admin
let mut admission = if (request.source == HealRequestSource::Admin
&& config.overlap_policy == HealOverlapPolicy::MinioError)
|| (duplicate_state != "queued" && mrf_notice_target.as_ref().is_some_and(|target| target.kind.is_durable()))
{
@@ -2270,6 +2306,44 @@ impl HealManager {
} else {
Self::duplicate_admission_for_request(&request, &config)
};
if matches!(admission, HealAdmissionResult::Merged)
&& request.source == HealRequestSource::AutoHeal
&& !request.heal_endpoints.is_empty()
{
let (owner_recovered, incoming_recovered) = {
// Match queue -> recovery-anchor ordering in the scanner.
let anchors = self
.replacement_recovery_anchors
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
(anchors.contains_key(&merged_task_id), anchors.contains_key(&request.id))
};
let incoming_frozen = incoming_recovered || request.retry_attempts != 0;
let covered = if incoming_frozen && request.id != merged_task_id {
false
} else if duplicate_state == "queued" {
queue.merge_replacement_targets(&merged_task_id, &request, !owner_recovered, incoming_frozen)
} else {
let owner = active_heals
.get(&merged_task_id)
.map(|task| (&task.heal_type, &task.options, task.source, &task.heal_endpoints))
.or_else(|| {
retrying_heals.get(&merged_task_id).map(|retry| {
let owner = &retry.request;
(&owner.heal_type, &owner.options, owner.source, &owner.heal_endpoints)
})
});
owner.is_some_and(|(heal_type, options, source, endpoints)| {
replacement_request_compatible(&request, heal_type, options, source)
&& request.heal_endpoints.iter().all(|endpoint| endpoints.contains(endpoint))
})
};
if !covered {
// A frozen generation cannot certify newly arriving disks.
// Leave them discoverable by the scanner for the next pass.
admission = HealAdmissionResult::Dropped(HealAdmissionDropReason::AlreadyRunning);
}
}
if matches!(admission, HealAdmissionResult::Merged)
&& let Some(target) = mrf_notice_target
{
@@ -2426,7 +2500,14 @@ impl HealManager {
let request_id = request.id.clone();
let request_heal_type = request.heal_type.clone();
let request_source = request.source;
let admission_decision = Self::admit_request_to_queue(&mut queue, request, &config, "submit");
let recovered_replacement = request.source == HealRequestSource::AutoHeal
&& !request.heal_endpoints.is_empty()
&& self
.replacement_recovery_anchors
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.contains_key(&request.id);
let admission_decision = Self::admit_request_to_queue(&mut queue, request, &config, "submit", recovered_replacement);
let admission = admission_decision.result;
if durable_handoff_required && !admission.is_admitted() {
self.root_recovery
+3 -1
View File
@@ -682,6 +682,7 @@ impl HealManager {
HealPriority::Low,
);
let recovery_anchor = recovery.as_ref().map(|(_, _, _, anchor)| anchor.clone());
let recovered_replacement = recovery.is_some();
if let Some((task_id, _, _, _)) = recovery {
req.id = task_id;
}
@@ -691,7 +692,8 @@ impl HealManager {
let endpoint_count = req.heal_endpoints.len();
let config = config.read().await;
let mut queue = heal_queue.lock().await;
let admission_decision = Self::admit_request_to_queue(&mut queue, req, &config, "auto_scan");
let admission_decision =
Self::admit_request_to_queue(&mut queue, req, &config, "auto_scan", recovered_replacement);
let admission = admission_decision.result;
let should_notify =
matches!(admission, HealAdmissionResult::Accepted) && config.event_driven_scheduler_enable;
+52
View File
@@ -70,6 +70,8 @@ pub(super) struct PriorityQueueItem {
pub(super) sequence: u64,
pub(super) dedup_key: String,
pub(super) request: HealRequest,
/// Execution or durable recovery fixed these targets; grace waits can have zero retries.
pub(super) replacement_targets_frozen: bool,
}
impl Eq for PriorityQueueItem {}
@@ -316,6 +318,10 @@ impl PriorityHealQueue {
}
pub(super) fn push(&mut self, request: HealRequest) -> QueuePushOutcome {
self.push_with_replacement_scope(request, false)
}
pub(super) fn push_with_replacement_scope(&mut self, request: HealRequest, frozen: bool) -> QueuePushOutcome {
let key = Self::make_dedup_key(&request);
// Check for duplicates unless the caller explicitly forces admission.
@@ -340,6 +346,7 @@ impl PriorityHealQueue {
sequence: self.sequence,
dedup_key: key,
request,
replacement_targets_frozen: frozen,
});
if is_best_effort_source(source) {
self.best_effort_queued += 1;
@@ -680,6 +687,51 @@ impl PriorityHealQueue {
self.heap.iter().map(|item| &item.request)
}
pub(super) fn merge_replacement_targets(
&mut self,
owner_id: &str,
request: &HealRequest,
allow_expand: bool,
incoming_frozen: bool,
) -> bool {
let Some(item) = self.heap.iter().find(|item| item.request.id == owner_id) else {
return false;
};
let owner = &item.request;
let same_id = request.id == owner.id;
let eligible = replacement_request_compatible(request, &owner.heal_type, &owner.options, owner.source)
&& (!incoming_frozen || same_id);
let covered = eligible
&& request
.heal_endpoints
.iter()
.all(|endpoint| owner.heal_endpoints.contains(endpoint));
let mark_frozen = incoming_frozen && same_id && !item.replacement_targets_frozen;
let can_expand = eligible
&& allow_expand
&& !item.replacement_targets_frozen
&& !incoming_frozen
&& owner.retry_attempts == 0
&& request.retry_attempts == 0
&& !owner.heal_endpoints.is_empty();
if !mark_frozen && (covered || !can_expand) {
return covered;
}
// Neither targets nor their frozen flag affect heap ordering.
let mut items = std::mem::take(&mut self.heap).into_vec();
if let Some(item) = items.iter_mut().find(|item| item.request.id == owner_id) {
item.replacement_targets_frozen |= mark_frozen;
if !covered && can_expand {
item.request.heal_endpoints.extend(request.heal_endpoints.iter().cloned());
item.request.heal_endpoints.sort_unstable();
item.request.heal_endpoints.dedup();
}
}
self.heap = BinaryHeap::from(items);
covered || can_expand
}
#[cfg(test)]
pub(super) fn contains_request_id(&self, request_id: &str) -> bool {
self.heap.iter().any(|item| item.request.id == request_id)
+26 -14
View File
@@ -30,6 +30,30 @@ pub(super) fn retry_admission_delay(
Some(delay)
}
pub(super) fn running_mainline_pacer(
request: &HealRequest,
config: &HealConfig,
provider: &Option<WorkloadSnapshotProviderRef>,
) -> Option<Arc<crate::heal::pacing::MainlinePacer>> {
let pace_running = request.source == HealRequestSource::Admin
|| (request.source == HealRequestSource::AutoHeal
&& matches!(&request.heal_type, HealType::ErasureSet { .. })
&& !request.heal_endpoints.is_empty());
if !pace_running || !config.mainline_throttle_enable {
return None;
}
provider.as_ref().and_then(|provider| {
crate::heal::pacing::MainlinePacer::new(
provider.clone(),
config.mainline_read_utilization_high_percent,
config.mainline_write_utilization_high_percent,
config.mainline_max_sleep,
request.source,
)
.map(Arc::new)
})
}
impl HealManager {
/// Start scheduler
pub(super) async fn start_scheduler(&self) -> Result<()> {
@@ -202,19 +226,7 @@ impl HealManager {
.unwrap_or_else(|poisoned| poisoned.into_inner())
.get(&request.id)
.cloned();
let mainline_pacer = if request.source == HealRequestSource::Admin && config.mainline_throttle_enable {
workload_provider.as_ref().and_then(|provider| {
crate::heal::pacing::MainlinePacer::new(
provider.clone(),
config.mainline_read_utilization_high_percent,
config.mainline_write_utilization_high_percent,
config.mainline_max_sleep,
)
.map(Arc::new)
})
} else {
None
};
let mainline_pacer = running_mainline_pacer(&request, &config, workload_provider);
let task = Arc::new(
HealTask::from_replacement_recovery_request(request, storage.clone(), replacement_resume_endpoint)
.with_mainline_pacer(mainline_pacer)
@@ -582,7 +594,7 @@ impl HealManager {
return;
}
let admission_decision =
Self::admit_request_to_queue(&mut queue, retry_request.clone(), &retry_config, "retry");
Self::admit_request_to_queue(&mut queue, retry_request.clone(), &retry_config, "retry", true);
let admission = admission_decision.result;
let should_notify = matches!(admission, HealAdmissionResult::Accepted)
&& retry_config.event_driven_scheduler_enable;
+295
View File
@@ -2505,6 +2505,301 @@ fn test_heal_request_and_task_metric_labels_match() {
assert_eq!(task.metric_set_label(), "pool_0_set_1");
}
#[tokio::test]
async fn automatic_replacement_queued_targets_merge_without_changing_fifo_or_token() {
let manager = HealManager::new_without_root_recovery_for_test(Arc::new(MockStorage), None);
let owner = automatic_replacement_request(&["replacement-a"]);
let owner_id = owner.id.clone();
let created_at = owner.created_at;
let first = manager
.submit_heal_request_with_receipt(owner)
.await
.expect("queue first target");
assert_eq!(first.result, HealAdmissionResult::Accepted);
let other = HealRequest::object("bucket".to_string(), "unrelated".to_string(), None);
let other_id = other.id.clone();
manager.submit_heal_request(other).await.expect("queue unrelated work");
let merged = manager
.submit_heal_request_with_receipt(automatic_replacement_request(&["replacement-b", "replacement-a"]))
.await
.expect("merge all fresh targets");
assert_eq!(merged.result, HealAdmissionResult::Merged);
assert_eq!(merged.task_id, owner_id);
let mut queue = manager.heal_queue.lock().await;
assert_eq!(queue.len(), 2);
let owner = queue.pop_next().expect("original task remains first");
assert_eq!(owner.id, owner_id);
assert_eq!(owner.created_at, created_at);
assert_eq!(owner.heal_endpoints, ["replacement-a", "replacement-b"]);
assert_eq!(queue.pop_next().expect("unrelated task remains queued").id, other_id);
}
fn automatic_replacement_request(targets: &[&str]) -> HealRequest {
let mut request = HealRequest::new(
HealType::ErasureSet {
buckets: Vec::new(),
set_disk_id: "pool_0_set_1".to_string(),
},
HealOptions {
pool_index: Some(0),
set_index: Some(1),
..Default::default()
},
HealPriority::Normal,
);
request.source = HealRequestSource::AutoHeal;
request.heal_endpoints = targets.iter().map(|target| (*target).to_string()).collect();
request
}
#[tokio::test]
async fn automatic_replacement_frozen_targets_reject_late_disks_but_cover_duplicates() {
for state in ["active", "retrying", "grace", "recovered", "recovery_anchor"] {
let storage: Arc<dyn HealStorageAPI> = Arc::new(MockStorage);
let manager = HealManager::new_without_root_recovery_for_test(storage.clone(), None);
let mut owner = automatic_replacement_request(&["replacement-a"]);
let owner_id = owner.id.clone();
if state == "grace" {
let now = SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("test clock")
.as_secs();
let task = HealTask::from_request(owner, storage.clone());
owner = retry_request_for_result_with_budget(
&task,
&Err(Error::DanglingDeleteDeferred {
retry_not_before: now + 60,
}),
)
.await
.expect("grace wait keeps the generation replayable")
.0;
assert_eq!(owner.retry_attempts, 0, "grace must not consume a failure retry");
}
match state {
"active" => {
manager
.active_heals
.lock()
.await
.insert(owner_id.clone(), Arc::new(HealTask::from_request(owner, storage.clone())));
}
"retrying" => {
manager.retrying_heals.lock().await.insert(
owner_id.clone(),
RetryingHeal {
request: owner,
error: "target unavailable".to_string(),
cancel_token: CancellationToken::new(),
},
);
}
"grace" | "recovered" => {
let config = manager.config.read().await.clone();
let mut queue = manager.heal_queue.lock().await;
let context = if state == "grace" { "retry" } else { "auto_scan" };
assert_eq!(
HealManager::admit_request_to_queue(&mut queue, owner, &config, context, true).result,
HealAdmissionResult::Accepted
);
assert!(
queue
.heap
.iter()
.any(|item| item.request.id == owner_id && item.replacement_targets_frozen),
"zero retries cannot mean the scope is fresh"
);
}
_ => {
manager.submit_heal_request(owner).await.expect("queue frozen generation");
if state == "recovery_anchor" {
manager
.replacement_recovery_anchors
.lock()
.expect("recovery anchors")
.insert(owner_id.clone(), "survivor".to_string());
}
}
}
let late = manager
.submit_heal_request(automatic_replacement_request(&["replacement-b"]))
.await
.expect("classify late target");
assert_eq!(late, HealAdmissionResult::Dropped(HealAdmissionDropReason::AlreadyRunning), "{state}");
let covered = manager
.submit_heal_request_with_receipt(automatic_replacement_request(&["replacement-a"]))
.await
.expect("covered target remains owned");
assert_eq!(covered.result, HealAdmissionResult::Merged, "{state}");
assert_eq!(covered.task_id, owner_id, "{state}");
let targets = match state {
"active" => manager.active_heals.lock().await[&owner_id].heal_endpoints.clone(),
"retrying" => manager.retrying_heals.lock().await[&owner_id].request.heal_endpoints.clone(),
_ => {
manager
.heal_queue
.lock()
.await
.pop_next()
.expect("retained generation")
.heal_endpoints
}
};
assert_eq!(targets, ["replacement-a"], "{state}: frozen scope must not grow");
}
}
#[tokio::test]
async fn automatic_replacement_merge_preserves_options_policy_and_recovery_identity() {
let manager = HealManager::new_without_root_recovery_for_test(Arc::new(MockStorage), None);
manager
.submit_heal_request(automatic_replacement_request(&["replacement-a"]))
.await
.expect("queue fresh generation");
for variant in [
"dry_run",
"bucket_scope",
"pool_scope",
"retried_incoming",
"recovery_anchor",
"merge_disabled",
] {
let mut incoming = automatic_replacement_request(&["replacement-b"]);
match variant {
"dry_run" => incoming.options.dry_run = true,
"bucket_scope" => {
if let HealType::ErasureSet { buckets, .. } = &mut incoming.heal_type {
buckets.push("other-bucket".to_string());
}
}
"pool_scope" => incoming.options.pool_index = Some(2),
"retried_incoming" => {
incoming.heal_endpoints = vec!["replacement-a".to_string()];
incoming.retry_attempts = 1;
}
"recovery_anchor" => {
incoming.heal_endpoints = vec!["replacement-a".to_string()];
manager
.replacement_recovery_anchors
.lock()
.expect("recovery anchors")
.insert(incoming.id.clone(), "survivor".to_string());
}
"merge_disabled" => {
manager.config.write().await.low_priority_merge_enable = false;
incoming.priority = HealPriority::Low;
}
_ => unreachable!("test variant"),
}
let result = manager
.submit_heal_request(incoming)
.await
.expect("classify incompatible generation");
let reason = if variant == "merge_disabled" {
HealAdmissionDropReason::PolicyDropped
} else {
HealAdmissionDropReason::AlreadyRunning
};
assert_eq!(result, HealAdmissionResult::Dropped(reason), "{variant}");
let queue = manager.heal_queue.lock().await;
assert_eq!(queue.len(), 1, "{variant}");
assert_eq!(
queue.requests().next().expect("original generation").heal_endpoints,
["replacement-a"],
"{variant}"
);
}
}
#[test]
fn automatic_replacement_direct_admission_checks_frozen_targets_before_capacity() {
let config = HealConfig {
queue_size: 1,
..HealConfig::default()
};
let mut queue = PriorityHealQueue::new();
let owner = automatic_replacement_request(&["replacement-a"]);
let owner_id = owner.id.clone();
assert_eq!(queue.push(owner), QueuePushOutcome::Accepted);
let admit = |queue: &mut PriorityHealQueue, request, frozen| {
HealManager::admit_request_to_queue(queue, request, &config, "auto_scan", frozen).result
};
assert_eq!(
admit(&mut queue, automatic_replacement_request(&["replacement-b"]), false),
HealAdmissionResult::Merged
);
let mut replay = automatic_replacement_request(&["replacement-a"]);
replay.id = owner_id.clone();
assert_eq!(admit(&mut queue, replay, true), HealAdmissionResult::Merged);
assert!(
queue
.heap
.iter()
.any(|item| item.request.id == owner_id && item.replacement_targets_frozen)
);
assert_eq!(
admit(&mut queue, automatic_replacement_request(&["replacement-c"]), false),
HealAdmissionResult::Dropped(HealAdmissionDropReason::AlreadyRunning)
);
assert_eq!(
admit(&mut queue, automatic_replacement_request(&["replacement-a"]), false),
HealAdmissionResult::Merged
);
assert_eq!(
admit(&mut queue, automatic_replacement_request(&["replacement-a"]), true),
HealAdmissionResult::Dropped(HealAdmissionDropReason::AlreadyRunning)
);
assert_eq!(queue.len(), 1);
let owner = queue.pop_next().expect("frozen owner retained");
assert_eq!(owner.id, owner_id);
assert_eq!(owner.heal_endpoints, ["replacement-a", "replacement-b"]);
let owner = automatic_replacement_request(&["replacement-a"]);
let owner_id = owner.id.clone();
assert_eq!(queue.push(owner), QueuePushOutcome::Accepted);
let mut conflicting_replay = automatic_replacement_request(&["replacement-b"]);
conflicting_replay.id = owner_id.clone();
assert_eq!(
admit(&mut queue, conflicting_replay, true),
HealAdmissionResult::Dropped(HealAdmissionDropReason::AlreadyRunning)
);
assert!(
queue
.heap
.iter()
.any(|item| item.request.id == owner_id && item.replacement_targets_frozen)
);
assert_eq!(
admit(&mut queue, automatic_replacement_request(&["replacement-c"]), false),
HealAdmissionResult::Dropped(HealAdmissionDropReason::AlreadyRunning)
);
let mut queue = PriorityHealQueue::new();
let owner = automatic_replacement_request(&["replacement-a"]);
let owner_id = owner.id.clone();
assert_eq!(queue.push(owner), QueuePushOutcome::Accepted);
let mut incompatible_replay = automatic_replacement_request(&["replacement-a"]);
incompatible_replay.id = owner_id.clone();
incompatible_replay.options.dry_run = true;
assert_eq!(
admit(&mut queue, incompatible_replay, true),
HealAdmissionResult::Dropped(HealAdmissionDropReason::AlreadyRunning)
);
assert!(
queue
.heap
.iter()
.any(|item| item.request.id == owner_id && item.replacement_targets_frozen)
);
assert_eq!(
admit(&mut queue, automatic_replacement_request(&["replacement-b"]), false),
HealAdmissionResult::Dropped(HealAdmissionDropReason::AlreadyRunning)
);
}
#[tokio::test]
async fn test_submit_heal_request_returns_merged_for_duplicate() {
let storage: Arc<dyn HealStorageAPI> = Arc::new(MockStorage);
@@ -258,6 +258,16 @@ impl ScannerFixture {
assert_eq!(request.id, self.task_id, "scanner must replay the durable UUID");
assert_eq!(request.heal_endpoints, vec![self.local_targets[0].clone()]);
assert_eq!(request.source, HealRequestSource::AutoHeal);
assert!(
self.manager
.heal_queue
.lock()
.await
.heap
.iter()
.any(|item| item.request.id == request.id && item.replacement_targets_frozen),
"replayed durable targets cannot expand"
);
assert!(matches!(
request.heal_type,
HealType::ErasureSet { set_disk_id, buckets }
@@ -6,6 +6,41 @@ use crate::heal::storage::HealListItem;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use tokio::sync::Semaphore;
#[tokio::test(start_paused = true)]
async fn automatic_replacement_uses_running_foreground_pacing() {
let provider = Arc::new(super::super::super::pacing::TestPressure::new(WorkloadClass::ForegroundRead, 100));
let provider: Option<WorkloadSnapshotProviderRef> = Some(provider);
let mut config = HealConfig {
mainline_throttle_enable: true,
mainline_read_utilization_high_percent: 80,
mainline_max_sleep: Duration::from_millis(250),
..HealConfig::default()
};
let mut request = HealRequest::new(
HealType::ErasureSet {
buckets: Vec::new(),
set_disk_id: "pool_0_set_0".to_owned(),
},
HealOptions::default(),
HealPriority::Low,
);
request.source = HealRequestSource::AutoHeal;
request.heal_endpoints = vec!["replacement-a".to_owned()];
let pacer = super::super::scheduler::running_mainline_pacer(&request, &config, &provider)
.expect("automatic replacement must pace under foreground pressure");
let started = tokio::time::Instant::now();
pacer.wait(&CancellationToken::new()).await.expect("bounded progress");
assert_eq!(started.elapsed(), Duration::from_millis(250));
assert!(super::super::scheduler::running_mainline_pacer(&request, &config, &None).is_none());
config.mainline_throttle_enable = false;
assert!(super::super::scheduler::running_mainline_pacer(&request, &config, &provider).is_none());
config.mainline_throttle_enable = true;
request.heal_endpoints.clear();
assert!(super::super::scheduler::running_mainline_pacer(&request, &config, &provider).is_none());
}
#[derive(Default)]
struct PressureProbe {
active: AtomicUsize,
+15 -7
View File
@@ -6,6 +6,7 @@ use rustfs_concurrency::{
WorkloadAdmissionSnapshotProvider,
workload::{ForegroundPressure, foreground_pressure},
};
use rustfs_heal_contracts::heal_channel::HealRequestSource;
use std::{sync::Arc, time::Duration};
use tokio::{sync::Mutex, time::Instant};
use tokio_util::sync::CancellationToken;
@@ -21,12 +22,13 @@ pub(crate) enum PacingDecision {
Wait(Option<ForegroundPressure>),
}
/// Cooperative pacing for one admin execution, not a storage admission permit.
/// Cooperative pacing for one heal execution, not a storage admission permit.
pub(crate) struct MainlinePacer {
provider: Arc<dyn WorkloadAdmissionSnapshotProvider + Send + Sync>,
read_high: usize,
write_high: usize,
pause: Duration,
source: HealRequestSource,
state: Mutex<PacingState>,
}
@@ -36,6 +38,7 @@ impl MainlinePacer {
read_high: usize,
write_high: usize,
pause: Duration,
source: HealRequestSource,
) -> Option<Self> {
if (read_high == 0 && write_high == 0) || pause.is_zero() {
return None;
@@ -45,6 +48,7 @@ impl MainlinePacer {
read_high: read_high.min(100),
write_high: write_high.min(100),
pause: pause.min(Duration::from_secs(1)),
source,
state: Mutex::new(PacingState::default()),
})
}
@@ -108,7 +112,7 @@ impl MainlinePacer {
}
metrics::counter!(
"rustfs_heal_mainline_throttle_total",
"source" => "admin",
"source" => self.source.as_str(),
"result" => "delayed",
"reason" => pressure.or(observed).map_or("recovery_window", |pressure| pressure.reason())
)
@@ -170,6 +174,7 @@ mod tests {
if class == WorkloadClass::ForegroundRead { 40 } else { 0 },
if class == WorkloadClass::ForegroundWrite { 40 } else { 0 },
pause,
HealRequestSource::Admin,
)
.expect("enabled pacer");
let cancel = CancellationToken::new();
@@ -192,9 +197,9 @@ mod tests {
#[tokio::test(start_paused = true)]
async fn running_mainline_huge_pause_is_capped_and_disabled_classes_do_not_sleep() {
let provider = Arc::new(TestPressure::new(WorkloadClass::ForegroundRead, 100));
assert!(MainlinePacer::new(provider.clone(), 0, 0, Duration::from_secs(1)).is_none());
assert!(MainlinePacer::new(provider.clone(), 80, 80, Duration::ZERO).is_none());
let pacer = MainlinePacer::new(provider, 80, 80, Duration::from_secs(3600)).expect("pacer");
assert!(MainlinePacer::new(provider.clone(), 0, 0, Duration::from_secs(1), HealRequestSource::Admin).is_none());
assert!(MainlinePacer::new(provider.clone(), 80, 80, Duration::ZERO, HealRequestSource::Admin).is_none());
let pacer = MainlinePacer::new(provider, 80, 80, Duration::from_secs(3600), HealRequestSource::Admin).expect("pacer");
let before = Instant::now();
pacer.wait(&CancellationToken::new()).await.expect("hard-capped pause");
assert_eq!(Instant::now() - before, Duration::from_secs(1));
@@ -203,7 +208,9 @@ mod tests {
#[tokio::test(start_paused = true)]
async fn running_mainline_waiters_cancel_and_task_latches_are_isolated() {
let provider = Arc::new(TestPressure::new(WorkloadClass::ForegroundRead, 100));
let paced = Arc::new(MainlinePacer::new(provider.clone(), 80, 80, Duration::from_secs(1)).expect("pacer"));
let paced = Arc::new(
MainlinePacer::new(provider.clone(), 80, 80, Duration::from_secs(1), HealRequestSource::Admin).expect("pacer"),
);
let cancel_first = CancellationToken::new();
let first = tokio::spawn({
let paced = paced.clone();
@@ -221,7 +228,8 @@ mod tests {
cancel_second.cancel();
assert!(matches!(second.await.expect("queued waiter"), Err(Error::TaskCancelled)));
provider.active.store(0, Ordering::SeqCst);
let other_task = MainlinePacer::new(provider.clone(), 80, 80, Duration::from_secs(1)).expect("independent task");
let other_task = MainlinePacer::new(provider.clone(), 80, 80, Duration::from_secs(1), HealRequestSource::Admin)
.expect("independent task");
let before = Instant::now();
other_task
.wait(&CancellationToken::new())
+8 -4
View File
@@ -489,9 +489,13 @@ impl HealTask {
))
.await?;
if let Some(error) = storage_result.error {
return Err(Error::TaskExecutionFailed {
message: format!("Failed to recreate missing object {bucket}/{object}: {error}"),
});
if error.is_dangling_delete_grace() {
// Preserve the existing MRF grace retry ownership.
return Err(Error::TaskExecutionFailed {
message: format!("Failed to recreate missing object {bucket}/{object}: {error}"),
});
}
return Err(error);
}
let object_size = storage_result.item.object_size as u64;
@@ -526,7 +530,7 @@ impl HealTask {
}
} else {
Error::TaskExecutionFailed {
message: format!("Missing exact storage proof for durable MRF repair {bucket}/{object}"),
message: format!("{}{bucket}/{object}", crate::error::MRF_MISSING_PROOF_PREFIX),
}
});
}
+38
View File
@@ -2912,6 +2912,44 @@ fn mrf_task_for_item(object: &str, item: HealResultItem) -> (HealTask, Arc<MockS
(HealTask::from_request(request, storage.clone()), storage)
}
#[tokio::test]
async fn durable_mrf_slowdown_remains_retryable_without_a_storage_receipt() {
let (task, storage) = mrf_task_for_item("object", HealResultItem::default());
*storage.heal_object_outcome.lock().unwrap() = Some(MockHealObjectOutcome::RetryableSlowDown);
let error = task
.execute()
.await
.expect_err("a failed repair cannot discharge the responsibility");
assert!(matches!(error, Error::Storage(EcstoreError::SlowDown)), "{error:?}");
assert!(error.is_recoverable_heal());
assert_eq!(task.get_outcome().await.counters.healed, 0);
}
#[tokio::test]
async fn durable_mrf_grace_remains_owned_by_the_responsibility() {
let (task, storage) = mrf_task_for_item("object", HealResultItem::default());
*storage.heal_object_outcome.lock().unwrap() = Some(MockHealObjectOutcome::DanglingGraceDeferred);
let error = task.execute().await.expect_err("grace must retain the responsibility");
assert!(error.is_dangling_delete_grace());
assert!(!error.is_recoverable_heal());
assert_eq!(task.get_outcome().await.counters.healed, 0);
}
#[tokio::test]
async fn durable_mrf_unverified_object_names_cannot_change_retry_classification() {
for object in ["ordinary", "timed out", "network error"] {
let (task, _) = mrf_task_for_item(object, HealResultItem::default());
let error = task
.execute()
.await
.expect_err("missing proof must retain the responsibility");
assert!(!error.is_recoverable_heal(), "object name must not change retry classification: {object}");
assert_eq!(task.get_outcome().await.counters.healed, 0);
}
}
#[tokio::test]
async fn mrf_legacy_object_names_cannot_change_retry_classification() {
for object in [
@@ -0,0 +1,29 @@
// Copyright 2026 RustFS Team
// Licensed under the Apache License, Version 2.0.
use rustfs_heal::{HealOptions, HealPriority, HealRequest, HealType};
use rustfs_heal_contracts::heal_channel::HealRequestSource;
use std::time::SystemTime;
#[test]
fn existing_heal_request_literal_remains_constructible() {
let now = SystemTime::now();
let request = HealRequest {
id: "request".to_string(),
heal_type: HealType::ErasureSet {
set_disk_id: "pool_0_set_0".to_string(),
buckets: Vec::new(),
},
bucket_incarnation_id: None,
expected_mrf_bucket_incarnation_id: None,
options: HealOptions::default(),
priority: HealPriority::Normal,
source: HealRequestSource::AutoHeal,
force_start: false,
retry_attempts: 0,
heal_endpoints: vec!["drive-a".to_string()],
created_at: now,
enqueued_at: now,
};
assert_eq!(request.heal_endpoints, ["drive-a"]);
}
+18
View File
@@ -26,6 +26,9 @@ use crate::{
/// Default shard count for guard storage (must be power of 2)
const DEFAULT_GUARD_SHARD_COUNT: usize = 64;
const EVENT_NAMESPACE_LOCK_RELEASE: &str = "namespace_lock_release";
const LOG_COMPONENT_LOCK: &str = "lock";
const LOG_SUBSYSTEM_NAMESPACE: &str = "namespace";
type GuardShard = Arc<RwLock<HashMap<LockId, LocalGuardEntry>>>;
type GuardStorage = Arc<Vec<GuardShard>>;
@@ -333,7 +336,22 @@ impl LockClient for LocalClient {
};
if let Some(guard) = guard {
// Guard automatically releases the lock when dropped
let physical_guard_id = (!guard.guard.is_disabled()).then(|| guard.guard.guard_id());
drop(guard.guard);
if let Some(physical_guard_id) = physical_guard_id {
tracing::trace!(
target: "rustfs_lock::namespace_owner",
event = EVENT_NAMESPACE_LOCK_RELEASE,
component = LOG_COMPONENT_LOCK,
subsystem = LOG_SUBSYSTEM_NAMESPACE,
state = "mapped",
peer_kind = "local",
lock_id = %lock_id,
process_id = std::process::id(),
physical_guard_id,
"Namespace peer lock mapped to physical guard"
);
}
Ok(true)
} else {
// Lock not found or already released
+5
View File
@@ -412,6 +412,11 @@ impl DistributedLockGuard {
&self.lock_id
}
/// Actual granted peer identities, which may differ from the logical guard ID.
pub fn granted_lock_ids(&self) -> impl Iterator<Item = &LockId> {
self.entries.iter().map(|entry| &entry.lock_id)
}
/// Whether the guard's heartbeat has observed a refresh-quorum loss.
pub fn is_lock_lost(&self) -> bool {
self.lock_lost.is_lost()
+23
View File
@@ -24,6 +24,9 @@ use std::sync::atomic::{AtomicU64, Ordering};
/// Global counter for guard IDs to prevent double-release
static GUARD_ID_COUNTER: AtomicU64 = AtomicU64::new(1);
const EVENT_NAMESPACE_LOCK_RELEASE: &str = "namespace_lock_release";
const LOG_COMPONENT_LOCK: &str = "lock";
const LOG_SUBSYSTEM_NAMESPACE: &str = "namespace";
/// RAII guard for fast object locks
///
@@ -109,6 +112,7 @@ impl FastLockGuard {
record_lock_held_release(self.mode);
// Unregister the guard after successful release
shard.unregister_guard(self.guard_id);
self.record_physical_release();
}
success
} else {
@@ -118,6 +122,22 @@ impl FastLockGuard {
}
}
fn record_physical_release(&self) {
tracing::trace!(
target: "rustfs_lock::namespace_owner",
event = EVENT_NAMESPACE_LOCK_RELEASE,
component = LOG_COMPONENT_LOCK,
subsystem = LOG_SUBSYSTEM_NAMESPACE,
state = "released",
peer_kind = "local",
process_id = std::process::id(),
physical_guard_id = self.guard_id,
resource = %self.key,
owner = %self.owner,
"Namespace physical guard release completed"
);
}
/// Check if the lock has been released
pub fn is_released(&self) -> bool {
self.released
@@ -168,6 +188,9 @@ impl Drop for FastLockGuard {
}
// Always unregister the guard to prevent leaks, regardless of release success
shard.unregister_guard(self.guard_id);
if success {
self.record_physical_release();
}
} else {
// If guard was already released or disabled, just unregister it
shard.unregister_guard(self.guard_id);
+47 -34
View File
@@ -1410,27 +1410,46 @@ impl DataUsageCache {
Some((flattened, visited.len()))
}
fn flatten_with_guard(&self, root: &DataUsageEntry, visited: &mut HashSet<String>, depth: usize) -> DataUsageEntry {
let mut root = root.clone();
fn flatten_with_guard<'a>(
&'a self,
root: &'a DataUsageEntry,
visited: &mut HashSet<&'a str>,
depth: usize,
) -> DataUsageEntry {
// A summary never owns child links. Cloning and clearing the set
// retains its allocation in every completed subtree and update.
let mut flat = DataUsageEntry {
children: HashSet::new(),
size: root.size,
objects: root.objects,
versions: root.versions,
delete_markers: root.delete_markers,
obj_sizes: root.obj_sizes.clone(),
obj_versions: root.obj_versions.clone(),
replication_stats: root.replication_stats.clone(),
compacted: root.compacted,
failed_objects: root.failed_objects,
all_tier_stats: root.all_tier_stats.clone(),
unknown_tier_stats: root.unknown_tier_stats.clone(),
tier_accounting_proof: root.tier_accounting_proof,
};
if depth >= MAX_DATA_USAGE_CACHE_DEPTH {
root.children.clear();
return root;
return flat;
}
for id in root.children.clone().iter() {
if !visited.insert(id.clone()) {
for id in &root.children {
if !visited.insert(id.as_str()) {
continue;
}
if let Some(e) = self.cache.get(id) {
let mut e = e.clone();
if !e.children.is_empty() {
e = self.flatten_with_guard(&e, visited, depth + 1);
if e.children.is_empty() {
flat.merge(e);
} else {
flat.merge(&self.flatten_with_guard(e, visited, depth + 1));
}
root.merge(&e);
}
}
root.children.clear();
root
flat
}
pub fn copy_with_children(&mut self, src: &DataUsageCache, hash: &DataUsageHash, parent: &Option<DataUsageHash>) {
@@ -1467,38 +1486,31 @@ impl DataUsageCache {
}
pub fn delete_recursive(&mut self, hash: &DataUsageHash) {
let mut visited = HashSet::new();
self.delete_recursive_guard(hash, &mut visited, 0);
self.delete_recursive_guard(&hash.0, 0);
}
fn delete_recursive_guard(&mut self, hash: &DataUsageHash, visited: &mut HashSet<String>, depth: usize) {
if !visited.insert(hash.key()) {
fn delete_recursive_guard(&mut self, path: &str, depth: usize) {
// Removal transfers child ownership and makes cyclic/shared references
// no-ops, without cloning paths or maintaining a second visited set.
let Some(children) = self.cache.remove(path).map(|entry| entry.children) else {
return;
}
let mut need_remove = Vec::new();
if let Some(v) = self.cache.get(&hash.string()) {
for child in v.children.iter() {
need_remove.push(child.clone());
}
}
self.cache.remove(&hash.string());
};
if depth >= MAX_DATA_USAGE_CACHE_DEPTH {
return;
}
for child in need_remove {
self.delete_recursive_guard(&DataUsageHash(child), visited, depth + 1);
for child in children {
self.delete_recursive_guard(&child, depth + 1);
}
}
pub fn size_recursive(&self, path: &str) -> Option<DataUsageEntry> {
match self.find(path) {
Some(root) => {
match self.cache.get_key_value(&hash_path(path).key()) {
Some((key, root)) => {
if root.children.is_empty() {
return Some(root.clone());
}
let mut visited = HashSet::new();
visited.insert(hash_path(path).key());
visited.insert(key.as_str());
let mut flat = self.flatten_with_guard(root, &mut visited, 0);
if flat.replication_stats.as_ref().is_some_and(|stats| stats.is_empty()) {
flat.replication_stats = None;
@@ -1553,7 +1565,7 @@ impl DataUsageCache {
}
pub fn reduce_children_of(&mut self, path: &DataUsageHash, limit: usize, compact_self: bool) {
let Some(e) = self.cache.get(&path.key()).cloned() else {
let Some(e) = self.cache.get(&path.0) else {
return;
};
@@ -1605,12 +1617,13 @@ impl DataUsageCache {
}
pub fn total_children_rec(&self, path: &str) -> usize {
let root = hash_path(path);
let mut visited = HashSet::new();
visited.insert(hash_path(path).key());
visited.insert(root.0.as_str());
self.total_children_rec_guard(path, &mut visited, 0)
}
fn total_children_rec_guard(&self, path: &str, visited: &mut HashSet<String>, depth: usize) -> usize {
fn total_children_rec_guard<'a>(&'a self, path: &str, visited: &mut HashSet<&'a str>, depth: usize) -> usize {
let Some(root) = self.find(path) else {
return 0;
};
@@ -1620,7 +1633,7 @@ impl DataUsageCache {
let mut n = 0;
for ch in root.children.iter() {
if visited.insert(ch.clone()) {
if visited.insert(ch.as_str()) {
n += 1 + self.total_children_rec_guard(ch, visited, depth + 1);
}
}
@@ -2428,6 +2428,69 @@ fn test_data_usage_cache_recursive_helpers_tolerate_cycles() {
assert!(!copied.cache.contains_key(&child_hash.key()));
}
#[test]
fn delete_recursive_preserves_depth_boundary_and_unrelated_entries() {
let mut cache = DataUsageCache::default();
for depth in 0..=MAX_DATA_USAGE_CACHE_DEPTH + 1 {
let mut entry = DataUsageEntry::default();
if depth <= MAX_DATA_USAGE_CACHE_DEPTH {
entry.children.insert(format!("node-{}", depth + 1));
}
cache.cache.insert(format!("node-{depth}"), entry);
}
cache.cache.insert("unrelated".to_string(), DataUsageEntry::default());
cache.delete_recursive(&DataUsageHash("node-0".to_string()));
assert_eq!(cache.cache.len(), 2, "delete only reachable entries within the depth limit");
assert!(cache.cache.contains_key(&format!("node-{}", MAX_DATA_USAGE_CACHE_DEPTH + 1)));
assert!(cache.cache.contains_key("unrelated"));
}
#[test]
fn scanner_summary_releases_wide_child_sets() {
let root = hash_path("bucket");
let mut cache = DataUsageCache::default();
cache.replace_hashed(
&root,
&None,
&DataUsageEntry {
size: 17,
objects: 1,
versions: 2,
delete_markers: 1,
failed_objects: 1,
all_tier_stats: Some(AllTierStats::new()),
..Default::default()
},
);
for index in 0..10_000 {
let child = hash_path(&format!("bucket/{index:05}"));
cache.replace_hashed(
&child,
&Some(root.clone()),
&DataUsageEntry {
size: 3,
objects: 1,
versions: 2,
..Default::default()
},
);
}
let summary = cache.size_recursive("bucket").expect("wide bucket summary");
assert_eq!((summary.size, summary.objects, summary.versions), (30_017, 10_001, 20_002));
assert_eq!((summary.delete_markers, summary.failed_objects), (1, 1));
assert_eq!(summary.children.capacity(), 0, "summaries must not retain the wide namespace allocation");
assert!(summary.all_tier_stats.is_some(), "preserve the root's optional statistics representation");
assert_eq!(cache.find("bucket").expect("unchanged root").children.len(), 10_000);
cache.cache.insert("unrelated".to_string(), DataUsageEntry::default());
cache.delete_recursive(&root);
assert_eq!(cache.cache.len(), 1, "wide subtree deletion must consume every child exactly once");
assert!(cache.cache.contains_key("unrelated"));
}
#[test]
fn test_data_usage_cache_flatten_does_not_count_root_twice_in_cycle() {
let root_hash = hash_path("bucket");
@@ -236,6 +236,7 @@ async fn profile_case(objects: usize, scenario: &str, samples: usize) {
let mut clone_ns = Vec::new();
let mut copy_ns = Vec::new();
let mut delete_ns = Vec::new();
let mut flatten_ns = Vec::new();
let mut encode_ns = Vec::new();
let mut save_ns = Vec::new();
@@ -257,6 +258,14 @@ async fn profile_case(objects: usize, scenario: &str, samples: usize) {
same_cache(&copied, &cache);
drop(copied);
let mut deleted = cache.clone();
let root = hash_path("bucket");
let started = WallInstant::now();
deleted.delete_recursive(black_box(&root));
delete_ns.push(elapsed_ns(started));
assert!(deleted.cache.is_empty(), "delete the root and every reachable entry");
drop(deleted);
let started = WallInstant::now();
let aggregate = black_box(cache.checked_flatten("bucket").expect("valid fixture tree"));
flatten_ns.push(elapsed_ns(started));
@@ -326,6 +335,7 @@ async fn profile_case(objects: usize, scenario: &str, samples: usize) {
"changed_entry_wire_bytes": changed_entry_wire_bytes, "save_body_bytes_per_sample": expected_wire.len() * 2,
"snapshot_complete": cache.info.snapshot_complete,
"clone": quantiles(clone_ns), "copy_with_children": quantiles(copy_ns), "checked_flatten": quantiles(flatten_ns),
"delete_recursive": quantiles(delete_ns),
"encode": quantiles(encode_ns), "save_inclusive": quantiles(save_ns), "memory_backend_ingest": quantiles(ingest_ns),
})
);
+41 -11
View File
@@ -183,17 +183,7 @@ impl Settlement {
"insufficient_scan"
} else {
match (candidate.identity.as_ref(), completion.object.as_ref()) {
(Some(expected), Some(actual)) if expected != &actual.identity => "identity_mismatch",
(Some(expected), Some(_))
if expected.bucket_incarnation_id.is_some_and(|id| !id.is_nil())
&& expected.pool_index.is_some()
&& expected.set_index.is_some()
&& expected.version_id.as_ref().is_some_and(|version| !version.is_empty()) =>
{
// Even an exact version and VerifiedHealthy do not bind the
// candidate's content and leader epoch with today's contract.
"content_and_leader_unknown"
}
(Some(expected), Some(actual)) => Self::identity_scope(expected, &actual.identity),
_ => "identity_unknown",
}
};
@@ -209,6 +199,46 @@ impl Settlement {
}
}
fn identity_scope(expected: &HealObjectIdentity, actual: &HealObjectIdentity) -> &'static str {
let expected_version = expected.version_id.as_deref().filter(|version| !version.is_empty());
let actual_version = actual.version_id.as_deref().filter(|version| !version.is_empty());
let expected_incarnation = expected.bucket_incarnation_id.filter(|id| !id.is_nil());
let actual_incarnation = actual.bucket_incarnation_id.filter(|id| !id.is_nil());
if expected.kind != actual.kind
|| expected.bucket != actual.bucket
|| expected.object != actual.object
|| expected_version
.zip(actual_version)
.is_some_and(|(left, right)| left != right)
|| expected_incarnation
.zip(actual_incarnation)
.is_some_and(|(left, right)| left != right)
|| expected
.pool_index
.zip(actual.pool_index)
.is_some_and(|(left, right)| left != right)
|| expected
.set_index
.zip(actual.set_index)
.is_some_and(|(left, right)| left != right)
{
"identity_mismatch"
} else if expected_version.is_none()
|| actual_version.is_none()
|| expected_incarnation.is_none()
|| actual_incarnation.is_none()
|| expected.pool_index.is_none()
|| actual.pool_index.is_none()
|| expected.set_index.is_none()
|| actual.set_index.is_none()
{
"identity_unknown"
} else {
// Matching identities still do not bind content and leader epoch.
"content_and_leader_unknown"
}
}
fn record(&self) {
counter!("rustfs_scanner_heal_candidate_terminal_total",
"disposition" => self.disposition, "scope" => self.scope, "scan_mode" => self.mode.as_str())
@@ -98,6 +98,91 @@ fn replacement_bucket_version_or_location_cannot_match_an_old_result() {
}
}
#[test]
fn missing_identity_fields_are_unknown_on_either_side() {
type IdentityChange = fn(&mut HealObjectIdentity);
let changes: [(&str, IdentityChange); 6] = [
("version", |identity| identity.version_id = None),
("empty version", |identity| identity.version_id = Some(String::new())),
("incarnation", |identity| identity.bucket_incarnation_id = None),
("nil incarnation", |identity| identity.bucket_incarnation_id = Some(Uuid::nil())),
("pool", |identity| identity.pool_index = None),
("set", |identity| identity.set_index = None),
];
for (label, change) in changes {
for candidate_missing in [true, false] {
let mut observed = candidate();
let mut terminal = (*completion("partial", HealObjectDisposition::VerifiedHealthy)).clone();
if candidate_missing {
change(observed.identity.as_mut().expect("candidate identity"));
} else {
change(&mut terminal.object.as_mut().expect("receipt").identity);
}
let mut recent = Recent {
completion: Arc::new(terminal),
counted: false,
};
assert_eq!(
Settlement::new(observed, &mut recent).scope,
"identity_unknown",
"{label}, candidate missing: {candidate_missing}"
);
}
}
}
#[test]
fn known_identity_conflicts_are_not_hidden_by_other_missing_fields() {
for change in 0..3 {
let mut observed = candidate();
let mut terminal = (*completion("conflict", HealObjectDisposition::VerifiedHealthy)).clone();
let expected = observed.identity.as_mut().expect("candidate identity");
let actual = &mut terminal.object.as_mut().expect("receipt").identity;
match change {
0 => {
expected.version_id = None;
actual.bucket = "replacement".into();
}
1 => {
expected.pool_index = None;
actual.version_id = Some(Uuid::from_u128(12).to_string());
}
_ => {
expected.bucket_incarnation_id = None;
actual.set_index = Some(2);
}
}
let mut recent = Recent {
completion: Arc::new(terminal),
counted: false,
};
assert_eq!(Settlement::new(observed, &mut recent).scope, "identity_mismatch", "case {change}");
}
}
#[test]
fn scanner_candidate_without_location_records_identity_unknown() {
let mut scanner_request = request();
scanner_request.expected_bucket_incarnation_id = None;
scanner_request.pool_index = None;
scanner_request.set_index = None;
let mut observations = Observations::default();
assert!(
observations
.admit(
"scanner".into(),
Candidate::from_request(&scanner_request).expect("bounded scanner candidate"),
)
.is_none()
);
let settlement = observations
.complete(completion("scanner", HealObjectDisposition::VerifiedHealthy), Instant::now())
.expect("terminal candidate observed");
assert_eq!(settlement.scope, "identity_unknown");
assert_eq!(observations.unknown_counts.get("identity_unknown"), Some(&1));
assert_eq!(observations.unknown_counts.get("identity_mismatch"), None);
}
#[test]
fn unversioned_overwrite_and_unknown_leader_never_certify_payload() {
let mut observed = candidate();
+176 -163
View File
@@ -12,6 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use std::borrow::Cow;
use std::collections::{HashMap, HashSet};
use std::fs::FileType;
use std::io::ErrorKind;
@@ -751,11 +752,36 @@ struct ScannerCheckpointAncestor {
entry: DataUsageEntry,
}
struct ScannerUsageUpdates {
sender: mpsc::Sender<DataUsageEntry>,
cache: DataUsageCache,
last_update: SystemTime,
}
impl ScannerUsageUpdates {
fn new(sender: mpsc::Sender<DataUsageEntry>, bucket: String) -> Self {
Self {
sender,
cache: DataUsageCache {
info: DataUsageCacheInfo {
name: bucket,
..Default::default()
},
..Default::default()
},
last_update: SystemTime::UNIX_EPOCH,
}
}
fn is_due(&self) -> bool {
self.last_update.elapsed().unwrap_or(Duration::ZERO) >= Duration::from_secs(60)
}
}
pub struct FolderScanner {
root: String,
old_cache: DataUsageCache,
new_cache: DataUsageCache,
update_cache: DataUsageCache,
data_usage_scanner_debug: bool,
heal_object_select: u32,
@@ -773,11 +799,10 @@ pub struct FolderScanner {
disks: Vec<Arc<Disk>>,
disks_quorum: usize,
updates: Option<mpsc::Sender<DataUsageEntry>>,
usage_updates: Option<ScannerUsageUpdates>,
checkpoint_tx: Option<mpsc::Sender<DataUsageCache>>,
checkpoint_ancestors: Vec<ScannerCheckpointAncestor>,
checkpoint_depth: usize,
last_update: SystemTime,
checkpoint_objects: u64,
object_metadata_visits: metrics::Counter,
last_checkpoint_objects: u64,
@@ -798,10 +823,6 @@ pub struct FolderScanner {
resume_failed_paths: Vec<String>,
coverage_gap: bool,
raw_enumeration_progress: Vec<RawEnumerationProgress>,
pending_heal_sync_deferred: bool,
pending_heal_batch_dirty: bool,
#[cfg(test)]
pending_heal_sync_count: usize,
pending_size_reconciliation_keys: HashSet<String>,
pending_size_reconciliation_scopes: HashSet<String>,
pending_size_reconciliation_truncated: bool,
@@ -1036,8 +1057,9 @@ impl FolderScanner {
fn reuse_clean_root_child(&mut self, child: &CachedFolder, child_hash: &DataUsageHash, into: &mut DataUsageEntry) {
self.new_cache.copy_with_children(&self.old_cache, child_hash, &child.parent);
self.update_cache
.copy_with_children(&self.old_cache, child_hash, &child.parent);
if let Some(updates) = &mut self.usage_updates {
updates.cache.copy_with_children(&self.old_cache, child_hash, &child.parent);
}
into.add_child(child_hash);
}
@@ -1145,8 +1167,8 @@ impl FolderScanner {
}
/// Apply the per-object size-resolution ledger updates in one place. The
/// scanner cache is the durable boundary; both working copies are updated
/// so an incremental publication cannot lose a debt or its resolution.
/// scanner cache is the durable boundary; progress updates contain only
/// usage totals and never own a copy of the reconciliation ledger.
fn apply_size_reconciliation(&mut self, summary: &SizeSummary) {
let now = Self::now_secs();
self.pending_size_reconciliation_keys
@@ -1159,26 +1181,25 @@ impl FolderScanner {
);
self.pending_size_reconciliation_truncated |= summary.size_reconciliation_truncated;
for info in [&mut self.new_cache.info, &mut self.update_cache.info] {
for incoming in &summary.size_reconciliation {
if let Some(existing) = info.size_reconciliation.get_mut(&incoming.key) {
existing.reason = incoming.reason.clone();
existing.physical_size = incoming.physical_size;
existing.generation = incoming.generation.clone();
existing.version_id = incoming.version_id.clone();
existing.attempts = existing.attempts.saturating_add(1);
continue;
}
if size_reconciliation_entry_bytes(incoming) > MAX_SIZE_RECONCILIATION_BYTES_PER_BUCKET {
continue;
}
let mut entry = incoming.clone();
entry.first_seen = now;
entry.attempts = 1;
info.size_reconciliation.insert(entry.key.clone(), entry);
let info = &mut self.new_cache.info;
for incoming in &summary.size_reconciliation {
if let Some(existing) = info.size_reconciliation.get_mut(&incoming.key) {
existing.reason = incoming.reason.clone();
existing.physical_size = incoming.physical_size;
existing.generation = incoming.generation.clone();
existing.version_id = incoming.version_id.clone();
existing.attempts = existing.attempts.saturating_add(1);
continue;
}
if size_reconciliation_entry_bytes(incoming) > MAX_SIZE_RECONCILIATION_BYTES_PER_BUCKET {
continue;
}
let mut entry = incoming.clone();
entry.first_seen = now;
entry.attempts = 1;
info.size_reconciliation.insert(entry.key.clone(), entry);
}
}
@@ -1188,27 +1209,24 @@ impl FolderScanner {
let scopes = std::mem::take(&mut self.pending_size_reconciliation_scopes);
let truncated = std::mem::replace(&mut self.pending_size_reconciliation_truncated, false);
for info in [&mut self.new_cache.info, &mut self.update_cache.info] {
if !truncated {
info.size_reconciliation.retain(|key, entry| {
!scopes.contains(&size_reconciliation_scope_key(&entry.bucket, &entry.object)) || current_keys.contains(key)
});
}
prune_size_reconciliation(info, now);
let info = &mut self.new_cache.info;
if !truncated {
info.size_reconciliation.retain(|key, entry| {
!scopes.contains(&size_reconciliation_scope_key(&entry.bucket, &entry.object)) || current_keys.contains(key)
});
}
prune_size_reconciliation(info, now);
}
fn record_scan_resume_hint(&mut self, folder: &str) {
self.new_cache.info.scan_resume_after = Some(folder.to_string());
self.update_cache.info.scan_resume_after = Some(folder.to_string());
let checkpoint = DataUsageScanCheckpoint::new(folder.to_string(), DataUsageScanCheckpointReason::Unknown);
global_metrics().record_scanner_checkpoint_set(
checkpoint.version,
checkpoint.resume_after.clone(),
checkpoint.reason.as_str(),
);
self.new_cache.info.scan_checkpoint = Some(checkpoint.clone());
self.update_cache.info.scan_checkpoint = Some(checkpoint);
self.new_cache.info.scan_checkpoint = Some(checkpoint);
}
fn record_completed_child(&mut self, folder: &str) {
@@ -1511,8 +1529,7 @@ impl FolderScanner {
self.record_scan_resume_hint_if_not_ancestor(&child_hash.key());
parent_entry.add_child(child_hash);
self.new_cache.replace_hashed(child_hash, parent, &child_entry);
self.update_cache.delete_recursive(child_hash);
self.update_cache.copy_with_children(&self.new_cache, child_hash, parent);
self.refresh_usage_subtree(child_hash, parent);
self.send_update().await;
}
}
@@ -1655,29 +1672,75 @@ impl FolderScanner {
self.data_usage_scanner_debug = debug;
}
/// Send update if enough time has passed
/// Should be called on a regular basis when the new_cache contains more recent total than previously.
/// May or may not send an update upstream.
fn should_send_update(&self) -> bool {
if self.updates.is_none() {
return false;
fn compact_completed_folder(&mut self, folder: &CachedFolder, hash: &DataUsageHash, entry: &DataUsageEntry) {
// Completed healthy subtrees can keep aggregate usage. Coverage gaps
// and failed-object debt still need child entries for precise resume
// and to avoid rereading healthy siblings on a retry.
let forward_sweep = self.old_cache.info.scan_progress.is_some();
if entry.compacted || self.new_cache.info.name == folder.name || (forward_sweep && self.coverage_gap) {
return;
}
let summary = if entry.children.is_empty() {
Cow::Borrowed(entry)
} else if let Some(flat) = self.new_cache.size_recursive(&hash.0) {
Cow::Owned(flat)
} else {
return;
};
if forward_sweep && summary.failed_objects != 0 {
return;
}
let should_compact = summary.objects < DATA_SCANNER_COMPACT_LEAST_OBJECT
|| entry.children.iter().all(|key| {
self.new_cache
.cache
.get(key)
.is_none_or(|child| child.children.is_empty() && child.objects <= 1)
});
if !should_compact {
return;
}
// Heal revisits also reach this path without a parent advancing
// coverage. Keep the endpoint addressable when its completed
// subtree is replaced by an aggregate.
if self.coverage_frontier.as_deref().is_some_and(|frontier| {
frontier
.strip_prefix(&folder.name)
.is_some_and(|suffix| suffix.starts_with('/'))
}) {
self.record_completed_child(&folder.name);
}
match summary {
Cow::Borrowed(_) => {
// The leaf is already cached; only its sampling mode changes.
if let Some(cached) = self.new_cache.cache.get_mut(&hash.0) {
cached.compacted = true;
}
}
Cow::Owned(mut flat) => {
flat.compacted = true;
self.new_cache.delete_recursive(hash);
self.new_cache.replace_hashed(hash, &folder.parent, &flat);
}
}
let elapsed = self.last_update.elapsed().unwrap_or(Duration::from_secs(0));
elapsed >= Duration::from_secs(60)
}
fn refresh_usage_subtree(&mut self, hash: &DataUsageHash, parent: &Option<DataUsageHash>) {
if let Some(updates) = &mut self.usage_updates {
updates.cache.delete_recursive(hash);
updates.cache.copy_with_children(&self.new_cache, hash, parent);
}
}
/// Send at most one aggregate per minute when a caller requested progress.
pub async fn send_update(&mut self) {
// Send at most an update every minute.
if !self.should_send_update() {
let Some(updates) = &mut self.usage_updates else { return };
if !updates.is_due() {
return;
}
if let Some(flat) = self.update_cache.size_recursive(&self.new_cache.info.name)
&& let Some(ref updates) = self.updates
{
// Try to send without blocking
if let Err(e) = updates.send(flat).await {
if let Some(flat) = updates.cache.size_recursive(&self.new_cache.info.name) {
if let Err(e) = updates.sender.send(flat).await {
error!(
target: "rustfs::scanner::folder",
event = EVENT_SCANNER_FOLDER_STATE,
@@ -1689,17 +1752,17 @@ impl FolderScanner {
"Scanner folder update send failed"
);
}
self.last_update = SystemTime::now();
updates.last_update = SystemTime::now();
}
}
async fn send_update_for_entry(&mut self, hash: &DataUsageHash, parent: &Option<DataUsageHash>, entry: &DataUsageEntry) {
if !self.should_send_update() {
return;
if let Some(updates) = &mut self.usage_updates
&& updates.is_due()
{
updates.cache.replace_hashed(hash, parent, entry);
self.send_update().await;
}
self.update_cache.replace_hashed(hash, parent, entry);
self.send_update().await;
}
async fn record_missing_erasure_metadata(
@@ -1838,8 +1901,7 @@ impl FolderScanner {
wait_for_scanner_io(&ctx, self.sleeper.sleep_folder()).await?;
}
let mut existing_folders: Vec<CachedFolder> = Vec::new();
let mut new_folders: Vec<CachedFolder> = Vec::new();
let mut queued_folders: Vec<QueuedFolder> = Vec::new();
let mut found_object_metadata = false;
let mut object_count: u64 = 0;
let yield_every_objects = scanner_yield_every_n_objects();
@@ -2147,12 +2209,21 @@ impl FolderScanner {
self.reuse_clean_root_child(&this, &h, into);
} else if exists {
abandoned_children.remove(&h.key());
existing_folders.push(this);
self.update_cache
.copy_with_children(&self.old_cache, &h, &Some(this_hash.clone()));
queued_folders.push(QueuedFolder {
folder: this,
source: FolderScanSource::Existing,
});
if let Some(updates) = &mut self.usage_updates {
updates
.cache
.copy_with_children(&self.old_cache, &h, &Some(this_hash.clone()));
}
} else {
abandoned_children.remove(&h.key());
new_folders.push(this);
queued_folders.push(QueuedFolder {
folder: this,
source: FolderScanSource::New,
});
}
continue;
}
@@ -2374,17 +2445,17 @@ impl FolderScanner {
// frontier. Keep the emergency compaction limit for large trees.
let should_compact = (self.new_cache.info.name != folder.name
&& self.old_cache.info.scan_progress.is_none()
&& existing_folders.len() + new_folders.len() >= DATA_SCANNER_COMPACT_AT_FOLDERS)
|| existing_folders.len() + new_folders.len() >= DATA_SCANNER_FORCE_COMPACT_AT_FOLDERS;
&& queued_folders.len() >= DATA_SCANNER_COMPACT_AT_FOLDERS)
|| queued_folders.len() >= DATA_SCANNER_FORCE_COMPACT_AT_FOLDERS;
let total_folders = existing_folders.len() + new_folders.len();
let total_folders = queued_folders.len();
self.alert_excessive_folders(&folder.name, total_folders);
if !into.compacted && should_compact {
into.compacted = true;
new_folders.append(&mut existing_folders);
existing_folders.clear();
for queued in &mut queued_folders {
queued.source = FolderScanSource::New;
}
if self.data_usage_scanner_debug {
debug!(
@@ -2393,20 +2464,13 @@ impl FolderScanner {
component = LOG_COMPONENT_SCANNER,
subsystem = LOG_SUBSYSTEM_FOLDER,
folder = %folder.name,
entry_count = new_folders.len(),
entry_count = queued_folders.len(),
state = "preemptive_compaction",
"Scanner folder switched to compacted mode"
);
}
}
if !into.compacted {
for folder_item in &existing_folders {
let h = hash_path(&folder_item.name);
self.update_cache.copy_with_children(&self.old_cache, &h, &folder_item.parent);
}
}
let is_scan_root = folder.name == self.old_cache.info.name;
let scan_checkpoint = self.old_cache.info.scan_checkpoint.as_ref();
let checkpoint_resume_after = scan_checkpoint.and_then(|checkpoint| {
@@ -2430,15 +2494,6 @@ impl FolderScanner {
.and_then(|resume_after| folder_resume_match(&folder.name, resume_after))
.is_some_and(|resume_match| matches!(resume_match, FolderResumeMatch::Descendant));
let scan_resume_after = checkpoint_resume_after.or(self.old_cache.info.scan_resume_after.as_deref());
let mut queued_folders = Vec::with_capacity(new_folders.len() + existing_folders.len());
queued_folders.extend(new_folders.into_iter().map(|folder| QueuedFolder {
folder,
source: FolderScanSource::New,
}));
queued_folders.extend(existing_folders.into_iter().map(|folder| QueuedFolder {
folder,
source: FolderScanSource::Existing,
}));
let has_queued_folders = !queued_folders.is_empty();
let forward_sweep = self.old_cache.info.scan_progress.is_some();
let resume_order = if forward_sweep {
@@ -2479,19 +2534,21 @@ impl FolderScanner {
match queued_folder.source {
FolderScanSource::New => {
// Add new folders to the update tree so totals update for these.
if !into.compacted {
if !into.compacted
&& let Some(updates) = &mut self.usage_updates
{
let mut found_any = false;
let mut parent = this_hash.clone();
let update_cache_name_hash = hash_path(&self.update_cache.info.name);
let update_root_hash = hash_path(&updates.cache.info.name);
while parent != update_cache_name_hash {
while parent != update_root_hash {
let parent_key = parent.key();
let e = self.update_cache.find(&parent_key);
let e = updates.cache.find(&parent_key);
if e.is_none_or(|v| v.compacted) {
found_any = true;
break;
}
if let Some(next) = self.update_cache.search_parent(&parent) {
if let Some(next) = updates.cache.search_parent(&parent) {
parent = next;
} else {
found_any = true;
@@ -2500,7 +2557,8 @@ impl FolderScanner {
}
if !found_any {
// Add non-compacted empty entry.
self.update_cache
updates
.cache
.replace_hashed(&h, &Some(this_hash.clone()), &DataUsageEntry::default());
}
}
@@ -2574,13 +2632,17 @@ impl FolderScanner {
};
if outcome == FolderScanOutcome::Vanished {
self.new_cache.delete_recursive(&h);
self.update_cache.delete_recursive(&h);
if let Some(updates) = &mut self.usage_updates {
updates.cache.delete_recursive(&h);
}
into.children.remove(&h.key());
continue;
}
if outcome == FolderScanOutcome::ErasureData {
self.new_cache.delete_recursive(&h);
self.update_cache.delete_recursive(&h);
if let Some(updates) = &mut self.usage_updates {
updates.cache.delete_recursive(&h);
}
into.children.remove(&h.key());
// Earlier namespace siblings may have completed, but none
// can certify coverage of this unresolved parent object.
@@ -2600,23 +2662,13 @@ impl FolderScanner {
self.send_update_for_entry(&this_hash, &folder.parent, into).await;
tokio::task::yield_now().await;
} else {
tokio::task::yield_now().await;
into.add_child(&h);
self.record_completed_child(&folder_item.name);
tokio::task::yield_now().await;
self.maybe_send_checkpoint(&folder, &this_hash, into);
self.update_cache.delete_recursive(&h);
self.update_cache.copy_with_children(&self.new_cache, &h, &folder_item.parent);
self.refresh_usage_subtree(&h, &folder_item.parent);
self.send_update().await;
}
if queued_folder.source == FolderScanSource::New
&& !into.compacted
&& self.update_cache.find(&this_hash.key()).is_some_and(|v| !v.compacted)
{
self.update_cache.delete_recursive(&h);
self.update_cache
.copy_with_children(&self.new_cache, &h, &Some(this_hash.clone()));
}
}
// Scan for healing
@@ -3013,8 +3065,7 @@ impl FolderScanner {
into.add_child(&h);
// We scanned a folder, optionally send update.
self.update_cache.delete_recursive(&h);
self.update_cache.copy_with_children(&self.new_cache, &h, &folder_item.parent);
self.refresh_usage_subtree(&h, &folder_item.parent);
self.send_update().await;
self.maybe_send_checkpoint(&folder, &this_hash, into);
}
@@ -3028,36 +3079,7 @@ impl FolderScanner {
self.new_cache.replace_hashed(&this_hash, &folder.parent, into);
}
// Keep independently accounted children while the sweep cursor may
// reference them; the hard cardinality compaction below still applies.
if !into.compacted
&& self.old_cache.info.scan_progress.is_none()
&& self.new_cache.info.name != folder.name
&& let Some(mut flat) = self.new_cache.size_recursive(&this_hash.key())
{
flat.compacted = true;
let mut should_compact = false;
if flat.objects < DATA_SCANNER_COMPACT_LEAST_OBJECT {
should_compact = true;
} else {
// Compact if we only have objects as children...
should_compact = true;
for k in &into.children {
if let Some(v) = self.new_cache.cache.get(k)
&& (!v.children.is_empty() || v.objects > 1)
{
should_compact = false;
break;
}
}
}
if should_compact {
self.new_cache.delete_recursive(&this_hash);
self.new_cache.replace_hashed(&this_hash, &folder.parent, &flat);
}
}
self.compact_completed_folder(&folder, &this_hash, into);
// Compact if too many children...
if !into.compacted {
@@ -3070,12 +3092,13 @@ impl FolderScanner {
done_compact();
}
if self.update_cache.cache.contains_key(&this_hash.key()) && !was_compacted {
// Replace if existed before.
if let Some(flat) = self.new_cache.size_recursive(&this_hash.key()) {
self.update_cache.delete_recursive(&this_hash);
self.update_cache.replace_hashed(&this_hash, &folder.parent, &flat);
}
if !was_compacted
&& let Some(updates) = &mut self.usage_updates
&& updates.cache.cache.contains_key(&this_hash.key())
&& let Some(flat) = self.new_cache.size_recursive(&this_hash.key())
{
updates.cache.delete_recursive(&this_hash);
updates.cache.replace_hashed(&this_hash, &folder.parent, &flat);
}
self.finish_size_reconciliation_batch();
@@ -3173,10 +3196,7 @@ pub(crate) async fn scan_data_folder_scoped(
info: cache.info.clone(),
..Default::default()
};
let update_cache = DataUsageCache {
info: cache.info.clone(),
..Default::default()
};
let usage_updates = updates.map(|sender| ScannerUsageUpdates::new(sender, bucket_name.clone()));
let resume_failed_paths = if resume_frontier.is_some() {
failed_scan_paths(&cache)
} else {
@@ -3187,7 +3207,6 @@ pub(crate) async fn scan_data_folder_scoped(
root: base_path,
old_cache: cache,
new_cache,
update_cache,
data_usage_scanner_debug: false,
heal_object_select,
scan_mode,
@@ -3200,11 +3219,10 @@ pub(crate) async fn scan_data_folder_scoped(
sleeper,
disks,
disks_quorum,
updates,
usage_updates,
checkpoint_tx,
checkpoint_ancestors: Vec::new(),
checkpoint_depth: 0,
last_update: SystemTime::UNIX_EPOCH,
checkpoint_objects: 0,
object_metadata_visits,
last_checkpoint_objects: 0,
@@ -3220,11 +3238,7 @@ pub(crate) async fn scan_data_folder_scoped(
resume_frontier,
resume_failed_paths,
coverage_gap: false,
pending_heal_sync_deferred: false,
pending_heal_batch_dirty: false,
raw_enumeration_progress: Vec::new(),
#[cfg(test)]
pending_heal_sync_count: 0,
pending_size_reconciliation_keys: HashSet::new(),
pending_size_reconciliation_scopes: HashSet::new(),
pending_size_reconciliation_truncated: false,
@@ -3234,7 +3248,6 @@ pub(crate) async fn scan_data_folder_scoped(
let now = FolderScanner::now_secs();
prune_size_reconciliation(&mut scanner.new_cache.info, now);
prune_size_reconciliation(&mut scanner.update_cache.info, now);
// Check if context is cancelled
if ctx.is_cancelled() {
+26 -66
View File
@@ -18,28 +18,6 @@ use super::*;
const PENDING_HEAL_RETRY_BASE_SECS: u64 = 15 * 60;
const PENDING_HEAL_RETRY_CAP_SECS: u64 = 6 * 60 * 60;
pub(super) struct PendingHealSyncBatch<'a> {
pub(super) scanner: &'a mut FolderScanner,
}
impl<'a> PendingHealSyncBatch<'a> {
pub(super) fn new(scanner: &'a mut FolderScanner) -> Self {
scanner.pending_heal_sync_deferred = true;
scanner.pending_heal_batch_dirty = false;
Self { scanner }
}
}
impl Drop for PendingHealSyncBatch<'_> {
fn drop(&mut self) {
self.scanner.pending_heal_sync_deferred = false;
if self.scanner.pending_heal_batch_dirty {
self.scanner.pending_heal_batch_dirty = false;
self.scanner.sync_pending_heals();
}
}
}
pub(super) fn record_pending_heal_attempt(entry: &mut PendingScannerHeal, now: u64) {
entry.last_attempt = now;
entry.attempts = entry.attempts.saturating_add(1);
@@ -52,19 +30,6 @@ pub(super) fn observe_pending_heal_admission(entry: &mut PendingScannerHeal, res
}
impl FolderScanner {
pub(super) fn sync_pending_heals(&mut self) {
self.pending_heals_changed = true;
if self.pending_heal_sync_deferred {
self.pending_heal_batch_dirty = true;
return;
}
self.update_cache.info.pending_heals = self.new_cache.info.pending_heals.clone();
#[cfg(test)]
{
self.pending_heal_sync_count += 1;
}
}
pub(super) fn clear_pending_scanner_heal(
&mut self,
kind: PendingScannerHealKind,
@@ -78,7 +43,7 @@ impl FolderScanner {
.pending_heals
.retain(|entry| !pending_scanner_heal_matches(entry, kind, bucket, object, version_id));
if self.new_cache.info.pending_heals.len() != before {
self.sync_pending_heals();
self.pending_heals_changed = true;
}
}
@@ -100,7 +65,7 @@ impl FolderScanner {
.find(|entry| pending_scanner_heal_matches(entry, kind, bucket, object, version_id))
{
observe_pending_heal_admission(entry, result);
self.sync_pending_heals();
self.pending_heals_changed = true;
return;
}
@@ -116,9 +81,8 @@ impl FolderScanner {
last_admission_result: result.result_label().to_string(),
last_admission_reason: result.reason_label().to_string(),
});
if self.prune_pending_scanner_heal_capacity() == 0 {
self.sync_pending_heals();
}
self.pending_heals_changed = true;
self.prune_pending_scanner_heal_capacity();
}
/// Preserve the discovery reason when a candidate could not be admitted
@@ -140,7 +104,7 @@ impl FolderScanner {
.find(|entry| pending_scanner_heal_matches(entry, kind, bucket, object, version_id))
{
entry.last_admission_reason = reason.to_string();
self.sync_pending_heals();
self.pending_heals_changed = true;
}
}
@@ -169,22 +133,22 @@ impl FolderScanner {
state = "pending_heal_expired",
"Scanner pending heal ledger expired old entries"
);
self.sync_pending_heals();
self.pending_heals_changed = true;
}
self.prune_pending_scanner_heal_capacity();
}
fn prune_pending_scanner_heal_capacity(&mut self) -> usize {
fn prune_pending_scanner_heal_capacity(&mut self) {
let len = self.new_cache.info.pending_heals.len();
if len <= MAX_PENDING_SCANNER_HEALS_PER_BUCKET {
return 0;
return;
}
sort_pending_scanner_heals_for_retry(&mut self.new_cache.info.pending_heals);
let remove_count = len.saturating_sub(MAX_PENDING_SCANNER_HEALS_PER_BUCKET);
self.new_cache.info.pending_heals.drain(..remove_count);
self.sync_pending_heals();
self.pending_heals_changed = true;
counter!(
METRIC_SCANNER_PENDING_HEAL_PRUNE_TOTAL,
"bucket" => self.new_cache.info.name.clone()
@@ -201,7 +165,6 @@ impl FolderScanner {
state = "pending_heal_pruned",
"Scanner pending heal ledger pruned oldest entries"
);
remove_count
}
pub(super) fn update_pending_scanner_heal_after_admission(
@@ -233,20 +196,18 @@ impl FolderScanner {
.find(|entry| pending_scanner_heal_matches(entry, kind, bucket, object, version_id))
{
observe_pending_heal_admission(entry, result);
self.sync_pending_heals();
self.pending_heals_changed = true;
}
}
}
}
pub(super) async fn retry_pending_scanner_heals(&mut self) -> Result<(), ScannerError> {
let batch = PendingHealSyncBatch::new(self);
let scanner = &mut *batch.scanner;
if !scanner.should_heal().await {
if !self.should_heal().await {
return Ok(());
}
let bucket = scanner.new_cache.info.name.clone();
let bucket = self.new_cache.info.name.clone();
// Legacy notices cannot bind a verified disposition to the current
// incarnation, kind, set scope and responsibility generation.
let repaired = rustfs_common::mrf_channel::take_mrf_repaired_events_for(&bucket);
@@ -254,14 +215,14 @@ impl FolderScanner {
counter!("rustfs_scanner_unverified_repair_notices_total")
.increment(u64::try_from(repaired.len()).unwrap_or(u64::MAX));
}
scanner.prune_pending_scanner_heals();
for pending in pending_scanner_heal_retry_candidates(&scanner.new_cache.info.pending_heals, &bucket) {
if !scanner.should_heal().await {
self.prune_pending_scanner_heals();
for pending in pending_scanner_heal_retry_candidates(&self.new_cache.info.pending_heals, &bucket) {
if !self.should_heal().await {
break;
}
let Some(request) = build_pending_scanner_heal_request(&pending) else {
scanner.clear_pending_scanner_heal(pending.kind, &pending.bucket, None, pending.version_id.as_deref());
self.clear_pending_scanner_heal(pending.kind, &pending.bucket, None, pending.version_id.as_deref());
counter!(
METRIC_SCANNER_PENDING_HEAL_MALFORMED_TOTAL,
"bucket" => pending.bucket.clone(),
@@ -280,7 +241,7 @@ impl FolderScanner {
continue;
};
if let Some(entry) = scanner.new_cache.info.pending_heals.iter_mut().find(|entry| {
if let Some(entry) = self.new_cache.info.pending_heals.iter_mut().find(|entry| {
pending_scanner_heal_matches(
entry,
pending.kind,
@@ -290,17 +251,16 @@ impl FolderScanner {
)
}) {
record_pending_heal_attempt(entry, Self::now_secs());
scanner.sync_pending_heals();
self.pending_heals_changed = true;
}
scanner
.send_required_scanner_heal_request(
pending.kind,
pending.bucket.clone(),
pending.object.clone(),
pending.version_id.clone(),
request,
)
.await?;
self.send_required_scanner_heal_request(
pending.kind,
pending.bucket.clone(),
pending.object.clone(),
pending.version_id.clone(),
request,
)
.await?;
}
Ok(())
+12 -33
View File
@@ -31,6 +31,7 @@ pub(super) mod checkpoint_fixture;
pub(super) mod enumeration_restart;
mod incremental_enumeration;
mod metadata_visits;
mod usage_updates;
/// Reset the process-global alert cooldown map; test-only.
fn reset_alert_cooldowns() {
@@ -353,7 +354,6 @@ async fn build_test_scanner() -> (FolderScanner, std::path::PathBuf) {
root: temp_dir.to_string_lossy().to_string(),
old_cache: DataUsageCache::default(),
new_cache: DataUsageCache::default(),
update_cache: DataUsageCache::default(),
data_usage_scanner_debug: false,
heal_object_select: 0,
scan_mode: HealScanMode::Normal,
@@ -366,11 +366,10 @@ async fn build_test_scanner() -> (FolderScanner, std::path::PathBuf) {
sleeper: SCANNER_SLEEPER.clone(),
disks: Vec::new(),
disks_quorum: 0,
updates: None,
usage_updates: None,
checkpoint_tx: None,
checkpoint_ancestors: Vec::new(),
checkpoint_depth: 0,
last_update: SystemTime::UNIX_EPOCH,
checkpoint_objects: 0,
object_metadata_visits: metrics::Counter::noop(),
last_checkpoint_objects: 0,
@@ -391,9 +390,6 @@ async fn build_test_scanner() -> (FolderScanner, std::path::PathBuf) {
resume_failed_paths: Vec::new(),
coverage_gap: false,
raw_enumeration_progress: Vec::new(),
pending_heal_sync_deferred: false,
pending_heal_batch_dirty: false,
pending_heal_sync_count: 0,
pending_size_reconciliation_keys: HashSet::new(),
pending_size_reconciliation_scopes: HashSet::new(),
pending_size_reconciliation_truncated: false,
@@ -710,7 +706,6 @@ async fn malformed_size_reconciliation_replays_after_restart() {
scanner.finish_size_reconciliation_batch();
assert_eq!(scanner.new_cache.info.size_reconciliation.len(), 1);
assert_eq!(scanner.update_cache.info.size_reconciliation.len(), 1);
assert_eq!(scanner.new_cache.info.size_reconciliation[&entry.key].attempts, 2);
let encoded = rmp_serde::to_vec_named(&scanner.new_cache.info).expect("size ledger should encode");
@@ -724,7 +719,6 @@ async fn malformed_size_reconciliation_replays_after_restart() {
scanner.apply_size_reconciliation(&resolved);
scanner.finish_size_reconciliation_batch();
assert!(scanner.new_cache.info.size_reconciliation.is_empty());
assert!(scanner.update_cache.info.size_reconciliation.is_empty());
}
#[tokio::test]
@@ -1532,7 +1526,6 @@ async fn mrf_ownership_legacy_notices_preserve_pending_entries() {
let (mut scanner, temp_dir) = build_test_scanner().await;
let _guard = TestGuard::new(u64::MAX, usize::MAX, &mut scanner, temp_dir);
scanner.new_cache.info.name = "bucket".to_string();
scanner.update_cache.info.name = "bucket".to_string();
scanner.heal_object_select = 1;
let version = Uuid::new_v4().to_string();
@@ -1679,7 +1672,6 @@ async fn test_pending_heal_prune_expires_stale_entries() {
let (mut scanner, temp_dir) = build_test_scanner().await;
let _guard = TestGuard::new(u64::MAX, usize::MAX, &mut scanner, temp_dir);
scanner.new_cache.info.name = "bucket".to_string();
scanner.update_cache.info.name = "bucket".to_string();
let mut stale = pending_heal(PendingScannerHealKind::Object, "bucket", Some("stale"), None, 1, 1);
stale.first_seen = FolderScanner::now_secs().saturating_sub(MAX_PENDING_SCANNER_HEAL_AGE_SECS + 1);
@@ -1690,7 +1682,6 @@ async fn test_pending_heal_prune_expires_stale_entries() {
assert_eq!(scanner.new_cache.info.pending_heals.len(), 1);
assert_eq!(scanner.new_cache.info.pending_heals[0].object.as_deref(), Some("fresh"));
assert_eq!(scanner.update_cache.info.pending_heals, scanner.new_cache.info.pending_heals);
assert!(scanner.pending_heals_changed);
}
@@ -1699,7 +1690,6 @@ async fn test_pending_heal_update_keeps_stale_entry_until_retry_prune() {
let (mut scanner, temp_dir) = build_test_scanner().await;
let _guard = TestGuard::new(u64::MAX, usize::MAX, &mut scanner, temp_dir);
scanner.new_cache.info.name = "bucket".to_string();
scanner.update_cache.info.name = "bucket".to_string();
let mut stale = pending_heal(PendingScannerHealKind::Object, "bucket", Some("object"), None, 1, 1);
stale.first_seen = FolderScanner::now_secs().saturating_sub(MAX_PENDING_SCANNER_HEAL_AGE_SECS + 1);
@@ -1717,7 +1707,6 @@ async fn test_pending_heal_update_keeps_stale_entry_until_retry_prune() {
assert_eq!(scanner.new_cache.info.pending_heals.len(), 1);
assert_eq!(scanner.new_cache.info.pending_heals[0].attempts, 1);
assert_eq!(scanner.new_cache.info.pending_heals[0].object.as_deref(), Some("object"));
assert_eq!(scanner.update_cache.info.pending_heals, scanner.new_cache.info.pending_heals);
}
#[tokio::test]
@@ -1725,7 +1714,6 @@ async fn test_pending_heal_queue_full_deduplicates_object_entry() {
let (mut scanner, temp_dir) = build_test_scanner().await;
let _guard = TestGuard::new(u64::MAX, usize::MAX, &mut scanner, temp_dir);
scanner.new_cache.info.name = "bucket".to_string();
scanner.update_cache.info.name = "bucket".to_string();
scanner.update_pending_scanner_heal_after_admission(
PendingScannerHealKind::Object,
@@ -1751,7 +1739,6 @@ async fn test_pending_heal_queue_full_deduplicates_object_entry() {
assert_eq!(pending.attempts, 1);
assert_eq!(pending.last_admission_result, "dropped");
assert_eq!(pending.last_admission_reason, "queue_full");
assert_eq!(scanner.update_cache.info.pending_heals, scanner.new_cache.info.pending_heals);
assert!(scanner.pending_heals_changed);
}
@@ -1877,7 +1864,6 @@ async fn test_scan_folder_keeps_old_cache_bounded_by_loaded_entries() {
let _guard = TestGuard::new(60, 100, &mut scanner, temp_dir.clone());
scanner.old_cache.info.name = "bucket".to_string();
scanner.new_cache.info.name = "bucket".to_string();
scanner.update_cache.info.name = "bucket".to_string();
scanner.is_erasure_mode = true;
for directory in 0..16 {
for object in 0..32 {
@@ -2297,7 +2283,6 @@ async fn test_scan_folder_skips_unreadable_child_directory() {
scanner.old_cache.info.name = "bucket".to_string();
scanner.new_cache.info.name = "bucket".to_string();
scanner.update_cache.info.name = "bucket".to_string();
let folder = CachedFolder {
name: "bucket".to_string(),
@@ -2325,7 +2310,6 @@ async fn test_scan_folder_treats_child_removed_before_open_as_empty() {
scanner.old_cache.info.name = "bucket".to_string();
scanner.new_cache.info.name = "bucket".to_string();
scanner.update_cache.info.name = "bucket".to_string();
let folder = CachedFolder {
name: "bucket/gone".to_string(),
@@ -2358,7 +2342,6 @@ async fn scan_folder_charges_each_enumerated_entry_to_the_disk_budget() {
scanner.sleeper = SCANNER_SLEEPER.for_disk(&temp_dir);
scanner.old_cache.info.name = "bucket".to_string();
scanner.new_cache.info.name = "bucket".to_string();
scanner.update_cache.info.name = "bucket".to_string();
let folder = CachedFolder {
name: "bucket".to_string(),
@@ -2485,7 +2468,6 @@ async fn test_scan_folder_missing_bucket_root_still_reports_not_found() {
scanner.old_cache.info.name = "bucket".to_string();
scanner.new_cache.info.name = "bucket".to_string();
scanner.update_cache.info.name = "bucket".to_string();
let folder = CachedFolder {
name: "bucket".to_string(),
@@ -2630,7 +2612,6 @@ async fn test_scan_folder_xl_meta_named_directory_uses_namespace_descent() {
scanner.old_cache.info.name = "bucket".to_string();
scanner.new_cache.info.name = "bucket".to_string();
scanner.update_cache.info.name = "bucket".to_string();
scanner.is_erasure_mode = true;
let parent = CancellationToken::new();
@@ -2690,7 +2671,6 @@ async fn scan_corrupt_metadata_with_captured_logs(logs: &CapturedLogs) {
scanner.old_cache.info.name = "bucket".to_string();
scanner.new_cache.info.name = "bucket".to_string();
scanner.update_cache.info.name = "bucket".to_string();
scanner.is_erasure_mode = true;
let parent = CancellationToken::new();
@@ -2841,7 +2821,6 @@ async fn test_scan_folder_missing_xl_meta_stops_erasure_data_dir_descent() {
scanner.old_cache.info.name = "bucket".to_string();
scanner.new_cache.info.name = "bucket".to_string();
scanner.update_cache.info.name = "bucket".to_string();
scanner.is_erasure_mode = true;
scanner.sleeper = DynamicSleeper::new(rustfs_config::ScannerSpeed::Fastest).for_disk_with_budget_for_tests(
&temp_dir,
@@ -2934,8 +2913,9 @@ async fn test_scan_folder_uuid_namespace_part_name_directory_is_not_data_dir() {
scanner.old_cache.info.name = "bucket".to_string();
scanner.new_cache.info.name = "bucket".to_string();
scanner.update_cache.info.name = "bucket".to_string();
scanner.is_erasure_mode = true;
let (sender, _receiver) = mpsc::channel(1);
scanner.usage_updates = Some(ScannerUsageUpdates::new(sender, "bucket".to_string()));
let namespace_hash = hash_path(&format!("bucket/{namespace_name}"));
scanner.old_cache.replace_hashed(
&namespace_hash,
@@ -2970,7 +2950,13 @@ async fn test_scan_folder_uuid_namespace_part_name_directory_is_not_data_dir() {
assert_eq!(budget.reason(), Some(crate::scanner_budget::ScannerCycleBudgetReason::Directories));
assert!(scanner.new_cache.info.failed_objects.is_empty());
assert!(
scanner.update_cache.find(&namespace_hash.key()).is_some(),
scanner
.usage_updates
.as_ref()
.expect("requested progress")
.cache
.find(&namespace_hash.key())
.is_some(),
"an existing UUID namespace must retain its cached subtree"
);
}
@@ -2991,7 +2977,6 @@ async fn test_scan_folder_non_erasure_metadata_keeps_namespace_descent() {
scanner.old_cache.info.name = "bucket".to_string();
scanner.new_cache.info.name = "bucket".to_string();
scanner.update_cache.info.name = "bucket".to_string();
scanner.is_erasure_mode = false;
let parent = CancellationToken::new();
@@ -3032,7 +3017,6 @@ async fn scoped_root_scan_reuses_clean_top_level_entries_and_rescans_dirty_entri
scanner.old_cache.info.name = "bucket".to_string();
scanner.new_cache.info.name = "bucket".to_string();
scanner.update_cache.info.name = "bucket".to_string();
scanner.old_cache.replace("bucket", "", DataUsageEntry::default());
scanner.old_cache.replace(
"bucket/clean",
@@ -3091,7 +3075,6 @@ async fn scan_hot_cold_segment_fixture(scoped: bool) -> (DataUsageEntry, Vec<Str
scanner.old_cache.info.name = "bucket".to_string();
scanner.new_cache.info.name = "bucket".to_string();
scanner.update_cache.info.name = "bucket".to_string();
if scoped {
scanner.old_cache.replace("bucket", "", DataUsageEntry::default());
scanner.old_cache.replace(
@@ -3177,7 +3160,6 @@ async fn scoped_root_scan_preserves_erasure_health_walks() {
scanner.is_erasure_mode = true;
scanner.old_cache.info.name = "bucket".to_string();
scanner.new_cache.info.name = "bucket".to_string();
scanner.update_cache.info.name = "bucket".to_string();
scanner.old_cache.replace("bucket", "", DataUsageEntry::default());
scanner.old_cache.replace(
"bucket/clean",
@@ -3221,11 +3203,9 @@ async fn test_scan_folder_compacted_parent_sends_partial_update() {
scanner.old_cache.info.name = "bucket".to_string();
scanner.new_cache.info.name = "bucket".to_string();
scanner.update_cache.info.name = "bucket".to_string();
scanner.last_update = SystemTime::UNIX_EPOCH;
let (tx, mut rx) = mpsc::channel(1);
scanner.updates = Some(tx);
scanner.usage_updates = Some(ScannerUsageUpdates::new(tx, "bucket".to_string()));
let folder = CachedFolder {
name: "bucket".to_string(),
@@ -4226,7 +4206,6 @@ async fn test_scan_folder_ignores_symlinked_child_directory() {
scanner.old_cache.info.name = "bucket".to_string();
scanner.new_cache.info.name = "bucket".to_string();
scanner.update_cache.info.name = "bucket".to_string();
let folder = CachedFolder {
name: "bucket".to_string(),
@@ -20,7 +20,9 @@ use crate::{DataUsageCacheSource, DataUsageScanPlanDigest};
use std::io::Cursor;
use tokio::io::AsyncReadExt;
mod completed_compaction;
mod deep_compacted;
mod memory_budget;
mod periodic_snapshot;
mod segment_observation;
@@ -783,7 +785,13 @@ async fn check_complete_sampling_resumption(resume_mode: HealScanMode) {
matches!(state, crate::scanner_io::DataUsageCacheScanState::Prepared { .. }),
"Normal complete is not Deep Current"
);
cache.prepare_bucket_checkpoint("bucket", 12, 7, SOURCE, PLAN, identity);
// The first sweep already compacts this prefix. Select its sampling
// cycle so the bounded scan exercises interruption rather than reuse.
let sampling_cycles = data_usage_update_dir_cycles();
let sampling_cycle = (12..12 + sampling_cycles)
.find(|cycle| hash_path("bucket/prefix").mod_(*cycle, sampling_cycles))
.expect("one prefix sampling cycle per rotation");
cache.prepare_bucket_checkpoint("bucket", u64::from(sampling_cycle), 7, SOURCE, PLAN, identity);
assert!(cache.info.scan_progress.is_none(), "complete unchanged baseline uses existing sampling");
let parent = CancellationToken::new();
let budget = ScannerCycleBudget::new_with_progress_tracking(
@@ -835,7 +843,7 @@ async fn check_complete_sampling_resumption(resume_mode: HealScanMode) {
.load_with_revisions(store.clone(), CACHE_NAME)
.await
.expect("reload partial each recovery round");
loaded.prepare_bucket_checkpoint("bucket", 12, 7, SOURCE, PLAN, resumed_identity);
loaded.prepare_bucket_checkpoint("bucket", u64::from(sampling_cycle), 7, SOURCE, PLAN, resumed_identity);
assert!(loaded.info.scan_progress.is_some(), "sampling partial must enter forward validation");
loaded.info.skip_healing = true;
let parent = CancellationToken::new();
@@ -0,0 +1,246 @@
// Copyright 2026 RustFS Team
// Licensed under the Apache License, Version 2.0.
use super::*;
#[tokio::test]
async fn completed_leaf_compaction_reuses_cached_storage() {
let (mut scanner, root) = build_test_scanner().await;
let _cleanup = TestGuard { temp_dir: Some(root) };
scanner.old_cache = bound_checkpoint().0;
let folder = CachedFolder {
name: "bucket/object".to_string(),
parent: Some(hash_path("bucket")),
object_heal_prob_div: 1,
};
let hash = hash_path(&folder.name);
for (coverage_gap, failed_objects, should_compact) in [(false, 0, true), (true, 0, false), (false, 1, false)] {
scanner.coverage_gap = coverage_gap;
scanner.new_cache = DataUsageCache {
info: scanner.old_cache.info.clone(),
..Default::default()
};
let mut target = String::with_capacity(4096);
target.push_str("arn:test:replica");
let allocation = target.as_ptr();
let capacity = target.capacity();
let mut leaf = DataUsageEntry {
size: 2048,
objects: 1,
versions: 3,
failed_objects,
replication_stats: Some(rustfs_data_usage::ReplicationAllStats {
targets: HashMap::from([(target, rustfs_data_usage::ReplicationTargetUsage::default())]),
..Default::default()
}),
all_tier_stats: Some(rustfs_data_usage::AllTierStats::new()),
..Default::default()
};
leaf.obj_sizes.add(2048);
leaf.obj_versions.add(3);
let accumulator = leaf.clone();
let mut expected = serde_json::to_value(&leaf).expect("leaf metadata");
expected["compacted"] = serde_json::json!(should_compact);
scanner.new_cache.replace(&folder.name, "bucket", leaf);
scanner.compact_completed_folder(&folder, &hash, &accumulator);
let cached = scanner.new_cache.find(&folder.name).expect("retained leaf");
assert_eq!(serde_json::to_value(cached).expect("compacted metadata"), expected);
let target = cached
.replication_stats
.as_ref()
.expect("retained replication statistics")
.targets
.keys()
.next()
.expect("retained replication target");
assert_eq!(target.capacity(), capacity, "leaf compaction must preserve owned allocations");
assert_eq!(target.as_ptr(), allocation);
assert!(!accumulator.compacted, "cache compaction must not change the caller's accounting mode");
}
}
async fn scan(
disk: &Arc<Disk>,
cache: DataUsageCache,
mode: HealScanMode,
max_objects: u64,
) -> (ScannerDiskScanOutcome, Arc<ScannerCycleBudget>) {
let budget = ScannerCycleBudget::new_with_progress_tracking(
&CancellationToken::new(),
ScannerCycleBudgetConfig {
max_objects: Some(max_objects),
..Default::default()
},
);
let outcome = disk
.clone()
.nsscanner_disk(budget.token(), budget.clone(), vec![disk.clone()], cache, None, scan_options(mode))
.await
.expect("scan checkpoint fixture through the real disk path");
(outcome, budget)
}
async fn assert_completed_compaction_resumes(mode: HealScanMode) {
let (scanner, root) = build_test_scanner().await;
let _cleanup = TestGuard {
temp_dir: Some(root.clone()),
};
for (prefix, size) in [("a", 2), ("b", 3), ("c", 5)] {
for object in 0..4 {
write_checkpoint_object(&root, &format!("nested/{prefix}/{object:04}"), &[(None, size)]).await;
}
}
let identity = crate::DataUsageScanIdentity {
scan_mode: mode,
tier_registry_generation: crate::runtime_tier_registry_for_cycle(11, 7).await.generation,
..bound_checkpoint().1
};
let mut cache = DataUsageCache::default();
cache.prepare_bucket_checkpoint("bucket", 11, 7, SOURCE, PLAN, identity);
cache.info.skip_healing = true;
let (outcome, budget) = scan(&scanner.local_disk, cache, mode, 6).await;
assert_eq!(budget.progress().0, 6);
assert_eq!(budget.reason(), Some(ScannerCycleBudgetReason::Objects));
let ScannerDiskScanOutcome::Partial(cache) = outcome else {
panic!("the object budget must interrupt the second prefix")
};
let completed = cache.find("bucket/nested/a").expect("completed prefix remains addressable");
assert!(completed.compacted, "a completed prefix must release leaves during the first sweep");
assert!(completed.children.is_empty());
assert_eq!((completed.objects, completed.size), (4, 8));
assert!(
cache.find("bucket/nested/a/0000").is_none(),
"completed leaves must not remain in the cache"
);
for prefix in ["bucket", "bucket/nested", "bucket/nested/b"] {
assert!(!cache.find(prefix).expect("unfinished ancestor retained").compacted);
}
assert!(
cache.find("bucket/nested/b/0000").is_some(),
"unfinished prefix retains its resume boundary"
);
let partial = cache
.checked_flatten_complete_scope("bucket")
.expect("partial graph has no orphan entries");
assert_eq!((partial.objects, partial.versions, partial.size), (6, 0, 14));
assert_eq!(cache.validated_scan_frontier(), Some("bucket/nested/b/0000"));
let store = FixtureStore::new();
let revisions = DataUsageCache::default()
.load_with_revisions(store.clone(), CACHE_NAME)
.await
.expect("initial fixture revisions");
cache
.save_with_revisions_for_epoch(store.clone(), CACHE_NAME, &revisions, 0)
.await
.expect("persist compacted partial cache through the production codec and CAS path");
let mut reloaded = store.strict_load().await;
assert_eq!(reloaded.info.scan_coverage_receipt, cache.info.scan_coverage_receipt);
assert_eq!(reloaded.validated_scan_frontier(), cache.validated_scan_frontier());
assert_eq!(
reloaded.prepare_bucket_checkpoint("bucket", 12, 8, SOURCE, PLAN, identity),
crate::DataUsageCachePrepareOutcome::Reused,
"a later cycle and leader must adopt compacted completed coverage"
);
let (outcome, budget) = scan(&scanner.local_disk, reloaded, mode, 13).await;
assert_eq!(
budget.progress().0,
7,
"only the interrupted boundary object and six unseen objects may be read on resume"
);
let ScannerDiskScanOutcome::Complete(completed) = outcome else {
panic!("resumed sweep must complete")
};
let totals = completed
.checked_flatten_complete_scope("bucket")
.expect("completed cache has no orphan entries");
assert_eq!((totals.objects, totals.versions, totals.size), (12, 0, 40));
assert!(completed.info.snapshot_complete);
assert!(completed.info.scan_progress.is_none());
assert!(!completed.has_scan_position());
assert!(completed.find("bucket/nested").expect("completed nested prefix").compacted);
assert_eq!(completed.cache.len(), 2, "only the bucket root and completed prefix aggregate remain");
}
#[tokio::test]
#[serial]
async fn completed_compaction_normal_resumes_without_revisiting_completed_prefix() {
assert_completed_compaction_resumes(HealScanMode::Normal).await;
}
#[tokio::test]
#[serial]
async fn completed_compaction_deep_resumes_without_revisiting_completed_prefix() {
assert_completed_compaction_resumes(HealScanMode::Deep).await;
}
#[tokio::test]
#[serial]
async fn completed_compaction_preserves_frontier_inside_coverage_gap() {
assert_compaction_frontier(true).await;
}
#[tokio::test]
#[serial]
async fn completed_compaction_promotes_frontier_without_parent_bookkeeping() {
assert_compaction_frontier(false).await;
}
async fn assert_compaction_frontier(coverage_gap: bool) {
let (mut scanner, root) = build_test_scanner().await;
let _cleanup = TestGuard {
temp_dir: Some(root.clone()),
};
for object in 0..3 {
write_checkpoint_object(&root, &format!("prefix/{object:04}"), &[(None, 2)]).await;
}
let identity = crate::DataUsageScanIdentity {
tier_registry_generation: crate::runtime_tier_registry_for_cycle(11, 7).await.generation,
..bound_checkpoint().1
};
scanner
.old_cache
.prepare_bucket_checkpoint("bucket", 11, 7, SOURCE, PLAN, identity);
scanner.new_cache.info = scanner.old_cache.info.clone();
scanner.is_erasure_mode = true;
scanner.skip_heal.store(true, Ordering::SeqCst);
scanner.sleeper = DynamicSleeper::new(rustfs_config::ScannerSpeed::Fastest);
// A failed child freezes the frontier. Without a gap, completing this
// subtree must preserve coverage even when the caller is a heal rewalk
// that does not advance the parent frontier after scan_folder returns.
scanner.coverage_gap = coverage_gap;
scanner.coverage_frontier = Some("bucket/prefix/0000".to_string());
let mut entry = DataUsageEntry::default();
scanner
.scan_folder(
CancellationToken::new(),
CachedFolder {
name: "bucket/prefix".to_string(),
parent: Some(hash_path("bucket")),
object_heal_prob_div: 1,
},
&mut entry,
)
.await
.expect("walk the ancestor of a frozen coverage frontier");
let expected_frontier = if coverage_gap { "bucket/prefix/0000" } else { "bucket/prefix" };
assert_eq!(scanner.coverage_frontier.as_deref(), Some(expected_frontier));
assert_eq!(
scanner.new_cache.find("bucket/prefix").expect("completed ancestor").compacted,
!coverage_gap
);
assert_eq!(scanner.new_cache.find("bucket/prefix/0000").is_some(), coverage_gap);
scanner
.new_cache
.seal_scan_frontier(scanner.coverage_frontier.as_deref())
.expect("seal the retained frontier");
assert_eq!(scanner.new_cache.validated_scan_frontier(), Some(expected_frontier));
let totals = scanner
.new_cache
.checked_flatten_complete_scope("bucket")
.expect("coverage-gap cache remains connected");
assert_eq!((totals.objects, totals.size), (3, 6));
}
@@ -72,18 +72,16 @@ impl CompactedFixture {
..bound_checkpoint().1
};
let mut cache = DataUsageCache::default();
// The first real scan builds coverage; the second same-plan scan takes
// the normal compaction path. No synthetic compacted cache is injected.
for cycle in [11, 12] {
cache.prepare_bucket_checkpoint("bucket", cycle, 7, SOURCE, PLAN, identity);
cache.info.skip_healing = true;
let (outcome, budget) = scan(&scanner.local_disk, cache, mode, OBJECTS + 1).await;
let ScannerDiskScanOutcome::Complete(completed) = outcome else {
panic!("fixture baseline must complete")
};
assert_eq!(budget.progress().0, OBJECTS, "baseline must read every metadata object");
cache = completed;
}
// Build and compact coverage in the first real sweep. No synthetic
// compacted cache is injected.
cache.prepare_bucket_checkpoint("bucket", 11, 7, SOURCE, PLAN, identity);
cache.info.skip_healing = true;
let (outcome, budget) = scan(&scanner.local_disk, cache, mode, OBJECTS + 1).await;
let ScannerDiskScanOutcome::Complete(completed) = outcome else {
panic!("fixture baseline must complete")
};
assert_eq!(budget.progress().0, OBJECTS, "baseline must read every metadata object");
cache = completed;
let store = FixtureStore::new();
cache = save_reload(&store, &cache).await;
assert!(cache.find(PREFIX).expect("baseline prefix").compacted);
@@ -263,9 +261,11 @@ async fn check_large_prefix_resumption(mode: HealScanMode) {
assert!(cache.info.scan_checkpoint.is_none());
assert!(cache.info.scan_coverage_receipt.is_none());
// Completing the sweep must restore compaction on the next same-plan scan.
// A completed prefix releases its children before the first sweep
// returns; a later same-plan scan keeps that compact representation.
let retained_entries = cache.cache.len();
assert!(!cache.find(PREFIX).expect("completed prefix").compacted);
assert!(cache.find(PREFIX).expect("completed prefix").compacted);
assert_eq!(retained_entries, 3, "only the bucket, compacted prefix and tail remain");
cache.prepare_bucket_checkpoint("bucket", 12 + round, 7 + round, SOURCE, PLAN, identity);
assert!(cache.info.scan_progress.is_none(), "a complete same-plan cache needs no forward sweep");
let (outcome, _) = scan(&scanner.local_disk, cache, mode, expected_reads + 1).await;
@@ -275,8 +275,8 @@ async fn check_large_prefix_resumption(mode: HealScanMode) {
let reloaded = save_reload(&store, &compacted).await;
assert!(reloaded.find(PREFIX).expect("compacted prefix").compacted);
assert!(
reloaded.cache.len() < retained_entries,
"completed sweeps must release child cache entries"
reloaded.cache.len() <= retained_entries,
"later sweeps must not retain the compacted prefix's children"
);
let total = reloaded.checked_flatten("bucket").expect("compacted bucket coverage");
assert_eq!((total.objects, total.size), (expected, expected));
@@ -0,0 +1,167 @@
// Copyright 2026 RustFS Team
// Licensed under the Apache License, Version 2.0.
use super::*;
const OBJECTS_PER_PREFIX: usize = 16;
const FROZEN_FRONTIER: &str = "bucket/prefix-0000/0000";
fn cache_shape(cache: &DataUsageCache, wire_bytes: usize) -> serde_json::Value {
let mut key_capacity_bytes = 0;
let mut child_links = 0;
let mut child_set_capacity = 0;
let mut child_key_capacity_bytes = 0;
for (key, entry) in &cache.cache {
key_capacity_bytes += key.capacity();
child_links += entry.children.len();
child_set_capacity += entry.children.capacity();
child_key_capacity_bytes += entry.children.iter().map(String::capacity).sum::<usize>();
}
// The canonical histograms own eleven and seven u64 counters. Map control
// bytes, allocator overhead, optional statistics and transient work are excluded.
let histogram_payload_lower_bound_bytes = cache.cache.len() * (11 + 7) * std::mem::size_of::<u64>();
let map_slot_size = std::mem::size_of::<(String, DataUsageEntry)>();
let retained_lower_bound_bytes = cache.cache.capacity() * map_slot_size
+ key_capacity_bytes
+ child_set_capacity * std::mem::size_of::<String>()
+ child_key_capacity_bytes
+ histogram_payload_lower_bound_bytes;
serde_json::json!({
"entries": cache.cache.len(),
"entry_capacity": cache.cache.capacity(),
"entry_size_bytes": std::mem::size_of::<DataUsageEntry>(),
"map_slot_size_bytes": map_slot_size,
"map_key_capacity_bytes": key_capacity_bytes,
"child_links": child_links,
"child_set_capacity": child_set_capacity,
"child_key_capacity_bytes": child_key_capacity_bytes,
"histogram_payload_lower_bound_bytes": histogram_payload_lower_bound_bytes,
"retained_lower_bound_bytes": retained_lower_bound_bytes,
"wire_bytes": wire_bytes,
})
}
async fn characterize_memory_budget(prefixes: usize, coverage_gap: bool) {
let (mut scanner, root) = build_test_scanner().await;
let _cleanup = TestGuard {
temp_dir: Some(root.clone()),
};
for prefix in 0..prefixes {
for object in 0..OBJECTS_PER_PREFIX {
write_checkpoint_object(&root, &format!("prefix-{prefix:04}/{object:04}"), &[(None, 1)]).await;
}
}
let identity = crate::DataUsageScanIdentity {
tier_registry_generation: crate::runtime_tier_registry_for_cycle(11, 7).await.generation,
..bound_checkpoint().1
};
scanner
.old_cache
.prepare_bucket_checkpoint("bucket", 11, 7, SOURCE, PLAN, identity);
scanner.new_cache.info = scanner.old_cache.info.clone();
scanner.is_erasure_mode = true;
scanner.skip_heal.store(true, Ordering::SeqCst);
scanner.sleeper = DynamicSleeper::new(rustfs_config::ScannerSpeed::Fastest);
// Isolate the already-established gap state from filesystem fault injection.
// Every object below is healthy; this control does not reproduce the fault
// or durable debt that caused a real scan's coverage gap.
scanner.coverage_gap = coverage_gap;
if coverage_gap {
scanner.coverage_frontier = Some(FROZEN_FRONTIER.to_string());
}
scanner
.scan_folder(
CancellationToken::new(),
CachedFolder {
name: "bucket".to_string(),
parent: None,
object_heal_prob_div: 1,
},
&mut DataUsageEntry::default(),
)
.await
.expect("walk the real wide-root metadata fixture");
let expected_objects = prefixes * OBJECTS_PER_PREFIX;
let totals = scanner
.new_cache
.checked_flatten_complete_scope("bucket")
.expect("the measured cache must be connected and contain no orphan entries");
assert_eq!((totals.objects, totals.size, totals.versions), (expected_objects, expected_objects, 0));
assert_eq!(totals.failed_objects, 0, "the gap control must not fabricate object failure debt");
assert!(
scanner.usage_updates.is_none(),
"production-style scans must not retain a progress mirror"
);
let bucket = scanner.new_cache.find("bucket").expect("retained root inventory");
assert!(!bucket.compacted, "these root fanouts preserve prefix statistics");
assert_eq!(bucket.children.len(), prefixes);
if coverage_gap {
assert_eq!(scanner.coverage_frontier.as_deref(), Some(FROZEN_FRONTIER));
assert!(
scanner.new_cache.find(FROZEN_FRONTIER).is_some(),
"the frozen endpoint must remain addressable"
);
} else {
for prefix in 0..prefixes {
let path = format!("bucket/prefix-{prefix:04}");
let entry = scanner.new_cache.find(&path).expect("completed root prefix");
assert!(entry.compacted);
assert!(entry.children.is_empty());
assert_eq!((entry.objects, entry.size), (OBJECTS_PER_PREFIX, OBJECTS_PER_PREFIX));
}
assert_eq!(scanner.new_cache.cache.len(), prefixes + 1, "healthy completed leaves must be released");
}
scanner
.new_cache
.seal_scan_frontier(scanner.coverage_frontier.as_deref())
.expect("seal the measured coverage endpoint");
assert!(scanner.new_cache.validated_scan_frontier().is_some());
let encoded = scanner
.new_cache
.marshal_msg()
.expect("encode the measured cache with the production codec");
let restored = DataUsageCache::unmarshal(&encoded).expect("reload the measured coverage graph");
assert_eq!(restored.validated_scan_frontier(), scanner.new_cache.validated_scan_frontier());
assert_eq!(restored.info.scan_coverage_receipt, scanner.new_cache.info.scan_coverage_receipt);
let restored_totals = restored
.checked_flatten_complete_scope("bucket")
.expect("reloaded accounting graph");
assert_eq!((restored_totals.objects, restored_totals.size), (expected_objects, expected_objects));
if coverage_gap {
assert_eq!(restored.validated_scan_frontier(), Some(FROZEN_FRONTIER));
}
println!(
"SCANNER_MEMORY_BUDGET {}",
serde_json::json!({
"schema": 1,
"measurement_point": "folder_walk_return_before_bucket_finalization",
"scenario": if coverage_gap { "seeded_coverage_gap" } else { "healthy_forward_sweep" },
"synthetic_gap_control": coverage_gap,
"prefixes": prefixes,
"objects_per_prefix": OBJECTS_PER_PREFIX,
"objects": expected_objects,
"object_bytes": expected_objects,
"validated_frontier": restored.validated_scan_frontier(),
"cache": cache_shape(&scanner.new_cache, encoded.len()),
})
);
}
#[tokio::test]
#[serial]
async fn memory_budget_characterizes_wide_root_and_seeded_coverage_gap() {
let prefixes: &[usize] = match std::env::var("RUSTFS_SCANNER_MEMORY_BUDGET_PROFILE") {
Err(std::env::VarError::NotPresent) => &[64],
Ok(value) if value == "1" => &[64, 128, 256],
_ => panic!("RUSTFS_SCANNER_MEMORY_BUDGET_PROFILE must be absent or 1"),
};
for &prefixes in prefixes {
assert!(prefixes * (OBJECTS_PER_PREFIX + 1) < DATA_SCANNER_COMPACT_AT_CHILDREN);
for coverage_gap in [false, true] {
characterize_memory_budget(prefixes, coverage_gap).await;
}
}
}
@@ -30,10 +30,6 @@ fn prepare_scanner(scanner: &mut FolderScanner, cache: DataUsageCache) {
info: cache.info.clone(),
..Default::default()
};
scanner.update_cache = DataUsageCache {
info: cache.info.clone(),
..Default::default()
};
scanner.old_cache = cache;
scanner.is_erasure_mode = true;
scanner.skip_heal.store(true, Ordering::SeqCst);
@@ -250,7 +250,6 @@ async fn walk_and_save(observe: bool) -> (Vec<String>, serde_json::Value) {
assert_ne!(before, after, "a changed key requires an observable successful fixture write");
scanner.old_cache.info.name = "bucket".to_string();
scanner.new_cache.info.name = "bucket".to_string();
scanner.update_cache.info.name = "bucket".to_string();
let paths = Arc::new(Mutex::new(Vec::<String>::new()));
let proposed_walked = Arc::new(Mutex::new(BTreeSet::<String>::new()));
scanner.update_current_path = Arc::new({
@@ -148,11 +148,7 @@ async fn erasure_candidate_finds_late_part_without_scanning_data_children() {
async fn candidate_scanner() -> (FolderScanner, std::path::PathBuf, CachedFolder) {
let (mut scanner, root) = build_test_scanner().await;
for info in [
&mut scanner.old_cache.info,
&mut scanner.new_cache.info,
&mut scanner.update_cache.info,
] {
for info in [&mut scanner.old_cache.info, &mut scanner.new_cache.info] {
info.name = "bucket".into();
}
scanner.is_erasure_mode = true;
@@ -354,8 +350,7 @@ fn enable_checkpoint(scanner: &mut FolderScanner) {
cache.prepare_bucket_checkpoint("bucket", 7, 3, crate::DataUsageCacheSource::new(1, 0), plan, identity);
cache.replace("bucket", "", DataUsageEntry::default());
scanner.old_cache = cache.clone();
scanner.new_cache = cache.clone();
scanner.update_cache = cache;
scanner.new_cache = cache;
}
#[tokio::test]
@@ -462,8 +457,7 @@ async fn erasure_candidate_cancelled_batch_keeps_partial_checkpoint_and_reclassi
let bytes = serde_json::to_vec(&snapshot).expect("serialize partial checkpoint");
let reloaded: DataUsageCache = serde_json::from_slice(&bytes).expect("reload partial checkpoint");
scanner.old_cache = reloaded.clone();
scanner.new_cache = reloaded.clone();
scanner.update_cache = reloaded;
scanner.new_cache = reloaded;
scanner.raw_enumeration_progress.clear();
let next_budget = ScannerCycleBudget::new_with_progress_tracking(&ctx, Default::default());
scanner.budget = next_budget.clone();
@@ -110,7 +110,7 @@ fn mrf_ownership_retry_due_boundaries_and_priority_are_bounded() {
}
#[tokio::test]
async fn mrf_ownership_full_hint_table_has_bounded_multicycle_work_and_sync() {
async fn mrf_ownership_full_hint_table_has_bounded_multicycle_work() {
let (mut scanner, temp_dir) = build_test_scanner().await;
let _guard = TestGuard::new(u64::MAX, usize::MAX, &mut scanner, temp_dir);
let base = 1_700_000_000;
@@ -131,39 +131,23 @@ async fn mrf_ownership_full_hint_table_has_bounded_multicycle_work_and_sync() {
.enumerate()
.map(|(index, entry)| (entry.object.clone().expect("object identity"), index))
.collect();
scanner.sync_pending_heals();
let initial_syncs = scanner.pending_heal_sync_count;
let mut requests = 0usize;
let mut nonempty_batches = 0;
for minute in 0..24 * 60 {
let now = base + minute * 60;
let candidates = pending_scanner_heal_retry_candidates_at(&scanner.new_cache.info.pending_heals, "bucket", now);
assert!(candidates.len() <= MAX_PENDING_SCANNER_HEAL_RETRIES_PER_BUCKET);
if candidates.is_empty() {
continue;
for candidate in candidates {
assert_eq!(
build_pending_scanner_heal_request(&candidate)
.expect("retry request")
.priority,
HealChannelPriority::Low
);
let index = indices[candidate.object.as_ref().expect("object identity")];
record_pending_heal_attempt(&mut scanner.new_cache.info.pending_heals[index], now);
observe_pending_heal_admission(&mut scanner.new_cache.info.pending_heals[index], HealAdmissionResult::Accepted);
requests += 1;
}
nonempty_batches += 1;
let before = scanner.pending_heal_sync_count;
{
let batch = PendingHealSyncBatch::new(&mut scanner);
for candidate in candidates {
assert_eq!(
build_pending_scanner_heal_request(&candidate)
.expect("retry request")
.priority,
HealChannelPriority::Low
);
let index = indices[candidate.object.as_ref().expect("object identity")];
record_pending_heal_attempt(&mut batch.scanner.new_cache.info.pending_heals[index], now);
observe_pending_heal_admission(
&mut batch.scanner.new_cache.info.pending_heals[index],
HealAdmissionResult::Accepted,
);
batch.scanner.sync_pending_heals();
requests += 1;
}
}
assert_eq!(scanner.pending_heal_sync_count, before + 1, "one table clone per changed retry batch");
assert_eq!(scanner.new_cache.info.pending_heals.len(), MAX_PENDING_SCANNER_HEALS_PER_BUCKET);
}
assert!(requests >= MAX_PENDING_SCANNER_HEALS_PER_BUCKET, "every retained hint receives a retry");
@@ -172,12 +156,13 @@ async fn mrf_ownership_full_hint_table_has_bounded_multicycle_work_and_sync() {
"15min..6h backoff bounds repeated work within 24h"
);
assert!(scanner.new_cache.info.pending_heals.iter().all(|entry| entry.attempts >= 2));
assert_eq!(scanner.pending_heal_sync_count - initial_syncs, nonempty_batches);
assert_eq!(scanner.update_cache.info.pending_heals, scanner.new_cache.info.pending_heals);
let restored =
DataUsageCache::unmarshal(&scanner.new_cache.marshal_msg().expect("encode retry ledger")).expect("reload retry ledger");
assert_eq!(restored.info.pending_heals, scanner.new_cache.info.pending_heals);
}
#[tokio::test]
async fn mrf_ownership_cancelled_batch_restores_sync_without_per_item_clones() {
async fn mrf_ownership_cancelled_retry_retains_canonical_ledger() {
let (mut scanner, temp_dir) = build_test_scanner().await;
let _guard = TestGuard::new(u64::MAX, usize::MAX, &mut scanner, temp_dir);
scanner
@@ -185,34 +170,45 @@ async fn mrf_ownership_cancelled_batch_restores_sync_without_per_item_clones() {
.info
.pending_heals
.push(pending_heal(PendingScannerHealKind::Object, "bucket", Some("object"), None, 1, 1));
let before = scanner.pending_heal_sync_count;
let mut work = Box::pin(async {
let batch = PendingHealSyncBatch::new(&mut scanner);
record_pending_heal_attempt(&mut batch.scanner.new_cache.info.pending_heals[0], 100);
batch.scanner.sync_pending_heals();
batch.scanner.sync_pending_heals();
record_pending_heal_attempt(&mut scanner.new_cache.info.pending_heals[0], 100);
scanner.update_pending_scanner_heal_after_admission(
PendingScannerHealKind::Object,
"bucket",
Some("object"),
None,
HealScanMode::Normal,
HealAdmissionResult::Accepted,
);
std::future::pending::<()>().await;
});
assert!(futures::poll!(&mut work).is_pending());
drop(work);
assert!(!scanner.pending_heal_sync_deferred);
assert!(!scanner.pending_heal_batch_dirty);
assert_eq!(scanner.pending_heal_sync_count, before + 1);
assert!(scanner.pending_heals_changed, "cancellation must retain the dirty marker");
assert_eq!(scanner.new_cache.info.pending_heals[0].attempts, 2);
assert!(pending_scanner_heal_retry_candidates_at(&scanner.new_cache.info.pending_heals, "bucket", 101).is_empty());
assert_eq!(scanner.update_cache.info.pending_heals, scanner.new_cache.info.pending_heals);
let restored = DataUsageCache::unmarshal(&scanner.new_cache.marshal_msg().expect("encode cancelled retry"))
.expect("reload cancelled retry");
assert_eq!(restored.info.pending_heals, scanner.new_cache.info.pending_heals);
scanner.pending_heals_changed = false;
let result: std::result::Result<(), &'static str> = async {
let batch = PendingHealSyncBatch::new(&mut scanner);
record_pending_heal_attempt(&mut batch.scanner.new_cache.info.pending_heals[0], 200);
observe_pending_heal_admission(&mut batch.scanner.new_cache.info.pending_heals[0], HealAdmissionResult::Merged);
batch.scanner.sync_pending_heals();
Err("injected retry batch failure")
record_pending_heal_attempt(&mut scanner.new_cache.info.pending_heals[0], 200);
scanner.update_pending_scanner_heal_after_admission(
PendingScannerHealKind::Object,
"bucket",
Some("object"),
None,
HealScanMode::Normal,
HealAdmissionResult::Merged,
);
Err("injected retry failure")
}
.await;
assert!(result.is_err());
assert_eq!(scanner.pending_heal_sync_count, before + 2);
assert!(!scanner.pending_heal_sync_deferred);
assert_eq!(scanner.update_cache.info.pending_heals, scanner.new_cache.info.pending_heals);
assert!(scanner.pending_heals_changed, "an error must retain the dirty marker");
assert_eq!(scanner.new_cache.info.pending_heals[0].attempts, 3);
assert_eq!(scanner.new_cache.info.pending_heals[0].last_admission_result, "merged");
}
#[derive(Default)]
@@ -436,7 +432,6 @@ async fn mrf_ownership_manager_completion_preserves_scanner_pending() {
let _guard = TestGuard::new(u64::MAX, usize::MAX, &mut scanner, temp_dir);
let bucket = format!("mrf-ownership-{}", Uuid::new_v4());
scanner.new_cache.info.name = bucket.clone();
scanner.update_cache.info.name = bucket.clone();
scanner.heal_object_select = 1;
let storage = Arc::new(NoticeStorage {
bucket_incarnation_id: Uuid::new_v4(),
@@ -521,12 +516,12 @@ async fn mrf_ownership_manager_completion_preserves_scanner_pending() {
"the second object call remains blocked without a duplicate execution"
);
note_mrf_repaired(&bucket, object, Some(*version.as_bytes()));
let syncs_before_retry = scanner.pending_heal_sync_count;
scanner.pending_heals_changed = false;
scanner
.retry_pending_scanner_heals()
.await
.expect("real scanner ledger retry");
assert_eq!(scanner.pending_heal_sync_count, syncs_before_retry + 1, "the real retry batch syncs once");
assert!(scanner.pending_heals_changed, "the real retry marks its canonical ledger for persistence");
assert_eq!(
scanner.new_cache.info.pending_heals.len(),
index + 1,
@@ -0,0 +1,121 @@
// Copyright 2026 RustFS Team
// Licensed under the Apache License, Version 2.0.
use super::*;
#[tokio::test]
#[serial]
async fn usage_updates_preserve_totals_without_allocating_an_unrequested_cache() {
for observed in [false, true] {
let (mut scanner, root) = build_test_scanner().await;
let _guard = TestGuard {
temp_dir: Some(root.clone()),
};
scanner.old_cache.info.name = "bucket".to_string();
scanner.new_cache.info.name = "bucket".to_string();
scanner.sleeper = DynamicSleeper::new(rustfs_config::ScannerSpeed::Fastest);
scanner.skip_heal.store(true, Ordering::SeqCst);
for prefix in ["existing", "new"] {
for index in 0..8 {
let object = format!("{prefix}/{index:04}");
write_test_object_metadata_bytes(&root, "bucket", &object, &metadata_for_object_version("bucket", &object, None))
.await;
}
}
scanner.old_cache.replace("bucket", "", DataUsageEntry::default());
scanner
.old_cache
.replace("bucket/existing", "bucket", DataUsageEntry::default());
scanner.old_cache.replace(
"bucket/existing/0000",
"bucket/existing",
DataUsageEntry {
objects: 1,
size: 99,
..Default::default()
},
);
let (sender, mut receiver) = mpsc::channel(2);
if observed {
scanner.usage_updates = Some(ScannerUsageUpdates::new(sender, "bucket".to_string()));
}
scanner
.scan_folder(
CancellationToken::new(),
CachedFolder {
name: "bucket".to_string(),
parent: None,
object_heal_prob_div: 1,
},
&mut DataUsageEntry::default(),
)
.await
.expect("scan existing and new prefixes");
let totals = scanner
.new_cache
.checked_flatten_complete_scope("bucket")
.expect("complete accounting");
assert_eq!((totals.objects, totals.size), (16, 16));
assert_eq!(
scanner
.old_cache
.find("bucket/existing/0000")
.expect("retained baseline")
.size,
99
);
if observed {
while receiver.try_recv().is_ok() {}
let updates = scanner.usage_updates.as_mut().expect("requested progress owner");
let progress = updates.cache.checked_flatten("bucket").expect("progress cache is connected");
assert_eq!((progress.objects, progress.size), (16, 16));
updates.last_update = SystemTime::UNIX_EPOCH;
scanner.send_update().await;
let update = receiver.try_recv().expect("requested aggregate update");
assert_eq!((update.objects, update.size), (16, 16));
scanner.send_update().await;
assert!(receiver.try_recv().is_err(), "unchanged minute interval must not emit another aggregate");
} else {
assert!(scanner.usage_updates.is_none(), "unobserved scans must never allocate a progress cache");
}
}
}
#[tokio::test]
async fn usage_updates_do_not_mirror_durable_ledgers_or_checkpoint_metadata() {
let (mut scanner, root) = build_test_scanner().await;
let _guard = TestGuard { temp_dir: Some(root) };
scanner.new_cache.info.name = "bucket".to_string();
let (sender, _receiver) = mpsc::channel(1);
scanner.usage_updates = Some(ScannerUsageUpdates::new(sender, "bucket".to_string()));
scanner.record_pending_scanner_heal(
PendingScannerHealKind::Object,
"bucket",
Some("object"),
None,
HealScanMode::Normal,
HealAdmissionResult::Full,
);
scanner.record_scan_resume_hint("bucket/object");
let mut summary = SizeSummary::default();
summary.record_size_reconciliation(SizeReconciliationEntry {
key: "unresolved-size".to_string(),
bucket: "bucket".to_string(),
object: "object".to_string(),
reason: "invalid_declared_size".to_string(),
..Default::default()
});
scanner.apply_size_reconciliation(&summary);
scanner.finish_size_reconciliation_batch();
let restored = DataUsageCache::unmarshal(&scanner.new_cache.marshal_msg().expect("encode canonical state"))
.expect("reload canonical state");
assert_eq!(restored.info.pending_heals.len(), 1);
assert_eq!(restored.info.size_reconciliation.len(), 1);
assert_eq!(restored.info.scan_checkpoint, scanner.new_cache.info.scan_checkpoint);
let progress = &scanner.usage_updates.as_ref().expect("requested progress owner").cache;
assert_eq!(progress.info.name, "bucket");
assert!(progress.info.pending_heals.is_empty());
assert!(progress.info.size_reconciliation.is_empty());
assert!(progress.info.scan_checkpoint.is_none());
assert!(progress.info.scan_resume_after.is_none());
}
+1
View File
@@ -9,6 +9,7 @@ collection:
## Operations
- [Retained MRF responsibilities](operations/mrf-responsibilities.md) — unknown bucket identities, bounded probes, and unreadable checkpoint recovery.
- [Read-only BTF declaration inspection](operations/btf-prototype-inspection.md) — bounded prototypes and scalar member layouts, explicit void handling, and independent native references.
- [Internode HTTP streaming transport](operations/internode-http-transport.md) — protocol selection, pooled connection isolation, and distributed read validation.
+1
View File
@@ -46,6 +46,7 @@ Required headings and strings in these files are asserted by `scripts/check_arch
| [background-controller-contract.md](background-controller-contract.md) | adding a status snapshot or reconcile surface for a background service, or being tempted to fold several services into a generic controller |
| [background-services-inventory.md](background-services-inventory.md) | you need one audited background service's desired source, current-status inputs, status surface, and declared side effects |
| [scanner-usage-publication.md](scanner-usage-publication.md) | changing scanner data-usage cache publication, quota-visible usage snapshots, scanner cycle recovery, or the persisted scanner usage artifacts |
| [scanner-dirty-journal-source-contract.md](scanner-dirty-journal-source-contract.md) | designing storage-verified provenance, completion receipts, repair responsibility or replay for the node-owned scanner dirty-producer journal (proposed; not runtime implemented) |
| [scanner-usage-authority-decision.md](scanner-usage-authority-decision.md) | deciding whether quota admission depends on scanner data usage, removing scanner publication layers, or designing a scanner storage boundary |
| [scanner-per-bucket-convergence.md](scanner-per-bucket-convergence.md) | changing what makes a scanner cycle `Complete`, `Superseded`, or converged, adding or re-classifying a dirty-usage producer, deciding whether a background writeback may invalidate a cycle, changing scanner worker concurrency or throttling, or activating scoped dirty acknowledgements |
| [config-model-boundary-adr.md](config-model-boundary-adr.md) | touching the server-config model (`Config`, `KV`, `KVS`) or its persistence, or asking which crate owns which part of server configuration |
+1 -1
View File
@@ -64,7 +64,7 @@
- `backlog-2097-tier-mutation-v4-error-text` tier-mutation Prepare rejection classification: a v3 server rejects a v4 request before store/runtime dispatch with the FailedPrecondition status and an authenticated, byte-exact unsupported-version message. A v4 coordinator recognizes only that exact code/message/requested-version tuple as definitely not persisted and fails the mutation without sending that peer an incompatible Abort; Unimplemented, near-text, missing/unknown failure classes, timeouts, and every other transport outcome remain ambiguous and stay in identity-bound Abort fanout. There is no automatic v3 retry. New servers retain v3 request/proof decoding for older coordinators, while operators must pause tier edit/remove/clear during a mixed v3/v4 rollout. Remove the text classifier after the minimum supported RustFS peer version returns the signed v4 PreDispatchRejected failure class.
- `backlog-2097-tier-delete-journal-v6` tier-delete sole-owner recovery: v6 distinguishes transactions that may replace the xl.meta free-version owner and therefore require the all-pool live-source proof. v5-and-older readers reject and retain v6 records during rolling upgrades instead of performing an unsafe remote delete. Keep v1-v5 readers for upgrade recovery and keep the downgrade prohibition while any v6 record exists; retire the fence only after every supported rollback release understands and enforces v6 proof semantics.
- `multipart-compression-default-off-window` staged multipart disk-compression rollout: releases before the resumable legacy decompressor fail transient reads of compressed objects under mid-payload suspension, so multipart uploads advertise the compression marker only when RUSTFS_COMPRESSION_MULTIPART_ENABLED is set in addition to RUSTFS_COMPRESSION_ENABLED, keeping rolling upgrades from creating new compressed multipart objects while pre-fix nodes may still serve reads. Flip the default to enabled (and retire the extra switch) after the minimum supported direct-upgrade release ships the resumable decompressor.
- `listing-cursor-legacy-tag` ListObjects cursor compatibility: releases up to 1.0.x appended a metacache tag ([rustfs_cache:v2,id:...,src:walker,gen:live]) to every internal continuation marker and exposed it through the base64 V2 continuation token and the ListObjectVersions NextKeyMarker; no server component consumed the tag, and it caused keys sorting between the marker and the tag to be skipped (backlog#1047). Current servers emit plain markers for ordinary keys; cache-shaped literal keys retain one legacy outer tag so both old and new readers resume after the complete key. V2 tokens remain base64 of the cursor. ListPathOptions::parse_marker decodes the cursor once at the listing entry point, and list_marker_key removes the outer tag for scanner comparisons. V1 responses remove only the conditional outer escape; the pre-existing ambiguity of incoming raw V1 markers remains during the compatibility window. Remove the tag stripping after every supported direct-upgrade release has issued plain key markers for a full release, so no client can still hold a tagged cursor.
- `listing-cursor-legacy-tag` ListObjects cursor compatibility: releases up to 1.0.x appended a metacache tag ([rustfs_cache:v2,id:...,src:walker,gen:live]) to continuation markers. V2 tokens continue to accept that legacy form, and cache-shaped literal keys retain an outer tag in newly issued V2 cursors. V1 markers, V2 StartAfter, and ListObjectVersions markers are literal keys. Clients must restart old version-listing chains and V1 chains holding escaped markers after upgrading. Remove the V2 tag stripping after every supported direct-upgrade release has issued plain key markers for a full release, so no client can still hold a tagged cursor.
- `s3gate-trailer-adapter` aws-chunked trailer handle bridge: s3s decodes aws-chunked request bodies and publishes their x-amz-checksum-* trailers through its own handle, while rio consumes trailers only through its framework-neutral TrailerSource trait. The RustFS application crate adapts the s3s handle at the object write path so rio carries no s3s dependency. Remove the adapter after the gateway stack replaces s3s as the request body decoder and publishes trailers through its own TrailerSource implementation.
## Review Checklist
+1 -1
View File
@@ -313,7 +313,7 @@ Small objects store their payload inline after the container CRC ([filemeta_inli
## 8. Read path and read quorum
- **INVARIANT — read quorum = `data_blocks`.** `object_quorum_from_meta` returns `(read_quorum = data_blocks, write_quorum)` ([set_disk/metadata.rs](../../crates/ecstore/src/set_disk/metadata.rs)); `parity_blocks = common_parity(...)` is the parity value held by the most disks that still reaches its own read quorum. When `default_parity_count == 0`, read = write = all shards.
- Authoritative FileInfo selection — `find_file_info_in_quorum` ([set_disk/metadata.rs](../../crates/ecstore/src/set_disk/metadata.rs)) groups valid metas by a content-identity SHA-256 (`file_info_quorum_hash`) that hashes size/flags/mod_time/transition/version_id/data_dir/parts and, for real objects, data/parity/distribution — **excluding replication-status keys** so replication noise never splits quorum. A meta counts only if its mod_time equals the common mod_time (or etag matches when mod_time is absent). The winning hash must reach quorum, else `ErasureReadQuorum`. Latest-version reads may escalate to write_quorum to avoid resurrecting a partially-overwritten version.
- Authoritative FileInfo selection — `find_file_info_in_quorum` ([set_disk/metadata.rs](../../crates/ecstore/src/set_disk/metadata.rs)) groups valid metas by a content-identity SHA-256 (`file_info_quorum_hash`) that hashes size/flags/mod_time/transition/version_id/data_dir/parts and, for real objects, data/parity/distribution — **excluding replication-status keys** so replication noise never splits quorum. A meta counts only if its mod_time equals the common mod_time (or etag matches when mod_time is absent). The winning hash must reach quorum, else `ErasureReadQuorum`. Reads select at read quorum through `select_valid_fileinfo`, the same rule heal uses and MinIO's `getObjectFileInfo`: the most common mod_time wins and a tie goes to the newer one, so a write that failed after reaching exactly `data_blocks` copies is served as listing shows it, instead of `ErasureReadQuorum` until heal runs. Two identities tied at the selected mod_time (a data = parity set split in half, such as a metadata update that failed after reaching half the drives) resolve to the one on the lowest-index disk, in reads and heal alike, so a retried metadata write can repair the split. A selected version with local shards also needs as many copies as its own `data_blocks`, since delete markers and empty versions can lower the common parity's read quorum. A metadata update whose target disks cannot reach write quorum is refused before any write. A version replaced by a committed write keeps fewer than `data_blocks` copies and can never be selected again; the newer side of a failed write can be served and then lose to the older side if one of its disks fails before heal converges the set.
- **INVARIANT — decode needs ≥ `data_blocks` shards.** The stripe reader requires `available_shards ≥ data_shards`; below that the read fails closed with a read-quorum error (never silent truncation) ([set_disk/read.rs](../../crates/ecstore/src/set_disk/read.rs), [set_disk/shard_source.rs](../../crates/ecstore/src/set_disk/shard_source.rs)). Codec geometry is validated at construction: `Erasure::try_new` / `try_new_with_options` ([erasure.rs](../../crates/ecstore/src/erasure/coding/erasure.rs)) return `ErasureConstructionError` for `data_shards == 0`, `block_size == 0`, shard-count overflow, or an unsupported shard configuration, so a read never reaches a `block_size` / `data_shards` division with invalid geometry (§13).
- If `available ≥ data_blocks` but some shards are missing, the read is served **and** a background read-repair heal is enqueued.
- **INVARIANT — cross-stripe read verification.** When a data shard is missing and `available > data_blocks`, reconstruction regenerates parity and compares it to the surviving parity; a mismatch is `InvalidData "inconsistent read source shards"` (backlog#832), catching corruption that passed per-shard bitrot but disagrees across the stripe ([erasure.rs](../../crates/ecstore/src/erasure/coding/erasure.rs)).
@@ -0,0 +1,233 @@
# Scanner dirty-journal metadata source contract
**Use this when:** adding storage-verified provenance to the node-owned scanner dirty-producer journal, its write completion, repair responsibilities, or replay.
**Status:** proposed implementation contract. The authority, mutation tickets and
receipts below are not implemented by the current journal. This document does
not enable a new writer, change a persisted format, migrate data, or authorize
repair. The existing reporting and quota decisions remain in
[scanner usage publication](scanner-usage-publication.md) and
[scanner usage authority](scanner-usage-authority-decision.md).
## Scope and current boundary
The scope is only the node-owned dirty-producer journal currently implemented in
`rustfs/src/storage/scanner_dirty_journal.rs`. Its configured endpoint selects an
owner hash and the owned JSON object. Mutation callbacks coalesce into bounded
state; the worker captures a state/revision under the mutex and releases it
before storage I/O. `confirm_flush` cannot confirm a newer in-memory revision.
Replay validates the stored owner and producer/generation fields. This is an
asynchronous restart hint, not a durability receipt for each S3 mutation.
The endpoint hash is a routing key, not a storage-verified namespace or writer
generation. A successful configuration save is not, by itself, proof that all
physical tail tasks completed. These facts must not be promoted into authority
for the old source-unknown MRF entries associated with internal JSON writes.
This contract introduces no generic internal-metadata authority. It does not
cover usage publication, bucket metadata, bucket-incarnation markers or
`pool.bin`. Pool metadata's transaction owner is not a dirty-journal owner.
Storage primitives may be reused only if they enforce this scope's own fences.
## Required identities
The names below describe storage roles, not exported Rust APIs.
| Identity | Required evidence | Meaning |
|---|---|---|
| Owner key | Domain-separated digest of the configured RPC authority | Selects the owned journal; never proves historical provenance |
| Namespace bootstrap | Immutable committed bytes naming this class, owner key and a non-nil namespace UUID, with an independent native transaction commit ID and digest | Establishes a new namespace only after storage verification; never labels legacy bytes |
| Writer epoch | A positive epoch activated by fenced CAS of the journal's active head | Excludes a replaced writer without treating process identity or restart as proof |
| Mutation ticket | Exact namespace/epoch/bootstrap binding, responsibility and mutation UUIDs, target pool/set/bucket/key, producer/trigger, payload digest and retained payload bytes or an immutable verified payload reference | Retains one new write's responsibility before dispatch |
| Completion receipt | The exact ticket, committed target revision/digest, terminal physical-tail evidence and read-back binding | Can settle only that new responsibility |
Namespace, bootstrap-commit, responsibility and mutation UUIDs must be non-nil.
The native target revision is an explicit observed-absent or observed-existing
CAS token, not an empty/default "current" value. Read failure, unsupported data
and proven absence are distinct; only proven absence permits conditional create.
The namespace UUID is generated only for an explicit new bootstrap transaction
and becomes usable only after that transaction is fully verified. Missing,
corrupt, conflicting, unsupported or unverified bootstrap data means unavailable
authority. It must not become a new current identity through a default value.
Unknown legacy entries retain their original identity, object/scope, status,
enqueue/held times and operator audit.
## Bootstrap and epoch activation
Bootstrap requires a storage-native conditional-create transaction with its own
durable recovery owner. It must retain indeterminate creation work independently
of the namespace being created: bootstrap cannot prove its own success by
decoding its JSON or by borrowing a current endpoint, process or pool owner.
The transaction creates immutable bootstrap bytes, drains native work, validates
the durability result and reads back the exact committed bytes. Only this
storage-verified result may construct the opaque bootstrap binding. Arbitrary
decoded records cannot construct it. Concurrent creation must select the same
committed record or fail closed; it cannot overwrite a competing namespace.
The active checkpoint is the single CAS surface for both the writer epoch and
the journal publication. An epoch transition cannot be a separate unguarded
object update followed by an ordinary PUT. Every physical mutation must carry a
storage-native parent/epoch condition effective at the final commit boundary.
The current generic configuration precondition callback is not assumed to
provide this capability. An implementation lacking the primitive cannot enable
source-bearing writes.
Epoch activation also retains all old in-flight work and debt. Every admitted
write needs a native terminal record; missing or indeterminate records remain
unresolved. A new process, expired lease or current registry observation cannot
stand in for proof that the old physical operations ended.
## Producer, binding and dispatch
Only the actual dirty-journal worker may use this class. Its target must be the
owner-selected journal object in the storage metadata namespace, with the exact
pool/set routing identity checked at admission and commit. The bootstrap object
and unrelated metadata objects are outside the ticket's repair scope.
User metadata, a decoded wire record or an authenticated RPC claim cannot
construct a verified binding. A receiving storage peer must validate the
bootstrap/ticket, target and native fence again before mutation; transport
authentication alone is not this scope's repair authorization.
The new head key is
`scanner/dirty-journal-source/v1/<owner>/checkpoint.bin`, where `<owner>` is the
canonical hash produced by `durable_dirty_usage_owner`. Immutable bootstrap
records live under that owner's distinct `bootstrap/` keyspace. This does not
reuse the shared or owner-only legacy JSON key. Mutation tickets and receipts
belong to the bounded, source-aware responsibility/checkpoint store; it must not
create an unbounded new object per callback or flush.
The producer obtains a verified bootstrap and active epoch outside the journal
state mutex. It captures the exact payload, revision and producer causes under
that mutex, then releases it. The dirty-state/namespace/storage locks must not
be hidden beneath the journal mutex while binding or writing.
Authority I/O belongs to bootstrap and the journal worker. S3 callbacks remain
bounded in-memory coalescing; unavailable authority retains dirty/unverified
state and does not turn this restart hint into an S3 admission veto.
Before dispatch, it durably admits the bounded mutation ticket and retains the
exact input required to retry that mutation after restart. Ticket admission
failure prevents dispatch and leaves the pending state unconfirmed. Digest-only
evidence is insufficient when the exact payload cannot otherwise be recovered.
After namespace locks are acquired, storage rechecks the bootstrap, active epoch,
target scope and parent revision. A changed identity rejects the candidate; it
does not rebind the old ticket to the current identity. The producer/trigger
distinguishes initial dispatch, minority/tail failure, cancellation and native
indeterminate completion. Missing trigger history remains unknown.
The metadata producer is the journal worker; the original object-mutation
producer labels remain payload hints. They do not confer metadata authority.
## Commit, tail and publication
A write-quorum ACK is not a completion receipt. Physical disk tasks retain the
mutation identity and their owner until terminal completion, including when an
async wrapper times out or its caller is cancelled.
Completion must honor the selected durability policy, including its file,
rename, parent and required-ancestor persistence boundaries. A mode that cannot
supply durable evidence cannot produce a durable source receipt. It retains
unverified state; this protocol neither silently forces extra fsync work in that
mode nor weakens the configured durability policy to obtain success.
System-metadata repair remains with this scoped owner. A user-bucket MRF proof
cannot authorize it. `TailDrainedWithOwnerRepair` is an available completion
boundary, not an authority token: a repair-required result must retain the
ticket and retry the exact mutation or remain unresolved. It cannot be converted
to success by a later ordinary read or a successful unrelated mutation.
Publication requires the current epoch/parent fence, the completed native
transaction and a read-back of the exact intended payload. The committed
checkpoint binds the bootstrap, ticket, target revision, payload digest and
native completion result. A torn checkpoint, stale epoch, conflicting bytes or
unknown tail status prevents confirmation and responsibility settlement.
`confirm_flush` additionally compares the captured revision with the current
in-memory revision. Completion of an older snapshot must not acknowledge newer
callbacks. Producer coalescing does not prove that a newer payload supersedes a
different pending ticket; automatic cross-mutation settlement is out of scope.
An empty verified state is a fenced tombstone publication, not an unguarded
physical delete of the active head. It cannot remove another owner's record or
any legacy/shared object. This keeps the epoch and completion binding available
for replay.
## Checkpoint, replay and exact settlement
Source-bearing readers are introduced before a source-bearing writer. They
validate the complete schema/version and byte limits, the immutable bootstrap
and native transaction evidence, the active checkpoint, and every ticket's exact
target and payload. A companion must bind the checkpoint owner, sequence, full
length and digest; absence or a mismatch cannot be read as current authority.
Bootstrap, ticket, checkpoint and receipt persistence retain their independent
storage-transaction recovery owners. Publishing a receipt cannot use the same
responsibility it settles as its sole evidence. A persistence failure leaves
the original responsibility and native recovery work unresolved; it does not
start a recursive chain of self-authorizing source claims.
Replay may resume an exact source-bearing ticket only with the same verified
namespace and mutation evidence. Reuse of an endpoint after restart does not
change the historical source. A new epoch retains old debt until the required
native terminal evidence and exact receipt exist.
Only a durably published receipt matching the complete responsibility identity
may settle it. Matching an object key, current owner, version, payload digest or
successful newer write alone is insufficient. Source-unknown entries, another
scope, another mutation, and legacy records are never automatically accepted,
rechecked, retired or physically cleaned by this protocol.
## Compatibility and bounded resources
The legacy shared JSON and current owner-only JSON remain retained as legacy
sources. They cannot be imported into a new authority namespace or overwritten
to manufacture provenance. A reader-first rollout needs distinct source-bearing
storage and an explicit minimum-reader/capability contract. An unsupported peer
must reject source-bearing admission before mutation, rather than silently
falling back to an owner-only write. Rollback cannot treat unknown fields as
source authority or automatically remove source-bearing material.
These are restrictions on the new protocol. A legacy process may still update
its own old-format key; that activity is not provenance for the new namespace
and must not be mistaken for byte-stable historical evidence.
This protocol does not turn scanner reports or restart hints into quota
admission authority. Existing full-scan, dirty-generation, peer-instance and
movement fences remain required. The existing user-bucket MRF journal and old
unknown responsibilities retain their current semantics.
The current journal's 64 KiB record, entry/scope limits and queue byte limits
remain constraints. Bootstrap bytes, tickets and retained payloads, companions,
checkpoints, native transaction records and receipts are all charged explicitly.
An implementation must not borrow an unrelated bucket's spare budget, omit
fields, evict identities, silently increase limits or accumulate unbounded
immutable objects. Exhaustion retains dirty state and blocks new admission.
No epoch/ticket garbage collection is authorized without a separate exact
ownership and durable-retention proof. This contract authorizes no cleanup of
the original residual data.
## Required conformance cases
These are implementation requirements, not tests claimed to have passed.
| Boundary | Required observation |
|---|---|
| Bootstrap absent, racing create, corrupt bytes or unknown native result | No authority token; retained creation/recovery evidence; legacy bytes unchanged |
| Endpoint reuse, writer replacement, movement or stale parent | Native fence rejects the stale mutation; old in-flight work/debt is not relabelled |
| Admission failure or crash before dispatch | No unrecorded write; exact retained ticket/payload can be read after restart |
| Quorum ACK while a native tail is blocked | No completion receipt, checkpoint settlement or premature owner release |
| Minority/tail failure, caller timeout/cancel or fence loss | Same ticket remains unresolved; no generic user-bucket repair authorization |
| Crash before/after native commit and checkpoint publication | Exact replay outcome or explicit indeterminate state, never a guessed success |
| Callback or dirty acknowledgement concurrent with I/O | Only the captured snapshot can complete; newer state remains pending |
| Partial/torn/misbound companion, payload or receipt | Fail closed without adopting a current namespace or deleting retained evidence |
| Exact success versus same-key/different-source/mutation success | Settle only the exact new ticket; all other and legacy unknown entries remain |
| Empty state, capacity exhaustion and unsupported reader | Fenced tombstone or retained failure; no broad delete, capacity relaxation or implicit downgrade |
The current owner-only journal, its tests and the pool metadata contract are
useful foundations, but none alone satisfies this source contract. Runtime
implementation and conformance acceptance are separate from this design. If the
contract enters a performance comparison, both arms must first use the same
accepted implementation and be fixed and registered again.
+37 -1
View File
@@ -325,9 +325,45 @@ worker finishing does not certify its namespace coverage.
| `bucket_result_rejected` | A bucket result did not match the cycle's frozen tier-registry generation and was not applied. |
| `set_cache_publish_skipped` | `completed_buckets` counts distinct bucket roots successfully applied to this set cache and `carried_buckets` the partial walks covered by a certified value. Use `first_incomplete_bucket` with pool/set/cycle to locate one actual gap in this set. Inspect `unprocessed_buckets` and budget/cancellation state, then correlate that bucket's worker events. |
| `set_scan_completed` | Set workers have finished. `coverage_complete` reports the structural coverage gate, not durable publication. This event alone does not prove a complete set snapshot. |
| `incomplete_coverage` | The cycle did not obtain complete structural coverage. This does not identify a hot bucket or prove that ordinary writes caused the gap. |
| `incomplete_coverage` | A Partial cycle did not match either of the more specific partial-cause labels. It does not identify which completeness or publication check failed, or prove that a bucket or drive was missed. |
| `publication_proof_pending` | Publication is waiting for a retryable proof. Use cycle/stage, `pending_duration`, the preceding `publication_proof_started` kind, and the peer/purpose in the error. This event does not assert that the walk was Complete. |
`bucket_sweep_incomplete`, `partial_saved_not_published`,
`set_cache_publish_skipped`, and `set_scan_completed` are debug events. Their
absence cannot exclude those paths when debug logging was not collected.
`metrics.last_cycle_bucket_drive_scans` is the local process's change in
`scan_bucket_drive` timed completions during the cycle. It includes successful,
partial, namespace-not-found, and non-cancelled error returns; a cancelled
scan can emit a partial result without incrementing this count. The leader's
remote progress adds object and directory counts, but not remote bucket-drive
completions. The count can also include concurrent local scan work. It is not a
cluster-wide success count or a coverage denominator, so comparing it with
bucket count multiplied by set or drive count cannot identify missing drives.
See `Metrics::scan_cycle_work_since` in [scanner metrics](../../crates/scanner-metrics/src/metrics.rs)
and `ScannerIODisk::nsscanner_disk` in [the disk scanner](../../crates/scanner/src/scanner_io/io_disk.rs).
For a Partial cycle, compare the same leader process's `metrics.usage_candidates`
in `/rustfs/admin/v3/scanner/status` before and after the cycle, when available.
The corresponding `rustfs_scanner_usage_candidates_total` counters require an
enabled exporter; other scanner metrics endpoints can expose a different schema.
For `kind=completed`, the `outcome` names the first candidate-construction check
that returned no candidate, or `accepted`; acceptance does not prove durable
publication. Counters are process-lifetime, not per-cycle or per-bucket, and
reset on restart. Correlate their deltas with the same cycle and leader epoch in
logs; concurrent candidate construction and non-atomic sampling can make
attribution uncertain. An unavailable field or counter is not zero.
The scanner metrics `rustfs_scanner_bucket_drive_result_total` and
`rustfs_scanner_cycle_bucket_drive_result` expose local results by server, bucket, drive and
`result` (`success`, `partial`, or `error`); the latter also has `cycle_scope`
(`current` or `last`). `rustfs_scanner_active_bucket_drive_scans` shows work
active at the sample time. These are observations, not per-drive terminal acknowledgements:
the local result table is capped and evicts old keys, the cycle labels do not
carry a cycle or epoch, and retries can repeat work. A missing row does not
prove that a terminal was never received. Match observations to the relevant
node and cycle before comparing them with set-cache and publication logs.
For a report such as 52 completed buckets out of 53, first identify whether
the missing bucket was Partial, failed, never admitted, or rejected at merge.
Only then inspect its worker evidence. Mixed coverage is a resumable sweep
@@ -0,0 +1,85 @@
# Read-only BTF declaration inspection
Use `scripts/inspect_btf_prototypes.py` to inspect declared function types or selected
integer member layouts in a
raw, little-endian BTF v1 regular file. Symlinks, devices and pipes are rejected.
The command reads the named file and writes one
JSON document to stdout. It does not load BPF, attach probes, read kernel memory,
change a service, or write to the source file.
```bash
python3 scripts/inspect_btf_prototypes.py \
--btf /sys/kernel/btf/vmlinux \
--function bpf_ringbuf_output
```
Repeat `--function` for up to 16 unique, exact names. The result preserves request
order, the observed source size/SHA-256, function/prototype type IDs, original
parameter type IDs, qualifier chains, and resolved declarations. A missing or
ambiguous function is an error, rather than a substitute selected by proximity.
The schema version is `1`.
Prototype records expose common BTF headers. A reported prototype is not a
complete C type, recursive layout proof or runtime calling convention.
For integer counters, select one exact struct and one or more named member paths:
```bash
python3 scripts/inspect_btf_prototypes.py \
--btf /sys/kernel/btf/vmlinux \
--struct bpf_ringbuf \
--field mask --field consumer_pos --field producer_pos --field pending_pos
```
This mode validates the selected struct member records, qualifier chains and
integer payloads. `scalar_fields` preserves request order and reports each
declared byte offset, width, integer encoding flags and member path. Nested
structs are supported; every selected span must fit its declared container.
Only complete 1, 2, 4 or 8-byte integers are supported. Bitfields (including
legacy integer-offset encodings), pointer traversal, pointer/array/union leaves,
ambiguous members, missing types and by-value cycles are errors. No pointer
width is inferred. Byte alignment does not guarantee natural machine alignment.
`--struct` requires `--field` and cannot be combined with `--function`. Up to
16 unique paths, each containing at most 32 named members, are accepted. A
failure in any selected path rejects the whole report before emitting JSON.
The mode sets `runtime_ABI_verified` to `false`: it does not bind BTF to executing
machine code, pointer values, loaded images or an observer's admission contract.
Only requested paths are validated, not the complete BTF graph or all C layouts.
The reader supports BTF kinds 1 through 19, indexes at most 500,000 records,
reads at most 32 MiB plus one overflow-detection byte, limits names to 4,096
bytes, follows at most 32 type records per qualifier chain, and accepts at most 16 parameters
per selected prototype. Reports above 1 MiB are rejected before any JSON prefix
is emitted. Invalid/truncated/unsupported source, missing resolved type IDs,
malformed pointer/qualifier headers, and ambiguous selectors exit nonzero with
empty stdout. Success exits zero. Input I/O and inspection errors exit `2`.
Under the [Linux BTF format](https://docs.kernel.org/bpf/btf.html), ID zero denotes
void without a stored type record. The tool permits it at explicit return and
argument-pointee boundaries, including qualified void. A void descriptor has no
invented size or offset. The tool does not infer a pointer width, dereference
runtime pointers, or certify that a declared type matches the currently executing
machine code. It is a prototype inspector, not a complete BTF semantic verifier,
recursive layout validator, caller profiler, or runtime ABI acceptance gate.
Run the local contract tests with:
```bash
python3 -B scripts/test_inspect_btf_prototypes.py
```
The `BTF prototype reference` workflow additionally compiles a small C fixture,
extracts its raw `.BTF` section, and independently compares both selected
prototypes, scalar layouts and resolved types against `bpftool` output. A separate
ordinary C executable uses compiler `offsetof`/`sizeof` to check layout offsets
and widths, including nested and packed members; it does not parse BTF or load
probes. The BPF object is never loaded. The workflow selects a concrete
`bpftool` executable by availability and
version before comparing data; it does not depend on the hosted kernel's tools
wrapper or switch tools after a comparison failure. The native
[file-dump mode](https://github.com/libbpf/bpftool/blob/main/docs/bpftool-btf.rst)
accepts ELF or raw BTF files. That comparison validates
the tool's declared-format scope; it does not
qualify an original test node's kernel generation, complete observer budget, or
storage/business performance experiment.
@@ -64,12 +64,14 @@ A single local path (`rustfs D:\rustfs\data`) is a single-node single-drive depl
An empty `RUSTFS_STORAGE_CLASS_STANDARD` or `RUSTFS_STORAGE_CLASS_RRS` restores the automatic policy and overrides a persisted value (`standard_policy` / `rrs_policy`). `MINIO_`-prefixed spellings of these keys, of `ERASURE_SET_DRIVE_COUNT`, and of `VOLUMES` are mapped onto the `RUSTFS_` names when the `RUSTFS_` name is unset (`apply_external_env_compat` in `crates/utils/src/envs.rs`); when both are set the `RUSTFS_` value wins and the conflict is reported at startup.
Heal knobs (`RUSTFS_HEAL_*`) and their defaults are documented once, in [scanner-runtime-controls.md](scanner-runtime-controls.md#heal-runtime-controls); the deep bitrot cycle is the admin config key `heal.bitrot_cycle` (environment `RUSTFS_SCANNER_BITROT_CYCLE_SECS`), not a `RUSTFS_HEAL_*` variable. Data-movement knobs: `RUSTFS_REBALANCE_MAX_ATTEMPTS` (default `DEFAULT_REBALANCE_MAX_ATTEMPTS` = 3, `crates/ecstore/src/services/rebalance/mod.rs`), `RUSTFS_DECOMMISSION_BUCKET_CONCURRENCY` (default cap 4) and `RUSTFS_DECOMMISSION_ENTRY_CONCURRENCY` (default cap 8, hard cap 64) in `crates/ecstore/src/core/pools.rs`, and `RUSTFS_LIST_OBJECTS_QUORUM` (default `strict`, which reads every online drive of the set; `disk`, `reduced`, `optimal` and `auto` read a sample, and anything else means `strict`, per `normalize_list_quorum` in `crates/ecstore/src/store/list_objects.rs`). `optimal` reads about half of the drives, so a deleted object can briefly reappear in a listing while the sampled drives still hold a stale copy; a GET or HEAD of that key still returns 404. None of them relaxes object quorum, object locks, or version checks.
Heal knobs (`RUSTFS_HEAL_*`) and their defaults are documented once, in [scanner-runtime-controls.md](scanner-runtime-controls.md#heal-runtime-controls); the deep bitrot cycle is the admin config key `heal.bitrot_cycle` (environment `RUSTFS_SCANNER_BITROT_CYCLE_SECS`), not a `RUSTFS_HEAL_*` variable. Data-movement knobs: `RUSTFS_REBALANCE_MAX_ATTEMPTS` (default `DEFAULT_REBALANCE_MAX_ATTEMPTS` = 3, `crates/ecstore/src/services/rebalance/mod.rs`), `RUSTFS_DECOMMISSION_BUCKET_CONCURRENCY` (default cap 4) and `RUSTFS_DECOMMISSION_ENTRY_CONCURRENCY` (default cap 8, hard cap 64) in `crates/ecstore/src/core/pools.rs`, and `RUSTFS_LIST_OBJECTS_QUORUM` (default `strict`, which reads every online drive of the set; `disk`, `reduced`, `optimal` and `auto` read a sample, and anything else means `strict`, per `normalize_list_quorum` in `crates/ecstore/src/store/list_objects.rs`). `optimal` reads about half of the drives, so a deleted object can briefly reappear in a listing while the sampled drives still hold a stale copy; a GET or HEAD of that key still returns 404. A sampled listing can also omit a committed object whose copies mostly sit outside the sample: an object written with more parity than the set default, for example `EC:8` on a 16-drive set whose default is `EC:4`, can hold copies on only nine drives, and a random eight of the sixteen then holds fewer than the four copies a listing needs in roughly one listing in six. `disk` and `reduced` sample fewer drives and have both limits more often. Keep `strict` wherever every committed object must appear, such as backup, sync, replication and lifecycle tooling. None of them relaxes object quorum, object locks, or version checks.
### 3.1 Retired listing index: upgrade and rollback
ListObjects uses the disk walker; the experimental `RUSTFS_LIST_OBJECTS_INDEX_*` provider is retired. New writers no longer update its namespace mutation journal. An older node can therefore mistake a persistent index snapshot for a current one and omit newly written keys. Deployments that never used persistent index snapshots need no additional rollout step.
After all nodes have upgraded to literal listing markers, restart any unfinished version-listing pagination from the beginning, including manual transition jobs resumed from older checkpoints. Restart V1 pagination chains holding escaped markers as well. During a mixed-version rollout, old and new nodes interpret cache-shaped key markers differently, so do not continue those pagination chains until the upgrade is complete.
For deployments that used persistent snapshots:
1. Before replacing any binary, first restart every old node with `RUSTFS_LIST_OBJECTS_INDEX_MODE` unset. Verify the running configuration on every node and finish this configuration rollout while all nodes still use the old binary.
@@ -105,6 +105,12 @@ locking, and movement guards still apply. Prefix recovery settles only matching
keys, including transactions hidden from listings. Dry-run inspects and reports without
settling transactions.
LIST does not remove historical residue on updated nodes. During a rolling
upgrade, older nodes can still remove it while serving LIST. A bucket with
hidden residue can still fail DeleteBucket; recover known keys with an
unconditional exact-key DELETE, or inspect and remove eligible residue through
administrator heal.
An unconditional DELETE of a missing exact key, or an administrator heal with
`remove` enabled, can authorize historical residue removal only when the
bucket is authoritatively never-versioned and has no Object Lock. Every
+5 -15
View File
@@ -1,7 +1,7 @@
# Hotpath warp runbook (A/B gate and ABBA evidence)
**Use this when:** you need S3-face (PutObject / GetObject / mixed) performance evidence for a code change: the nightly relative-budget gate, a quick local A/B, or a formal ABBA run whose numbers will be quoted in a PR.
**Source of truth:** `scripts/run_hotpath_warp_ab.sh` (quick A/B rig), `scripts/run_hotpath_warp_abba.sh` (ABBA runner; also what CI executes), `scripts/hotpath_warp_ab_gate.sh` (relative-budget gate), `scripts/run_object_batch_bench_enhanced.sh` (warp driver, medians, `baseline_compare.csv`), `.github/workflows/performance-ab.yml` (CI gate).
**Use this when:** you need S3-face (PutObject / GetObject / mixed) performance evidence for a code change: a quick local A/B, or a formal ABBA run whose numbers will be quoted in a PR.
**Source of truth:** `scripts/run_hotpath_warp_ab.sh` (quick A/B rig), `scripts/run_hotpath_warp_abba.sh` (ABBA runner), `scripts/hotpath_warp_ab_gate.sh` (relative-budget gate), `scripts/run_object_batch_bench_enhanced.sh` (warp driver, medians, `baseline_compare.csv`).
This runbook compares two binaries. To sweep one `RUSTFS_*` runtime knob at a time against a fixed binary, use [object-io-tuning-ab-matrix.md](object-io-tuning-ab-matrix.md) instead.
@@ -10,7 +10,7 @@ Two entry points share one workload matrix, one gate, and one deploy-hook shape:
| Entry | Script | Legs per cell | Use |
| --- | --- | --- | --- |
| Quick A/B | `scripts/run_hotpath_warp_ab.sh` | baseline → candidate | local smoke, fast triage of a suspected regression |
| ABBA | `scripts/run_hotpath_warp_abba.sh` | A1 baseline → B1 candidate → B2 candidate → A2 baseline | formal evidence; the CI gate |
| ABBA | `scripts/run_hotpath_warp_abba.sh` | A1 baseline → B1 candidate → B2 candidate → A2 baseline | formal evidence |
This rig is the only entry point for S3-face performance coverage. There is deliberately no in-process criterion benchmark for those operations: a criterion harness that stands up an embedded server measures the harness, not the S3 path. Micro-benchmarks stay at function level (EC encode, `xl.meta` parse, `rename_data`). Knob-level sweeps against a fixed binary are a different question; see [object-io-tuning-ab-matrix.md](object-io-tuning-ab-matrix.md).
@@ -163,16 +163,6 @@ After each window confirm the `.json.gz` profile and its `.syms.json` sidecar ar
Instrumented builds (features in `rustfs/Cargo.toml`): `--features hotpath-alloc` for allocation attribution, `--features hotpath-cpu` for CPU hotpath sections. Compare instrumented binaries only with other builds of the same mode; never use them for throughput acceptance, because the instrumentation changes what is measured.
## CI gate (`performance-ab.yml`)
## Performance gate status
| Aspect | Value |
| --- | --- |
| Triggers | `schedule` (nightly cron `31 6 * * *` UTC against `main`) and `workflow_dispatch` (inputs `duration`, default `12s`; `allow_regression`, boolean). No `pull_request` trigger and no label gating. |
| Jobs | `warp-ab` (runner `sm-standard-2`, `timeout-minutes: 180`); `alert-on-failure` (opens the scheduled-failure issue; scheduled runs only). |
| Baseline commit | scheduled: head of the last successful scheduled run (falls back to the candidate itself when there is none); dispatch: `origin/main`. The baseline must be an ancestor of the candidate. |
| Binary cache | `actions/cache` inside `warp-ab`, key `rustfs-baseline-<baseline_sha>`. Miss: source build (same-commit runs build once and reuse the binary for both phases); a run whose gate passes saves the candidate binary under `rustfs-baseline-<candidate_sha>` for the next night. |
| Command | `scripts/run_hotpath_warp_abba.sh --duration <input> --rounds 3 --cooldown 5 --health-timeout 180 --baseline-revision <sha> --candidate-revision <sha> --baseline-bin ... --candidate-bin ...` |
| Exemption | `allow_regression=true` on dispatch adds `--allow-regression --exemption-reason "workflow dispatch override"`. |
| Artifacts | `hotpath-warp-ab-<run_number>` containing `target/hotpath-abba/` (14-day retention); the step summary renders `candidate_gate.md`, or the server-log tails when the rig failed before the gate. The `Enforce gate` step fails the job on a non-zero rig exit. |
The nightly detects a regression within a day of landing; it does not block a merge. For pre-merge evidence run the ABBA procedure above and attach the outputs to the PR.
There is no scheduled CI performance gate. The ABBA procedure above is the evidence path for performance comparisons; attach its outputs to the PR that claims a performance effect. A scheduled regression gate requires calibrated budgets: on the previous gate's runner, measured drift between identical binaries at the default 12-second rounds exceeded the former 10% fail budget, so the scheduled entry never seeded a baseline (the workflow only ever passed on its pre-removal push trigger). Restore a scheduled gate only after a noise study grounds the budgets on the target runner.
+1 -1
View File
@@ -41,7 +41,7 @@ The `--abba` mode runs five independent scenario cells: `cold-hot`, `fresh-hot`,
three A1/B1/B2/A2 groups for both baseline/candidate with background work on,
and candidate-only background off/on. A measured release leg lasts at least 7200
seconds; the minimum matrix contains 120 legs (240 hours before setup/oracles).
The existing `performance-ab.yml` supplies the pattern for immutable build
The hotpath warp ABBA rig (`scripts/run_hotpath_warp_abba.sh`) supplies the pattern for immutable build
provenance and failure propagation, but its short Warp workload is not this
scanner gate. No scheduled workflow starts this matrix automatically.
+12 -8
View File
@@ -561,11 +561,11 @@ Heal knobs are environment-only and read by `HealConfig::default` (`crates/heal/
| `RUSTFS_HEAL_EVENT_DRIVEN_SCHEDULER_ENABLE` | `true` (`DEFAULT_HEAL_EVENT_DRIVEN_SCHEDULER_ENABLE`) | Notify-driven scheduler wakeups. |
| `RUSTFS_HEAL_SET_BULKHEAD_ENABLE` | `true` (`DEFAULT_HEAL_SET_BULKHEAD_ENABLE`) | Per-set bulkhead scheduling. |
| `RUSTFS_HEAL_PAGE_PARALLEL_ENABLE` | `true` (`DEFAULT_HEAL_PAGE_PARALLEL_ENABLE`) | Page-level parallel object healing during erasure-set repair. |
| `RUSTFS_HEAL_PAGE_OBJECT_CONCURRENCY` | `8` (`DEFAULT_HEAL_PAGE_OBJECT_CONCURRENCY`) | Concurrent object heals within one erasure-set page. Forced to `1` when page parallelism is off, for `Deep` scan mode, and for `AutoHeal`-sourced requests (`ErasureSetHealer::effective_heal_page_object_concurrency_for_source`). |
| `RUSTFS_HEAL_MAINLINE_THROTTLE_ENABLE` | `true` (`DEFAULT_HEAL_MAINLINE_THROTTLE_ENABLE`) | Defer best-effort starts and cooperatively pace running admin heal at safe work boundaries. |
| `RUSTFS_HEAL_MAINLINE_READ_UTILIZATION_HIGH_PERCENT` | `80` (`DEFAULT_HEAL_MAINLINE_READ_UTILIZATION_HIGH_PERCENT`, capped at 100) | Read-utilization high watermark for start admission and running admin pacing; zero disables this class. |
| `RUSTFS_HEAL_MAINLINE_WRITE_UTILIZATION_HIGH_PERCENT` | `80` (`DEFAULT_HEAL_MAINLINE_WRITE_UTILIZATION_HIGH_PERCENT`, capped at 100) | Write-utilization high watermark for start admission and running admin pacing; zero disables this class. |
| `RUSTFS_HEAL_MAINLINE_MAX_SLEEP_MS` | `250` (`DEFAULT_HEAL_MAINLINE_MAX_SLEEP_MS`) | Start recheck interval; running admin waits cap each pacing-gate holder at 1000 ms. Zero disables running pacing. |
| `RUSTFS_HEAL_PAGE_OBJECT_CONCURRENCY` | `8` for ordinary heals; `2` for automatic replacement | Concurrent object heals within one erasure-set page. An explicit value applies to both; automatic replacement clamps it to the semaphore's supported range. Forced to `1` when page parallelism is off, for `Deep` scans, and for automatic heals without a replacement generation or object locks. |
| `RUSTFS_HEAL_MAINLINE_THROTTLE_ENABLE` | `true` (`DEFAULT_HEAL_MAINLINE_THROTTLE_ENABLE`) | Defer best-effort starts and cooperatively pace running admin and automatic replacement heals at safe work boundaries. |
| `RUSTFS_HEAL_MAINLINE_READ_UTILIZATION_HIGH_PERCENT` | `80` (`DEFAULT_HEAL_MAINLINE_READ_UTILIZATION_HIGH_PERCENT`, capped at 100) | Read-utilization high watermark for start admission and running heal pacing; zero disables this class. |
| `RUSTFS_HEAL_MAINLINE_WRITE_UTILIZATION_HIGH_PERCENT` | `80` (`DEFAULT_HEAL_MAINLINE_WRITE_UTILIZATION_HIGH_PERCENT`, capped at 100) | Write-utilization high watermark for start admission and running heal pacing; zero disables this class. |
| `RUSTFS_HEAL_MAINLINE_MAX_SLEEP_MS` | `250` (`DEFAULT_HEAL_MAINLINE_MAX_SLEEP_MS`) | Start recheck interval; running waits cap each pacing-gate holder at 1000 ms. Zero disables running pacing. |
| `RUSTFS_HEAL_OVERLAP_POLICY` | `merge` (`DEFAULT_HEAL_OVERLAP_POLICY`) | `merge` reuses the token of an equivalent active, queued, or retrying admin heal; incompatible same-target starts and intersecting admin scopes return `already_running` / `overlapping_paths`. Durable-only owners reject until recovered or replaced with `forceStart`. `minio_error` also rejects equivalent starts and preserves overlap rejection against background tasks. |
| `RUSTFS_HEAL_MRF_ENABLE` | `true` (`DEFAULT_HEAL_MRF_ENABLE`) | MRF intent pipeline: error paths deliver repair intents to the heal runtime and unconsumed intents replay from the durable journal after restart. |
| `RUSTFS_HEAL_MRF_QUEUE_SIZE` | `100000` (`DEFAULT_HEAL_MRF_QUEUE_SIZE`) | MRF in-memory queue capacity. |
@@ -595,6 +595,8 @@ Admin heal has three separate budgets. Increasing one does not extend the others
| Task execution | `RUSTFS_HEAL_TASK_TIMEOUT_SECS` supplies the default of 300 seconds when the execution has no explicit timeout. Elapsed execution time is deducted before a recoverable scheduler retry, which keeps the request identity and remaining budget. Object/listing backoff and pressure pacing inside an execution consume that budget; time queued or in the scheduler's between-attempt backoff is not a new absolute wall-clock deadline. Zero is not an unlimited execution budget. |
| Object/listing retry | A recursive bucket/prefix traversal retries recoverable failures at most three times after the initial attempt, with 2/4/8-second delays plus the existing task-derived jitter. Object retries also obey the bounded delayed window and its 30-second age, checked at safe boundaries; listing retries keep their cursor. These limits do not extend the task execution budget or force an in-flight storage operation to finish within the retry age. |
Automatic replacement retries a transient object repair twice within its current page, waiting 250 ms and then 500 ms without holding page capacity. An exhausted retry remains unverified and still triggers the existing generation retry; target readback and checkpoint requirements are unchanged.
For a large admin heal, select a finite configured execution budget appropriate to the expected work and contention, and inspect progress while it runs. Investigate stalled work and object failures before increasing this budget; a larger timeout must not hide lock or quorum problems. A longer HTTP or internode timeout does not keep a repair task alive past its execution budget. A successful start response returns the canonical token for accepted or merged work; it does not prove that repair has completed. Query that token without `forceStart`; terminal timeout/cancellation reports retain completed progress while the task report remains available.
Capability preflight runs before constructing and admitting a start request. However, the public `cluster heal coordination unavailable` error can also follow a transport failure or an invalid coordinator response after a request was sent. An unknown or lost HTTP response is therefore not proof that no task was admitted.
@@ -605,13 +607,13 @@ Implementation references: `rustfs/src/admin/handlers/heal.rs` (`submit_cluster_
### Running admin heal pacing
The manager passes its existing workload provider and a configuration snapshot into each admin execution. Bucket/prefix listing and object boundaries resample foreground pressure; erasure-set page workers also resample after earlier work releases page capacity. `High`, `Urgent`, and `force_start` do not exempt ordinary admin execution from this runtime pacing. The existing start-time bypass and overlap-control meanings are unchanged.
The manager passes its existing workload provider and a configuration snapshot into each admin execution and automatic replacement generation. Bucket/prefix listing and object boundaries resample foreground pressure; erasure-set page workers also resample after earlier work releases page capacity. `High`, `Urgent`, and `force_start` do not exempt ordinary admin execution from this runtime pacing. The existing start-time bypass and overlap-control meanings are unchanged.
Each execution has its own pacing latch, with no new global manager or cross-set pacing lock. The low watermark for each enabled class is `max(1, floor(high * 3 / 4))`: the default high watermark 80 therefore recovers below 60. High pressure latches pacing, and intermediate pressure resets the recovery window. Unpaced starts resume after sampled pressure remains below the low watermarks for four pause intervals, normally one second. While pressure persists, a pacing-gate holder waits only one interval, at most one second, then permits maintenance to continue. Concurrent page waiters serialize through this task-local gate; queue waiting still counts against the existing task execution timeout.
The pacing gate holds neither namespace locks nor I/O/page permits while sleeping. At final page admission, each real permit acquisition gets a fresh, nonblocking pressure decision. Low-pressure work keeps that permit; only a unit that needs a pause releases capacity to wait. A unit that has completed one bounded pause may proceed despite persistent pressure, which supplies minimum maintenance progress without an endless acquire/pause loop. Existing object operations and commit tails are not interrupted because pressure rose. Cancellation and deadlines remain interruptible, and disabling pacing cannot bypass the global, per-set or page-concurrency hard caps. The existing `RUSTFS_HEAL_MAINLINE_THROTTLE_ENABLE=false` setting is the operational opt-out for newly created executions; no additional request override is introduced.
The pacing gate holds neither namespace locks nor I/O/page permits while sleeping. At final page admission, each real permit acquisition gets a fresh, nonblocking pressure decision. Low-pressure work keeps that permit; only a unit that needs a pause releases capacity to wait. A unit that has completed one bounded pause may proceed despite persistent pressure, which supplies minimum maintenance progress without an endless acquire/pause loop. Existing object operations and commit tails are not interrupted because pressure rose. Cancellation interrupts the pacing wait; replacement operations retain their execution lease until storage work settles. Disabling pacing cannot bypass the global, per-set or page-concurrency hard caps. The existing `RUSTFS_HEAL_MAINLINE_THROTTLE_ENABLE=false` setting is the operational opt-out for newly created executions; no additional request override is introduced.
A missing provider, zero pause, or both class thresholds set to zero preserves unpaced execution. Missing counts follow the existing shared pressure interpreter; they are observations, not health, quorum or resource-ownership proof. The current provider exposes node-level workload classes, so this does not claim independent per-set foreground measurements or a hard global resource budget. Runtime waits increment `rustfs_heal_mainline_throttle_total` with `source=admin`, `result=delayed`, and a foreground-pressure or `recovery_window` reason. Real p99/throughput protection requires the separate W20 fixed-load ABBA measurements.
A missing provider, zero pause, or both class thresholds set to zero preserves unpaced execution. Missing counts follow the existing shared pressure interpreter; they are observations, not health, quorum or resource-ownership proof. The current provider exposes node-level workload classes, so this does not claim independent per-set foreground measurements or a hard global resource budget. Runtime waits increment `rustfs_heal_mainline_throttle_total` with `source=admin` or `source=auto_heal`, `result=delayed`, and a foreground-pressure or `recovery_window` reason. Real p99/throughput protection requires the separate W20 fixed-load ABBA measurements.
## Live Scanner Heal Completions
@@ -727,6 +729,8 @@ Stale-upload cleanup differs in one crash-recovery detail. RustFS's stale multip
## Replacement Recovery Completion
One reconnect sweep groups the newly attached blank disks in a set. If another reconnect is still pending, the ready targets are submitted after a one-second window rather than waiting indefinitely; later targets are submitted separately. Compatible automatic requests can add targets while the owner is still queued and has never executed or recovered a durable generation. Once execution or recovery fixes the target scope, later disks cannot join that generation or receive a merged receipt from it. Blank disks outside the frozen scope remain discoverable for the next automatic scan after the owner finishes.
`POST /v3/background-heal/status` is an execution-queue view. `state=idle`, zero queue and active counts, an online disk, a readable object, or acceptance of an admin deep-heal request do not independently prove that a replacement disk contains every erasure shard.
Treat replacement recovery as verified only after the repair task has completed for the exact replacement instance and an operator has confirmed the target disk contains the expected `xl.meta` and data parts for every relevant object version. A replacement that is not mounted, is unsafe to format, loses its marker, or returns a partial target outcome must be treated as deferred or incomplete. Do not automate destructive replacement actions from an `idle` observation alone. A new node must not infer replacement completion from an old or unavailable peer; regard that information as unknown or degraded until every required peer can report the same replacement instance and verified completion.
+2
View File
@@ -28,6 +28,8 @@ The [scanner checkpoint fixture](scanner-checkpoint-fixture.md) diagnoses retain
The [scanner cache cost profile](scanner-cache-cost.md) separates clone, subtree copy, encoding, and counted save costs without changing production cache behavior.
The [scanner memory acceptance runner](scanner-memory-acceptance.md) verifies owned S3 datasets, scanner restart progress, and Linux process/cgroup memory evidence under a fixed container limit.
The [Pool layout compatibility reference](pool-layout-compatibility.md) defines the topology and EC regression matrix for single-drive, single-node multi-drive, and multi-node expansion pools.
The [Scanner/Heal topology protection gates](scanner-heal-topology-gates.md) map G-EXP, G-REB and G-DEC and their fault windows to the `scanner_heal_topology_` cases, their recorded inventory, and the basis of their timeouts.
+1 -2
View File
@@ -99,7 +99,6 @@ Scheduled lanes never block a PR. Their workflow-local gate fails the run, sched
| `fuzz.yml` (nightly) | `nightly-fuzz-corpus` per target | gate; corpus and crash artifacts | yes | `MAX_TOTAL_TIME=<seconds> ./scripts/fuzz/run.sh` |
| `minio-interop.yml` (nightly) | `minio-interop` | EC + SSE read-parity gate | yes, with `never_ran_grace_until` | pinned Docker fixture steps in the workflow |
| `on-demand-migration-interop.yml` (nightly) | `minio-source`, `cloud-source` (`aws`, `r2`, `gcs`) | report-only provider interop; one JSON report per provider naming cases, timings and source request counts, plus JUnit and MinIO logs. A cloud provider whose `ODM_INTEROP_*` secrets are absent is skipped with a summary note, not failed | no | start the pinned MinIO container as in the job, export the `RUSTFS_ODM_INTEROP_*` variables, run `python3 scripts/e2e_binary.py build --bins`, then `python3 scripts/e2e_binary.py run -- cargo nextest run --profile e2e-odm-interop -p e2e_test` |
| `performance-ab.yml` (nightly) | `warp-ab` | regression-budget gate; A/B summaries and server logs | yes | `bash scripts/run_hotpath_warp_abba.sh --help` |
| `nightly-gnu.yml` (nightly) | `build`, `kms-vault-lane`, `kms-vault-ha-failover` | build, live Vault, and HA failover gates | yes | commands and pinned Vault images in the workflow |
| `audit.yml` (nightly) | `cargo-deny`, `workflow-pin-report` | dependency and workflow-pin gates | yes | `cargo deny check`; `scripts/security/check_workflow_pins.sh` |
| `mint.yml` (weekly) | `mint` | report-only by design; per-suite PASS/FAIL/NA and raw `log.json` | yes | pinned Docker sequence in the workflow |
@@ -111,7 +110,7 @@ Scheduled lanes never block a PR. Their workflow-local gate fails the run, sched
| `gateway-integration-sync.yml` (weekdays) | `sync` | merges `main` into `gateway/integration` as a `--no-ff` merge commit; a conflicting merge is aborted and opens a `sync/<source>-<date>` pull request listing the conflicting files, which keeps the run green; a rejected push or missing pull request fails it | no | dispatch, optionally with `source_ref` |
| `scheduled-validation-freshness.yml` (nightly) | `check-freshness` | fails on missing or stale attempts or completed successes | n/a | dispatch |
Manual `workflow_dispatch` runs are debugging evidence and do not open scheduled-failure issues. A manual performance run may explicitly allow a known regression; that override is not a passing baseline.
Manual `workflow_dispatch` runs are debugging evidence and do not open scheduled-failure issues.
## Packaged functional acceptance
+26 -1
View File
@@ -33,7 +33,7 @@ The expansion fixture is an all-current-binary fleet, so it initializes pool met
- Pool expand, decommission, rebalance, checksum integrity, versioned and multipart data, and S3 during active movement
- Bidirectional site-replication convergence plus enabled/synchronized peer state on both sites
- A 24-worker mixed PUT/HEAD/GET/COPY/DELETE workload; concurrent PUT during active decommission
- Node kill/restart, full process restart, node-facing TCP blackhole/recovery, in-flight streaming GETs across a peer kill, and fresh-drive replacement verified by physical `xl.meta`/part-shard census
- Node kill/restart, full process restart, node-facing TCP blackhole/recovery, in-flight streaming GETs across a peer kill, and fresh-drive replacement verified by physical `xl.meta`/part-shard census. The EC:4 partial-return case pins one 16-drive set and actual 12+4 object metadata, stops two nodes (eight drives), then restores only one. All three live coordinators must read existing objects and write/read new objects while the fourth node remains stopped. This is a process-fault acceptance test, not physical disk-unmount evidence.
- Multipart, cross-node listing, list-buckets agreement
- Direct and rolling upgrade from the pinned previous release: historical objects, versioned history, and IAM user AK/SK still work afterwards
- The `scanner_heal_topology_` protection matrix: versioned, locked and multipart inventory verified through every node across expansion, rebalance stop/restart and queued decommission, with kill and commit-failure injection at proven movement windows; gate map and timeouts in [scanner-heal-topology-gates.md](scanner-heal-topology-gates.md)
@@ -143,3 +143,28 @@ python3 scripts/e2e_binary.py run --features e2e-test-hooks -- \
`RUSTFS_E2E_LOG_FILTER` optionally overrides node log filtering for distributed fixtures; its absence preserves each fixture's existing filter. Use a narrow module filter when diagnosing RPC responses and keep logs with the matching server provenance.
`RUSTFS_E2E_MPU_LOAD_ROUNDS` accepts 1–256 and defaults to three rounds. Allocate a private target directory and bounded CPU/memory resources before running the eight-process case. Temporary drive directories follow `TMPDIR`; use a task-owned directory on the intended filesystem. These are plaintext loopback processes and logical drives on one host, so passing them does not establish physical-drive independence, TLS behavior, the original incident cause, or a throughput improvement. Performance comparisons require a separate controlled baseline and drift-checked measurement.
### Namespace owner release evidence
For distributed MPU diagnostics, retain the HTTP request span as well as the lock events:
```bash
RUSTFS_OBJECT_LOCK_DIAG_ENABLE=true \
RUSTFS_E2E_LOG_FILTER='warn,rustfs::server::http=info,rustfs_ecstore::object_lock_diag=trace,rustfs_lock::namespace_owner=trace' \
RUSTFS_E2E_LOG_DIR=/path/to/isolated-run/logs \
python3 scripts/e2e_binary.py run --features e2e-test-hooks -- \
cargo nextest run -p e2e_test --profile e2e-distributed --test-threads 1 \
-E 'test(=distributed::multipart_fence_acceptance_test::native_certified_partial_abort_retry_reclaims_survivors_below_metadata_quorum)'
```
Match the S3 response request ID to the retained `http-request` span. The `object_namespace_lock` acquired and guard-dropped events carry `lock_attempt_id` and the logical guard `lock_id`. Each `object_namespace_lock_peer` event maps that guard to an actual granted `peer_lock_id`; acquisition retries can give peers an ID different from the logical guard. Join `peer_lock_id` to the `lock_id` in `namespace_lock_release`. Its local `state=mapped` record identifies the process-local `physical_guard_id`; the separate `state=released` record is emitted only after the underlying manager actually releases that guard and unregisters it. A stale or disabled guard cannot produce that physical completion record. Remote `state=acknowledged` records require a successful unlock response without an error; they describe the remote response, while physical completion still requires the peer's local record. Batch acknowledgements retain each actual lock ID and peer. Missing, false or errored release responses do not produce an acknowledgement. Combine `process_id` and physical guard IDs with the retained node process/lifecycle provenance; guard IDs are unique only within one process.
`guard_dropped` still means that logical guard ownership ended; asynchronous peer cleanup can follow it. The local mapping is logged after dropping the guard so logging does not delay that physical release; join records by identity rather than arrival order. A lock-acquisition failure or a fast local guard has no acquired distributed lock ID. Transport request IDs may differ from the S3 request ID; use the actual lock identity rather than assuming equality. These events describe ordinary namespace cleanup; forced release and lease reaping require separate evidence. They do not establish completion of native filesystem callbacks, snapshot leases or detached mutation tails. Preserve their separate physical-ownership evidence and the final scoped lock-table snapshots. This diagnostic configuration increases logging and is not a performance-measurement configuration.
### Late part transaction packets
The registered late-Complete case also retains original signed `PreparePartTransaction` and `SettlePartTransaction(Commit)` packets from an 8 MiB replacement of an acknowledged 9 MiB part. Two peers hold one original packet each until the single-attempt S3 replacement fails below quorum. The one-packet limit permits fresh recovery requests for that same upload. Ordinary Abort must reclaim every original source before delivery. Each method runs with and without same-name bucket recreation.
Before releasing the old packets, create and acknowledge a new upload at the same key. Preserve every fresh source/control/shard byte, then require the original packets to reach their peers with successful gRPC transport and their typed application result. Prepare must refuse. Settlement after its transaction directory was removed may return idempotent success; success alone does not establish that no write occurred. The case separately checks absent old roots, exact fresh-source snapshots, the prior committed object through all four clients, old-upload NoSuchUpload, and completion of the fresh upload with its full acknowledged body. No expired packet is discarded or re-signed to obtain this delivery evidence.
Proxy regressions retain unknown protobuf fields and gRPC trailers, distinguish success/refusal/ambiguous results, inspect the captured settlement action, and exclude gRPC authentication failure from application outcomes. The local `native_multipart_late_part_mutators_cannot_recreate_removed_upload` regression additionally supplies an existing TMP shard while the source is missing, so missing temporary input cannot substitute for source-admission protection. These EC:2 non-inline cases do not qualify late rollback packets, empty-part transactions, other layouts or actual server-task cancellation.
+106
View File
@@ -0,0 +1,106 @@
# Scanner memory acceptance
**Use this when:** validating scanner memory with large S3 namespaces, a memory-limited Linux process, interrupted scans, or a separate scanner-disabled request burst.
**Source of truth:** `scripts/scanner_memory_acceptance.py`; scanner status and usage contracts in `rustfs/src/admin/handlers/scanner.rs`, `rustfs/src/admin/handlers/account_info.rs`, and `crates/scanner-metrics/src/metrics.rs`.
This is an operator-run acceptance harness, not a CI gate or a throughput benchmark. It creates real S3 objects and preserves raw observations in a private run directory. It never starts, restarts, stops, or removes a service, container, volume, bucket, or base fixture object. Service lifecycle belongs to the operator. The only deletes are copies created under the current run's uniquely named burst prefix.
## Evidence and limits
Run wide and nested layouts as separate manifests. The defaults are 2,000,000 objects of 50 KiB, with 170,000 direct source prefixes for the wide layout. Nested keys put the same sources below `photos/xx/yy/`. Payloads are deterministic pseudorandom bytes, shared by all fixture objects; they are not a variable-size photo distribution. Each bucket also contains one small ownership marker. Exact listing and scanner usage checks include this marker separately, so expected scanner totals are **N + 1 objects**, with the marker's serialized bytes added to logical payload size.
Every live observation verifies the running executable digest, process start identity, cgroup v2 memory limit and S3 endpoint binding. Docker mode additionally verifies the container's run label, bound data source and, for named volumes, the volume's run label. The source revision is declared build provenance; a digest match proves which binary is running, not that the declared source was independently rebuilt by this script. Keep the exact source archive and build receipt beside the evidence.
Samples distinguish process RSS, anonymous RSS, `smaps_rollup` anonymous memory, cgroup current/peak usage, anonymous/file/slab/kernel accounting, swap and OOM events. A missing reading remains unknown. Optional Prometheus collection accepts live allocator requested bytes only when `rustfs_memory_allocator_malloc_requested_bytes_available` equals one. Reserved/committed bytes and allocation totals are not substitutes for active heap.
`summary.json` reports phase measurements and restart evidence, but deliberately does not automatically certify overall acceptance. A finite run cannot prove a universal memory bound, and RSS retention alone is not a memory-leak diagnosis. Inspect the trend across completed cycles, anonymous memory versus file/slab accounting, live heap when available, and the burst idle window. Record the actual object scale, duration, resource limit and unavailable measurements in the conclusion. A small pilot does not satisfy the 2M-object case.
## Prerequisites and ownership
Use Python 3.11+ with `boto3`, a disposable local RustFS instance, and disposable credentials supplied as `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY`. The runner bypasses HTTP proxies explicitly for localhost traffic. It accepts only literal `127.0.0.1` or `::1` endpoints with explicit ports; ambiguous `localhost` names are rejected. TCP bindings must match the endpoint address and IP family, or an explicitly observed wildcard in that same family. Credentials and raw SDK error messages are never written to evidence.
Docker containers need `sh`, `cat`, `stat`, `readlink`, `sha256sum`, `df`, and `tail`, a readable `/proc`, and cgroup v2. Bind the tested binary read-only. Use a four-GiB memory limit and, for an exact no-swap limit, matching `--memory-swap=4g`. The runner records and verifies the actual kernel limit rather than trusting a launch command. Native Linux `--pid PID` mode requires the current user to own the process, a dedicated cgroup containing only that PID, the endpoint listening socket to belong to it, and the same mount namespace and filesystem root as the observer.
`prepare` creates a new mode-0700 run directory and immutable manifest. It prints a generated run ID, bucket name and required label. The bucket is never selected by a user-supplied name. On every writing batch, the runner verifies its exact ownership marker and requires versioning to remain disabled. Resume markers bind to the manifest digest. Reusing an existing unmarked bucket, a foreign container, or a foreign volume fails closed.
Before any workload write and on every live sample, the runner reads authenticated `/rustfs/admin/v3/storageinfo` from the serving process. Credentials need permission for this route as well as scanner status. The inventory must be fresh, complete against `DrivesPerSet`/`TotalSets`, healthy and local to one node; missing, cached, remote or incomplete observations fail closed. Every effective disk path must resolve inside the manifest's bind/named-volume mount or native `data_root`, on that same filesystem. Symlink escapes, nested mounts and a different process mount namespace/root are rejected. The inventory reflects expanded server configuration, including positional CLI arguments that override `RUSTFS_VOLUMES`; an unused owned mount cannot authorize a foreign write directory. Storage bindings are hashed into phase identity. Resolved disk paths and raw storage-info responses stay out of samples.
The storage reserve is one GiB plus 1.2 times remaining payload and 16 KiB per-object metadata allowance. Bind/native modes check host free space at preparation. Named-volume mode defers the capacity check until the labelled container is running, and checks `df` inside the mounted volume before the first PUT. Batches recheck the sampled free-space allowance. This is a guard against an undersized test volume, not a promise about every filesystem's physical overhead.
## Prepare and run a small pilot
Use a new absolute output path. Values below are placeholders; source and binary digests must be taken from the exact build being tested. Keep binary/build artifacts outside the repository.
```sh
python3 scripts/scanner_memory_acceptance.py prepare /tmp/scanner-memory-wide-pilot \
--endpoint http://127.0.0.1:19000 \
--source-sha SOURCE_COMMIT_SHA \
--binary-sha256 BINARY_SHA256 \
--layout wide --objects 4096 --root-prefixes 256 --object-bytes 51200 \
--data-volume scanner-memory-wide-pilot
```
Create that **new** named volume and the disposable service container with the printed `rustfs.scanner-memory.run-id=UUID` label. Start with `RUSTFS_SCANNER_ENABLED=false`, healing disabled, the chosen binary and the manifest's four-GiB limit. Publish the S3 port directly to the supplied loopback endpoint. Alternatively use `--data-root /absolute/owned/data` at preparation and bind that exact directory.
```sh
python3 scripts/scanner_memory_acceptance.py load /tmp/scanner-memory-wide-pilot \
--container scanner-memory-wide-pilot --workers 48 --seconds 3600
python3 scripts/scanner_memory_acceptance.py verify /tmp/scanner-memory-wide-pilot
```
Only one action may hold a run directory at a time. Load checkpoints advance after a complete bounded batch. An interrupted batch can be safely repeated because fixture keys and bodies are deterministic and the owned bucket is unversioned. A timeout or failed request leaves progress for the next invocation and exits nonzero; it never produces passing load evidence.
For the full-scale matrix, create separate new manifests with the defaults, once with `--layout wide` and once with `--layout nested`. Do not change a pilot manifest's object count or repurpose its bucket. Allocate adequate disk and wall time; two million S3 PUTs are real work.
## Scanner cycles and external restart
Recreate or restart the owned service externally with scanner enabled, preserving the same volume and binary. Record the chosen runtime knobs, including `RUSTFS_SCANNER_CYCLE`, `RUSTFS_SCANNER_CYCLE_MAX_OBJECTS`, `RUSTFS_SCANNER_CYCLE_MAX_DURATION_SECS`, and the disk I/O budget. Runtime configuration samples capture those values. A change in binary requires a separate manifest.
```sh
python3 scripts/scanner_memory_acceptance.py probe /tmp/scanner-memory-wide-pilot \
--container scanner-memory-wide-pilot --label before_restart \
--seconds 600 --stop-after-progress 128
# Externally stop/restart the owned process while preserving its data.
python3 scripts/scanner_memory_acceptance.py probe /tmp/scanner-memory-wide-pilot \
--container scanner-memory-wide-pilot --label resumed_cycles \
--seconds 1800 --min-completed-cycles 2
```
`--stop-after-progress` ends observation after scanner status reports an active cycle with the requested amount of work. It does **not** cancel the scanner itself. Act on the external process promptly and retain the lifecycle event evidence; a completed cycle before the restart is not an interrupted-scan test. Each invocation has a unique phase ID and binds to one process identity through its final inventory and usage verification. It rejects a restart at any point in that phase. `--seconds` is the total deadline, including verification; a probe exits once its requested progress or new completed-cycle count is observed. A later successful probe on a different identity is recorded as a restart window, after exact S3 inventory and converged admin usage verification. Completion counts include only newly observed successful cycle-end timestamps, not a previous completion present when the probe began.
A budgeted partial sweep is a separate state from process cancellation. Pair real observations with the scanner checkpoint fixture described in [scanner-checkpoint-fixture.md](scanner-checkpoint-fixture.md); do not infer process-crash durability from an in-process test.
`restart_window_observed` alone does not prove checkpoint reuse: the new process could have rescanned the entire namespace. Checkpoint-reuse acceptance additionally needs persisted checkpoint identity/frontier evidence and metadata-visit evidence showing that completed prefixes were retained rather than reread. Likewise, a successful cycle can reuse cached usage; its success does not prove a full namespace walk. Keep the recorded object-visit counts and distinguish full-walk, cached-cycle and restart-reuse evidence in the conclusion.
## Scanner-disabled burst and idle window
Restart externally with scanner disabled, then run the separate request burst:
```sh
python3 scripts/scanner_memory_acceptance.py burst /tmp/scanner-memory-wide-pilot \
--container scanner-memory-wide-pilot --workers 48 \
--burst-objects 4096 --seconds 3600 --idle-seconds 1800
python3 scripts/scanner_memory_acceptance.py summary /tmp/scanner-memory-wide-pilot
```
Each operation copies a base fixture object to an owned scratch key, HEADs its length, and deletes that copy. Completed batches are resumable. Results distinguish the burst ID, initial/final cursors, newly completed objects and cumulative progress. Three logical requests per newly completed object do not count SDK retry attempts; observed HTTP request count remains unknown. An idle-only continuation reports zero new logical requests. `--seconds` includes the workload, inventory verification and the requested idle interval. The full inventory must return to the original exact keys and byte count before the idle window begins. No `delete_objects`, bucket deletion or broad prefix cleanup is performed. An aborted burst can leave owned scratch objects; resume the same burst to reconcile them rather than deleting unrelated keys.
Supply `--allocator-metrics-url http://127.0.0.1:PORT/metrics` to live phases only when that endpoint exports this process's allocator metrics. Without an authenticated binding between that metrics endpoint and the observed process, treat allocator attribution as operator-supplied context. The runner cannot fabricate live-heap evidence from process RSS.
## Artifacts and validation
- `manifest.json`: immutable workload, source declaration, binary digest, storage source and memory limit.
- `state.json`: manifest-bound load and burst resume positions.
- `observations.jsonl`: phase starts/results/errors, raw selected scanner status, process/cgroup samples and inventory oracles.
- `summary.json`: measurements grouped by invocation, process identity and workload/verification/idle stage, sampled-current and kernel-reported cgroup peaks, first/last RSS, allocator availability, operation results and restart-window status.
Raw artifacts may contain local paths, binary fingerprints and configuration. Keep them private; publish only a redacted conclusion and the measurements needed to understand it. There is no automatic GitHub write or cleanup step.
Offline harness tests run without Docker, networking or credentials:
```sh
python3 scripts/test_scanner_memory_acceptance.py
python3 -m py_compile scripts/scanner_memory_acceptance.py scripts/test_scanner_memory_acceptance.py
```
The structural characterization in `crates/scanner/src/scanner_folder/tests/checkpoint_fixture/memory_budget.rs` complements these measurements. Its `SCANNER_MEMORY_BUDGET` rows describe cache entries/capacities, a heap lower bound and wire size after a real small filesystem walk. The seeded coverage-gap case is synthetic and retains a frozen frontier; it is not a real fault injection, process RSS measurement, or 2M-object reproduction. Use it to explain retained structure, then use this harness to measure the running service.
@@ -33,12 +33,15 @@ impl Operation for EnableTableBucketHandler {
let warehouse = warehouse_from_params(&params)?;
let resource = TableCatalogResource::warehouse(&warehouse);
authorize_table_catalog_resource_request(&req, &resource, AdminAction::SetTableBucketAction).await?;
let app_context = runtime_sources::app_context_from_extensions(&req.extensions);
let backend = table_catalog_backend_from_extensions(&req.extensions)?;
let object_store = runtime_sources::object_store_from_req(&req)
.ok_or_else(|| table_catalog_internal_error("request object store is not initialized"))?;
let store = table_catalog_store_from_backend(backend.clone())?;
let publication = TableCommitObjectBackend::preauthorized(backend);
let response = enable_table_bucket_response(&store, &publication, object_store.as_ref(), &warehouse).await?;
crate::app::bucket_usecase::notify_table_bucket_metadata_reload(warehouse.clone(), "enable table bucket", app_context)
.await;
build_json_response(StatusCode::OK, &response)
}
}
@@ -51,12 +54,15 @@ impl Operation for DisableTableBucketHandler {
let warehouse = warehouse_from_params(&params)?;
let resource = TableCatalogResource::warehouse(&warehouse);
authorize_table_catalog_resource_request(&req, &resource, AdminAction::SetTableBucketAction).await?;
let app_context = runtime_sources::app_context_from_extensions(&req.extensions);
let backend = table_catalog_backend_from_extensions(&req.extensions)?;
let object_store = runtime_sources::object_store_from_req(&req)
.ok_or_else(|| table_catalog_internal_error("request object store is not initialized"))?;
let store = table_catalog_store_from_backend(backend.clone())?;
let publication = TableCommitObjectBackend::preauthorized(backend);
let response = disable_table_bucket_response(&store, &publication, object_store.as_ref(), &warehouse).await?;
crate::app::bucket_usecase::notify_table_bucket_metadata_reload(warehouse.clone(), "disable table bucket", app_context)
.await;
build_json_response(StatusCode::OK, &response)
}
}
@@ -1679,6 +1679,26 @@ fn enable_table_bucket_response_fences_before_marker_and_catalog_entry() {
);
}
#[test]
fn table_bucket_toggle_handlers_reload_peer_metadata_after_publication() {
let src = table_catalog_handler_source();
let enable = operation_block(&src, "EnableTableBucketHandler");
let disable = operation_block(&src, "DisableTableBucketHandler");
for (block, response, operation) in [
(enable, "enable_table_bucket_response", "enable table bucket"),
(disable, "disable_table_bucket_response", "disable table bucket"),
] {
let response_offset = block
.find(&format!("let response = {response}"))
.expect("table bucket toggle should publish local state");
let reload = block
.find(&format!("notify_table_bucket_metadata_reload(warehouse.clone(), \"{operation}\""))
.expect("table bucket toggle should reload peer metadata");
assert!(response_offset < reload, "peer reload must follow local publication");
}
}
#[test]
fn table_commit_publication_acquires_migration_permits_before_publication_lock() {
let src = table_catalog_handler_source();
+8
View File
@@ -631,6 +631,14 @@ async fn notify_bucket_metadata_reload(
notify_bucket_metadata_reload_with_context(bucket, operation, request_context, scanner_maintenance_change, None).await;
}
pub(crate) async fn notify_table_bucket_metadata_reload(
bucket: String,
operation: &'static str,
app_context: Option<Arc<AppContext>>,
) {
notify_bucket_metadata_reload_with_context(bucket, operation, None, false, app_context).await;
}
async fn notify_bucket_metadata_reload_with_context(
bucket: String,
operation: &'static str,
+56 -2
View File
@@ -185,16 +185,17 @@ pub(crate) async fn list_objects_v2(
}
for page_index in 0..TABLE_LIST_MAX_RAW_PAGES {
// Table cursors store literal object names, not ecstore continuation tokens.
let raw = store
.clone()
.list_objects_v2(
bucket,
prefix,
marker.clone(),
None,
None,
TABLE_LIST_RAW_PAGE_SIZE,
false,
raw_start_after.take(),
marker.clone().or_else(|| raw_start_after.take()),
incl_deleted,
)
.await
@@ -634,6 +635,59 @@ mod tests {
use crate::app::storage_api::bucket_usecase::StorageObjectInfo as ObjectInfo;
use crate::app::storage_api::multipart_usecase::contract::multipart::MultipartInfo;
#[tokio::test]
#[serial_test::serial]
async fn protected_table_list_resumes_after_a_literal_cache_shaped_key() {
use super::{ListObjectsV2Request, list_objects_v2};
use crate::app::storage_api::bucket_usecase::access::TableDataPlaneListAccess;
use crate::app::storage_api::test::StoragePutObjReader;
use crate::app::storage_api::test::contract::bucket::{BucketOperations as _, MakeBucketOptions};
use crate::app::storage_api::test::contract::object::ObjectIO as _;
let store = crate::app::gating_test_env::shared_gating_ecstore().await;
let bucket = format!("table-marker-{}", uuid::Uuid::new_v4().simple());
store
.make_bucket(&bucket, &MakeBucketOptions::default())
.await
.expect("create test bucket");
let keys = [
"tables/table-id/a0",
"tables/table-id/a1",
"tables/table-id/a[rustfs_cache:v2,return:]",
"tables/table-id/z",
];
for key in keys.into_iter().chain(["tables/zz-denied/hidden"]) {
store
.put_object(&bucket, key, &mut StoragePutObjReader::from_vec(vec![1]), &Default::default())
.await
.expect("seed object");
}
let access = TableDataPlaneListAccess::test_listing_fixture(&bucket);
let mut token = None;
for (index, key) in keys.into_iter().enumerate() {
let page = list_objects_v2(
store.clone(),
&access,
ListObjectsV2Request {
bucket: &bucket,
prefix: "",
continuation_token: token.as_deref(),
delimiter: None,
max_keys: 1,
start_after: None,
incl_deleted: false,
opaque_cursor_supported: true,
},
)
.await
.expect("list next protected page");
assert_eq!(page.objects.iter().map(|object| object.name.as_str()).collect::<Vec<_>>(), [key]);
assert_eq!(page.is_truncated, index + 1 < keys.len());
token = page.next_continuation_token;
}
assert!(token.is_none());
}
#[test]
fn object_list_entry_keeps_an_object_without_a_delimiter() {
let object = ObjectInfo {
+24
View File
@@ -241,6 +241,30 @@ impl TableDataPlaneListCursor {
}
impl TableDataPlaneListAccess {
#[cfg(test)]
pub(crate) fn test_listing_fixture(bucket: &str) -> Self {
let resource = |table: &str, prefix: &str, allowed| TableDataPlaneListResource {
resource: crate::table_catalog::TableDataPlaneResource {
table_bucket: bucket.to_owned(),
namespace: "test".to_owned(),
table: table.to_owned(),
table_id: table.to_owned(),
warehouse_object_prefix: prefix.to_owned(),
},
allowed,
};
Self {
bucket: bucket.to_owned(),
principal_fingerprint: "principal".to_owned(),
snapshot_fingerprint: "snapshot".to_owned(),
cursor_key: [0x42; 32],
resources: vec![
resource("table-id", "tables/table-id/", true),
resource("denied-id", "tables/zz-denied/", false),
],
}
}
pub(crate) fn allows_object(&self, object: &str) -> bool {
if crate::table_catalog::is_reserved_table_object_key(object) {
return false;
+35 -8
View File
@@ -391,14 +391,22 @@ fn calculate_next_marker(v2: &ListObjectsV2Output) -> Option<String> {
// The token was built by this server around the internal cursor, which is
// the last raw key the walker scanned: on a delimiter page that folded into
// fewer prefixes than max-keys it lies past the last visible entry.
if let Some(marker) = v2
if let Some(mut marker) = v2
.next_continuation_token
.as_deref()
.and_then(|token| decode_list_objects_v2_continuation_token(token).ok())
{
// Only cache-shaped literal keys carry this server-generated escape.
let marker = marker.strip_suffix("[rustfs_cache:v2,return:]").unwrap_or(&marker);
return Some(encode_list_output_value(marker.to_owned(), v2.encoding_type.as_ref()));
// V2 cursors protect literal cache-shaped keys with an outer tag. V1
// exposes the key itself, which is sent back as a literal StartAfter.
// RUSTFS_COMPAT_TODO(listing-cursor-legacy-tag): Remove after supported upgrade sources stop issuing tagged V2 cursors.
let unescaped_len = marker
.strip_suffix("[rustfs_cache:v2,return:]")
.filter(|key| key.rfind("[rustfs_cache:").is_some_and(|start| key[start..].contains(']')))
.map(str::len);
if let Some(len) = unescaped_len {
marker.truncate(len);
}
return Some(encode_list_output_value(marker, v2.encoding_type.as_ref()));
}
let last_key = v2
@@ -584,15 +592,34 @@ mod tests {
}
#[test]
fn test_list_objects_next_marker_unwraps_only_the_literal_key_escape() {
fn test_list_objects_next_marker_returns_the_literal_key() {
for key in [
"key[rustfs_cache:v2,return:]",
"key[rustfs_cache:v1,id:x]suffix",
"key[rustfs_cache:v1,id:x][rustfs_cache:v2,return:]",
] {
let cursor = format!("{key}[rustfs_cache:v2,return:]");
for encoding in [None, Some(EncodingType::from_static(EncodingType::URL))] {
let v2 = ListObjectsV2Output {
is_truncated: Some(true),
encoding_type: encoding.clone(),
next_continuation_token: Some(encode_list_objects_v2_continuation_token(&cursor)),
..Default::default()
};
let expected = super::encode_list_output_value(key.to_owned(), encoding.as_ref());
assert_eq!(build_list_objects_output(v2, None).next_marker.as_deref(), Some(expected.as_str()));
}
}
}
#[test]
fn test_list_objects_next_marker_preserves_an_unescaped_literal_return_tag() {
let key = "key[rustfs_cache:v2,return:]";
let cursor = format!("{key}[rustfs_cache:v2,return:]");
let v2 = ListObjectsV2Output {
is_truncated: Some(true),
next_continuation_token: Some(encode_list_objects_v2_continuation_token(&cursor)),
next_continuation_token: Some(encode_list_objects_v2_continuation_token(key)),
..Default::default()
};
assert_eq!(build_list_objects_output(v2, None).next_marker.as_deref(), Some(key));
}

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