Compare commits

...

91 Commits

Author SHA1 Message Date
overtrue 9bc3192b80 Merge main into validation runner 2026-10-28 20:16:48 +08:00
Hauser b1ba6cdfdc test(targets): bound failing-delete replay observations (#9032)
Wait for eight completed delete attempts instead of auto-advancing through
300 seconds of replay scans against a durable on-disk store. Retain the
original virtual upper bound and assert hook/store state after cancelling
and joining the worker.

Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-10-28 20:10:18 +08:00
Chris An 0bfc7612d4 fix(heal): coordinate delete recovery and preserve transient errors (#9031) 2026-10-28 20:02:39 +08:00
Hauser 4c997519e8 refactor(storage): replay decoded dirty journal records (#9030)
Reuse the owned frame's decoded record with bounded canonical size checks
and the existing scanner validation/application core. Keep the complete
outer parser and own the journal state fields without a JSON round trip.

Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-10-28 20:01:43 +08:00
overtrue 2362c54e48 fix(validation): align read-only dispatch defaults 2026-10-28 18:21:21 +08:00
cxymds 40092a3a35 ci(validation): wire persistence and lock source plans 2026-10-28 17:59:23 +08:00
Hauser 95c5d108d1 test(storage): pin dirty journal JSON recursion boundary (#9029)
Cover the neighboring accepted and rejected nesting depths in an owned
replay frame so a future inner-root decode cannot relax its depth limit.

Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-10-28 16:47:48 +08:00
Chris An f6ad79dd58 feat(plugins): add minimal original-user runner integration (#9017)
* feat(plugins): add minimal original-user runner integration

* test(plugins): use the main storage fixture

* fix(plugins): reuse runner connections across requests

* test(plugins): allocate stack for delegation fixture
2026-10-28 13:06:26 +08:00
Hauser 82947d3595 refactor(storage): reuse dirty journal replay JSON frames (#9026)
Embed the validated producer frame without rebuilding an owned replay tree.
Preserve the owner wrapper, byte limits, compaction and unknown marker bytes.

Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-10-28 13:04:57 +08:00
overtrue 4454b029dd fix: merge main into validation runner 2026-10-28 12:12:23 +08:00
hector aef67b6499 ci: move lightweight jobs to GitHub-hosted Ubuntu (#9027)
* ci: move lightweight jobs to GitHub-hosted Ubuntu

Move API-only control jobs and prebuilt Docker image packaging to ubuntu-latest. Keep lab suites and heavy compilation on their existing runners, and remove redundant GitHub CLI installation.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* ci: retain functional health on the automation runner

Restore smoke-testing and the GitHub CLI installation fallback for functional-chain health, as requested by the runner operator.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-28 12:06:00 +08:00
Chris An 8e88419f3a feat(scanner): share node admission with background heal (#8903)
* feat(scanner): share node admission with background heal

* fix(scanner): drain candidates after listing completion

* fix(scanner): yield cache leases for shared node admission

* test(scanner): attest adaptive admission in process receipts

* fix(concurrency): declare Tokio time dependency

* fix(scanner): route test disk access through owner facade

* test(scanner): fix lint warnings
2026-10-28 11:57:29 +08:00
Hauser 0197a4b538 fix(ecstore): harden stale multipart cleanup (#9023)
* fix(ecstore): identify stale multipart cleanup failures

Preserve quorum failure metrics while avoiding an UploadPart warning for background cleanup. Keep the existing write quorum and source recovery checks intact.

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

* perf(ecstore): skip redundant stale upload part scans

Once the default deadline is due, lifecycle rules cannot make an upload ineligible. Avoid querying lifecycle and listing parts in that case while preserving the locked recheck.

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

* fix(ecstore): validate multipart lifecycle size

Skip part scans when no abort rule uses size. Treat unavailable, incomplete or overflowing sizes as unknown and keep size-independent lifecycle rules eligible.

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-28 11:38:23 +08:00
Chris An 7a3bf8cdbd fix(ecstore): resolve dangling latest versions in metadata updates (#9009)
* 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.

* fix(ecstore): serve the latest readable version past dangling versions

A failed versioned PUT whose rollback also failed can leave the new
version on top of fewer drives than its data blocks. Reading only each
drive's top version then left the committed version below it short of
read quorum, so GET and HEAD of the latest version answered 503 although
that version is intact in every stack. MinIO resolves the stacks in
pickLatestQuorumFilesInfo.

When every drive answered and an S3 latest read still cannot select a
version, walk the newest version headers of each stack through
exact-version reads: serve the readable version with the newest selected
modification time, skip versions whose exact read proves them dangling
(the NoSuchVersion an explicit GET reports) and queue heal for them, and
keep the read-quorum error on any other outcome. Candidates are ordered
by each version's newest modification time across drives, and at most
eight exact reads are spent.

Test PUTs now wait for every drive so tests that edit one drive's
metadata no longer race the PUT's background tail writes.

* fix(ecstore): resolve dangling latest versions in metadata updates

A failed versioned PUT whose rollback also failed can leave the new
version on fewer drives than its data blocks. GET and HEAD of the
latest version already look past such versions, but read-before-write
callers without a version id still answered 503: object tagging read
each drive's top version with the quorum-error read, and
put_object_metadata and the CopyObject source read selected from
read_all_xl, whose merged latest version is missing on the other
drives, so object_quorum_from_meta failed before selection.

Object tagging now reads through a variant that runs the same walk past
dangling versions as a latest GET while keeping the quorum error for a
dangling key. put_object_metadata and the CopyObject source try the walk
when their quorum or selection step fails with read quorum, before the
existing dangling cleanup could report a key with a committed version
absent. A version-only copy takes the served version's own copies and
write quorum. The walk still applies only to S3 buckets, latest reads,
and sets where every drive answered, and the update still targets only
the served version's disks, so a version below write quorum is refused
before writing.

* fix(ecstore): refuse version-only copies below write quorum up front

Review of the dangling-latest metadata updates found three gaps:

- A version-only CopyObject wrote the new version and reverted on a
  write-quorum miss; refuse it before writing when the served version's
  disks cannot reach its write quorum, like update_object_meta_with_opts.
- Replication status write-back, including the zero-write ValidateOnly
  mode, now keeps its read-quorum error and never walks past dangling
  versions, since the walk queues heal for the versions it skips.
- The erasure-coding doc overstated that these callers never report a
  dangling key as absent; put_object_metadata and CopyObject keep their
  existing dangling cleanup when the walk finds no readable version.
2026-10-28 08:59:37 +08:00
Hauser 3d3957a248 chore(deps): refresh fuzz workspace dependencies (#9024)
Run cargo update --verbose and cargo upgrade --verbose twice in the
standalone fuzz workspace. Refresh its lockfile, including the fixed
rustls-webpki, webpki-root-certs, zstd, and zstd-safe releases.

Repeat main's pinned SigV4a fix in the standalone workspace and redirect
its shared Smithy types to registry crates, removing crypto-bigint 0.5.5.

Keep pkcs1 at 0.8.0-rc.4 to remain compatible with the workspace-pinned
rsa 0.10.0-rc.18; rc.5 changes the PKCS#1 key types and fails to compile.

Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-10-28 08:40:55 +08:00
Hauser b4026b43b2 feat(observability): attribute local batch shard reads (#8991)
Record local batch eligibility, blocking queue and execution time, shard
outcomes, and actual read bytes behind the existing GET-stage metrics gate.
Keep captured metric handles with the blocking worker after cancellation
and use scoped low-latency histogram buckets for the new duration series.

Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-10-28 07:48:32 +08:00
Chris An 851c295753 fix(rebalance): reuse the target pool holding a dangling copy (#9018)
* fix(rebalance): reuse the target pool holding a dangling copy

Rebalance places a version with a fresh free-space choice whenever the
exact-version pool lookup finds no target copy. An attempt that loses
write quorum can leave the version on fewer drives than read quorum,
and object reads report such a copy as absent (#8963), so the retry
could commit the version on a different pool and leave it in two. The
distributed kill-before-source-cleanup case saw this: a delete marker
whose first copy failed with InsufficientWriteQuorum ended on pools 1
and 3 after the source was cleaned up.

Before the free-space fallback, data movement without a decommission
capacity owner now targets an eligible pool where any drive already
holds the exact version, so the retry rewrites that copy to full
quorum instead of creating a second one. This covers both the object
copy path and the delete-marker path.

* fix(rebalance): check free space before reusing a holding pool

The pool holding an earlier copy was returned before any space check,
so a full holder pinned every retry and could stall a deferred entry.
The holder now passes through the same filters as the free-space
choice (suspended, rebalancing and source pools, has_space_for for the
expected size, and the max-used filter), computed once per selection;
placement falls back to that weighted choice when the holder does not
qualify. The two free-space helpers share one implementation.

The tests no longer rely on random placement. A test-only override
pins each target pool's reported capacity so the free-space choice
always picks the non-holder pool, the earlier copies keep the version
ids but carry different bytes and modification times, and the moved
versions are checked for marker state, modification time, data on
every drive and readable bytes. A second test covers a full holder.

* chore(ci): shrink the error(format!) ratchet baseline for rebalance

The rebalance control and meta modules on main now have fewer
Error::other(format!) call sites than the baseline records, which
fails the shrink-only ratchet in Quick Checks. Record the smaller
counts.
2026-10-28 07:26:56 +08:00
Chris An f879946ead chore: remove redundant review skills (#9022) 2026-10-28 07:03:23 +08:00
cxymds 3e337bad97 ci(validation): bind recovery to retained runner evidence 2026-10-26 15:31:40 +08:00
cxymds 12a23859a1 ci(validation): retain interrupted evidence without fleet recovery 2026-10-26 15:08:15 +08:00
cxymds 081d3b1469 ci(validation): isolate each client virtual environment 2026-10-26 14:39:21 +08:00
cxymds 56a80f7b62 ci(validation): prepare Python before creating client environments 2026-10-26 14:37:43 +08:00
overtrue 0a366d967b chore: merge main into validation runner branch 2026-10-26 12:43:57 +08:00
cxymds 31c9d85645 ci(validation): inspect shared runner without fleet access 2026-10-26 09:59:52 +08:00
overtrue b1f7fb2769 test(scanner): adapt scope regression to admission receipts 2026-10-24 09:35:28 +08:00
overtrue c20448dfce chore: merge main into validation runner 2026-10-24 08:00:59 +08:00
overtrue 887716ca61 chore: merge main into validation runner 2026-10-23 19:34:20 +08:00
cxymds dae95d7afe fix(heal): retain resolved object identity on terminal failures 2026-10-23 11:58:02 +08:00
cxymds b0f60ed8d9 fix(ci): retain validation evidence outside runner temp 2026-10-23 09:43:20 +08:00
cxymds 78c803e1ef fix(heal): retain cancellation after successful bucket storage I/O 2026-10-23 06:26:00 +08:00
cxymds a2d6aa5a7f test(admin): reuse error facade in heal query regression 2026-10-23 05:12:08 +08:00
cxymds 553be4b4c6 fix(heal): query automatic tasks on their owning node 2026-10-23 04:45:20 +08:00
overtrue 96858cd1f1 style: format RPC digest precedence assertion 2026-10-23 04:37:42 +08:00
overtrue 3d357cec44 chore: merge main into validation runner 2026-10-23 04:30:19 +08:00
cxymds 9bce929ae5 test(scanner): reuse storage boundary for scope regression 2026-10-23 01:58:03 +08:00
cxymds a74ddfab3c fix(scanner): bind object heals to the scanning disk scope 2026-10-23 01:22:15 +08:00
cxymds 1676878676 test(validation): wire I05 queued-successor native prerequisites 2026-10-22 21:05:52 +08:00
overtrue b3031bed59 chore: merge main into validation runner 2026-10-21 21:16:33 +08:00
cxymds c86faf193a fix(ci): require complete native admission for multipart races 2026-10-21 10:09:01 +08:00
cxymds e30012c4ef test(scanner): assert typed corruption after metadata truncation 2026-10-20 12:47:13 +08:00
cxymds 2babf7624d ci(validation): admit source-bound owner and copy race proofs 2026-10-20 12:16:00 +08:00
cxymds 757b751482 test(ecstore): cover default heal owners and copy metadata races 2026-10-20 11:32:20 +08:00
cxymds 09fb813a00 test(ecstore): fence multipart continue complete and abort during heal 2026-10-20 10:06:54 +08:00
cxymds 738e5fa14a ci(validation): require G04 native and runtime admission 2026-10-20 09:31:57 +08:00
cxymds 8380466f3a test(ecstore): fence marker and batch deletes during heal 2026-10-20 09:26:19 +08:00
cxymds 170ec8d983 ci(validation): require complete race native runtime preflight 2026-10-20 08:59:50 +08:00
cxymds bd0671ba78 test(ecstore): fence version races and bind cleanup slots 2026-10-20 06:27:54 +08:00
cxymds f954164048 ci(validation): admit source-bound G01 native evidence before fleet execution 2026-10-20 04:35:09 +08:00
cxymds 6afdec9fe1 test(ecstore): exercise heal during committed overwrite cleanup 2026-10-20 04:07:05 +08:00
cxymds 87bd36b313 merge: sync validation fixes with current main 2026-10-20 03:35:22 +08:00
cxymds a4c02d5ac3 fix(filemeta): classify supported inline framing damage as corrupt 2026-10-20 01:59:26 +08:00
cxymds 62ff96774f fix(s3): map typed RPC unavailability to service refusal 2026-10-20 00:34:17 +08:00
cxymds 85c706cac0 fix(observability): retain typed object error diagnostics 2026-10-19 22:13:24 +08:00
cxymds 7951bb6298 fix(heal): retain exhausted admin batch repair receipts 2026-10-16 15:33:15 +08:00
cxymds a3230599a8 fix(ci): allow recovery of completed cancelled validation runs 2026-10-15 19:19:31 +08:00
cxymds 1c7fc818dd fix(ecstore): retain below-quorum data during no-remove heal 2026-10-13 02:02:13 +08:00
cxymds 5475bd06bc chore(ecstore): shrink typed-error ratchet baseline 2026-10-13 01:07:12 +08:00
cxymds a3ba311ab0 fix(ecstore): retain typed quorum errors for missing parts 2026-10-13 00:59:50 +08:00
cxymds e517d35fa2 test(ci): archive scoped F07 directory fsync evidence 2026-10-12 23:47:15 +08:00
cxymds 1b7a197267 fix(ci): isolate validation package publication credentials 2026-10-12 23:30:45 +08:00
Chris 3b416b76cd Merge branch 'main' into cxymds/validation-runner 2026-10-12 06:18:15 -07:00
cxymds bebee3d5d0 ci(validation): verify C11 native storage evidence 2026-10-12 19:07:12 +08:00
cxymds 1aa9be87d1 test(ecstore): verify incarnation fence at repair commit 2026-10-12 18:38:26 +08:00
cxymds e224eb48e5 fix(heal): deeply verify read repair payload failures 2026-10-12 17:40:36 +08:00
cxymds 16cf19817e ci(validation): dispatch isolated packages through the driver 2026-10-12 12:29:11 +08:00
cxymds 32fd1e704a test(validation): verify isolated package publication failures 2026-10-12 12:22:53 +08:00
cxymds ac197ea655 test(validation): enforce private candidate manifest schema 2026-10-12 12:18:15 +08:00
cxymds e18ed73942 ci(validation): publish isolated immutable candidate packages 2026-10-12 12:06:59 +08:00
cxymds 3c131f2b2e fix(heal): preserve typed errors and SSE-C response headers 2026-10-12 11:36:29 +08:00
cxymds c5f4b5a4f7 fix(heal): preserve typed bucket errors for bounded retries 2026-10-12 10:45:27 +08:00
cxymds 03fb3d78a0 ci(validation): consume pinned manager native prerequisites 2026-10-11 11:55:36 +08:00
cxymds b8126e50f7 test(ci): expose manager regressions through tools-only dispatch 2026-10-11 11:21:35 +08:00
cxymds 59fb72aa82 test(ci): add isolated manager native validation builds 2026-10-11 11:20:46 +08:00
cxymds 9de01c9170 docs(ci): describe supported validation recovery 2026-10-11 11:07:20 +08:00
cxymds 2ec26fa4b9 feat(ci): expose read-only validation fleet inventory 2026-10-11 10:42:48 +08:00
cxymds f4507178a3 test(ci): bind C10 execution to the verified shared codec 2026-10-11 10:09:26 +08:00
cxymds 9b4cc40abd test(validation): build optional C10 fixture codec on hosted Linux 2026-10-11 09:44:09 +08:00
cxymds b14ff4eeb1 test(validation): bind C07 to pinned native authority evidence 2026-10-11 09:27:59 +08:00
cxymds ae9b5cbabf Run validation tool resolution on hosted Linux without acquiring the fleet lock 2026-10-11 07:49:26 +08:00
cxymds 6c78b0d996 test(validation): route native tools through the existing driver 2026-10-11 06:06:17 +08:00
cxymds 0066e34764 test(validation): add optional native Heal regression tooling 2026-10-11 06:03:43 +08:00
cxymds fbddcc2965 ci: audit retained validation environments before finalization 2026-10-10 23:28:39 +08:00
cxymds b1793b55a0 test(validation): verify Heal tools against released shard captures 2026-10-10 21:11:43 +08:00
cxymds 835bd3f52d test(validation): verify pinned Heal tools before fleet execution 2026-10-10 20:46:40 +08:00
cxymds cb32d55878 test(validation): expose tool artifacts through the existing manual lane 2026-10-10 20:35:14 +08:00
cxymds 864e58592c test(validation): build the candidate-bound Heal inspector artifact 2026-10-10 20:32:34 +08:00
cxymds 3016209330 test(validation): provide the Python client for Heal cases 2026-10-10 20:30:10 +08:00
cxymds 64e0eeba91 Install validation client for replication cases 2026-10-10 18:52:42 +08:00
cxymds 35fa7488a5 Prepare Python client for pool validation cases 2026-10-10 18:41:48 +08:00
cxymds 1b77846f8e Install pinned ILM client for canonical validation lane 2026-10-10 18:26:49 +08:00
cxymds b0c861841e test(validation): add pinned auto-testing manual lane 2026-10-10 17:34:37 +08:00
133 changed files with 16876 additions and 1900 deletions
+1 -4
View File
@@ -27,7 +27,4 @@ Risk and review shape:
and list verified and unverified target classes in the PR Impact section.
Available domain lenses are security, concurrency/durability, compatibility,
and performance. Select `.agents/skills/adversarial-validation/SKILL.md` for an
explicit adversarial request, a high-risk change, or a substantial PR review;
then read only its matching role references. A routine standard pass does not
load the playbook unless the reviewer needs a RustFS-specific probe.
and performance; select only those matched by the change.
@@ -1,48 +0,0 @@
---
name: adversarial-validation
description: Review RustFS diffs or designs for explicit adversarial requests, high-risk changes under the repository review policy, or substantial PR reviews. Skip ordinary questions, diagnosis, planning, status, routine low-risk implementation, and prose with no execution effect.
---
# RustFS Adversarial Validation
Use the [repository risk tiers and review shape](../../references/adversarial-validation.md). This skill
routes a review to RustFS-specific probes without loading unrelated domains.
## Select Lenses
Read only the references required by the diff:
| Lens | When to read |
|---|---|
| [Correctness](references/correctness.md) | Every non-exempt adversarial review |
| [Simplicity](references/simplicity.md) | Mechanical/standard changes and production growth |
| [Test coverage](references/test-coverage.md) | Behavior or test changes |
| [Security](references/security.md) | Authn/authz, IAM, RPC trust, paths, secrets, parsing, browser, encryption |
| [Concurrency/durability](references/concurrency-durability.md) | Async shared state, locks, storage commit, cancellation, persisted queues |
| [Compatibility](references/compatibility.md) | S3 surface, MinIO interop, metadata, wire/disk formats, mixed versions |
| [Performance](references/performance.md) | Request/object hot paths, allocation, blocking work, fsync, fan-out |
Do not read all references as a precaution. A path name alone is insufficient;
the changed behavior must touch the lens's domain.
For a dedicated security audit or advisory analysis, use
`security-advisory-lessons` instead of loading it automatically during every
adversarial review.
## Review Protocol
1. Freeze the exact final diff/head (or the design under review) and list the
selected lenses.
2. Run the review shape required by the repository risk tier.
3. For each selected lens, either report a concrete finding or a null verdict
naming the attacks performed.
4. Apply root `AGENTS.md`'s finding standard. Test each candidate against callers,
existing coverage, and invariants before accepting it; an adversarial role
does not have to produce a defect.
5. Fix or rebut supported findings with code-path, test, or invariant evidence.
6. After a non-trivial edit, rerun only lenses affected by that edit against the
new exact diff.
Do not turn a null verdict into a long checklist. Record concise evidence that
the relevant failure classes were attacked, then stop under the root completion
rule. Keep the required per-lens verdicts for high-risk PRs.
@@ -1,38 +0,0 @@
# Compatibility Lens
- Internal metadata uses `metadata_compat` helpers for dual RustFS/MinIO keys,
including mixed casing and removal of both twins.
- Binary UUID metadata treats absent, empty, and nil as no value. Unversioned
remote tiers receive no `versionId`; versioned purge requests retain the real
version ID.
- `xl.meta` changes preserve supported header/meta versions, recompute
signatures, decode legacy fixtures, and remain readable by old RustFS/MinIO.
- Foreign/corrupt metadata validates parallel array lengths and missing fields;
it returns a decode error rather than indexing, panicking, or fabricating data.
- Do not “correct” byte-for-byte MinIO ports without legacy fixture evidence.
Bitrot framing, shard math, distribution, and inline prefixes are contracts.
- Client-visible metadata/events strip both internal prefixes
case-insensitively.
- Proto fields are appended, never reused/renumbered; FlatBuffers tables extend
compatibly and absent new fields fail closed where authorization/quorum is
involved.
- Replay real client request shapes and exact pagination boundaries for S3
handler changes.
- Bucket metadata/IAM/config parsing remains compatible with pinned real MinIO
fixtures and encrypted migration data.
- Compatibility shims use `RUSTFS_COMPAT_TODO(<task-id>)`, have a removal
condition, and default toward reading old data safely.
## Outbound targets
- A change to what the replication or migration client sends by default
(checksum policy, payload framing, headers, version-id addressing) is judged
against every target class, not the one it fixes. Name each target-side rule
the current default satisfies — checksum required with Object Lock
parameters, `aws-chunked` decoding, version-id adoption, ETag equals content
MD5 — and show which cell of
`crates/e2e_test/src/replication_target_matrix_test.rs` covers each.
- A test that asserts the fix ("no trailer header") is not evidence; the
matrix cell that asserts the target accepted and stored the object is.
- Every new environment escape hatch appears in
`docs/operations/replication-outbound-transport.md` in the same diff.
@@ -1,27 +0,0 @@
# Concurrency and Durability Lens
- For every changed lock, enumerate overlapping lock sets and construct the
ABBA interleaving. Multiple-lock order must be documented and consistent.
- Mark guard lifetimes and every `.await`, disk,
and RPC call; estimate contention/timeouts. Tokio read/write guards across
`.await` need bounded hold time; long-lived reads can wedge writers (#4195).
Keep `std::sync::Mutex` holds brief and never across `.await`.
- Use direct `fetch_*` for unconditional atomic read-modify-write and
`compare_exchange` for conditional updates.
- Object commits remain fenced if the distributed lock is lost after shard
writes and before metadata rename.
- For write/rename changes, trace `write tmp -> sync tmp -> rename -> sync parent
-> sync required ancestors`; simulate a crash after each step and honor the
configured durability gate.
- Multi-disk fan-out counts every result. Quorum-minus-one cannot become success;
heal remains best-effort per target where that is the established contract.
- At every new cancellable await between mutation and cleanup/commit, drop the
future and inspect leftover files, counters, permits, and replay state.
- Multipart operations on the same upload ID are serialized where required;
abort/complete/list races cannot delete parts before durable commit.
- Post-commit cleanup is best-effort, retry-safe, and cannot fail an already
committed write or delete the last surviving copy.
- Persisted read-modify-write uses serialization/CAS. Queue replay is crash-safe
and duplicate delivery has an idempotency contract.
- Streaming reconstruction failures after partial output surface as errors, not
successful EOF.
@@ -1,31 +0,0 @@
# Correctness Lens
Attack the changed behavior, not every subsystem in the repository.
- Trace new error paths to the caller. Inject the ignored/wildcard variants and
verify they cannot become success, not-found, or a plausible default.
- Exercise zero/empty/missing, maximum, and exact-boundary inputs for every
changed count, size, index, page limit, or optional value.
- For aggregation/quorum changes, test exactly quorum and quorum-minus-one with
mixed disk errors and nil/placeholder entries.
- For listing/pagination, test `n == max`, `n == max + 1`, delimiter folding,
continuation markers, and object/prefix name collisions.
- For EC/read/streaming changes, inject failure after partial output and verify
the client receives an error rather than a clean truncated body. Assert exact
bytes and length.
- For multipart/object commits, fail before/after rename and cleanup; committed
data must remain readable and pre-commit cleanup must not destroy parts.
- Bound recursion over untrusted or persisted input; handle corrupted/cyclic
tree and cache traversals safely.
- For version/index ordering, test `len - 1`, `len`, equal timestamps, missing
versions, and deterministic tie-breaking.
- For directory-object behavior, trace `__XLDIR__` at the store layer; branches
below the layer that sees trailing slashes are dead.
- For binary UUID metadata, absent, empty, and nil all mean no value. Never send
nil/empty `versionId` to an unversioned tier.
- For agent rules/skill routers, test a trigger matrix covering ordinary
inquiry, low-risk implementation, explicit review, high-risk code, PR
creation, release, and post-PR monitoring. Each case must select only the
intended workflow and retain required safety/authorization boundaries.
Null verdicts name only the probes relevant to the diff.
@@ -1,34 +0,0 @@
# Performance Lens
- For added clones/allocations on request/object/block paths, quantify copied
data and frequency. Recommend borrowing, move, `Bytes`/`Arc`, `Cow`, or
capacity reservation only for a concrete repeated cost.
- 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; coalesce `open`,
`read`/`write`, `fsync`, and `close` into one blocking closure per disk. When
coalescing steps across execution lanes, route the combined closure to the
dedicated fsync lane whenever any step performs file or directory sync so
stalled syncs cannot starve foreground metadata reads.
- Check for per-disk syscall amplification (`disks × parts/files`): pin a
directory once (`openat2` on Linux) and reuse the handle across sibling reads
or same-directory renames instead of re-walking ancestor components per file
or re-opening the same parent directory twice.
- When the file size is already known from `fstat` metadata, pre-size the
destination buffer (`len + 1`) before reading to EOF so small metadata reads
complete in a single `read()` + EOF check rather than exponential-growth
reallocations and syscalls.
- Avoid redundant disk re-reads or existence checks under the same exclusive
lock when the state or raw 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.
- Detect per-item serial I/O/RPC in batch APIs and accidental quadratic scans;
use a gate or bounded concurrency when the concrete fan-out warrants it.
- Count buffer growth and byte copies in EC/bitrot paths; preserve pool gauge
balance and avoid repeated metadata decode/fetch per object.
- Repetitive success logs stay at `trace`; metrics/instrumentation on hot paths
require an existing gate.
- Claims of no impact on PUT/GET/commit/erasure paths need relevant benchmark or
A/B evidence, especially for 4 KiB objects.
@@ -1,31 +0,0 @@
# Security Lens
Use `security-advisory-lessons` only for a dedicated advisory/security audit.
For an ordinary matched diff, attack these boundaries:
- Admin routes: route registration, whitelist, handler authn, and the exact
`AdminAction` must agree. Read-only diagnostics still require admin authz.
- IAM/service accounts: treat parent, claims, keys, groups, status, and policy
names as attacker-controlled; prove ownership/root authority before writes.
- Protocol frontends: every changed/sibling command authorizes the matching S3
action before reaching storage.
- Secrets/signatures: use constant-time comparison, normalize public failures,
keep RPC/root/STS keys independent, and fail closed when secrets are absent.
- RPC: bind signatures to the exact method/path and timestamp; reject replay,
stale, malformed, truncated, and invalid-enum payloads without panic.
- Paths/object/archive entries: reject traversal, absolute/platform escapes,
and normalization differences between authz and storage.
- Copy/multipart/presigned POST: enforce source, destination, version-aware
actions, copy-source conditions, and every signed policy condition.
- Logging/errors: never expose credentials, tokens, expected signatures, raw
secret-bearing input, or merged configs—including via `Debug` and parse errors.
- Untrusted serde: reject unknown fields where compatible and validate
security-critical defaults/ranges before numeric conversion.
- SSE/browser/CORS/trusted proxy: inspect stored ciphertext and wrapper order;
isolate user content; never reflect credentialed arbitrary origins or trust
forwarded identity from direct clients.
- Object Lock: unreadable/fabricated/unparsable metadata fails closed across
foreground, lifecycle, scanner, and force-delete paths.
Security findings distinguish unauthenticated compromise from a
low-privileged authenticated bypass.
@@ -1,22 +0,0 @@
# Simplicity Lens
- Compare the production diff with the smallest equivalent local edit. Fewer
lines alone are not evidence; the replacement must preserve correctness,
compatibility, readability, and real boundaries.
- Search the touched crate, domain owner, `crates/utils`, `crates/common`, and
relevant dependencies for each new helper, constant, wrapper, or fixture.
- Reject forced reuse when normalization, error, deadline, or durability
semantics differ.
- Require a concrete trigger for every new defensive branch. Keep boundary
checks for disk/RPC/version data and checks immediately before destructive
actions.
- Flag one-caller helpers only when they merely forward or split a short linear
flow without adding domain naming, invariant isolation, or useful context.
- Ensure a replacement removes the superseded in-scope path or keeps one
canonical core behind a documented compatibility adapter.
- Remove narration/change-history comments; preserve concise safety, lock,
durability, and compatibility invariants.
- Treat tests, fixtures, generated code, and documentation separately from
production growth. Do not optimize away meaningful regression coverage.
A finding must include a concrete smaller design, not a style preference.
@@ -1,26 +0,0 @@
# Test-Coverage Lens
- For every behavior claim, name the focused test/check that fails if the
changed hunk is reverted. If none is practical, require the reason and
residual risk.
- Confirm tests exercise the real production path and distinguish the intended
behavior from the named regression. A success, `is_err()`, or no-panic check
can be sufficient when that is the actual contract; require exact values,
bytes, state, or error variants when those distinctions matter to the change.
- For new flags/modes, verify each branch and ask which test fails if the branch
is inverted.
- For new error propagation, inject the failure and assert the caller observes
it; mentally replacing `?`/`return Err` with success must break a test.
- Streaming GET tests assert the complete body and length under degraded reads.
- Disk/wire-format tests use pinned foreign/legacy fixtures; same-code
round-trips are insufficient for compatibility.
- Concurrency tests use readiness polling, isolate global state, and avoid fixed
sleeps or unrealistically short timeouts. Use nextest groups when process-level
serialization is required.
- Internal metadata tests assert both RustFS and MinIO keys, not only read-back
through a helper that prefers one key.
- Avoid duplicate tests of the same production path and poison-value class.
Boundary companions are distinct coverage: `n == max` vs `max + 1`, and
absent vs empty vs nil UUID.
- A focused test proves only the targets/features it builds. Add compilation or
Clippy only for uncovered changed targets.
+1 -1
View File
@@ -13,7 +13,7 @@ description: Review a GitHub PR using its exact base/head and repository risk po
```
Record both SHAs; refresh if either moved during fetching. Follow relevant [implementation boundaries](../../references/implementation.md).
2. Apply the [risk policy](../../references/adversarial-validation.md). Ordinary reviews use the root finding standard directly. For explicit adversarial, substantial, or high-risk reviews, use [the domain probes](../adversarial-validation/SKILL.md). File count does not authorize delegation; use exactly two independent reviewers only when authorized, otherwise two fresh sequential passes for that risk tier. Reviewers do not spawn further agents.
2. Apply the [risk policy](../../references/adversarial-validation.md). Ordinary reviews use the root finding standard directly. File count does not authorize delegation; use exactly two independent reviewers only when authorized, otherwise two fresh sequential passes for that risk tier. Reviewers do not spawn further agents.
3. Check `gh pr checks <N> --repo <owner/repo>`. Investigate failures when relevant to a finding or a requested CI/readiness diagnosis. Distinguish pending, unavailable, pre-existing, flaky, and PR-caused states using evidence; a code review does not authorize unrelated repairs.
4. Report the reviewed SHAs, risk tier, supported findings or `No findings`, material verification limits, check state, and verdict. P0/P1 findings block approval. Severity: P0 = demonstrated data loss, security breach, remote crash, or deadlock; P1 = correctness, compatibility, or material hot-path regression; P2 = a concrete maintainability defect; P3 = optional style, only when requested.
5. When posting is authorized, follow [Git and PR rules](../../references/pull-requests.md), recheck the remote head, and review any delta before submitting. Use `gh pr review` with `--approve`, `--request-changes`, or `--comment` and `--body-file`; use [the API example](references/posting.md) for authorized inline comments. Reuse existing authorization.
@@ -1,56 +0,0 @@
---
name: security-advisory-lessons
description: Perform a dedicated RustFS security/advisory review for authn/authz, IAM, RPC trust, paths, secrets, browser isolation, encryption, Object Lock, or other security boundaries. Use only when the user requests a security/advisory review or an adversarial review explicitly escalates to the full advisory map; do not auto-load solely because code touches a sensitive path.
---
# RustFS Security Advisory Lessons
Use this skill as the deep security lens. For a normal adversarial review with a
matched security surface, the concise security reference under
`adversarial-validation` is sufficient.
## Workflow
1. Freeze the exact diff/head and identify the changed trust boundaries.
2. Inspect the headings in [advisory-patterns.md](references/advisory-patterns.md),
then read the matching sections. Read the full map only for a broad security
audit. Useful headings are
auth/admin, IAM/STS/OIDC, policy/plugins, S3/copy/multipart, protocols, paths,
secrets/logging/RPC, browser/CORS/proxy, SSE, Object Lock, and serde.
3. Trace unauthenticated, low-privilege, wrong-action/owner/bucket, malformed,
and default-config cases. Security decisions must fail closed.
4. Require a focused negative regression test for the bypass/exploit form, not
only the intended success path. State residual risk when a test is impractical.
5. Report proven vulnerabilities separately from defense-in-depth hardening.
When advisory currentness matters, fetch the live inventory instead of treating
the reference as a status mirror:
```bash
gh api repos/rustfs/rustfs/security-advisories --paginate \
--jq '.[] | {ghsa_id,state,severity,summary,updated_at}'
```
Fetch an individual advisory only when the live summary indicates a new or
changed lesson.
## Finding Standard
Every candidate names the lower-trust principal, the boundary it crosses, the
affected resource, and the observable result. A best-practice gap or an impact
confined to the caller's own principal is hardening, not a candidate.
Report each candidate under exactly one verdict:
- `confirmed`: a complete `file:line` source trace and a bounded observed
result. Only confirmed findings carry severity; each also states attacker
prerequisites, concrete input/path, impact, smallest safe fix, and a
regression check.
- `needs_validation`: the trace is blocked on one exact unresolved fact, such
as deployment or infrastructure behavior. State that fact; assign no severity
and do not substitute an assumption.
- `rejected`: a disproved candidate, with the caller, invariant, or test that
disproves it.
Do not exaggerate unauthenticated impact when the actual issue requires
authenticated low privilege.
@@ -1,158 +0,0 @@
# RustFS Advisory Pattern Map
This file is a lesson map, not an advisory inventory mirror. It keeps durable security patterns distilled from RustFS GitHub Security Advisories.
When current advisory state, severity, URLs, or full text matters, fetch it live:
```bash
gh api repos/rustfs/rustfs/security-advisories --paginate \
--jq '.[] | {ghsa_id,state,severity,summary,updated_at}'
gh api repos/rustfs/rustfs/security-advisories/<GHSA_ID>
```
Update this file only when an advisory adds or changes a reusable lesson, affected surface, validation pattern, or regression-test expectation. Do not update it for state-only, URL-only, count-only, or timestamp-only changes.
## Pattern Index
### Admin authorization and route exposure
- `GHSA-pfcq-4gjr-6gjm`: notification target endpoints accepted authenticated users but skipped admin authorization. Lesson: distinguish authn from authz; admin target CRUD must call the operation-specific admin authorization path.
- `GHSA-mm2q-qcmx-gw4w`: `ListServiceAccount` used `UpdateServiceAccountAdminAction`, while update lacked target ownership checks. Lesson: exact action constants and ownership checks are both required; information disclosure can chain into secret rotation and takeover.
- `GHSA-vcwh-pff9-64cc`: `ImportIam` checked `ExportIAMAction` for an import/write operation. Lesson: every admin handler must authorize the action it actually performs.
- `GHSA-jqmc-mg33-v45g` and `GHSA-8784-9m7f-c6p6`: `/profile/cpu` and `/profile/memory` were whitelisted from auth and allowed expensive diagnostics plus path disclosure. Lesson: profiling/debug endpoints need admin auth, opt-in, rate limits, and non-sensitive responses.
- `GHSA-f5cv-v44x-2xgf`: `/rustfs/admin/v3/metrics` accepted any authenticated IAM user and skipped admin authorization. Lesson: read-only metrics and diagnostic admin endpoints still require an operation-specific admin action check.
- `GHSA-796f-j7xp-hwf4`: `/rustfs/admin/v3/list-remote-targets` checked only that credentials existed and leaked replication target credentials. Lesson: replication target reads are privileged admin operations, and stored remote credentials require strict authz plus response redaction review.
- `GHSA-xp32-gxq2-3v52`: console license metadata endpoint was public and exposed subject and expiration fields. Lesson: management metadata endpoints should require admin auth or return only coarse public status.
### IAM import, service accounts, and privilege boundaries
- `GHSA-566f-q62r-wcr8`: `ImportIam` accepted attacker-controlled service account `parent`, `claims`, `accessKey`, and `secretKey`, enabling persistent backdoor accounts under root. Lesson: imported IAM payloads are untrusted data and must be validated against privilege boundaries.
- `GHSA-3495-h8r9-gfqg`: `ExportIAM` wrote regular-user and service-account secret keys into exported ZIP data. Lesson: IAM export is a credential-disclosure boundary; redact, seal, or strictly justify every exported secret before treating export permission as safe.
- `GHSA-5354-r3w2-34m8`: `AddServiceAccount` checked `CreateServiceAccountAdminAction` but trusted caller-supplied `target_user`, allowing service accounts under the root parent. Lesson: service-account create paths must validate parent ownership or root/admin authority, not only the create action.
- `GHSA-xgr5-qc6w-vcg9`: `deny_only=true` skipped allow checks and let restricted service accounts mint unrestricted children. Lesson: deny-only logic must never become implicit allow for privilege creation.
- `GHSA-mm2q-qcmx-gw4w`: leaked service account access keys plus update-without-ownership formed an escalation chain. Lesson: service-account identifiers are security-sensitive because update APIs consume them.
### STS, OIDC, and federation flows
- `GHSA-5qfg-mf7r-jp3w` and `GHSA-3473-5353-xhwh`: `AssumeRoleWithWebIdentity` was reachable through unauthenticated `POST /` routing and could issue temporary credentials from crafted web identity input. Lesson: every STS route needs explicit SigV4 or trusted identity-provider validation before role assumption, and unauthenticated exemptions must be narrowed to the exact action with uniform failure responses.
- `GHSA-jxrr-r6pv-h958`: unsigned JWT issuer data was decoded before verification to select an OIDC provider, and distinguishable failures could expose provider configuration. Lesson: web-identity routing may be unauthenticated, but pre-verification claims are untrusted routing hints; bound and rate-limit the request, normalize public errors, and verify signature, issuer, audience, and expiration before issuing credentials.
- `GHSA-ccrv-v8v9-ch9q`, `GHSA-48rf-7j3q-3hfv`, and `GHSA-xvfh-7c9g-hpw2`: service-account-controlled material could self-sign JWT session tokens with forged policy claims, and missing `exp` was accepted for service-account tokens. Lesson: session tokens must be signed by a trusted issuer/key path, enforce required claims and expiration, and reject self-signed or principal-controlled tokens.
- `GHSA-9pjf-w3c2-m32r`, `GHSA-4x2q-cpx9-9h26`, and `GHSA-xvpm-p3f7-34c3`: public OIDC authorize/callback flows trusted request `Host` or forwarded scheme when building credential-bearing redirects. Lesson: OIDC redirects must use configured allowlisted origins and trusted-proxy handling; never derive the post-login credential destination from direct client headers.
- `GHSA-m479-9x88-94w6`, `GHSA-frwq-mfqx-83p8`, `GHSA-q9q8-rf9r-fg9f`, and `GHSA-j5c2-hhf7-6gf5`: OIDC validation accepted attacker-controlled discovery URLs because hostname checks rejected only literal forbidden IPs, allowing DNS rebinding SSRF. Lesson: outbound federation URL validation must resolve and classify hostnames at the connection boundary and reject loopback, private, link-local, and rebound addresses.
### IAM policy conditions and external policy plugins
- `GHSA-6r96-hmgc-726c`: request headers collided with lowercase server-derived condition keys such as `userid`, `groups`, `versionid`, and JWT/LDAP claims. Lesson: never let caller-controlled headers append to or replace server-derived policy context; reserve trusted condition keys and keep intentional request-header keys separate.
- `GHSA-v9cp-qfw9-9pfp`: quantified negated string conditions applied negation after aggregation, transposing `ForAllValues` and `ForAnyValue` semantics. Lesson: push negation into the per-value predicate for quantified operators and test partially overlapping multi-value sets.
- `GHSA-5w8r-p896-6vq2`: OPA policy mode skipped `ExistingObjectTag/*` loading, so tagged objects looked untagged to external policies. Lesson: external authorization plugins need the same object-tag and request context as built-in policy evaluation before they decide.
- `GHSA-9wgh-5r68-49p7`: substituted policy-variable values could recreate `${...}` and loop inside a single resolution pass, never reaching the outer iteration limit. Lesson: bound substitutions within each pass and test cycles formed across otherwise literal resolver values; confirm whether attacker-controlled claims can reach those values before assigning remote impact.
### S3 object actions, copy, multipart, and upload policy validation
- `GHSA-g8w9-qw9q-fghr` and `GHSA-xm99-m3gq-83g8`: a valid presigned `PutObject` accepted extra `x-amz-*` headers omitted from `SignedHeaders`; `x-amz-copy-source` could turn that upload capability into a cross-bucket read performed as the signer. Lesson: a presigned URL is a bounded capability; inspect all received security-sensitive headers and reject unsigned ones before selecting the S3 operation or reaching storage.
- `GHSA-3ppv-fx5m-m749`: explicit `versionId` reads and copy sources authorized `s3:GetObject` instead of `s3:GetObjectVersion`. Lesson: version-specific object access must select version-specific actions for direct reads, `CopyObject`, and `UploadPartCopy`, with tests proving the backend is not reached on denial.
- `GHSA-cg33-pxfp-qcj3` and `GHSA-65wq-gjw8-xr9w`: `HeadObject` and object-tagging paths authorized current-object actions while operating on an explicit historical `versionId`. Lesson: select the corresponding version action for metadata and tag reads and writes too; audit every operation that accepts a version ID.
- `GHSA-gjph-xcgw-4p7w`: `PutObject` and browser POST accepted a caller-supplied `versionId` and replaced an unlocked historical version with only `s3:PutObject`. Lesson: only replication-authorized writes may choose an existing version ID; ordinary uploads must not rewrite version history.
- `GHSA-x298-9x87-fvjq`: anonymous `ListObjectVersions` fell back to `ListBucket` and returned before public-access-block gates. Lesson: compatibility fallbacks must converge on the same post-authorization checks as direct grants, especially `RestrictPublicBuckets` and anonymous data-plane denies.
- `GHSA-mx42-j6wv-px98`: `UploadPartCopy` missed source authorization and allowed cross-bucket object exfiltration. Lesson: multipart copy must enforce the same source and destination contract as `CopyObject`.
- `GHSA-wfxj-ph3v-7mjf`: `UploadPartCopy` checked source and destination independently but missed destination copy-source policy constraints. Lesson: source read and destination write checks are not sufficient when policy constrains allowed copy sources.
- `GHSA-w5fh-f8xh-5x3p`: presigned POST accepted uploads without enforcing signed policy conditions. Lesson: parse and enforce all POST policy constraints server-side, including size, key prefix, and content type.
### Protocol frontends and IAM parity
- `GHSA-3g29-xff2-92vp`: FTP `RETR` and `SIZE`/`MDTM` read paths authenticated the user but skipped IAM before calling storage. Lesson: non-HTTP protocol frontends must enforce the same per-operation authorization as the S3 API before backend access.
- `GHSA-g3vq-vv42-f647`: FTPS `MKD` called `create_bucket` without checking `s3:CreateBucket`. Lesson: protocol command handlers need action-specific checks even when sibling handlers already authorize correctly.
- `GHSA-3p3x-734c-h5vx`: FTPS and WebDAV compared secret keys with early-return string equality, while FTPS also returned distinguishable invalid-user and invalid-password failures. Lesson: password-style protocol auth needs constant-time secret comparison, indistinguishable failures where practical, and rate limiting.
- `GHSA-rhg6-jrf2-29c4`: WebDAV, FTP/FTPS, and SFTP used identity-policy checks with empty conditions and bypassed S3 bucket-policy explicit denies and condition-key denies. Lesson: alternate listeners must evaluate the same effective policy and trustworthy request context before storage access.
### Filesystem paths and object key traversal
- `GHSA-pq29-69jg-9mxc`: RPC `read_file_stream` joined untrusted paths under a volume directory without canonical boundary checks. Lesson: `PathBuf::join` plus length checks are not path security.
- `GHSA-8r6f-hmq2-28rg`: object keys containing traversal sequences bypassed bucket/object authorization when mapped to filesystem paths. Lesson: reject traversal at object-key parsing and verify final storage paths remain under the expected bucket/key root.
- `GHSA-f4vq-9ffr-m8m3`: Snowball auto-extract accepted archive entries such as `../victim-bucket/object`, authorized the raw attacker-bucket path, then storage path cleaning crossed bucket boundaries. Lesson: archive entries become object keys and need traversal rejection plus consistent authz/storage normalization before writes.
- `GHSA-g7fq-6xjm-mxf4`: versioned `DeleteObjects` accepted dot-segment keys, authorized the literal prefix, then wrote delete markers under a resolved sibling prefix. Lesson: validate each batch key before authorization and storage normalization, using the same object-name rules as single delete.
### Secrets, defaults, and cryptographic misuse
- `GHSA-j59h-h7q5-q348`, `GHSA-3wm5-wpm5-hmfm`, `GHSA-6wc8-xm48-qhmx`, `GHSA-9gf3-jx4p-4xxf`, `GHSA-63xc-c3w3-m2cf`, and `GHSA-ch63-6q4v-hwp5`: RustFS shipped known default root credentials that could authenticate to S3, admin APIs, IAM, KMS, console, and token-signing surfaces. Lesson: root credentials must be operator-provided or generated per install; known defaults and warnings are not acceptable for network-reachable deployments.
- `GHSA-h956-rh7x-ppgj`: gRPC used the hard-coded token `rustfs rpc` on both client and server. Lesson: source-visible shared tokens are authentication bypasses.
- `GHSA-r5qv-rc46-hv8q`: internode RPC HMAC secret fell back to the public default `rustfsadmin`. Lesson: RPC/internode auth must fail closed instead of silently using public defaults.
- `GHSA-75fx-qg6f-8rm7` and `GHSA-68cw-96m3-h2cf`: internode RPC secrets were derivable from known root credentials, making raw storage RPC signatures forgeable when explicit RPC secrets were unset. Lesson: RPC auth keys must be independent random secrets, never derived from S3 root credentials, and raw storage RPC should not share the public S3 listener without an internode-only boundary.
- `GHSA-m77q-r63m-pj89`: STS JWTs used the root secret key as the shared token signing key, allowing token forgery when the root secret was known. Lesson: STS signing keys need key separation, rotation, and key IDs; do not reuse root credentials for JWT/HMAC signing.
- `GHSA-923g-jp7v-f97f`: license verification embedded a production RSA private key and used private-key decryption as authenticity. Lesson: ship verifying/public keys only and use real signature verification.
### Sensitive logging and debug output
- `GHSA-r54g-49rx-98cr`: STS credentials were logged at info level. Lesson: generated credentials must never be logged in plaintext.
- `GHSA-8cm2-h255-v749`: debug logs leaked session tokens, secret keys, JWT claims, and raw STS response bodies. Lesson: redaction must cover custom `Debug` implementations and dependency response-body logging.
- `GHSA-333v-68xh-8mmq`: invalid RPC signature logging included the shared HMAC secret and expected signature. Lesson: error paths often leak secrets; never log raw secrets or derived authenticators.
### RPC input validation and panic safety
- `GHSA-gw2x-q739-qhcr`: malformed gRPC `GetMetrics` payloads reached `unwrap()` on deserialization and caused remote DoS. Lesson: every network/RPC deserialization failure returns an error, not a panic.
- `GHSA-h956-rh7x-ppgj` and `GHSA-r5qv-rc46-hv8q`: weak RPC auth increased reachability of otherwise internal handlers. Lesson: panic bugs become more severe when internode auth is weak or defaulted.
- `GHSA-c667-rgrv-99vj`: NodeService authentication signed the service prefix instead of the concrete generated method path, so valid metadata for one RPC could be replayed to another method during the timestamp window. Lesson: RPC HMAC payloads must bind exact gRPC method path, HTTP method surrogate, timestamp, and secret.
### Browser, CORS, and console isolation
- `GHSA-v9fg-3cr2-277j`: object preview rendered attacker-controlled HTML in a same-origin iframe, exposing console credentials stored in `localStorage`. Lesson: user content must be origin-isolated from the console and protected with `nosniff`, CSP, and strict content-type handling.
- `GHSA-7gcx-wg4x-q9x6`: an incomplete preview fix reintroduced extension-based PDF detection and bypassed the sandboxed fallback for attacker-controlled content. Lesson: browser-surface fixes need regression tests for alternate viewers and file-type branches, and preview trust must come from validated content type plus sandboxing rather than object names.
- `GHSA-x5xv-223c-8vm7`: default CORS reflected arbitrary origins with credentials. Lesson: never combine reflected origins with `Access-Control-Allow-Credentials: true`; default should be fail-closed.
### Trusted proxy and source IP conditions
- `GHSA-fc6g-2gcp-2qrq`: `aws:SourceIp` trusted client-supplied `X-Forwarded-For` or `X-Real-IP`. Lesson: forwarded IP headers are valid only behind configured trusted proxies; direct clients use socket peer IP.
### SSE and on-disk storage invariants
- `GHSA-xrrf-67jm-3c2r`: SSE metadata reported encryption while reader composition bypassed `EncryptReader` and stored plaintext. Lesson: test actual bytes on disk and wrapper order, not only API metadata.
- `GHSA-wqmc-vjgv-jrpw`: SSE-C persisted and listed the plaintext MD5 as the ETag, allowing list-only users to confirm or brute-force low-entropy content and correlate equal plaintext across keys. Lesson: encrypted-object ETags must not expose deterministic plaintext fingerprints; protect them with per-object key material and keep listing, conditional, copy, and multipart semantics consistent.
### Object Lock and retention invariants
- `GHSA-j548-9grx-fh4f`: Object Lock enforcement treated unreadable, fabricated, or unparsable bucket metadata as absent configuration and allowed retained objects to be deleted or expired. Lesson: retention must fail closed unless Object Lock absence is authoritative, and every delete, lifecycle, scanner, force-delete, and default-retention path needs the same state distinction.
- `GHSA-28w4-rp2p-636w` and `GHSA-c7hx-vr97-45rg`: an authorized `DeleteBucket` with `x-rustfs-force-delete: true` removed active COMPLIANCE-retained versions even though ordinary version deletion was denied. Lesson: bucket force-delete must enforce retention across every version, including against root/owner callers.
- `GHSA-q23w-fp7p-472v`: `PutObjectRetention` policy conditions read `x-amz-object-lock-*` headers while the handler applied mode and date from the XML body. Lesson: authorize the parsed retention that will be stored, or reject mismatched header/body values; compare retention dates as instants so offsets cannot change the applied bound.
### Serde deserialization and input validation
- Reject unknown fields in untrusted S3 API XML/JSON, lifecycle, policy, and replication input where compatibility permits. Check the current type and supported payload fixtures; do not infer the repository's current coverage from an older audit. Where extra fields are part of the compatibility contract, validate security-critical values explicitly.
- `#[serde(default)]` on security-critical fields silently accepts missing values as zero/empty. Lesson: when a field has security implications (retention days, permissions, limits), validate the deserialized value explicitly rather than relying on defaults.
- Integer fields deserialized from user input and cast with `as` (e.g., `i32 as u32`) can wrap negative values to large positives. Lesson: validate ranges before casting; use `try_into()` with a typed error, or clamp only when the domain explicitly requires saturation.
- XML config typos (e.g., `"NoncurentDays"` instead of `"NoncurrentDays"`) are silently accepted when `deny_unknown_fields` is absent. Lesson: strict deserialization prevents silent misconfiguration that could cause data loss or unexpected retention behavior.
## Useful Search Seeds
Use these targeted searches when a diff touches security-sensitive code:
```bash
rg -n "validate_admin_request|check_permissions|AdminAction::|deny_only|is_allowed" rustfs crates
rg -n "authorize_operation|FtpsDriver|SftpDriver|RETR|MKD|SIZE|MDTM|CreateBucket|GetObject|HeadObject" crates/protocols rustfs
rg -n "UploadPartCopy|upload_part_copy|CompleteMultipart|PostObject|presign|SignedHeaders|x-amz-copy-source|collect_signed_headers|content-length-range|starts-with" rustfs crates
rg -n "ListBucketVersions|GetObjectVersion|versionId|VersionId|ExistingObjectTag|ForAllValues|ForAnyValue|POLICY_PLUGIN|opa" rustfs crates
rg -n "normalize_extract_entry_key|Snowball|auto-extract|PathBuf::join|canonicalize|\\.\\.|x-forwarded-for|x-real-ip|SourceIp" rustfs crates
rg -n "DEFAULT_SECRET|DEFAULT_ACCESS|TEST_PRIVATE_KEY|rustfs rpc|RUSTFS_RPC_SECRET" rustfs crates
rg -n "TONIC_RPC_PREFIX|verify_rpc_signature|check_auth|NodeServiceServer|x-rustfs-signature" rustfs crates
rg -n "debug!|trace!|info!|error!|\\?resp|\\?merged_config|session_token|secret_key" rustfs crates
rg -n "HashReader|EncryptReader|try_resolve_etag|ETag|SSE|server-side encryption|Access-Control-Allow-Credentials|Origin" rustfs crates
rg -n "ObjectLock|object_lock|retention|COMPLIANCE|GOVERNANCE|delete_prefix|lifecycle|scanner" rustfs crates
rg -n "deny_unknown_fields|serde.default|as u32|as usize|as i32" rustfs crates
```
## Minimum Regression Test Expectations
- Authz fixes: include unauthenticated, valid low-privilege, wrong-action, correct-action, owner, non-owner, and root/admin cases as applicable.
- Protocol frontend authz fixes: include denied `RETR`, `SIZE`/`MDTM`, `MKD`, bucket probe, and sibling allowed-operation cases; test bucket-policy explicit denies and condition-key denies on each enabled listener against an S3 control, and assert denied paths do not reach storage.
- IAM fixes: include import/update/list service-account cases with attacker-controlled parent, claims, access key, secret key, and policy.
- Copy/upload fixes: include cross-bucket, cross-user, source-denied, destination-denied, copy-source-condition, and multipart completion cases.
- Presigned upload fixes: include a valid presign with extra unsigned tagging, redirect, storage-class, and cross-bucket copy-source headers; require rejection before source or destination storage access, and verify explicitly signed equivalents still work.
- Version-action fixes: include historical UUID, explicit current version, `null`, range, partNumber, presigned, STS/session, service-account, anonymous bucket-policy, copy source, multipart-copy source, `HeadObject`, and tag read/write cases.
- Version-write fixes: a `PutObject`-only principal must not replace an existing version through `PutObject` or browser POST `?versionId=`; verify replication-authorized writes and versioned, suspended, and unversioned buckets separately.
- Policy-condition fixes: include reserved-key header collisions, missing keys, partially overlapping multi-value sets, cyclic variable substitutions, plugin mode, and built-in policy mode.
- Path fixes: include encoded traversal, absolute path, nested traversal, archive entries with `..`, valid object keys that resemble traversal text but should be rejected, and canonical bucket/prefix boundary checks; test per-key `DeleteObjects` errors and prefix-scoped denials on versioned buckets.
- Logging fixes: assert redacted output for structs and response bodies that may contain credentials.
- IAM export fixes: assert exported archives omit plaintext user and service-account secrets unless the format deliberately encrypts or seals them.
- RPC auth fixes: include captured metadata replay across two concrete methods, stale timestamps, wrong path, wrong method surrogate, wrong secret, and valid same-method calls.
- Browser/CORS fixes: assert no credentials on reflected/default origins, correct behavior for explicit allowlists, and no same-origin script execution for previewed object content.
- SSE fixes: inspect stored bytes and verify API metadata, read-back behavior, and on-disk ciphertext together; for ETags, upload equal plaintext under different keys and assert list-only callers cannot derive or correlate the plaintext digest, including multipart objects.
- Object Lock fixes: include unreadable metadata, fabricated metadata defaults, unparsable config, confirmed absent config, COMPLIANCE/GOVERNANCE retention, lifecycle expiry, scanner sweeps, and force-delete paths; an authorized force-delete must preserve active COMPLIANCE versions. For `PutObjectRetention`, test conflicting header/body values and offset dates against policy bounds and read-back retention.
+2
View File
@@ -64,6 +64,8 @@ script-tests: ## Run shell script tests
$(RUSTFS_PYTHON_BIN) ./scripts/test_nightly_candidate.py
$(RUSTFS_PYTHON_BIN) ./scripts/test_functional_chain.py
$(RUSTFS_PYTHON_BIN) ./scripts/test_functional_chain_health.py
$(RUSTFS_PYTHON_BIN) ./scripts/test_inspect_validation_runner.py
$(RUSTFS_PYTHON_BIN) ./scripts/test_retain_validation_evidence.py
$(RUSTFS_PYTHON_BIN) ./scripts/test_ci_timing_report.py
$(RUSTFS_PYTHON_BIN) ./scripts/s3-tests/test_report_compat.py
$(RUSTFS_PYTHON_BIN) ./scripts/s3-tests/test_runner_tools.py
+2 -2
View File
@@ -128,7 +128,7 @@ setup = 'ecstore-large-stack'
# path on a real store; measured with RUST_MIN_STACK on 2026-10-15 they need
# 2016-2040 KiB of the 2 MiB libtest default and overflowed on Linux CI.
[[profile.default.scripts]]
filter = '(package(rustfs) & test(/^(admin::handlers::replication::target_repair_tests::[a-z_]+|admin::handlers::site_replication::tests::durable_iam_state_apply_iam_item_rejects_a_stale_recreate_after_a_replicated_delete|admin::service::federated_identity::tests::(binding_keeps_explicit_iam_mapping_strict|markerless_legacy_oidc_session_crosses_auth_and_iam_deletion_boundaries)|storage::scanner_dirty_journal::tests::(clear_callback_keeps_unknown_coverage_until_all_pending_work_is_confirmed|owned_journals_keep_peer_records_after_interleaved_writes_and_clear))$/)) | (package(rustfs-heal) & ((binary(heal_b5_versioned_regression_test) & test(/^serial_tests::(test_heal_unversioned_bucket_e2e|test_historical_null_recursive_heal_restores_physical_versions|test_recursive_deep_heal_reports_stale_delete_marker_repair)$/)) | (binary(usage_observation_read_repair_test) & test(=usage_observation_read_repair_survives_snapshot_cleanup)))) | (package(rustfs-test-utils) & test(=data_usage_snapshot_tests::data_usage_snapshot_recovery_wires_primary_backup_and_negative_cache))'
filter = '(package(rustfs) & test(/^(admin::handlers::replication::target_repair_tests::[a-z_]+|admin::handlers::plugin_attempt::v2_tests::delegation_v2_persists_and_vends_with_real_parent_intersection|admin::handlers::site_replication::tests::durable_iam_state_apply_iam_item_rejects_a_stale_recreate_after_a_replicated_delete|admin::service::federated_identity::tests::(binding_keeps_explicit_iam_mapping_strict|markerless_legacy_oidc_session_crosses_auth_and_iam_deletion_boundaries)|storage::scanner_dirty_journal::tests::(clear_callback_keeps_unknown_coverage_until_all_pending_work_is_confirmed|owned_journals_keep_peer_records_after_interleaved_writes_and_clear))$/)) | (package(rustfs-heal) & ((binary(heal_b5_versioned_regression_test) & test(/^serial_tests::(test_heal_unversioned_bucket_e2e|test_historical_null_recursive_heal_restores_physical_versions|test_recursive_deep_heal_reports_stale_delete_marker_repair)$/)) | (binary(usage_observation_read_repair_test) & test(=usage_observation_read_repair_survives_snapshot_cleanup)))) | (package(rustfs-test-utils) & test(=data_usage_snapshot_tests::data_usage_snapshot_recovery_wires_primary_backup_and_negative_cache))'
setup = 'ecstore-base-stack'
[[profile.default.scripts]]
@@ -419,7 +419,7 @@ setup = 'ecstore-large-stack'
# These cross-crate real-storage fixtures need the same bounded stack as ECStore tests.
[[profile.ci.scripts]]
filter = '(package(rustfs) & test(/^(admin::handlers::replication::target_repair_tests::[a-z_]+|admin::handlers::site_replication::tests::durable_iam_state_apply_iam_item_rejects_a_stale_recreate_after_a_replicated_delete|admin::service::federated_identity::tests::(binding_keeps_explicit_iam_mapping_strict|markerless_legacy_oidc_session_crosses_auth_and_iam_deletion_boundaries)|storage::scanner_dirty_journal::tests::(clear_callback_keeps_unknown_coverage_until_all_pending_work_is_confirmed|owned_journals_keep_peer_records_after_interleaved_writes_and_clear))$/)) | (package(rustfs-heal) & ((binary(heal_b5_versioned_regression_test) & test(/^serial_tests::(test_heal_unversioned_bucket_e2e|test_historical_null_recursive_heal_restores_physical_versions|test_recursive_deep_heal_reports_stale_delete_marker_repair)$/)) | (binary(usage_observation_read_repair_test) & test(=usage_observation_read_repair_survives_snapshot_cleanup)))) | (package(rustfs-test-utils) & test(=data_usage_snapshot_tests::data_usage_snapshot_recovery_wires_primary_backup_and_negative_cache))'
filter = '(package(rustfs) & test(/^(admin::handlers::replication::target_repair_tests::[a-z_]+|admin::handlers::plugin_attempt::v2_tests::delegation_v2_persists_and_vends_with_real_parent_intersection|admin::handlers::site_replication::tests::durable_iam_state_apply_iam_item_rejects_a_stale_recreate_after_a_replicated_delete|admin::service::federated_identity::tests::(binding_keeps_explicit_iam_mapping_strict|markerless_legacy_oidc_session_crosses_auth_and_iam_deletion_boundaries)|storage::scanner_dirty_journal::tests::(clear_callback_keeps_unknown_coverage_until_all_pending_work_is_confirmed|owned_journals_keep_peer_records_after_interleaved_writes_and_clear))$/)) | (package(rustfs-heal) & ((binary(heal_b5_versioned_regression_test) & test(/^serial_tests::(test_heal_unversioned_bucket_e2e|test_historical_null_recursive_heal_restores_physical_versions|test_recursive_deep_heal_reports_stale_delete_marker_repair)$/)) | (binary(usage_observation_read_repair_test) & test(=usage_observation_read_repair_survives_snapshot_cleanup)))) | (package(rustfs-test-utils) & test(=data_usage_snapshot_tests::data_usage_snapshot_recovery_wires_primary_backup_and_negative_cache))'
setup = 'ecstore-base-stack'
[[profile.ci.scripts]]
+6 -2
View File
@@ -16,7 +16,7 @@ Every other label in this repository is capacity RustFS pays for. ARC pods and l
| `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 |
| `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 |
| `dind-sm-standard-2` | RustFS | ARC pod with Docker-in-Docker | Docker workloads with a measured resource or environment requirement beyond the hosted runner |
| `smoke-testing`, `pf-testing` | RustFS | long-lived lab machines | the packaged functional chain and performance suites |
The self-hosted pods and the hosted image are both Ubuntu 24.04, so a binary built on one runs on the other; `ci.yml` builds the debug binary on `sm-standard-4` and runs it on both pools.
@@ -27,11 +27,15 @@ A job runs on `ubuntu-latest` unless it has one of these reasons, and the reason
1. **Disk.** It restores or produces the workspace `target/`: `cargo build` or `cargo test` with `--all-targets`, `cargo nextest run --all`, or any reader of the `ci-dev` / `ci-feat-*` caches. The workspace test build leaves about 64 GB in `target/`; a hosted runner has about 14 GB.
2. **Critical-path CPU.** It is a build that several downstream lanes wait on and would blow its timeout at 4 vCPU (`build-rustfs-debug-binary` gates the e2e and s3-tests lanes).
3. **Hardware.** Native aarch64, Docker-in-Docker, or the lab machines.
3. **Hardware or environment.** Native aarch64 or access to the lab machines. Docker alone is not a reason: hosted Ubuntu ships Docker and supports the QEMU and Buildx setup actions.
4. **Cache ownership.** It reads a `rust-cache` key that a self-hosted job writes (see the cache rule below).
Jobs that do only the following have no reason to be self-hosted: shell or Python scripts, `cargo fmt`, `cargo deny`, typos, actionlint, API calls (`gh`, dependency review, issue and stale bots, CLA checks), downloading an artifact and running a prebuilt binary, or a `cargo build -p rustfs` with its own cache key. `e2e-distributed.yml` and `e2e-upgrade.yml` build the server and the `e2e_test` harness on `ubuntu-latest` inside their budgets, so "it compiles something" alone is not a reason.
The functional chain's `prepare` and `complete-chain` jobs, the performance runner availability check, and runner hygiene use `ubuntu-latest`: they inspect APIs or artifacts, not the lab hosts. The actual functional and performance suites remain on their lab runners. Functional-chain health collection stays on the operator-managed `smoke-testing` runner as part of the automation environment. Docker image packaging also uses `ubuntu-latest` for both musl and glibc variants; it downloads release binaries rather than compiling the workspace, and still sets up QEMU and Buildx explicitly.
Moving these jobs off AKS reduces capacity demand, not necessarily the Azure bill immediately. VM costs fall only when nodes scale down or smaller capacity replaces them.
A hosted runner is also the only choice for a few things the pods cannot do: `mount -o loop` and `mount -t tmpfs` fail inside the ARC pods, and a container runtime cannot guarantee that `io_uring_setup` is allowed. `uring-integration` and `e2e-distributed` are on `ubuntu-latest` for that reason, not for cost.
## Cache rule
+1 -1
View File
@@ -337,7 +337,7 @@ jobs:
name: Build Docker Images
needs: build-check
if: needs.build-check.outputs.should_build == 'true'
runs-on: dind-sm-standard-2
runs-on: ubuntu-latest
timeout-minutes: 60
strategy:
fail-fast: false
+1 -1
View File
@@ -44,7 +44,7 @@ jobs:
check-ephemerality:
if: github.repository == 'rustfs/rustfs'
name: Check runner ephemerality
runs-on: sm-standard-2
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout repository
+581 -7
View File
@@ -20,13 +20,91 @@ on:
workflow_dispatch:
inputs:
build_run_id:
description: Successful nightly build run on main
description: Successful nightly or validation package run; empty for package-only build
type: string
required: true
required: false
default: ''
build_run_attempt:
description: Exact successful build attempt
type: string
required: true
required: false
default: ''
validation_package_sha:
description: Exact source SHA for an isolated package-only build
type: string
required: false
default: ''
validation_case:
description: Canonical case ID, environment.inspect for read-only resources, or empty for functional chain
type: string
required: false
default: ''
i05_diagnostic_branch:
description: I05 source-plan runs both H1/H2 controllers with exact native evidence; subsets remain diagnostic
type: choice
options: [source-plan, timeout, queued-successor]
required: false
default: source-plan
auto_testing_sha:
description: Exact auto-testing commit for a validation case
type: string
required: false
default: ''
validation_retain_evidence_run_id:
description: Upload existing persistent evidence from one interrupted validation run without fleet access
type: string
required: false
default: ''
validation_retain_evidence_run_attempt:
description: Exact interrupted run attempt to retain
type: string
required: false
default: '1'
validation_runner_inspect_only:
description: Inspect GitHub runner registration and queued jobs on a hosted runner without fleet access
type: boolean
required: false
default: false
validation_tools_only:
description: Build candidate-bound validation tools without running a fleet case
type: boolean
required: false
default: false
validation_native_tools:
description: Include exact-source native regressions in a tools-only build
type: boolean
required: false
default: false
validation_shared_tools:
description: Include the controlled C10 shared-reference encoder in a tools-only build
type: boolean
required: false
default: false
validation_manager_tools:
description: Include exact checkpoint/MRF/incarnation regressions in a tools-only build
type: boolean
required: false
default: false
validation_cleanup_run_id:
description: Failed validation run whose supported retained environment needs evidence-based finalization
type: string
required: false
default: ''
validation_cleanup_retained_evidence:
description: Recover from verified persistent runner-local evidence when the failed run uploaded no artifact
type: boolean
required: false
default: false
validation_tools_run_id:
description: Successful tool build run; required by Heal physical metadata checks
type: string
required: false
default: ''
validation_tools_run_attempt:
description: Exact tool build attempt
type: string
required: false
default: '1'
workflow_run:
workflows: ["Nightly GNU Build"]
types: [completed]
@@ -36,13 +114,104 @@ permissions:
actions: read
concurrency:
group: rustfs-functional-chain-runs
group: ${{ github.event_name == 'workflow_dispatch' && inputs.validation_runner_inspect_only && 'rustfs-functional-validation-runner-inspection' || github.event_name == 'workflow_dispatch' && inputs.validation_package_sha != '' && format('rustfs-functional-validation-package-{0}', inputs.validation_package_sha) || github.event_name == 'workflow_dispatch' && inputs.validation_tools_only && 'rustfs-functional-validation-tools' || 'rustfs-functional-chain-runs' }}
cancel-in-progress: false
jobs:
prepare:
if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.event == 'schedule' }}
validation-runner-inspection:
if: ${{ github.event_name == 'workflow_dispatch' && inputs.validation_runner_inspect_only }}
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
- name: Inspect shared runner registration and queue without fleet access
env:
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
RUNNER_INSPECTION_INPUTS: ${{ toJSON(inputs) }}
run: >-
python3 -B scripts/inspect_validation_runner.py
--output "${RUNNER_TEMP}/validation-runner-inspection/report.json"
- name: Upload read-only runner inspection including incomplete reports
if: ${{ always() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: validation-runner-inspection-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/validation-runner-inspection/report.json
if-no-files-found: error
validation-evidence-retention:
if: ${{ github.event_name == 'workflow_dispatch' && !inputs.validation_runner_inspect_only && inputs.validation_retain_evidence_run_id != '' }}
# The original interrupted evidence exists only on this long-lived runner.
runs-on: smoke-testing
timeout-minutes: 15
concurrency:
group: rustfs-shared-functional-tests-v2
cancel-in-progress: false
steps:
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
- name: Verify existing interrupted evidence without changing it
env:
GH_TOKEN: ${{ github.token }}
EVIDENCE_RETAIN_INPUTS: ${{ toJSON(inputs) }}
run: >-
python3 -B scripts/retain_validation_evidence.py
--report "${RUNNER_TEMP}/validation-evidence-retention/report.json"
- name: Upload admitted original evidence under collection provenance
if: ${{ env.VALIDATION_RETAIN_ROOT != '' }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: rustfs-validation-retained-${{ env.VALIDATION_RETAIN_SOURCE_RUN }}-${{ env.VALIDATION_RETAIN_SOURCE_ATTEMPT }}-by-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ env.VALIDATION_RETAIN_ROOT }}
if-no-files-found: error
include-hidden-files: true
- name: Upload retention receipt including incomplete reports
if: ${{ always() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: validation-evidence-retention-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/validation-evidence-retention/report.json
if-no-files-found: error
validation-package-request:
if: ${{ github.event_name == 'workflow_dispatch' && !inputs.validation_runner_inspect_only && inputs.validation_retain_evidence_run_id == '' && inputs.validation_package_sha != '' }}
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Validate exclusive package-only request
env:
PACKAGE_INPUTS: ${{ toJSON(inputs) }}
run: |
python3 - <<'PY'
import json
import os
import re
value = json.loads(os.environ["PACKAGE_INPUTS"])
if re.fullmatch(r"[0-9a-f]{40}", value["validation_package_sha"]) is None:
raise ValueError("package build requires an exact source SHA")
exclusive = ("validation_retain_evidence_run_id", "validation_runner_inspect_only", "build_run_id", "build_run_attempt", "validation_case", "auto_testing_sha",
"validation_tools_only", "validation_native_tools", "validation_shared_tools",
"validation_manager_tools", "validation_cleanup_run_id", "validation_cleanup_retained_evidence", "validation_tools_run_id")
if any(value.get(name) for name in exclusive):
raise ValueError("package-only build cannot select existing candidate, tools, fleet or recovery execution")
PY
validation-package:
needs: validation-package-request
if: ${{ github.event_name == 'workflow_dispatch' && !inputs.validation_runner_inspect_only && inputs.validation_retain_evidence_run_id == '' && inputs.validation_package_sha != '' }}
uses: ./.github/workflows/rustfs-validation-package.yml
with:
source_sha: ${{ inputs.validation_package_sha }}
secrets:
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
R2_BUCKET: ${{ secrets.R2_BUCKET }}
prepare:
if: ${{ (github.event_name == 'workflow_dispatch' && !inputs.validation_runner_inspect_only && inputs.validation_retain_evidence_run_id == '' && inputs.validation_package_sha == '' && inputs.validation_case == '' && !inputs.validation_tools_only && !inputs.validation_native_tools && !inputs.validation_shared_tools && !inputs.validation_manager_tools && !inputs.validation_cleanup_retained_evidence && inputs.validation_cleanup_run_id == '') || github.event.workflow_run.event == 'schedule' }}
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
manifest: ${{ steps.candidate.outputs.manifest }}
@@ -89,6 +258,411 @@ jobs:
echo "performance_ready=offline" >> "$GITHUB_OUTPUT"
fi
validation:
if: ${{ github.event_name == 'workflow_dispatch' && !inputs.validation_runner_inspect_only && inputs.validation_retain_evidence_run_id == '' && inputs.validation_package_sha == '' && (inputs.validation_case != '' || inputs.validation_tools_only || inputs.validation_native_tools || inputs.validation_shared_tools || inputs.validation_manager_tools || inputs.validation_cleanup_run_id != '' || inputs.validation_cleanup_retained_evidence) }}
runs-on: ${{ inputs.validation_tools_only && 'ubuntu-latest' || 'smoke-testing' }}
timeout-minutes: 90
outputs:
source_sha: ${{ steps.candidate.outputs.source_sha }}
concurrency:
group: ${{ inputs.validation_tools_only && 'rustfs-validation-tools-resolver' || 'rustfs-shared-functional-tests-v2' }}
cancel-in-progress: false
env:
RUSTFS_ACCESS_KEY: ${{ !inputs.validation_tools_only && secrets.RUSTFS_ACCESS_KEY || '' }}
RUSTFS_SECRET_KEY: ${{ !inputs.validation_tools_only && secrets.RUSTFS_SECRET_KEY || '' }}
RUSTFS_API_ENDPOINT: ${{ !inputs.validation_tools_only && (secrets.RUSTFS_API_ENDPOINT || vars.RUSTFS_API_ENDPOINT || vars.RUSTFS_RC_ENDPOINT) || '' }}
RUSTFS_NODES: ${{ !inputs.validation_tools_only && (secrets.RUSTFS_NODES || vars.RUSTFS_NODES) || '' }}
RUSTFS_SSH_USER: ${{ !inputs.validation_tools_only && (secrets.RUSTFS_SSH_USER || vars.RUSTFS_SSH_USER) || '' }}
steps:
- name: Checkout candidate resolver
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
- name: Initialize validation evidence
env:
VALIDATION_TOOLS_ONLY: ${{ inputs.validation_tools_only }}
run: |
set -euo pipefail
umask 077
python3 -B scripts/initialize_validation_evidence.py
- name: Resolve immutable candidate
id: candidate
env:
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
BUILD_RUN_ID: ${{ inputs.build_run_id }}
BUILD_RUN_ATTEMPT: ${{ inputs.build_run_attempt }}
AUTO_TESTING_SHA: ${{ inputs.auto_testing_sha }}
CLEANUP_RUN_ID: ${{ inputs.validation_cleanup_run_id }}
CLEANUP_RETAINED_EVIDENCE: ${{ inputs.validation_cleanup_retained_evidence }}
SELECTED_CASE: ${{ inputs.validation_case }}
TOOLS_ONLY: ${{ inputs.validation_tools_only }}
NATIVE_TOOLS: ${{ inputs.validation_native_tools }}
SHARED_TOOLS: ${{ inputs.validation_shared_tools }}
MANAGER_TOOLS: ${{ inputs.validation_manager_tools }}
I05_DIAGNOSTIC_BRANCH: ${{ inputs.i05_diagnostic_branch }}
run: |
set -euo pipefail
python3 - <<'PY'
import json
import os
from pathlib import Path
import re
from scripts.resolve_functional_candidate import resolve
revision = os.environ["AUTO_TESTING_SHA"]
cleanup = os.environ["CLEANUP_RUN_ID"]
if os.environ["CLEANUP_RETAINED_EVIDENCE"] == "true" and not cleanup:
raise ValueError("persistent recovery input requires one exact cleanup run")
if os.environ["TOOLS_ONLY"] == "true" and (cleanup or os.environ["SELECTED_CASE"]):
raise ValueError("tools-only execution cannot select a fleet case or cleanup")
if os.environ["NATIVE_TOOLS"] == "true" and (os.environ["TOOLS_ONLY"] != "true" or cleanup or os.environ["SELECTED_CASE"]):
raise ValueError("native regressions require tools-only execution, without a fleet case or cleanup")
if os.environ["SHARED_TOOLS"] == "true" and (os.environ["TOOLS_ONLY"] != "true" or cleanup or os.environ["SELECTED_CASE"]):
raise ValueError("shared fixture builds require tools-only execution, without a fleet case or cleanup")
if os.environ["MANAGER_TOOLS"] == "true" and (os.environ["TOOLS_ONLY"] != "true" or cleanup or os.environ["SELECTED_CASE"]):
raise ValueError("manager regressions require tools-only execution, without a fleet case or cleanup")
if cleanup and (not cleanup.isdigit() or int(cleanup) <= 0 or os.environ["SELECTED_CASE"] or os.environ["TOOLS_ONLY"] == "true"):
raise ValueError("cleanup requires one exact failed run and is exclusive with case/tool execution")
diagnostic_branch = os.environ["I05_DIAGNOSTIC_BRANCH"] or "source-plan"
if diagnostic_branch not in {"source-plan", "timeout", "queued-successor"}:
raise ValueError("I05 diagnostic branch is not registered")
if diagnostic_branch == "queued-successor" and os.environ["SELECTED_CASE"] != "heal.lock-timeout-and-caller-abort":
raise ValueError("queued-successor is available only for the I05 diagnostic case")
if re.fullmatch(r"[0-9a-f]{40}", revision) is None:
raise ValueError("validation requires an exact auto-testing commit SHA")
candidate = resolve(int(os.environ["BUILD_RUN_ID"]), int(os.environ["BUILD_RUN_ATTEMPT"]), allow_validation=True)
manifest = candidate["manifest"]
root = Path(os.environ["VALIDATION_ROOT"])
(root / "candidate-source.json").write_text(json.dumps(candidate, sort_keys=True) + "\n")
(root / "candidate.json").write_text(json.dumps({
"package_url": manifest["package_url"],
"package_sha256": manifest["package_sha256"],
"source_sha": manifest["source_sha"],
}, sort_keys=True) + "\n")
with open(os.environ["GITHUB_OUTPUT"], "a") as output:
output.write("source_sha=" + manifest["source_sha"] + "\n")
PY
- name: Checkout exact auto-testing revision
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
repository: rustfs/auto-testing
ref: ${{ inputs.auto_testing_sha }}
token: ${{ secrets.PF_TESTING_GH_TOKEN }}
path: auto-testing
persist-credentials: false
- name: Prepare validation Python runtime
if: ${{ !inputs.validation_tools_only && (startsWith(inputs.validation_case, 'ilm.') || startsWith(inputs.validation_case, 'pool.') || startsWith(inputs.validation_case, 'bucket-replication.') || startsWith(inputs.validation_case, 'site-replication.') || startsWith(inputs.validation_case, 'heal.')) }}
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
- name: Install validation Python client
if: ${{ !inputs.validation_tools_only && (startsWith(inputs.validation_case, 'ilm.') || startsWith(inputs.validation_case, 'pool.') || startsWith(inputs.validation_case, 'bucket-replication.') || startsWith(inputs.validation_case, 'site-replication.') || startsWith(inputs.validation_case, 'heal.')) }}
run: |
set -euo pipefail
validation_python="${RUNNER_TEMP}/rustfs-validation-python-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
test ! -e "${validation_python}"
python3 -m venv "${validation_python}"
"${validation_python}/bin/python" -m pip install \
-r auto-testing/scripts/validation_ilm/requirements.txt
printf '%s\n' "${validation_python}/bin" >> "${GITHUB_PATH}"
- name: Validate Heal tool build identity
if: ${{ !inputs.validation_tools_only && startsWith(inputs.validation_case, 'heal.') }}
env:
GH_TOKEN: ${{ github.token }}
TOOL_RUN_ID: ${{ inputs.validation_tools_run_id }}
TOOL_RUN_ATTEMPT: ${{ inputs.validation_tools_run_attempt }}
run: |
set -euo pipefail
[[ "${TOOL_RUN_ID}" =~ ^[1-9][0-9]*$ ]]
[[ "${TOOL_RUN_ATTEMPT}" =~ ^[1-9][0-9]*$ ]]
gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${TOOL_RUN_ID}/attempts/${TOOL_RUN_ATTEMPT}" \
> "${VALIDATION_ROOT}/tool-run.json"
python3 - <<'PY'
import json
import os
from pathlib import Path
value = json.loads((Path(os.environ["VALIDATION_ROOT"]) / "tool-run.json").read_text())
if (value.get("conclusion") != "success" or value.get("status") != "completed"
or value.get("run_attempt") != int(os.environ["TOOL_RUN_ATTEMPT"])
or value.get("path") not in {".github/workflows/rustfs-functional-chain.yml", ".github/workflows/rustfs-validation-tools.yml"}):
raise ValueError("Heal tool run is not an exact successful validation build attempt")
PY
- name: Download pinned Heal metadata inspector
if: ${{ !inputs.validation_tools_only && startsWith(inputs.validation_case, 'heal.') }}
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
github-token: ${{ github.token }}
run-id: ${{ inputs.validation_tools_run_id }}
name: rustfs-validation-tools-${{ inputs.validation_tools_run_id }}-${{ inputs.validation_tools_run_attempt }}
path: ${{ runner.temp }}/rustfs-validation-tools-${{ github.run_id }}-${{ github.run_attempt }}
- name: Verify Heal metadata inspector
if: ${{ !inputs.validation_tools_only && startsWith(inputs.validation_case, 'heal.') }}
run: |
set -euo pipefail
TOOL_DIRECTORY="${RUNNER_TEMP}/rustfs-validation-tools-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
python3 -B auto-testing/scripts/validation_heal/inspector.py \
--directory "${TOOL_DIRECTORY}" --candidate "${VALIDATION_ROOT}/candidate.json" \
> "${VALIDATION_ROOT}/tool-manifest.json"
printf 'VALIDATION_HEAL_TOOL_DIRECTORY=%s\n' "${TOOL_DIRECTORY}" >> "${GITHUB_ENV}"
- name: Select exact-source race native evidence
id: race_native
if: ${{ inputs.validation_case == 'heal.race-unversioned-overwrite' || inputs.validation_case == 'heal.race-versioned-put' || inputs.validation_case == 'heal.race-version-delete' || inputs.validation_case == 'heal.race-markers-and-batch-delete' || inputs.validation_case == 'heal.race-multipart-complete-abort' || inputs.validation_case == 'heal.admin-nolock-safety' || inputs.validation_case == 'heal.race-copy-and-metadata-update' }}
env:
CANDIDATE_SHA: ${{ steps.candidate.outputs.source_sha }}
VALIDATION_CASE_ID: ${{ inputs.validation_case }}
run: |
set -euo pipefail
required=false
if [ -f auto-testing/scripts/validation_heal/race_native.py ]; then
status=0
python3 -B auto-testing/scripts/validation_heal/race_native.py supports \
--commit "${CANDIDATE_SHA}" --case-id "${VALIDATION_CASE_ID}" || status=$?
if [ "${status}" -eq 0 ]; then
required=true
elif [ "${status}" -ne 2 ]; then
exit "${status}"
fi
fi
reviewed=false
case "${CANDIDATE_SHA}:${VALIDATION_CASE_ID}" in
6afdec9fe1b0b88007f2e5b445c19bf73d51d3ca:heal.race-unversioned-overwrite | \
bd0671ba781d8ebf509f4f03533228dfb44496f1:heal.race-unversioned-overwrite | \
bd0671ba781d8ebf509f4f03533228dfb44496f1:heal.race-versioned-put | \
bd0671ba781d8ebf509f4f03533228dfb44496f1:heal.race-version-delete | \
8380466f3a27c3c3ef4115084178a0ab99a78f32:heal.race-unversioned-overwrite | \
8380466f3a27c3c3ef4115084178a0ab99a78f32:heal.race-versioned-put | \
8380466f3a27c3c3ef4115084178a0ab99a78f32:heal.race-version-delete | \
8380466f3a27c3c3ef4115084178a0ab99a78f32:heal.race-markers-and-batch-delete | \
757b751482bb970bc321e92059690a7698554358:heal.admin-nolock-safety | \
757b751482bb970bc321e92059690a7698554358:heal.race-multipart-complete-abort | \
757b751482bb970bc321e92059690a7698554358:heal.race-copy-and-metadata-update) reviewed=true ;;
esac
if [ "${reviewed}" = true ] && [ "${required}" != true ]; then
printf 'Race candidate requires its reviewed native collector before fleet execution\n' >&2
exit 1
fi
printf 'required=%s\n' "${required}" >> "${GITHUB_OUTPUT}"
- name: Download pinned native storage evidence
if: ${{ inputs.validation_case == 'heal.conflicting-subquorum-authority' || inputs.validation_case == 'heal.bucket-incarnation-recreation' || steps.race_native.outputs.required == 'true' || (inputs.validation_case == 'heal.lock-timeout-and-caller-abort' && inputs.i05_diagnostic_branch != 'timeout') || (inputs.validation_case == 'heal.checkpoint-and-mrf-corruption' && steps.candidate.outputs.source_sha == '1aa9be87d18cf941e5020f19e8622c26ba4b10ac') }}
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
github-token: ${{ github.token }}
run-id: ${{ inputs.validation_tools_run_id }}
name: rustfs-validation-native-${{ inputs.validation_tools_run_id }}-${{ inputs.validation_tools_run_attempt }}
path: ${{ runner.temp }}/rustfs-validation-native-${{ github.run_id }}-${{ github.run_attempt }}
- name: Verify C07 native source and exact test evidence
if: ${{ inputs.validation_case == 'heal.conflicting-subquorum-authority' }}
run: |
set -euo pipefail
NATIVE_DIRECTORY="${RUNNER_TEMP}/rustfs-validation-native-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
cp "${VALIDATION_ROOT}/tool-run.json" "${NATIVE_DIRECTORY}/tool-run.json"
python3 -B auto-testing/scripts/validation_heal/authority.py \
--directory "${NATIVE_DIRECTORY}" --candidate "${VALIDATION_ROOT}/candidate.json" \
> "${VALIDATION_ROOT}/native-authority-manifest.json"
printf 'VALIDATION_HEAL_NATIVE_DIRECTORY=%s\n' "${NATIVE_DIRECTORY}" >> "${GITHUB_ENV}"
- name: Verify C11 physical storage and exact final-rename barrier
if: ${{ inputs.validation_case == 'heal.bucket-incarnation-recreation' }}
run: |
set -euo pipefail
NATIVE_DIRECTORY="${RUNNER_TEMP}/rustfs-validation-native-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
cp "${VALIDATION_ROOT}/tool-run.json" "${NATIVE_DIRECTORY}/tool-run.json"
python3 -B auto-testing/scripts/validation_heal/incarnation_native.py \
--directory "${NATIVE_DIRECTORY}" --candidate "${VALIDATION_ROOT}/candidate.json" \
--testing-commit "${{ inputs.auto_testing_sha }}" \
> "${VALIDATION_ROOT}/native-incarnation-manifest.json"
printf 'VALIDATION_HEAL_NATIVE_DIRECTORY=%s\n' "${NATIVE_DIRECTORY}" >> "${GITHUB_ENV}"
- name: Verify exact-source race native and runtime evidence
if: ${{ steps.race_native.outputs.required == 'true' }}
env:
AUTO_TESTING_SHA: ${{ inputs.auto_testing_sha }}
VALIDATION_CASE_ID: ${{ inputs.validation_case }}
run: |
set -euo pipefail
NATIVE_DIRECTORY="${RUNNER_TEMP}/rustfs-validation-native-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
cp "${VALIDATION_ROOT}/tool-run.json" "${NATIVE_DIRECTORY}/tool-run.json"
python3 -B auto-testing/scripts/validation_heal/race_native_evidence.py \
--directory "${NATIVE_DIRECTORY}" --candidate "${VALIDATION_ROOT}/candidate.json" \
--testing-commit "${AUTO_TESTING_SHA}" --case-id "${VALIDATION_CASE_ID}" \
> "${VALIDATION_ROOT}/native-race-manifest.json"
runtime_driver=race.py
if [ "${VALIDATION_CASE_ID}" = 'heal.admin-nolock-safety' ]; then
runtime_driver=nolock.py
fi
VALIDATION_HEAL_NATIVE_DIRECTORY="${NATIVE_DIRECTORY}" \
python3 -B "auto-testing/scripts/validation_heal/${runtime_driver}" preflight \
--candidate "${VALIDATION_ROOT}/candidate.json" --testing-commit "${AUTO_TESTING_SHA}" \
--case-id "${VALIDATION_CASE_ID}" \
> "${VALIDATION_ROOT}/native-race-runtime-preflight.json"
printf 'VALIDATION_HEAL_NATIVE_DIRECTORY=%s\n' "${NATIVE_DIRECTORY}" >> "${GITHUB_ENV}"
- name: Verify I05 source-plan or queued-successor native prerequisites
if: ${{ inputs.validation_case == 'heal.lock-timeout-and-caller-abort' && inputs.i05_diagnostic_branch != 'timeout' }}
env:
AUTO_TESTING_SHA: ${{ inputs.auto_testing_sha }}
I05_DIAGNOSTIC_BRANCH: ${{ inputs.i05_diagnostic_branch }}
run: |
set -euo pipefail
NATIVE_DIRECTORY="${RUNNER_TEMP}/rustfs-validation-native-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
cp "${VALIDATION_ROOT}/tool-run.json" "${NATIVE_DIRECTORY}/tool-run.json"
if [ "${I05_DIAGNOSTIC_BRANCH}" = 'queued-successor' ]; then
python3 -B auto-testing/scripts/validation_heal/race_native_evidence.py \
--directory "${NATIVE_DIRECTORY}" --candidate "${VALIDATION_ROOT}/candidate.json" \
--testing-commit "${AUTO_TESTING_SHA}" --case-id heal.lock-timeout-and-caller-abort \
> "${VALIDATION_ROOT}/native-i05-prerequisite.json"
else
python3 -B auto-testing/scripts/validation_heal/lock_source.py \
--directory "${NATIVE_DIRECTORY}" --candidate "${VALIDATION_ROOT}/candidate.json" \
--testing-commit "${AUTO_TESTING_SHA}" \
> "${VALIDATION_ROOT}/native-i05-source-prerequisites.json"
fi
printf 'VALIDATION_HEAL_NATIVE_DIRECTORY=%s\n' "${NATIVE_DIRECTORY}" >> "${GITHUB_ENV}"
- name: Bind F07 exact-candidate directory fsync evidence
if: ${{ inputs.validation_case == 'heal.checkpoint-and-mrf-corruption' && steps.candidate.outputs.source_sha == '1aa9be87d18cf941e5020f19e8622c26ba4b10ac' }}
run: |
set -euo pipefail
NATIVE_DIRECTORY="${RUNNER_TEMP}/rustfs-validation-native-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
cp "${VALIDATION_ROOT}/tool-run.json" "${NATIVE_DIRECTORY}/tool-run.json"
printf 'VALIDATION_HEAL_NATIVE_DIRECTORY=%s\n' "${NATIVE_DIRECTORY}" >> "${GITHUB_ENV}"
- name: Download pinned manager persistence and incarnation evidence
if: ${{ inputs.validation_case == 'heal.checkpoint-and-mrf-corruption' || inputs.validation_case == 'heal.bucket-incarnation-recreation' || inputs.validation_case == 'heal.outcome-counters-and-error-truth' }}
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
github-token: ${{ github.token }}
run-id: ${{ inputs.validation_tools_run_id }}
name: rustfs-validation-manager-native-${{ inputs.validation_tools_run_id }}-${{ inputs.validation_tools_run_attempt }}
path: ${{ runner.temp }}/rustfs-validation-manager-native-${{ github.run_id }}-${{ github.run_attempt }}
- name: Verify exact manager native prerequisites before fleet mutation
if: ${{ inputs.validation_case == 'heal.checkpoint-and-mrf-corruption' || inputs.validation_case == 'heal.bucket-incarnation-recreation' || inputs.validation_case == 'heal.outcome-counters-and-error-truth' }}
env:
VALIDATION_CASE: ${{ inputs.validation_case }}
run: |
set -euo pipefail
MANAGER_DIRECTORY="${RUNNER_TEMP}/rustfs-validation-manager-native-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
cp "${VALIDATION_ROOT}/tool-run.json" "${MANAGER_DIRECTORY}/tool-run.json"
python3 -B auto-testing/scripts/validation_heal/manager_fixture.py \
--directory "${MANAGER_DIRECTORY}" --candidate "${VALIDATION_ROOT}/candidate.json" \
--case-id "${VALIDATION_CASE}" > "${VALIDATION_ROOT}/manager-prerequisite-manifest.json"
printf 'VALIDATION_HEAL_MANAGER_DIRECTORY=%s\n' "${MANAGER_DIRECTORY}" >> "${GITHUB_ENV}"
- name: Download pinned C10 shared-reference encoder
if: ${{ inputs.validation_case == 'heal.dangling-and-orphan-safety' }}
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
github-token: ${{ github.token }}
run-id: ${{ inputs.validation_tools_run_id }}
name: rustfs-validation-shared-${{ inputs.validation_tools_run_id }}-${{ inputs.validation_tools_run_attempt }}
path: ${{ runner.temp }}/rustfs-validation-shared-${{ github.run_id }}-${{ github.run_attempt }}
- name: Verify C10 exact-source codec and original tests
if: ${{ inputs.validation_case == 'heal.dangling-and-orphan-safety' }}
run: |
set -euo pipefail
SHARED_DIRECTORY="${RUNNER_TEMP}/rustfs-validation-shared-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
cp "${VALIDATION_ROOT}/tool-run.json" "${SHARED_DIRECTORY}/tool-run.json"
python3 -B auto-testing/scripts/validation_heal/shared_fixture.py \
--directory "${SHARED_DIRECTORY}" --candidate "${VALIDATION_ROOT}/candidate.json" \
> "${VALIDATION_ROOT}/shared-fixture-manifest.json"
printf 'VALIDATION_HEAL_SHARED_DIRECTORY=%s\n' "${SHARED_DIRECTORY}" >> "${GITHUB_ENV}"
- name: Run selected canonical validation case
if: ${{ !inputs.validation_tools_only && inputs.validation_cleanup_run_id == '' && inputs.validation_case != 'environment.inspect' }}
env:
VALIDATION_CASE: ${{ inputs.validation_case }}
AUTO_TESTING_SHA: ${{ inputs.auto_testing_sha }}
I05_DIAGNOSTIC_BRANCH: ${{ inputs.i05_diagnostic_branch }}
run: |
set -euo pipefail
test "$(git -C auto-testing rev-parse HEAD)" = "${AUTO_TESTING_SHA}"
./auto-testing/scripts/run_validation_case.sh \
"${VALIDATION_CASE}" "${VALIDATION_ROOT}/candidate.json" \
"${AUTO_TESTING_SHA}" "${VALIDATION_ROOT}/run"
- name: Inspect current Heal fleet resources without mutation
if: ${{ !inputs.validation_tools_only && inputs.validation_case == 'environment.inspect' }}
env:
AUTO_TESTING_SHA: ${{ inputs.auto_testing_sha }}
run: |
set -euo pipefail
test "$(git -C auto-testing rev-parse HEAD)" = "${AUTO_TESTING_SHA}"
python3 -B auto-testing/scripts/validation_heal/environment.py \
--candidate "${VALIDATION_ROOT}/candidate.json" \
--testing-commit "${AUTO_TESTING_SHA}" --evidence "${VALIDATION_ROOT}/environment"
- name: Audit and finalize retained validation environment
if: ${{ inputs.validation_cleanup_run_id != '' }}
env:
GH_TOKEN: ${{ github.token }}
FAILED_RUN_ID: ${{ inputs.validation_cleanup_run_id }}
AUTO_TESTING_SHA: ${{ inputs.auto_testing_sha }}
CLEANUP_RETAINED_EVIDENCE: ${{ inputs.validation_cleanup_retained_evidence }}
run: |
set -euo pipefail
test "$(git -C auto-testing rev-parse HEAD)" = "${AUTO_TESTING_SHA}"
gh run view "${FAILED_RUN_ID}" --repo "${GITHUB_REPOSITORY}" \
--json status,conclusion,headBranch,workflowName > "${VALIDATION_ROOT}/failed-run.json"
python3 - <<'PY'
import json
import os
from pathlib import Path
run = json.loads((Path(os.environ["VALIDATION_ROOT"]) / "failed-run.json").read_text())
if (run["status"] != "completed" or run["conclusion"] not in {"failure", "cancelled"}
or run["workflowName"] != "RustFS Functional Chain" or run["headBranch"] != os.environ["GITHUB_REF_NAME"]):
raise ValueError("cleanup source must be a completed failed or cancelled Functional Chain run")
PY
if [ "${CLEANUP_RETAINED_EVIDENCE}" = true ]; then
python3 -B scripts/prepare_retained_validation_recovery.py \
--report "${VALIDATION_ROOT}/retained-recovery-input.json"
else
gh run download "${FAILED_RUN_ID}" --repo "${GITHUB_REPOSITORY}" \
--pattern "rustfs-validation-${FAILED_RUN_ID}-*" --dir "${VALIDATION_ROOT}/failed-artifact"
fi
python3 -B auto-testing/scripts/validation_heal/recover_environment.py \
--failed-run-id "${FAILED_RUN_ID}" --artifact "${VALIDATION_ROOT}/failed-artifact" \
--candidate "${VALIDATION_ROOT}/candidate.json" --evidence "${VALIDATION_ROOT}/recovery"
- name: Show validation report
if: ${{ always() }}
run: |
if [ -f "${VALIDATION_ROOT}/run/report.md" ]; then
cat "${VALIDATION_ROOT}/run/report.md" >> "${GITHUB_STEP_SUMMARY}"
fi
- name: Upload validation evidence
if: ${{ always() && env.VALIDATION_ROOT != '' }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: rustfs-validation-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ env.VALIDATION_ROOT }}
if-no-files-found: error
validation-tools:
needs: [validation]
if: ${{ github.event_name == 'workflow_dispatch' && inputs.validation_package_sha == '' && inputs.validation_tools_only }}
uses: ./.github/workflows/rustfs-validation-tools.yml
with:
candidate_sha: ${{ needs.validation.outputs.source_sha }}
auto_testing_sha: ${{ inputs.auto_testing_sha }}
build_native_heal: ${{ inputs.validation_native_tools }}
build_shared_heal: ${{ inputs.validation_shared_tools }}
build_manager_native: ${{ inputs.validation_manager_tools }}
secrets: inherit
upgrade:
needs: [prepare]
if: ${{ always() && needs.prepare.result == 'success' }}
@@ -188,7 +762,7 @@ jobs:
complete-chain:
needs: [prepare, upgrade, s3, kms, tier, storage, heal, pool, security, replication, fault-tolerance, table, performance]
if: ${{ always() && needs.prepare.result != 'skipped' }}
runs-on: smoke-testing
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
@@ -90,7 +90,7 @@ env:
jobs:
runner-check:
if: ${{ inputs.chain_manifest != '' || github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
runs-on: sm-standard-2
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
@@ -0,0 +1,244 @@
# 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.
name: RustFS Validation Package
on:
workflow_call:
inputs:
source_sha:
description: Exact RustFS commit SHA to build
required: true
type: string
secrets:
R2_ACCESS_KEY_ID:
required: true
R2_SECRET_ACCESS_KEY:
required: true
R2_ENDPOINT:
required: true
R2_BUCKET:
required: true
workflow_dispatch:
inputs:
source_sha:
description: Exact RustFS commit SHA to build
required: true
type: string
permissions:
contents: read
concurrency:
group: rustfs-validation-package-${{ inputs.source_sha }}
cancel-in-progress: false
jobs:
resolve-source:
if: github.repository == 'rustfs/rustfs'
name: Resolve exact validation source
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
source_sha: ${{ steps.source.outputs.sha }}
source_ref: ${{ steps.source.outputs.ref }}
steps:
- name: Validate requested source
id: source
env:
REQUESTED_SHA: ${{ inputs.source_sha }}
run: |
set -euo pipefail
[[ "${REQUESTED_SHA}" =~ ^[0-9a-f]{40}$ ]]
source_directory="$(mktemp -d "${RUNNER_TEMP}/validation-source.XXXXXX")"
trap 'rm -rf "${source_directory}"' EXIT
git init -q "${source_directory}"
git -C "${source_directory}" fetch --no-tags --depth=1 https://github.com/rustfs/rustfs.git "${REQUESTED_SHA}"
test "$(git -C "${source_directory}" rev-parse FETCH_HEAD)" = "${REQUESTED_SHA}"
echo "sha=${REQUESTED_SHA}" >> "${GITHUB_OUTPUT}"
echo "ref=${REQUESTED_SHA}" >> "${GITHUB_OUTPUT}"
build:
needs: resolve-source
name: Build immutable validation DEB
runs-on: ubuntu-latest
timeout-minutes: 150
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
VALIDATION_SOURCE_SHA: ${{ needs.resolve-source.outputs.source_sha }}
steps:
- name: Checkout exact source
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
ref: ${{ needs.resolve-source.outputs.source_sha }}
- name: Confirm checked out source
env:
EXPECTED_SHA: ${{ needs.resolve-source.outputs.source_sha }}
run: test "$(git rev-parse HEAD)" = "${EXPECTED_SHA}"
- name: Setup Rust environment
uses: ./.github/actions/setup
with:
cache-shared-key: validation-package-hosted-x86_64-unknown-linux-gnu
cache-save-if: "false"
install-build-packaging-tools: "false"
install-test-tools: "false"
- name: Build RustFS
run: cargo build --release --locked --target x86_64-unknown-linux-gnu -p rustfs --bins
- name: Build validation DEB
shell: bash
run: |
set -euo pipefail
command -v fakeroot >/dev/null 2>&1 || sudo apt-get install -y -qq fakeroot
package_dir="rustfs-validation-${VALIDATION_SOURCE_SHA}"
package_file="${RUNNER_TEMP}/validation-package/rustfs.deb"
mkdir -p "${RUNNER_TEMP}/validation-package"
binary="target/x86_64-unknown-linux-gnu/release/rustfs"
test -x "${binary}"
mkdir -p "${package_dir}/DEBIAN" "${package_dir}/usr/bin" "${package_dir}/etc/default" \
"${package_dir}/lib/systemd/system" "${package_dir}/usr/share/doc/rustfs"
cp "${binary}" "${package_dir}/usr/bin/rustfs"
chmod 755 "${package_dir}/usr/bin/rustfs"
cp deploy/build/rustfs.service "${package_dir}/lib/systemd/system/"
cat > "${package_dir}/etc/default/rustfs" <<'ENVEOF'
# RustFS Environment Configuration
# RUSTFS_ACCESS_KEY=<changeme>
# RUSTFS_SECRET_KEY=<changeme>
# RUSTFS_VOLUMES=""
# RUSTFS_ADDRESS=":9000"
ENVEOF
cat > "${package_dir}/DEBIAN/control" <<'CONTROLEOF'
Package: rustfs
Version: 0.0.0~validation
Section: utils
Priority: optional
Architecture: amd64
Depends: libc6 (>= 2.31)
Maintainer: RustFS Team <support@rustfs.com>
Description: RustFS immutable validation package
Candidate package for RustFS validation only.
CONTROLEOF
printf '%s\n' /etc/default/rustfs > "${package_dir}/DEBIAN/conffiles"
./scripts/release/package_service_scripts.sh after-install > "${package_dir}/DEBIAN/postinst"
./scripts/release/package_service_scripts.sh before-remove > "${package_dir}/DEBIAN/prerm"
./scripts/release/package_service_scripts.sh after-remove > "${package_dir}/DEBIAN/postrm"
chmod 755 "${package_dir}/DEBIAN/postinst" "${package_dir}/DEBIAN/prerm" "${package_dir}/DEBIAN/postrm"
cp LICENSE README.md "${package_dir}/usr/share/doc/rustfs/"
fakeroot dpkg-deb --build "${package_dir}" "${package_file}"
test -s "${package_file}"
- name: Upload opaque validation package
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: validation-package-bytes-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/validation-package/rustfs.deb
if-no-files-found: error
publish:
needs: [resolve-source, build]
name: Publish immutable validation DEB
# Candidate-controlled build scripts never run on this fresh hosted runner.
runs-on: ubuntu-latest
timeout-minutes: 20
env:
VALIDATION_SOURCE_SHA: ${{ needs.resolve-source.outputs.source_sha }}
VALIDATION_SOURCE_REF: ${{ needs.resolve-source.outputs.source_ref }}
steps:
- name: Download opaque validation package
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: validation-package-bytes-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/validation-package-input
- name: Validate and stage opaque package bytes
shell: bash
run: |
set -euo pipefail
python3 -I - <<'PYTHON'
import os
from pathlib import Path
import shutil
import stat
root = Path(os.environ["RUNNER_TEMP"])
incoming = root / "validation-package-input"
if incoming.is_symlink() or not incoming.is_dir() or sorted(p.name for p in incoming.iterdir()) != ["rustfs.deb"]:
raise SystemExit("Validation artifact must contain exactly one rustfs.deb file")
fd = os.open(incoming / "rustfs.deb", os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
with os.fdopen(fd, "rb") as source:
info = os.fstat(source.fileno())
if not stat.S_ISREG(info.st_mode) or info.st_size == 0:
raise SystemExit("Validation package must be a nonempty regular file")
staged = root / "validation-publication"
staged.mkdir(mode=0o700)
with (staged / "rustfs.deb").open("xb") as target:
shutil.copyfileobj(source, target)
PYTHON
- name: Prepare trusted publication tools
id: tools
env:
AWS_EC2_METADATA_DISABLED: true
shell: bash
run: |
set -euo pipefail
AWS_CLI=aws
if ! "${AWS_CLI}" s3api put-object --generate-cli-skeleton input | jq -e 'has("IfNoneMatch")' >/dev/null; then
sudo apt-get update
sudo apt-get install -y -qq python3-venv
AWS_CLI_DIR="$(mktemp -d "${RUNNER_TEMP}/validation-awscli.XXXXXX")"
python3 -m venv "${AWS_CLI_DIR}"
"${AWS_CLI_DIR}/bin/python" -m pip install --disable-pip-version-check 'awscli==1.44.79'
AWS_CLI="${AWS_CLI_DIR}/bin/aws"
fi
"${AWS_CLI}" s3api put-object --generate-cli-skeleton input | jq -e 'has("IfNoneMatch")' >/dev/null
echo "aws_cli=${AWS_CLI}" >> "${GITHUB_OUTPUT}"
- name: Upload validation package to immutable R2 path
id: publish
env:
PACKAGE_FILE: ${{ runner.temp }}/validation-publication/rustfs.deb
AWS_CLI: ${{ steps.tools.outputs.aws_cli }}
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
R2_BUCKET: ${{ secrets.R2_BUCKET }}
AWS_EC2_METADATA_DISABLED: true
shell: bash
run: |
set -euo pipefail
if [[ -z "${R2_ACCESS_KEY_ID}" || -z "${R2_SECRET_ACCESS_KEY}" || -z "${R2_ENDPOINT}" || -z "${R2_BUCKET}" ]]; then
echo "Validation package publication requires complete R2 configuration" >&2
exit 1
fi
[[ "${VALIDATION_SOURCE_SHA}" =~ ^[0-9a-f]{40}$ ]]
test "${VALIDATION_SOURCE_REF}" = "${VALIDATION_SOURCE_SHA}"
test -s "${PACKAGE_FILE}" && test ! -L "${PACKAGE_FILE}"
export AWS_ACCESS_KEY_ID="${R2_ACCESS_KEY_ID}" AWS_SECRET_ACCESS_KEY="${R2_SECRET_ACCESS_KEY}" AWS_DEFAULT_REGION=auto
package_sha256="$(sha256sum "${PACKAGE_FILE}" | cut -d ' ' -f 1)"
key="artifacts/rustfs/packages/validation/runs/${GITHUB_RUN_ID}/${GITHUB_RUN_ATTEMPT}/${package_sha256}/rustfs.deb"
url="https://dl.rustfs.com/${key}"
"${AWS_CLI}" s3api put-object --bucket "${R2_BUCKET}" --key "${key}" --body "${PACKAGE_FILE}" \
--if-none-match '*' --endpoint-url "${R2_ENDPOINT}"
downloaded_sha256="$(curl -fsSL --retry 3 --connect-timeout 15 --max-time 300 "${url}" | sha256sum | cut -d ' ' -f 1)"
test "${downloaded_sha256}" = "${package_sha256}"
manifest="${RUNNER_TEMP}/validation-candidate-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}.json"
jq -n --arg workflow_sha "${GITHUB_SHA}" --arg source_ref "${VALIDATION_SOURCE_REF}" \
--arg source_sha "${VALIDATION_SOURCE_SHA}" --arg channel validation \
--argjson build_run_id "${GITHUB_RUN_ID}" --argjson build_run_attempt "${GITHUB_RUN_ATTEMPT}" \
--arg package_url "${url}" --arg package_sha256 "${package_sha256}" \
'{schema: 3, workflow_sha: $workflow_sha, source_ref: $source_ref, source_sha: $source_sha, channel: $channel, build_run_id: $build_run_id, build_run_attempt: $build_run_attempt, package_url: $package_url, package_sha256: $package_sha256}' > "${manifest}"
echo "candidate_file=${manifest}" >> "${GITHUB_OUTPUT}"
- name: Upload validation candidate manifest
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: validation-candidate-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.publish.outputs.candidate_file }}
if-no-files-found: error
@@ -0,0 +1,343 @@
# Build source-bound helpers separately so live validation needs only artifacts.
name: RustFS Validation Tools
on:
workflow_call:
inputs:
candidate_sha:
type: string
required: true
auto_testing_sha:
type: string
required: true
build_native_heal:
type: boolean
required: false
default: false
build_shared_heal:
type: boolean
required: false
default: false
build_manager_native:
type: boolean
required: false
default: false
secrets:
PF_TESTING_GH_TOKEN:
required: true
workflow_dispatch:
inputs:
candidate_sha:
description: Exact RustFS source SHA from the validation candidate manifest
type: string
required: true
auto_testing_sha:
description: Exact auto-testing SHA containing the Heal metadata inspector
type: string
required: true
build_native_heal:
description: Also compile and run separate exact-source native Heal regressions
type: boolean
required: false
default: false
build_shared_heal:
description: Build the controlled C10 shared-reference fixture encoder
type: boolean
required: false
default: false
build_manager_native:
description: Build and run exact candidate checkpoint/MRF/incarnation regressions
type: boolean
required: false
default: false
permissions:
contents: read
jobs:
manager-native-regressions:
if: ${{ inputs.build_manager_native }}
runs-on: ubuntu-22.04
timeout-minutes: 180
env:
CANDIDATE_SHA: ${{ inputs.candidate_sha }}
AUTO_TESTING_SHA: ${{ inputs.auto_testing_sha }}
steps:
- name: Validate immutable manager native inputs
run: |
set -euo pipefail
[[ "${CANDIDATE_SHA}" =~ ^[0-9a-f]{40}$ ]]
[[ "${AUTO_TESTING_SHA}" =~ ^[0-9a-f]{40}$ ]]
- name: Checkout exact manager candidate source
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
ref: ${{ inputs.candidate_sha }}
path: candidate
persist-credentials: false
- name: Checkout exact manager collector source
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
repository: rustfs/auto-testing
ref: ${{ inputs.auto_testing_sha }}
token: ${{ secrets.PF_TESTING_GH_TOKEN }}
path: auto-testing
persist-credentials: false
- name: Install manager native build dependencies
run: |
set -euo pipefail
sudo apt-get update
sudo apt-get install -y clang lld cmake pkg-config libssl-dev protobuf-compiler
df -h .
- name: Build exact unmodified candidate manager test executable
run: |
set -euo pipefail
test "$(git -C auto-testing rev-parse HEAD)" = "${AUTO_TESTING_SHA}"
python3 -B auto-testing/scripts/validation_heal/manager_native.py build \
--source candidate --commit "${CANDIDATE_SHA}" --out manager-tools
- name: Run exact manager regressions independently
run: |
set -euo pipefail
python3 -B auto-testing/scripts/validation_heal/manager_native.py run \
--directory manager-tools --out manager-evidence
- name: Upload manager executable identities and original diagnostics
if: ${{ always() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: rustfs-validation-manager-native-${{ github.run_id }}-${{ github.run_attempt }}
path: |
manager-tools/heal-manager-native-tests
manager-tools/*.json
manager-tools/*.jsonl
manager-tools/*.stderr
manager-tools/*.log
manager-evidence/
if-no-files-found: warn
native-heal-regressions:
if: ${{ inputs.build_native_heal }}
runs-on: ubuntu-22.04
timeout-minutes: 180
env:
CANDIDATE_SHA: ${{ inputs.candidate_sha }}
AUTO_TESTING_SHA: ${{ inputs.auto_testing_sha }}
steps:
- name: Validate immutable native source inputs
run: |
set -euo pipefail
[[ "${CANDIDATE_SHA}" =~ ^[0-9a-f]{40}$ ]]
[[ "${AUTO_TESTING_SHA}" =~ ^[0-9a-f]{40}$ ]]
- name: Checkout exact native candidate source
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
ref: ${{ inputs.candidate_sha }}
path: candidate
persist-credentials: false
- name: Checkout exact native collector source
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
repository: rustfs/auto-testing
ref: ${{ inputs.auto_testing_sha }}
token: ${{ secrets.PF_TESTING_GH_TOKEN }}
path: auto-testing
persist-credentials: false
- name: Install native build dependencies
run: |
set -euo pipefail
sudo apt-get update
sudo apt-get install -y clang lld cmake pkg-config libssl-dev protobuf-compiler
df -h .
- name: Build and enumerate the unmodified candidate test executable
run: |
set -euo pipefail
test "$(git -C auto-testing rev-parse HEAD)" = "${AUTO_TESTING_SHA}"
use_race_native=false
if [[ -f auto-testing/scripts/validation_heal/race_native.py ]]; then
support_rc=0
python3 -B auto-testing/scripts/validation_heal/race_native.py supports \
--commit "${CANDIDATE_SHA}" || support_rc=$?
case "${support_rc}" in
0) use_race_native=true ;;
2) ;; # The original exact-source collector handles legacy candidates.
*) exit "${support_rc}" ;;
esac
fi
if [[ "${use_race_native}" == true ]]; then
python3 -B auto-testing/scripts/validation_heal/race_native.py build \
--source candidate --commit "${CANDIDATE_SHA}" --testing-commit "${AUTO_TESTING_SHA}" --out native-tools
else
python3 -B auto-testing/scripts/validation_heal/native_regressions.py build \
--source candidate --commit "${CANDIDATE_SHA}" --out native-tools
fi
- name: Run exact native regressions separately from fleet validation
run: |
set -euo pipefail
if [[ -f native-tools/race-native.json ]]; then
test ! -e native-tools/native-regressions.json
python3 -B auto-testing/scripts/validation_heal/race_native.py run \
--directory native-tools --out native-evidence
else
python3 -B auto-testing/scripts/validation_heal/native_regressions.py run \
--directory native-tools --out native-evidence
fi
- name: Run existing F07 directory fsync failure regressions
if: ${{ inputs.candidate_sha == '1aa9be87d18cf941e5020f19e8622c26ba4b10ac' }}
run: |
set -euo pipefail
# Older immutable harness pins retain their original native suite;
# absence of this additive collector must never fabricate its proof.
if [[ ! -f auto-testing/scripts/validation_heal/persistence_native.py ]]; then
mkdir native-persistence-evidence
echo '{"complete":false,"reason":"pinned harness predates F07 directory-fsync collector","requiredNativeCoverageComplete":false,"canonicalCasePassClaimed":false}' \
> native-persistence-evidence/not-available.json
exit 0
fi
python3 -B auto-testing/scripts/validation_heal/persistence_native.py run \
--directory native-tools --native-evidence native-evidence --out native-persistence-evidence \
--testing-commit "${AUTO_TESTING_SHA}"
python3 -B auto-testing/scripts/validation_heal/persistence_native.py verify \
--directory native-tools --native-evidence native-evidence --out native-persistence-evidence \
--testing-commit "${AUTO_TESTING_SHA}"
- name: Upload native executable identities and original diagnostics
if: ${{ always() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: rustfs-validation-native-${{ github.run_id }}-${{ github.run_attempt }}
path: |
native-tools/ecstore-native-tests
native-tools/ecstore-race-native-tests
native-tools/*.json
native-tools/*.jsonl
native-tools/*.stderr
native-tools/*.log
native-evidence/
native-persistence-evidence/
if-no-files-found: warn
heal-metadata-inspector:
runs-on: ubuntu-22.04
timeout-minutes: 30
env:
CANDIDATE_SHA: ${{ inputs.candidate_sha }}
AUTO_TESTING_SHA: ${{ inputs.auto_testing_sha }}
steps:
- name: Validate immutable source inputs
run: |
set -euo pipefail
[[ "${CANDIDATE_SHA}" =~ ^[0-9a-f]{40}$ ]]
[[ "${AUTO_TESTING_SHA}" =~ ^[0-9a-f]{40}$ ]]
- name: Checkout exact candidate source
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
ref: ${{ inputs.candidate_sha }}
path: candidate
persist-credentials: false
- name: Checkout exact inspector source
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
repository: rustfs/auto-testing
ref: ${{ inputs.auto_testing_sha }}
token: ${{ secrets.PF_TESTING_GH_TOKEN }}
path: auto-testing
persist-credentials: false
- name: Build the read-only candidate metadata decoder
run: |
set -euo pipefail
test "$(git -C candidate rev-parse HEAD)" = "${CANDIDATE_SHA}"
test "$(git -C auto-testing rev-parse HEAD)" = "${AUTO_TESTING_SHA}"
df -h .
python3 auto-testing/scripts/validation_heal/prepare_heal_meta_inspector.py \
--source candidate --commit "${CANDIDATE_SHA}" --out inspector-build
# Seed the helper's separate graph from the candidate's frozen graph.
# Cargo adds the helper package; both lock identities are retained.
cp candidate/Cargo.lock inspector-build/Cargo.lock
cargo build --release --manifest-path inspector-build/Cargo.toml
python3 auto-testing/scripts/validation_heal/check_bitrot_tool.py \
--binary inspector-build/target/release/heal-meta-inspect --source candidate
mkdir validation-tools
cp inspector-build/target/release/heal-meta-inspect validation-tools/
cp inspector-build/Cargo.lock validation-tools/inspector-Cargo.lock
python3 - <<'PY'
import hashlib
import json
import os
from pathlib import Path
import subprocess
source = json.loads(Path("inspector-build/source.json").read_text())
binary = Path("validation-tools/heal-meta-inspect")
source.update(built=True, testingCommit=os.environ["AUTO_TESTING_SHA"],
binarySha256=hashlib.sha256(binary.read_bytes()).hexdigest(),
inspectorLockSha256=hashlib.sha256(Path("validation-tools/inspector-Cargo.lock").read_bytes()).hexdigest(),
toolchain=subprocess.check_output(["rustc", "-vV"], text=True),
target="x86_64-unknown-linux-gnu", runId=os.environ["GITHUB_RUN_ID"],
runAttempt=os.environ["GITHUB_RUN_ATTEMPT"])
Path("validation-tools/heal-meta-inspect.json").write_text(json.dumps(source, indent=2) + "\n")
PY
git -C candidate diff --exit-code
- name: Build and test the controlled C10 metadata encoder
if: ${{ inputs.build_shared_heal }}
run: |
set -euo pipefail
python3 -B auto-testing/scripts/validation_heal/prepare_heal_shared_fixture.py \
--source candidate --commit "${CANDIDATE_SHA}" --out shared-build
cp candidate/Cargo.lock shared-build/Cargo.lock
export CARGO_TARGET_DIR="${GITHUB_WORKSPACE}/inspector-build/target"
cargo build --release --manifest-path shared-build/Cargo.toml
mkdir shared-tools
if ! cargo test --release --manifest-path shared-build/Cargo.toml -- --test-threads=1 \
> shared-tools/shared-fixture-tests.log 2>&1; then
cat shared-tools/shared-fixture-tests.log
exit 1
fi
cp "${CARGO_TARGET_DIR}/release/heal-shared-fixture" shared-tools/
cp shared-build/Cargo.lock shared-tools/shared-Cargo.lock
python3 - <<'PY'
import hashlib
import json
import os
from pathlib import Path
import subprocess
source = json.loads(Path("shared-build/source.json").read_text())
source.update(schemaVersion=1, built=True, sourceUnmodified=True,
candidateSourceTree=subprocess.check_output(["git", "-C", "candidate", "rev-parse", "HEAD^{tree}"], text=True).strip(),
testingCommit=os.environ["AUTO_TESTING_SHA"],
binarySha256=hashlib.sha256(Path("shared-tools/heal-shared-fixture").read_bytes()).hexdigest(),
sharedLockSha256=hashlib.sha256(Path("shared-tools/shared-Cargo.lock").read_bytes()).hexdigest(),
toolchain=subprocess.check_output(["rustc", "-vV"], text=True), target="x86_64-unknown-linux-gnu",
runId=os.environ["GITHUB_RUN_ID"], runAttempt=os.environ["GITHUB_RUN_ATTEMPT"])
Path("shared-tools/heal-shared-fixture.json").write_text(json.dumps(source, indent=2) + "\n")
PY
git -C candidate diff --exit-code
- name: Upload controlled C10 fixture encoder evidence
if: ${{ always() && inputs.build_shared_heal }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: rustfs-validation-shared-${{ github.run_id }}-${{ github.run_attempt }}
path: shared-tools/
if-no-files-found: warn
- name: Upload immutable validation tools
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: rustfs-validation-tools-${{ github.run_id }}-${{ github.run_attempt }}
path: validation-tools/
if-no-files-found: error
+1 -3
View File
@@ -158,9 +158,7 @@ requested adversarial/design reviews, and agent-instruction changes that alter
execution. Ordinary questions, diagnoses, status reports, non-adversarial code
reviews, and low-risk planning do not trigger it.
For applicable work and substantial PR reviews, read the [risk tiers and review shape](.agents/references/adversarial-validation.md).
Load only the matching domain probes; ordinary reviews do not become adversarial
merely because this reference exists.
For applicable work and substantial PR reviews, follow the [risk tiers and review shape](.agents/references/adversarial-validation.md) and check changed trust boundaries and relevant correctness, security, concurrency, durability, compatibility, performance, and negative regression coverage.
A review has no finding quota; `No findings` is a complete outcome. A request to
find problems is not evidence that a defect exists. Before reporting a candidate,
+1 -1
View File
@@ -26,7 +26,7 @@ rustfs-io-core = { workspace = true }
serde = { workspace = true, features = ["derive"] }
# Async runtime
tokio = { workspace = true, features = ["sync", "fs", "rt-multi-thread"] }
tokio = { workspace = true, features = ["sync", "fs", "rt-multi-thread", "time"] }
# Logging
tracing = { workspace = true }
+9
View File
@@ -371,6 +371,15 @@ pub mod compression {
pub mod config {
pub mod com {
/// Preserve empty existing objects so callers can distinguish corruption from absence.
pub async fn read_config_preserve_empty_with_metadata_opts(
api: std::sync::Arc<crate::store::ECStore>,
file: &str,
opts: &crate::object_api::ObjectOptions,
) -> crate::error::Result<(Vec<u8>, crate::object_api::ObjectInfo)> {
crate::config::com::read_config_preserve_empty_with_metadata_opts(api, file, opts).await
}
pub use crate::config::com::{
COMMA_SEPARATED_LISTS, CONFIG_PREFIX, ENV_CONFIG_RECOVER_ON_CORRUPTION, STORAGE_CLASS_SUB_SYS,
ServerConfigCorruptError, ServerConfigSaveResult, ServerConfigSnapshot, delete_config, delete_config_no_lock,
@@ -1086,8 +1086,9 @@ impl ExpiryState {
}))
}
#[cfg(test)]
fn new_with_unconsumed_worker_channel(capacity: usize) -> Arc<RwLock<Self>> {
#[cfg(any(test, feature = "test-util"))]
#[doc(hidden)]
pub fn new_with_unconsumed_worker_channel(capacity: usize) -> Arc<RwLock<Self>> {
let (tx, rx) = mpsc::channel(capacity);
Arc::new(RwLock::new(Self {
tasks_tx: vec![tx],
@@ -3516,13 +3517,21 @@ async fn stale_upload_current_size_with_opts(
.await
.ok()?;
Some(
parts
.parts
.iter()
.map(|part| part.actual_size.max(part.size as i64).max(0) as usize)
.sum(),
)
stale_upload_size_from_parts(&parts)
}
fn stale_upload_size_from_parts(parts: &crate::storage_api_contracts::multipart::ListPartsInfo) -> Option<usize> {
if parts.is_truncated {
return None;
}
parts.parts.iter().try_fold(0usize, |total, part| {
// ListParts represents a quorum-confirmed missing part with an empty ETag.
if part.etag.as_deref().is_none_or(str::is_empty) {
return None;
}
let actual_size = usize::try_from(part.actual_size.max(0)).ok()?;
total.checked_add(part.size.max(actual_size))
})
}
async fn stale_upload_lifecycle_due(
@@ -3539,18 +3548,43 @@ async fn stale_upload_lifecycle_due(
let bucket = metadata.get(RUSTFS_MULTIPART_BUCKET_KEY)?;
let object = metadata.get(RUSTFS_MULTIPART_OBJECT_KEY)?;
let lifecycle = match metadata_boundary::get_lifecycle_config(bucket).await {
let mut lifecycle = match metadata_boundary::get_lifecycle_config(bucket).await {
Ok((lifecycle, _)) => lifecycle,
Err(_) => return None,
};
let has_size_filter = |filter: &s3s::dto::LifecycleRuleFilter| {
filter.object_size_greater_than.is_some()
|| filter.object_size_less_than.is_some()
|| filter
.and
.as_ref()
.is_some_and(|and| and.object_size_greater_than.is_some() || and.object_size_less_than.is_some())
};
let needs_size = lifecycle
.rules
.iter()
.any(|rule| rule.abort_incomplete_multipart_upload.is_some() && rule.filter.as_ref().is_some_and(has_size_filter));
let size = if needs_size {
match stale_upload_current_size_with_opts(set, metadata, upload_dir, no_lock).await {
Some(size) => size,
None => {
// An unknown size cannot authorize a size-filtered abort.
// Unrelated rules can still be evaluated independently.
lifecycle
.rules
.retain(|rule| !rule.filter.as_ref().is_some_and(has_size_filter));
0
}
}
} else {
0
};
let object_opts = ObjectOpts {
name: object.clone(),
user_tags: metadata.get(AMZ_OBJECT_TAGGING).cloned().unwrap_or_default(),
mod_time: Some(initiated),
size: stale_upload_current_size_with_opts(set, metadata, upload_dir, no_lock)
.await
.unwrap_or_default(),
size,
is_latest: true,
delete_marker: false,
user_defined: metadata.clone(),
@@ -3803,7 +3837,8 @@ async fn cleanup_stale_multipart_uploads_in_set(set: &Arc<SetDisks>, now: Offset
for candidate in candidates.into_values() {
let upload_dir = candidate.path.rsplit('/').next().unwrap_or_default().to_string();
let mut due = stale_upload_default_due(candidate.initiated, default_expiry);
if let Some(metadata) = candidate.metadata.as_ref()
if now < due
&& let Some(metadata) = candidate.metadata.as_ref()
&& let Some(lifecycle_due) = stale_upload_lifecycle_due(set, metadata, candidate.initiated, &upload_dir, false).await
&& lifecycle_due < due
{
@@ -3832,8 +3867,9 @@ async fn cleanup_stale_multipart_uploads_in_set(set: &Arc<SetDisks>, now: Offset
let current_metadata = cleanup_guard.file_info().metadata.clone();
let current_initiated = initiated_from_upload_dir(&upload_dir, cleanup_guard.file_info().mod_time);
let mut current_due = stale_upload_default_due(current_initiated, default_expiry);
if let Some(lifecycle_due) =
stale_upload_lifecycle_due(set, &current_metadata, current_initiated, &upload_dir, true).await
if now < current_due
&& let Some(lifecycle_due) =
stale_upload_lifecycle_due(set, &current_metadata, current_initiated, &upload_dir, true).await
&& lifecycle_due < current_due
{
current_due = lifecycle_due;
@@ -15868,6 +15904,261 @@ mod tests {
assert!(ecstore.is_ok(), "fresh local ECStore boot should succeed, got {ecstore:?}");
}
#[tokio::test(flavor = "current_thread")]
#[serial]
async fn stale_multipart_cleanup_preserves_partial_metadata_without_upload_part_warning() {
use tracing::instrument::WithSubscriber as _;
#[derive(Clone, Default)]
struct CapturedLogs(Arc<StdMutex<Vec<u8>>>);
struct LogWriter(Arc<StdMutex<Vec<u8>>>);
impl std::io::Write for LogWriter {
fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
self.0.lock().unwrap().extend_from_slice(bytes);
Ok(bytes.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for CapturedLogs {
type Writer = LogWriter;
fn make_writer(&'a self) -> Self::Writer {
LogWriter(self.0.clone())
}
}
let (paths, ecstore) = setup_test_env().await;
let bucket = format!("stale-partial-{}", Uuid::new_v4().simple());
create_test_bucket(&ecstore, &bucket).await;
for copies in [1, 2] {
let object = format!("partial-{copies}.bin");
let initiated = OffsetDateTime::now_utc() - time::Duration::hours(30);
let upload = ecstore
.new_multipart_upload(
&bucket,
&object,
&ObjectOptions {
mod_time: Some(initiated),
max_parity: true,
..Default::default()
},
)
.await
.expect("multipart upload should be created");
let decoded = base64_simd::URL_SAFE_NO_PAD
.decode_to_vec(upload.upload_id.as_bytes())
.unwrap();
let decoded = String::from_utf8(decoded).unwrap();
let (_, upload_dir) = decoded.split_once('.').expect("deployment-prefixed upload id");
let upload_path = format!("{}/{upload_dir}", multipart_sha_dir(&bucket, &object));
let physical_paths: Vec<_> = paths
.iter()
.map(|path| path.join(RUSTFS_META_MULTIPART_BUCKET).join(&upload_path))
.collect();
let original: Vec<_> = physical_paths
.iter()
.take(copies)
.map(|path| std::fs::read(path.join(STORAGE_FORMAT_FILE)).unwrap())
.collect();
let info = super::get_file_info(
&original[0],
RUSTFS_META_MULTIPART_BUCKET,
&upload_path,
"",
super::FileInfoOpts {
data: false,
include_free_versions: false,
include_part_checksums: false,
},
)
.unwrap();
assert_eq!((info.erasure.data_blocks, info.erasure.parity_blocks), (2, 2));
for path in physical_paths.iter().skip(copies) {
fs::remove_dir_all(path).await.expect("remove selected metadata copies");
}
if copies == 2 {
ecstore
.get_multipart_info(&bucket, &object, &upload.upload_id, &ObjectOptions::default())
.await
.expect("two metadata copies must meet read quorum");
}
let logs = CapturedLogs::default();
let subscriber = tracing_subscriber::fmt()
.with_max_level(tracing::Level::DEBUG)
.with_writer(logs.clone())
.with_ansi(false)
.without_time()
.finish();
cleanup_stale_multipart_uploads_once_at(
ecstore.clone(),
OffsetDateTime::now_utc(),
StdDuration::from_secs(24 * 60 * 60),
)
.with_subscriber(subscriber)
.await;
for (path, expected) in physical_paths.iter().zip(&original) {
assert_eq!(fs::read(path.join(STORAGE_FORMAT_FILE)).await.unwrap(), *expected);
}
let log_output = String::from_utf8(logs.0.lock().unwrap().clone()).unwrap();
assert!(
log_output.contains(&upload_path),
"missing background diagnostic for {upload_path}: {log_output}"
);
assert!(log_output.contains("multipart_cleanup_lock_or_recheck_failed"), "{log_output}");
assert!(!log_output.contains("op=\"upload_part\""), "unexpected upload-part warning: {log_output}");
for path in physical_paths.iter().take(copies) {
fs::remove_dir_all(path).await.unwrap();
}
}
}
#[tokio::test(flavor = "current_thread")]
#[serial]
async fn stale_multipart_cleanup_lists_parts_only_when_size_is_needed() {
use std::sync::atomic::{AtomicUsize, Ordering};
use tracing::instrument::WithSubscriber as _;
use tracing_subscriber::prelude::*;
struct PartListCounter(Arc<AtomicUsize>);
impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for PartListCounter {
fn on_new_span(
&self,
attrs: &tracing::span::Attributes<'_>,
_id: &tracing::span::Id,
_ctx: tracing_subscriber::layer::Context<'_, S>,
) {
if attrs.metadata().name() == "list_object_parts" {
self.0.fetch_add(1, Ordering::Relaxed);
}
}
}
let (_, ecstore) = setup_test_env().await;
let bucket = format!("stale-list-parts-{}", Uuid::new_v4().simple());
create_test_bucket(&ecstore, &bucket).await;
set_abort_incomplete_lifecycle(&bucket, "logs/", 0).await;
let now = OffsetDateTime::now_utc();
for (object, age, size_filter, should_list) in [
("logs/default-expired", time::Duration::hours(30), false, false),
("logs/lifecycle-only", time::Duration::minutes(5), false, false),
("logs/size-filter", time::Duration::minutes(5), true, true),
] {
if size_filter {
set_abort_incomplete_lifecycle_with_size(&bucket, "logs/", 0, 0).await;
}
let upload = ecstore
.new_multipart_upload(
&bucket,
object,
&ObjectOptions {
mod_time: Some(now - age),
..Default::default()
},
)
.await
.expect("create multipart upload");
if size_filter {
let mut data = PutObjReader::from_vec(vec![1, 2, 3, 4]);
ecstore
.put_object_part(&bucket, object, &upload.upload_id, 1, &mut data, &ObjectOptions::default())
.await
.expect("upload a part for size filtering");
}
let calls = Arc::new(AtomicUsize::new(0));
let subscriber = tracing_subscriber::registry().with(PartListCounter(Arc::clone(&calls)));
cleanup_stale_multipart_uploads_once_at(ecstore.clone(), now, StdDuration::from_secs(24 * 60 * 60))
.with_subscriber(subscriber)
.await;
let listed = calls.load(Ordering::Relaxed);
assert_eq!(listed > 0, should_list, "unexpected ListParts calls for {object}: {listed}");
let result = ecstore
.get_multipart_info(&bucket, object, &upload.upload_id, &ObjectOptions::default())
.await;
assert!(
matches!(result, Err(ref err) if is_err_invalid_upload_id(err)),
"upload must be removed: {result:?}"
);
}
}
#[test]
fn stale_multipart_cleanup_rejects_incomplete_or_overflowing_part_sizes() {
use crate::storage_api_contracts::multipart::{ListPartsInfo, PartInfo};
let part = |size, actual_size, etag: &str| PartInfo {
size,
actual_size,
etag: Some(etag.to_owned()),
..Default::default()
};
let mut parts = ListPartsInfo {
parts: vec![part(4, 6, "valid")],
..Default::default()
};
assert_eq!(super::stale_upload_size_from_parts(&parts), Some(6));
parts.is_truncated = true;
assert_eq!(super::stale_upload_size_from_parts(&parts), None);
parts.is_truncated = false;
parts.parts = vec![part(0, 0, "")];
assert_eq!(super::stale_upload_size_from_parts(&parts), None);
parts.parts = vec![part(usize::MAX, 0, "valid"), part(1, 1, "valid")];
assert_eq!(super::stale_upload_size_from_parts(&parts), None);
}
#[tokio::test]
#[serial]
async fn stale_multipart_cleanup_handles_unknown_size_without_false_abort() {
let (_, ecstore) = setup_test_env().await;
let bucket = format!("stale-unknown-size-{}", Uuid::new_v4().simple());
let object = "logs/object";
create_test_bucket(&ecstore, &bucket).await;
let size_rule = r#"<Rule><ID>size</ID><Status>Enabled</Status><Filter><ObjectSizeLessThan>5</ObjectSizeLessThan></Filter><AbortIncompleteMultipartUpload><DaysAfterInitiation>0</DaysAfterInitiation></AbortIncompleteMultipartUpload></Rule>"#;
let independent_rule = r#"<Rule><ID>prefix</ID><Status>Enabled</Status><Filter><Prefix>logs/</Prefix></Filter><AbortIncompleteMultipartUpload><DaysAfterInitiation>0</DaysAfterInitiation></AbortIncompleteMultipartUpload></Rule>"#;
let lifecycle = |rules: &str| format!("<LifecycleConfiguration>{rules}</LifecycleConfiguration>").into_bytes();
metadata_sys::update(&bucket, BUCKET_LIFECYCLE_CONFIG, lifecycle(size_rule))
.await
.expect("install upper-size lifecycle rule");
let metadata = HashMap::from([
(super::RUSTFS_MULTIPART_BUCKET_KEY.to_owned(), bucket.clone()),
(super::RUSTFS_MULTIPART_OBJECT_KEY.to_owned(), object.to_owned()),
]);
let initiated = OffsetDateTime::now_utc() - time::Duration::hours(1);
let set = &ecstore.pools[0].disk_set[0];
let upload = ecstore
.new_multipart_upload(&bucket, object, &ObjectOptions::default())
.await
.expect("create empty multipart upload");
let decoded = base64_simd::URL_SAFE_NO_PAD
.decode_to_vec(upload.upload_id.as_bytes())
.expect("decode upload ID");
let decoded = String::from_utf8(decoded).expect("upload ID must be UTF-8");
let (_, upload_dir) = decoded.split_once('.').expect("deployment-prefixed upload ID");
assert!(
super::stale_upload_lifecycle_due(set, &metadata, initiated, upload_dir, false)
.await
.is_some(),
"a known empty upload must match the upper-size rule"
);
let missing_upload = format!("{}x{}", Uuid::new_v4(), initiated.unix_timestamp_nanos());
assert!(
super::stale_upload_lifecycle_due(set, &metadata, initiated, &missing_upload, false)
.await
.is_none(),
"an unreadable size must not match the upper-size rule"
);
metadata_sys::update(&bucket, BUCKET_LIFECYCLE_CONFIG, lifecycle(&format!("{size_rule}{independent_rule}")))
.await
.expect("install size-independent lifecycle rule");
assert!(
super::stale_upload_lifecycle_due(set, &metadata, initiated, &missing_upload, false)
.await
.is_some(),
"a size-independent rule remains eligible when size is unknown"
);
}
#[tokio::test]
#[serial]
async fn stale_multipart_cleanup_uses_default_expiry_without_lifecycle() {
+12 -4
View File
@@ -3186,9 +3186,17 @@ pub mod test_support {
use crate::store::init_local_disks_with_instance_ctx;
pub async fn isolated_store_over_temp_disks() -> (Vec<tempfile::TempDir>, Arc<ECStore>) {
let mut dirs = Vec::with_capacity(4);
let mut endpoints = Vec::with_capacity(4);
for disk_idx in 0..4 {
isolated_store_over_temp_disks_with_count(4).await
}
pub async fn isolated_store_over_temp_disks_with_count(disk_count: usize) -> (Vec<tempfile::TempDir>, Arc<ECStore>) {
assert!(
matches!(disk_count, 4 | 16),
"isolated store fixture requires a registered logical set geometry"
);
let mut dirs = Vec::with_capacity(disk_count);
let mut endpoints = Vec::with_capacity(disk_count);
for disk_idx in 0..disk_count {
let dir = tempfile::tempdir().expect("tempdir should be created");
let mut endpoint =
Endpoint::try_from(dir.path().to_str().expect("tempdir path should be utf8")).expect("endpoint should parse");
@@ -3201,7 +3209,7 @@ pub mod test_support {
let endpoint_pools = EndpointServerPools(vec![PoolEndpoints {
legacy: false,
set_count: 1,
drives_per_set: 4,
drives_per_set: disk_count,
endpoints: Endpoints::from(endpoints),
cmd_line: "metadata-sys-cache-test".to_string(),
platform: "test".to_string(),
+12
View File
@@ -488,6 +488,18 @@ where
Ok(data)
}
/// Read config bytes and metadata while preserving an existing empty payload.
pub(crate) async fn read_config_preserve_empty_with_metadata_opts<S>(
api: Arc<S>,
file: &str,
opts: &ObjectOptions,
) -> Result<(Vec<u8>, ObjectInfo)>
where
S: EcstoreObjectIO,
{
read_config_with_metadata_inner(api, file, opts, true, None).await
}
pub async fn read_config_no_lock<S>(api: Arc<S>, file: &str) -> Result<Vec<u8>>
where
S: ObjectIO<
+168 -11
View File
@@ -8450,6 +8450,23 @@ impl LocalDisk {
return Err(std::io::Error::other("metadata read worker cancelled"));
}
let _io_permit = io_permit;
#[cfg(all(any(test, feature = "test-util"), not(windows)))]
{
let opened = root.open_readonly(&path);
if opened.is_ok() {
os::prepared_publication_test_hooks::run(
os::prepared_publication_test_hooks::Stage::MetadataReadAfterOpen,
&path,
);
}
let result = read_fn(opened);
os::prepared_publication_test_hooks::run(
os::prepared_publication_test_hooks::Stage::MetadataReadAfterRead,
&path,
);
Ok(result)
}
#[cfg(not(all(any(test, feature = "test-util"), not(windows))))]
Ok(read_fn(root.open_readonly(&path)))
})
.await
@@ -11309,34 +11326,57 @@ impl LocalDisk {
#[cfg(unix)]
pub(crate) async fn batch_shard_pread(requests: Vec<(std::path::PathBuf, usize, usize)>) -> Vec<Result<Bytes>> {
let n = requests.len();
let metrics = rustfs_io_metrics::LocalBatchReadMetrics::capture();
let submitted = metrics.as_ref().map(|_| std::time::Instant::now());
tokio::task::spawn_blocking(move || {
use rustfs_io_metrics::LocalBatchReadStage;
use std::os::unix::fs::FileExt;
let execution_started = metrics.as_ref().map(|_| std::time::Instant::now());
if let (Some(metrics), Some(submitted)) = (&metrics, submitted) {
metrics.record_stage(LocalBatchReadStage::QueueWait, true, submitted.elapsed());
metrics.record_batch(n);
}
let mut results = Vec::with_capacity(n);
for (file_path, offset, length) in requests {
let mut bytes_read = 0;
let r = (|| -> Result<Bytes> {
let meta = std::fs::metadata(&file_path).map_err(DiskError::from)?;
let meta = measure_local_batch_stage(metrics.as_deref(), LocalBatchReadStage::Metadata, || {
std::fs::metadata(&file_path).map_err(DiskError::from)
})?;
let end = offset.checked_add(length).ok_or(DiskError::FileCorrupt)?;
if meta.len() < u64::try_from(end).unwrap_or(u64::MAX) {
return Err(DiskError::FileCorrupt);
}
let file = std::fs::File::open(&file_path).map_err(DiskError::from)?;
let file = measure_local_batch_stage(metrics.as_deref(), LocalBatchReadStage::Open, || {
std::fs::File::open(&file_path).map_err(DiskError::from)
})?;
let mut buf = vec![0u8; length];
let mut total = 0usize;
while total < length {
let nbytes = file
.read_at(&mut buf[total..], u64::try_from(offset + total).unwrap_or(u64::MAX))
.map_err(DiskError::from)?;
if nbytes == 0 {
return Err(DiskError::FileCorrupt);
}
total += nbytes;
if length > 0 {
measure_local_batch_stage(metrics.as_deref(), LocalBatchReadStage::Read, || {
while bytes_read < length {
let nbytes = file
.read_at(&mut buf[bytes_read..], u64::try_from(offset + bytes_read).unwrap_or(u64::MAX))
.map_err(DiskError::from)?;
if nbytes == 0 {
return Err(DiskError::FileCorrupt);
}
bytes_read += nbytes;
}
Ok(())
})?;
}
Ok(Bytes::from(buf))
})();
if let Some(metrics) = &metrics {
metrics.record_shard(r.is_ok(), length, bytes_read);
}
results.push(r);
}
if let (Some(metrics), Some(started)) = (&metrics, execution_started) {
metrics.record_stage(LocalBatchReadStage::Execution, results.iter().all(Result::is_ok), started.elapsed());
}
results
})
.await
@@ -11346,6 +11386,20 @@ pub(crate) async fn batch_shard_pread(requests: Vec<(std::path::PathBuf, usize,
})
}
#[cfg(unix)]
fn measure_local_batch_stage<T>(
metrics: Option<&rustfs_io_metrics::LocalBatchReadMetrics>,
stage: rustfs_io_metrics::LocalBatchReadStage,
operation: impl FnOnce() -> Result<T>,
) -> Result<T> {
let started = metrics.map(|_| std::time::Instant::now());
let result = operation();
if let (Some(metrics), Some(started)) = (metrics, started) {
metrics.record_stage(stage, result.is_ok(), started.elapsed());
}
result
}
#[async_trait::async_trait]
impl DiskAPI for LocalDisk {
async fn supports_guarded_delete_versions(&self) -> Result<()> {
@@ -30957,6 +31011,109 @@ mod test {
);
}
#[cfg(unix)]
#[test]
fn local_batch_read_metrics_cover_success_and_unattempted_failure_stages() {
use crate::test_metrics::CapturingRecorder;
let dir = tempfile::tempdir().expect("batch metrics directory");
let good = dir.path().join("good.bin");
std::fs::write(&good, b"good data").expect("write batch metrics payload");
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("batch metrics runtime");
let recorder = CapturingRecorder::default();
let previous = rustfs_io_metrics::get_stage_metrics_enabled();
rustfs_io_metrics::set_get_stage_metrics_enabled(true);
let results = metrics::with_local_recorder(&recorder, || {
runtime.block_on(batch_shard_pread(vec![
(good.clone(), 0, 9),
(dir.path().join("missing.bin"), 0, 4),
(good.clone(), 0, 1024),
(good, 0, 0),
]))
});
rustfs_io_metrics::set_get_stage_metrics_enabled(previous);
assert_eq!(results[0].as_ref().expect("valid shard read").as_ref(), b"good data");
assert!(results[1].is_err(), "missing shard must remain an error");
assert!(matches!(results[2], Err(DiskError::FileCorrupt)), "range failure must preserve its error");
assert!(results[3].as_ref().expect("empty range succeeds").is_empty());
let counter = "rustfs_io_get_object_local_batch_shards_total";
assert_eq!(recorder.counter_value(counter, &[("outcome", "success")]), 2);
assert_eq!(recorder.counter_value(counter, &[("outcome", "error")]), 2);
assert_eq!(recorder.counter_value("rustfs_io_get_object_local_batch_read_bytes_total", &[]), 9);
let stages = "rustfs_io_get_object_local_batch_stage_seconds";
for (stage, success, errors) in [("metadata", 3, 1), ("open", 2, 0), ("read", 1, 0), ("execution", 0, 1)] {
assert_eq!(
recorder
.histogram_values(stages, &[("stage", stage), ("outcome", "success")])
.len(),
success
);
assert_eq!(
recorder
.histogram_values(stages, &[("stage", stage), ("outcome", "error")])
.len(),
errors
);
}
assert_eq!(recorder.histogram_values(stages, &[("stage", "queue_wait")]).len(), 1);
}
#[cfg(unix)]
#[test]
fn local_batch_read_metrics_survive_caller_cancellation() {
use crate::test_metrics::CapturingRecorder;
let dir = tempfile::tempdir().expect("cancelled batch directory");
let path = dir.path().join("shard");
std::fs::write(&path, b"data").expect("write cancellation fixture");
let runtime = tokio::runtime::Builder::new_current_thread()
.max_blocking_threads(1)
.enable_all()
.build()
.expect("single blocking-worker runtime");
let recorder = CapturingRecorder::default();
let previous = rustfs_io_metrics::get_stage_metrics_enabled();
rustfs_io_metrics::set_get_stage_metrics_enabled(true);
metrics::with_local_recorder(&recorder, || {
runtime.block_on(async {
let (started_tx, started_rx) = tokio::sync::oneshot::channel();
let (release_tx, release_rx) = std::sync::mpsc::channel();
let blocker = tokio::task::spawn_blocking(move || {
started_tx.send(()).expect("signal occupied blocking worker");
release_rx.recv().expect("release occupied blocking worker");
});
started_rx.await.expect("blocking worker has started");
let mut batch = Box::pin(batch_shard_pread(vec![(path, 0, 4)]));
let pending = futures::FutureExt::now_or_never(&mut batch).is_none();
drop(batch);
rustfs_io_metrics::set_get_stage_metrics_enabled(false);
release_tx.send(()).expect("release worker after dropping caller");
blocker.await.expect("blocking worker finishes");
assert!(pending, "batch must have been submitted while the worker was occupied");
tokio::time::timeout(Duration::from_secs(5), async {
while recorder
.histogram_values("rustfs_io_get_object_local_batch_stage_seconds", &[("stage", "execution")])
.is_empty()
{
tokio::task::yield_now().await;
}
})
.await
.expect("detached batch must still record completion");
});
});
rustfs_io_metrics::set_get_stage_metrics_enabled(previous);
assert_eq!(
recorder.counter_value("rustfs_io_get_object_local_batch_shards_total", &[("outcome", "success")]),
1
);
assert_eq!(recorder.counter_value("rustfs_io_get_object_local_batch_read_bytes_total", &[]), 4);
}
#[cfg(unix)]
#[tokio::test]
async fn test_batch_shard_pread_basic() {
+6
View File
@@ -333,6 +333,8 @@ pub(crate) mod prepared_publication_test_hooks {
PreparedRename,
Rename,
Remove,
MetadataReadAfterOpen,
MetadataReadAfterRead,
#[cfg(test)]
Rollback,
#[cfg(test)]
@@ -457,6 +459,8 @@ pub enum LocalPublicationStage {
PreparedRename,
Rename,
Remove,
MetadataReadAfterOpen,
MetadataReadAfterRead,
}
#[cfg(all(feature = "test-util", not(windows)))]
@@ -469,6 +473,8 @@ impl LocalPublicationPause {
LocalPublicationStage::PreparedRename => prepared_publication_test_hooks::Stage::PreparedRename,
LocalPublicationStage::Rename => prepared_publication_test_hooks::Stage::Rename,
LocalPublicationStage::Remove => prepared_publication_test_hooks::Stage::Remove,
LocalPublicationStage::MetadataReadAfterOpen => prepared_publication_test_hooks::Stage::MetadataReadAfterOpen,
LocalPublicationStage::MetadataReadAfterRead => prepared_publication_test_hooks::Stage::MetadataReadAfterRead,
};
let (entered_tx, entered) = oneshot::channel();
let (release, release_rx) = std::sync::mpsc::channel::<()>();
@@ -1782,6 +1782,285 @@ mod tests {
fixture.assert_moved().await;
}
/// Each drive's copy of `version_id` of `object` on `pool`.
#[cfg(feature = "test-util")]
async fn drive_copies(store: &ECStore, pool: usize, bucket: &str, object: &str, version_id: uuid::Uuid) -> Vec<FileInfo> {
use crate::disk::DiskAPI as _;
let set = store.pools[pool].get_disks_by_key(object);
let mut copies = Vec::new();
for disk in set.disks.read().await.iter().flatten() {
let path = disk.path().join(bucket).join(object).join(crate::disk::STORAGE_FORMAT_FILE);
let Ok(bytes) = tokio::fs::read(&path).await else {
continue;
};
let meta = FileMeta::load(&bytes).expect("drive metadata should decode");
if meta.find_version(Some(version_id)).is_ok() {
copies.push(
meta.into_fileinfo(bucket, object, &version_id.to_string(), false, false, false)
.expect("drive copy should decode"),
);
}
}
copies
}
#[cfg(feature = "test-util")]
fn drive_space(total: u64, used: u64) -> crate::disk::DiskInfo {
crate::disk::DiskInfo {
total,
used,
free: total - used,
free_inodes: 1 << 20,
..Default::default()
}
}
#[cfg(feature = "test-util")]
const DANGLING_COPY_REBALANCE_ID: &str = "rebalance-dangling-target-copy";
/// A delete marker over one object version on pool 0 of three four-drive
/// pools, plus what an earlier attempt that lost write quorum left on one
/// drive of pool 1: both version ids, with older modification times and
/// different bytes. Object reads report such copies as absent. Pool 2
/// reports far more free space than pool 1, so a free-space choice picks
/// pool 2 (the kill-resume e2e case ended with the marker on two pools).
#[cfg(feature = "test-util")]
struct DanglingTargetCopy {
_temp_dirs: Vec<tempfile::TempDir>,
store: Arc<ECStore>,
_space: crate::store::PoolSpaceForTest,
bucket: &'static str,
object: &'static str,
incarnation: uuid::Uuid,
source_set: Arc<SetDisks>,
marker: FileInfo,
version: FileInfo,
payload: Vec<u8>,
}
#[cfg(feature = "test-util")]
impl DanglingTargetCopy {
async fn new(holder_space: crate::disk::DiskInfo) -> Self {
let (temp_dirs, store, _peer) =
crate::services::rebalance::test_three_pool_stores_with_four_disk_sets_with_isolated_node_contexts(None).await;
let space = crate::store::PoolSpaceForTest::install(store.id, &[(1, holder_space), (2, drive_space(1 << 50, 0))]);
let bucket = "rebalance-dangling-target";
let object = "versioned/06.bin";
Box::pin(store.make_bucket(bucket, &MakeBucketOptions::default()))
.await
.expect("create bucket");
let incarnation = store.bucket_incarnation_id(bucket).await.expect("bucket incarnation");
let mut rebalance_meta = active_rebalance_meta(DANGLING_COPY_REBALANCE_ID);
rebalance_meta.pool_stats.push(RebalanceStats::default());
rebalance_meta
.save(Arc::clone(&store.pools[0]))
.await
.expect("persist the active rebalance run");
*store.rebalance_meta.write().await = Some(rebalance_meta);
let versioned = ObjectOptions {
versioned: true,
expected_bucket_incarnation_id: Some(incarnation),
..Default::default()
};
let payload = vec![0x5a; 2048];
let source_set = store.pools[0].get_disks_by_key(object);
Box::pin(source_set.put_object(bucket, object, &mut PutObjReader::from_vec(payload.clone()), &versioned))
.await
.expect("seed the object version");
let marker = Box::pin(source_set.delete_object(bucket, object, versioned.clone()))
.await
.expect("seed the delete marker");
assert!(marker.delete_marker);
let seeded = source_set
.load_file_info_versions_exact(bucket, object)
.await
.expect("read seeded versions")
.expect("seeded versions must exist");
let [marker, version] = seeded.versions.as_slice() else {
panic!("expected a delete marker over one version: {seeded:?}");
};
let (marker, version) = (marker.clone(), version.clone());
let marker_id = marker.version_id.expect("the marker has a version id");
let version_id = version.version_id.expect("the object version has a version id");
let earlier = |time: Option<OffsetDateTime>| time.map(|time| time - time::Duration::hours(1));
let holder = store.pools[1].get_disks_by_key(object);
let mut marker_opts =
super::super::migration::rebalance_delete_marker_opts(&marker, Some(marker_id.to_string()), 0, Some(incarnation));
marker_opts.mod_time = earlier(marker.mod_time);
Box::pin(holder.delete_object(bucket, object, marker_opts))
.await
.expect("publish the earlier marker copy");
Box::pin(holder.put_object(
bucket,
object,
&mut PutObjReader::from_vec(vec![0x11; 4096]),
&ObjectOptions {
version_id: Some(version_id.to_string()),
mod_time: earlier(version.mod_time),
..versioned
},
))
.await
.expect("publish the earlier object version copy");
{
use crate::disk::DiskAPI as _;
for disk in holder.disks.read().await.iter().flatten().skip(1) {
tokio::fs::remove_dir_all(disk.path().join(bucket).join(object))
.await
.expect("drop the copies from the drives the attempt missed");
}
}
for version_id in [marker_id, version_id] {
assert_eq!(drive_copies(&store, 1, bucket, object, version_id).await.len(), 1);
}
Self {
_temp_dirs: temp_dirs,
store,
_space: space,
bucket,
object,
incarnation,
source_set,
marker,
version,
payload,
}
}
async fn run_entry(&self) -> RebalanceEntryOutcome {
let entry = metacache_entry_from_source(&self.source_set, self.bucket, self.object).await;
Box::pin(Arc::clone(&self.store).rebalance_entry(
RebalanceEntryTarget {
bucket: self.bucket.to_string(),
pool_index: 0,
},
entry,
Arc::clone(&self.source_set),
Arc::new(RebalanceBucketConfigs {
bucket_incarnation_id: Some(self.incarnation),
..Default::default()
}),
Arc::from(DANGLING_COPY_REBALANCE_ID),
CancellationToken::new(),
))
.await
.expect("rebalance entry")
}
/// Pools whose drives hold `version_id`, with the number of drives.
async fn holders(&self, version_id: uuid::Uuid) -> Vec<(usize, usize)> {
let mut holders = Vec::new();
for pool in 0..self.store.pools.len() {
let drives = drive_copies(&self.store, pool, self.bucket, self.object, version_id)
.await
.len();
if drives > 0 {
holders.push((pool, drives));
}
}
holders
}
/// The source entry is gone and `pool` serves both versions as the
/// source wrote them, on every drive.
async fn assert_moved_to(&self, pool: usize) {
assert!(
self.source_set
.load_file_info_versions_exact(self.bucket, self.object)
.await
.expect("read source versions")
.is_none(),
"the source entry must be cleaned up"
);
let target = self.store.pools[pool]
.get_disks_by_key(self.object)
.load_file_info_versions_exact(self.bucket, self.object)
.await
.expect("read target versions")
.expect("both versions must reach the target pool");
let moved = |id| {
target
.versions
.iter()
.find(|version| version.version_id == id)
.unwrap_or_else(|| panic!("version {id:?} must reach pool {pool}: {target:?}"))
};
let marker = moved(self.marker.version_id);
assert!(marker.deleted, "the marker must stay a delete marker");
assert_eq!(marker.mod_time, self.marker.mod_time);
let version = moved(self.version.version_id);
assert!(!version.deleted);
assert_eq!(version.mod_time, self.version.mod_time);
let version_id = self.version.version_id.expect("object version id");
let copies = drive_copies(&self.store, pool, self.bucket, self.object, version_id).await;
assert_eq!(copies.len(), 4, "the object version must be on every drive of pool {pool}");
for copy in &copies {
assert_eq!(copy.mod_time, self.version.mod_time, "an earlier copy must be replaced, not kept");
assert_eq!(copy.data_dir, copies[0].data_dir, "every drive must hold the same data");
}
let marker_id = self.marker.version_id.expect("marker version id");
let markers = drive_copies(&self.store, pool, self.bucket, self.object, marker_id).await;
assert_eq!(markers.len(), 4, "the marker must be on every drive of pool {pool}");
assert!(markers.iter().all(|copy| copy.deleted));
let mut reader = self.store.pools[pool]
.get_object_reader(
self.bucket,
self.object,
None,
HeaderMap::new(),
&ObjectOptions {
version_id: Some(version_id.to_string()),
..Default::default()
},
)
.await
.expect("the moved version must be readable");
let mut actual = Vec::new();
reader.stream.read_to_end(&mut actual).await.expect("drain the moved version");
assert_eq!(actual, self.payload, "the moved version must carry the source bytes");
}
}
/// A retry reuses the pool that holds an earlier attempt's copy, even
/// though free space favours another pool, and rewrites that copy from
/// the source instead of leaving the version in two pools.
#[cfg(feature = "test-util")]
#[test]
#[serial_test::serial]
fn real_rebalance_retry_reuses_the_pool_holding_a_dangling_copy() {
run_large_stack_async_test("rebalance-dangling-target-copy", || async {
let fixture = DanglingTargetCopy::new(drive_space(64 << 20, 0)).await;
assert_eq!(fixture.run_entry().await, RebalanceEntryOutcome::Completed);
for version in [&fixture.marker, &fixture.version] {
let id = version.version_id.expect("version id");
assert_eq!(fixture.holders(id).await, vec![(1, 4)], "version {id} must be in one pool");
}
fixture.assert_moved_to(1).await;
});
}
/// A full pool holding an earlier copy must not pin the retry: placement
/// falls back to the free-space choice, and the stale copy stays for heal.
#[cfg(feature = "test-util")]
#[test]
#[serial_test::serial]
fn real_rebalance_retry_skips_a_full_pool_holding_a_dangling_copy() {
run_large_stack_async_test("rebalance-dangling-full-holder", || async {
let fixture = DanglingTargetCopy::new(drive_space(1 << 30, 1 << 30)).await;
assert_eq!(fixture.run_entry().await, RebalanceEntryOutcome::Completed);
for version in [&fixture.marker, &fixture.version] {
let id = version.version_id.expect("version id");
assert_eq!(fixture.holders(id).await, vec![(1, 1), (2, 4)], "version {id}");
}
fixture.assert_moved_to(2).await;
});
}
/// An `ExpiredObjectAllVersions` expiry is a prefix delete that takes the
/// bucket lifecycle write lock, while the entry holds that lock for read
/// through its bucket incarnation fence. Applied inline, it waited out the
@@ -603,6 +603,10 @@ pub(in crate::set_disk) async fn submit_read_repair_heal_with_submitter(
request.object_version_id = version_id.filter(|value| !value.is_empty()).map(str::to_string);
// A degraded read's surviving minority is not a recreate instruction; the
// locked storage read decides whether the target is absent (backlog#2900).
// Reader failures include checksum damage that metadata-only healing cannot detect.
if matches!(reason, "missing_shards" | "decode_error") {
request.scan_mode = Some(rustfs_heal_contracts::heal_channel::HealScanMode::Deep);
}
let request_id = request.id.clone();
let bucket = bucket.to_string();
@@ -1234,16 +1238,23 @@ async fn try_create_bitrot_readers_via_batch_pread(
skip_verify_bitrot: bool,
) -> Option<BitrotReaderSetup> {
use crate::disk::local::batch_shard_pread;
use rustfs_io_metrics::{LocalBatchReadDecision, record_local_batch_read_decision};
use std::io::Cursor;
let prepare_started = rustfs_io_metrics::get_stage_metrics_enabled().then(std::time::Instant::now);
let record_decision = |decision| {
record_local_batch_read_decision(decision, prepare_started.map(|started| started.elapsed()));
};
let (adj_off, adj_len) = adjust_shard_read_params(read_offset, read_length, shard_size, &checksum_algo);
if adj_len > object_mmap_read_max_length() {
record_decision(LocalBatchReadDecision::ReadCapExceeded);
return None;
}
let mut batch_items: Vec<(usize, std::path::PathBuf, usize, usize)> = Vec::new();
for (idx, disk_op) in disks.iter().enumerate() {
if files.get(idx).is_some_and(|fi| fi.data.is_some()) {
record_decision(LocalBatchReadDecision::InlineData);
return None;
}
if let Some(disk) = disk_op.as_ref() {
@@ -1251,12 +1262,20 @@ async fn try_create_bitrot_readers_via_batch_pread(
let path_str = format!("{object}/{data_dir}/part.{part_number}");
match disk.get_object_path_for_io_if_local(bucket, &path_str) {
Some(Ok(p)) => batch_items.push((idx, p, adj_off, adj_len)),
_ => return None,
Some(Err(_)) => {
record_decision(LocalBatchReadDecision::InvalidPath);
return None;
}
None => {
record_decision(LocalBatchReadDecision::NonLocalShard);
return None;
}
}
}
}
if batch_items.is_empty() {
record_decision(LocalBatchReadDecision::NoLocalShards);
return None;
}
@@ -1265,6 +1284,7 @@ async fn try_create_bitrot_readers_via_batch_pread(
for (disk_index, _, _, _) in &batch_items {
disk_call_counters::record(object, disk_call_counters::KIND_DISK_SHARD_OPEN, *disk_index);
}
record_decision(LocalBatchReadDecision::Selected);
let batch_results = batch_shard_pread(requests).await;
let mut setup = BitrotReaderSetup::new(disks.len());
@@ -1313,6 +1333,7 @@ async fn try_create_bitrot_readers_via_batch_pread(
_checksum_algo: HashAlgorithm,
_skip_verify_bitrot: bool,
) -> Option<BitrotReaderSetup> {
rustfs_io_metrics::record_local_batch_read_decision(rustfs_io_metrics::LocalBatchReadDecision::UnsupportedPlatform, None);
None
}
@@ -1476,11 +1497,19 @@ pub(in crate::set_disk) async fn create_bitrot_readers_until_quorum_with_prefere
) -> BitrotReaderSetup {
let strategy = get_bitrot_reader_setup_strategy(mode, prefer_data_blocks_first);
if use_mmap_read
&& !matches!(
crate::set_disk::get_object_read_policy(),
crate::set_disk::GetObjectReadPolicy::CopySource
)
let batch_read_enabled = if !use_mmap_read {
rustfs_io_metrics::record_local_batch_read_decision(rustfs_io_metrics::LocalBatchReadDecision::MmapDisabled, None);
false
} else if matches!(
crate::set_disk::get_object_read_policy(),
crate::set_disk::GetObjectReadPolicy::CopySource
) {
rustfs_io_metrics::record_local_batch_read_decision(rustfs_io_metrics::LocalBatchReadDecision::CopySource, None);
false
} else {
true
};
if batch_read_enabled
&& let Some(mut setup) = try_create_bitrot_readers_via_batch_pread(
files,
disks,
@@ -7864,6 +7893,228 @@ mod tests {
(dirs, disks)
}
#[cfg(unix)]
#[test]
fn local_batch_read_decisions_follow_the_actual_reader_setup_entry() {
use crate::test_metrics::CapturingRecorder;
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("reader setup validation runtime");
let recorder = CapturingRecorder::default();
let previous = rustfs_io_metrics::get_stage_metrics_enabled();
rustfs_io_metrics::set_get_stage_metrics_enabled(true);
metrics::with_local_recorder(&recorder, || {
runtime.block_on(temp_env::async_with_vars(
[
(rustfs_config::ENV_OBJECT_MMAP_READ_MAX_LENGTH, Some("4")),
(rustfs_config::ENV_INTERNODE_PREWARM, Some("false")),
],
async {
let bucket = "batch-path-attribution";
let object = "object";
let (_dirs, disks) = call_counter_local_disks(bucket, 2).await;
let data_dir = Uuid::new_v4();
let mut files = vec![FileInfo::new(object, 1, 1); 2];
for (file, disk) in files.iter_mut().zip(&disks) {
file.data_dir = Some(data_dir);
disk.as_ref()
.expect("local disk")
.write_all(bucket, &format!("{object}/{data_dir}/part.1"), Bytes::from_static(b"data"))
.await
.expect("write batch fixture");
}
assert_eq!(object_mmap_read_max_length(), 4, "cap must be controlled by this test");
for length in [3, 4] {
let setup = create_bitrot_readers_until_quorum_with_preference(
&files,
&disks,
bucket,
object,
1,
0,
length,
4,
HashAlgorithm::None,
false,
true,
1,
1,
BitrotReaderSetupMode::ReadQuorum,
false,
None,
None,
)
.await;
assert_eq!(setup.readers.iter().flatten().count(), 2);
}
assert!(
try_create_bitrot_readers_via_batch_pread(
&files,
&disks,
bucket,
object,
1,
0,
5,
4,
HashAlgorithm::None,
false,
)
.await
.is_none(),
"a read above the adjusted-length cap must fall back"
);
assert!(
try_create_bitrot_readers_via_batch_pread(
&files,
&disks,
bucket,
object,
1,
0,
4,
4,
HashAlgorithm::HighwayHash256S,
false,
)
.await
.is_none(),
"checksum overhead must be included in the read cap"
);
let mut inline_files = files.clone();
inline_files[0].data = Some(Bytes::from_static(b"data"));
assert!(
try_create_bitrot_readers_via_batch_pread(
&inline_files,
&disks,
bucket,
object,
1,
0,
4,
4,
HashAlgorithm::None,
false,
)
.await
.is_none()
);
let mut endpoint = Endpoint::try_from("http://127.0.0.1:1/disk").expect("remote placeholder endpoint");
endpoint.is_local = false;
let remote = new_disk(
&endpoint,
&DiskOption {
cleanup: false,
health_check: false,
},
)
.await
.expect("remote placeholder must initialize without a connection");
assert!(
try_create_bitrot_readers_via_batch_pread(
&files,
&[Some(remote), None],
bucket,
object,
1,
0,
4,
4,
HashAlgorithm::None,
false,
)
.await
.is_none(),
"any remote shard must keep the setup out of the local batch path"
);
assert!(
try_create_bitrot_readers_via_batch_pread(
&files,
&disks,
bucket,
"../outside",
1,
0,
4,
4,
HashAlgorithm::None,
false,
)
.await
.is_none()
);
let missing = vec![None, None];
assert!(
try_create_bitrot_readers_via_batch_pread(
&files,
&missing,
bucket,
object,
1,
0,
4,
4,
HashAlgorithm::None,
false,
)
.await
.is_none()
);
for (use_mmap, policy) in [
(false, crate::set_disk::GetObjectReadPolicy::Default),
(true, crate::set_disk::GetObjectReadPolicy::CopySource),
] {
crate::set_disk::with_get_object_read_policy(policy, async {
create_bitrot_readers_until_quorum_with_preference(
&files,
&missing,
bucket,
object,
1,
0,
4,
4,
HashAlgorithm::None,
false,
use_mmap,
1,
1,
BitrotReaderSetupMode::ReadQuorum,
false,
None,
None,
)
.await;
})
.await;
}
},
));
});
rustfs_io_metrics::set_get_stage_metrics_enabled(previous);
let metric = "rustfs_io_get_object_local_batch_decisions_total";
for (decision, expected) in [
("selected", 2),
("read_cap_exceeded", 2),
("inline_data", 1),
("invalid_path", 1),
("non_local_shard", 1),
("no_local_shards", 1),
("mmap_disabled", 1),
("copy_source", 1),
] {
assert_eq!(recorder.counter_value(metric, &[("decision", decision)]), expected, "{decision}");
}
assert_eq!(
recorder.counter_value("rustfs_io_get_object_local_batch_shards_total", &[("outcome", "success")]),
4
);
assert_eq!(recorder.counter_value("rustfs_io_get_object_local_batch_read_bytes_total", &[]), 14);
assert_eq!(recorder.histogram_sample_count("rustfs_io_get_object_local_batch_prepare_seconds"), 8);
}
async fn reopen_local_disk(dir: &TempDir) -> DiskStore {
let endpoint =
Endpoint::try_from(dir.path().to_str().expect("tempdir path should be utf8")).expect("endpoint should parse");
@@ -13580,6 +13831,61 @@ mod tests {
release_read_repair_heal_reservation(&key).await;
}
#[tokio::test]
async fn read_repair_reader_failure_requests_deep_scan_for_the_exact_version() {
fn captured() -> &'static std::sync::Mutex<Option<rustfs_heal_contracts::heal_channel::HealChannelRequest>> {
static REQUEST: OnceLock<std::sync::Mutex<Option<rustfs_heal_contracts::heal_channel::HealChannelRequest>>> =
OnceLock::new();
REQUEST.get_or_init(|| std::sync::Mutex::new(None))
}
fn capture(request: rustfs_heal_contracts::heal_channel::HealChannelRequest) -> ReadRepairAdmissionFuture {
*captured().lock().expect("read-repair request capture must be available") = Some(request);
Box::pin(async { ReadRepairAdmissionOutcome::Response(HealAdmissionResult::Accepted) })
}
for (reason, expected_scan) in [
("missing_shards", Some(rustfs_heal_contracts::heal_channel::HealScanMode::Deep)),
("decode_error", Some(rustfs_heal_contracts::heal_channel::HealScanMode::Deep)),
("metadata_read_error", None),
] {
let object = format!("reader-failure-{}", Uuid::new_v4());
let version = Uuid::new_v4().to_string();
submit_read_repair_heal_with_submitter(
ReadRepairHealSubmission {
bucket: "reader-failure",
object: &object,
version_id: Some(&version),
pool_index: 1,
set_index: 2,
part_number: Some(1),
reason,
mrf_intent: None,
},
capture,
)
.await;
let request = tokio::time::timeout(Duration::from_secs(5), async {
loop {
if let Some(request) = captured().lock().expect("captured request must be readable").take() {
break request;
}
tokio::task::yield_now().await;
}
})
.await
.expect("the actual asynchronous admission must be captured");
assert_eq!(request.scan_mode, expected_scan, "reader damage needs payload verification");
assert_eq!(request.source, HealRequestSource::ReadRepair);
assert_eq!(request.bucket, "reader-failure");
assert_eq!(request.object_prefix.as_deref(), Some(object.as_str()));
assert_eq!(request.object_version_id.as_deref(), Some(version.as_str()));
assert_eq!((request.pool_index, request.set_index), (Some(1), Some(2)));
assert_eq!(request.recreate_missing, None, "storage evidence decides absence");
let key = ReadRepairHealCacheKey::new("reader-failure", &object, Some(&version), 1, 2);
release_read_repair_heal_reservation(&key).await;
}
}
// ========================================================================
// backlog#898 — groups A/C: pure old-data-dir cleanup classification.
// ========================================================================
+24 -2
View File
@@ -889,6 +889,8 @@ pub(crate) use ops::heal::AbsenceProofRequest;
#[cfg(test)]
pub(crate) use ops::heal::DanglingDeleteFailure;
pub(crate) use ops::heal::HealedObjectAbsence;
#[cfg(test)]
pub(crate) use ops::heal::ReadRepairPauseScope;
#[cfg(test)]
pub(crate) use ops::hermetic_set_disks_isolated;
@@ -4245,14 +4247,23 @@ pub(super) struct MultipartWriteQuorumContext<'a> {
part_number: Option<usize>,
}
fn record_multipart_write_quorum_failure(
stage: &'static str,
errs: &[Option<DiskError>],
write_quorum: usize,
) -> crate::disk::error_reduce::WriteQuorumFailureSummary {
let summary = build_write_quorum_failure_summary(errs, OBJECT_OP_IGNORED_ERRS, write_quorum);
runtime_sources::record_erasure_write_quorum_failure(stage, summary.dominant_error_label);
summary
}
fn log_multipart_write_quorum_failure(
context: MultipartWriteQuorumContext<'_>,
errs: &[Option<DiskError>],
write_quorum: usize,
returned_error: &DiskError,
) {
let summary = build_write_quorum_failure_summary(errs, OBJECT_OP_IGNORED_ERRS, write_quorum);
runtime_sources::record_erasure_write_quorum_failure(context.stage, summary.dominant_error_label);
let summary = record_multipart_write_quorum_failure(context.stage, errs, write_quorum);
warn!(
target: "rustfs_ecstore::set_disk",
event = EVENT_SET_DISK_MULTIPART,
@@ -6854,6 +6865,17 @@ impl SetDisks {
Ok(())
}
/// Whether any drive holds `version_id` of the key, including a copy on
/// fewer drives than read quorum that object reads report as absent.
pub(crate) async fn holds_version_on_any_drive(&self, bucket: &str, object: &str, version_id: Uuid) -> bool {
let disks = self.disk_inventory().await;
let (copies, _) = Self::read_all_raw_file_info(&disks, bucket, object, false).await;
copies
.iter()
.flatten()
.any(|copy| FileMeta::load(&copy.buf).is_ok_and(|meta| meta.find_version(Some(version_id)).is_ok()))
}
/// Publish an internal tier free-version record without changing its
/// delete-marker shape or remote-tier identity. The caller holds the
/// source and target object locks; a write quorum is required before the
@@ -40,9 +40,35 @@ pub(crate) mod test_hooks {
use std::sync::{Arc, Mutex};
type Hook = Arc<dyn Fn() -> BoxFuture<'static, ()> + Send + Sync>;
type TransactionLockHook = Arc<dyn Fn(&rustfs_lock::NamespaceLockGuard) + Send + Sync>;
static BEFORE_GC_FINALIZE: Mutex<Vec<(String, String, Hook)>> = Mutex::new(Vec::new());
static CLEANUP_ATTEMPTS: Mutex<Vec<(String, String)>> = Mutex::new(Vec::new());
static AFTER_TRANSACTION_LOCK: Mutex<Vec<(String, String, TransactionLockHook)>> = Mutex::new(Vec::new());
pub(crate) fn after_transaction_lock_acquired(bucket: &str, object: &str, hook: TransactionLockHook) {
AFTER_TRANSACTION_LOCK
.lock()
.expect("transaction lock hooks")
.push((bucket.to_owned(), object.to_owned(), hook));
}
pub(in crate::set_disk) fn run_after_transaction_lock_acquired(
bucket: &str,
object: &str,
guard: &rustfs_lock::NamespaceLockGuard,
) {
let hook = {
let mut hooks = AFTER_TRANSACTION_LOCK.lock().expect("transaction lock hooks");
hooks
.iter()
.position(|(hook_bucket, hook_object, _)| hook_bucket == bucket && hook_object == object)
.map(|index| hooks.remove(index).2)
};
if let Some(hook) = hook {
hook(guard);
}
}
pub(super) fn record_cleanup_attempt(bucket: &str, object: &str) {
CLEANUP_ATTEMPTS
@@ -112,6 +138,7 @@ pub(crate) struct DeleteRecoveryContext<'a> {
/// Only the locked heal path may repair a live marker-creation source.
pub repair_marker_source: bool,
pub namespace_owner: Option<Arc<dyn Send + Sync>>,
pub transaction_guard: Option<Arc<rustfs_lock::NamespaceLockGuard>>,
pub recovery_ticket: Option<crate::store::delete_recovery_runtime::DeleteRecoveryTicket>,
pub published_hint: Option<(Uuid, &'a [Result<DeleteRecoveryInspection>])>,
pub can_commit: &'a (dyn Fn() -> bool + Sync),
@@ -125,6 +152,7 @@ impl<'a> DeleteRecoveryContext<'a> {
explicit_delete: false,
repair_marker_source: false,
namespace_owner: None,
transaction_guard: None,
recovery_ticket: None,
published_hint: None,
can_commit,
@@ -373,9 +401,22 @@ impl SetDisks {
write_quorum: usize,
mut context: DeleteRecoveryContext<'_>,
) -> Result<()> {
if context.namespace_owner.is_none() {
context.namespace_owner = Some(self.ctx.begin_namespace_commit());
}
let transaction_guard = match context.transaction_guard.take() {
Some(guard) => guard,
None => self
.acquire_delete_transaction_lock(bucket, object)
.await
.map_err(DiskError::other)?,
};
let outer_can_commit = context.can_commit;
let can_commit = || outer_can_commit() && !transaction_guard.is_lock_lost();
context.can_commit = &can_commit;
context.transaction_guard = Some(Arc::clone(&transaction_guard));
let owner = context
.namespace_owner
.take()
.unwrap_or_else(|| self.ctx.begin_namespace_commit());
context.namespace_owner = Some(Arc::new((owner, Arc::clone(&transaction_guard))));
let can_commit = context.can_commit;
let states = inspect(disks, bucket, object, transaction).await;
let untouched_absence = |state: &Result<DeleteRecoveryInspection>| matches!(state, Ok(state) if state.state == DeleteRecoveryState::Absent && state.current_digest.is_none());
@@ -547,8 +588,25 @@ impl SetDisks {
&self,
bucket: &str,
object: &str,
context: DeleteRecoveryContext<'_>,
mut context: DeleteRecoveryContext<'_>,
) -> Result<DeleteRecoverySummary> {
let transaction_guard = match context.transaction_guard.take() {
Some(guard) => guard,
None => self
.acquire_delete_transaction_lock(bucket, object)
.await
.map_err(DiskError::other)?,
};
let outer_can_commit = context.can_commit;
let can_commit = || outer_can_commit() && !transaction_guard.is_lock_lost();
context.can_commit = &can_commit;
if !context.dry_run {
let owner = context
.namespace_owner
.take()
.unwrap_or_else(|| self.ctx.begin_namespace_commit());
context.namespace_owner = Some(Arc::new((owner, Arc::clone(&transaction_guard))));
}
let DeleteRecoveryContext {
incarnation,
dry_run,
@@ -558,6 +616,7 @@ impl SetDisks {
published_hint,
can_commit,
recovery_ticket: _,
transaction_guard: _,
} = context;
let disks = self.disk_inventory().await;
let complete_inventory = disks.len() == self.set_drive_count && disks.iter().all(Option::is_some);
@@ -1257,6 +1316,135 @@ mod tests {
assert_eq!(body(&store, bucket, None).await, original);
}
#[tokio::test]
async fn delete_recovery_deferred_batch_discards_released_alias_fence_and_preserves_caller_fence() {
use crate::bucket::metadata_sys::ObjectLockConfigState;
use crate::object_api::{NamespaceLockFence, NamespaceLockSignalTestFence, ObjectLockConfigSnapshot};
use crate::storage_api_contracts::object::ObjectToDelete;
use std::sync::Mutex;
use std::sync::atomic::{AtomicBool, Ordering};
for lose_caller in [false, true] {
let bucket = if lose_caller {
"deferred-batch-lost-caller"
} else {
"deferred-batch-released-alias"
};
let (_dirs, store, disks) = fixture(bucket).await;
put(&store, bucket, &[27; 4096], false).await;
let transaction = applied(&store, &disks, bucket, None, disks.len()).await;
let before = join_all(
disks
.iter()
.map(|disk| disk.inspect_delete_transaction(bucket, "object", transaction)),
)
.await
.into_iter()
.collect::<Result<Vec<_>>>()
.expect("capture pending recovery evidence");
let mut set = (*store.pools[0].disk_set[0]).clone();
set.lockers = vec![Arc::new(rustfs_lock::LocalClient::with_manager(Arc::new(
rustfs_lock::GlobalLockManager::Enabled(Arc::new(rustfs_lock::FastObjectLockManager::new())),
)))];
set.ctx
.update_erasure_type(crate::layout::endpoints::SetupType::DistErasure)
.await;
let (caller_fence, caller_lost) = NamespaceLockFence::loss_handle_for_test();
let released_alias_lost = Arc::new(AtomicBool::new(false));
let signal_fence = Arc::new(Mutex::new(None));
let captured_signal_fence = Arc::clone(&signal_fence);
let captured_alias_lost = Arc::clone(&released_alias_lost);
test_hooks::after_transaction_lock_acquired(
bucket,
"object",
Arc::new(move |guard| {
let signal = guard.lock_lost_signal().expect("distributed alias signal");
let fence = NamespaceLockSignalTestFence::install_with_loss_handle(&signal, Arc::clone(&captured_alias_lost));
// Retain the signal so a later allocation cannot reuse its
// registry identity after the original guard is dropped.
*captured_signal_fence.lock().expect("captured alias fence") = Some((fence, signal));
}),
);
let replay_started = Arc::new(AtomicBool::new(false));
let captured_replay_started = Arc::clone(&replay_started);
let expired_alias = Arc::clone(&released_alias_lost);
test_hooks::after_transaction_lock_acquired(
bucket,
"object",
Arc::new(move |_| {
// Acquiring the same transaction key again proves the first
// lease was released. Only its copied fence becomes stale.
expired_alias.store(true, Ordering::Release);
caller_lost.store(lose_caller, Ordering::Release);
captured_replay_started.store(true, Ordering::Release);
}),
);
let (deleted, errors) = set
.delete_objects(
bucket,
vec![ObjectToDelete {
object_name: "object".into(),
..Default::default()
}],
ObjectOptions {
namespace_lock_fence: Some(caller_fence),
object_lock_config_snapshot: Some(Arc::new(ObjectLockConfigSnapshot::new(
ObjectLockConfigState::ConfirmedAbsent,
))),
..Default::default()
},
)
.await;
assert!(
replay_started.load(Ordering::Acquire),
"missing metadata must take the deferred replay path"
);
assert!(
released_alias_lost.load(Ordering::Acquire),
"the completed batch alias must be marked lost"
);
if lose_caller {
assert!(errors[0].is_some(), "losing the original caller fence must reject deferred replay");
for (disk, original) in disks.iter().zip(before) {
let after = disk
.inspect_delete_transaction(bucket, "object", transaction)
.await
.expect("retained proof");
assert_eq!(
after.fingerprint, original.fingerprint,
"lost caller authority must preserve every prepared journal"
);
}
} else {
assert!(
errors.iter().all(Option::is_none),
"released alias loss must not reject fresh recovery: {errors:?}"
);
assert!(
!deleted[0].found,
"fresh unversioned recovery commits absence before the idempotent batch retry"
);
for disk in &disks {
assert!(
discover(disk, bucket, Some("object"))
.await
.expect("final recovery evidence")
.is_empty()
);
assert_eq!(
disk.read_version("", bucket, "object", "", &ReadOptions::default())
.await
.expect_err("replayed delete must remove the source"),
DiskError::FileNotFound
);
}
}
drop(signal_fence);
}
}
#[tokio::test]
async fn delete_recovery_corrupt_bucket_does_not_prevent_other_bucket_rollback() {
let bad_bucket = "recover-corrupt-bucket";
File diff suppressed because it is too large Load Diff
+40 -12
View File
@@ -44,6 +44,21 @@ impl crate::storage_api_contracts::namespace::NamespaceLocking for SetDisks {
#[tracing::instrument(level = "trace", skip(self))]
async fn new_ns_lock(&self, bucket: &str, object: &str) -> Result<NamespaceLockWrapper> {
// Lock the file the disk resolves, not the spelling: on Windows `a\b`
// and `a/b` share one xl.meta, so they must share one lock. The key is
// folded by the requesting node only; lock servers compare it verbatim.
let resource = ObjectKey {
bucket: Arc::from(bucket),
object: Arc::from(object_name_disk_identity(object).as_ref()),
version: None,
};
Ok(self.namespace_lock_for_key(resource).await)
}
}
impl SetDisks {
async fn namespace_lock_for_key(&self, resource: ObjectKey) -> NamespaceLockWrapper {
// Resolved from this set's own instance context (backlog#1052), not the
// ambient facade: the facade tracks whichever context is currently
// published process-wide, so a second instance (or, in tests, another
@@ -69,20 +84,33 @@ impl crate::storage_api_contracts::namespace::NamespaceLocking for SetDisks {
NamespaceLock::with_local_manager_shared(self.set_lock_namespace.clone(), self.local_lock_manager.clone())
};
// Lock the file the disk resolves, not the spelling: on Windows `a\b`
// and `a/b` share one xl.meta, so they must share one lock. The key is
// folded by the requesting node only; lock servers compare it verbatim.
let resource = ObjectKey {
bucket: Arc::from(bucket),
object: Arc::from(object_name_disk_identity(object).as_ref()),
version: None,
};
Ok(NamespaceLockWrapper::new(set_lock, resource, self.locker_owner.clone()))
NamespaceLockWrapper::new(set_lock, resource, self.locker_owner.clone())
}
/// Raw object locks precede this set-local transaction lock. Its versioned
/// resource cannot collide with normal namespace keys, which use None.
/// Keeping the target set's clients avoids nesting overlapping raw quorums.
pub(in crate::set_disk) async fn acquire_delete_transaction_lock(
&self,
bucket: &str,
object: &str,
) -> Result<Arc<rustfs_lock::NamespaceLockGuard>> {
let resource = ObjectKey::with_version(
bucket,
object_name_disk_identity(object).as_ref(),
format!("rustfs:delete-transaction:{}:{}", self.pool_index, self.set_index),
);
let guard = self
.namespace_lock_for_key(resource)
.await
.get_write_lock(super::super::get_lock_acquire_timeout())
.await
.map_err(|error| self.map_namespace_lock_error(bucket, object, "delete_transaction", error))?;
#[cfg(test)]
super::delete_recovery::test_hooks::run_after_transaction_lock_acquired(bucket, object, &guard);
Ok(Arc::new(guard))
}
}
impl SetDisks {
#[allow(dead_code, reason = "lock diagnostics formatter with no caller in this port (backlog#1823)")]
pub(in crate::set_disk) fn format_lock_error(&self, bucket: &str, object: &str, mode: &str, err: &LockResult) -> String {
match err {
+52 -28
View File
@@ -44,9 +44,9 @@ use super::super::{
create_bitrot_writer, debug, disk, error, file_info_is_valid_for_metadata, get_complete_multipart_md5, get_header_map,
get_str, insert_str, is_err_object_not_found, is_err_version_not_found, is_min_allowed_part_size,
log_multipart_write_quorum_failure, parts_after_marker, path_join_buf, prepare_complete_multipart_parts,
record_compression_total_memory, reduce_read_quorum_errs, reduce_write_quorum_errs, remove_header_map, resolve_write_layout,
restore_commit_operation_id_from_metadata, should_persist_encryption_original_size, strip_internal_multipart_metadata,
to_object_err, warn,
record_compression_total_memory, record_multipart_write_quorum_failure, reduce_read_quorum_errs, reduce_write_quorum_errs,
remove_header_map, resolve_write_layout, restore_commit_operation_id_from_metadata, should_persist_encryption_original_size,
strip_internal_multipart_metadata, to_object_err, warn,
};
use super::bitrot_self_verify::{BitrotSelfVerifyTarget, drop_failed_writer_disks, verify_written_bitrot_shards};
#[cfg(test)]
@@ -233,6 +233,19 @@ fn admitted_multipart_size(current: u64, candidate: u64, limit: u64) -> Result<u
Ok(total)
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum MultipartMetadataQuorum {
Read,
Write,
Cleanup,
}
impl From<bool> for MultipartMetadataQuorum {
fn from(write: bool) -> Self {
if write { Self::Write } else { Self::Read }
}
}
pub(crate) struct StaleMultipartCleanupGuard {
file_info: FileInfo,
upload_path: String,
@@ -1139,7 +1152,7 @@ impl SetDisks {
}
let upload_id = runtime_sources::deployment_upload_id(raw_upload_id);
let file_info = match self
.check_multipart_upload_path_exists(bucket, object, &upload_id, &upload_path, false, true)
.check_multipart_upload_path_exists(bucket, object, &upload_id, &upload_path, MultipartMetadataQuorum::Read, true)
.await
{
Ok((file_info, _)) => file_info,
@@ -1226,7 +1239,7 @@ impl SetDisks {
.acquire_write_lock_diag("abort_data_movement_multipart", RUSTFS_META_MULTIPART_BUCKET, &upload_path)
.await?;
let file_info = match self
.check_multipart_upload_path_exists(bucket, object, upload_id, &upload_path, true, true)
.check_multipart_upload_path_exists(bucket, object, upload_id, &upload_path, MultipartMetadataQuorum::Write, true)
.await
{
Ok((file_info, _)) => file_info,
@@ -1333,7 +1346,7 @@ impl SetDisks {
data_movement: bool,
) -> Result<(FileInfo, Vec<FileInfo>)> {
let upload_id_path = Self::validated_multipart_upload_path(bucket, object, upload_id, data_movement)?;
self.check_multipart_upload_path_exists(bucket, object, upload_id, &upload_id_path, write, false)
self.check_multipart_upload_path_exists(bucket, object, upload_id, &upload_id_path, write.into(), false)
.await
}
@@ -1373,7 +1386,7 @@ impl SetDisks {
object: &str,
upload_id: &str,
upload_id_path: &str,
write: bool,
quorum_mode: MultipartMetadataQuorum,
include_recovery: bool,
) -> Result<(FileInfo, Vec<FileInfo>)> {
// Slow metadata I/O must not hold the topology registry's read lock.
@@ -1489,22 +1502,26 @@ impl SetDisks {
}
let mut quorum = read_quorum as usize;
if write {
if quorum_mode != MultipartMetadataQuorum::Read {
quorum = write_quorum as usize;
if let Some(err) = reduce_write_quorum_errs(&errs, OBJECT_OP_IGNORED_ERRS, quorum) {
log_multipart_write_quorum_failure(
MultipartWriteQuorumContext {
stage: MULTIPART_WRITE_QUORUM_UPLOAD_METADATA,
bucket,
object,
upload_id,
part_number: None,
},
&errs,
quorum,
&err,
);
if quorum_mode == MultipartMetadataQuorum::Write {
log_multipart_write_quorum_failure(
MultipartWriteQuorumContext {
stage: MULTIPART_WRITE_QUORUM_UPLOAD_METADATA,
bucket,
object,
upload_id,
part_number: None,
},
&errs,
quorum,
&err,
);
} else {
record_multipart_write_quorum_failure(MULTIPART_WRITE_QUORUM_UPLOAD_METADATA, &errs, quorum);
}
return Err(map_error(err));
}
} else if let Some(err) = reduce_read_quorum_errs(&errs, OBJECT_OP_IGNORED_ERRS, quorum) {
@@ -1523,7 +1540,7 @@ impl SetDisks {
.acquire_write_lock_diag("stale_multipart_cleanup", RUSTFS_META_MULTIPART_BUCKET, upload_path)
.await?;
let (file_info, _) = self
.check_multipart_upload_path_exists("", "", "", upload_path, true, true)
.check_multipart_upload_path_exists("", "", "", upload_path, MultipartMetadataQuorum::Cleanup, true)
.await?;
let write_quorum = file_info.write_quorum(self.default_write_quorum());
Ok(StaleMultipartCleanupGuard {
@@ -3032,7 +3049,7 @@ impl crate::storage_api_contracts::multipart::MultipartOperations for SetDisks {
// its full write quorum and the upload's incarnation/lifecycle fences.
let upload_id_path = Self::validated_multipart_upload_path(bucket, object, upload_id, opts.data_movement)?;
let (fi, _) = self
.check_multipart_upload_path_exists(bucket, object, upload_id, &upload_id_path, false, true)
.check_multipart_upload_path_exists(bucket, object, upload_id, &upload_id_path, MultipartMetadataQuorum::Read, true)
.await?;
ensure_data_movement_upload_access(&fi, bucket, object, upload_id, opts)?;
ensure_multipart_bucket_incarnation(&self.ctx, &fi, bucket, object, upload_id, opts.expected_bucket_incarnation_id)
@@ -3192,7 +3209,14 @@ impl crate::storage_api_contracts::multipart::MultipartOperations for SetDisks {
}
SourceRecovery::Missing => {
return match self
.check_multipart_upload_path_exists(bucket, object, upload_id, &upload_id_path, false, true)
.check_multipart_upload_path_exists(
bucket,
object,
upload_id,
&upload_id_path,
MultipartMetadataQuorum::Read,
true,
)
.await
{
Ok(_) => Err(Error::ErasureWriteQuorum),
@@ -11133,7 +11157,7 @@ mod tests {
*disk = None;
}
let (info, _) = set
.check_multipart_upload_path_exists(bucket, object, &upload, &path, false, true)
.check_multipart_upload_path_exists(bucket, object, &upload, &path, MultipartMetadataQuorum::Read, true)
.await
.expect("four primary plus four recovery copies reach metadata read quorum");
assert_eq!(info.name, path);
@@ -11154,7 +11178,7 @@ mod tests {
*disk = None;
}
assert!(
set.check_multipart_upload_path_exists(bucket, object, &upload, &path, false, true)
set.check_multipart_upload_path_exists(bucket, object, &upload, &path, MultipartMetadataQuorum::Read, true)
.await
.is_err(),
"two files on each of four disks must not count as eight voters"
@@ -14471,7 +14495,7 @@ mod tests {
object,
&u_new,
&SetDisks::get_upload_id_dir(bucket, object, &u_new),
true,
MultipartMetadataQuorum::Write,
true,
)
.await
@@ -14516,7 +14540,7 @@ mod tests {
object,
&upload,
&SetDisks::get_upload_id_dir(bucket, object, &upload),
true,
MultipartMetadataQuorum::Write,
true,
)
.await
@@ -14599,7 +14623,7 @@ mod tests {
assert_completion_files_preserved(&disk_stores, RUSTFS_META_MULTIPART_BUCKET, &source_files).await;
set_disks
.check_multipart_upload_path_exists(
bucket, object, &u_new, &SetDisks::get_upload_id_dir(bucket, object, &u_new), true, true,
bucket, object, &u_new, &SetDisks::get_upload_id_dir(bucket, object, &u_new), MultipartMetadataQuorum::Write, true,
)
.await
.expect("upload authority must survive the post-commit crash");
@@ -507,7 +507,7 @@ mod native_abort_closure_tests {
let path = SetDisks::get_upload_id_dir(bucket, object, &upload);
pin_historical_unmarked_upload(&disks, bucket, &path).await;
let before = originals(&disks, &path).await;
set.check_multipart_upload_path_exists(bucket, object, &upload, &path, false, true)
set.check_multipart_upload_path_exists(bucket, object, &upload, &path, MultipartMetadataQuorum::Read, true)
.await
.expect("readonly legacy cleanup identity lookup");
assert_eq!(
@@ -786,7 +786,7 @@ mod native_abort_closure_tests {
"known orphan native evidence remains unknown: {inspected:?}"
);
let readonly = set
.check_multipart_upload_path_exists(bucket, object, &upload, &path, false, true)
.check_multipart_upload_path_exists(bucket, object, &upload, &path, MultipartMetadataQuorum::Read, true)
.await;
assert!(
matches!(
+568 -89
View File
@@ -73,6 +73,7 @@ struct DeleteCommitContext<'a> {
incarnation: Option<Uuid>,
options: Option<&'a ObjectOptions>,
guard: Option<Arc<super::super::ObjectLockDiagGuard>>,
transaction_guard: Option<Arc<rustfs_lock::NamespaceLockGuard>>,
}
impl<'a> DeleteCommitContext<'a> {
@@ -81,17 +82,37 @@ impl<'a> DeleteCommitContext<'a> {
incarnation: options.expected_bucket_incarnation_id,
options: Some(options),
guard: guard.cloned(),
transaction_guard: None,
}
}
fn with_transaction_guard(mut self, guard: Option<&Arc<rustfs_lock::NamespaceLockGuard>>) -> Self {
self.transaction_guard = guard.cloned();
self
}
fn ensure_transaction_lock_held(&self, bucket: &str, object: &str) -> Result<()> {
if self.transaction_guard.as_ref().is_some_and(|guard| guard.is_lock_lost()) {
return Err(StorageError::NamespaceLockQuorumUnavailable {
mode: "delete_transaction",
bucket: bucket.to_owned(),
object: object.to_owned(),
required: 1,
achieved: 0,
});
}
Ok(())
}
fn can_commit(&self, bucket: &str, object: &str) -> bool {
self.options.is_none_or(|options| {
ensure_delete_commit_locks_held(self.guard.as_deref(), bucket, object, options).is_ok()
&& options
.scanner_publication_commit_scope
.as_ref()
.is_none_or(|scope| scope.can_commit())
})
!self.transaction_guard.as_ref().is_some_and(|guard| guard.is_lock_lost())
&& self.options.is_none_or(|options| {
ensure_delete_commit_locks_held(self.guard.as_deref(), bucket, object, options).is_ok()
&& options
.scanner_publication_commit_scope
.as_ref()
.is_none_or(|scope| scope.can_commit())
})
}
}
@@ -3852,6 +3873,7 @@ impl SetDisks {
object: &str,
opts: &ObjectOptions,
lock_guard: Option<&Arc<super::super::ObjectLockDiagGuard>>,
transaction_guard: Option<&Arc<rustfs_lock::NamespaceLockGuard>>,
) -> Result<bool> {
if is_meta_bucketname(bucket)
|| opts.version_id.is_some()
@@ -3888,6 +3910,7 @@ impl SetDisks {
object,
DeleteRecoveryContext {
explicit_delete: true,
transaction_guard: transaction_guard.cloned(),
namespace_owner: Some(Arc::new((
self.ctx.begin_namespace_commit(),
lock_guard.cloned(),
@@ -8177,13 +8200,23 @@ fn notify_put_object_commit_namespace_acquired(bucket: &str, object: &str) {
}
}
#[cfg(test)]
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum DeleteObjectCommitPause {
BeforeNamespace,
BeforeCommit,
AfterPublish,
}
#[cfg(test)]
struct DeleteObjectCommitBarrierState {
bucket: String,
object: String,
pause_after_publish: bool,
pause: DeleteObjectCommitPause,
arrived: tokio::sync::Notify,
release: tokio::sync::Notify,
namespace_pending: tokio::sync::Notify,
namespace_acquired: std::sync::atomic::AtomicBool,
}
#[cfg(test)]
@@ -8198,21 +8231,27 @@ static DELETE_OBJECT_COMMIT_BARRIER: std::sync::OnceLock<std::sync::Mutex<Option
#[cfg(test)]
impl DeleteObjectCommitBarrier {
pub(crate) fn install(bucket: &str, object: &str) -> Self {
Self::install_with_mode(bucket, object, false)
Self::install_with_mode(bucket, object, DeleteObjectCommitPause::BeforeCommit)
}
pub(crate) fn install_before_namespace(bucket: &str, object: &str) -> Self {
Self::install_with_mode(bucket, object, DeleteObjectCommitPause::BeforeNamespace)
}
#[cfg(feature = "test-util")]
pub(crate) fn install_after_publish(bucket: &str, object: &str) -> Self {
Self::install_with_mode(bucket, object, true)
Self::install_with_mode(bucket, object, DeleteObjectCommitPause::AfterPublish)
}
fn install_with_mode(bucket: &str, object: &str, pause_after_publish: bool) -> Self {
fn install_with_mode(bucket: &str, object: &str, pause: DeleteObjectCommitPause) -> Self {
let state = Arc::new(DeleteObjectCommitBarrierState {
bucket: bucket.to_string(),
object: object.to_string(),
pause_after_publish,
pause,
arrived: tokio::sync::Notify::new(),
release: tokio::sync::Notify::new(),
namespace_pending: tokio::sync::Notify::new(),
namespace_acquired: std::sync::atomic::AtomicBool::new(false),
});
let mut slot = DELETE_OBJECT_COMMIT_BARRIER
.get_or_init(|| std::sync::Mutex::new(None))
@@ -8232,6 +8271,19 @@ impl DeleteObjectCommitBarrier {
pub(crate) fn release(&self) {
self.state.release.notify_one();
}
pub(crate) async fn release_and_wait_until_namespace_pending(&self) {
assert_eq!(self.state.pause, DeleteObjectCommitPause::BeforeNamespace);
let pending = self.state.namespace_pending.notified();
self.release();
tokio::time::timeout(Duration::from_secs(5), pending)
.await
.expect("delete object must poll its actual namespace acquisition to Pending");
}
pub(crate) fn namespace_acquired(&self) -> bool {
self.state.namespace_acquired.load(std::sync::atomic::Ordering::Acquire)
}
}
#[cfg(test)]
@@ -8248,6 +8300,43 @@ impl Drop for DeleteObjectCommitBarrier {
}
}
#[cfg(test)]
async fn pause_delete_object_before_namespace(bucket: &str, object: &str) {
let barrier = DELETE_OBJECT_COMMIT_BARRIER
.get_or_init(|| std::sync::Mutex::new(None))
.lock()
.expect("delete object commit barrier mutex should not poison")
.as_ref()
.filter(|barrier| {
barrier.bucket == bucket && barrier.object == object && barrier.pause == DeleteObjectCommitPause::BeforeNamespace
})
.cloned();
if let Some(barrier) = barrier {
barrier.arrived.notify_one();
barrier.release.notified().await;
}
}
#[cfg(test)]
fn notify_delete_object_namespace(bucket: &str, object: &str, acquired: bool) {
let barrier = DELETE_OBJECT_COMMIT_BARRIER
.get_or_init(|| std::sync::Mutex::new(None))
.lock()
.expect("delete object commit barrier mutex should not poison")
.as_ref()
.filter(|barrier| {
barrier.bucket == bucket && barrier.object == object && barrier.pause == DeleteObjectCommitPause::BeforeNamespace
})
.cloned();
if let Some(barrier) = barrier {
if acquired {
barrier.namespace_acquired.store(true, std::sync::atomic::Ordering::Release);
} else {
barrier.namespace_pending.notify_one();
}
}
}
#[cfg(test)]
async fn pause_delete_object_commit(bucket: &str, object: &str) {
let barrier = DELETE_OBJECT_COMMIT_BARRIER
@@ -8255,7 +8344,9 @@ async fn pause_delete_object_commit(bucket: &str, object: &str) {
.lock()
.expect("delete object commit barrier mutex should not poison")
.as_ref()
.filter(|barrier| barrier.bucket == bucket && barrier.object == object && !barrier.pause_after_publish)
.filter(|barrier| {
barrier.bucket == bucket && barrier.object == object && barrier.pause == DeleteObjectCommitPause::BeforeCommit
})
.cloned();
if let Some(barrier) = barrier {
barrier.arrived.notify_one();
@@ -8270,7 +8361,9 @@ async fn pause_delete_object_commit_after_publish(bucket: &str, object: &str) {
.lock()
.expect("delete object commit barrier mutex should not poison")
.as_ref()
.filter(|barrier| barrier.bucket == bucket && barrier.object == object && barrier.pause_after_publish)
.filter(|barrier| {
barrier.bucket == bucket && barrier.object == object && barrier.pause == DeleteObjectCommitPause::AfterPublish
})
.cloned();
if let Some(barrier) = barrier {
barrier.arrived.notify_one();
@@ -8839,7 +8932,7 @@ impl SetDisks {
// quorum, failing write quorum on update_object_meta (backlog#872).
let read_opts = opts.as_read_opts();
let (mut fi, _, disks) = self
.get_object_fileinfo_gated(bucket, object, &read_opts, false, false)
.get_object_fileinfo_for_metadata_update(bucket, object, &read_opts)
.await?
.into_owned();
@@ -8877,8 +8970,11 @@ impl SetDisks {
fi: &FileInfo,
force_del_marker: bool,
delete_marker_purge: Option<MrfDeleteMarkerPurge>,
commit_context: DeleteCommitContext<'_>,
mut commit_context: DeleteCommitContext<'_>,
) -> Result<()> {
if !is_meta_bucketname(bucket) && commit_context.transaction_guard.is_none() {
commit_context.transaction_guard = Some(self.acquire_delete_transaction_lock(bucket, object).await?);
}
let source_bucket_incarnation_id = commit_context.incarnation;
let transported = delete_file_info_with_replication_transport_metadata(fi);
let fi = &transported;
@@ -8887,6 +8983,7 @@ impl SetDisks {
Arc::new((
self.ctx.begin_namespace_commit(),
commit_context.guard.clone(),
commit_context.transaction_guard.clone(),
commit_context
.options
.and_then(|options| options.object_lock_config_snapshot.clone()),
@@ -8926,7 +9023,11 @@ impl SetDisks {
for disk in disks.iter() {
let disk_namespace_owner = namespace_owner.clone().map(|owner| owner as Arc<dyn Send + Sync>);
let transaction_guard = commit_context.transaction_guard.as_ref();
futures.push(async move {
if transaction_guard.is_some_and(|guard| guard.is_lock_lost()) {
return Err(DiskError::DiskOngoingReq);
}
if let Some(disk) = disk {
let options = DeleteOptions {
old_data_dir: Some(rollback_dir),
@@ -8976,6 +9077,7 @@ impl SetDisks {
}
}
commit_context.ensure_transaction_lock_held(bucket, object)?;
let mut quorum_result = resolve_tiered_decommission_write_quorum_result(&errs, write_quorum, bucket, object);
if !is_meta_bucketname(bucket) && errs.iter().flatten().any(super::delete_recovery::unsupported_recovery) {
quorum_result = Err(DiskError::UnsupportedDisk.into());
@@ -9008,6 +9110,7 @@ impl SetDisks {
write_quorum,
DeleteRecoveryContext {
recovery_ticket,
transaction_guard: commit_context.transaction_guard.clone(),
namespace_owner: namespace_owner.clone().map(|owner| owner as Arc<dyn Send + Sync>),
..DeleteRecoveryContext::new(source_bucket_incarnation_id, &|| commit_context.can_commit(bucket, object))
},
@@ -9017,6 +9120,7 @@ impl SetDisks {
}
#[cfg(test)]
pause_delete_before_cleanup(bucket).await;
commit_context.ensure_transaction_lock_held(bucket, object)?;
let mut rollback_futures = Vec::new();
for (index, err) in errs.iter().enumerate() {
if structured_cleanup {
@@ -9042,7 +9146,11 @@ impl SetDisks {
let object = object.to_string();
let fi = fi.clone();
let disk_namespace_owner = namespace_owner.clone().map(|owner| owner as Arc<dyn Send + Sync>);
let transaction_guard = commit_context.transaction_guard.as_ref();
rollback_futures.push(async move {
if transaction_guard.is_some_and(|guard| guard.is_lock_lost()) {
return;
}
if should_rollback {
// The dedicated undo path never forwards the marker-only creation flag.
if let Err(err) = disk
@@ -9099,6 +9207,7 @@ impl SetDisks {
}
join_all(rollback_futures).await;
commit_context.ensure_transaction_lock_held(bucket, object)?;
drop(namespace_owner);
if quorum_result.is_ok()
&& errs.iter().any(Option::is_some)
@@ -9294,36 +9403,56 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
}
};
let (read_quorum, write_quorum) = match Self::object_quorum_from_meta(&metas, &errs, self.default_parity_count) {
Ok((r, w)) => (
usize::try_from(r)
.map_err(|_| to_object_err(DiskError::ErasureReadQuorum.into(), vec![src_bucket, src_object]))?,
usize::try_from(w)
.map_err(|_| to_object_err(DiskError::ErasureWriteQuorum.into(), vec![src_bucket, src_object]))?,
),
let src_version_id = src_opts.version_id.as_deref().unwrap_or_default();
let quorum = Self::object_quorum_from_meta(&metas, &errs, self.default_parity_count);
let selection = match &quorum {
Ok((read_quorum, write_quorum)) => {
let read_quorum = usize::try_from(*read_quorum)
.map_err(|_| to_object_err(DiskError::ErasureReadQuorum.into(), vec![src_bucket, src_object]))?;
let write_quorum = usize::try_from(*write_quorum)
.map_err(|_| to_object_err(DiskError::ErasureWriteQuorum.into(), vec![src_bucket, src_object]))?;
Self::select_fileinfo_for_read(src_bucket, &disks, &metas, &errs, src_version_id, read_quorum, write_quorum)
.map(|(online_disks, fi)| (online_disks, fi, write_quorum))
}
Err(err) => Err(err.clone()),
};
let (online_disks, mut fi, write_quorum) = match selection {
Ok(selection) => selection,
Err(mut err) => {
if err == DiskError::ErasureReadQuorum
&& !src_bucket.starts_with(RUSTFS_META_BUCKET)
&& self
.delete_if_dangling(src_bucket, src_object, &metas, &errs, &HashMap::new(), src_opts.clone())
.await
.is_ok()
// The merged latest version can be one a failed PUT left below
// quorum; resolve it like a latest GET before the dangling
// cleanup below can report a key with a committed version absent.
if let Some(snapshot) = self
.select_latest_past_dangling_versions(src_bucket, src_object, src_opts, true, &disks, &err)
.await
{
if src_opts.version_id.is_some() {
err = DiskError::FileVersionNotFound
} else {
err = DiskError::FileNotFound
// A version-only copy writes the served version's own
// copies, so they and its write quorum replace the latest
// read's.
let (fi, parts_metadata, online_disks) = snapshot.into_owned();
metas = parts_metadata;
let write_quorum = fi.write_quorum(self.default_write_quorum());
(online_disks, fi, write_quorum)
} else {
if quorum.is_err()
&& err == DiskError::ErasureReadQuorum
&& !src_bucket.starts_with(RUSTFS_META_BUCKET)
&& self
.delete_if_dangling(src_bucket, src_object, &metas, &errs, &HashMap::new(), src_opts.clone())
.await
.is_ok()
{
if src_opts.version_id.is_some() {
err = DiskError::FileVersionNotFound
} else {
err = DiskError::FileNotFound
}
}
return Err(to_object_err(err.into(), vec![src_bucket, src_object]));
}
return Err(to_object_err(err.into(), vec![src_bucket, src_object]));
}
};
let src_version_id = src_opts.version_id.as_deref().unwrap_or_default();
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 {
if src_opts.version_id.is_none() {
return Err(to_object_err(Error::FileNotFound, vec![src_bucket, src_object]));
@@ -9461,6 +9590,16 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
}
}
// A version-only copy writes and then reverts on a quorum miss;
// refuse it before writing when the served version's disks cannot
// reach its write quorum even if every write succeeds.
let reachable = online_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(to_object_err(err.into(), vec![src_bucket, src_object]));
}
Self::write_unique_file_info(&online_disks, "", src_bucket, src_object, &metas, write_quorum)
.await
.map_err(|e| to_object_err(e.into(), vec![src_bucket, src_object]))?;
@@ -9528,9 +9667,8 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
&self,
bucket: &str,
objects: Vec<ObjectToDelete>,
opts: ObjectOptions,
mut opts: ObjectOptions,
) -> (Vec<DeletedObject>, Vec<Option<Error>>, Vec<Option<DeleteAccounting>>) {
let current_generations = opts.conditional_delete_generations.as_deref();
let mut del_objects = vec![DeletedObject::default(); objects.len()];
let mut accounting = vec![None; objects.len()];
let delete_config_snapshot = opts
@@ -9625,6 +9763,30 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
}
}
let outer_namespace_lock_fence = opts.namespace_lock_fence.clone();
let mut transaction_guards = std::collections::BTreeMap::new();
if !is_meta_bucketname(bucket) {
// Every raw batch lock is acquired first. Canonical sorting also
// gives aliases of one disk key exactly one transaction owner.
let keys: std::collections::BTreeSet<_> = locked_objects.iter().cloned().collect();
for key in keys {
match self.acquire_delete_transaction_lock(bucket, &key).await {
Ok(guard) => {
opts.add_namespace_lock_guard(&guard);
transaction_guards.insert(key, guard);
}
Err(error) => {
for (index, lock_name) in lock_names.iter().enumerate() {
if lock_name.as_ref() == key {
del_errs[index] = Some(error.clone());
}
}
}
}
}
}
let transaction_guards = Arc::new(transaction_guards);
let current_generations = opts.conditional_delete_generations.as_deref();
let object_lock_config = if is_meta_bucketname(bucket) {
Arc::new(ObjectLockConfigSnapshot::new(ObjectLockConfigState::ConfirmedAbsent))
} else {
@@ -9722,6 +9884,7 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
&dobj.object_name,
DeleteRecoveryContext {
dry_run: !opts.no_lock,
transaction_guard: transaction_guards.get(lock_names[i].as_ref()).cloned(),
namespace_owner: opts.delete_mutation_owner.clone(),
..DeleteRecoveryContext::new(opts.expected_bucket_incarnation_id, &|| {
ensure_delete_commit_locks_held(None, bucket, &dobj.object_name, &opts).is_ok()
@@ -10003,6 +10166,7 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
let namespace_owner = (!is_meta_bucketname(bucket)).then(|| {
Arc::new((
self.ctx.begin_namespace_commit(),
Arc::clone(&transaction_guards),
opts.delete_mutation_owner.clone(),
opts.object_lock_config_snapshot.clone(),
opts.scanner_publication_commit_scope.clone(),
@@ -10178,6 +10342,24 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
}
let results = join_all(futures).await;
if transaction_guards.values().any(|guard| guard.is_lock_lost()) {
for (index, object) in objects.iter().enumerate() {
if del_errs[index].is_none() {
accounting[index] = None;
del_errs[index] = Some(StorageError::NamespaceLockQuorumUnavailable {
mode: "delete_transaction",
bucket: bucket.to_owned(),
object: object.object_name.clone(),
required: 1,
achieved: 0,
});
}
}
if dist_erasure {
self.release_dist_delete_object_locks_batch(dist_batch_lock_ids).await;
}
return (del_objects, del_errs, accounting);
}
let mut del_obj_errs: Vec<Vec<Option<DiskError>>> = vec![vec![None; objects.len()]; disks.len()];
@@ -10273,6 +10455,9 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
let group = &vers[index];
let recovery_ticket = recovery_tickets[index].clone();
let namespace_owner = namespace_owner.clone();
let transaction_guard = transaction_guards
.get(object_name_disk_identity(&group.name).as_ref())
.cloned();
let disks = &disks;
let opts = &opts;
async move {
@@ -10293,6 +10478,7 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
DeleteRecoveryContext {
recovery_ticket,
namespace_owner,
transaction_guard,
..DeleteRecoveryContext::new(opts.expected_bucket_incarnation_id, &can_commit)
},
)
@@ -10339,7 +10525,11 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
let object = fi_vers.name.clone();
let versions = fi_vers.clone();
let disk_namespace_owner = namespace_owner.clone();
let transaction_guard = transaction_guards.get(object_name_disk_identity(&object).as_ref()).cloned();
rollback_futures.push(async move {
if transaction_guard.as_ref().is_some_and(|guard| guard.is_lock_lost()) {
return;
}
if should_rollback {
let errs = disk
.delete_versions_with_namespace_owner(
@@ -10415,7 +10605,16 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
if del_errs[index].is_none() {
let object = &objects[index].object_name;
let result = match ensure_delete_commit_locks_held(None, bucket, object, &opts) {
Ok(()) => self.reclaim_missing_delete_residue(bucket, object, &opts, None).await,
Ok(()) => {
self.reclaim_missing_delete_residue(
bucket,
object,
&opts,
None,
transaction_guards.get(object_name_disk_identity(object).as_ref()),
)
.await
}
Err(err) => Err(err),
};
if let Err(err) = result {
@@ -10424,6 +10623,13 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
}
}
// Deferred recovery takes fresh raw and transaction locks. Discard the
// completed leases' deadlines while retaining every caller-owned fence.
opts.namespace_lock_fence = outer_namespace_lock_fence;
// Release inner owners before reacquiring any raw key lock.
drop(namespace_owner);
drop(transaction_guards);
// TODO(backlog): support partial object deletion for multi-part objects
if dist_erasure {
@@ -10529,7 +10735,30 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
// Guard lock for single object delete
let _lock_guard = if (!opts.delete_prefix || opts.delete_prefix_object) && !opts.no_lock {
Some(Arc::new(self.acquire_write_lock_diag("delete_object", bucket, object).await?))
#[cfg(test)]
let guard = {
pause_delete_object_before_namespace(bucket, object).await;
let guard = self
.acquire_write_lock_diag_with_pending_hook("delete_object", bucket, object, || {
notify_delete_object_namespace(bucket, object, false);
})
.await?;
notify_delete_object_namespace(bucket, object, true);
guard
};
#[cfg(not(test))]
let guard = self.acquire_write_lock_diag("delete_object", bucket, object).await?;
Some(Arc::new(guard))
} else {
None
};
// Lock order: the caller's raw object lock, then this target set's
// transaction key. The inner key remains distinct even when pool
// client quorums overlap, and no_lock never bypasses journal ownership.
let transaction_guard = if !is_meta_bucketname(bucket) && (!opts.delete_prefix || opts.delete_prefix_object) {
let guard = self.acquire_delete_transaction_lock(bucket, object).await?;
opts.add_namespace_lock_guard(&guard);
Some(guard)
} else {
None
};
@@ -10633,7 +10862,8 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
&delete_request,
false,
None,
DeleteCommitContext::request(&opts, _lock_guard.as_ref()),
DeleteCommitContext::request(&opts, _lock_guard.as_ref())
.with_transaction_guard(transaction_guard.as_ref()),
)
.await?;
if let Some((_, deleted_object)) = replication_delete {
@@ -10667,7 +10897,8 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
&delete_request,
false,
None,
DeleteCommitContext::request(&opts, _lock_guard.as_ref()),
DeleteCommitContext::request(&opts, _lock_guard.as_ref())
.with_transaction_guard(transaction_guard.as_ref()),
)
.await?;
}
@@ -10687,7 +10918,8 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
&delete_request,
false,
None,
DeleteCommitContext::request(&opts, _lock_guard.as_ref()),
DeleteCommitContext::request(&opts, _lock_guard.as_ref())
.with_transaction_guard(transaction_guard.as_ref()),
)
.await?;
}
@@ -10776,6 +11008,7 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
bucket,
object,
DeleteRecoveryContext {
transaction_guard: transaction_guard.clone(),
namespace_owner: Some(Arc::new((
self.ctx.begin_namespace_commit(),
_lock_guard.clone(),
@@ -10816,7 +11049,7 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
if is_err_object_not_found(err) {
ensure_delete_commit_locks_held(_lock_guard.as_deref(), bucket, object, &opts)?;
if self
.reclaim_missing_delete_residue(bucket, object, &opts, _lock_guard.as_ref())
.reclaim_missing_delete_residue(bucket, object, &opts, _lock_guard.as_ref(), transaction_guard.as_ref())
.await?
{
if let Some(scope) = scanner_publication_commit_scope.as_ref() {
@@ -10866,7 +11099,7 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
&dfi,
false,
None,
DeleteCommitContext::request(&opts, _lock_guard.as_ref()),
DeleteCommitContext::request(&opts, _lock_guard.as_ref()).with_transaction_guard(transaction_guard.as_ref()),
)
.await
.map_err(|e| to_object_err(e, vec![bucket, object]))?;
@@ -10968,7 +11201,7 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
&fi,
should_force_delete_marker_for_missing_version(&opts),
delete_marker_purge.clone(),
DeleteCommitContext::request(&opts, _lock_guard.as_ref()),
DeleteCommitContext::request(&opts, _lock_guard.as_ref()).with_transaction_guard(transaction_guard.as_ref()),
)
.await
.map_err(|e| to_object_err(e, vec![bucket, object]))?;
@@ -11033,7 +11266,7 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
&dfi,
opts.delete_marker,
delete_marker_purge,
DeleteCommitContext::request(&opts, _lock_guard.as_ref()),
DeleteCommitContext::request(&opts, _lock_guard.as_ref()).with_transaction_guard(transaction_guard.as_ref()),
)
.await
.map_err(|e| to_object_err(e, vec![bucket, object]))?;
@@ -11147,45 +11380,63 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
}
};
let (read_quorum, write_quorum) = match Self::object_quorum_from_meta(&metas, &errs, self.default_parity_count) {
Ok((read_quorum, write_quorum)) => (read_quorum, write_quorum),
let version_id = opts.version_id.as_deref().unwrap_or_default();
let quorum = Self::object_quorum_from_meta(&metas, &errs, self.default_parity_count);
let selection = match &quorum {
Ok((read_quorum, write_quorum)) => {
let read_quorum = usize::try_from(*read_quorum)
.map_err(|_| to_object_err(DiskError::ErasureReadQuorum.into(), vec![bucket, object]))?;
let write_quorum = usize::try_from(*write_quorum)
.map_err(|_| to_object_err(DiskError::ErasureWriteQuorum.into(), vec![bucket, object]))?;
Self::select_fileinfo_for_read(bucket, &disks, &metas, &errs, version_id, read_quorum, write_quorum)
}
Err(err) => Err(err.clone()),
};
let (online_disks, mut fi) = match selection {
Ok(selection) => selection,
Err(mut err) => {
// A replication terminal CAS is a validation/write-back
// attempt, never a heal operation. In particular,
// ValidateOnly must remain zero-write, and a worker whose
// outer replication lease is already lost must not delete the
// surviving xl.meta copies before the namespace fence below
// can reject it. Leave dangling cleanup to heal/scanner.
if opts.replication_status_writeback.is_some() {
// The merged latest version can be one a failed PUT left below
// quorum; resolve it like a latest GET before the dangling
// cleanup below can report a key with a committed version absent.
// A replication terminal CAS stays side-effect free: the walk
// queues heal for the versions it skips, so it does not run.
let resolved = if opts.replication_status_writeback.is_none() {
self.select_latest_past_dangling_versions(bucket, object, opts, false, &disks, &err)
.await
} else {
None
};
if let Some(snapshot) = resolved {
let (fi, _, online_disks) = snapshot.into_owned();
(online_disks, fi)
} else {
// A replication terminal CAS is a validation/write-back
// attempt, never a heal operation. In particular,
// ValidateOnly must remain zero-write, and a worker whose
// outer replication lease is already lost must not delete
// the surviving xl.meta copies before the namespace fence
// below can reject it. Leave dangling cleanup to
// heal/scanner.
if quorum.is_err()
&& opts.replication_status_writeback.is_none()
&& err == DiskError::ErasureReadQuorum
&& !bucket.starts_with(RUSTFS_META_BUCKET)
&& self
.delete_if_dangling(bucket, object, &metas, &errs, &HashMap::new(), opts.clone())
.await
.is_ok()
{
if opts.version_id.is_some() {
err = DiskError::FileVersionNotFound
} else {
err = DiskError::FileNotFound
}
}
return Err(to_object_err(err.into(), vec![bucket, object]));
}
if err == DiskError::ErasureReadQuorum
&& !bucket.starts_with(RUSTFS_META_BUCKET)
&& self
.delete_if_dangling(bucket, object, &metas, &errs, &HashMap::new(), opts.clone())
.await
.is_ok()
{
if opts.version_id.is_some() {
err = DiskError::FileVersionNotFound
} else {
err = DiskError::FileNotFound
}
}
return Err(to_object_err(err.into(), vec![bucket, object]));
}
};
let read_quorum =
usize::try_from(read_quorum).map_err(|_| to_object_err(DiskError::ErasureReadQuorum.into(), vec![bucket, object]))?;
let write_quorum = usize::try_from(write_quorum)
.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_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]));
}
@@ -21436,6 +21687,206 @@ mod put_object_tmp_cleanup_tests {
.await;
}
#[tokio::test]
async fn deep_heal_preserves_committed_generation_during_old_data_cleanup() {
use rustfs_heal_contracts::heal_channel::{HealOpts, HealScanMode};
for (disk_count, parity, read_repair) in [(4, 2, false), (4, 2, true), (16, 4, false), (16, 4, true)] {
let (dirs, disks, set) = super::hermetic_set_disks_support::hermetic_set_disks_for_pool_with_default_parity_isolated(
disk_count, 0, parity,
)
.await;
let bucket = "native-heal-commit-cleanup";
let object = format!("generation-{disk_count}-{parity}-{read_repair}.bin");
for disk in &disks {
disk.make_volume(bucket).await.expect("create actual fixture volume");
}
let options = ObjectOptions {
write_completion: crate::object_api::WriteCompletion::TailDrained,
shard_integrity_write_mode: Some(crate::object_api::ShardIntegrityWriteMode::Protected),
..Default::default()
};
set.put_object(bucket, &object, &mut PutObjReader::from_vec(vec![0x17; TEST_OBJECT_SIZE]), &options)
.await
.expect("acknowledge original generation on every disk");
let old = disks[0]
.read_version("", bucket, &object, "", &ReadOptions::default())
.await
.expect("read actual original generation");
let old_dir = old.data_dir.expect("original external data directory");
let old_part = dirs[0]
.path()
.join(bucket)
.join(&object)
.join(old_dir.to_string())
.join("part.1");
assert!(tokio::fs::try_exists(&old_part).await.expect("original part exists"));
// Cleanup slots follow the writer's shuffled erasure distribution,
// not the physical fixture order. Pause every real cleanup slot so
// no physical old shard can disappear before the proof below.
let barriers: Vec<_> = (0..disk_count)
.map(|slot| rename_fanout_barrier::arm(&object, slot, rename_fanout_barrier::PHASE_CLEANUP))
.collect();
let writer_set = Arc::clone(&set);
let writer_object = object.clone();
let writer_options = options.clone();
let payload = vec![0xa3; TEST_OBJECT_SIZE];
let writer_payload = payload.clone();
let writer = tokio::spawn(async move {
writer_set
.put_object(bucket, &writer_object, &mut PutObjReader::from_vec(writer_payload), &writer_options)
.await
});
tokio::time::timeout(Duration::from_secs(30), async {
for barrier in &barriers {
barrier.wait_until_paused().await;
}
})
.await
.expect("overwrite commits before its real old-data cleanup pause");
assert!(!writer.is_finished(), "tail-drained ACK must remain pending at cleanup");
assert!(
tokio::fs::try_exists(&old_part)
.await
.expect("old source still present at pause")
);
let mut committed = Vec::new();
let mut healthy_metadata = Vec::new();
for disk in &disks {
let info = disk
.read_version("", bucket, &object, "", &ReadOptions::default())
.await
.expect("read independently committed physical generation without GET");
assert_eq!((info.erasure.data_blocks, info.erasure.parity_blocks), (disk_count - parity, parity));
assert!(info.data.is_none(), "use actual external parts");
assert_ne!(info.data_dir, Some(old_dir));
committed.push(info);
healthy_metadata.push(
disk.read_all(bucket, &format!("{object}/xl.meta"))
.await
.expect("committed raw metadata"),
);
}
let new_dir = committed[0].data_dir.expect("new committed data directory");
assert!(committed.iter().all(|info| info.data_dir == Some(new_dir)));
let new_part = dirs[0]
.path()
.join(bucket)
.join(&object)
.join(new_dir.to_string())
.join("part.1");
let expected_part = tokio::fs::read(&new_part).await.expect("exact new source bytes");
let mut committed_parts = Vec::new();
for dir in &dirs {
let original = dir.path().join(bucket).join(&object).join(old_dir.to_string()).join("part.1");
assert!(
tokio::fs::try_exists(original)
.await
.expect("every old cleanup slot is paused")
);
committed_parts.push(
tokio::fs::read(dir.path().join(bucket).join(&object).join(new_dir.to_string()).join("part.1"))
.await
.expect("every new encoded shard before fault"),
);
}
tokio::fs::remove_file(&new_part)
.await
.expect("remove only the newly committed target shard");
println!(
"native_g01: geometry={disk_count}/{parity} read_repair={read_repair} old_cleanup_paused new_generation_committed target_part_missing"
);
let (result, error) = tokio::time::timeout(
Duration::from_secs(30),
set.heal_object(
bucket,
&object,
"",
&HealOpts {
scan_mode: HealScanMode::Deep,
read_repair,
..Default::default()
},
),
)
.await
.expect("real locked Heal must progress during old cleanup")
.expect("complete actual deep Heal");
assert!(error.is_none(), "new generation must remain repairable: {error:?}");
assert_eq!(result.drives_healed(), Some(1));
// Shared data-phase repair deliberately does not certify full integrity.
assert_eq!(result.integrity_verified, !read_repair);
assert_eq!(tokio::fs::read(&new_part).await.expect("rebuilt part before any GET"), expected_part);
let repaired = disks[0]
.read_version("", bucket, &object, "", &ReadOptions::default())
.await
.expect("read actual repaired generation");
assert_eq!(repaired.data_dir, Some(new_dir));
assert_eq!(repaired.mod_time, committed[0].mod_time);
for (disk, expected) in disks.iter().zip(&healthy_metadata).skip(1) {
assert_eq!(
disk.read_all(bucket, &format!("{object}/xl.meta"))
.await
.expect("healthy raw metadata after Heal"),
*expected
);
}
println!(
"native_g01: geometry={disk_count}/{parity} read_repair={read_repair} locked_heal_completed new_generation_part_rebuilt_before_get"
);
for barrier in &barriers {
barrier.release();
}
let acknowledged = tokio::time::timeout(Duration::from_secs(30), writer)
.await
.expect("cleanup drains")
.expect("writer joins")
.expect("overwrite ACK remains successful");
assert_eq!(acknowledged.size, i64::try_from(payload.len()).expect("fixture size fits i64"));
drop(barriers);
for (slot, dir) in dirs.iter().enumerate() {
let root = dir.path().join(bucket).join(&object);
assert!(
!tokio::fs::try_exists(root.join(old_dir.to_string()))
.await
.expect("old cleanup completed")
);
assert_eq!(
tokio::fs::read(root.join(new_dir.to_string()).join("part.1"))
.await
.expect("new shard survives cleanup before GET"),
committed_parts[slot],
"old cleanup must preserve every exact committed encoded shard"
);
}
let mut reader = set
.get_object_reader(bucket, &object, None, HeaderMap::new(), &options)
.await
.expect("read final acknowledged generation");
let mut body = Vec::new();
reader
.stream
.read_to_end(&mut body)
.await
.expect("read complete final payload");
assert_eq!(body, payload);
for disk in &disks {
let final_info = disk
.read_version("", bucket, &object, "", &ReadOptions::default())
.await
.expect("final physical generation");
assert_eq!(final_info.data_dir, Some(new_dir), "old cleanup must not revert the healed generation");
}
println!(
"native_g01: geometry={disk_count}/{parity} read_repair={read_repair} overwrite_ack_preserved final_physical_generation_and_full_body_verified"
);
}
}
#[tokio::test]
async fn committed_put_releases_namespace_lock_before_old_data_cleanup() {
let (_temp_dirs, disk_stores, set_disks) = hermetic_set_disks(4).await;
@@ -25358,6 +25809,7 @@ mod delete_objects_lock_gating_tests {
}
#[tokio::test]
#[serial_test::serial]
async fn delete_object_aborts_when_outer_lock_is_lost_at_commit() {
let (_temp_dirs, disk_stores, set_disks) = hermetic_set_disks(4).await;
let bucket = "single-lost-outer-lock";
@@ -25578,6 +26030,13 @@ mod delete_objects_lock_gating_tests {
.as_fast_lock_manager()
.expect("isolated fixture should use the local lock manager");
let lock_metrics = lock_manager.get_metrics().shard_metrics;
let raw_key = ObjectKey::new(bucket, "obj-a");
let raw_holder = lock_manager.get_lock_info(&raw_key).expect("outer holder must be visible");
let transaction_key = ObjectKey::with_version(
bucket,
"obj-a",
format!("rustfs:delete-transaction:{}:{}", set_disks.pool_index, set_disks.set_index),
);
assert_eq!(lock_manager.current_lock_counts(), (1, 0));
let mut delete = Box::pin(set_disks.delete_objects(
bucket,
@@ -25594,23 +26053,43 @@ mod delete_objects_lock_gating_tests {
// than a second on CI without waiting for the namespace lock at all.
let deletion = futures::future::poll_fn(|cx| {
let result = std::future::Future::poll(delete.as_mut(), cx);
let current_holder = lock_manager.get_lock_info(&raw_key).expect("outer holder must remain held");
assert_eq!(
(current_holder.mode, &current_holder.owner, current_holder.acquired_at),
(raw_holder.mode, &raw_holder.owner, raw_holder.acquired_at),
"no_lock must preserve the original raw object holder"
);
let transaction_holder = lock_manager.get_lock_info(&transaction_key);
if let Some(holder) = &transaction_holder {
assert_eq!(holder.mode, rustfs_lock::LockMode::Exclusive);
}
assert_eq!(
lock_manager.current_lock_counts(),
(1, 0),
"no_lock batch delete must preserve the outer holder without adding lock waiters"
(1 + u64::from(transaction_holder.is_some()), 0),
"only the distinct transaction owner may be added, without raw-key waiters"
);
let current = lock_manager.get_metrics().shard_metrics;
assert_eq!(
(current.total_acquisitions(), current.timeouts),
(lock_metrics.total_acquisitions(), lock_metrics.timeouts),
"no_lock batch delete must not acquire or time out on another namespace lock"
assert!(
current.total_acquisitions() <= lock_metrics.total_acquisitions() + 1,
"the transaction owner must be reused by inline recovery"
);
assert_eq!(current.timeouts, lock_metrics.timeouts, "the raw key must never be reacquired");
result
});
let (deleted, errs) = tokio::time::timeout(Duration::from_secs(30), deletion)
.await
.expect("no_lock batch delete disk work should complete");
assert_eq!(lock_manager.current_lock_counts(), (1, 0));
assert!(
lock_manager.get_lock_info(&transaction_key).is_none(),
"the transaction owner must be released"
);
assert_eq!(
lock_manager.get_metrics().shard_metrics.total_acquisitions(),
lock_metrics.total_acquisitions() + 1,
"no_lock still requires exactly one distinct transaction owner"
);
assert!(errs[0].is_none(), "no_lock batch delete should not fail with a lock error: {:?}", errs[0]);
assert!(deleted[0].found, "existing object must still be deleted");
for disk in &disk_stores {
+521 -8
View File
@@ -278,6 +278,10 @@ const MAX_DANGLING_LATEST_VERSIONS: usize = 8;
#[derive(Clone, Copy, PartialEq, Eq)]
enum DanglingRead {
KeepQuorumError,
/// Metadata updates of the latest version keep the quorum error but, like
/// object reads, look past newer versions proven dangling
/// (`read_latest_past_dangling_versions`).
SkipDanglingVersions,
ReportNotFound,
}
@@ -729,9 +733,10 @@ impl SetDisks {
}
/// Like `get_object_fileinfo`, but `allow_early_stop=false` forces the full
/// quorum fanout. Read-before-write callers (object tagging) must use this:
/// the returned online-disk set is the write target, and the early-stop
/// subset would fail write quorum (backlog#872 regression fix).
/// quorum fanout. Read-before-write callers must use this or
/// `get_object_fileinfo_for_metadata_update`: the returned online-disk set
/// is the write target, and the early-stop subset would fail write quorum
/// (backlog#872 regression fix).
#[tracing::instrument(level = "debug", skip(self))]
pub(in crate::set_disk) async fn get_object_fileinfo_gated(
&self,
@@ -753,6 +758,64 @@ impl SetDisks {
.await
}
/// The full-fanout read behind a metadata update such as object tagging.
/// A latest read whose newest versions never committed resolves to the
/// version a latest GET serves, while a dangling key keeps the quorum
/// error. The returned online disks are the write target.
#[tracing::instrument(level = "debug", skip(self))]
pub(in crate::set_disk) async fn get_object_fileinfo_for_metadata_update(
&self,
bucket: &str,
object: &str,
opts: &ObjectOptions,
) -> Result<GetObjectFileInfo> {
self.get_object_fileinfo_gated_inner(
bucket,
object,
opts,
false,
false,
MetadataReadPurpose::FullLayout,
DanglingRead::SkipDanglingVersions,
)
.await
}
/// Recovers a failed latest selection in read-before-write callers that
/// read and select metadata themselves (`put_object_metadata` and the
/// CopyObject source) the way a latest GET does. `None` keeps the caller's
/// error: a failure other than read quorum, an explicit version, the
/// system bucket's commit-proof rule, or a walk that finds no readable
/// version or cannot read every drive. The selected metadata keeps its
/// part checksums because a version-only copy carries them into the new
/// version.
pub(in crate::set_disk) async fn select_latest_past_dangling_versions(
&self,
bucket: &str,
object: &str,
opts: &ObjectOptions,
read_data: bool,
disks: &[Option<DiskStore>],
err: &DiskError,
) -> Option<GetObjectFileInfo> {
if *err != DiskError::ErasureReadQuorum || opts.version_id.is_some() || crate::bucket::utils::is_meta_bucketname(bucket) {
return None;
}
let opts = ObjectOptions {
include_part_checksums: true,
..opts.clone()
};
Box::pin(self.read_latest_past_dangling_versions(
bucket,
object,
&opts,
read_data,
MetadataReadPurpose::FullLayout,
disks,
))
.await
}
#[allow(clippy::too_many_arguments)]
async fn get_object_fileinfo_gated_inner(
&self,
@@ -978,7 +1041,7 @@ impl SetDisks {
Err(err) => {
if err == DiskError::ErasureReadQuorum
&& vid.is_empty()
&& dangling_read == DanglingRead::ReportNotFound
&& dangling_read != DanglingRead::KeepQuorumError
&& metadata_fanout_complete
&& !crate::bucket::utils::is_meta_bucketname(bucket)
&& let Some(snapshot) =
@@ -1755,7 +1818,10 @@ impl SetDisks {
"Create bitrot reader failed read quorum"
);
record_get_object_pipeline_failure(GET_STAGE_READER_SETUP, reason);
return Err(to_object_err(read_err.into(), vec![bucket, object]));
// The metadata selected this object, but the part cannot
// be reconstructed. A dominant disk I/O/not-found error
// must not erase the object's read-quorum failure.
return Err(to_object_err(DiskError::ErasureReadQuorum.into(), vec![bucket, object]));
}
let reason = GetObjectFailureReason::ReadQuorum;
error!(
@@ -1780,7 +1846,7 @@ impl SetDisks {
"Create bitrot reader did not have enough disks"
);
record_get_object_pipeline_failure(GET_STAGE_READER_SETUP, reason);
return Err(Error::other(format!("not enough disks to read: {:?}", reader_setup.errors)));
return Err(to_object_err(DiskError::ErasureReadQuorum.into(), vec![bucket, object]));
}
// Check if we have missing shards even though we can read successfully
@@ -2315,10 +2381,10 @@ impl SetDisks {
if let Some(read_err) = reduce_read_quorum_errs(&reader_setup.errors, OBJECT_OP_IGNORED_ERRS, erasure.data_shards) {
let reason = classify_disk_error(&read_err);
record_get_object_pipeline_failure_for_path(metrics_path, GET_STAGE_READER_SETUP, reason);
return Err(to_object_err(read_err.into(), vec![bucket, object]));
return Err(to_object_err(DiskError::ErasureReadQuorum.into(), vec![bucket, object]));
}
record_get_object_pipeline_failure_for_path(metrics_path, GET_STAGE_READER_SETUP, GetObjectFailureReason::ReadQuorum);
return Err(Error::other(format!("not enough disks to read: {:?}", reader_setup.errors)));
return Err(to_object_err(DiskError::ErasureReadQuorum.into(), vec![bucket, object]));
}
let missing_shards = reader_setup.completed_failed_shards();
@@ -8129,6 +8195,105 @@ mod tests {
(data_dir, files)
}
#[tokio::test]
#[serial_test::serial]
async fn external_missing_shards_keep_typed_read_quorum() {
use tokio::io::AsyncReadExt;
let payload = vec![0x37; 256 * 1024];
for readable_shards in [1, 2, 4] {
let erasure = coding::Erasure::new(4, 2, 1024 * 1024);
let mut fi = codec_streaming_test_fileinfo(i64::try_from(payload.len()).expect("fixture size fits i64"), 1);
fi.erasure.data_blocks = erasure.data_shards;
fi.erasure.parity_blocks = erasure.parity_shards;
fi.erasure.block_size = erasure.block_size;
fi.erasure.distribution = (1..=erasure.total_shard_count()).collect();
let (dirs, disks) = local_test_disks(erasure.total_shard_count(), CODEC_STREAMING_TEST_BUCKET).await;
let (data_dir, files) = codec_streaming_external_files(&erasure, &payload, &disks).await;
fi.data_dir = Some(data_dir);
for directory in dirs.iter().skip(readable_shards) {
tokio::fs::remove_file(
directory
.path()
.join(CODEC_STREAMING_TEST_BUCKET)
.join(CODEC_STREAMING_TEST_OBJECT)
.join(data_dir.to_string())
.join("part.1"),
)
.await
.expect("remove real part while retaining its selected metadata");
}
let mut output = Vec::new();
let legacy = SetDisks::get_object_with_fileinfo(
CODEC_STREAMING_TEST_BUCKET,
CODEC_STREAMING_TEST_OBJECT,
Arc::new(ErasureCache::new()),
0,
fi.size,
&mut output,
fi.clone(),
files.clone(),
&disks,
0,
0,
false,
true,
false,
false,
GET_OBJECT_PATH_LEGACY_DUPLEX,
"plain_single_part",
"le_1mib",
)
.await;
let streaming = SetDisks::get_object_mid_size_reader_with_fileinfo(
CODEC_STREAMING_TEST_BUCKET,
CODEC_STREAMING_TEST_OBJECT,
Arc::new(ErasureCache::new()),
&fi,
&files,
&disks,
0,
0,
false,
true,
"plain_single_part",
"le_1mib",
false,
)
.await;
if readable_shards < erasure.data_shards {
for error in [
legacy.expect_err("legacy read must reject quorum minus one"),
streaming.err().expect("streaming setup must reject quorum minus one"),
] {
assert_eq!(
error,
Error::InsufficientReadQuorum(
CODEC_STREAMING_TEST_BUCKET.to_owned(),
CODEC_STREAMING_TEST_OBJECT.to_owned(),
)
);
}
assert!(output.is_empty(), "quorum rejection must precede all response bytes");
} else {
legacy.expect("legacy reader retains reconstructable quorum");
assert_eq!(output, payload, "legacy read returns every original byte at quorum");
let GetCodecStreamingReaderBuildOutcome::Reader(mut reader) =
streaming.expect("streaming quorum remains readable")
else {
panic!("mid-size quorum must yield an actual reader");
};
let mut decoded = Vec::new();
reader
.read_to_end(&mut decoded)
.await
.expect("streaming reader returns the complete body");
assert_eq!(decoded, payload, "streaming read returns every original byte at quorum");
}
}
}
#[test]
#[serial]
fn payload_read_repair_is_suppressed_only_for_read_only_requests() {
@@ -9930,6 +10095,7 @@ mod dangling_read_tests {
use crate::storage_api_contracts::bucket::{BucketOperations, MakeBucketOptions};
use crate::storage_api_contracts::object::ObjectOperations;
use http::HeaderMap;
use std::collections::HashMap;
use std::path::PathBuf;
use tempfile::TempDir;
use tokio::io::AsyncReadExt;
@@ -10451,6 +10617,353 @@ mod dangling_read_tests {
assert_ne!(latest.version_id, Some(v2));
}
/// Version one on every drive under a version two that a failed PUT left
/// on drives `0..3`, below the read quorum of four.
async fn put_committed_below_dangling(set: &SetDisks, bucket: &str, object: &str) -> (Uuid, Uuid) {
let v1 = put(set, bucket, object, b"version one", true)
.await
.version_id
.expect("versioned PUT should assign a version id");
let v2 = put(set, bucket, object, b"version two", true)
.await
.version_id
.expect("versioned PUT should assign a version id");
drop_version_from(set, bucket, object, v2, 3..DRIVES).await;
(v1, v2)
}
/// Each drive's copy of `version_id`, or `None` where the drive lacks it.
async fn version_metadata(
set: &SetDisks,
bucket: &str,
object: &str,
version_id: Uuid,
) -> Vec<Option<HashMap<String, String>>> {
let disks = set.disks.read().await.clone();
let mut copies = Vec::with_capacity(disks.len());
for disk in &disks {
let disk = disk.as_ref().expect("every drive should be online");
copies.push(
disk.read_version("", bucket, object, &version_id.to_string(), &ReadOptions::default())
.await
.ok()
.map(|fi| fi.metadata),
);
}
copies
}
#[tokio::test]
async fn latest_tagging_skips_dangling_versions_above_a_committed_version() {
let bucket = "dangling-latest-tagging";
let object = "torn/latest";
let (_dirs, set) = dangling_test_set(bucket).await;
let (v1, v2) = put_committed_below_dangling(&set, bucket, object).await;
let tag_of = |metadata: &Option<HashMap<String, String>>| {
metadata
.as_ref()
.and_then(|metadata| metadata.get(rustfs_utils::http::headers::AMZ_OBJECT_TAGGING).cloned())
};
let tagged = set
.put_object_tags(bucket, object, "stage=committed", &ObjectOptions::default())
.await
.expect("PutObjectTagging must update the version a latest GET serves");
assert_eq!(tagged.version_id, Some(v1));
for (disk, metadata) in version_metadata(&set, bucket, object, v1).await.iter().enumerate() {
assert_eq!(
tag_of(metadata).as_deref(),
Some("stage=committed"),
"drive {disk} must carry the tag on version one"
);
}
for (disk, metadata) in version_metadata(&set, bucket, object, v2).await.iter().enumerate() {
assert_eq!(tag_of(metadata), None, "drive {disk} must leave the dangling version untouched");
}
assert_eq!(*head(&set, bucket, object, None).await.expect("latest HEAD").user_tags, "stage=committed");
let untagged = set
.delete_object_tags(bucket, object, &ObjectOptions::default())
.await
.expect("DeleteObjectTagging must update the version a latest GET serves");
assert_eq!(untagged.version_id, Some(v1));
for (disk, metadata) in version_metadata(&set, bucket, object, v1).await.iter().enumerate() {
assert_eq!(tag_of(metadata).as_deref(), Some(""), "drive {disk} must clear the tag on version one");
}
}
#[tokio::test]
async fn latest_metadata_update_skips_dangling_versions_above_a_committed_version() {
let bucket = "dangling-latest-metadata";
let object = "torn/latest";
let (_dirs, set) = dangling_test_set(bucket).await;
let (v1, v2) = put_committed_below_dangling(&set, bucket, object).await;
let updated = set
.put_object_metadata(
bucket,
object,
&ObjectOptions {
versioned: true,
eval_metadata: Some(HashMap::from([("x-amz-meta-probe".to_string(), "updated".to_string())])),
..Default::default()
},
)
.await
.expect("a latest metadata update must target the version a latest GET serves");
assert_eq!(updated.version_id, Some(v1));
assert!(updated.is_latest, "the updated version is the latest one");
for (disk, metadata) in version_metadata(&set, bucket, object, v1).await.iter().enumerate() {
let metadata = metadata.as_ref().expect("version one is on every drive");
assert_eq!(metadata.get("x-amz-meta-probe").map(String::as_str), Some("updated"), "drive {disk}");
}
for (disk, metadata) in version_metadata(&set, bucket, object, v2).await.iter().enumerate() {
assert!(
metadata
.as_ref()
.is_none_or(|metadata| !metadata.contains_key("x-amz-meta-probe")),
"drive {disk} must leave the dangling version untouched"
);
}
}
#[tokio::test]
async fn latest_copy_source_skips_dangling_versions_above_a_committed_version() {
let bucket = "dangling-latest-copy";
let (_dirs, set) = dangling_test_set(bucket).await;
// A metadata-only self-copy rewrites the latest version in place.
let object = "torn/in-place";
let (v1, _) = put_committed_below_dangling(&set, bucket, object).await;
let mut source = head(&set, bucket, object, None).await.expect("latest HEAD");
assert_eq!(source.version_id, Some(v1));
source.metadata_only = true;
source.user_defined = Arc::new(HashMap::from([("x-amz-meta-copied".to_string(), "in-place".to_string())]));
let copied = set
.copy_object(
bucket,
object,
bucket,
object,
&mut source,
&ObjectOptions::default(),
&ObjectOptions::default(),
)
.await
.expect("a CopyObject source without a version id must resolve like a latest GET");
assert_eq!(copied.version_id, Some(v1));
for (disk, metadata) in version_metadata(&set, bucket, object, v1).await.iter().enumerate() {
let metadata = metadata.as_ref().expect("version one is on every drive");
assert_eq!(metadata.get("x-amz-meta-copied").map(String::as_str), Some("in-place"), "drive {disk}");
}
// A version-only self-copy commits a new version that shares the
// served version's data.
let object = "torn/new-version";
let (_, v2) = put_committed_below_dangling(&set, bucket, object).await;
let v3 = Uuid::new_v4();
let mut source = head(&set, bucket, object, None).await.expect("latest HEAD");
source.version_only = true;
let copied = set
.copy_object(
bucket,
object,
bucket,
object,
&mut source,
&ObjectOptions {
versioned: true,
..Default::default()
},
&ObjectOptions {
version_id: Some(v3.to_string()),
versioned: true,
object_lock_config_snapshot: Some(Arc::new(crate::object_api::ObjectLockConfigSnapshot::new(
crate::bucket::metadata_sys::ObjectLockConfigState::ConfirmedAbsent,
))),
..Default::default()
},
)
.await
.expect("a version-only CopyObject source without a version id must resolve like a latest GET");
assert_eq!(copied.version_id, Some(v3));
let latest = head(&set, bucket, object, None).await.expect("latest HEAD");
assert_eq!(latest.version_id, Some(v3));
assert_eq!(get(&set, bucket, object, None).await.expect("latest GET"), b"version one");
assert!(
version_metadata(&set, bucket, object, v2).await[..3]
.iter()
.all(Option::is_some),
"the copy must not remove the dangling version; heal owns its cleanup"
);
}
#[tokio::test]
async fn latest_metadata_update_past_dangling_versions_keeps_write_quorum() {
// EC 3+3: read quorum three, write quorum four. Version one reached
// three drives, so it is readable but below write quorum; versions two
// and three are dangling on two drives each. A latest read serves
// version one, and a metadata update of it is refused before writing.
let bucket = "dangling-latest-write-quorum";
let object = "torn/latest";
let (_dirs, set) = dangling_test_set_with(bucket, 6, 3).await;
let mut versions = Vec::new();
for body in [b"version zero".as_slice(), b"version one", b"version two", b"version three"] {
versions.push(
put(&set, bucket, object, body, true)
.await
.version_id
.expect("versioned PUT should assign a version id"),
);
}
let [_, v1, v2, v3] = versions[..] else {
unreachable!("four versions were written");
};
drop_version_from(&set, bucket, object, v1, 3..6).await;
drop_version_from(&set, bucket, object, v2, 2..6).await;
drop_version_from(&set, bucket, object, v3, 0..3).await;
drop_version_from(&set, bucket, object, v3, 5..6).await;
let latest = head(&set, bucket, object, None)
.await
.expect("version one is readable at read quorum");
assert_eq!(latest.version_id, Some(v1));
let is_write_quorum_error =
|err: &StorageError| matches!(err, StorageError::ErasureWriteQuorum | StorageError::InsufficientWriteQuorum(_, _));
let Err(err) = set
.put_object_tags(bucket, object, "stage=refused", &ObjectOptions::default())
.await
else {
panic!("tagging a version below write quorum must be refused");
};
assert!(is_write_quorum_error(&err), "tagging keeps the write-quorum error: {err:?}");
let Err(err) = set
.put_object_metadata(
bucket,
object,
&ObjectOptions {
versioned: true,
eval_metadata: Some(HashMap::from([("x-amz-meta-probe".to_string(), "refused".to_string())])),
..Default::default()
},
)
.await
else {
panic!("a metadata update of a version below write quorum must be refused");
};
assert!(is_write_quorum_error(&err), "the metadata update keeps the write-quorum error: {err:?}");
let v4 = Uuid::new_v4();
let mut source = head(&set, bucket, object, None).await.expect("latest HEAD");
source.version_only = true;
let Err(err) = set
.copy_object(
bucket,
object,
bucket,
object,
&mut source,
&ObjectOptions {
versioned: true,
..Default::default()
},
&ObjectOptions {
version_id: Some(v4.to_string()),
versioned: true,
object_lock_config_snapshot: Some(Arc::new(crate::object_api::ObjectLockConfigSnapshot::new(
crate::bucket::metadata_sys::ObjectLockConfigState::ConfirmedAbsent,
))),
..Default::default()
},
)
.await
else {
panic!("a version-only copy of a version below write quorum must be refused");
};
assert!(is_write_quorum_error(&err), "the version-only copy keeps the write-quorum error: {err:?}");
assert!(
version_metadata(&set, bucket, object, v4).await.iter().all(Option::is_none),
"a refused version-only copy must not write the new version to any drive"
);
for (disk, metadata) in version_metadata(&set, bucket, object, v1).await.iter().enumerate() {
assert!(
metadata.as_ref().is_none_or(|metadata| {
!metadata.contains_key(rustfs_utils::http::headers::AMZ_OBJECT_TAGGING)
&& !metadata.contains_key("x-amz-meta-probe")
}),
"drive {disk} must not be written by a refused update"
);
}
}
#[tokio::test]
async fn replication_status_writeback_never_walks_past_dangling_versions() {
// A replication terminal CAS stays side-effect free: it keeps the
// read-quorum error instead of resolving the latest version, so it
// neither queues heal for the dangling version nor writes version one.
let bucket = "dangling-latest-replication-cas";
let object = "torn/latest";
let (_dirs, set) = dangling_test_set(bucket).await;
let (v1, _) = put_committed_below_dangling(&set, bucket, object).await;
let Err(err) = set
.put_object_metadata(
bucket,
object,
&ObjectOptions {
versioned: true,
eval_metadata: Some(HashMap::from([("x-amz-meta-probe".to_string(), "written".to_string())])),
replication_status_writeback: Some(Box::new(crate::object_api::ReplicationStatusWritebackCondition {
mode: crate::object_api::ReplicationStatusWritebackMode::ValidateOnly,
..Default::default()
})),
..Default::default()
},
)
.await
else {
panic!("a replication terminal CAS must not resolve past a dangling version");
};
assert!(is_read_quorum_error(&err), "the CAS keeps the read-quorum error: {err:?}");
for (disk, metadata) in version_metadata(&set, bucket, object, v1).await.iter().enumerate() {
let metadata = metadata.as_ref().expect("version one is on every drive");
assert!(!metadata.contains_key("x-amz-meta-probe"), "drive {disk} must not be written");
}
}
#[tokio::test]
async fn latest_metadata_updates_past_dangling_versions_require_every_drive_to_answer() {
let bucket = "dangling-latest-update-offline";
let object = "torn/latest";
let (_dirs, set) = dangling_test_set(bucket).await;
put_committed_below_dangling(&set, bucket, object).await;
let offline = set.disks.write().await[5].take().expect("drive five should start online");
let Err(err) = set
.put_object_tags(bucket, object, "stage=uncertain", &ObjectOptions::default())
.await
else {
panic!("tagging must not resolve past a version an offline drive may commit");
};
assert!(is_read_quorum_error(&err), "tagging keeps the read-quorum error: {err:?}");
let Err(err) = set
.put_object_metadata(
bucket,
object,
&ObjectOptions {
versioned: true,
eval_metadata: Some(HashMap::from([("x-amz-meta-probe".to_string(), "uncertain".to_string())])),
..Default::default()
},
)
.await
else {
panic!("a metadata update must not resolve past a version an offline drive may commit");
};
assert!(is_read_quorum_error(&err), "the metadata update keeps the read-quorum error: {err:?}");
set.disks.write().await[5] = Some(offline);
}
#[tokio::test]
async fn dangling_classification_keeps_quorum_error_for_corrupt_metadata() {
let bucket = "dangling-read-corrupt";
+204
View File
@@ -2167,6 +2167,210 @@ mod tests {
shutdown.cancel();
}
#[tokio::test]
#[serial_test::serial]
async fn bucket_incarnation_fence_survives_caller_abort_at_real_repair_commit() {
use crate::set_disk::ReadRepairPauseScope;
let (_root, store, shutdown) = multi_pool_heal_store().await;
let bucket = format!("heal-commit-owner-{}", Uuid::new_v4().simple());
let object = "history.bin";
let version = Uuid::new_v4().to_string();
store
.make_bucket(&bucket, &MakeBucketOptions::default())
.await
.expect("original bucket");
let old = store
.bucket_incarnation_id_from_disk(&bucket)
.await
.expect("original identity");
let set = store.pools[0].get_disks(0);
let mut reader = PutObjReader::from_vec(vec![0x17; 1024 * 1024]);
set.put_object(
&bucket,
object,
&mut reader,
&ObjectOptions {
versioned: true,
version_id: Some(version.clone()),
write_completion: crate::object_api::WriteCompletion::TailDrained,
..Default::default()
},
)
.await
.expect("original acknowledged version");
let missing = set.disks.read().await[0].clone().expect("repair target");
let healthy = set.disks.read().await[1].clone().expect("healthy target");
let baseline = healthy
.read_all(&bucket, &format!("{object}/xl.meta"))
.await
.expect("healthy metadata baseline");
let original = healthy
.read_version("", &bucket, object, &version, &Default::default())
.await
.expect("original version metadata");
assert!(original.data.is_none(), "the commit barrier fixture must use actual external data shards");
missing
.delete(
&bucket,
object,
DeleteOptions {
recursive: true,
immediate: true,
..Default::default()
},
)
.await
.expect("remove one physical shard");
// The scope pauses the real repair after reconstruction, immediately
// before its generation check and final shard rename.
let (_pause, arrived, release) = ReadRepairPauseScope::install_incarnation_commit(&bucket, object);
let (drained_tx, drained_rx) = tokio::sync::oneshot::channel();
let worker_store = store.clone();
let worker_bucket = bucket.clone();
let worker_version = version.clone();
let opts = HealOpts {
pool: Some(0),
set: Some(0),
scan_mode: rustfs_heal_contracts::heal_channel::HealScanMode::Deep,
..Default::default()
};
let mut waiter = tokio::spawn(async move {
worker_store
.run_bucket_heal_at_incarnation(&worker_bucket, old, &opts, move |store, bucket, opts| async move {
let result = store.heal_object_with_proof(&bucket, object, &worker_version, &opts).await?;
drained_tx
.send(result)
.expect("retain the actual storage result after caller cancellation");
Ok(())
})
.await
});
tokio::select! {
paused = tokio::time::timeout(std::time::Duration::from_secs(10), arrived.notified()) => {
paused.expect("real missing-shard repair reaches the final commit pause");
}
completed = &mut waiter => panic!("repair ended before the actual final commit barrier: {completed:?}"),
}
assert!(
missing.read_xl(&bucket, object, false).await.is_err(),
"final shard is not published at the pause"
);
println!("incarnation_commit_barrier: final_rename_paused");
waiter.abort();
assert!(waiter.await.expect_err("caller aborted").is_cancelled());
let mut writer = Box::pin(store.acquire_bucket_lifecycle_write_lock(&bucket));
assert!(
futures::poll!(&mut writer).is_pending(),
"bucket deletion/recreation cannot acquire its generation writer while the cancelled repair still owns commit"
);
println!("incarnation_commit_barrier: caller_aborted_writer_pending");
release.notify_one();
let result = tokio::time::timeout(std::time::Duration::from_secs(10), drained_rx)
.await
.expect("actual repair drains after release")
.expect("retained repair result");
assert!(
result.error.is_none(),
"physical repair must succeed before lifecycle handoff: {:?}",
result.error
);
let guard = tokio::time::timeout(std::time::Duration::from_secs(10), writer)
.await
.expect("generation writer acquires only after physical repair drains")
.expect("generation writer");
assert!(
missing.read_xl(&bucket, object, false).await.is_ok(),
"the physical shard is published before writer admission"
);
let repaired = missing
.read_version("", &bucket, object, &version, &Default::default())
.await
.expect("repaired exact version metadata");
assert_eq!(repaired.version_id, original.version_id);
assert_eq!(repaired.data_dir, original.data_dir);
let checked = missing
.check_parts(&bucket, object, &repaired)
.await
.expect("verify the published physical shard");
assert_eq!(
checked.results,
vec![crate::disk::CHECK_PART_SUCCESS],
"the actual published part passes bitrot verification"
);
assert_eq!(
healthy
.read_all(&bucket, &format!("{object}/xl.meta"))
.await
.expect("healthy after repair"),
baseline,
"repair does not overwrite the surviving version"
);
println!("incarnation_commit_barrier: repair_drained_writer_acquired");
drop(guard);
set.delete_object(
&bucket,
object,
ObjectOptions {
versioned: true,
version_id: Some(version.clone()),
..Default::default()
},
)
.await
.expect("normal explicit deletion of the old version");
store
.delete_bucket(&bucket, &DeleteBucketOptions::default())
.await
.expect("normal empty bucket deletion");
store
.make_bucket(&bucket, &MakeBucketOptions::default())
.await
.expect("same-name successor bucket");
let new = store
.bucket_incarnation_id_from_disk(&bucket)
.await
.expect("successor identity");
assert_ne!(old, new);
let successor_version = Uuid::new_v4().to_string();
let mut successor_reader = PutObjReader::from_vec(vec![0x29; 512 * 1024]);
set.put_object(
&bucket,
object,
&mut successor_reader,
&ObjectOptions {
versioned: true,
version_id: Some(successor_version),
write_completion: crate::object_api::WriteCompletion::TailDrained,
..Default::default()
},
)
.await
.expect("acknowledged successor version");
let successor = healthy
.read_all(&bucket, &format!("{object}/xl.meta"))
.await
.expect("successor metadata");
assert!(
store
.heal_object_at_incarnation(&bucket, object, &version, old, &opts)
.await
.is_err()
);
assert_eq!(
healthy
.read_all(&bucket, &format!("{object}/xl.meta"))
.await
.expect("successor after obsolete admission"),
successor,
"obsolete admission cannot change the same-name successor"
);
println!("incarnation_commit_barrier: successor_rejects_obsolete_admission");
shutdown.cancel();
}
#[tokio::test]
#[serial_test::serial]
async fn bucket_incarnation_heal_owner_survives_cancelled_waiter_and_queued_writer() {
+2
View File
@@ -452,6 +452,8 @@ pub use object::{
};
mod peer;
mod rebalance;
#[cfg(test)]
pub(crate) use rebalance::PoolSpaceForTest;
pub(crate) mod utils;
use peer::init_local_peer;
+233 -2
View File
@@ -4291,8 +4291,40 @@ impl ECStore {
.select_decommission_capacity_target_pool(owner, expected_data_bytes)
.await;
}
let holder = self
.data_movement_pool_holding_version(bucket, object, opts.version_id.as_deref(), opts.src_pool_idx)
.await;
self.get_available_pool_idx_preferring(bucket, object, size, None, holder)
.await
.ok_or(Error::DiskFull)
}
self.get_available_pool_idx(bucket, object, size).await.ok_or(Error::DiskFull)
/// The target pool that already holds a copy of the exact version being
/// moved. An attempt that lost write quorum can leave that copy on fewer
/// drives than read quorum, which the pool lookup reports as absent; a
/// retry that placed the version on another pool would leave it in two.
/// Callers still subject the holder to the free-space checks, so a full
/// holder cannot pin every retry of a deferred entry.
async fn data_movement_pool_holding_version(
&self,
bucket: &str,
object: &str,
version_id: Option<&str>,
src_pool_idx: usize,
) -> Option<usize> {
let version_id = Uuid::parse_str(version_id?).ok()?;
let holders = join_all(self.pools.iter().enumerate().map(|(pool_idx, pool)| async move {
// Match the pools the free-space choice can return.
if pool_idx == src_pool_idx || self.is_suspended(pool_idx).await || self.is_pool_rebalancing(pool_idx).await {
return None;
}
pool.get_disks_by_key(object)
.holds_version_on_any_drive(bucket, object, version_id)
.await
.then_some(pool_idx)
}))
.await;
holders.into_iter().flatten().next()
}
async fn find_data_movement_target_info(
@@ -5420,7 +5452,10 @@ impl ECStore {
if let Some(owner) = DecommissionCapacityOwner::from_options(&opts) {
self.select_decommission_capacity_target_pool(owner, 0).await?
} else {
self.get_available_pool_idx_excluding(bucket, object, 0, opts.src_pool_idx)
let holder = self
.data_movement_pool_holding_version(bucket, object, opts.version_id.as_deref(), opts.src_pool_idx)
.await;
self.get_available_pool_idx_preferring(bucket, object, 0, Some(opts.src_pool_idx), holder)
.await
.ok_or(Error::DiskFull)?
}
@@ -13132,6 +13167,202 @@ mod tests {
})
.await;
}
async fn assert_delete_excludes_recovery_across_pool_lock_domains(topology: &str, batch: bool) {
let ctx = Arc::new(crate::runtime::instance::InstanceContext::new());
let (_first_dirs, first_set) = make_local_set_disks_with_ctx(4, 2, Arc::clone(&ctx)).await;
let (_second_dirs, second_set) = make_local_set_disks_with_ctx(4, 2, Arc::clone(&ctx)).await;
let mut store = new_prepared_reader_test_store_with_ctx(&[first_set, second_set], Arc::clone(&ctx)).await;
let clients: Vec<Arc<dyn rustfs_lock::LockClient>> = (0..4)
.map(|_| {
Arc::new(rustfs_lock::LocalClient::with_manager(Arc::new(rustfs_lock::GlobalLockManager::Enabled(
Arc::new(rustfs_lock::FastObjectLockManager::new()),
)))) as Arc<dyn rustfs_lock::LockClient>
})
.collect();
let domains = match topology {
"disjoint" => [vec![clients[0].clone()], vec![clients[1].clone()]],
"identical" => [clients[..3].to_vec(), clients[..3].to_vec()],
"overlapping" => [clients[..3].to_vec(), clients[1..].to_vec()],
_ => panic!("unknown lock topology"),
};
for (pool, lockers) in store.pools.iter_mut().zip(domains) {
let mut set = (*pool.disk_set[0]).clone();
set.lockers = lockers;
Arc::make_mut(pool).disk_set[0] = Arc::new(set);
}
let store = Arc::new(store);
crate::bucket::metadata_sys::init_bucket_metadata_sys(Arc::clone(&store), Vec::new()).await;
let bucket = "delete-nonzero-pool-lock";
let object = "object.bin";
store
.handle_make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("create the bucket in both pools");
if batch {
// Latest-marker routing sends this batch only to the nonzero pool,
// so the cleanup barrier observes its prepared journal directly.
store
.update_bucket_metadata_config(
bucket,
crate::bucket::metadata::BUCKET_VERSIONING_CONFIG,
b"<VersioningConfiguration><Status>Enabled</Status></VersioningConfiguration>".to_vec(),
)
.await
.expect("enable versioning for the routed batch");
}
let target = store.pools[1].get_disks_by_key(object);
let source = target
.put_object(
bucket,
object,
&mut PutObjReader::from_vec(b"delete lock regression".to_vec()),
&ObjectOptions {
versioned: batch,
..Default::default()
},
)
.await
.expect("seed an object only in the nonzero pool");
ctx.update_erasure_type(SetupType::DistErasure).await;
let object_barrier = (!batch).then(|| crate::set_disk::DeleteObjectCommitBarrier::install(bucket, object));
let batch_barrier = batch.then(|| crate::set_disk::DeleteCleanupBarrier::install(bucket));
let delete_store = Arc::clone(&store);
let delete = tokio::spawn(async move {
if batch {
let (_, errors) = delete_store
.delete_objects(
bucket,
vec![ObjectToDelete {
object_name: object.to_owned(),
..Default::default()
}],
ObjectOptions::default(),
)
.await;
match errors.into_iter().flatten().next() {
Some(error) => Err(error),
None => Ok(()),
}
} else {
delete_store
.delete_object(bucket, object, ObjectOptions::default())
.await
.map(|_| ())
}
});
if let Some(barrier) = &object_barrier {
barrier.wait_until_paused().await;
}
if let Some(barrier) = &batch_barrier {
barrier.wait_until_paused().await;
}
let recovery_store = Arc::clone(&store);
let mut recovery = tokio::spawn(async move {
recovery_store
.recover_delete_transactions_at_incarnation(
bucket,
Some(object),
None,
&HealOpts {
pool: Some(1),
set: Some(0),
..Default::default()
},
)
.await
});
let early_recovery = tokio::time::timeout(Duration::from_millis(100), &mut recovery).await;
let recovery_was_blocked = early_recovery.is_err();
if let Some(barrier) = &object_barrier {
barrier.release();
}
if let Some(barrier) = &batch_barrier {
barrier.release();
}
tokio::time::timeout(Duration::from_secs(10), delete)
.await
.expect("the delete should finish after releasing its commit barrier")
.expect("the delete task should join")
.expect("the delete should succeed");
let recovered = match early_recovery {
Ok(result) => result,
Err(_) => tokio::time::timeout(Duration::from_secs(10), recovery)
.await
.expect("recovery should finish after the frontend delete"),
};
recovered
.expect("the recovery task should join")
.expect("recovery should succeed");
assert!(
recovery_was_blocked,
"recovery must not inspect an in-flight frontend delete with {topology} pool lock clients"
);
if batch {
let marker = target
.get_object_info(bucket, object, &ObjectOptions::default())
.await
.expect("the versioned batch must publish a current delete marker");
assert!(marker.delete_marker && marker.is_latest);
assert_eq!(marker.num_versions, 2, "the source version must remain beside the new marker");
assert_ne!(marker.version_id, source.version_id);
let mut reader = target
.get_object_reader(
bucket,
object,
None,
HeaderMap::new(),
&ObjectOptions {
version_id: Some(source.version_id.expect("versioned source identity").to_string()),
..Default::default()
},
)
.await
.expect("the original source version must remain readable");
let mut body = Vec::new();
reader
.stream
.read_to_end(&mut body)
.await
.expect("read the retained source version");
assert_eq!(body, b"delete lock regression");
} else {
let error = target
.get_object_info(bucket, object, &ObjectOptions::default())
.await
.expect_err("the deleted object must be absent");
assert!(is_err_object_not_found(&error));
}
}
#[tokio::test]
#[serial_test::serial]
#[serial_test::serial(storage_class_env)]
async fn batch_delete_excludes_recovery_on_nonzero_pool() {
assert_delete_excludes_recovery_across_pool_lock_domains("disjoint", true).await;
}
#[tokio::test]
#[serial_test::serial]
#[serial_test::serial(storage_class_env)]
async fn delete_excludes_recovery_with_disjoint_pool_lock_clients() {
assert_delete_excludes_recovery_across_pool_lock_domains("disjoint", false).await;
}
#[tokio::test]
#[serial_test::serial]
#[serial_test::serial(storage_class_env)]
async fn delete_excludes_recovery_with_identical_pool_lock_clients() {
assert_delete_excludes_recovery_across_pool_lock_domains("identical", false).await;
}
#[tokio::test]
#[serial_test::serial]
#[serial_test::serial(storage_class_env)]
async fn delete_excludes_recovery_with_overlapping_pool_lock_clients() {
assert_delete_excludes_recovery_across_pool_lock_domains("overlapping", false).await;
}
}
#[cfg(test)]
+69 -25
View File
@@ -122,6 +122,51 @@ async fn pause_prepared_pool_read_before_refetch(object: &str) {
state.refetch_release.notified().await;
}
}
/// Capacity that every online drive of one pool reports to free-space target
/// selection, per store, until the guard drops.
#[cfg(test)]
static POOL_SPACE_FOR_TEST: std::sync::Mutex<Vec<(Uuid, usize, crate::disk::DiskInfo)>> = std::sync::Mutex::new(Vec::new());
#[cfg(test)]
pub(crate) struct PoolSpaceForTest(Uuid);
#[cfg(test)]
impl PoolSpaceForTest {
pub(crate) fn install(store: Uuid, pools: &[(usize, crate::disk::DiskInfo)]) -> Self {
POOL_SPACE_FOR_TEST
.lock()
.expect("pool space override must not be poisoned")
.extend(pools.iter().map(|(pool, info)| (store, *pool, info.clone())));
Self(store)
}
}
#[cfg(test)]
impl Drop for PoolSpaceForTest {
fn drop(&mut self) {
POOL_SPACE_FOR_TEST
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.retain(|(store, _, _)| *store != self.0);
}
}
#[cfg(test)]
fn pool_space_for_test(
store: Uuid,
pool: usize,
infos: Vec<Option<crate::disk::DiskInfo>>,
) -> Vec<Option<crate::disk::DiskInfo>> {
let overrides = POOL_SPACE_FOR_TEST.lock().expect("pool space override must not be poisoned");
let Some((_, _, info)) = overrides
.iter()
.find(|(override_store, override_pool, _)| *override_store == store && *override_pool == pool)
else {
return infos;
};
infos.into_iter().map(|disk| disk.map(|_| info.clone())).collect()
}
#[cfg(test)]
use support::resolve_latest_object_info_candidates;
use support::{
@@ -438,30 +483,7 @@ impl ECStore {
}
pub(super) async fn get_available_pool_idx(&self, bucket: &str, object: &str, size: i64) -> Option<usize> {
// // Return a random one first
let mut server_pools = self.get_server_pools_available_space(bucket, object, size).await;
server_pools.filter_max_used(100 - (100_f64 * DISK_RESERVE_FRACTION) as u64);
let total = server_pools.total_available();
if total == 0 {
return None;
}
let mut rng = rand::rng();
let random_u64: u64 = rng.random_range(0..total);
let choose = random_u64 % total;
let mut at_total = 0;
for pool in server_pools.iter() {
at_total += pool.available;
if at_total > choose && pool.available > 0 {
return Some(pool.index);
}
}
None
self.get_available_pool_idx_preferring(bucket, object, size, None, None).await
}
pub(super) async fn get_available_pool_idx_excluding(
@@ -470,13 +492,33 @@ impl ECStore {
object: &str,
size: i64,
excluded_pool_idx: usize,
) -> Option<usize> {
self.get_available_pool_idx_preferring(bucket, object, size, Some(excluded_pool_idx), None)
.await
}
/// Free-space choice among the pools that can take `size` more bytes,
/// weighted by available space. `preferred_pool_idx` is returned whenever
/// it passes the same checks, so a full or excluded pool is never forced.
pub(super) async fn get_available_pool_idx_preferring(
&self,
bucket: &str,
object: &str,
size: i64,
excluded_pool_idx: Option<usize>,
preferred_pool_idx: Option<usize>,
) -> Option<usize> {
let mut server_pools = self.get_server_pools_available_space(bucket, object, size).await;
server_pools.filter_max_used(100 - (100_f64 * DISK_RESERVE_FRACTION) as u64);
if let Some(pool) = server_pools.0.get_mut(excluded_pool_idx) {
if let Some(pool) = excluded_pool_idx.and_then(|idx| server_pools.0.get_mut(idx)) {
pool.available = 0;
}
if let Some(pool_idx) = preferred_pool_idx
&& server_pools.0.get(pool_idx).is_some_and(|pool| pool.available > 0)
{
return Some(pool_idx);
}
let total = server_pools.total_available();
if total == 0 {
@@ -509,6 +551,8 @@ impl ECStore {
let disks = pool.get_disks_by_key(object).disk_inventory().await;
let disk_infos = get_disk_infos(&disks).await;
#[cfg(test)]
let disk_infos = pool_space_for_test(self.id, idx, disk_infos);
(idx, pool.set_count, disk_infos)
}))
+46
View File
@@ -2429,6 +2429,52 @@ mod test {
}
}
#[test]
fn truncated_inline_xlmeta_is_file_corrupt_only_for_supported_formats() {
let mut meta = FileMeta::new();
meta.data
.replace("fixture", vec![0x5a; 4095])
.expect("seed real inline framing");
let inline_len = meta.data.as_slice().len();
let original = meta.marshal_msg().expect("serialize valid inline metadata");
let inline_start = original.len() - inline_len;
let parsed = FileMeta::load(&original).expect("complete inline metadata must decode");
assert_eq!(parsed.data, meta.data);
for cut in inline_start + 1..original.len() {
assert_eq!(
FileMeta::load(&original[..cut]).expect_err("incomplete inline framing must fail"),
Error::FileCorrupt,
"inline truncation at byte {cut} must remain repairable"
);
}
for inline_version in [0, 2] {
let mut unsupported = original[..original.len() / 2].to_vec();
unsupported[inline_start] = inline_version;
assert_ne!(
FileMeta::load(&unsupported).expect_err("malformed unsupported inline format must fail"),
Error::FileCorrupt,
"unknown inline version must not authorize repair"
);
}
for (header_ver, meta_ver) in [
(XL_HEADER_VERSION + 1, XL_META_VERSION),
(XL_HEADER_VERSION, XL_META_VERSION + 1),
] {
let mut headers = Vec::new();
rmp::encode::write_uint(&mut headers, u64::from(header_ver)).expect("write future header");
rmp::encode::write_uint(&mut headers, u64::from(meta_ver)).expect("write future metadata version");
rmp::encode::write_uint(&mut headers, 0).expect("write empty version count");
let mut unsupported = build_xl_buffer(&headers);
unsupported.extend_from_slice(&meta.data.as_slice()[..inline_len / 2]);
assert_ne!(
FileMeta::load(&unsupported).expect_err("unsupported metadata with truncated inline data must fail"),
Error::FileCorrupt,
"future metadata schema must not authorize repair"
);
}
}
#[test]
fn truncated_xlmeta_index_and_format_reads_are_file_corrupt() {
let buf = FileMeta::default()
+7 -1
View File
@@ -188,7 +188,13 @@ impl FileMeta {
if !buf.is_empty() {
InlineData::validate_slice(buf).map_err(|e| {
error!("data validation failed: {}", e);
e
// Validation only examines bytes already read. Classify damage
// as repairable corruption only for formats we understand.
if InlineData::version_ok_slice(buf) && Self::decode_xl_headers(meta).is_ok() {
Error::FileCorrupt
} else {
e
}
})?;
}
+4 -4
View File
@@ -168,11 +168,11 @@ impl InlineData {
self.0.as_slice()
}
pub fn version_ok(&self) -> bool {
if self.0.is_empty() {
return true;
}
Self::version_ok_slice(&self.0)
}
self.0[0] > 0 && self.0[0] <= INLINE_DATA_VER
pub(crate) fn version_ok_slice(raw: &[u8]) -> bool {
raw.first().is_none_or(|version| *version > 0 && *version <= INLINE_DATA_VER)
}
pub fn after_version(&self) -> &[u8] {
+37 -1
View File
@@ -16,7 +16,7 @@ use rustfs_io_metrics::internode_metrics::INTERNODE_OPERATION_PUT_FILE_STREAM;
use rustfs_rio::{InternodeHttpError, InternodeHttpErrorKind};
use thiserror::Error;
use super::heal::{DiskError, EcstoreError};
use super::heal::{DiskError, EcstoreError, storage_api::owner::EcstorePoolMetadataFailure};
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 ";
@@ -236,6 +236,14 @@ pub(crate) fn classify_heal_error(error: &Error) -> HealErrorClass {
/// with object absence, the conflation that once let an offline drive be
/// recorded as absent and skipped (backlog#856 / #799 B7).
pub(crate) fn classify_storage_error(error: &EcstoreError) -> HealErrorClass {
if let Some(failure) = error.pool_metadata_failure() {
return match failure.kind {
EcstorePoolMetadataFailure::ReadUnavailable | EcstorePoolMetadataFailure::TransactionUnknown => {
HealErrorClass::Transient
}
EcstorePoolMetadataFailure::RecoveryRequired | EcstorePoolMetadataFailure::FenceLost => HealErrorClass::Permanent,
};
}
if let EcstoreError::Lock(lock_error) = error {
return if lock_error.is_fatal() {
HealErrorClass::Permanent
@@ -435,6 +443,34 @@ mod tests {
use super::Error;
use crate::heal::{DiskError, EcstoreError};
#[test]
fn pool_metadata_failures_keep_their_typed_recovery_classification() {
use crate::heal::storage_api::owner::{EcstorePoolMetadataError, EcstorePoolMetadataFailure};
for (kind, retryable) in [
(EcstorePoolMetadataFailure::ReadUnavailable, true),
(EcstorePoolMetadataFailure::TransactionUnknown, true),
(EcstorePoolMetadataFailure::RecoveryRequired, false),
(EcstorePoolMetadataFailure::FenceLost, false),
] {
let error = EcstoreError::other(EcstorePoolMetadataError {
kind,
operation: "bucket heal".to_owned(),
phase: "native_heal",
since: std::time::UNIX_EPOCH.into(),
source: Some(std::sync::Arc::new(EcstoreError::SlowDown)),
});
let text = error.to_string();
for error in [error.clone(), error] {
assert_eq!(Error::Storage(error).is_recoverable_heal(), retryable, "{kind:?}");
}
assert!(
!Error::Storage(EcstoreError::other(text)).is_recoverable_heal(),
"display text must not grant recovery authority"
);
}
}
#[test]
fn namespace_lock_quorum_is_recoverable_across_io_error_conversions() {
for (required, achieved) in [(2, 1), (1, 0)] {
+27 -17
View File
@@ -196,6 +196,20 @@ impl HealChannelProcessor {
.map_err(Error::other)
}
/// Query a local automatic task; other tokens require coordinator routing.
pub async fn execute_local_automatic_query(
&self,
heal_path: &str,
client_token: &str,
since_seq: Option<u64>,
) -> Result<Option<HealChannelResponse>> {
self.heal_manager
.get_local_automatic_task_report(heal_path, client_token, since_seq)
.await?
.map(|report| Self::query_report_response(client_token.to_owned(), Ok(report)))
.transpose()
}
/// Execute cancellation directly against the manager.
pub async fn execute_cancel_request(&self, heal_path: String, client_token: String) -> Result<HealChannelResponse> {
let (response_tx, response_rx) = oneshot::channel();
@@ -463,6 +477,13 @@ impl HealChannelProcessor {
.await
};
let response = Self::query_report_response(client_token, report)?;
let _ = response_tx.send(Ok(response.clone()));
self.publish_response(response);
Ok(())
}
fn query_report_response(client_token: String, report: Result<HealTaskReport>) -> Result<HealChannelResponse> {
let outcome = report.as_ref().ok().and_then(|report| report.outcome.clone());
let settings = report
.as_ref()
@@ -581,27 +602,21 @@ impl HealChannelProcessor {
0,
),
Err(crate::Error::InvalidClientToken) => {
let response = HealChannelResponse {
return Ok(HealChannelResponse {
request_id: client_token,
success: false,
data: None,
error: Some("invalid heal client token".to_string()),
};
let _ = response_tx.send(Ok(response.clone()));
self.publish_response(response);
return Ok(());
});
}
Err(err) => {
let error_text = err.to_string();
let response = HealChannelResponse {
return Ok(HealChannelResponse {
request_id: client_token,
success: false,
data: None,
error: Some(error_text),
};
let _ = response_tx.send(Ok(response.clone()));
self.publish_response(response);
return Ok(());
});
}
};
@@ -618,17 +633,12 @@ impl HealChannelProcessor {
},
)?;
let response = HealChannelResponse {
Ok(HealChannelResponse {
request_id: client_token,
success: true,
data: Some(data),
error: detail,
};
let _ = response_tx.send(Ok(response.clone()));
self.publish_response(response);
Ok(())
})
}
/// Process cancel request
+77 -29
View File
@@ -234,6 +234,8 @@ fn record_displaced_terminal(
request: &HealRequest,
) -> Arc<CompletedHealStatus> {
let terminal = Arc::new(CompletedHealStatus {
task_id: request.id.clone(),
source: request.source,
outcome: None,
progress: None,
retained_bytes: std::sync::OnceLock::new(),
@@ -669,7 +671,10 @@ fn retry_budget_for_result(task: &HealTask, result: &Result<()>, retryable_batch
if task.retry_attempts >= MAX_RECOVERABLE_HEAL_RETRIES {
return None;
}
if task.has_batch_failure() && !retryable_batch_failure {
// Admin traversals already exhaust their per-object retries. Restarting
// the whole traversal under the same token discards those receipts and
// reports previously repaired objects as unchanged on the next scan.
if task.has_batch_failure() && (task.source == HealRequestSource::Admin || !retryable_batch_failure) {
return None;
}
@@ -1071,9 +1076,9 @@ pub struct HealManager {
/// cascade without re-locking.
enum TaskStateLookup {
Active(Arc<HealTask>),
Retrying(HealTaskStatus, HealOptions),
Retrying(HealTaskStatus, HealOptions, HealRequestSource),
Completed(Arc<CompletedHealStatus>),
Queued(HealOptions),
Queued(HealOptions, HealRequestSource),
/// A durable root heal record that cannot be admitted or quarantined;
/// it answers status queries with the reason until an operator repairs it.
Unrunnable(String),
@@ -2586,6 +2591,23 @@ impl HealManager {
/// completed entry. `heal_path` additionally constrains the map matches
/// the way the `*_for_path` variants always have.
async fn lookup_task_state(&self, canonical_task_id: &str, heal_path: Option<&str>) -> Result<TaskStateLookup> {
let state = self.lookup_memory_task_state(canonical_task_id, heal_path).await;
if !matches!(state, TaskStateLookup::NotFound) {
return Ok(state);
}
if let Some(completed) = self.root_recovery.completed(canonical_task_id).await?
&& heal_path.is_none_or(|path| heal_type_matches_path(&completed.heal_type, path))
{
return Ok(TaskStateLookup::Completed(Arc::new(completed)));
}
// A durable record that cannot run still answers with its reason.
if let Some(reason) = self.root_recovery.record_issue(canonical_task_id).await {
return Ok(TaskStateLookup::Unrunnable(reason));
}
Ok(TaskStateLookup::NotFound)
}
async fn lookup_memory_task_state(&self, canonical_task_id: &str, heal_path: Option<&str>) -> TaskStateLookup {
let matches_path = |heal_type: &HealType| heal_path.is_none_or(|path| heal_type_matches_path(heal_type, path));
{
@@ -2594,7 +2616,7 @@ impl HealManager {
.get(canonical_task_id)
.filter(|task| matches_path(&task.heal_type))
{
return Ok(TaskStateLookup::Active(Arc::clone(task)));
return TaskStateLookup::Active(Arc::clone(task));
}
}
@@ -2604,7 +2626,7 @@ impl HealManager {
.get(canonical_task_id)
.filter(|retrying| matches_path(&retrying.request.heal_type))
{
return Ok(TaskStateLookup::Retrying(retrying.status(), retrying.request.options.clone()));
return TaskStateLookup::Retrying(retrying.status(), retrying.request.options.clone(), retrying.request.source);
}
}
@@ -2617,7 +2639,7 @@ impl HealManager {
prune_completed_heal_statuses(&mut completed_heals);
if let Some(completed) = completed_heals.get(canonical_task_id).filter(|c| matches_path(&c.heal_type)) {
if completed_status_is_retrying(&completed.status) {
return Ok(TaskStateLookup::Completed(Arc::clone(completed)));
return TaskStateLookup::Completed(Arc::clone(completed));
}
terminal_completed = Some(Arc::clone(completed));
}
@@ -2626,7 +2648,7 @@ impl HealManager {
{
let queue = self.heal_queue.lock().await;
if let Some(request) = queue.request_matching_id_and_path(canonical_task_id, heal_path) {
return Ok(TaskStateLookup::Queued(request.options.clone()));
return TaskStateLookup::Queued(request.options.clone(), request.source);
}
}
@@ -2639,22 +2661,10 @@ impl HealManager {
.cloned();
}
if terminal_completed.is_none()
&& let Some(completed) = self.root_recovery.completed(canonical_task_id).await?
&& matches_path(&completed.heal_type)
{
terminal_completed = Some(Arc::new(completed));
}
if let Some(completed) = terminal_completed {
return Ok(TaskStateLookup::Completed(completed));
return TaskStateLookup::Completed(completed);
}
// A durable root record that can neither run nor be quarantined still
// answers status queries with its reason.
if let Some(reason) = self.root_recovery.record_issue(canonical_task_id).await {
return Ok(TaskStateLookup::Unrunnable(reason));
}
Ok(TaskStateLookup::NotFound)
TaskStateLookup::NotFound
}
async fn publish_admin_terminal(
@@ -2678,6 +2688,8 @@ impl HealManager {
) -> Result<bool> {
let previous = self.completed_heals.lock().await.get(task_id).cloned();
let mut completed = CompletedHealStatus {
task_id: task_id.to_owned(),
source,
outcome: None,
progress: None,
retained_bytes: std::sync::OnceLock::new(),
@@ -2713,9 +2725,9 @@ impl HealManager {
let canonical_task_id = self.canonical_task_id(task_id).await;
match self.lookup_task_state(&canonical_task_id, None).await? {
TaskStateLookup::Active(task) => Ok(task.get_status().await),
TaskStateLookup::Retrying(status, _) => Ok(status),
TaskStateLookup::Retrying(status, _, _) => Ok(status),
TaskStateLookup::Completed(completed) => Ok(completed.status.clone()),
TaskStateLookup::Queued(_) => Ok(HealTaskStatus::Pending),
TaskStateLookup::Queued(_, _) => Ok(HealTaskStatus::Pending),
TaskStateLookup::Unrunnable(error) => Ok(HealTaskStatus::Failed { error }),
TaskStateLookup::NotFound => Err(Error::TaskNotFound {
task_id: task_id.to_string(),
@@ -2734,9 +2746,9 @@ impl HealManager {
let canonical_task_id = self.canonical_task_id(task_id).await;
match self.lookup_task_state(&canonical_task_id, None).await? {
TaskStateLookup::Active(task) => Ok(active_task_report(&task, since).await),
TaskStateLookup::Retrying(status, options) => Ok(empty_task_report_with_options(status, options)),
TaskStateLookup::Retrying(status, options, _) => Ok(empty_task_report_with_options(status, options)),
TaskStateLookup::Completed(completed) => Ok(completed_task_report(&completed, since)),
TaskStateLookup::Queued(options) => Ok(empty_task_report_with_options(HealTaskStatus::Pending, options)),
TaskStateLookup::Queued(options, _) => Ok(empty_task_report_with_options(HealTaskStatus::Pending, options)),
TaskStateLookup::Unrunnable(error) => {
Ok(empty_task_report_with_options(HealTaskStatus::Failed { error }, HealOptions::default()))
}
@@ -2750,6 +2762,42 @@ impl HealManager {
self.get_task_report_for_path_since(heal_path, task_id, None).await
}
/// Resolve a node-owned automatic task without granting local control over
/// Admin tasks. The source and report come from the same state snapshot.
pub async fn get_local_automatic_task_report(
&self,
heal_path: &str,
task_id: &str,
since: Option<u64>,
) -> Result<Option<HealTaskReport>> {
let canonical_task_id = self.canonical_task_id(task_id).await;
if canonical_task_id != task_id {
return Ok(None);
}
let state = self.lookup_memory_task_state(&canonical_task_id, Some(heal_path)).await;
let source = match &state {
TaskStateLookup::Active(task) => task.source,
TaskStateLookup::Retrying(_, _, source) | TaskStateLookup::Queued(_, source) => *source,
TaskStateLookup::Completed(completed) if completed.task_id == task_id => completed.source,
TaskStateLookup::Completed(_) => return Ok(None),
TaskStateLookup::Unrunnable(_) | TaskStateLookup::NotFound => return Ok(None),
};
if !matches!(
source,
HealRequestSource::Scanner | HealRequestSource::Mrf | HealRequestSource::AutoHeal | HealRequestSource::ReadRepair
) {
return Ok(None);
}
let report = match state {
TaskStateLookup::Active(task) => active_task_report(&task, since).await,
TaskStateLookup::Retrying(status, options, _) => empty_task_report_with_options(status, options),
TaskStateLookup::Completed(completed) => completed_task_report(&completed, since),
TaskStateLookup::Queued(options, _) => empty_task_report_with_options(HealTaskStatus::Pending, options),
TaskStateLookup::Unrunnable(_) | TaskStateLookup::NotFound => return Ok(None),
};
Ok(Some(report))
}
/// Incremental variant of [`Self::get_task_report_for_path`] (HS-06).
pub async fn get_task_report_for_path_since(
&self,
@@ -2760,9 +2808,9 @@ impl HealManager {
let canonical_task_id = self.canonical_task_id(task_id).await;
match self.lookup_task_state(&canonical_task_id, Some(heal_path)).await? {
TaskStateLookup::Active(task) => Ok(active_task_report(&task, since).await),
TaskStateLookup::Retrying(status, options) => Ok(empty_task_report_with_options(status, options)),
TaskStateLookup::Retrying(status, options, _) => Ok(empty_task_report_with_options(status, options)),
TaskStateLookup::Completed(completed) => Ok(completed_task_report(&completed, since)),
TaskStateLookup::Queued(options) => Ok(empty_task_report_with_options(HealTaskStatus::Pending, options)),
TaskStateLookup::Queued(options, _) => Ok(empty_task_report_with_options(HealTaskStatus::Pending, options)),
TaskStateLookup::Unrunnable(error) => {
Ok(empty_task_report_with_options(HealTaskStatus::Failed { error }, HealOptions::default()))
}
@@ -2786,9 +2834,9 @@ impl HealManager {
let canonical_task_id = self.canonical_task_id(task_id).await;
match self.lookup_task_state(&canonical_task_id, Some(heal_path)).await? {
TaskStateLookup::Active(task) => Ok(task.get_status().await),
TaskStateLookup::Retrying(status, _) => Ok(status),
TaskStateLookup::Retrying(status, _, _) => Ok(status),
TaskStateLookup::Completed(completed) => Ok(completed.status.clone()),
TaskStateLookup::Queued(_) => Ok(HealTaskStatus::Pending),
TaskStateLookup::Queued(_, _) => Ok(HealTaskStatus::Pending),
TaskStateLookup::Unrunnable(error) => Ok(HealTaskStatus::Failed { error }),
TaskStateLookup::NotFound => {
if self.path_has_task(heal_path).await {
+5
View File
@@ -109,7 +109,9 @@ pub(super) enum QueuePushOutcome {
#[derive(Debug, Clone)]
pub(super) struct CompletedHealStatus {
pub(super) task_id: String,
pub(super) heal_type: HealType,
pub(super) source: HealRequestSource,
/// Options used to execute the task, retained for token-scoped status.
pub(super) options: HealOptions,
pub(super) status: HealTaskStatus,
@@ -137,6 +139,7 @@ impl CompletedHealStatus {
fn measure_retained_bytes(&self) -> usize {
let mut bytes = size_of::<Self>();
let mut add = |amount: usize| bytes = bytes.saturating_add(amount);
add(self.task_id.capacity());
add(self.outcome.as_ref().map_or(0, |outcome| outcome.retained_bytes()));
match &self.heal_type {
HealType::Cluster => {}
@@ -258,7 +261,9 @@ impl CompletedHealStatus {
outcome.finish(Some(crate::heal::outcome::HealAbortReason::Cancelled));
}
let mut snapshot = Self {
task_id: task.id.clone(),
heal_type: task.heal_type.clone(),
source: task.source,
options: task.options.clone(),
status,
progress: Some(task.get_progress().await),
@@ -460,6 +460,8 @@ impl RootHealTerminal {
fn into_completed(self) -> CompletedHealStatus {
CompletedHealStatus {
task_id: self.task_id,
source: HealRequestSource::Admin,
outcome: None,
progress: self.progress,
retained_bytes: std::sync::OnceLock::new(),
+217
View File
@@ -118,6 +118,8 @@ struct MockStorage;
fn completed_retention_fixture(completed_at: SystemTime) -> CompletedHealStatus {
CompletedHealStatus {
task_id: "fixture-token".to_string(),
source: HealRequestSource::Internal,
outcome: None,
heal_type: HealType::Cluster,
options: HealOptions::default(),
@@ -3071,6 +3073,181 @@ async fn test_active_duplicate_token_can_query_and_cancel_original_task() {
);
}
#[tokio::test]
async fn local_automatic_query_preserves_source_path_token_and_state() {
use crate::heal::channel::HealChannelProcessor;
use crate::heal::outcome::{
HealExecutionOutcome, HealObjectDisposition, HealObjectIdentity, HealObjectKind, HealObjectOutcome, HealTaskOutcome,
HealTraversalCoverage,
};
for source in [
HealRequestSource::Scanner,
HealRequestSource::Mrf,
HealRequestSource::AutoHeal,
HealRequestSource::ReadRepair,
HealRequestSource::Admin,
HealRequestSource::Internal,
] {
for state in ["queued", "active", "retrying", "completed", "displaced"] {
let storage: Arc<dyn HealStorageAPI> = Arc::new(MockStorage);
let manager = Arc::new(HealManager::new_without_root_recovery_for_test(storage.clone(), None));
let mut request = HealRequest::object("bucket".to_string(), "object".to_string(), None);
request.source = source;
let token = request.id.clone();
let task = Arc::new(HealTask::from_request(request.clone(), storage));
let mut snapshot = CompletedHealStatus::snapshot(&task, HealTaskStatus::Completed).await;
assert_eq!(snapshot.source, source, "completion must retain actual task source");
let mut outcome = HealTaskOutcome::default();
outcome.execution = HealExecutionOutcome::Completed;
outcome.coverage = HealTraversalCoverage::Complete;
outcome.record(HealObjectOutcome {
identity: HealObjectIdentity {
kind: HealObjectKind::Object,
bucket: "bucket".to_string(),
object: "object".to_string(),
version_id: None,
bucket_incarnation_id: Some(uuid::Uuid::nil()),
pool_index: Some(0),
set_index: Some(0),
},
disposition: HealObjectDisposition::VerifiedHealthy,
detail: None,
});
snapshot.outcome = Some(Arc::new(outcome));
snapshot.seqed_items = vec![(
3,
HealResultItem {
bucket: "bucket".to_string(),
object: "object".to_string(),
..Default::default()
},
)];
snapshot.min_seq = 3;
snapshot.next_seq = 4;
let expected_status = match state {
"queued" => {
manager.heal_queue.lock().await.push(request.clone());
HealTaskStatus::Pending
}
"active" => {
manager.active_heals.lock().await.insert(token.clone(), task);
HealTaskStatus::Pending
}
"retrying" => {
manager.retrying_heals.lock().await.insert(
token.clone(),
RetryingHeal {
request: request.clone(),
error: "transient".to_string(),
cancel_token: CancellationToken::new(),
},
);
HealTaskStatus::Retrying {
error: "transient".to_string(),
retry_attempt: request.retry_attempts,
}
}
"completed" => {
manager.completed_heals.lock().await.insert(token.clone(), Arc::new(snapshot));
HealTaskStatus::Completed
}
"displaced" => {
let terminal = record_displaced_terminal(&manager.displaced_terminals, &request);
terminal.status.clone()
}
_ => unreachable!("fixed test states"),
};
let automatic = !matches!(source, HealRequestSource::Admin | HealRequestSource::Internal);
let report = manager
.get_local_automatic_task_report("bucket/object", &token, Some(2))
.await
.expect("local source lookup");
assert_eq!(report.is_some(), automatic, "source={source:?} state={state}");
if let Some(report) = report {
assert_eq!(report.status, expected_status);
if state == "completed" {
let outcome = report.outcome.as_ref().expect("retained structured outcome");
assert_eq!(outcome.objects.len(), 1);
assert_eq!(outcome.objects[0].identity.object, "object");
assert_eq!(outcome.objects[0].identity.pool_index, Some(0));
assert_eq!(outcome.objects[0].identity.set_index, Some(0));
assert_eq!(outcome.objects[0].disposition, HealObjectDisposition::VerifiedHealthy);
assert_eq!(report.min_seq, 3);
assert_eq!(report.next_seq, 4);
assert_eq!(report.result_items.len(), 1);
assert_eq!(report.result_items[0].object, "object");
}
let processor = HealChannelProcessor::new(manager.clone());
let local = processor
.execute_local_automatic_query("bucket/object", &token, Some(2))
.await
.expect("local response")
.expect("automatic task response");
let direct = processor
.execute_query_request_since("bucket/object".to_string(), token.clone(), Some(2))
.await
.expect("original query response");
assert!(local.success);
assert_eq!(local.request_id, direct.request_id);
assert_eq!(local.data, direct.data, "same report encoding and cursor contract");
assert_eq!(local.error, direct.error);
let coordinator = Arc::new(HealManager::new_without_root_recovery_for_test(Arc::new(MockStorage), None));
let missing = HealChannelProcessor::new(coordinator)
.execute_query_request("bucket/object".to_string(), token.clone())
.await
.expect("coordinator query");
let body: serde_json::Value =
serde_json::from_slice(missing.data.as_deref().expect("coordinator response")).expect("coordinator JSON");
assert_eq!(body["summary"], "notFound", "a remote manager does not own the automatic task");
if state == "completed" {
let completed = manager
.completed_heals
.lock()
.await
.get(&token)
.cloned()
.expect("original completion");
manager
.completed_heals
.lock()
.await
.insert("retired-alias".to_string(), completed);
assert!(
manager
.get_local_automatic_task_report("bucket/object", "retired-alias", None)
.await
.expect("retired alias lookup")
.is_none()
);
}
}
assert!(
manager
.get_local_automatic_task_report("bucket/other", &token, None)
.await
.expect("wrong path lookup")
.is_none()
);
assert!(
manager
.get_local_automatic_task_report("bucket/object", "unknown", None)
.await
.expect("unknown token lookup")
.is_none()
);
manager.insert_task_alias("admin-alias", &token).await;
assert!(
manager
.get_local_automatic_task_report("bucket/object", "admin-alias", None)
.await
.expect("alias lookup")
.is_none()
);
}
}
}
#[tokio::test]
async fn test_queued_duplicate_token_can_query_and_cancel_original_request() {
let storage: Arc<dyn HealStorageAPI> = Arc::new(MockStorage);
@@ -3886,6 +4063,38 @@ async fn test_retry_request_rescans_batch_when_all_exhausted_objects_are_retryab
}
}
#[tokio::test]
async fn admin_exhausted_batches_do_not_restart_the_traversal() {
for heal_type in [
HealType::Bucket {
bucket: "bucket".to_owned(),
},
HealType::Prefix {
bucket: "bucket".to_owned(),
prefix: "prefix/".to_owned(),
},
HealType::Cluster,
] {
let mut request = HealRequest::new(heal_type, HealOptions::default(), HealPriority::Normal);
request.source = HealRequestSource::Admin;
let task = HealTask::from_request(request, Arc::new(MockStorage));
let result = Err(task
.record_batch_failure(BatchHealFailure {
scope: "completed-admin-traversal".to_owned(),
failed: 1,
retryable: 1,
permanent: 0,
first_object: "exhausted-object".to_owned(),
first_error: Error::Storage(EcstoreError::SlowDown).to_string(),
})
.await);
assert!(
retry_request_for_result_with_budget(&task, &result).await.is_none(),
"the exhausted admin batch must retain its original object receipts"
);
}
}
#[tokio::test]
async fn test_retry_request_does_not_rescan_cancelled_or_timed_out_retryable_batch() {
for terminal_error in [Error::TaskCancelled, Error::TaskTimeout] {
@@ -3968,6 +4177,8 @@ async fn insert_retrying_request(manager: &HealManager, request: HealRequest) ->
Arc::new(CompletedHealStatus {
progress: None,
outcome: None,
source: request.source,
task_id: request.id.clone(),
retained_bytes: std::sync::OnceLock::new(),
heal_type: request.heal_type,
options: request.options.clone(),
@@ -4816,6 +5027,8 @@ async fn test_retrying_completion_outranks_the_queue_for_the_same_id() {
Arc::new(CompletedHealStatus {
progress: None,
outcome: None,
source: request.source,
task_id: request.id.clone(),
retained_bytes: std::sync::OnceLock::new(),
heal_type: request.heal_type.clone(),
options: request.options.clone(),
@@ -4855,6 +5068,8 @@ async fn test_get_task_status_reads_recent_completed_status() {
Arc::new(CompletedHealStatus {
progress: None,
outcome: None,
source: HealRequestSource::Internal,
task_id: "completed-token".to_string(),
retained_bytes: std::sync::OnceLock::new(),
heal_type: HealType::Bucket {
bucket: "bucket".to_string(),
@@ -4888,6 +5103,8 @@ async fn test_get_task_report_for_path_reads_completed_items() {
Arc::new(CompletedHealStatus {
progress: None,
outcome: None,
source: HealRequestSource::Internal,
task_id: "completed-token".to_string(),
retained_bytes: std::sync::OnceLock::new(),
heal_type: HealType::Object {
bucket: "bucket".to_string(),
@@ -117,6 +117,8 @@ fn admin_request(heal_type: HealType) -> HealRequest {
fn completed_admin_status(heal_type: &HealType, completed_at: SystemTime) -> CompletedHealStatus {
CompletedHealStatus {
task_id: "admin-fixture".to_string(),
source: HealRequestSource::Admin,
outcome: None,
heal_type: heal_type.clone(),
options: HealOptions::default(),
@@ -1239,6 +1241,8 @@ async fn root_recovery_completed_non_root_admin_is_queryable_after_restart() {
.expect("durable bucket responsibility");
let completed = CompletedHealStatus {
outcome: None,
source: request.source,
task_id: request.id.clone(),
heal_type: request.heal_type.clone(),
options: request.options.clone(),
status: HealTaskStatus::Completed,
+8 -1
View File
@@ -26,7 +26,11 @@ pub(crate) use rustfs_ecstore::api::disk::{
RUSTFS_META_BUCKET as ECSTORE_RUSTFS_META_BUCKET, ReplacementExecutionLease as EcstoreReplacementExecutionLease,
};
pub(crate) use rustfs_ecstore::api::disk::{DiskOption as EcstoreDiskOption, new_disk as ecstore_new_disk};
pub(crate) use rustfs_ecstore::api::error::{Error as EcstoreErrorType, StorageError as EcstoreStorageError};
#[cfg(test)]
pub(crate) use rustfs_ecstore::api::error::PoolMetadataError as EcstorePoolMetadataError;
pub(crate) use rustfs_ecstore::api::error::{
Error as EcstoreErrorType, PoolMetadataFailure as EcstorePoolMetadataFailure, StorageError as EcstoreStorageError,
};
#[cfg(test)]
pub(crate) use rustfs_ecstore::api::layout::{
EndpointServerPools as EcstoreEndpointServerPools, Endpoints as EcstoreEndpoints, PoolEndpoints as EcstorePoolEndpoints,
@@ -53,6 +57,9 @@ pub(crate) use rustfs_ecstore::api::storage::{
use rustfs_storage_api as storage_contracts;
pub(crate) mod owner {
#[cfg(test)]
pub(crate) use super::EcstorePoolMetadataError;
pub(crate) use super::EcstorePoolMetadataFailure;
pub(crate) use super::storage_contracts::{ObjectIO, ObjectOperations};
pub(crate) use super::{
+21 -2
View File
@@ -37,7 +37,7 @@ use std::{
collections::VecDeque,
future::Future,
sync::{
Arc,
Arc, OnceLock,
atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering},
},
time::{Duration, Instant, SystemTime},
@@ -59,6 +59,19 @@ pub(crate) struct OutcomeFinishTestHook {
pub(crate) static OUTCOME_FINISH_TEST_HOOK: std::sync::LazyLock<tokio::sync::Mutex<Option<Arc<OutcomeFinishTestHook>>>> =
std::sync::LazyLock::new(|| tokio::sync::Mutex::new(None));
#[cfg(test)]
static STORAGE_RECEIPT_TEST_HOOK: std::sync::LazyLock<tokio::sync::Mutex<Option<Arc<OutcomeFinishTestHook>>>> =
std::sync::LazyLock::new(|| tokio::sync::Mutex::new(None));
#[cfg(test)]
async fn pause_storage_receipt(task_id: &str) {
let hook = STORAGE_RECEIPT_TEST_HOOK.lock().await.clone();
if let Some(hook) = hook.filter(|hook| hook.task_id == task_id) {
hook.reached.notify_one();
hook.release.notified().await;
}
}
#[cfg(test)]
async fn pause_outcome_finish(task_id: &str) {
let hook = OUTCOME_FINISH_TEST_HOOK.lock().await.clone();
@@ -436,6 +449,9 @@ pub struct HealTask {
replacement_start_retry_count: Arc<AtomicU32>,
/// Set when the locked storage read classified the single target as absent.
object_absent: Arc<AtomicBool>,
/// The first resolved incarnation belongs to this task even if storage
/// fails or the bucket is recreated before its terminal outcome is recorded.
single_object_incarnation: Arc<OnceLock<Option<Uuid>>>,
/// Task status
pub status: Arc<RwLock<HealTaskStatus>>,
/// Progress tracking
@@ -508,6 +524,7 @@ impl HealTask {
replacement_running: Arc::new(AtomicBool::new(false)),
replacement_start_retry_count: Arc::new(AtomicU32::new(0)),
object_absent: Arc::new(AtomicBool::new(false)),
single_object_incarnation: Arc::new(OnceLock::new()),
status: Arc::new(RwLock::new(HealTaskStatus::Pending)),
progress: Arc::new(RwLock::new(HealProgress::new())),
result_items: Arc::new(RwLock::new(VecDeque::with_capacity(MAX_RETAINED_HEAL_RESULT_ITEMS))),
@@ -669,7 +686,9 @@ impl HealTask {
} => (bucket, object, Some(version_id.as_str())),
_ => return None,
};
Some(self.outcome_identity(bucket, object, version, self.options.pool_index, self.options.set_index))
let mut identity = self.outcome_identity(bucket, object, version, self.options.pool_index, self.options.set_index);
identity.bucket_incarnation_id = self.single_object_incarnation.get().copied().flatten();
Some(identity)
}
pub(super) async fn record_verified_storage_receipt(
+23 -21
View File
@@ -306,9 +306,9 @@ impl HealTask {
let mut progress = self.progress.write().await;
progress.update_stage(3, 3);
}
Err(Error::TaskExecutionFailed {
message: format!("Failed to heal bucket {bucket}: {e}"),
})
// Keep storage/quorum identity for the cluster retry classifier.
// The structured log above already carries the bucket context.
Err(e)
}
}
}
@@ -520,9 +520,7 @@ impl HealTask {
error = %err,
"Heal cluster pool metadata failed"
);
Err(Error::TaskExecutionFailed {
message: format!("Failed to heal cluster pool metadata: {err}"),
})
Err(err)
}
Err(Error::TaskCancelled) => Err(Error::TaskCancelled),
Err(Error::TaskTimeout) => Err(Error::TaskTimeout),
@@ -539,9 +537,7 @@ impl HealTask {
error = %err,
"Heal cluster pool metadata failed"
);
Err(Error::TaskExecutionFailed {
message: format!("Failed to heal cluster pool metadata: {err}"),
})
Err(err)
}
}
}
@@ -796,21 +792,27 @@ impl HealTask {
match unavailable_recreate_error(&storage_result.item, &heal_opts) {
Some(error) => Some(error),
None => {
telemetry_unknown |= !increment_counter(&mut healed);
telemetry_unknown |= !add_bytes(
&mut bytes,
u64::try_from(storage_result.item.object_size).unwrap_or(u64::MAX),
);
#[cfg(test)]
super::pause_storage_receipt(&self.id).await;
recorded_authoritative_outcome =
self.record_verified_storage_receipt(&identity, storage_result.receipt).await;
if !recorded_authoritative_outcome
&& !heal_opts.dry_run
&& !storage_result.item.detail.is_empty()
{
detail = Some(storage_result.item.detail.clone());
if !recorded_authoritative_outcome && self.cancel_token.is_cancelled() {
Some(Error::TaskCancelled)
} else {
telemetry_unknown |= !increment_counter(&mut healed);
telemetry_unknown |= !add_bytes(
&mut bytes,
u64::try_from(storage_result.item.object_size).unwrap_or(u64::MAX),
);
if !recorded_authoritative_outcome
&& !heal_opts.dry_run
&& !storage_result.item.detail.is_empty()
{
detail = Some(storage_result.item.detail.clone());
}
self.record_result_item(storage_result.item).await;
None
}
self.record_result_item(storage_result.item).await;
None
}
}
}
+5
View File
@@ -220,6 +220,11 @@ impl HealTask {
let mut expected_identity =
self.outcome_identity(bucket, object, version_id, self.options.pool_index, self.options.set_index);
expected_identity.bucket_incarnation_id = self.outcome_bucket_incarnation_id(bucket, self.options.dry_run).await?;
if matches!(self.heal_type, HealType::Object { .. }) {
expected_identity.bucket_incarnation_id = *self
.single_object_incarnation
.get_or_init(|| expected_identity.bucket_incarnation_id);
}
let heal_fut = self.storage.heal_object_with_receipt(bucket, object, version_id, &heal_opts);
let heal_result = if self.source == HealRequestSource::ReadRepair {
+400 -8
View File
@@ -16,6 +16,7 @@ use super::super::{DiskOption, DiskStore, Endpoint, new_disk};
use super::*;
use crate::heal::EcstoreError;
use crate::heal::storage::HealStorageObjectResult;
use crate::heal::storage_api::owner::{EcstorePoolMetadataError, EcstorePoolMetadataFailure};
mod absent_verdict;
mod concurrent_delete;
@@ -305,6 +306,53 @@ mod canonical_outcome {
assert_eq!(storage.healed_objects.lock().expect("healed objects").len(), 2);
}
#[tokio::test]
async fn bucket_cancellation_after_storage_success_keeps_typed_cancelled_responsibility() {
let incarnation = Uuid::new_v4();
let storage = Arc::new(MockStorage {
heal_object_receipts: Mutex::new(HashMap::from([(
"object-a".to_string(),
VecDeque::from([object_receipt("object-a", None, HealObjectDisposition::Repaired, incarnation)]),
)])),
bucket_incarnation_id: Mutex::new(Some(incarnation)),
..Default::default()
});
let task = Arc::new(bucket_task(storage.clone()));
let hook = Arc::new(OutcomeFinishTestHook {
task_id: task.id.clone(),
reached: tokio::sync::Notify::new(),
release: tokio::sync::Notify::new(),
});
*STORAGE_RECEIPT_TEST_HOOK.lock().await = Some(hook.clone());
let runner = {
let task = task.clone();
tokio::spawn(async move { task.execute().await })
};
tokio::time::timeout(Duration::from_secs(10), hook.reached.notified())
.await
.expect("storage success must reach the receipt boundary");
assert_eq!(storage.healed_objects.lock().expect("storage mutations").as_slice(), ["object-a"]);
task.cancel().await.expect("cancel before receipt recording");
hook.release.notify_one();
let result = tokio::time::timeout(Duration::from_secs(10), runner)
.await
.expect("cancelled worker must stop")
.expect("worker must not panic");
STORAGE_RECEIPT_TEST_HOOK.lock().await.take();
assert!(matches!(result, Err(Error::TaskCancelled)));
let outcome = task.get_outcome().await;
assert_eq!(outcome.execution, HealExecutionOutcome::Aborted(HealAbortReason::Cancelled));
assert_eq!(outcome.coverage, HealTraversalCoverage::Partial);
assert_eq!(outcome.counters.processed, 1);
assert_eq!(outcome.counters.healed, 0);
assert_eq!(outcome.counters.unchanged, 0);
assert_eq!(outcome.counters.unknown, 0);
assert_eq!(outcome.counters.skipped, 1);
assert_eq!(outcome.objects.len(), 1);
assert_eq!(outcome.objects[0].identity.object, "object-a");
assert_eq!(outcome.objects[0].disposition, HealObjectDisposition::Cancelled);
}
#[tokio::test]
async fn bucket_heal_rejects_stale_receipts_without_double_recording() {
let expected_incarnation = Uuid::new_v4();
@@ -784,7 +832,10 @@ mod canonical_outcome {
async fn untraversable_bucket_does_not_claim_complete_cluster_coverage() {
let storage = Arc::new(MockStorage {
listed_buckets: Mutex::new(Some(vec!["bucket-a".to_string(), "bucket-b".to_string()])),
bucket_heal_errors: Mutex::new(HashMap::from([("bucket-a".to_string(), VecDeque::from(["metadata unavailable"]))])),
bucket_heal_errors: Mutex::new(HashMap::from([(
"bucket-a".to_string(),
VecDeque::from([Error::other("metadata unavailable")]),
)])),
..Default::default()
});
let task = HealTask::from_request(
@@ -809,6 +860,85 @@ mod canonical_outcome {
);
}
#[tokio::test(start_paused = true)]
async fn cluster_bucket_write_quorum_retries_preserve_budget_and_truthful_coverage() {
for (failures, succeeds) in [(1, true), (4, false)] {
let storage = Arc::new(MockStorage {
listed_buckets: Mutex::new(Some(vec!["bucket-a".to_string(), "bucket-b".to_string()])),
typed_bucket_heal_errors: Mutex::new(HashMap::from([(
"bucket-a".to_string(),
(0..failures)
.map(|_| Error::Storage(EcstoreError::ErasureWriteQuorum))
.collect(),
)])),
..Default::default()
});
let task = HealTask::from_request(
HealRequest::new(
HealType::Cluster,
HealOptions {
recursive: true,
timeout: None,
..Default::default()
},
HealPriority::Normal,
),
storage.clone(),
);
let result = task.execute().await;
assert_eq!(
result.is_ok(),
succeeds,
"original cluster task must honor its bounded bucket retry budget"
);
let outcome = task.get_outcome().await;
if succeeds {
assert_eq!(outcome.execution, HealExecutionOutcome::Completed);
assert_eq!(outcome.coverage, HealTraversalCoverage::Complete);
} else {
assert_eq!(outcome.execution, HealExecutionOutcome::Aborted(HealAbortReason::Untraversable));
assert_eq!(outcome.coverage, HealTraversalCoverage::Partial);
let failure = task.take_batch_failure().await.expect("exhausted bucket retains its failure");
assert_eq!((failure.failed, failure.retryable, failure.permanent), (1, 1, 0));
}
let calls = storage.bucket_heal_calls.lock().expect("original bucket calls");
assert_eq!(
calls.iter().filter(|bucket| bucket.as_str() == "bucket-a").count(),
if succeeds { 2 } else { 4 }
);
assert_eq!(calls.last().map(String::as_str), Some("bucket-b"), "later buckets must still be visited");
}
}
#[tokio::test]
async fn bucket_prepass_preserves_typed_quorum_and_terminal_access_denial() {
for error in [
Error::Storage(EcstoreError::ErasureWriteQuorum),
Error::Disk(DiskError::FileAccessDenied),
] {
let retryable = error.is_recoverable_heal();
let storage = Arc::new(MockStorage {
typed_bucket_heal_errors: Mutex::new(HashMap::from([("bucket-a".to_string(), VecDeque::from([error]))])),
..Default::default()
});
let task = bucket_task(storage.clone());
let observed = task
.heal_bucket("bucket-a")
.await
.expect_err("bucket prepass must retain the storage error");
assert!(matches!(
observed,
Error::Storage(EcstoreError::ErasureWriteQuorum) | Error::Disk(DiskError::FileAccessDenied)
));
assert_eq!(observed.is_recoverable_heal(), retryable);
assert_eq!(storage.bucket_heal_calls.lock().expect("bucket calls").as_slice(), ["bucket-a"]);
assert!(
storage.heal_object_calls.lock().expect("object calls").is_empty(),
"failed prepass must not heal objects"
);
}
}
#[tokio::test(start_paused = true)]
async fn cancellation_and_deadline_leave_partial_coverage() {
for cancel in [false, true] {
@@ -1566,7 +1696,8 @@ struct MockStorage {
include_object_dir_candidate: Mutex<bool>,
listed_buckets: Mutex<Option<Vec<String>>>,
bucket_discovery_incomplete: bool,
bucket_heal_errors: Mutex<HashMap<String, VecDeque<&'static str>>>,
bucket_heal_errors: Mutex<HashMap<String, VecDeque<Error>>>,
typed_bucket_heal_errors: Mutex<HashMap<String, VecDeque<Error>>>,
bucket_heal_calls: Mutex<Vec<String>>,
delete_recovery_results: Mutex<HashMap<String, Result<Option<HealResultItem>>>>,
delete_recovery_calls: Mutex<Vec<String>>,
@@ -1815,6 +1946,127 @@ async fn failed_object_heal_rejects_matching_positive_storage_receipt() {
| HealObjectDisposition::AuthoritativelyAbsent
)
}));
assert_eq!(outcome.objects.len(), 1);
assert_eq!(outcome.objects[0].identity.bucket_incarnation_id, Some(incarnation));
}
#[tokio::test]
async fn object_failure_retains_incarnation_for_inner_and_outer_storage_errors() {
for failure in [
MockHealObjectOutcome::PermissionDenied,
MockHealObjectOutcome::RetryableReadQuorum,
MockHealObjectOutcome::RetryableLockTimeout,
MockHealObjectOutcome::ErrOther("backend failed"),
] {
let incarnation = Uuid::new_v4();
let storage = Arc::new(MockStorage {
bucket_incarnation_id: Mutex::new(Some(incarnation)),
heal_object_outcome: Mutex::new(Some(failure)),
..Default::default()
});
let mut request = HealRequest::object("bucket-a".to_owned(), "object-a".to_owned(), Some("version-a".to_owned()));
request.options.pool_index = Some(2);
request.options.set_index = Some(3);
let task = HealTask::from_request(request, storage);
task.execute()
.await
.expect_err("the failed storage operation must remain failed");
let outcome = task.get_outcome().await;
assert_eq!(outcome.objects.len(), 1);
assert_eq!(
(outcome.counters.processed, outcome.counters.failed, outcome.counters.attempt_failures),
(1, 1, 1)
);
assert_eq!((outcome.counters.healed, outcome.counters.unchanged, outcome.counters.unknown), (0, 0, 0));
let object = &outcome.objects[0];
assert_eq!(object.identity.bucket_incarnation_id, Some(incarnation));
assert_eq!(object.identity.bucket, "bucket-a");
assert_eq!(object.identity.object, "object-a");
assert_eq!(object.identity.version_id.as_deref(), Some("version-a"));
assert_eq!((object.identity.pool_index, object.identity.set_index), (Some(2), Some(3)));
assert!(matches!(object.disposition, HealObjectDisposition::Failed(_)));
}
}
#[tokio::test(start_paused = true)]
async fn object_abort_keeps_pre_storage_incarnation_after_bucket_recreation() {
for cancel in [false, true] {
let original = Uuid::new_v4();
let storage = Arc::new(MockStorage {
bucket_incarnation_id: Mutex::new(Some(original)),
block_heal_object: Mutex::new(true),
..Default::default()
});
let mut request = HealRequest::object("bucket-a".to_owned(), "object-a".to_owned(), None);
request.options.timeout = Some(Duration::from_secs(10));
let task = HealTask::from_request(request, storage.clone());
let execution = task.execute();
tokio::pin!(execution);
assert!(futures::poll!(&mut execution).is_pending());
assert_eq!(storage.heal_object_calls.lock().expect("storage boundary").as_slice(), ["object-a"]);
*storage.bucket_incarnation_id.lock().expect("recreated bucket") = Some(Uuid::new_v4());
if cancel {
task.cancel().await.expect("cancel blocked storage");
} else {
tokio::time::advance(Duration::from_secs(11)).await;
}
let result = execution.await;
assert!(matches!(
(&result, cancel),
(Err(Error::TaskCancelled), true) | (Err(Error::TaskTimeout), false)
));
let outcome = task.get_outcome().await;
assert_eq!(outcome.objects.len(), 1);
assert_eq!(outcome.objects[0].identity.bucket_incarnation_id, Some(original));
assert_eq!((outcome.counters.processed, outcome.counters.skipped), (1, 1));
assert_eq!((outcome.counters.failed, outcome.counters.healed, outcome.counters.unknown), (0, 0, 0));
assert_eq!(
outcome.objects[0].disposition,
if cancel {
HealObjectDisposition::Cancelled
} else {
HealObjectDisposition::Deferred {
reason: HealDeferredReason::Deadline,
retry_not_before: None,
}
}
);
}
}
#[tokio::test]
async fn object_unverified_outcome_keeps_original_incarnation_without_claiming_repair() {
let original = Uuid::new_v4();
let successor = Uuid::new_v4();
let storage = Arc::new(MockStorage {
bucket_incarnation_id: Mutex::new(Some(original)),
bucket_incarnation_after_object_heal: Mutex::new(Some(successor)),
..Default::default()
});
let task = HealTask::from_request(HealRequest::object("bucket-a".to_owned(), "object-a".to_owned(), None), storage.clone());
task.execute().await.expect("legacy storage keeps its unverified result");
let outcome = task.get_outcome().await;
assert_eq!(outcome.objects[0].identity.bucket_incarnation_id, Some(original));
assert_eq!(outcome.objects[0].disposition, HealObjectDisposition::Unknown);
assert_eq!((outcome.counters.healed, outcome.counters.unknown), (0, 1));
assert_eq!(*storage.bucket_incarnation_id.lock().expect("successor"), Some(successor));
}
#[tokio::test]
async fn object_dry_run_does_not_adopt_bucket_incarnation() {
let storage = Arc::new(MockStorage {
bucket_incarnation_id: Mutex::new(Some(Uuid::new_v4())),
..Default::default()
});
let mut request = HealRequest::object("bucket-a".to_owned(), "object-a".to_owned(), None);
request.options.dry_run = true;
let task = HealTask::from_request(request, storage);
task.execute().await.expect("dry-run observation");
let outcome = task.get_outcome().await;
assert_eq!(outcome.objects.len(), 1);
assert_eq!(outcome.objects[0].identity.bucket_incarnation_id, None);
assert_eq!(outcome.objects[0].disposition, HealObjectDisposition::DryRunObserved);
assert_eq!(outcome.counters.healed, 0);
}
#[tokio::test]
@@ -1990,6 +2242,7 @@ fn replacement_identity(
#[derive(Clone)]
enum MockHealObjectOutcome {
PoolMetadata { kind: EcstorePoolMetadataFailure, outer: bool },
LegacyAmbiguous { outer: bool },
MissingObject { outer: bool },
MissingVersion,
@@ -2009,6 +2262,16 @@ enum MockHealObjectOutcome {
PermanentOther(&'static str),
}
fn pool_metadata_error(kind: EcstorePoolMetadataFailure) -> Error {
Error::Storage(EcstoreError::other(EcstorePoolMetadataError {
kind,
operation: "bucket heal".to_owned(),
phase: "native_heal",
since: std::time::UNIX_EPOCH.into(),
source: None,
}))
}
fn unavailable_drive_heal_result(state: DriveState) -> (HealResultItem, Option<Error>) {
(
HealResultItem {
@@ -2180,6 +2443,14 @@ impl HealStorageAPI for MockStorage {
.and_then(VecDeque::pop_front)
{
return match outcome {
MockHealObjectOutcome::PoolMetadata { kind, outer } => {
let error = pool_metadata_error(kind);
if outer {
Err(error)
} else {
Ok((HealResultItem::default(), Some(error)))
}
}
MockHealObjectOutcome::LegacyAmbiguous { outer } => {
let error = Error::Storage(EcstoreError::DeleteRecoveryLegacyAmbiguous);
if outer {
@@ -2256,6 +2527,14 @@ impl HealStorageAPI for MockStorage {
}
if let Some(outcome) = self.heal_object_outcome.lock().unwrap().take() {
return match outcome {
MockHealObjectOutcome::PoolMetadata { kind, outer } => {
let error = pool_metadata_error(kind);
if outer {
Err(error)
} else {
Ok((HealResultItem::default(), Some(error)))
}
}
MockHealObjectOutcome::LegacyAmbiguous { outer } => {
let error = Error::Storage(EcstoreError::DeleteRecoveryLegacyAmbiguous);
if outer {
@@ -2387,14 +2666,23 @@ impl HealStorageAPI for MockStorage {
async fn heal_bucket(&self, bucket: &str, opts: &HealOpts) -> Result<HealResultItem> {
self.bucket_heal_calls.lock().unwrap().push(bucket.to_string());
self.bucket_heal_opts.lock().unwrap().push(*opts);
if let Some(message) = self
if let Some(error) = self
.typed_bucket_heal_errors
.lock()
.unwrap()
.get_mut(bucket)
.and_then(VecDeque::pop_front)
{
return Err(error);
}
if let Some(error) = self
.bucket_heal_errors
.lock()
.unwrap()
.get_mut(bucket)
.and_then(VecDeque::pop_front)
{
return Err(Error::other(message));
return Err(error);
}
Ok(HealResultItem::default())
}
@@ -3429,13 +3717,70 @@ async fn cluster_recreate_fails_when_pool_metadata_heal_fails() {
.await
.expect_err("cluster recreate heal must not hide pool metadata failure");
assert!(matches!(err, Error::TaskExecutionFailed { .. }));
assert!(matches!(err, Error::Other(ref message) if message == "pool metadata missing"));
assert_eq!(
storage.heal_object_calls.lock().expect("object calls").as_slice(),
["object-a".to_string(), "object-b".to_string(), POOL_META_NAME.to_string()]
);
}
#[tokio::test]
async fn cluster_pool_metadata_failures_preserve_recovery_classification_in_both_response_shapes() {
for outer in [false, true] {
for (kind, retryable) in [
(EcstorePoolMetadataFailure::ReadUnavailable, true),
(EcstorePoolMetadataFailure::TransactionUnknown, true),
(EcstorePoolMetadataFailure::RecoveryRequired, false),
(EcstorePoolMetadataFailure::FenceLost, false),
] {
let storage = Arc::new(MockStorage {
listed_buckets: Mutex::new(Some(Vec::new())),
..Default::default()
});
storage.heal_object_outcomes.lock().expect("object outcomes").insert(
POOL_META_NAME.to_string(),
VecDeque::from([MockHealObjectOutcome::PoolMetadata { kind, outer }]),
);
let mut request = HealRequest::new(
HealType::Cluster,
HealOptions {
recreate_missing: true,
timeout: None,
..Default::default()
},
HealPriority::Normal,
);
request.source = HealRequestSource::Admin;
let task = HealTask::from_request(request, storage.clone());
let error = task
.execute()
.await
.expect_err("pool metadata failure must reach the scheduler");
assert!(
matches!(&error, Error::Storage(error) if error.pool_metadata_failure().is_some_and(|failure| failure.kind == kind)),
"the original typed pool metadata state must survive either response shape: {error}"
);
assert_eq!(error.is_recoverable_heal(), retryable, "{kind:?}, outer={outer}");
assert_eq!(storage.heal_object_calls.lock().expect("object calls").as_slice(), [POOL_META_NAME]);
if retryable {
let retry = task
.retry_request_with_remaining_timeout()
.await
.expect("existing retry request");
assert_eq!(retry.id, task.id, "recovery retains the accepted task token");
HealTask::from_request(retry, storage.clone())
.execute()
.await
.expect("the same task should complete after pool metadata recovery");
assert_eq!(
storage.heal_object_calls.lock().expect("object calls").as_slice(),
[POOL_META_NAME, POOL_META_NAME]
);
}
}
}
}
#[tokio::test]
async fn cluster_dry_run_does_not_heal_pool_metadata() {
let storage = Arc::new(MockStorage::default());
@@ -3799,7 +4144,10 @@ async fn test_recursive_bucket_heal_reports_typed_exhausted_and_permanent_failur
async fn test_cluster_heal_continues_after_bucket_failure() {
let storage = Arc::new(MockStorage {
listed_buckets: Mutex::new(Some(vec!["bucket-a".to_string(), "bucket-b".to_string()])),
bucket_heal_errors: Mutex::new(HashMap::from([("bucket-a".to_string(), VecDeque::from(["metadata unavailable"]))])),
bucket_heal_errors: Mutex::new(HashMap::from([(
"bucket-a".to_string(),
VecDeque::from([Error::other("metadata unavailable")]),
)])),
..Default::default()
});
let request = HealRequest::new(
@@ -3831,7 +4179,10 @@ async fn test_cluster_heal_continues_after_bucket_failure() {
async fn test_cluster_heal_retries_only_recoverable_bucket() {
let storage = Arc::new(MockStorage {
listed_buckets: Mutex::new(Some(vec!["bucket-a".to_string(), "bucket-b".to_string()])),
bucket_heal_errors: Mutex::new(HashMap::from([("bucket-a".to_string(), VecDeque::from(["lock acquisition timeout"]))])),
bucket_heal_errors: Mutex::new(HashMap::from([(
"bucket-a".to_string(),
VecDeque::from([Error::other("lock acquisition timeout")]),
)])),
..Default::default()
});
let request = HealRequest::new(
@@ -3855,6 +4206,47 @@ async fn test_cluster_heal_retries_only_recoverable_bucket() {
);
}
#[tokio::test(start_paused = true)]
async fn cluster_bucket_heal_retries_only_recoverable_pool_metadata_failures() {
for (kind, retryable) in [
(EcstorePoolMetadataFailure::ReadUnavailable, true),
(EcstorePoolMetadataFailure::TransactionUnknown, true),
(EcstorePoolMetadataFailure::RecoveryRequired, false),
(EcstorePoolMetadataFailure::FenceLost, false),
] {
let storage = Arc::new(MockStorage {
bucket_incarnation_id: Mutex::new(Some(Uuid::new_v4())),
bucket_heal_errors: Mutex::new(HashMap::from([("bucket-a".to_owned(), VecDeque::from([pool_metadata_error(kind)]))])),
..Default::default()
});
let mut request = HealRequest::new(
HealType::Cluster,
HealOptions {
recursive: true,
timeout: None,
..Default::default()
},
HealPriority::Normal,
);
request.source = HealRequestSource::Admin;
let task = HealTask::from_request(request, storage.clone());
let result = task.execute().await;
if retryable {
result.expect("a recoverable pool metadata state must not terminate the root heal");
assert_eq!(task.get_status().await, HealTaskStatus::Completed);
assert_eq!(
storage.bucket_heal_calls.lock().expect("bucket calls").as_slice(),
["bucket-a", "bucket-a"]
);
} else {
result.expect_err("operator recovery and lost fences must remain terminal");
let failure = task.take_batch_failure().await.expect("permanent cluster failure");
assert_eq!((failure.retryable, failure.permanent), (0, 1));
assert_eq!(storage.bucket_heal_calls.lock().expect("bucket calls").as_slice(), ["bucket-a"]);
}
}
}
#[tokio::test]
async fn test_recursive_bucket_heal_does_not_remove_bucket_metadata() {
let storage = Arc::new(MockStorage::default());
@@ -4617,7 +5009,7 @@ async fn erasure_set_bucket_prepass_failure_stops_before_object_heal() {
let storage = Arc::new(MockStorage {
bucket_heal_errors: Mutex::new(HashMap::from([(
"bucket-a".to_string(),
VecDeque::from(["injected bucket prepass failure"]),
VecDeque::from([Error::other("injected bucket prepass failure")]),
)])),
resume_disk: Mutex::new(Some(disk)),
..Default::default()
@@ -550,3 +550,159 @@ fn admin_normal_protected_repairs_report_repaired_for_data_and_parity() {
.join()
.expect("heal fixture result");
}
#[test]
#[serial]
fn admin_mixed_batch_retains_repairs_after_object_retry_exhaustion() {
std::thread::Builder::new()
.stack_size(32 * 1024 * 1024)
.spawn(|| {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("mixed batch runtime")
.block_on(async {
use rustfs_heal::heal::{
manager::{HealConfig, HealManager},
outcome::{HealExecutionOutcome, HealFailureClass, HealTraversalCoverage},
task::{HealOptions, HealPriority, HealRequest, HealTaskStatus, HealType},
};
use rustfs_heal_contracts::heal_channel::HealRequestSource;
use std::sync::Arc;
let root = tempfile::tempdir().expect("mixed batch fixture");
let env = TestECStoreEnv::builder().base_dir(root.path()).build().await;
let bucket = "admin-mixed-batch";
env.make_bucket(bucket, false).await;
let set = env.ecstore.pools[0].get_disks(0);
let disks = set.disks.read().await.iter().flatten().cloned().collect::<Vec<_>>();
let mut originals = Vec::new();
for (object, missing, byte) in [("healthy", 0, 0x11), ("repaired", 1, 0x52), ("failed", 3, 0xa3)] {
env.ecstore
.put_object(
bucket,
object,
&mut PutObjReader::from_vec(vec![byte; 5 * 1024 * 1024 + 123]),
&ObjectOptions {
shard_integrity_write_mode: Some(ShardIntegrityWriteMode::Protected),
write_completion: WriteCompletion::TailDrained,
..Default::default()
},
)
.await
.expect("commit complete fixture");
for (slot, disk) in disks.iter().enumerate() {
let meta = disk
.read_version("", bucket, object, "", &ReadOptions::default())
.await
.expect("fixture metadata");
assert_eq!(meta.erasure.data_blocks, 2, "failed fixture must retain R-1 shards");
let path = env.disk_paths[slot]
.join(bucket)
.join(object)
.join(meta.data_dir.expect("external shard").to_string())
.join("part.1");
let bytes = tokio::fs::read(&path).await.expect("original part");
if slot < missing {
tokio::fs::remove_file(&path).await.expect("inject missing part");
}
originals.push((object, path, bytes, slot < missing));
}
}
let storage = Arc::new(ECStoreHealStorage::new(env.ecstore.clone()));
let manager = HealManager::new(
storage.clone(),
Some(HealConfig {
enable_auto_heal: false,
..Default::default()
}),
);
manager.start().await.expect("start mixed batch manager");
let mut request = HealRequest::new(
HealType::Bucket {
bucket: bucket.to_owned(),
},
HealOptions {
recursive: true,
scan_mode: HealScanMode::Deep,
pool_index: Some(0),
set_index: Some(0),
timeout: Some(Duration::from_secs(150)),
..Default::default()
},
HealPriority::High,
);
request.source = HealRequestSource::Admin;
let token = request.id.clone();
manager.submit_heal_request(request).await.expect("admit original mixed task");
let report = tokio::time::timeout(Duration::from_secs(150), async {
loop {
let report = manager.get_task_report(&token).await.expect("same-token report");
if matches!(
report.status,
HealTaskStatus::Completed | HealTaskStatus::Failed { .. } | HealTaskStatus::Timeout
) {
break report;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
})
.await
.expect("mixed task terminal deadline");
manager.stop().await.expect("stop mixed task manager");
for (object, path, original, missing) in originals {
if object == "failed" && missing {
assert!(!path.exists(), "below-R failure must not invent a shard");
} else {
assert_eq!(tokio::fs::read(&path).await.expect("preserved or repaired shard"), original);
}
}
assert!(matches!(report.status, HealTaskStatus::Failed { .. }), "{report:?}");
let outcome = report.outcome.as_ref().expect("canonical mixed outcome");
assert_eq!(outcome.execution, HealExecutionOutcome::CompletedWithErrors);
assert_eq!(outcome.coverage, HealTraversalCoverage::Complete);
assert_eq!(
(
outcome.counters.processed,
outcome.counters.healed,
outcome.counters.unchanged,
outcome.counters.failed,
outcome.counters.skipped,
outcome.counters.unknown,
outcome.counters.attempt_failures
),
(3, 1, 1, 1, 0, 0, 4),
"{outcome:?}"
);
for (object, disposition) in [
("healthy", HealObjectDisposition::VerifiedHealthy),
("repaired", HealObjectDisposition::Repaired),
("failed", HealObjectDisposition::Failed(HealFailureClass::RetryExhausted)),
] {
let receipt = outcome
.objects
.iter()
.find(|item| item.identity.object == object)
.expect("exact object receipt");
assert_eq!(receipt.disposition, disposition);
}
drop(manager);
let restarted = HealManager::new(
storage,
Some(HealConfig {
enable_auto_heal: false,
..Default::default()
}),
);
restarted.start().await.expect("restart with retained failed batch");
let restored = restarted.get_task_report(&token).await.expect("original token after restart");
assert_eq!(restored.status, report.status);
assert_eq!(restored.outcome, report.outcome);
assert_eq!(restarted.get_queue_length().await, 0, "terminal batch must not replay");
restarted.stop().await.expect("stop restarted mixed batch manager");
});
})
.expect("mixed batch thread")
.join()
.expect("mixed batch result");
}
+313
View File
@@ -0,0 +1,313 @@
// Copyright 2026 RustFS Team
// SPDX-License-Identifier: Apache-2.0
//! Durable restrictions owned by an authenticated user. A record is not an IAM
//! grant or credential; issuing credentials still requires current IAM checks.
use crate::{error::is_err_config_not_found, store::Store};
use serde::{Deserialize, Serialize};
const PREFIX: &str = "config/iam/plugin-delegations/v1";
#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct Restrictions {
#[serde(default, skip_serializing_if = "Option::is_none", deserialize_with = "present_value")]
pub version: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none", deserialize_with = "present_value")]
pub output_bucket: Option<String>,
pub deployment: String,
pub bucket: String,
pub input_prefix: String,
pub output_prefix: String,
pub expires_at_unix_seconds: u64,
}
// An absent v1 field is compatible; an explicitly null v2 field is invalid.
fn present_value<'de, T: Deserialize<'de>, D: serde::Deserializer<'de>>(deserializer: D) -> Result<Option<T>, D::Error> {
T::deserialize(deserializer).map(Some)
}
impl Restrictions {
pub fn output_bucket(&self) -> Result<&str, DelegationError> {
match (self.version, self.output_bucket.as_deref()) {
(None, None) => Ok(&self.bucket),
(Some(2), Some(output)) => Ok(output),
_ => Err(DelegationError::Invalid),
}
}
pub fn validate(&self, now: u64) -> Result<(), DelegationError> {
let output_bucket = self.output_bucket()?;
let names: [&str; 5] = [
&self.deployment,
&self.bucket,
&self.input_prefix,
&self.output_prefix,
output_bucket,
];
if names
.iter()
.any(|s| s.is_empty() || s.len() > 1024 || s.contains("${") || s.chars().any(char::is_control))
|| self.bucket.contains('/')
|| self.bucket.contains('*')
|| self.bucket.contains('?')
|| output_bucket.contains('/')
|| output_bucket.contains('*')
|| output_bucket.contains('?')
|| self.input_prefix.contains('*')
|| self.input_prefix.contains('?')
|| self.output_prefix.contains('*')
|| self.output_prefix.contains('?')
|| self.expires_at_unix_seconds <= now
|| self.expires_at_unix_seconds.saturating_sub(now) > 86400
{
return Err(DelegationError::Invalid);
}
Ok(())
}
}
#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct Delegation {
pub version: u32,
pub id: String,
pub revision: String,
pub owner_access_key: String,
pub created_at_unix_seconds: u64,
pub restrictions: Restrictions,
}
#[derive(Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
struct Tombstone {
version: u32,
id: String,
revision: String,
owner_access_key: String,
}
#[derive(Debug, thiserror::Error)]
pub enum DelegationError {
#[error("invalid delegation restriction")]
Invalid,
#[error("delegation not found")]
NotFound,
#[error("delegation owner rejected")]
OwnerRejected,
#[error("delegation unavailable")]
Unavailable,
#[error("delegation revoked")]
Revoked,
#[error("delegation expired")]
Expired,
}
fn record_path(id: &str) -> Result<String, DelegationError> {
if id.len() != 32 || !id.bytes().all(|c| c.is_ascii_hexdigit()) {
return Err(DelegationError::Invalid);
}
Ok(format!("{PREFIX}/{id}/record.json"))
}
async fn load<T: Store>(store: &T, id: &str) -> Result<Delegation, DelegationError> {
let record: Delegation = store.load_iam_config_strict(record_path(id)?).await.map_err(|error| {
if is_err_config_not_found(&error) {
DelegationError::NotFound
} else {
DelegationError::Unavailable
}
})?;
if !matches!(record.version, 1 | 2)
|| record.version != record.restrictions.version.unwrap_or(1)
|| record.id != id
|| record.revision != id
|| record.owner_access_key.is_empty()
{
return Err(DelegationError::Unavailable);
}
record
.restrictions
.validate(record.created_at_unix_seconds)
.map_err(|_| DelegationError::Unavailable)?;
Ok(record)
}
pub(crate) async fn create<T: Store>(
store: &T,
owner: &str,
restrictions: Restrictions,
now: u64,
) -> Result<Delegation, DelegationError> {
restrictions.validate(now)?;
if owner.is_empty() {
return Err(DelegationError::OwnerRejected);
}
// IDs are generated exclusively here; no update operation accepts an ID.
let id = format!("{:032x}", rand::random::<u128>());
let path = record_path(&id)?;
match store.load_iam_config_strict::<Delegation>(&path).await {
Err(error) if is_err_config_not_found(&error) => {}
_ => return Err(DelegationError::Unavailable),
}
let record = Delegation {
version: restrictions.version.unwrap_or(1),
revision: id.clone(),
id,
owner_access_key: owner.to_owned(),
created_at_unix_seconds: now,
restrictions,
};
store
.save_iam_config(&record, path)
.await
.map_err(|_| DelegationError::Unavailable)?;
if load(store, &record.id).await? != record {
return Err(DelegationError::Unavailable);
}
Ok(record)
}
pub(crate) async fn revoke<T: Store>(store: &T, owner: &str, id: &str) -> Result<(), DelegationError> {
let record = load(store, id).await?;
if record.owner_access_key != owner {
return Err(DelegationError::OwnerRejected);
}
let tombstone = Tombstone {
version: 1,
id: record.id,
revision: record.revision,
owner_access_key: record.owner_access_key,
};
let path = format!("{PREFIX}/{id}/revoked.json");
// Every retry writes exactly the same revocation; active records never change.
store
.save_iam_config(&tombstone, &path)
.await
.map_err(|_| DelegationError::Unavailable)?;
let saved: Tombstone = store
.load_iam_config_strict(path)
.await
.map_err(|_| DelegationError::Unavailable)?;
if saved != tombstone {
return Err(DelegationError::Unavailable);
}
Ok(())
}
pub(crate) async fn read_active<T: Store>(store: &T, owner: &str, id: &str, now: u64) -> Result<Delegation, DelegationError> {
let record = load(store, id).await?;
if record.owner_access_key != owner {
return Err(DelegationError::OwnerRejected);
}
check_active(store, record, now).await
}
pub(crate) async fn read_owned<T: Store>(store: &T, owner: &str, id: &str) -> Result<Delegation, DelegationError> {
let record = load(store, id).await?;
if record.owner_access_key != owner {
return Err(DelegationError::OwnerRejected);
}
Ok(record)
}
pub(crate) async fn read_active_for_broker<T: Store>(store: &T, id: &str, now: u64) -> Result<Delegation, DelegationError> {
let record = load(store, id).await?;
check_active(store, record, now).await
}
async fn check_active<T: Store>(store: &T, record: Delegation, now: u64) -> Result<Delegation, DelegationError> {
let id = &record.id;
match store
.load_iam_config_strict::<Tombstone>(format!("{PREFIX}/{id}/revoked.json"))
.await
{
Ok(_) => return Err(DelegationError::Revoked),
Err(error) if is_err_config_not_found(&error) => {}
Err(_) => return Err(DelegationError::Unavailable),
}
if now >= record.restrictions.expires_at_unix_seconds {
return Err(DelegationError::Expired);
}
Ok(record)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn delegation_v1_json_remains_compatible() {
let legacy = serde_json::json!({"deployment":"runner-a","bucket":"photos",
"input_prefix":"inputs/","output_prefix":"outputs/","expires_at_unix_seconds":200});
let restriction: Restrictions = serde_json::from_value(legacy.clone()).expect("legacy restriction");
restriction.validate(100).expect("legacy validation");
assert_eq!(restriction.output_bucket().expect("v1 output"), "photos");
assert_eq!(serde_json::to_value(restriction).expect("legacy serialization"), legacy);
let record = serde_json::json!({"version":1,"id":"a".repeat(32),"revision":"a".repeat(32),
"owner_access_key":"owner","created_at_unix_seconds":100,"restrictions":legacy});
let decoded: Delegation = serde_json::from_value(record.clone()).expect("legacy record");
assert_eq!(serde_json::to_value(decoded).expect("legacy record serialization"), record);
}
#[test]
fn delegation_v2_requires_explicit_output_bucket() {
let valid = serde_json::json!({"version":2,"deployment":"runner-a","bucket":"photos",
"input_prefix":"inputs/","output_bucket":"artifacts","output_prefix":"outputs/",
"expires_at_unix_seconds":200});
let restriction: Restrictions = serde_json::from_value(valid.clone()).expect("v2 restriction");
restriction.validate(100).expect("explicit output bucket");
assert_eq!(restriction.output_bucket().expect("v2 output"), "artifacts");
assert_eq!(serde_json::to_value(restriction).expect("v2 serialization"), valid);
for key in ["version", "output_bucket"] {
let mut bad = valid.clone();
bad.as_object_mut().expect("object").remove(key);
assert!(
serde_json::from_value::<Restrictions>(bad).map_or(true, |r| r.validate(100).is_err()),
"v2 required {key}"
);
}
for (key, value) in [
("version", serde_json::json!(1)),
("version", serde_json::json!(3)),
("version", serde_json::Value::Null),
("output_bucket", serde_json::Value::Null),
("output_bucket", serde_json::json!("")),
("output_bucket", serde_json::json!("other/*")),
("output_bucket", serde_json::json!("${aws:username}")),
("output_bucket", serde_json::json!("other?")),
("output_bucket", serde_json::json!("other\n")),
("unknown", serde_json::json!(true)),
] {
let mut bad = valid.clone();
bad[key] = value;
assert!(
serde_json::from_value::<Restrictions>(bad).map_or(true, |r| r.validate(100).is_err()),
"invalid v2 {key}"
);
}
}
#[test]
fn restrictions_are_literal_bounded_and_expiring() {
let mut restriction = Restrictions {
version: None,
output_bucket: None,
deployment: "runner-a".into(),
bucket: "photos".into(),
input_prefix: "inputs/".into(),
output_prefix: "outputs/".into(),
expires_at_unix_seconds: 200,
};
assert!(restriction.validate(100).is_ok());
restriction.output_prefix = "outputs/*".into();
assert!(matches!(restriction.validate(100), Err(DelegationError::Invalid)));
restriction.output_prefix = "outputs/".into();
assert!(matches!(restriction.validate(200), Err(DelegationError::Invalid)));
restriction.expires_at_unix_seconds = 200;
restriction.input_prefix = "inputs/${aws:username}/".into();
assert!(
matches!(restriction.validate(100), Err(DelegationError::Invalid)),
"C07_LITERAL_SCOPE_BOUND: policy variables are not literal restrictions"
);
assert!(record_path("../../users").is_err());
}
}
+1
View File
@@ -28,6 +28,7 @@ pub(crate) const EVENT_OIDC_DIAGNOSTICS: &str = "oidc_diagnostics";
const EVENT_OIDC_STATE: &str = "oidc_state";
pub mod cache;
pub mod delegation;
pub mod error;
pub mod federation;
pub mod keyring;
+1
View File
@@ -91,6 +91,7 @@ pub(crate) mod crate_boundary {
pub(crate) mod object_store {
pub(crate) use super::storage_contracts::{HTTPPreconditions, ListOperations, ObjectInfoOrErr, ObjectOperations};
pub(crate) use rustfs_ecstore::api::config::com::read_config_preserve_empty_with_metadata_opts;
}
pub(crate) mod runtime {
+5
View File
@@ -26,6 +26,11 @@ pub trait Store: Clone + Send + Sync + 'static {
fn has_watcher(&self) -> bool;
async fn save_iam_config<Item: Serialize + Send>(&self, item: Item, path: impl AsRef<str> + Send) -> Result<()>;
async fn load_iam_config<Item: DeserializeOwned>(&self, path: impl AsRef<str> + Send) -> Result<Item>;
/// Only confirmed storage absence may return ConfigNotFound. Corrupt or
/// undecryptable objects must remain errors, especially for revocations.
async fn load_iam_config_strict<Item: DeserializeOwned>(&self, _path: impl AsRef<str> + Send) -> Result<Item> {
Err(crate::error::Error::IamSysNotInitialized)
}
async fn delete_iam_config(&self, path: impl AsRef<str> + Send) -> Result<()>;
async fn save_user_identity(&self, name: &str, user_type: UserType, item: UserIdentity, ttl: Option<usize>) -> Result<()>;
+46
View File
@@ -42,6 +42,7 @@ use tracing::{debug, error, warn};
use crate::storage_api::object_store::{
HTTPPreconditions, ListOperations, ObjectInfoOrErr as StorageObjectInfoOrErr, ObjectOperations,
read_config_preserve_empty_with_metadata_opts,
};
pub static IAM_CONFIG_PREFIX: LazyLock<String> = LazyLock::new(|| format!("{IAM_CONFIG_ROOT_PREFIX}/iam"));
@@ -611,6 +612,11 @@ impl ObjectStore {
Ok(serde_json::from_slice(&outcome.plain)?)
}
fn decode_iam_config_strict<Item: DeserializeOwned>(data: &[u8]) -> Result<Item> {
let outcome = Self::decrypt_data_with_source(data)?;
Ok(serde_json::from_slice(&outcome.plain)?)
}
/// Parameterized core of [`Store::load_user_identity`].
async fn load_user_identity_with(&self, name: &str, user_type: UserType, mode: LoadMode) -> Result<UserIdentity> {
let mut u: UserIdentity = self
@@ -824,6 +830,12 @@ impl Store for ObjectStore {
async fn load_iam_config<Item: DeserializeOwned>(&self, path: impl AsRef<str> + Send) -> Result<Item> {
self.load_iam_config_with(path, LoadMode::Locked).await
}
async fn load_iam_config_strict<Item: DeserializeOwned>(&self, path: impl AsRef<str> + Send) -> Result<Item> {
let (data, _) =
read_config_preserve_empty_with_metadata_opts(self.object_api.clone(), path.as_ref(), &LoadMode::Locked.read_opts())
.await?;
Self::decode_iam_config_strict(&data)
}
/// Saves IAM configuration with a retry mechanism on failure.
///
/// Attempts to save the IAM configuration up to 5 times if the storage layer is not ready,
@@ -1458,6 +1470,40 @@ mod tests {
assert!(result.is_err(), "corrupt stream_io data should fail decrypt");
}
#[test]
fn strict_revocation_load_keeps_real_crypto_failure_distinct_from_absence() {
let cred = test_cred();
let key = format!("{}:{}", cred.access_key, cred.secret_key);
let plain = br#"{"version":1,"id":"revoked"}"#;
let mut encrypted = rustfs_crypto::encrypt_stream_io(key.as_bytes(), plain).expect("real stored ciphertext");
// Exercise the exact decoder used after the actual object read.
assert!(ObjectStore::decode_iam_config_strict::<serde_json::Value>(&encrypted).is_ok());
encrypted[50] ^= 0xFF;
let error = ObjectStore::decode_iam_config_strict::<serde_json::Value>(&encrypted)
.expect_err("corrupt tombstone must not become missing");
assert!(
matches!(error, Error::CryptoError(_)),
"C07_REVOCATION_FAIL_CLOSED: preserve actual crypto error"
);
assert!(
!crate::error::is_err_config_not_found(&error),
"C07_REVOCATION_FAIL_CLOSED: corrupted revocation is not absence"
);
// The preserve-empty reader keeps an existing zero-byte object as
// Ok(empty); ecstore's read_config_preserve_empty_distinguishes_empty_object
// covers that storage boundary. Its payload must fail here, not become
// ConfigNotFound and reactivate a revoked delegation.
let empty =
ObjectStore::decode_iam_config_strict::<serde_json::Value>(&[]).expect_err("an existing empty tombstone is corrupt");
assert!(
!crate::error::is_err_config_not_found(&empty),
"C07_REVOCATION_FAIL_CLOSED: empty existing revocation is not absence"
);
let invalid_json =
ObjectStore::decode_iam_config_strict::<serde_json::Value>(br#"{"version": }"#).expect_err("invalid stored JSON");
assert!(!crate::error::is_err_config_not_found(&invalid_json));
}
#[test]
fn test_decrypt_data_short_data_fails() {
let short = &[0x00u8; 40]; // less than 41-byte stream_io header, not valid JSON
+257 -7
View File
@@ -75,6 +75,7 @@ pub const STATUS_ENABLED: &str = "enabled";
pub const STATUS_DISABLED: &str = "disabled";
pub const POLICYNAME: &str = "policy";
pub const PLUGIN_ATTEMPT_CREDENTIAL_CLAIM: &str = "rustfs:plugin-attempt-v1";
pub const SESSION_POLICY_NAME: &str = "sessionPolicy";
pub const SESSION_POLICY_NAME_EXTRACTED: &str = "sessionPolicy-extracted";
const VERIFIED_FEDERATED_POLICY_CLAIM: &str = "x-rustfs-internal-federated-policy";
@@ -168,6 +169,12 @@ enum PreparedServicePolicyMode {
#[derive(Clone)]
enum PreparedIamMode {
TerminalDeny,
PluginAttemptSts {
is_owner: bool,
combined_policy: Policy,
session_policy: PreparedSessionPolicy,
},
Opa,
Owner,
Deny,
@@ -202,7 +209,7 @@ impl PreparedIamAuth {
pub async fn needs_existing_object_tag_for_args(&self, args: &Args<'_>) -> bool {
match &self.mode {
PreparedIamMode::Opa => true,
PreparedIamMode::Owner | PreparedIamMode::Deny => false,
PreparedIamMode::Owner | PreparedIamMode::Deny | PreparedIamMode::TerminalDeny => false,
PreparedIamMode::Regular { combined_policy } => {
policy_needs_existing_object_tag_for_args(combined_policy, args).await
}
@@ -210,6 +217,11 @@ impl PreparedIamAuth {
combined_policy,
session_policy,
..
}
| PreparedIamMode::PluginAttemptSts {
combined_policy,
session_policy,
..
} => {
policy_needs_existing_object_tag_for_args(combined_policy, args).await
|| prepared_session_policy_needs_existing_object_tag_for_args(session_policy, args).await
@@ -239,14 +251,56 @@ impl PreparedIamAuth {
pub fn combined_policy_for_view(&self) -> Option<&Policy> {
match &self.mode {
PreparedIamMode::Regular { combined_policy } => Some(combined_policy),
PreparedIamMode::Sts { combined_policy, .. } => Some(combined_policy),
PreparedIamMode::Sts { combined_policy, .. } | PreparedIamMode::PluginAttemptSts { combined_policy, .. } => {
Some(combined_policy)
}
PreparedIamMode::ServiceAccount { combined_policy, .. } => Some(combined_policy),
PreparedIamMode::Opa | PreparedIamMode::Owner | PreparedIamMode::Deny => None,
PreparedIamMode::Opa | PreparedIamMode::Owner | PreparedIamMode::Deny | PreparedIamMode::TerminalDeny => None,
}
}
}
impl<T: Store> IamSys<T> {
/// For the authenticated, explicitly configured broker handler only.
pub async fn read_active_plugin_delegation_for_broker(
&self,
id: &str,
now: u64,
) -> std::result::Result<crate::delegation::Delegation, crate::delegation::DelegationError> {
crate::delegation::read_active_for_broker(&self.store.api, id, now).await
}
pub async fn create_plugin_delegation(
&self,
owner: &str,
restrictions: crate::delegation::Restrictions,
now: u64,
) -> std::result::Result<crate::delegation::Delegation, crate::delegation::DelegationError> {
crate::delegation::create(&self.store.api, owner, restrictions, now).await
}
pub async fn revoke_plugin_delegation(
&self,
owner: &str,
id: &str,
) -> std::result::Result<(), crate::delegation::DelegationError> {
crate::delegation::revoke(&self.store.api, owner, id).await
}
pub async fn read_active_plugin_delegation(
&self,
owner: &str,
id: &str,
now: u64,
) -> std::result::Result<crate::delegation::Delegation, crate::delegation::DelegationError> {
crate::delegation::read_active(&self.store.api, owner, id, now).await
}
/// Reads an owned delegation record for historical lookups, including expired or revoked records.
pub async fn read_owned_plugin_delegation(
&self,
owner: &str,
id: &str,
) -> std::result::Result<crate::delegation::Delegation, crate::delegation::DelegationError> {
crate::delegation::read_owned(&self.store.api, owner, id).await
}
/// Create a new IamSys instance with the given IamCache store
///
/// # Arguments
@@ -281,6 +335,16 @@ impl<T: Store> IamSys<T> {
*guard = PolicyPluginState::Ready(client);
}
/// Restricted credentials require the built-in parent/session intersection.
/// External authorization bypasses that path, so only Disabled is supported.
pub async fn ensure_restricted_credential_vending() -> Result<()> {
if matches!(Self::policy_plugin_state().await, PolicyPluginState::Disabled) {
Ok(())
} else {
Err(Error::IAMActionNotAllowed)
}
}
pub async fn get_policy_plugin_client() -> Option<rustfs_policy::policy::opa::AuthZPlugin> {
let policy_plugin_state = get_policy_plugin_state();
let guard = policy_plugin_state.read().await;
@@ -1383,17 +1447,50 @@ impl<T: Store> IamSys<T> {
/// 1) know whether policy evaluation may need `s3:ExistingObjectTag`, and
/// 2) evaluate with final conditions without re-merging identity policies.
pub async fn prepare_auth(&self, args: &Args<'_>) -> PreparedIamAuth {
if args.is_owner {
let marked = args.claims.contains_key(PLUGIN_ATTEMPT_CREDENTIAL_CLAIM);
if args.is_owner && !marked {
return PreparedIamAuth {
needs_existing_object_tag: false,
mode: PreparedIamMode::Owner,
};
}
if let Some(prepared) = Self::policy_plugin_state().await.prepared_iam_auth() {
// One observed state decides this request's path. Marked credentials
// never enter OPA, including after a restart or on another node.
let state = Self::policy_plugin_state().await;
if marked {
if args.is_owner
|| args.claims.get(PLUGIN_ATTEMPT_CREDENTIAL_CLAIM) != Some(&Value::Bool(true))
|| !matches!(state, PolicyPluginState::Disabled)
{
return PreparedIamAuth {
needs_existing_object_tag: false,
mode: PreparedIamMode::TerminalDeny,
};
}
let prepared = self.prepare_builtin_auth(args).await;
let mode = match prepared.mode {
PreparedIamMode::Sts {
is_owner,
combined_policy,
session_policy,
} => PreparedIamMode::PluginAttemptSts {
is_owner,
combined_policy,
session_policy,
},
_ => PreparedIamMode::TerminalDeny,
};
return PreparedIamAuth {
needs_existing_object_tag: prepared.needs_existing_object_tag,
mode,
};
}
if let Some(prepared) = state.prepared_iam_auth() {
return prepared;
}
self.prepare_builtin_auth(args).await
}
async fn prepare_builtin_auth(&self, args: &Args<'_>) -> PreparedIamAuth {
let Ok((is_svc, parent_user)) = self.is_service_account(args.account).await else {
return PreparedIamAuth {
needs_existing_object_tag: false,
@@ -1425,6 +1522,20 @@ impl<T: Store> IamSys<T> {
/// bucket policies on an IAM denial. An error must not become an implicit deny.
pub async fn try_eval_prepared(&self, prepared: &PreparedIamAuth, args: &Args<'_>) -> Result<bool> {
Ok(match &prepared.mode {
PreparedIamMode::TerminalDeny => return Err(Error::IAMActionNotAllowed),
PreparedIamMode::PluginAttemptSts {
is_owner,
combined_policy,
session_policy,
} => {
// A false IAM result permits bucket-policy fallback. Restricted
// plugin credentials must instead terminate authorization.
let session_allowed = evaluate_prepared_session_policy(session_policy, args).await == Some(true);
if !session_allowed || !(*is_owner || combined_policy.is_allowed(args).await) {
return Err(Error::IAMActionNotAllowed);
}
true
}
PreparedIamMode::Opa => {
let Some(opa_enable) = Self::get_policy_plugin_client().await else {
tracing::warn!("eval_prepared: OPA mode requested but plugin is unavailable");
@@ -2127,6 +2238,145 @@ mod tests {
assert!(prepared.combined_policy_for_view().is_none());
}
#[tokio::test]
#[serial]
async fn marked_signed_sts_denial_is_terminal_before_public_bucket_fallback() {
let iam = IamSys::new(IamCache::new(StsTestMockStore::new(false)).await.unwrap());
let previous = IamSys::<StsTestMockStore>::policy_plugin_state().await;
let signing_key = "c07-test-token-signing-key";
let mut metadata=HashMap::from([
("parent".into(),Value::String("sts-fallback-test-parent".into())),
("exp".into(),serde_json::json!((OffsetDateTime::now_utc()+time::Duration::hours(1)).unix_timestamp())),
(SESSION_POLICY_NAME.into(),Value::String(base64_simd::URL_SAFE_NO_PAD.encode_to_string(br#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:GetObject"],"Resource":["arn:aws:s3:::mybucket/inside"]}]}"#))),
]);
metadata.insert(PLUGIN_ATTEMPT_CREDENTIAL_CLAIM.into(), Value::Bool(true));
let mut marked = get_new_credentials_with_metadata(&metadata, signing_key).unwrap();
marked.parent_user = "sts-fallback-test-parent".into();
let marked_claims: HashMap<String, Value> = rustfs_policy::utils::extract_claims(&marked.session_token, signing_key)
.expect("signed test credentials must decode")
.claims;
let marked_user = UserIdentity::from(marked.clone());
iam.store
.cache
.add_or_update_sts_account(&marked.access_key, &marked_user, OffsetDateTime::now_utc());
metadata.remove(PLUGIN_ATTEMPT_CREDENTIAL_CLAIM);
let mut ordinary = get_new_credentials_with_metadata(&metadata, signing_key).unwrap();
ordinary.parent_user = marked.parent_user.clone();
let ordinary_claims: HashMap<String, Value> = rustfs_policy::utils::extract_claims(&ordinary.session_token, signing_key)
.expect("signed test credentials must decode")
.claims;
iam.store.cache.add_or_update_sts_account(
&ordinary.access_key,
&UserIdentity::from(ordinary.clone()),
OffsetDateTime::now_utc(),
);
let groups = None;
let conditions = HashMap::new();
let args = Args {
account: &marked.access_key,
groups: &groups,
action: Action::S3Action(S3Action::GetObjectAction),
bucket: "mybucket",
object: "outside",
conditions: &conditions,
is_owner: false,
claims: &marked_claims,
deny_only: false,
};
*get_policy_plugin_state().write().await = PolicyPluginState::Disabled;
let denied = iam.prepare_auth(&args).await;
let disabled_terminal = matches!(iam.try_eval_prepared(&denied, &args).await, Err(Error::IAMActionNotAllowed));
let inside = Args {
object: "inside",
..args.clone()
};
let allowed = iam.prepare_auth(&inside).await;
let inside_allowed = matches!(iam.try_eval_prepared(&allowed, &inside).await, Ok(true));
let unmarked_args = Args {
account: &ordinary.access_key,
claims: &ordinary_claims,
..args.clone()
};
let unmarked = iam.prepare_auth(&unmarked_args).await;
let ordinary_false = matches!(iam.try_eval_prepared(&unmarked, &unmarked_args).await, Ok(false));
let public:rustfs_policy::policy::BucketPolicy=serde_json::from_value(serde_json::json!({"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":["*"]},"Action":["s3:GetObject"],"Resource":["arn:aws:s3:::mybucket/*"]}]})).unwrap();
let public_allows = public
.is_allowed(&rustfs_policy::policy::BucketPolicyArgs {
account: &marked.access_key,
groups: &groups,
action: args.action,
bucket: args.bucket,
object: args.object,
conditions: &conditions,
is_owner: false,
})
.await;
IamSys::<StsTestMockStore>::set_policy_plugin_client(opa::AuthZPlugin::new(opa::Args {
url: "http://127.0.0.1:8181/allow".into(),
auth_token: String::new(),
}))
.await;
let opa_marked = iam.prepare_auth(&args).await;
let ready_terminal = matches!(iam.try_eval_prepared(&opa_marked, &args).await, Err(Error::IAMActionNotAllowed));
let opa_ordinary = iam.prepare_auth(&unmarked_args).await;
let ordinary_keeps_opa = matches!(opa_ordinary.mode, PreparedIamMode::Opa);
// The captured Disabled preparation stays on built-in intersection even
// if an external policy client becomes ready before evaluation.
let captured_still_terminal = matches!(iam.try_eval_prepared(&denied, &args).await, Err(Error::IAMActionNotAllowed));
*get_policy_plugin_state().write().await = PolicyPluginState::Initializing;
let pending = iam.prepare_auth(&args).await;
let initializing_terminal = matches!(iam.try_eval_prepared(&pending, &args).await, Err(Error::IAMActionNotAllowed));
*get_policy_plugin_state().write().await = PolicyPluginState::Failed;
let failed = iam.prepare_auth(&args).await;
let failed_terminal = matches!(iam.try_eval_prepared(&failed, &args).await, Err(Error::IAMActionNotAllowed));
*get_policy_plugin_state().write().await = previous;
assert!(public_allows, "C07_PUBLIC_BUCKET_CONTROL: real public policy permits outside object");
assert!(
disabled_terminal && captured_still_terminal,
"C07_SCOPE_TERMINAL_BOUND: bucket policy must not rescue restricted scope denial"
);
assert!(
ready_terminal && initializing_terminal && failed_terminal,
"C07_EXTERNAL_POLICY_BOUND: marked persisted STS cannot enter OPA"
);
assert!(
inside_allowed && ordinary_false && ordinary_keeps_opa,
"C07_UNMARKED_COMPAT: preserve ordinary STS and valid marked scope"
);
}
#[tokio::test]
#[serial]
async fn restricted_vending_rejects_actual_external_policy_plugin_state() {
let previous = IamSys::<StsTestMockStore>::policy_plugin_state().await;
IamSys::<StsTestMockStore>::set_policy_plugin_client(opa::AuthZPlugin::new(opa::Args {
url: "http://127.0.0.1:8181/v1/data/rustfs/authz/allow".into(),
auth_token: String::new(),
}))
.await;
let active_rejected = IamSys::<StsTestMockStore>::ensure_restricted_credential_vending()
.await
.is_err();
*get_policy_plugin_state().write().await = PolicyPluginState::Initializing;
let initializing_rejected = IamSys::<StsTestMockStore>::ensure_restricted_credential_vending()
.await
.is_err();
*get_policy_plugin_state().write().await = PolicyPluginState::Failed;
let failed_rejected = IamSys::<StsTestMockStore>::ensure_restricted_credential_vending()
.await
.is_err();
*get_policy_plugin_state().write().await = PolicyPluginState::Disabled;
let disabled_allowed = IamSys::<StsTestMockStore>::ensure_restricted_credential_vending()
.await
.is_ok();
*get_policy_plugin_state().write().await = previous;
assert!(
active_rejected && initializing_rejected && failed_rejected,
"C07_EXTERNAL_POLICY_BOUND: external authorization cannot bypass minted session scope"
);
assert!(disabled_allowed);
}
#[tokio::test]
#[serial]
async fn test_prepare_auth_requests_existing_object_tags_in_opa_mode() {
+60 -2
View File
@@ -1,10 +1,67 @@
use criterion::{Criterion, criterion_group, criterion_main};
use metrics_util::debugging::DebuggingRecorder;
use rustfs_io_metrics::{
LocalBatchReadDecision, LocalBatchReadMetrics, LocalBatchReadStage, record_local_batch_read_decision,
set_get_stage_metrics_enabled,
};
use rustfs_io_metrics::{MetricsCollector, PerformanceMetrics, record_get_object_timeout_operation_start};
use rustfs_io_metrics::{record_s3_op, s3_http_metrics::S3HttpRequestGuard};
use rustfs_s3_ops::S3Operation;
use std::hint::black_box;
use std::sync::Arc;
use std::time::Duration;
use std::time::{Duration, Instant};
fn local_batch_attribution_sample() {
let prepare = rustfs_io_metrics::get_stage_metrics_enabled().then(Instant::now);
record_local_batch_read_decision(LocalBatchReadDecision::Selected, prepare.map(|started| started.elapsed()));
if let Some(metrics) = LocalBatchReadMetrics::capture() {
metrics.record_batch(4);
let execution = Instant::now();
metrics.record_stage(LocalBatchReadStage::QueueWait, true, execution.elapsed());
for _ in 0..4 {
for stage in [
LocalBatchReadStage::Metadata,
LocalBatchReadStage::Open,
LocalBatchReadStage::Read,
] {
let started = Instant::now();
metrics.record_stage(stage, true, started.elapsed());
}
metrics.record_shard(true, 32 * 1024, 32 * 1024);
}
metrics.record_stage(LocalBatchReadStage::Execution, true, execution.elapsed());
}
}
fn bench_local_batch_attribution(c: &mut Criterion) {
let mut group = c.benchmark_group("local_batch_attribution_4_shards_debug_recorder");
for (name, enabled) in [("disabled", false), ("enabled", true)] {
set_get_stage_metrics_enabled(enabled);
group.bench_function(name, |b| {
b.iter_custom(|iterations| {
let mut remaining = iterations;
let mut elapsed = Duration::ZERO;
while remaining > 0 {
// Bound captured histogram storage independently of Criterion's
// iteration count. Recorder setup/teardown is not measured.
let chunk = remaining.min(128);
let recorder = DebuggingRecorder::new();
metrics::with_local_recorder(&recorder, || {
let started = Instant::now();
for _ in 0..chunk {
local_batch_attribution_sample();
}
elapsed += started.elapsed();
});
remaining -= chunk;
}
elapsed
});
});
}
group.finish();
set_get_stage_metrics_enabled(false);
}
fn bench_record_get_object_timeout_operation_start(c: &mut Criterion) {
c.bench_function("record_get_object_timeout_operation_start", |b| {
@@ -54,6 +111,7 @@ criterion_group!(
bench_record_get_object_timeout_operation_start,
bench_s3_http_outcomes,
bench_update_concurrent_requests,
bench_metrics_collector_record_io_operation
bench_metrics_collector_record_io_operation,
bench_local_batch_attribution
);
criterion_main!(benches);
+5
View File
@@ -49,6 +49,11 @@
#[macro_use]
extern crate metrics;
mod local_batch_read;
pub use local_batch_read::{
LocalBatchReadDecision, LocalBatchReadMetrics, LocalBatchReadStage, record_local_batch_read_decision,
};
use std::collections::HashMap;
use std::sync::{
LazyLock, Mutex,
+210
View File
@@ -0,0 +1,210 @@
// 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.
//! Opt-in attribution for the buffered local shard batch path.
use crate::get_stage_metrics_enabled;
use metrics::{Counter, Histogram};
use std::time::Duration;
/// One decision per reader-setup attempt, rather than per S3 request.
#[derive(Clone, Copy, Debug)]
pub enum LocalBatchReadDecision {
Selected,
MmapDisabled,
CopySource,
ReadCapExceeded,
InlineData,
NonLocalShard,
InvalidPath,
NoLocalShards,
UnsupportedPlatform,
}
impl LocalBatchReadDecision {
const fn as_str(self) -> &'static str {
match self {
Self::Selected => "selected",
Self::MmapDisabled => "mmap_disabled",
Self::CopySource => "copy_source",
Self::ReadCapExceeded => "read_cap_exceeded",
Self::InlineData => "inline_data",
Self::NonLocalShard => "non_local_shard",
Self::InvalidPath => "invalid_path",
Self::NoLocalShards => "no_local_shards",
Self::UnsupportedPlatform => "unsupported_platform",
}
}
}
/// Record eligibility, including path preparation only when it actually ran.
pub fn record_local_batch_read_decision(decision: LocalBatchReadDecision, preparation: Option<Duration>) {
if !get_stage_metrics_enabled() {
return;
}
let decision = decision.as_str();
counter!("rustfs_io_get_object_local_batch_decisions_total", "decision" => decision).increment(1);
if let Some(duration) = preparation {
histogram!("rustfs_io_get_object_local_batch_prepare_seconds", "decision" => decision).record(duration.as_secs_f64());
}
}
/// Stages of the existing buffered positioned-read implementation.
#[derive(Clone, Copy)]
pub enum LocalBatchReadStage {
QueueWait,
Execution,
Metadata,
Open,
Read,
}
impl LocalBatchReadStage {
const fn index(self) -> usize {
match self {
Self::QueueWait => 0,
Self::Execution => 1,
Self::Metadata => 2,
Self::Open => 3,
Self::Read => 4,
}
}
}
/// Handles captured before dispatch, kept alive by the actual blocking worker.
///
/// Capturing the recorder once avoids per-I/O registration and also preserves
/// completion samples when the caller stops waiting. No timers or handles are
/// created with GET-stage metrics disabled. The snapshot applies to the whole
/// submitted batch, including work that finishes after the switch is disabled.
pub struct LocalBatchReadMetrics {
stages: [[Histogram; 2]; 5],
shards: [Counter; 2],
requested_bytes: [Counter; 2],
read_bytes: [Counter; 2],
batch_size: Histogram,
}
fn outcome(success: bool) -> &'static str {
if success { "success" } else { "error" }
}
impl LocalBatchReadMetrics {
/// Resolve bounded-label handles only for an explicitly instrumented batch.
/// Boxing the enabled state keeps the blocking task small when disabled.
pub fn capture() -> Option<Box<Self>> {
if !get_stage_metrics_enabled() {
return None;
}
let stages = ["queue_wait", "execution", "metadata", "open", "read"].map(|stage| {
[false, true].map(|success| {
histogram!("rustfs_io_get_object_local_batch_stage_seconds", "stage" => stage, "outcome" => outcome(success))
})
});
Some(Box::new(Self {
stages,
shards: [false, true]
.map(|success| counter!("rustfs_io_get_object_local_batch_shards_total", "outcome" => outcome(success))),
requested_bytes: [false, true]
.map(|success| counter!("rustfs_io_get_object_local_batch_requested_bytes_total", "outcome" => outcome(success))),
read_bytes: [false, true]
.map(|success| counter!("rustfs_io_get_object_local_batch_read_bytes_total", "outcome" => outcome(success))),
batch_size: histogram!("rustfs_io_get_object_local_batch_shards"),
}))
}
/// Count shards actually submitted to this blocking batch.
pub fn record_batch(&self, shards: usize) {
self.batch_size.record(crate::shard_read_fanout_to_f64(shards));
}
/// Record an executed stage; unattempted stages receive no sample.
///
/// Queue wait succeeds when the worker starts. Execution covers the whole
/// batch, while metadata/open/read describe individual I/O operations.
/// Read timing excludes buffer allocation; execution timing includes it.
pub fn record_stage(&self, stage: LocalBatchReadStage, success: bool, duration: Duration) {
self.stages[stage.index()][usize::from(success)].record(duration.as_secs_f64());
}
/// Separate completed shard outcomes and bytes actually returned by read_at.
pub fn record_shard(&self, success: bool, requested: usize, read: usize) {
let index = usize::from(success);
self.shards[index].increment(1);
self.requested_bytes[index].increment(u64::try_from(requested).unwrap_or(u64::MAX));
self.read_bytes[index].increment(u64::try_from(read).unwrap_or(u64::MAX));
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::set_get_stage_metrics_enabled;
use crate::tests::{METRICS_FLAG_LOCK, counter_total, histogram_samples};
use metrics_util::debugging::DebuggingRecorder;
use std::time::Duration;
#[test]
fn local_batch_read_metrics_are_disabled_without_stage_opt_in() {
let _guard = METRICS_FLAG_LOCK.lock().unwrap_or_else(|error| error.into_inner());
let recorder = DebuggingRecorder::new();
let snapshotter = recorder.snapshotter();
metrics::with_local_recorder(&recorder, || {
set_get_stage_metrics_enabled(false);
assert!(LocalBatchReadMetrics::capture().is_none());
record_local_batch_read_decision(LocalBatchReadDecision::Selected, Some(Duration::from_millis(1)));
});
assert!(snapshotter.snapshot().into_vec().is_empty());
}
#[test]
fn local_batch_read_handles_record_worker_costs_and_actual_bytes() {
let _guard = METRICS_FLAG_LOCK.lock().unwrap_or_else(|error| error.into_inner());
let recorder = DebuggingRecorder::new();
let snapshotter = recorder.snapshotter();
let observation = metrics::with_local_recorder(&recorder, || {
set_get_stage_metrics_enabled(true);
record_local_batch_read_decision(LocalBatchReadDecision::Selected, Some(Duration::from_millis(2)));
LocalBatchReadMetrics::capture().expect("enabled batch metrics must capture recorder handles")
});
set_get_stage_metrics_enabled(false);
std::thread::spawn(move || {
observation.record_stage(LocalBatchReadStage::QueueWait, true, Duration::from_millis(3));
observation.record_stage(LocalBatchReadStage::Metadata, false, Duration::from_millis(4));
observation.record_shard(false, 1024, 0);
observation.record_shard(true, 16, 16);
observation.record_batch(2);
})
.join()
.expect("worker should record through the captured handles");
set_get_stage_metrics_enabled(false);
let rows = snapshotter.snapshot().into_vec();
assert_eq!(counter_total(&rows, "rustfs_io_get_object_local_batch_decisions_total"), Some(1));
assert_eq!(counter_total(&rows, "rustfs_io_get_object_local_batch_shards_total"), Some(2));
assert_eq!(counter_total(&rows, "rustfs_io_get_object_local_batch_requested_bytes_total"), Some(1040));
assert_eq!(counter_total(&rows, "rustfs_io_get_object_local_batch_read_bytes_total"), Some(16));
assert_eq!(histogram_samples(&rows, "rustfs_io_get_object_local_batch_prepare_seconds"), vec![0.002]);
assert_eq!(
histogram_samples(&rows, "rustfs_io_get_object_local_batch_stage_seconds"),
vec![0.003, 0.004]
);
assert_eq!(histogram_samples(&rows, "rustfs_io_get_object_local_batch_shards"), vec![2.0]);
for (key, _, _, _) in rows {
for label in key.key().labels() {
assert!(matches!(label.key(), "decision" | "stage" | "outcome"));
}
}
}
}
+47 -10
View File
@@ -83,6 +83,16 @@ const GET_OBJECT_DURATION_HISTOGRAM_BUCKETS: &[f64] = &[
0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0,
];
const LOCAL_BATCH_DURATION_HISTOGRAM_METRICS: &[&str] = &[
"rustfs_io_get_object_local_batch_prepare_seconds",
"rustfs_io_get_object_local_batch_stage_seconds",
];
const LOCAL_BATCH_DURATION_HISTOGRAM_BUCKETS: &[f64] = &[
0.0000001, 0.00000025, 0.0000005, 0.000001, 0.0000025, 0.000005, 0.00001, 0.000025, 0.00005, 0.0001, 0.00025, 0.0005, 0.001,
0.0025, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0,
];
const DEFAULT_OTLP_HTTP_TIMEOUT: Duration = Duration::from_secs(10);
const OTEL_EXPORTER_OTLP_TIMEOUT: &str = "OTEL_EXPORTER_OTLP_TIMEOUT";
const OTEL_EXPORTER_OTLP_TRACES_TIMEOUT: &str = "OTEL_EXPORTER_OTLP_TRACES_TIMEOUT";
@@ -481,21 +491,25 @@ fn build_meter_provider(
}
fn get_object_duration_histogram_view(instrument: &Instrument) -> Option<Stream> {
if !is_get_object_duration_histogram_metric(instrument.name()) {
return None;
}
let boundaries = get_object_duration_histogram_buckets(instrument.name())?;
Stream::builder()
.with_aggregation(Aggregation::ExplicitBucketHistogram {
boundaries: GET_OBJECT_DURATION_HISTOGRAM_BUCKETS.to_vec(),
boundaries: boundaries.to_vec(),
record_min_max: true,
})
.build()
.ok()
}
fn is_get_object_duration_histogram_metric(name: &str) -> bool {
GET_OBJECT_DURATION_HISTOGRAM_METRICS.contains(&name)
fn get_object_duration_histogram_buckets(name: &str) -> Option<&'static [f64]> {
if LOCAL_BATCH_DURATION_HISTOGRAM_METRICS.contains(&name) {
Some(LOCAL_BATCH_DURATION_HISTOGRAM_BUCKETS)
} else if GET_OBJECT_DURATION_HISTOGRAM_METRICS.contains(&name) {
Some(GET_OBJECT_DURATION_HISTOGRAM_BUCKETS)
} else {
None
}
}
/// Build an optional [`SdkLoggerProvider`] for the given log endpoint.
@@ -1098,10 +1112,22 @@ mod tests {
#[test]
fn test_get_object_duration_histogram_metric_match_is_scoped() {
assert!(is_get_object_duration_histogram_metric("rustfs_io_get_object_stage_duration_seconds"));
assert!(is_get_object_duration_histogram_metric("rustfs_io_get_object_request_duration_seconds"));
assert!(!is_get_object_duration_histogram_metric("rustfs_io_put_object_request_duration_seconds"));
assert!(!is_get_object_duration_histogram_metric("rustfs_io_get_object_response_size_bytes"));
for name in [
"rustfs_io_get_object_stage_duration_seconds",
"rustfs_io_get_object_request_duration_seconds",
] {
assert_eq!(get_object_duration_histogram_buckets(name), Some(GET_OBJECT_DURATION_HISTOGRAM_BUCKETS));
}
for name in LOCAL_BATCH_DURATION_HISTOGRAM_METRICS {
assert_eq!(get_object_duration_histogram_buckets(name), Some(LOCAL_BATCH_DURATION_HISTOGRAM_BUCKETS));
}
for name in [
"rustfs_io_put_object_request_duration_seconds",
"rustfs_io_get_object_response_size_bytes",
"rustfs_io_get_object_local_batch_shards",
] {
assert!(get_object_duration_histogram_buckets(name).is_none());
}
}
#[test]
@@ -1111,6 +1137,17 @@ mod tests {
assert_eq!(GET_OBJECT_DURATION_HISTOGRAM_BUCKETS.last(), Some(&10.0));
}
#[test]
fn local_batch_duration_buckets_resolve_sub_microsecond_operations() {
assert!(
LOCAL_BATCH_DURATION_HISTOGRAM_BUCKETS
.windows(2)
.all(|pair| pair[0] < pair[1])
);
assert_eq!(LOCAL_BATCH_DURATION_HISTOGRAM_BUCKETS.first(), Some(&0.0000001));
assert_eq!(LOCAL_BATCH_DURATION_HISTOGRAM_BUCKETS.last(), Some(&10.0));
}
#[cfg(all(
feature = "pyroscope",
any(target_os = "macos", all(target_os = "linux", target_env = "gnu", target_arch = "x86_64"))
+56
View File
@@ -568,6 +568,24 @@ pub enum AdminAction {
GetTableLifecycleAction,
#[strum(serialize = "admin:SetTableLifecycle")]
SetTableLifecycleAction,
#[strum(serialize = "admin:CreatePluginDelegation")]
CreatePluginDelegationAction,
#[strum(serialize = "admin:IssuePluginAttempt")]
IssuePluginAttemptAction,
#[strum(serialize = "admin:SubmitPluginJob")]
SubmitPluginJobAction,
#[strum(serialize = "admin:GetPluginRun")]
GetPluginRunAction,
#[strum(serialize = "admin:CancelPluginRun")]
CancelPluginRunAction,
#[strum(serialize = "admin:ListPluginRuns")]
ListPluginRunsAction,
#[strum(serialize = "admin:ReplayPluginRun")]
ReplayPluginRunAction,
#[strum(serialize = "admin:GetPluginDelegation")]
GetPluginDelegationAction,
#[strum(serialize = "admin:RevokePluginDelegation")]
RevokePluginDelegationAction,
#[strum(serialize = "admin:GetTableCredentials")]
GetTableCredentialsAction,
#[strum(serialize = "admin:RunTableMaintenance")]
@@ -713,6 +731,15 @@ impl AdminAction {
| AdminAction::GetTableLifecycleAction
| AdminAction::SetTableLifecycleAction
| AdminAction::GetTableCredentialsAction
| AdminAction::IssuePluginAttemptAction
| AdminAction::SubmitPluginJobAction
| AdminAction::GetPluginRunAction
| AdminAction::CancelPluginRunAction
| AdminAction::ListPluginRunsAction
| AdminAction::ReplayPluginRunAction
| AdminAction::GetPluginDelegationAction
| AdminAction::CreatePluginDelegationAction
| AdminAction::RevokePluginDelegationAction
| AdminAction::RunTableMaintenanceAction
| AdminAction::GetTableMetadataLocationAction
| AdminAction::SetTableMetadataLocationAction
@@ -913,6 +940,35 @@ mod tests {
assert_eq!(arr[0].as_str().unwrap(), "s3:*");
}
#[test]
fn plugin_delegation_actions_are_explicit_and_unscoped() {
for (name, expected) in [
("admin:GetPluginDelegation", AdminAction::GetPluginDelegationAction),
("admin:CreatePluginDelegation", AdminAction::CreatePluginDelegationAction),
("admin:RevokePluginDelegation", AdminAction::RevokePluginDelegationAction),
("admin:SubmitPluginJob", AdminAction::SubmitPluginJobAction),
("admin:GetPluginRun", AdminAction::GetPluginRunAction),
("admin:CancelPluginRun", AdminAction::CancelPluginRunAction),
("admin:ListPluginRuns", AdminAction::ListPluginRunsAction),
("admin:ReplayPluginRun", AdminAction::ReplayPluginRunAction),
] {
assert_eq!(AdminAction::try_from(name).unwrap(), expected);
assert!(expected.is_valid());
assert!(!expected.is_table_resource_scoped());
}
assert_eq!(
AdminAction::try_from("admin:IssuePluginAttempt").unwrap(),
AdminAction::IssuePluginAttemptAction
);
assert!(AdminAction::IssuePluginAttemptAction.is_valid());
assert!(AdminAction::SubmitPluginJobAction.is_valid());
assert!(AdminAction::GetPluginRunAction.is_valid());
assert!(AdminAction::CancelPluginRunAction.is_valid());
assert!(AdminAction::ListPluginRunsAction.is_valid());
assert!(AdminAction::ReplayPluginRunAction.is_valid());
assert!(AdminAction::try_from("admin:IssuePluginCredentials").is_err());
}
#[test]
fn test_get_replication_metrics_admin_action_is_valid() {
assert!(AdminAction::GetReplicationMetricsAction.is_valid());
+4
View File
@@ -53,6 +53,10 @@ pub enum ScannerError {
#[error("Remote namespace scanner disk is already active")]
RemoteDiskBusy,
/// Node resource pressure deferred work before its first storage poll.
#[error("Scanner node resource admission deferred")]
ResourceDeferred,
/// Partial data usage cache produced before the scanner stopped.
#[error("Scanner stopped with partial data usage cache")]
PartialCache(Box<DataUsageCache>),
+73 -14
View File
@@ -50,9 +50,9 @@ use storage_api::owner::{
};
#[cfg(test)]
use storage_api::owner::{
EcstoreDiskOption, EcstoreDiskStore, EcstoreEndpoint, EcstoreEndpointServerPools, EcstoreEndpoints, EcstoreInstanceContext,
EcstorePoolEndpoints, ecstore_config_init, ecstore_init_bucket_metadata_sys, ecstore_init_local_disks_with_instance_ctx,
ecstore_new_disk,
EcstoreDiskOption, EcstoreDiskStore, EcstoreEndpoint, EcstoreEndpointServerPools, EcstoreEndpoints, EcstoreExpiryState,
EcstoreInstanceContext, EcstorePoolEndpoints, ecstore_config_init, ecstore_init_bucket_metadata_sys,
ecstore_init_local_disks_with_instance_ctx, ecstore_new_disk,
};
use tokio_util::sync::CancellationToken;
use uuid::Uuid;
@@ -123,7 +123,7 @@ pub use storage_api::scan::{
SCANNER_DIRTY_USAGE_SNAPSHOT_PROTOCOL_VERSION, SCANNER_DIRTY_USAGE_SNAPSHOT_RPC_MAX_MESSAGE_SIZE,
SCANNER_SCOPED_DIRTY_USAGE_ACK_MAX_ENTRIES,
};
pub use workload_admission::set_scanner_workload_admission_snapshot_provider;
pub use workload_admission::{set_scanner_node_admission, set_scanner_workload_admission_snapshot_provider};
static SCANNER_ACTIVE_WORK_UNITS: AtomicU64 = AtomicU64::new(0);
static SCANNER_RUNTIME_INSTANCES: AtomicU64 = AtomicU64::new(0);
@@ -678,8 +678,37 @@ fn reset_tier_name_cache_for_test() {
TIER_REGISTRY_GENERATION.store(0, Ordering::Relaxed);
}
pub(crate) async fn enqueue_runtime_free_version(oi: ScannerObjectInfo) {
ecstore_expiry_state_handle().write().await.enqueue_free_version(oi);
// Only a task running an isolated unit test can override these handles.
#[cfg(test)]
struct ExpiryLockTestContext {
state: Arc<tokio::sync::RwLock<EcstoreExpiryState>>,
store: Option<Arc<ECStore>>,
completed_incarnation_reads: Arc<std::sync::atomic::AtomicUsize>,
}
#[cfg(test)]
tokio::task_local! {
static EXPIRY_LOCK_TEST_CONTEXT: ExpiryLockTestContext;
}
pub(crate) async fn enqueue_runtime_free_version(oi: ScannerObjectInfo, cancellation: Option<&CancellationToken>) {
#[cfg(test)]
let state = EXPIRY_LOCK_TEST_CONTEXT
.try_with(|context| context.state.clone())
.unwrap_or_else(|_| ecstore_expiry_state_handle());
#[cfg(not(test))]
let state = ecstore_expiry_state_handle();
let state = match cancellation {
Some(cancel) => tokio::select! {
biased;
_ = cancel.cancelled() => return,
state = state.write() => state,
},
None => state.write().await,
};
if !cancellation.is_some_and(CancellationToken::is_cancelled) {
state.enqueue_free_version(oi);
}
}
pub(crate) async fn enqueue_runtime_newer_noncurrent(
@@ -687,20 +716,50 @@ pub(crate) async fn enqueue_runtime_newer_noncurrent(
to_delete_objs: Vec<ObjectToDelete>,
event: Event,
src: &LcEventSrc,
cancellation: Option<&CancellationToken>,
) -> bool {
let Some(store) = ecstore_resolve_object_store_handle() else {
if cancellation.is_some_and(CancellationToken::is_cancelled) {
return false;
}
#[cfg(test)]
let store = EXPIRY_LOCK_TEST_CONTEXT
.try_with(|context| context.store.clone())
.unwrap_or_else(|_| ecstore_resolve_object_store_handle());
#[cfg(not(test))]
let store = ecstore_resolve_object_store_handle();
let Some(store) = store else {
return false;
};
let Ok(bucket_incarnation_id) = store.bucket_incarnation_id(bucket).await else {
return false;
};
ecstore_expiry_state_handle().write().await.enqueue_by_newer_noncurrent(
bucket,
to_delete_objs,
event,
src,
bucket_incarnation_id,
)
#[cfg(test)]
let _ = EXPIRY_LOCK_TEST_CONTEXT.try_with(|context| {
context
.completed_incarnation_reads
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
});
if cancellation.is_some_and(CancellationToken::is_cancelled) {
return false;
}
#[cfg(test)]
let state = EXPIRY_LOCK_TEST_CONTEXT
.try_with(|context| context.state.clone())
.unwrap_or_else(|_| ecstore_expiry_state_handle());
#[cfg(not(test))]
let state = ecstore_expiry_state_handle();
let mut state = match cancellation {
Some(cancel) => tokio::select! {
biased;
_ = cancel.cancelled() => return false,
state = state.write() => state,
},
None => state.write().await,
};
if cancellation.is_some_and(CancellationToken::is_cancelled) {
return false;
}
state.enqueue_by_newer_noncurrent(bucket, to_delete_objs, event, src, bucket_incarnation_id)
}
pub(crate) async fn queue_replication_heal(
+54 -13
View File
@@ -705,7 +705,7 @@ async fn scan_and_persist_local_bucket(
RemoteScannerServerError::worker("remote namespace scanner cache publication is blocked by data movement")
})?;
let cache_name = path_join_buf(&[&bucket, DATA_USAGE_CACHE_NAME]);
let guard = acquire_scanner_cache_locks(set.as_ref(), &cache_name, source)
let mut guard = acquire_scanner_cache_locks(set.as_ref(), &cache_name, source)
.await
.map_err(|err| {
if err.is_contention() {
@@ -791,19 +791,23 @@ async fn scan_and_persist_local_bucket(
let set_disks = scanner_set_disk_inventory(set.as_ref()).await;
let (checkpoint_tx, mut checkpoint_rx) = mpsc::channel::<DataUsageCache>(1);
let (node_tx, mut node_rx) = crate::workload_admission::scanner_node_request_channel();
let scan_ctx = ctx.child_token();
let scan = ScannerIODisk::nsscanner_disk(
disk.clone(),
scan_ctx.clone(),
budget,
set_disks,
cache,
None,
ScannerDiskScanOptions {
scan_mode,
prefix_scan_scope: None,
checkpoint_tx: Some(checkpoint_tx),
},
let scan = crate::workload_admission::with_scanner_node_requests(
node_tx,
ScannerIODisk::nsscanner_disk(
disk.clone(),
scan_ctx.clone(),
budget,
set_disks,
cache,
None,
ScannerDiskScanOptions {
scan_mode,
prefix_scan_scope: None,
checkpoint_tx: Some(checkpoint_tx),
},
),
);
tokio::pin!(scan);
let fence_watch = watch_remote_scanner_request_fence(next_cycle, leader_epoch, store.clone(), NS_SCANNER_FENCE_POLL_INTERVAL);
@@ -813,6 +817,27 @@ async fn scan_and_persist_local_bucket(
let mut checkpoint_channel_closed = false;
let outcome = loop {
tokio::select! {
request = async {
match node_rx.as_mut() {
Some(receiver) => receiver.recv().await,
None => std::future::pending().await,
}
} => {
let Some(request) = request else {
node_rx = None;
continue;
};
if let Err(error) = wait_for_remote_node_resumption(
guard.resume_node_operation(set.clone(), &cache_name, source, &revisions, request),
fence_watch.as_mut(),
).await {
scan_ctx.cancel();
let _ = tokio::time::timeout(NS_SCANNER_LOCK_LOSS_SHUTDOWN_TIMEOUT, scan.as_mut()).await;
return Err(RemoteScannerServerError::retry_bucket(format!(
"remote namespace scanner could not resume node admission: {error}",
)));
}
}
result = &mut scan => {
match result {
Ok(outcome) => break outcome,
@@ -1121,6 +1146,22 @@ pub(crate) async fn scan_remote_bucket(
result
}
/// Resource waits must keep checking the persisted leader fence.
async fn wait_for_remote_node_resumption<F, G>(resume: F, fence_watch: Pin<&mut G>) -> Result<(), StorageError>
where
F: Future<Output = Result<(), StorageError>>,
G: Future<Output = Result<(), ScannerError>>,
{
tokio::select! {
biased;
result = fence_watch => Err(match result {
Ok(()) => StorageError::other("remote namespace scanner fence watcher stopped unexpectedly"),
Err(error) => StorageError::other(error),
}),
result = resume => result,
}
}
fn finish_remote_scanner_stream(
result: RemoteScannerStreamResult<RemoteScannerOutcome>,
budget: &ScannerCycleBudget,
@@ -1006,6 +1006,58 @@ async fn fence_watcher_stops_work_after_persisted_leader_changes() {
assert!(err.to_string().contains("requested_epoch=9"));
}
#[tokio::test]
async fn node_admission_wait_does_not_hide_replacement_leader() {
use rustfs_concurrency::adaptive::{FeedbackController, FeedbackObservation, NodeAdmission, ResourcePressure};
use std::num::NonZeroU16;
let store = Arc::new(MutableCycleStateStore {
state: RwLock::new(crate::scanner::encode_scanner_cycle_fence_for_test(7, 9)),
});
let cache = Mutex::new(None);
let refresh = AsyncMutex::new(());
let watcher = watch_remote_scanner_request_fence_with_cache(
7,
9,
store.clone(),
Duration::from_millis(5),
&cache,
&refresh,
Duration::from_millis(5),
);
tokio::pin!(watcher);
let gate = NodeAdmission::new(NonZeroU16::new(1).expect("one shared node unit"));
let storage_polled = AtomicBool::new(false);
{
let resume = async {
let _permit = gate.acquire().await;
storage_polled.store(true, Ordering::Relaxed);
Ok(())
};
let waiting = wait_for_remote_node_resumption(resume, watcher.as_mut());
tokio::pin!(waiting);
assert!(futures::poll!(&mut waiting).is_pending(), "no resource sample grants a node unit");
*store.state.write().expect("persist replacement leader") = crate::scanner::encode_scanner_cycle_fence_for_test(7, 10);
let error = tokio::time::timeout(Duration::from_millis(100), waiting)
.await
.expect("resource pause must not hide a changed leader")
.expect_err("old leader cannot resume storage work");
assert!(error.to_string().contains("requested_epoch=9"));
}
assert!(!storage_polled.load(Ordering::Relaxed));
assert_eq!(gate.snapshot().active, 0);
gate.update(
&mut FeedbackController::new(),
Some(FeedbackObservation {
generation: 1,
max_concurrency: 1,
pressure: ResourcePressure::Unknown,
completed_units: None,
}),
);
assert!(gate.try_acquire().is_some(), "failed fencing drops the old node queue ticket");
}
#[tokio::test]
async fn concurrent_cycle_cache_misses_share_one_backend_read() {
let request = RemoteScannerRequest(test_request(Uuid::new_v4()));
+2 -2
View File
@@ -5440,8 +5440,8 @@ async fn scanner_legacy_usage_backup_survives_fencing_and_restart_after_real_met
.await
.expect_err("truncated primary must fail in the real object reader");
assert!(
error.to_string().contains("InlineData value out of range"),
"unexpected truncated-primary error: {error}"
matches!(error, EcstoreError::FileCorrupt),
"unexpected truncated-primary error: {error:?}"
);
assert_eq!(
read_config_with_revision(store.clone(), &backup_path)
+96 -28
View File
@@ -21,10 +21,10 @@ use std::time::{Duration, Instant, SystemTime};
use crate::ReplTargetSizeSummary;
use crate::data_usage_define::{
DATA_USAGE_SCAN_CHECKPOINT_VERSION, DataUsageCache, DataUsageCacheInfo, DataUsageEntry, DataUsageHash, DataUsageHashMap,
DataUsageRawEnumerationCursor, DataUsageScanCheckpoint, DataUsageScanCheckpointReason, MAX_DATA_USAGE_CACHE_DEPTH,
PendingScannerHeal, PendingScannerHealKind, ScannerSizeSummaryExt, SizeReconciliationEntry, SizeSummary, hash_path,
scan_frontier_covers_path,
DATA_USAGE_SCAN_CHECKPOINT_VERSION, DataUsageCache, DataUsageCacheInfo, DataUsageCacheSource, DataUsageEntry, DataUsageHash,
DataUsageHashMap, DataUsageRawEnumerationCursor, DataUsageScanCheckpoint, DataUsageScanCheckpointReason,
MAX_DATA_USAGE_CACHE_DEPTH, PendingScannerHeal, PendingScannerHealKind, ScannerSizeSummaryExt, SizeReconciliationEntry,
SizeSummary, hash_path, scan_frontier_covers_path,
};
use crate::error::ScannerError;
use crate::raw_page_index::{RawEnumerationPageIndex, RawEnumerationPageWriter};
@@ -43,6 +43,7 @@ use crate::storage_api::owner::{
};
#[cfg(test)]
use crate::storage_api::owner::{EcstoreExpirationStatus as ExpirationStatus, EcstoreLifecycleRule as LifecycleRule};
use crate::workload_admission::run_scanner_node_operation;
use metrics::{counter, describe_counter};
use rustfs_common::trace_bus::{TraceEvent, TraceFunc, TraceKind, trace_emit, trace_subscriber_count};
use rustfs_filemeta::{
@@ -795,6 +796,7 @@ pub struct FolderScanner {
resolved_failed_object_paths: HashSet<String>,
sleeper: DynamicSleeper,
node_admission: Option<Arc<rustfs_concurrency::adaptive::NodeAdmission>>,
// should_heal: Arc<dyn Fn() -> bool + Send + Sync>,
disks: Vec<Arc<Disk>>,
disks_quorum: usize,
@@ -828,6 +830,8 @@ pub struct FolderScanner {
pending_size_reconciliation_truncated: bool,
#[cfg(test)]
list_path_raw_options_observer: Option<mpsc::UnboundedSender<ListPathRawTimeoutSnapshot>>,
#[cfg(test)]
defer_abandoned_entries_until_finished: bool,
}
struct RawEnumerationProgress {
@@ -998,12 +1002,13 @@ async fn next_scanner_dir_entry(
ctx: &CancellationToken,
sleeper: &DynamicSleeper,
entries: &mut ScannerDirectory,
admission: Option<&Arc<rustfs_concurrency::adaptive::NodeAdmission>>,
) -> Result<Option<tokio::fs::DirEntry>, ScannerError> {
if entries.batch_entries == SCANNER_DIRECTORY_BATCH_ENTRIES {
wait_for_scanner_io(ctx, tokio::task::yield_now()).await?;
entries.batch_entries = 0;
}
let entry = wait_for_scanner_io(ctx, entries.next_entry()).await??;
let entry = wait_for_scanner_io(ctx, run_scanner_node_operation(admission, ctx, entries.next_entry())).await???;
if entry.is_some() {
// Tokio prefetches bounded chunks. Pacing consumption also paces the
// next refill, without charging an extra entry for EOF.
@@ -1603,7 +1608,7 @@ impl FolderScanner {
let candidate_type = pending_scanner_heal_candidate_type(kind);
let priority = request.priority;
let scan_mode = request.scan_mode.unwrap_or(self.scan_mode);
let result = match send_scanner_heal_request(candidate_type, request).await {
let result = match send_scanner_heal_request(candidate_type, request, scanner_disk_heal_scope(&self.local_disk)).await {
Ok(result) => result,
Err(err) => {
self.update_pending_scanner_heal_after_admission(
@@ -1923,7 +1928,12 @@ impl FolderScanner {
// directory that vanished during iteration instead of failing the
// whole parent subtree. The bucket root (no parent) still propagates
// NotFound so namespace removal is detected by the caller.
let mut dir_reader = match wait_for_scanner_io(&ctx, ScannerDirectory::open(&dir_path)).await? {
let mut dir_reader = match wait_for_scanner_io(
&ctx,
run_scanner_node_operation(self.node_admission.as_ref(), &ctx, ScannerDirectory::open(&dir_path)),
)
.await??
{
Ok(dir_reader) => Some(dir_reader),
Err(e)
if folder.parent.is_some()
@@ -1961,7 +1971,7 @@ impl FolderScanner {
// Hold at most one metadata entry until a candidate reaches
// EOF: a later part must not leave object accounting or ILM work.
let (entry, replayed_metadata) = if let Some(reader) = dir_reader.as_mut() {
let entry = match next_scanner_dir_entry(&ctx, &self.sleeper, reader).await {
let entry = match next_scanner_dir_entry(&ctx, &self.sleeper, reader, self.node_admission.as_ref()).await {
Ok(Some(entry)) => entry,
Ok(None) => {
raw_enumeration_complete = true;
@@ -2072,7 +2082,12 @@ impl FolderScanner {
// Ignore entries that disappeared during traversal or hit symlink
// loops, but propagate other walk errors.
let mut entry_type = match wait_for_scanner_io(&ctx, entry.file_type()).await? {
let mut entry_type = match wait_for_scanner_io(
&ctx,
run_scanner_node_operation(self.node_admission.as_ref(), &ctx, entry.file_type()),
)
.await??
{
Ok(entry_type) => entry_type,
Err(e) if e.kind() == ErrorKind::NotFound => {
debug!(
@@ -2123,7 +2138,12 @@ impl FolderScanner {
let mut entry_metadata_len = None;
if entry_type.is_symlink() {
wait_for_scanner_io(&ctx, self.sleeper.admit_disk_io(1, 0)).await?;
let metadata = match wait_for_scanner_io(&ctx, tokio::fs::metadata(&file_path)).await? {
let metadata = match wait_for_scanner_io(
&ctx,
run_scanner_node_operation(self.node_admission.as_ref(), &ctx, tokio::fs::metadata(&file_path)),
)
.await??
{
Ok(metadata) => metadata,
Err(e) if e.kind() == ErrorKind::NotFound => {
debug!(
@@ -2249,6 +2269,7 @@ impl FolderScanner {
lifecycle: active_life_cycle.clone(),
object_lock: active_object_lock.clone(),
replication: active_replication.clone(),
heal_scope: None,
heal_enabled,
heal_bitrot: self.scan_mode == HealScanMode::Deep,
debug: self.data_usage_scanner_debug,
@@ -2274,22 +2295,30 @@ impl FolderScanner {
len
} else {
wait_for_scanner_io(&ctx, self.sleeper.admit_disk_io(1, 0)).await?;
wait_for_scanner_io(&ctx, tokio::fs::metadata(&item.path))
.await?
.map(|metadata| metadata.len())
.unwrap_or(0)
wait_for_scanner_io(
&ctx,
run_scanner_node_operation(self.node_admission.as_ref(), &ctx, tokio::fs::metadata(&item.path)),
)
.await??
.map(|metadata| metadata.len())
.unwrap_or(0)
}
} else {
0
};
wait_for_scanner_io(&ctx, self.sleeper.admit_disk_io(1, budgeted_bytes)).await?;
self.object_metadata_visits.increment(1);
let sz = match self
.local_disk
.get_size_with_tier_names(item.clone(), &self.tier_registry.names)
.await
{
let size_result = run_scanner_node_operation(self.node_admission.as_ref(), &ctx, async {
self.object_metadata_visits.increment(1);
self.local_disk
.get_size_with_tier_names(item.clone(), &self.tier_registry.names)
.await
})
.await?;
if self.node_admission.is_some() && ctx.is_cancelled() {
return Err(ScannerError::Other("Operation cancelled".to_string()));
}
let sz = match size_result {
Ok(sz) => sz,
Err(e) => {
let failure_action = classify_get_size_failure(&item, &e);
@@ -2606,7 +2635,7 @@ impl FolderScanner {
{
Ok(outcome) => outcome,
Err(e) => {
if ctx.is_cancelled() {
if ctx.is_cancelled() || matches!(&e, ScannerError::ResourceDeferred) {
self.preserve_partial_child_progress(&folder_item.parent, &h, into, &dst)
.await;
return Err(e);
@@ -2847,19 +2876,23 @@ impl FolderScanner {
let mut seen_truncated_objects: HashSet<String> = HashSet::new();
loop {
if agreed_closed && partial_closed && finished_closed {
if child_ctx.is_cancelled() || (agreed_closed && partial_closed && finished_closed) {
break;
}
#[cfg(test)]
let defer_entries = self.defer_abandoned_entries_until_finished && !finished_closed;
#[cfg(not(test))]
let defer_entries = false;
select! {
entry_name = agreed_rx.recv(), if !agreed_closed => {
entry_name = agreed_rx.recv(), if !agreed_closed && !defer_entries => {
let Some(entry_name) = entry_name else {
agreed_closed = true;
continue;
};
(self.update_current_path)(&entry_name).await;
}
entries = partial_rx.recv(), if !partial_closed => {
entries = partial_rx.recv(), if !partial_closed && !defer_entries => {
let Some(entries) = entries else {
partial_closed = true;
continue;
@@ -2985,6 +3018,7 @@ impl FolderScanner {
finished_closed = true;
continue;
};
finished_closed = true;
if disk_errors_are_only_missing_paths(&errs) {
debug!(
target: "rustfs::scanner::folder",
@@ -3006,7 +3040,9 @@ impl FolderScanner {
"Scanner list_path_raw finished with disk errors"
);
}
child_ctx.cancel();
// The producer awaited callback sends, not their
// consumption. Drain the remaining candidate and
// agreed frames before closing this child walk.
}
_ = child_ctx.cancelled() => {
break;
@@ -3024,7 +3060,10 @@ impl FolderScanner {
if into.compacted {
// In compacted mode child totals are accumulated directly into the parent entry.
let fut = Box::pin(self.scan_folder(ctx.clone(), folder_item.clone(), into));
fut.await.map_err(|e| ScannerError::Other(e.to_string()))?;
fut.await.map_err(|error| match error {
ScannerError::ResourceDeferred => ScannerError::ResourceDeferred,
other => ScannerError::Other(other.to_string()),
})?;
self.send_update_for_entry(&this_hash, &folder.parent, into).await;
tokio::task::yield_now().await;
} else {
@@ -3043,7 +3082,7 @@ impl FolderScanner {
)
.await
{
if ctx.is_cancelled() {
if ctx.is_cancelled() || matches!(&e, ScannerError::ResourceDeferred) {
self.preserve_partial_child_progress(&folder_item.parent, &h, into, &dst)
.await;
return Err(e);
@@ -3217,6 +3256,7 @@ pub(crate) async fn scan_data_folder_scoped(
failed_object_paths_seen: HashSet::new(),
resolved_failed_object_paths: HashSet::new(),
sleeper,
node_admission: crate::workload_admission::scanner_node_admission(),
disks,
disks_quorum,
usage_updates,
@@ -3244,6 +3284,8 @@ pub(crate) async fn scan_data_folder_scoped(
pending_size_reconciliation_truncated: false,
#[cfg(test)]
list_path_raw_options_observer: None,
#[cfg(test)]
defer_abandoned_entries_until_finished: false,
};
let now = FolderScanner::now_secs();
@@ -3333,7 +3375,7 @@ pub(crate) async fn scan_data_folder_scoped(
}
}
Err(e) => {
if ctx.is_cancelled() {
if ctx.is_cancelled() || matches!(&e, ScannerError::ResourceDeferred) {
let root_hash = hash_path(&bucket_name);
let root_has_progress = data_usage_root_has_progress(&root);
let pending_heals_changed = scanner.pending_heals_changed;
@@ -3408,6 +3450,19 @@ pub(crate) async fn scan_data_folder_scoped(
close_disk_guard.close().await;
return Err(ScannerError::NamespaceNotFoundCache(Box::new(partial_cache)));
}
if matches!(&e, ScannerError::ResourceDeferred) {
// Admission pressure is not disk failure or an empty census.
// Keep the previous tree when no new useful progress exists.
let mut partial_cache = scanner.old_cache;
if partial_cache.cache.is_empty() {
partial_cache
.cache
.insert(hash_path(&bucket_name).0, DataUsageEntry::default());
}
partial_cache.info.snapshot_complete = false;
close_disk_guard.close().await;
return Err(ScannerError::PartialCache(Box::new(partial_cache)));
}
close_disk_guard.close().await;
// No useful information, return original cache
Err(e)
@@ -3418,6 +3473,7 @@ pub(crate) async fn scan_data_folder_scoped(
mod item_actions;
mod ledger;
pub(crate) use item_actions::scanner_disk_heal_scope;
use item_actions::*;
pub use item_actions::{GetSizeFn, ScannerItem};
use ledger::*;
@@ -3427,3 +3483,15 @@ pub(crate) use tests::checkpoint_fixture::run_checkpoint_fixture;
#[cfg(test)]
mod tests;
#[cfg(test)]
tokio::task_local! {
pub(crate) static TEST_SIZE_DISPATCH_BOUNDARIES: tokio::sync::mpsc::UnboundedSender<&'static str>;
}
#[cfg(test)]
pub(crate) fn observe_test_size_dispatch_boundary(stage: &'static str) -> bool {
TEST_SIZE_DISPATCH_BOUNDARIES
.try_with(|sender| sender.send(stage).is_ok())
.unwrap_or(false)
}
@@ -474,10 +474,23 @@ pub(super) fn record_heal_candidate_admission(
}
}
pub(crate) fn scanner_disk_heal_scope(disk: &Disk) -> Option<DataUsageCacheSource> {
let location = disk.get_disk_location();
location.valid().then_some(())?;
Some(DataUsageCacheSource::new(location.pool_idx?, location.set_idx?))
}
pub(super) async fn send_scanner_heal_request(
candidate_type: &'static str,
request: HealChannelRequest,
mut request: HealChannelRequest,
scope: Option<DataUsageCacheSource>,
) -> Result<HealAdmissionResult, ScannerError> {
if request.object_prefix.as_deref().is_some_and(|object| !object.is_empty()) {
let scope = scope
.ok_or_else(|| ScannerError::Other("scanner object heal requires the scanning disk's pool and set".to_string()))?;
request.pool_index = Some(scope.pool_index);
request.set_index = Some(scope.set_index);
}
let priority = request.priority;
let trace_context = scanner_heal_candidate_trace_context(&request);
let candidate = crate::heal_completion::prepare(&request);
@@ -537,6 +550,8 @@ pub struct ScannerItem {
pub lifecycle: Option<Arc<BucketLifecycleConfiguration>>,
pub object_lock: Option<Arc<ObjectLockConfiguration>>,
pub replication: Option<Arc<ReplicationConfig>>,
/// Actual scanning-disk location; refreshed by the disk reader before actions.
pub heal_scope: Option<DataUsageCacheSource>,
pub heal_enabled: bool,
pub heal_bitrot: bool,
pub debug: bool,
@@ -628,6 +643,22 @@ impl ScannerItem {
tier_names: &[String],
size_summary: &mut SizeSummary,
) {
self.apply_actions_cancellable(object_infos, lock_retention, versioning_config, tier_names, size_summary, None)
.await;
}
pub(crate) async fn apply_actions_cancellable(
&mut self,
object_infos: Vec<ObjectInfo>,
lock_retention: Option<Arc<ObjectLockConfiguration>>,
versioning_config: VersioningConfiguration,
tier_names: &[String],
size_summary: &mut SizeSummary,
cancellation: Option<&CancellationToken>,
) {
if cancellation.is_some_and(CancellationToken::is_cancelled) {
return;
}
let object_path = self.object_path();
if object_infos.is_empty() {
debug!(
@@ -671,6 +702,9 @@ impl ScannerItem {
let Some(lifecycle) = self.lifecycle.clone() else {
let mut cumulative_size: i64 = 0;
for (oi, resolved_size) in object_infos.iter().zip(resolved_sizes.iter()) {
if cancellation.is_some_and(CancellationToken::is_cancelled) {
return;
}
let accounting_size = match resolved_size {
SizeResolution::Known { logical, .. } => *logical,
// A valid compressed legacy sentinel has no logical size,
@@ -678,7 +712,7 @@ impl ScannerItem {
// physical size is only an input to those operations; it
// is not folded into the logical total below.
SizeResolution::Unknown { physical, .. } => {
self.heal_actions(oi, *physical, size_summary).await;
self.heal_actions(oi, *physical, size_summary, cancellation).await;
size_summary.actions_accounting_unknown(oi);
continue;
}
@@ -688,13 +722,16 @@ impl ScannerItem {
}
};
let size = self.heal_actions(oi, accounting_size, size_summary).await;
let size = self.heal_actions(oi, accounting_size, size_summary, cancellation).await;
size_summary.actions_accounting(oi, size, accounting_size);
cumulative_size = cumulative_size.saturating_add(size);
}
if cancellation.is_some_and(CancellationToken::is_cancelled) {
return;
}
self.alert_excessive_versions(object_infos.len(), cumulative_size);
debug!(
@@ -736,6 +773,10 @@ impl ScannerItem {
}
};
if cancellation.is_some_and(CancellationToken::is_cancelled) {
return;
}
// Every version handed to the evaluator counts as an ILM-checked
// version, whether or not a rule matched (NoneAction included). This is
// reached only for buckets with lifecycle rules; it feeds
@@ -754,6 +795,9 @@ impl ScannerItem {
.all(|candidate| Self::tier_is_known(candidate, tier_names));
'eventLoop: {
for (i, event) in events.iter().enumerate() {
if cancellation.is_some_and(CancellationToken::is_cancelled) {
return;
}
let oi = &object_infos[i];
let known_size = resolved_sizes[i].known_size();
if has_corrupt_size
@@ -772,7 +816,7 @@ impl ScannerItem {
// accounting. Size-filtered or deferred events remain
// pending, so retain the version-only physical counters.
if let SizeResolution::Unknown { physical, .. } = &resolved_sizes[i] {
self.heal_actions(oi, *physical, size_summary).await;
self.heal_actions(oi, *physical, size_summary, cancellation).await;
size_summary.actions_accounting_unknown(oi);
}
continue;
@@ -829,7 +873,7 @@ impl ScannerItem {
noncurrent_unknown.push(oi);
} else {
if let SizeResolution::Unknown { physical, .. } = &resolved_sizes[i] {
self.heal_actions(oi, *physical, size_summary).await;
self.heal_actions(oi, *physical, size_summary, cancellation).await;
}
size_summary.actions_accounting_unknown(oi);
}
@@ -846,7 +890,7 @@ impl ScannerItem {
}
IlmAction::NoneAction | IlmAction::ActionCount => {
if let SizeResolution::Unknown { physical, .. } = &resolved_sizes[i] {
self.heal_actions(oi, *physical, size_summary).await;
self.heal_actions(oi, *physical, size_summary, cancellation).await;
}
size_summary.actions_accounting_unknown(oi);
}
@@ -864,7 +908,7 @@ impl ScannerItem {
// side-effecting tier actions fail closed until the registry
// recognizes the source again.
if Self::action_blocked_by_unknown_tier(event.action, oi, all_versions_known, tier_names, &event.storage_class) {
size = self.heal_actions(oi, actual_size, size_summary).await;
size = self.heal_actions(oi, actual_size, size_summary, cancellation).await;
size_summary.actions_accounting(oi, size, actual_size);
cumulative_size += size;
continue;
@@ -973,7 +1017,7 @@ impl ScannerItem {
}
IlmAction::NoneAction | IlmAction::ActionCount => {
size = self.heal_actions(oi, actual_size, size_summary).await;
size = self.heal_actions(oi, actual_size, size_summary, cancellation).await;
}
}
@@ -984,6 +1028,9 @@ impl ScannerItem {
}
}
if cancellation.is_some_and(CancellationToken::is_cancelled) {
return;
}
if !to_delete_objs.is_empty()
&& let Some(event) = noncurrent_events.first().cloned()
{
@@ -991,7 +1038,8 @@ impl ScannerItem {
let count = u64::try_from(to_delete_objs.len()).unwrap_or(u64::MAX);
let done_ilm = Metrics::time_ilm(action);
let trace_started_at = trace_start_instant();
let queued = enqueue_runtime_newer_noncurrent(&self.bucket, to_delete_objs, event, &LcEventSrc::Scanner).await;
let queued =
enqueue_runtime_newer_noncurrent(&self.bucket, to_delete_objs, event, &LcEventSrc::Scanner, cancellation).await;
if let Some(trace_started_at) = trace_started_at {
let state = if queued { "queued" } else { "not_queued" };
trace_emit(|| {
@@ -1022,15 +1070,28 @@ impl ScannerItem {
}
}
}
self.alert_excessive_versions(remaining_versions, cumulative_size);
if !cancellation.is_some_and(CancellationToken::is_cancelled) {
self.alert_excessive_versions(remaining_versions, cumulative_size);
}
}
pub(super) async fn heal_actions(&mut self, oi: &ObjectInfo, actual_size: i64, size_summary: &mut SizeSummary) -> i64 {
pub(super) async fn heal_actions(
&mut self,
oi: &ObjectInfo,
actual_size: i64,
size_summary: &mut SizeSummary,
cancellation: Option<&CancellationToken>,
) -> i64 {
if cancellation.is_some_and(CancellationToken::is_cancelled) {
return actual_size;
}
if self.heal_enabled {
self.enqueue_heal(oi).await;
}
self.heal_replication(oi, size_summary).await;
if !cancellation.is_some_and(CancellationToken::is_cancelled) {
self.heal_replication(oi, size_summary).await;
}
actual_size
}
@@ -1161,6 +1222,7 @@ impl ScannerItem {
scan_mode,
HealChannelPriority::Low,
),
self.heal_scope,
)
.await;
@@ -1322,6 +1384,7 @@ mod tests {
lifecycle: None,
object_lock: None,
replication: None,
heal_scope: None,
heal_enabled: false,
heal_bitrot: false,
debug: false,
+97 -5
View File
@@ -30,7 +30,9 @@ use std::sync::Mutex;
pub(super) mod checkpoint_fixture;
pub(super) mod enumeration_restart;
mod incremental_enumeration;
mod listing_completion;
mod metadata_visits;
mod node_admission;
mod usage_updates;
/// Reset the process-global alert cooldown map; test-only.
@@ -337,7 +339,10 @@ async fn build_test_scanner() -> (FolderScanner, std::path::PathBuf) {
.await
.expect("failed to create test directory");
let endpoint = Endpoint::try_from(temp_dir.to_string_lossy().as_ref()).expect("failed to create endpoint");
let mut endpoint = Endpoint::try_from(temp_dir.to_string_lossy().as_ref()).expect("failed to create endpoint");
endpoint.set_pool_index(0);
endpoint.set_set_index(0);
endpoint.set_disk_index(0);
let disk = new_disk(
&endpoint,
&DiskOption {
@@ -364,6 +369,7 @@ async fn build_test_scanner() -> (FolderScanner, std::path::PathBuf) {
failed_object_paths_seen: HashSet::new(),
resolved_failed_object_paths: HashSet::new(),
sleeper: SCANNER_SLEEPER.clone(),
node_admission: None,
disks: Vec::new(),
disks_quorum: 0,
usage_updates: None,
@@ -394,6 +400,7 @@ async fn build_test_scanner() -> (FolderScanner, std::path::PathBuf) {
pending_size_reconciliation_scopes: HashSet::new(),
pending_size_reconciliation_truncated: false,
list_path_raw_options_observer: None,
defer_abandoned_entries_until_finished: false,
};
(scanner, temp_dir)
@@ -762,6 +769,7 @@ fn test_classify_get_size_failure_marks_metadata_heal_object_path() {
lifecycle: None,
object_lock: None,
replication: None,
heal_scope: None,
heal_enabled: false,
heal_bitrot: false,
debug: false,
@@ -793,6 +801,7 @@ fn test_classify_get_size_failure_records_transient_metadata_error_without_heal(
lifecycle: None,
object_lock: None,
replication: None,
heal_scope: None,
heal_enabled: false,
heal_bitrot: false,
debug: false,
@@ -849,6 +858,7 @@ async fn test_heal_replication_skips_only_unversioned_data_versions() {
lifecycle: None,
object_lock: None,
replication: Some(Arc::new(ReplicationConfig::new(None, None))),
heal_scope: None,
heal_enabled: false,
heal_bitrot: false,
debug: false,
@@ -2251,6 +2261,7 @@ async fn test_heal_actions_returns_actual_size_without_inline_heal() {
lifecycle: None,
object_lock: None,
replication: None,
heal_scope: None,
heal_enabled: true,
heal_bitrot: true,
debug: false,
@@ -2262,7 +2273,7 @@ async fn test_heal_actions_returns_actual_size_without_inline_heal() {
};
let mut size_summary = SizeSummary::default();
let size = item.heal_actions(&object_info, 123, &mut size_summary).await;
let size = item.heal_actions(&object_info, 123, &mut size_summary, None).await;
assert_eq!(size, 123);
}
@@ -2491,7 +2502,7 @@ async fn test_scan_folder_exits_when_abandoned_child_listing_finishes() {
let _guard = TestGuard::new(60, 100, &mut scanner, temp_dir.clone());
let heal_starts = Arc::new(AtomicUsize::new(0));
let heal_starts_clone = heal_starts.clone();
let healed_versions = Arc::new(Mutex::new(Vec::<Option<String>>::new()));
let healed_versions = Arc::new(Mutex::new(Vec::new()));
let healed_versions_clone = healed_versions.clone();
let (_commands, mut heal_rx) = rustfs_heal_contracts::heal_channel::init_heal_channels()
.expect("heal channels should initialize once for scanner tests");
@@ -2503,7 +2514,7 @@ async fn test_scan_folder_exits_when_abandoned_child_listing_finishes() {
healed_versions_clone
.lock()
.expect("heal version capture lock should not be poisoned")
.push(request.object_version_id);
.push((request.object_prefix, request.object_version_id, request.pool_index, request.set_index));
let _ = response_tx.send(Ok(rustfs_heal_contracts::heal_channel::HealAdmissionReceipt {
result: HealAdmissionResult::Accepted,
task_id: request.id,
@@ -2584,9 +2595,90 @@ async fn test_scan_folder_exits_when_abandoned_child_listing_finishes() {
.lock()
.expect("heal version capture lock should not be poisoned")
.iter()
.any(|version| version.as_deref() == Some(orphan_version_text.as_str())),
.any(|(_, version, _, _)| version.as_deref() == Some(orphan_version_text.as_str())),
"sub-quorum orphan version must be submitted as an exact heal candidate"
);
assert!(
healed_versions
.lock()
.expect("heal scope capture lock should not be poisoned")
.iter()
.filter(|(object, _, _, _)| object.is_some())
.all(|(_, _, pool, set)| (*pool, *set) == (Some(0), Some(0))),
"high-priority discovered objects must retain the scanning disk's scope"
);
let bucket_admission = scanner
.send_required_scanner_heal_request(
PendingScannerHealKind::Bucket,
bucket.to_string(),
None,
None,
build_bucket_heal_request(bucket.to_string(), HealChannelPriority::High),
)
.await
.expect("submit bucket request");
assert_eq!(bucket_admission, HealAdmissionResult::Accepted);
assert_eq!(
healed_versions.lock().expect("read captured bucket scope").last(),
Some(&(None, None, None, None)),
"bucket healing must retain its cross-pool scope"
);
scanner.new_cache.info.name = bucket.to_string();
scanner.new_cache.info.pending_heals = vec![pending_heal(
PendingScannerHealKind::Object,
bucket,
Some("pending-object"),
Some(&orphan_version_text),
0,
1,
)];
scanner
.retry_pending_scanner_heals()
.await
.expect("retry persisted object heal");
assert_eq!(
healed_versions.lock().expect("read retried object scope").last(),
Some(&(Some("pending-object".to_string()), Some(orphan_version_text.clone()), Some(0), Some(0))),
"persisted retries must bind the current scanning disk"
);
let unscoped_endpoint = Endpoint::try_from(temp_dir.to_string_lossy().as_ref()).expect("parse uninitialized endpoint");
scanner.local_disk = new_disk(
&unscoped_endpoint,
&DiskOption {
cleanup: false,
health_check: false,
},
)
.await
.expect("open disk without initialized location");
let request_count = healed_versions.lock().expect("count admitted requests").len();
let deferred = scanner
.send_required_scanner_heal_request(
PendingScannerHealKind::Object,
bucket.to_string(),
Some("unscoped-object".to_string()),
Some(orphan_version_text.clone()),
build_object_heal_request(
bucket.to_string(),
"unscoped-object".to_string(),
Some(orphan_version_text),
HealScanMode::Deep,
HealChannelPriority::High,
),
)
.await
.expect("defer object with unknown disk location");
assert_eq!(deferred, HealAdmissionResult::Full);
assert_eq!(healed_versions.lock().expect("count unchanged admissions").len(), request_count);
assert!(
scanner
.new_cache
.info
.pending_heals
.iter()
.any(|entry| entry.object.as_deref() == Some("unscoped-object")),
"an unscoped high-priority object must remain pending"
);
for (path, expected) in expected_metadata {
assert_eq!(
tokio::fs::read(&path)
@@ -0,0 +1,104 @@
// Copyright 2026 RustFS Team
// Licensed under the Apache License, Version 2.0.
use super::*;
#[tokio::test]
#[serial]
async fn abandoned_listing_drains_candidate_buffered_before_finished() {
let (mut scanner, root) = build_test_scanner().await;
let _guard = TestGuard {
temp_dir: Some(root.clone()),
};
let bucket = "bucket";
let object = "abandoned/object";
tokio::fs::create_dir_all(root.join(bucket)).await.expect("local bucket");
let mut disks = vec![scanner.local_disk.clone()];
let mut original_metadata = Vec::new();
for (index, version) in [(2, 0x1935), (3, 0x1935), (4, 0x1934)] {
let disk_root = root.join(format!("disk-{index}"));
let metadata = metadata_for_object_version(bucket, object, Some(Uuid::from_u128(version)));
write_test_object_metadata_bytes(&disk_root, bucket, object, &metadata).await;
original_metadata.push((disk_root.join(bucket).join(object).join(STORAGE_FORMAT_FILE), metadata));
let endpoint = Endpoint::try_from(disk_root.to_string_lossy().as_ref()).expect("listing fixture endpoint");
disks.push(
new_disk(
&endpoint,
&DiskOption {
cleanup: false,
health_check: false,
},
)
.await
.expect("listing fixture disk"),
);
}
for cache in [&mut scanner.old_cache, &mut scanner.new_cache] {
cache.info.name = bucket.to_owned();
}
scanner.old_cache.replace(
"bucket/abandoned/object",
bucket,
DataUsageEntry {
objects: 1,
..Default::default()
},
);
scanner.heal_object_select = 1;
scanner.disks = disks;
scanner.disks_quorum = 2;
scanner.sleeper = DynamicSleeper::new(rustfs_config::ScannerSpeed::Fastest);
// Force the valid select ordering that receives terminal errors before the
// final candidate. One candidate frame fits in the bounded partial channel.
scanner.defer_abandoned_entries_until_finished = true;
let (_commands, mut heal_rx) = rustfs_heal_contracts::heal_channel::init_heal_channels().expect("isolated heal channel");
let (captured_tx, mut captured_rx) = mpsc::unbounded_channel();
let responder = tokio::spawn(async move {
while let Some(command) = heal_rx.recv().await {
let rustfs_heal_contracts::heal_channel::HealReceiptCommand { request, response_tx } = command;
captured_tx
.send((request.object_prefix.clone(), request.object_version_id.clone()))
.expect("candidate capture remains open");
let _ = response_tx.send(Ok(rustfs_heal_contracts::heal_channel::HealAdmissionReceipt {
result: HealAdmissionResult::Accepted,
task_id: request.id,
}));
}
});
let result = tokio::time::timeout(
Duration::from_secs(2),
scanner.scan_folder(
CancellationToken::new(),
CachedFolder {
name: bucket.to_owned(),
parent: None,
object_heal_prob_div: 1,
},
&mut DataUsageEntry::default(),
),
)
.await;
responder.abort();
result
.expect("listing completion must close every callback channel")
.expect("complete folder scan");
let mut candidates = HashSet::new();
while let Ok((object, version)) = captured_rx.try_recv() {
if let (Some(object), Some(version)) = (object, version) {
candidates.insert((object, version));
}
}
let expected = [0x1934, 0x1935]
.into_iter()
// Raw base-object listings include the trailing separator. Preserve
// the producer's candidate identity when draining its callback frame.
.map(|version| (format!("{object}/"), Uuid::from_u128(version).to_string()))
.collect();
assert_eq!(
candidates, expected,
"terminal disk errors must not discard buffered exact-version candidates"
);
for (path, metadata) in original_metadata {
assert_eq!(tokio::fs::read(path).await.expect("retained metadata"), metadata);
}
}
File diff suppressed because it is too large Load Diff
+55 -3
View File
@@ -172,12 +172,55 @@ fn record_checkpoint_save_failure(cache_name: &str, checkpoint: &DataUsageCache,
#[derive(Debug)]
pub(crate) struct ScannerCacheLockGuards {
scoped: NamespaceLockGuard,
scoped: Option<NamespaceLockGuard>,
}
impl ScannerCacheLockGuards {
pub(crate) fn is_lock_lost(&self) -> bool {
self.scoped.is_lock_lost()
self.scoped.as_ref().is_none_or(NamespaceLockGuard::is_lock_lost)
}
/// Suspend the same traversal while yielding its cache lease. Lock order on
/// resumption is node admission -> cache lease -> cache revision reads.
pub(crate) async fn resume_node_operation(
&mut self,
store: Arc<SetDisks>,
cache_name: &str,
source: DataUsageCacheSource,
revisions: &DataUsageCacheRevisions,
request: crate::workload_admission::ScannerNodeRequest,
) -> Result<()> {
if self.is_lock_lost() {
return Err(Error::other("scanner cache lock was lost before node admission"));
}
if request.respond.is_closed() {
return Ok(());
}
let crate::workload_admission::ScannerNodeRequest { owner, respond, cancel } = request;
drop(self.scoped.take());
let resume = async {
let permit = owner.acquire().await;
*self = acquire_scanner_cache_locks(store.as_ref(), cache_name, source)
.await
.map_err(Error::other)?;
let current = crate::storage_api::ecstore_with_background_disk_io(
cancel.clone(),
DataUsageCache::read_revisions(store, cache_name),
)
.await?;
if self.is_lock_lost() || &current != revisions {
return Err(Error::other("scanner cache changed while waiting for node admission"));
}
// If the walk was dropped, send returns ownership and drops the
// permit without recording completed work.
let _ = respond.send(permit);
Ok(())
};
tokio::select! {
biased;
_ = cancel.cancelled() => Err(Error::other("scanner node admission cancelled")),
result = resume => result,
}
}
}
@@ -215,6 +258,15 @@ impl std::fmt::Display for ScannerCacheLockError {
}
}
impl std::error::Error for ScannerCacheLockError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Create { source, .. } => Some(source),
Self::Acquire { source, .. } => Some(source),
}
}
}
pub(crate) async fn acquire_scanner_cache_locks(
store: &SetDisks,
cache_name: &str,
@@ -237,7 +289,7 @@ pub(crate) async fn acquire_scanner_cache_locks(
source,
})?;
Ok(ScannerCacheLockGuards { scoped })
Ok(ScannerCacheLockGuards { scoped: Some(scoped) })
}
pub(super) async fn await_scanner_disk_shutdown<F>(scan: Pin<&mut F>)
+184 -2
View File
@@ -238,6 +238,71 @@ pub enum ScannerDurableDirtyUsageReplayScope {
TopLevelEntries { entries: BTreeSet<String> },
}
impl ScannerDurableDirtyUsageReplayRecord {
/// Replays decoded producer hints with the canonical byte limit and semantic checks.
///
/// Callers must retain their framing and owner checks. This performs the same
/// state update as [`replay_durable_dirty_usage_producer_record`]; it does not
/// establish storage-verified source authority.
pub fn replay(self) -> std::result::Result<ScannerDirtyUsageState, ScannerDurableDirtyUsageReplayError> {
check_durable_dirty_usage_replay_bytes(&self)?;
replay_decoded_dirty_usage_record(&self)
}
}
struct ReplayByteLimit {
remaining: usize,
}
impl std::io::Write for ReplayByteLimit {
fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
self.remaining = self
.remaining
.checked_sub(bytes.len())
.ok_or_else(|| std::io::Error::other("dirty replay byte limit"))?;
Ok(bytes.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
fn check_durable_dirty_usage_replay_bytes(
record: &ScannerDurableDirtyUsageReplayRecord,
) -> std::result::Result<(), ScannerDurableDirtyUsageReplayError> {
// Bound structure traversal and string scanning before counting escaped JSON.
// Each entry or prefix contributes at least one additional serialized byte.
let mut remaining = SCANNER_DURABLE_DIRTY_USAGE_REPLAY_MAX_BYTES;
let mut charge = |size: usize| -> std::result::Result<(), ScannerDurableDirtyUsageReplayError> {
remaining = remaining
.checked_sub(size)
.ok_or(ScannerDurableDirtyUsageReplayError::ByteLimit)?;
Ok(())
};
charge(record.writer_epoch.len())?;
charge(record.entries.len())?;
for entry in &record.entries {
charge(entry.bucket.len())?;
if let ScannerDurableDirtyUsageReplayScope::TopLevelEntries { entries } = &entry.scope {
charge(entries.len())?;
for entry in entries {
charge(entry.len())?;
}
}
}
let mut limit = ReplayByteLimit {
remaining: SCANNER_DURABLE_DIRTY_USAGE_REPLAY_MAX_BYTES,
};
serde_json::to_writer(&mut limit, record).map_err(|error| {
if error.is_io() {
ScannerDurableDirtyUsageReplayError::ByteLimit
} else {
ScannerDurableDirtyUsageReplayError::InvalidJson
}
})
}
pub fn encode_durable_dirty_usage_producer_replay_record(
entries: Vec<ScannerDurableDirtyUsageReplayEntry>,
) -> std::result::Result<Vec<u8>, ScannerDurableDirtyUsageReplayError> {
@@ -263,7 +328,13 @@ pub fn replay_durable_dirty_usage_producer_record(
}
let record: ScannerDurableDirtyUsageReplayRecord =
serde_json::from_slice(bytes).map_err(|_| ScannerDurableDirtyUsageReplayError::InvalidJson)?;
let replay = validate_durable_dirty_usage_replay_record(&record)?;
replay_decoded_dirty_usage_record(&record)
}
fn replay_decoded_dirty_usage_record(
record: &ScannerDurableDirtyUsageReplayRecord,
) -> std::result::Result<ScannerDirtyUsageState, ScannerDurableDirtyUsageReplayError> {
let replay = validate_durable_dirty_usage_replay_record(record)?;
let max_generation = replay.iter().map(|entry| entry.generation).max().unwrap_or(0);
set_dirty_usage_generation_floor(max_generation)?;
let pending = {
@@ -772,6 +843,109 @@ mod scoped_dirty_usage_tests {
);
}
fn decoded_replay_fixture() -> ScannerDurableDirtyUsageReplayRecord {
ScannerDurableDirtyUsageReplayRecord {
schema: SCANNER_DURABLE_DIRTY_USAGE_REPLAY_SCHEMA,
cache_key_format: crate::DATA_USAGE_CACHE_KEY_FORMAT,
writer_epoch: "writer".to_string(),
entries: vec![ScannerDurableDirtyUsageReplayEntry {
bucket: "photos".to_string(),
generation: 7,
scope: ScannerDurableDirtyUsageReplayScope::TopLevelEntries {
entries: BTreeSet::from(["2026".to_string(), "archive".to_string()]),
},
producers: SegmentInvalidationProducerIdentity::REQUIRED_PRODUCTION.into_iter().collect(),
}],
}
}
#[test]
#[serial]
fn durable_dirty_usage_decoded_replay_matches_byte_replay_at_size_boundary() {
let record = decoded_replay_fixture();
let fixed_bytes = serde_json::to_vec(&record).expect("fixture encoding").len() - record.writer_epoch.len();
for size in [
SCANNER_DURABLE_DIRTY_USAGE_REPLAY_MAX_BYTES - 1,
SCANNER_DURABLE_DIRTY_USAGE_REPLAY_MAX_BYTES,
SCANNER_DURABLE_DIRTY_USAGE_REPLAY_MAX_BYTES + 1,
] {
clear_dirty_usage_buckets_for_tests();
let mut record = record.clone();
record.writer_epoch = "x".repeat(size - fixed_bytes);
let bytes = serde_json::to_vec(&record).expect("bounded fixture encoding");
assert_eq!(bytes.len(), size);
let expected = replay_durable_dirty_usage_producer_record(&bytes);
let buckets = dirty_usage_buckets_for_tests();
let scopes = dirty_usage_bucket_scopes_for_tests();
let identities = dirty_usage_producer_identities().clone();
let coverage = DIRTY_USAGE_PRODUCER_COVERAGE.load(Ordering::Acquire);
clear_dirty_usage_buckets_for_tests();
assert_eq!(record.replay(), expected, "canonical size {size}");
assert_eq!(dirty_usage_buckets_for_tests(), buckets);
assert_eq!(dirty_usage_bucket_scopes_for_tests(), scopes);
assert_eq!(*dirty_usage_producer_identities(), identities);
assert_eq!(DIRTY_USAGE_PRODUCER_COVERAGE.load(Ordering::Acquire), coverage);
}
clear_dirty_usage_buckets_for_tests();
}
#[test]
fn durable_dirty_usage_decoded_size_counts_escaped_utf8() {
for repeats in [6000, 8000] {
let mut record = decoded_replay_fixture();
record.writer_epoch = "\"\n雪\\".repeat(repeats);
let bytes = serde_json::to_vec(&record).expect("escaped fixture encoding");
let expected = if bytes.len() > SCANNER_DURABLE_DIRTY_USAGE_REPLAY_MAX_BYTES {
Err(ScannerDurableDirtyUsageReplayError::ByteLimit)
} else {
Ok(())
};
assert_eq!(check_durable_dirty_usage_replay_bytes(&record), expected);
}
}
#[test]
#[serial]
fn durable_dirty_usage_decoded_replay_rejects_large_values_before_state_changes() {
for field in ["epoch", "prefix", "entries"] {
clear_dirty_usage_buckets_for_tests();
let before = scanner_dirty_usage_state();
let mut record = decoded_replay_fixture();
match field {
"epoch" => record.writer_epoch = "x".repeat(1024 * 1024),
"prefix" => {
record.entries[0].scope = ScannerDurableDirtyUsageReplayScope::TopLevelEntries {
entries: BTreeSet::from(["x".repeat(1024 * 1024)]),
};
}
_ => record.entries = vec![record.entries[0].clone(); SCANNER_DURABLE_DIRTY_USAGE_REPLAY_MAX_BYTES + 1],
}
assert_eq!(record.replay(), Err(ScannerDurableDirtyUsageReplayError::ByteLimit), "{field}");
assert_eq!(scanner_dirty_usage_state(), before);
assert!(dirty_usage_buckets_for_tests().is_empty());
assert!(dirty_usage_bucket_scopes_for_tests().is_empty());
assert!(dirty_usage_producer_identities().is_empty());
assert_eq!(DIRTY_USAGE_PRODUCER_COVERAGE.load(Ordering::Acquire), 0);
}
clear_dirty_usage_buckets_for_tests();
}
#[test]
#[serial]
fn durable_dirty_usage_decoded_replay_preserves_generation_exhaustion() {
clear_dirty_usage_buckets_for_tests();
let generation = DIRTY_USAGE_BUCKET_GENERATION.swap(u64::MAX, Ordering::AcqRel);
let result = decoded_replay_fixture().replay();
DIRTY_USAGE_BUCKET_GENERATION.store(generation, Ordering::Release);
assert_eq!(result, Err(ScannerDurableDirtyUsageReplayError::InvalidGeneration));
assert!(dirty_usage_buckets_for_tests().is_empty());
assert!(dirty_usage_bucket_scopes_for_tests().is_empty());
assert!(dirty_usage_producer_identities().is_empty());
assert_eq!(DIRTY_USAGE_PRODUCER_COVERAGE.load(Ordering::Acquire), 0);
clear_dirty_usage_buckets_for_tests();
}
#[test]
#[serial]
fn durable_dirty_usage_replay_rejects_invalid_records_without_partial_state() {
@@ -843,9 +1017,17 @@ mod scoped_dirty_usage_tests {
for (bytes, expected_error) in invalid_cases {
clear_dirty_usage_buckets_for_tests();
assert_eq!(replay_durable_dirty_usage_producer_record(&bytes), Err(expected_error));
let before = scanner_dirty_usage_state();
assert_eq!(replay_durable_dirty_usage_producer_record(&bytes), Err(expected_error.clone()));
assert!(dirty_usage_buckets_for_tests().is_empty());
assert!(dirty_usage_bucket_scopes_for_tests().is_empty());
let record: ScannerDurableDirtyUsageReplayRecord = serde_json::from_slice(&bytes).expect("typed invalid record");
assert_eq!(record.replay(), Err(expected_error));
assert_eq!(scanner_dirty_usage_state(), before);
assert!(dirty_usage_buckets_for_tests().is_empty());
assert!(dirty_usage_bucket_scopes_for_tests().is_empty());
assert!(dirty_usage_producer_identities().is_empty());
assert_eq!(DIRTY_USAGE_PRODUCER_COVERAGE.load(Ordering::Acquire), 0);
}
clear_dirty_usage_buckets_for_tests();
+22 -3
View File
@@ -1090,7 +1090,7 @@ impl ScannerIOCache for SetDisks {
// Lock order: scanner leader fence -> set-scoped per-bucket cache lock ->
// cache object read/write.
let cache_guard = match acquire_scanner_cache_locks(store_clone_clone.as_ref(), &cache_name, source).await {
let mut cache_guard = match acquire_scanner_cache_locks(store_clone_clone.as_ref(), &cache_name, source).await {
Ok(guard) => guard,
Err(e) => {
if e.is_contention() {
@@ -1320,9 +1320,10 @@ impl ScannerIOCache for SetDisks {
);
let (checkpoint_tx, mut checkpoint_rx) = mpsc::channel::<DataUsageCache>(1);
let (node_tx, mut node_rx) = crate::workload_admission::scanner_node_request_channel();
let scan_ctx = ctx_clone.child_token();
let scan = disk.clone().nsscanner_disk(
let scan = crate::workload_admission::with_scanner_node_requests(node_tx, disk.clone().nsscanner_disk(
scan_ctx.clone(),
budget_clone.clone(),
set_disk_inventory_clone.as_ref().clone(),
@@ -1333,7 +1334,7 @@ impl ScannerIOCache for SetDisks {
prefix_scan_scope,
checkpoint_tx: Some(checkpoint_tx),
},
);
));
tokio::pin!(scan);
let mut lock_watch = tokio::time::interval(SCANNER_CACHE_LOCK_POLL_INTERVAL);
lock_watch.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
@@ -1341,6 +1342,24 @@ impl ScannerIOCache for SetDisks {
let scan_result = loop {
tokio::select! {
result = &mut scan => break result,
request = async {
match node_rx.as_mut() {
Some(receiver) => receiver.recv().await,
None => std::future::pending().await,
}
} => {
let Some(request) = request else {
node_rx = None;
continue;
};
if let Err(error) = cache_guard.resume_node_operation(
store_clone_clone.clone(), &cache_name, source, &revisions, request,
).await {
scan_ctx.cancel();
await_scanner_disk_shutdown(scan.as_mut()).await;
break Err(error);
}
}
_ = lock_watch.tick() => {
if cache_guard.is_lock_lost() {
scan_ctx.cancel();
+39 -5
View File
@@ -38,6 +38,13 @@ pub(super) fn tier_stats_template(tier_names: &[String]) -> HashMap<String, Tier
tier_stats
}
fn ensure_scanner_node_operation_active(cancellation: Option<&CancellationToken>) -> Result<()> {
if cancellation.is_some_and(CancellationToken::is_cancelled) {
return Err(StorageError::other("Scanner operation cancelled"));
}
Ok(())
}
#[async_trait::async_trait]
impl ScannerIODisk for Disk {
async fn get_size(&self, item: ScannerItem) -> Result<SizeSummary> {
@@ -45,14 +52,18 @@ impl ScannerIODisk for Disk {
}
async fn get_size_with_tier_names(&self, mut item: ScannerItem, tier_names: &[String]) -> Result<SizeSummary> {
let cancellation = crate::workload_admission::scanner_node_operation_cancellation();
let done_object = Metrics::time(Metric::ScanObject);
if !is_xl_meta_path(&item.path) {
return Err(StorageError::other(SCANNER_SKIP_FILE_ERROR.to_string()));
}
ensure_scanner_node_operation_active(cancellation.as_ref())?;
let metadata_object_path = item.object_path();
let data = match self.read_metadata(&item.bucket, &metadata_object_path).await {
let metadata_result = self.read_metadata(&item.bucket, &metadata_object_path).await;
ensure_scanner_node_operation_active(cancellation.as_ref())?;
let data = match metadata_result {
Ok(data) => data,
Err(e) if DiskError::is_err_object_not_found(&e) || DiskError::is_err_version_not_found(&e) => {
return Err(StorageError::other(SCANNER_SKIP_FILE_ERROR.to_string()));
@@ -86,7 +97,10 @@ impl ScannerIODisk for Disk {
// (which used to query it a second time). On failure keep the
// historical fallback: default configuration (versioned = false) plus
// the warn that `apply_actions` used to emit.
let versioning_config = match BucketVersioningSys::get(&item.bucket).await {
ensure_scanner_node_operation_active(cancellation.as_ref())?;
let versioning_result = BucketVersioningSys::get(&item.bucket).await;
ensure_scanner_node_operation_active(cancellation.as_ref())?;
let versioning_config = match versioning_result {
Ok(versioning_config) => versioning_config,
Err(_) => {
warn!(
@@ -122,7 +136,9 @@ impl ScannerIODisk for Disk {
..Default::default()
};
ensure_scanner_node_operation_active(cancellation.as_ref())?;
let lock_config = object_lock_config_for_scanner_item(&item).await;
ensure_scanner_node_operation_active(cancellation.as_ref())?;
// Count every version this object contributes to the scan, independent
// of any lifecycle configuration, so scan-coverage metrics stay honest
@@ -130,17 +146,35 @@ impl ScannerIODisk for Disk {
// `object_infos`.
global_metrics().record_scanner_versions_scanned(object_infos.len() as u64);
item.apply_actions(object_infos, lock_config, versioning_config, tier_names, &mut size_summary)
.await;
// A caller-provided item or persisted usage cache cannot choose another set.
item.heal_scope = crate::scanner_folder::scanner_disk_heal_scope(self);
#[cfg(test)]
let _ = crate::scanner_folder::observe_test_size_dispatch_boundary("apply_actions");
item.apply_actions_cancellable(
object_infos,
lock_config,
versioning_config,
tier_names,
&mut size_summary,
cancellation.as_ref(),
)
.await;
ensure_scanner_node_operation_active(cancellation.as_ref())?;
if !free_version_infos.is_empty() {
for oi in free_version_infos {
ensure_scanner_node_operation_active(cancellation.as_ref())?;
if ScannerItem::tier_is_known(&oi, tier_names) {
enqueue_runtime_free_version(oi).await;
#[cfg(test)]
if crate::scanner_folder::observe_test_size_dispatch_boundary("free_version_dispatch") {
continue;
}
enqueue_runtime_free_version(oi, cancellation.as_ref()).await;
}
}
}
ensure_scanner_node_operation_active(cancellation.as_ref())?;
done_object();
Ok(size_summary)
+6
View File
@@ -46,7 +46,9 @@ use uuid::Uuid;
mod checkpoint_coverage_identity;
mod collector_lifecycle;
mod expiry_lock;
mod listing_window_scope;
mod node_admission;
mod partial_bucket_carry;
mod per_bucket_certification;
mod scoped_entry_fallback;
@@ -1770,6 +1772,7 @@ async fn scanner_item_object_lock_uses_cached_config() {
lifecycle: None,
object_lock: Some(cached.clone()),
replication: None,
heal_scope: None,
heal_enabled: false,
heal_bitrot: false,
debug: false,
@@ -4519,6 +4522,7 @@ async fn get_size_treats_missing_metadata_as_skip_file() {
lifecycle: None,
object_lock: None,
replication: None,
heal_scope: None,
heal_enabled: false,
heal_bitrot: false,
debug: false,
@@ -4574,6 +4578,7 @@ async fn get_size_marks_corrupt_metadata_for_heal() {
lifecycle: None,
object_lock: None,
replication: None,
heal_scope: None,
heal_enabled: false,
heal_bitrot: false,
debug: false,
@@ -4656,6 +4661,7 @@ async fn get_size_counts_delete_markers_separately_from_versions() {
lifecycle: None,
object_lock: None,
replication: None,
heal_scope: None,
heal_enabled: false,
heal_bitrot: false,
debug: false,
@@ -0,0 +1,183 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use super::*;
use crate::storage_api::owner::EcstoreExpiryState;
use std::future::Future;
use std::pin::Pin;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::task::Poll;
use std::time::Duration;
// Return Some(result) if the operation ended too early, None only if it was
// actually polled pending after the prerequisite read had finished.
async fn observe_queued<F: Future>(
mut operation: Pin<&mut F>,
read_complete: impl Fn() -> bool,
observation: Duration,
) -> std::result::Result<Option<F::Output>, tokio::time::error::Elapsed> {
tokio::time::timeout(
observation,
std::future::poll_fn(|cx| match operation.as_mut().poll(cx) {
Poll::Ready(value) => Poll::Ready(Some(value)),
Poll::Pending if read_complete() => Poll::Ready(None),
Poll::Pending => Poll::Pending,
}),
)
.await
}
#[tokio::test]
#[serial]
async fn free_version_cancelled_while_waiting_for_expiry_write_lock_never_queues() {
let state = EcstoreExpiryState::new_with_unconsumed_worker_channel(4);
let observation = Duration::from_secs(rustfs_config::DEFAULT_DRIVE_METADATA_TIMEOUT_SECS);
let context = crate::ExpiryLockTestContext {
state: state.clone(),
store: None,
completed_incarnation_reads: Arc::new(AtomicUsize::new(0)),
};
crate::EXPIRY_LOCK_TEST_CONTEXT
.scope(context, async {
let object = crate::ScannerObjectInfo {
bucket: "expiry-lock-bucket".to_string(),
name: "free-object".to_string(),
..Default::default()
};
let cancellation = CancellationToken::new();
let guard = state.write().await;
let mut pending = Box::pin(crate::enqueue_runtime_free_version(object.clone(), Some(&cancellation)));
let first = observe_queued(pending.as_mut(), || true, observation).await;
let observed_wait = matches!(&first, Ok(None));
let already_finished = matches!(&first, Ok(Some(())));
cancellation.cancel();
let cancelled_while_locked = if already_finished {
false
} else {
tokio::time::timeout(observation, pending.as_mut()).await.is_ok()
};
let pending_while_locked = guard.pending_tasks();
drop(guard);
if !already_finished && !cancelled_while_locked {
pending.as_mut().await;
}
drop(pending);
let pending_after_cancel = state.read().await.pending_tasks();
// Positive control queues a real free-version task while using the same
// implementation and isolated, unconsumed worker channel.
let mut positive = Box::pin(crate::enqueue_runtime_free_version(object, None));
let positive_in_time = tokio::time::timeout(observation, positive.as_mut()).await.is_ok();
if !positive_in_time {
positive.as_mut().await;
}
drop(positive);
let pending_after_control = state.read().await.pending_tasks();
assert!(observed_wait, "must poll the real enqueue while the write lock is held");
assert!(cancelled_while_locked, "cancellation must finish before the lock is released");
assert_eq!(pending_while_locked, 0);
assert_eq!(pending_after_cancel, 0);
assert!(positive_in_time, "positive control must finish within the observation limit");
assert_eq!(pending_after_control, 1, "positive control must queue a task");
})
.await;
}
#[tokio::test]
#[serial]
async fn newer_noncurrent_cancelled_after_real_read_while_waiting_for_expiry_write_lock_never_queues() {
let (_temp_dir, store) = setup_local_scanner_store(1, 4).await;
let bucket = "expiry-lock-bucket";
store
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("create bucket");
let expected_incarnation = store.bucket_incarnation_id(bucket).await.expect("real incarnation metadata");
assert!(!expected_incarnation.is_nil());
let state = EcstoreExpiryState::new_with_unconsumed_worker_channel(4);
let reads = Arc::new(AtomicUsize::new(0));
let context = crate::ExpiryLockTestContext {
state: state.clone(),
store: Some(store.clone()),
completed_incarnation_reads: reads.clone(),
};
let observation = Duration::from_secs(rustfs_config::DEFAULT_DRIVE_METADATA_TIMEOUT_SECS);
crate::EXPIRY_LOCK_TEST_CONTEXT
.scope(context, async {
let versions = vec![crate::ScannerObjectToDelete {
object_name: "noncurrent-object".to_string(),
version_id: Some(Uuid::new_v4()),
..Default::default()
}];
let event = crate::Event::default();
let src = crate::LcEventSrc::Scanner;
let cancellation = CancellationToken::new();
let guard = state.write().await;
let mut pending = Box::pin(crate::enqueue_runtime_newer_noncurrent(
bucket,
versions.clone(),
event.clone(),
&src,
Some(&cancellation),
));
let first = observe_queued(pending.as_mut(), || reads.load(Ordering::SeqCst) == 1, observation).await;
let observed_wait_after_read = matches!(&first, Ok(None));
let mut result = match first {
Ok(Some(value)) => Some(value),
_ => None,
};
cancellation.cancel();
let cancelled_while_locked = if result.is_some() {
false
} else {
match tokio::time::timeout(observation, pending.as_mut()).await {
Ok(value) => {
result = Some(value);
true
}
Err(_) => false,
}
};
let pending_while_locked = guard.pending_tasks();
drop(guard);
let result = match result {
Some(value) => value,
None => pending.as_mut().await,
};
drop(pending);
let pending_after_cancel = state.read().await.pending_tasks();
let mut positive = Box::pin(crate::enqueue_runtime_newer_noncurrent(bucket, versions, event, &src, None));
let bounded_positive = tokio::time::timeout(observation, positive.as_mut()).await;
let positive_in_time = bounded_positive.is_ok();
let positive_result = match bounded_positive {
Ok(value) => value,
Err(_) => positive.as_mut().await,
};
drop(positive);
let pending_after_control = state.read().await.pending_tasks();
assert!(
observed_wait_after_read,
"real incarnation read must finish before waiting on the held lock"
);
assert!(cancelled_while_locked, "cancellation must finish before the lock is released");
assert!(!result);
assert_eq!(pending_while_locked, 0);
assert_eq!(pending_after_cancel, 0);
assert!(positive_in_time, "positive control must finish within the observation limit");
assert!(positive_result, "positive control must enqueue the nonempty version batch");
assert_eq!(pending_after_control, 1);
assert_eq!(reads.load(Ordering::SeqCst), 2);
})
.await;
}
@@ -0,0 +1,136 @@
// Copyright 2026 RustFS Team
// Licensed under the Apache License, Version 2.0.
use super::*;
use crate::workload_admission::ScannerNodeRequest;
use rustfs_concurrency::adaptive::{FeedbackController, FeedbackObservation, NodeAdmission, ResourcePressure};
use std::num::NonZeroU16;
use tokio::sync::oneshot;
#[derive(Clone, Copy, PartialEq, Eq)]
enum ResumeCase {
Unchanged,
Updated,
Cancelled,
}
async fn check_cache_lease_resume(case: ResumeCase) {
let (_directory, store) = setup_local_scanner_store(1, 4).await;
let set = store.pools[0].disk_set[0].clone();
let source = DataUsageCacheSource::new(0, 0);
let cache_name = "node-resume/.usage-cache.bin";
let cache = DataUsageCache::default();
cache.save(set.clone(), cache_name).await.expect("seed cache");
let revisions = DataUsageCache::read_revisions(set.clone(), cache_name)
.await
.expect("initial revisions");
let gate = NodeAdmission::new(NonZeroU16::new(1).expect("one shared unit"));
gate.update(
&mut FeedbackController::new(),
Some(FeedbackObservation {
generation: 1,
max_concurrency: 1,
pressure: ResourcePressure::Unknown,
completed_units: None,
}),
);
let mut held = Some(gate.acquire().await);
let mut guard = acquire_scanner_cache_locks(set.as_ref(), cache_name, source)
.await
.expect("initial cache lease");
let ctx = CancellationToken::new();
let (respond, response) = oneshot::channel();
let request = ScannerNodeRequest {
owner: gate.clone(),
respond,
cancel: ctx.clone(),
};
let mut expected = revisions.clone();
let result = {
let resume = guard.resume_node_operation(set.clone(), cache_name, source, &revisions, request);
tokio::pin!(resume);
assert!(futures::poll!(&mut resume).is_pending(), "shared node is occupied");
let other = tokio::time::timeout(Duration::from_secs(1), acquire_scanner_cache_locks(set.as_ref(), cache_name, source))
.await
.expect("cache lock must be released before joining the node queue")
.expect("another cache worker can acquire the lease");
if case == ResumeCase::Updated {
let mut newer = cache;
newer.info.next_cycle = 1;
newer
.save(set.clone(), cache_name)
.await
.expect("another worker updates the cache");
expected = DataUsageCache::read_revisions(set.clone(), cache_name)
.await
.expect("new cache revisions");
assert_ne!(expected, revisions);
}
drop(other);
if case == ResumeCase::Cancelled {
ctx.cancel();
} else {
held.take().expect("held node unit").complete();
}
tokio::time::timeout(Duration::from_secs(2), resume)
.await
.expect("node resumption must finish or observe cancellation")
};
if case == ResumeCase::Unchanged {
result.expect("unchanged cache resumes the same walk");
let permit = response.await.expect("coordinator returns the node permit");
assert_eq!(gate.snapshot().active, 1);
assert_eq!(gate.completed(), Some(1), "admitting scanner work must not count it as completed");
assert!(!guard.is_lock_lost());
let lock = set
.new_ns_lock(RUSTFS_META_BUCKET, &scanner_cache_lock_resource(cache_name, source))
.await
.expect("competing cache lock");
assert!(
lock.get_write_lock_quiet(Duration::from_millis(20)).await.is_err(),
"cache lease must be restored before the operation resumes"
);
permit.complete();
} else {
result.expect_err("cancelled or stale work cannot resume");
assert!(response.await.is_err(), "failed resumption must not authorize storage work");
if case == ResumeCase::Cancelled {
assert!(guard.is_lock_lost());
assert_eq!(gate.snapshot().active, 1, "the running heal keeps its unit");
drop(held.take());
assert_eq!(gate.completed(), Some(0));
} else {
assert_eq!(gate.completed(), Some(1));
}
}
drop(guard);
assert_eq!(gate.snapshot().active, 0);
assert!(gate.try_acquire().is_some(), "dropped requests must leave no queue or capacity leak");
assert_eq!(
DataUsageCache::read_revisions(set.clone(), cache_name)
.await
.expect("final revisions"),
expected
);
acquire_scanner_cache_locks(set.as_ref(), cache_name, source)
.await
.expect("final cache lease is available");
}
#[tokio::test]
#[serial]
async fn scanner_node_wait_yields_cache_lease_before_fifo_admission() {
check_cache_lease_resume(ResumeCase::Unchanged).await;
}
#[tokio::test]
#[serial]
async fn scanner_node_wait_rejects_cache_changed_while_unlocked() {
check_cache_lease_resume(ResumeCase::Updated).await;
}
#[tokio::test]
#[serial]
async fn scanner_node_wait_cancellation_releases_queue_without_stealing_running_unit() {
check_cache_lease_resume(ResumeCase::Cancelled).await;
}
+15
View File
@@ -22,6 +22,8 @@ use serde::{Deserialize, Serialize};
use tokio::sync::Notify;
pub(crate) use rustfs_ecstore::with_background_disk_io as ecstore_with_background_disk_io;
#[cfg(all(test, unix))]
pub(crate) use rustfs_ecstore::with_background_resource_unit as ecstore_with_background_resource_unit;
pub(crate) use s3s::dto::{
BucketLifecycleConfiguration as EcstoreBucketLifecycleConfiguration, LifecycleRuleFilter as EcstoreLifecycleRuleFilter,
@@ -39,6 +41,8 @@ pub(crate) use rustfs_ecstore::api::data_usage::{NativeScannerPauseBacklogWriteF
pub(crate) use rustfs_ecstore::api::bucket::bucket_target_sys::BucketTargetSys as EcstoreBucketTargetSys;
pub(crate) use rustfs_ecstore::api::bucket::lifecycle::bucket_lifecycle_audit::LcEventSrc as EcstoreLcEventSrc;
#[cfg(test)]
pub(crate) use rustfs_ecstore::api::bucket::lifecycle::bucket_lifecycle_ops::ExpiryState as EcstoreExpiryState;
pub(crate) use rustfs_ecstore::api::bucket::lifecycle::bucket_lifecycle_ops::{
apply_expiry_rule as ecstore_apply_expiry_rule, apply_transition_rule as ecstore_apply_transition_rule,
lifecycle_version_delete_target as ecstore_lifecycle_version_delete_target,
@@ -99,6 +103,10 @@ pub(crate) use rustfs_ecstore::api::disk::{
pub(crate) use rustfs_ecstore::api::disk::{
DiskOption as EcstoreDiskOption, DiskStore as EcstoreDiskStore, new_disk as ecstore_new_disk,
};
#[cfg(all(test, unix))]
pub(crate) use rustfs_ecstore::api::disk::{
LocalPublicationPause as EcstoreLocalPublicationPause, LocalPublicationStage as EcstoreLocalPublicationStage,
};
pub(crate) use rustfs_ecstore::api::error::{
Error as EcstoreErrorType, Result as EcstoreResultType, StorageError as EcstoreStorageError,
};
@@ -161,10 +169,17 @@ pub(crate) mod ecstore_diagnostic {
pub(crate) type EcstoreHealResultItem = <EcstoreStore as storage_contracts::HealOperations>::HealResultItem;
pub(crate) mod owner {
#[cfg(test)]
pub(crate) use super::EcstoreExpiryState;
#[cfg(test)]
pub(crate) use super::ecstore_set_object_store_resolver;
#[cfg(test)]
pub(crate) use super::storage_contracts::DiskSetSelector;
#[cfg(all(test, unix))]
pub(crate) use super::{
EcstoreLocalPublicationPause, EcstoreLocalPublicationStage, ecstore_with_background_disk_io,
ecstore_with_background_resource_unit,
};
pub(crate) use super::{
MAX_SCANNER_PAUSE_BACKLOG_BYTES, ScannerPauseBacklogRetirementError, ScannerPauseBacklogRetirementPlan,
ScannerPauseBacklogRetirementReplica, register_scanner_pause_backlog_retirement_planner,
+135 -1
View File
@@ -12,9 +12,143 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use std::sync::{Arc, LazyLock, RwLock};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, LazyLock, OnceLock, RwLock};
use rustfs_concurrency::adaptive::{NodeAdmission, NodePermit};
use rustfs_concurrency::{AdmissionState, WorkloadAdmissionRegistrySnapshot, WorkloadAdmissionSnapshotProvider, WorkloadClass};
use tokio::sync::{mpsc, oneshot};
use tokio_util::sync::CancellationToken;
use crate::ScannerError;
static SCANNER_NODE_ADMISSION: OnceLock<Arc<NodeAdmission>> = OnceLock::new();
tokio::task_local! {
static SCANNER_NODE_REQUESTS: ScannerNodeRequestSender;
static SCANNER_NODE_OPERATION_CANCEL: CancellationToken;
}
/// Cancellation for the currently admitted scanner operation, when present.
pub(crate) fn scanner_node_operation_cancellation() -> Option<CancellationToken> {
SCANNER_NODE_OPERATION_CANCEL.try_with(Clone::clone).ok()
}
pub(crate) struct ScannerNodeRequest {
pub(crate) owner: Arc<NodeAdmission>,
pub(crate) respond: oneshot::Sender<NodePermit>,
pub(crate) cancel: CancellationToken,
}
pub(crate) type ScannerNodeRequestSender = mpsc::Sender<ScannerNodeRequest>;
pub(crate) type ScannerNodeRequestReceiver = mpsc::Receiver<ScannerNodeRequest>;
/// One suspended operation per walk; disabled admission allocates no channel.
pub(crate) fn scanner_node_request_channel() -> (Option<ScannerNodeRequestSender>, Option<ScannerNodeRequestReceiver>) {
if scanner_node_admission().is_none() {
return (None, None);
}
let (sender, receiver) = mpsc::channel(1);
(Some(sender), Some(receiver))
}
pub(crate) async fn with_scanner_node_requests<F: std::future::Future>(
sender: Option<ScannerNodeRequestSender>,
operation: F,
) -> F::Output {
match sender {
Some(sender) => SCANNER_NODE_REQUESTS.scope(sender, operation).await,
None => operation.await,
}
}
#[cfg(test)]
tokio::task_local! {
pub(crate) static TEST_SCANNER_NODE_ADMISSION: Arc<NodeAdmission>;
pub(crate) static TEST_SCANNER_AFTER_NODE_ACQUIRE: Arc<dyn Fn() + Send + Sync>;
}
/// Install the heal manager's node owner before starting scanner workers.
/// Refuse replacement: existing scans must not outlive an independently renewed owner.
pub fn set_scanner_node_admission(owner: Arc<NodeAdmission>) -> bool {
SCANNER_NODE_ADMISSION.set(owner).is_ok()
}
pub(crate) fn scanner_node_admission() -> Option<Arc<NodeAdmission>> {
#[cfg(test)]
if let Ok(owner) = TEST_SCANNER_NODE_ADMISSION.try_with(Arc::clone) {
return Some(owner);
}
SCANNER_NODE_ADMISSION.get().cloned()
}
/// Try the fast path first. A scoped coordinator releases the cache lease before
/// joining the node queue and validates the cache before returning a permit.
/// Uncoordinated callers defer instead of parking under an unknown lock.
pub(crate) async fn run_scanner_node_operation<T, E>(
owner: Option<&Arc<NodeAdmission>>,
cancel: &CancellationToken,
operation: impl std::future::Future<Output = Result<T, E>>,
) -> Result<Result<T, E>, ScannerError> {
let Some(owner) = owner else {
return Ok(operation.await);
};
let operation = SCANNER_NODE_OPERATION_CANCEL.scope(cancel.clone(), operation);
if cancel.is_cancelled() {
return Err(ScannerError::Other("Operation cancelled".to_string()));
}
let permit = match owner.try_acquire() {
Some(permit) => permit,
None => {
let sender = SCANNER_NODE_REQUESTS
.try_with(Clone::clone)
.map_err(|_| ScannerError::ResourceDeferred)?;
let acquire = async {
let (respond, response) = oneshot::channel();
sender
.send(ScannerNodeRequest {
owner: owner.clone(),
respond,
cancel: cancel.clone(),
})
.await
.map_err(|_| ScannerError::ResourceDeferred)?;
response.await.map_err(|_| ScannerError::ResourceDeferred)
};
tokio::select! {
biased;
_ = cancel.cancelled() => return Err(ScannerError::Other("Operation cancelled".to_string())),
permit = acquire => permit?,
}
}
};
#[cfg(test)]
let _ = TEST_SCANNER_AFTER_NODE_ACQUIRE.try_with(|hook| hook());
let operation_started = AtomicBool::new(false);
tokio::pin!(operation);
let observed = std::future::poll_fn(|context| {
operation_started.store(true, Ordering::Relaxed);
std::future::Future::poll(operation.as_mut(), context)
});
tokio::pin!(observed);
let result = tokio::select! {
biased;
_ = cancel.cancelled() => {
// The node slot belongs to the waiter, not the native operation.
drop(permit);
if !operation_started.load(Ordering::Relaxed) {
return Err(ScannerError::Other("Operation cancelled".to_string()));
}
// Keep polling the started operation under its existing locks.
return Ok(observed.await);
}
result = &mut observed => result,
};
if result.is_ok() && !cancel.is_cancelled() {
permit.complete();
}
Ok(result)
}
type WorkloadSnapshotProviderRef = Arc<dyn WorkloadAdmissionSnapshotProvider + Send + Sync>;
@@ -0,0 +1,144 @@
// 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 rustfs_filemeta::{FileInfo, FileMeta};
use rustfs_heal_contracts::heal_channel::{
HealAdmissionReceipt, HealAdmissionResult, HealReceiptCommand, HealRequestSource, init_heal_channels,
};
use rustfs_scanner::DataUsageCacheSource;
use rustfs_scanner::scanner_folder::ScannerItem;
use rustfs_scanner::scanner_io::ScannerIODisk;
use tokio::sync::mpsc;
use super::storage_api::lifecycle::{DiskOption, Endpoint, STORAGE_FORMAT_FILE, new_disk};
#[test]
fn scanner_object_requests_bind_the_actual_disk_scope() {
const CHILD: &str = "RUSTFS_TEST_SCANNER_SCOPE_CHILD";
if std::env::var_os(CHILD).is_none() {
let status = std::process::Command::new(std::env::current_exe().expect("locate integration test executable"))
.args([
"--exact",
"scanner_heal_scope::scanner_object_requests_bind_the_actual_disk_scope",
"--nocapture",
])
.env(CHILD, "1")
.status()
.expect("run scanner scope regression in an isolated process");
assert!(status.success(), "isolated scanner scope regression failed: {status}");
return;
}
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("build isolated scanner runtime")
.block_on(check_scanner_object_scope());
}
async fn check_scanner_object_scope() {
let (_commands, mut receipts) = init_heal_channels().expect("initialize isolated scanner request channels");
let (captured, mut requests) = mpsc::unbounded_channel();
let responder = tokio::spawn(async move {
while let Some(command) = receipts.recv().await {
let HealReceiptCommand { request, response_tx } = command;
let receipt = HealAdmissionReceipt {
result: HealAdmissionResult::Accepted,
task_id: request.id.clone(),
};
captured.send(request).expect("retain scanner request");
let _ = response_tx.send(Ok(receipt));
}
});
for (pool, set, disk_index) in [
(Some(0), Some(0), Some(1)),
(Some(2), Some(3), Some(4)),
(None, Some(3), Some(4)),
(Some(2), None, Some(4)),
(Some(2), Some(3), None),
(None, None, None),
] {
for version_id in [None, Some(uuid::Uuid::from_u128(42))] {
let root = tempfile::tempdir().expect("create scanner disk");
let bucket = "scanner-scope-bucket";
let object = "object";
let path = root.path().join(bucket).join(object).join(STORAGE_FORMAT_FILE);
tokio::fs::create_dir_all(path.parent().expect("metadata parent"))
.await
.expect("create object directory");
let mut info = FileInfo::new(object, 4, 2);
info.volume = bucket.to_string();
info.name = object.to_string();
info.size = 1;
info.version_id = version_id;
info.data_dir = Some(uuid::Uuid::new_v4());
info.erasure.index = 1;
info.mod_time = Some(time::OffsetDateTime::from_unix_timestamp(1).expect("valid object timestamp"));
info.add_object_part(1, "fixture-etag".to_string(), 1, info.mod_time, 1, None, None);
let mut meta = FileMeta::new();
meta.add_version(info).expect("encode original null version");
let original = meta.marshal_msg().expect("marshal original metadata");
tokio::fs::write(&path, &original).await.expect("write original metadata");
let mut endpoint = Endpoint::try_from(root.path().to_string_lossy().as_ref()).expect("parse scanning disk endpoint");
if let Some(pool) = pool {
endpoint.set_pool_index(pool);
}
if let Some(set) = set {
endpoint.set_set_index(set);
}
if let Some(disk_index) = disk_index {
endpoint.set_disk_index(disk_index);
}
let disk = new_disk(
&endpoint,
&DiskOption {
cleanup: false,
health_check: false,
},
)
.await
.expect("open actual scanning disk");
let item = ScannerItem {
path: path.to_string_lossy().to_string(),
bucket: bucket.to_string(),
prefix: object.to_string(),
object_name: STORAGE_FORMAT_FILE.to_string(),
file_type: tokio::fs::metadata(&path).await.expect("stat original metadata").file_type(),
lifecycle: None,
object_lock: None,
replication: None,
// This foreign caller-provided scope must never reach admission.
heal_scope: Some(DataUsageCacheSource::new(99, 98)),
heal_enabled: true,
heal_bitrot: true,
debug: false,
};
disk.get_size_with_tier_names(item, &[]).await.expect("scan original object");
if let (Some(pool), Some(set), Some(_)) = (pool, set, disk_index) {
let request = requests.try_recv().expect("scanner submitted one real object request");
assert_eq!(request.pool_index, Some(pool));
assert_eq!(request.set_index, Some(set));
assert_eq!(request.bucket, bucket);
assert_eq!(request.object_prefix.as_deref(), Some(object));
assert_eq!(request.object_version_id, version_id.map(|value| value.to_string()));
assert_eq!(request.source, HealRequestSource::Scanner);
assert_eq!(request.no_lock, None);
assert_eq!(request.recreate_missing, Some(false));
}
assert!(requests.try_recv().is_err(), "no duplicate or unscoped request may be admitted");
assert_eq!(tokio::fs::read(&path).await.expect("read original metadata"), original);
}
}
responder.abort();
}
@@ -39,6 +39,9 @@ use uuid::Uuid;
mod storage_api;
#[path = "lifecycle/scanner_heal_scope.rs"]
mod scanner_heal_scope;
use storage_api::lifecycle::{
BUCKET_LIFECYCLE_CONFIG, BUCKET_VERSIONING_CONFIG, BucketOperations, BucketOptions, BucketVersioningSys, CompletePart,
DeleteAfterObjectLockSnapshotBarrier, DiskOption, ECStore, EcstoreError, Endpoint, EndpointServerPools, Endpoints,
@@ -467,6 +470,7 @@ async fn scan_object_with_lifecycle(disk_path: &Path, bucket: &str, object: &str
lifecycle,
object_lock: None,
replication: None,
heal_scope: None,
heal_enabled: false,
heal_bitrot: false,
debug: false,
@@ -504,6 +508,7 @@ async fn scan_object_metadata(disk_path: &Path, bucket: &str, object: &str) {
lifecycle: None,
object_lock: None,
replication: None,
heal_scope: None,
heal_enabled: false,
heal_bitrot: false,
debug: false,
+20 -8
View File
@@ -1471,6 +1471,7 @@ mod tests {
inner: QueueStore<QueuedPayload>,
fail_put_failed_raw: Arc<std::sync::atomic::AtomicBool>,
fail_del: Arc<std::sync::atomic::AtomicBool>,
delete_attempts: Arc<Semaphore>,
}
impl Store<QueuedPayload> for FailingFailedStore {
@@ -1499,10 +1500,13 @@ mod tests {
self.inner.get_raw(key)
}
fn del(&self, key: &Self::Key) -> Result<(), Self::Error> {
if self.fail_del.load(Ordering::SeqCst) {
return Err(StoreError::Internal("injected del failure".to_string()));
}
self.inner.del(key)
let result = if self.fail_del.load(Ordering::SeqCst) {
Err(StoreError::Internal("injected del failure".to_string()))
} else {
self.inner.del(key)
};
self.delete_attempts.add_permits(1);
result
}
fn delete(&self) -> Result<(), Self::Error> {
self.inner.delete()
@@ -1986,6 +1990,7 @@ mod tests {
inner: inner.clone(),
fail_put_failed_raw: fail_flag.clone(),
fail_del: Arc::new(std::sync::atomic::AtomicBool::new(false)),
delete_attempts: Arc::new(Semaphore::new(0)),
};
// The target's terminal move writes through the rejecting failed handle, so the injected
// failure is what the move hits.
@@ -2042,6 +2047,7 @@ mod tests {
inner: inner.clone(),
fail_put_failed_raw: Arc::new(std::sync::atomic::AtomicBool::new(false)),
fail_del: Arc::new(std::sync::atomic::AtomicBool::new(true)),
delete_attempts: Arc::new(Semaphore::new(0)),
};
let target = Arc::new(ProgrammedTarget::new(
Some(TargetError::JetStreamPublish {
@@ -2053,8 +2059,15 @@ mod tests {
let tally = Arc::new(ReplayTally::default());
let (cancel_tx, worker) = spawn_failing_store_worker(&store, target, tally.clone());
// Many scans run while the live delete keeps failing.
tokio::time::sleep(Duration::from_secs(300)).await;
// Observe repeated durable overwrites without auto-advancing through thousands of
// real filesystem writes. Keep the original virtual observation bound on progress.
tokio::time::timeout(Duration::from_secs(300), store.delete_attempts.acquire_many(8))
.await
.expect("replay should retry the failing delete eight times")
.expect("delete progress remains open")
.forget();
cancel_tx.send(()).await.expect("replay worker should remain cancellable");
worker.await.expect("replay worker should drain after cancellation");
assert_eq!(
tally.permanent_failure.load(Ordering::SeqCst),
0,
@@ -2063,8 +2076,6 @@ mod tests {
assert_eq!(inner.len(), 1, "the live entry stays for the next scan");
assert_eq!(inner.failed_len(), 1, "the durable failed copy landed exactly once");
let _ = cancel_tx.send(()).await;
let _ = worker.await;
let _ = inner.delete();
}
@@ -2084,6 +2095,7 @@ mod tests {
inner: store.clone(),
fail_put_failed_raw: Arc::new(std::sync::atomic::AtomicBool::new(false)),
fail_del: Arc::new(std::sync::atomic::AtomicBool::new(false)),
delete_attempts: Arc::new(Semaphore::new(0)),
};
let target = Arc::new(ProgrammedTarget::new(
Some(TargetError::JetStreamPublish {
+1
View File
@@ -50,6 +50,7 @@ Required headings and strings in these files are asserted by `scripts/check_arch
| [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 |
| [plugin-delegation.md](plugin-delegation.md) | changing original-user plugin delegation, attempt credentials, or Runner task APIs |
| [admin-route-action-snapshot.md](admin-route-action-snapshot.md) | adding, moving, or re-authorizing an admin route and needing to know where the route → handler → `AdminAction` contract is enforced |
| [kms-bulk-rekey-contract.md](kms-bulk-rekey-contract.md) | changing the bulk envelope re-wrap sweep, its admin endpoints, the re-wrap primitive, or which objects a rekey may touch |
| [remote-credential-sealing-adr.md](remote-credential-sealing-adr.md) | adding, reading, or persisting a stored remote credential (replication target, remote tier, on-demand migration source), or needing the sealed-envelope format, the mixed-version rules, or the reason this is worth doing in one deployment and not in another |
+3 -3
View File
@@ -5,7 +5,7 @@
Status: normative. This document is the source of truth for how RustFS erasure-codes, stores, reads, reconstructs, and heals user data, and for the on-disk / on-wire compatibility contract that every future change must preserve. It governs the highest-risk code in the system: a regression here can silently corrupt or lose all user data, or make existing (and MinIO-migrated) objects permanently unreadable.
Erasure coding, quorum/heal, and metadata/on-disk formats are **High-risk** per [AGENTS.md](../../AGENTS.md) ("Broad or High-Risk Changes"). Any behavior-affecting change to code this document governs requires adversarial review with the `adversarial-validation` skill and, for anything touching decode or the on-disk format, a regression test against real on-disk and MinIO-migrated samples before merge.
Erasure coding, quorum/heal, and metadata/on-disk formats are **High-risk** per [AGENTS.md](../../AGENTS.md). Any behavior-affecting change to code this document governs requires adversarial review and, for anything touching decode or the on-disk format, a regression test against real on-disk and MinIO-migrated samples before merge.
This document describes the algorithm as implemented on `main`. The *invariant* stated is always the rule the code must satisfy; where the code enforces it, the enforcing symbol is cited.
@@ -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`. S3 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. Reads in the system bucket (`is_meta_bucketname`) keep the commit-proof rule in `select_committed_fileinfo`: 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. Pool metadata's PREPARE/COMMIT recovery and identity epochs rely on an ordinary read implying a committed record, so a split stays unreadable until native recovery resolves it. When every drive answered and an S3 latest read still cannot select a version, `read_latest_past_dangling_versions` walks the version stack newest first through exact-version reads, as MinIO's `pickLatestQuorumFilesInfo` resolves stacks: it serves the readable version with the newest selected mod_time, skips versions whose exact read proves them dangling (the NoSuchVersion an explicit GET reports) and queues heal for them, and keeps the read-quorum error on any other outcome. A failed versioned PUT that left the new version on fewer drives than its data blocks therefore no longer hides the committed version below it; ListObjectVersions can still show the dangling version as latest until heal removes it.
- 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`. S3 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. Reads in the system bucket (`is_meta_bucketname`) keep the commit-proof rule in `select_committed_fileinfo`: 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. Pool metadata's PREPARE/COMMIT recovery and identity epochs rely on an ordinary read implying a committed record, so a split stays unreadable until native recovery resolves it. When every drive answered and an S3 latest read still cannot select a version, `read_latest_past_dangling_versions` walks the version stack newest first through exact-version reads, as MinIO's `pickLatestQuorumFilesInfo` resolves stacks: it serves the readable version with the newest selected mod_time, skips versions whose exact read proves them dangling (the NoSuchVersion an explicit GET reports) and queues heal for them, and keeps the read-quorum error on any other outcome. A failed versioned PUT that left the new version on fewer drives than its data blocks therefore no longer hides the committed version below it; ListObjectVersions can still show the dangling version as latest until heal removes it. Metadata updates of the latest version (object tagging, `put_object_metadata`) and the CopyObject source read resolve a failed selection through the same walk; when the walk finds no readable version, tagging keeps the read-quorum error while `put_object_metadata` and CopyObject keep their existing `delete_if_dangling` cleanup. Replication status write-back never walks. The update targets only the served version's disks and is refused before writing when they cannot reach its write quorum, including a version-only copy.
- **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)).
@@ -400,7 +400,7 @@ Decode tolerance
## 13. Change procedure and guardrails
- **Risk tier.** All of the above is High-risk ([AGENTS.md](../../AGENTS.md) "Broad or High-Risk Changes"). Any behavior-affecting change requires adversarial review with the `adversarial-validation` skill.
- **Risk tier.** All of the above is High-risk ([AGENTS.md](../../AGENTS.md)). Any behavior-affecting change requires adversarial review.
- **Keep this document in sync.** A change to any governed behavior, formula, format field, or invariant must update this spec in the same PR; renaming a cited symbol must update its reference here. The spec is normative and is the checklist the next change is reviewed against, so drift is a correctness defect. References are symbol-based (not line numbers) specifically so ordinary refactors do not invalidate them — but semantic changes still must.
- **Adding an on-disk field** must be additive: new msgpack key or a `minor`/`meta_ver` bump with a read path for the old value; keep decoders skipping unknown keys; write both internal-key prefixes; never repurpose or reorder existing keys or header array positions.
- **Never make a decode boundary stricter** than what §11 allows without (a) proving no legitimate older-RustFS or MinIO-migrated shape is rejected, and (b) a regression test against real on-disk and MinIO fixtures. New validation belongs at the trust boundary and must fail *open to a tolerant default*, not closed to `FileCorrupt`, for anything recoverable. (Concretely: rejecting a negative `actual_size`, or hard-failing a non-16-byte `transitioned-versionID`, breaks existing data — see §11.)
@@ -4,6 +4,10 @@ Admin heal tokens retain their terminal status for ten minutes from the original
## Publication and Recovery
Authenticated token queries first inspect the receiving node's in-memory automatic tasks. An original Scanner, MRF, automatic-Heal, or read-repair token can return its path-bound snapshot on that node, including the retained outcome and incremental cursor. The task source and original ID remain attached to the completion snapshot; aliases cannot enter this local query path. The local lookup never reads administrator recovery records.
Administrator tokens and unknown local identities continue through the cluster coordinator. Starting and cancelling tasks retain their coordinator route. Querying another node does not discover or aggregate a remote automatic task, and this path does not extend automatic-task retention across process restart.
The original schema-1 `terminal-root-heal-<token>.json` remains the commit marker. Its separate `heal-terminal-report-<token>.json` report has its own schema version and embeds the exact terminal identity. Both files belong to the same coordinator disk as the pending intent.
Publication writes the report through the storage owner's conditional file update, writes the terminal marker, and then conditionally removes the pending intent. Recovery never treats a report without its marker as committed. A committed terminal continues to suppress stale pending work even when its report is unreadable. An initial publication failure leaves the existing responsibility available for recovery.
+142
View File
@@ -0,0 +1,142 @@
# Plugin delegation
**Use this when:** changing plugin delegation registration, revocation, broker identity, or the scope of attempt credentials.
## Integration boundary
Runner owns scheduling, leases, container execution and result publication. RustFS provides original-user delegation, scoped temporary credentials and authenticated task API forwarding. Inputs and outputs use ordinary versioned S3 buckets; event-driven jobs use the existing bucket notification/Webhook path. Managed artifact sealing, inventory, garbage collection and backup retention require separate storage contracts and are outside this integration.
Set `RUSTFS_PLUGIN_RUNNER_URL` to the Controller HTTPS URL ending in `/v1/ingress/manual/jobs`, and `RUSTFS_PLUGIN_RUNNER_DEPLOYMENT` to the delegation deployment. Forwarding requires the published outbound mTLS client identity and uses the published trust roots when configured. Connections are reused within a TLS generation; publishing new TLS material replaces the pool, and removing the client identity rejects new forwarding requests.
## Authority
A delegation is an immutable restriction record owned by an ordinary IAM user. It is neither a permission grant nor a credential. Creating it requires that user's real signed request and `admin:CreatePluginDelegation`; reading and revoking require the corresponding `admin:GetPluginDelegation` and `admin:RevokePluginDelegation` actions. Root, temporary credentials and service accounts cannot own records.
The record binds a deployment, an input bucket, literal input and output prefixes, and an expiry of at most 24 hours. The v1 profile writes to that same bucket; v2 explicitly names the output bucket. Wildcards, control characters and IAM variable expressions are rejected. Its random ID is also its immutable revision. There is no update operation.
The Controller is responsible for validating accepted task identity, current lease, epoch, node session and control generation before requesting credentials. RustFS authenticates the configured broker and enforces the delegation and current parent IAM. RustFS does not independently verify a remote Controller lease.
```mermaid
sequenceDiagram
participant U as Original IAM user
participant C as Controller broker
participant R as RustFS
participant P as Plugin
U->>R: Signed delegation registration
R-->>U: Immutable ID and revision
C->>C: Validate task and current lease
C->>R: Signed issue-attempt request
R->>R: Broker mapping + active record + current IAM
R->>R: Persist scoped STS and recheck authority
R-->>C: Short-lived credentials
C-->>P: Attempt credentials
P->>R: Exact version GET / attempt-prefix PUT
R->>R: Current parent IAM intersected with session policy
```
## Persistence and revocation
`crates/iam/src/delegation.rs` owns record validation and immutable revocation tombstones. Records use the existing IAM store under `config/iam/plugin-delegations/v1`. Writes require a successful readback. Revocation is idempotent and durable; it does not delete or overwrite the restriction record.
`ObjectStore::load_iam_config_strict` distinguishes a missing object from an empty object, invalid JSON or failed decryption. Only a genuinely absent tombstone permits an active record. Corrupt or unavailable storage rejects access rather than treating revocation as absent. Existing non-delegation readers keep their compatibility behavior.
Revocation prevents future issuance. It does not promise immediate invalidation of credentials already delivered; those remain subject to their expiration and current parent IAM. Registration, IAM changes and issuance are not one atomic transaction. Issuance rechecks after persistence and deletes the generated STS account if authority changed. Failed persistence or invalidation acknowledgment is reported as unknown issuance, never as safe retry permission.
Manual job submission reads the active delegation before calling the Runner. A submission that passed this check is in flight and may be admitted if revocation races with the outbound request; revocation prevents submissions whose authority check occurs afterward, but does not cancel an admitted job. Callers must treat a successful submit receipt as accepted work even if the delegation is revoked later. After an ambiguous timeout or service error, retry the same idempotency key while the delegation remains active; after revocation or expiry, query status instead of retrying submission.
`POST /rustfs/admin/v3/plugin-delegations/{id}/runs/query` accepts `{version:1,run_id}` and requires an ordinary IAM user with `admin:GetPluginRun` who owns the delegation in `{id}`. It reads the immutable owner record without checking expiry or revocation, then queries Runner using the record's delegation ID and revision and the authenticated access key. The result can contain only that caller's run status and committed manifest; it does not perform object reads or reauthorize object access. The Runner response must match the requested run ID and the strict status schema before RustFS returns it.
`POST /rustfs/admin/v3/plugin-delegations/{id}/runs/cancel` accepts the same request and requires `admin:CancelPluginRun` plus ownership of `{id}`. Expiry and revocation do not block cancelling an existing run. Runner scopes the run to that owner; missing and other-owner runs share the same not-found response, while successful and failed runs return conflict. Success returns `{version:1,run_id,state:"CANCELLED",cleanup_pending}` and does not imply that asynchronous cleanup has finished.
`POST /rustfs/admin/v3/plugin-delegations/{id}/runs/page` accepts `{version:1,limit,cursor?}` with a limit from 1 to 100 and requires `admin:ListPluginRuns` plus ownership of `{id}`. Expiry and revocation do not hide historical runs. RustFS supplies signer and delegation identity from authenticated IAM state; the Runner page is scoped to the exact caller and deployment. The strict response contains run summaries and an opaque-to-clients stable cursor; callers pass the returned cursor unchanged until it is null. RustFS bounds request/response sizes and rejects malformed, unordered, over-limit, or identity-invalid summaries.
`POST /rustfs/admin/v3/plugin-delegations/{id}/runs/history` accepts `{version:1,run_id}` and requires `admin:ListPluginRuns` plus ownership of `{id}`. RustFS derives signer and delegation identity from the authenticated user and IAM record, then proxies Runner's bounded history response. The response contains at most the latest 32 committed state events in Runner's history-ID order and a `has_older` flag; timestamps are display data and are not used to reorder events. Missing and foreign runs share the same not-found response. RustFS rejects malformed, empty, over-limit, unexpected-state, or mismatched-run responses and bounds both request and response bodies.
`POST /rustfs/admin/v3/plugin-delegations/{id}/runs/replay` accepts `{version:1,origin_run_id,idempotency_key,deadline_unix_micros}` and requires `admin:ReplayPluginRun`, current IAM authorization, ownership, and an active unexpired delegation. Caller identity fields are not accepted; RustFS derives signer and delegation revision from the authenticated request and IAM record. Runner accepts only an owned failed origin, preserves its fixed plugin/configuration/input version, and requires a new deadline no later than its configured run timeout. RustFS requires a positive Unix-microsecond deadline and verifies the strict receipt echoes it. Missing or foreign origins share not-found behavior; conflicts map to an operation conflict. Requests and receipts are bounded, and responses use `Cache-Control: no-store`.
## Broker profile
`rustfs/src/admin/handlers/plugin_attempt.rs` implements `POST /rustfs/admin/v3/plugin-delegations/{id}/issue-attempt`. The signer must be an ordinary IAM user with `admin:IssuePluginAttempt`, independently listed in `RUSTFS_PLUGIN_BROKERS`:
```json
{"broker-access-key":"deployment-name"}
```
This configuration contains public access keys, not secrets. It is immutable per process. Missing or invalid configuration disables broker issuance. The broker is a trusted Controller identity; ordinary users cannot substitute their own request fields for Controller validation.
The request carries `tenant`, `run_id`, `attempt_id`, `revision`, `deployment`, `input_bucket`, `input_key`, `input_version`, `output_bucket`, `output_prefix` and `ttl_seconds`. IDs must be non-nil. Both profiles accept one exact input version and an output prefix equal to:
```text
<delegation-output-prefix><lowercase-hex-tenant>/<run-uuid>/<attempt-uuid>/
```
The session policy permits only `s3:GetObjectVersion` for that input with an exact `s3:VersionId`, and `s3:PutObject` beneath that attempt prefix. Credential lifetime is at most 900 seconds and cannot exceed delegation expiry. The Controller must additionally cap it to its validated lease and hard deadline.
The original user's current IAM remains the parent permission boundary on every S3 request. The server's signing key only seals the token; it is not the plugin's authorization identity. Broker identity and original user identity are separate.
External policy plugins must be disabled: their authorization path does not enforce this built-in session-policy boundary. Initializing, failed and ready plugin states all reject issuance. Attempt tokens carry a signed, persisted marker: after restart or on another node, marked credentials are accepted only by built-in STS authorization. A denied marked request returns a terminal error, so public bucket policies cannot bypass its session scope. The first profile also rejects parent policies containing conditions, because a broker request cannot reconstruct the original user's IP, MFA or other request context. It does not silently replace that context with the broker's.
## Response and failures
Successful issuance returns `version`, `delegation_id`, `delegation_revision`, `owner_access_key`, `access_key_id`, `secret_access_key`, `session_token` and `expires_at_unix_seconds`, with `Cache-Control: no-store`. The Controller verifies the returned delegation and owner against the accepted caller before delivery. Credentials must never enter logs, metrics or task result manifests.
Missing records and records owned by a different user have the same read response. Revoked or expired records reject access. Storage failures remain errors. Post-persistence failures carry static `error_code`, `issued` and `issuance_unknown` facts without credentials; callers must reconcile an unknown outcome rather than automatically mint another credential.
The route/action inventory is maintained in `rustfs/src/admin/route_policy.rs`. This contract does not imply event capture, physical container cleanup, restart reconciliation or upstream release acceptance.
## Cross-bucket restriction profile v2
The existing registration endpoint accepts this explicit v2 restriction:
```json
{
"version": 2,
"deployment": "runner-production",
"bucket": "source-images",
"input_prefix": "inputs/",
"output_bucket": "plugin-artifacts",
"output_prefix": "outputs/",
"expires_at_unix_seconds": 1893456000
}
```
The example expiry must be replaced by a future value within the existing
24-hour limit. `bucket` still identifies the input bucket. The existing
`issue-attempt` request and credential response do not change: its
`input_bucket` must equal `bucket`, and `output_bucket` must equal the explicit
v2 output bucket. Neither a different bucket nor a sibling attempt prefix can
be supplied by the broker. The exact input version and deterministic output
prefix checks remain in force.
A v1 restriction has neither `version` nor `output_bucket`; its JSON fields
and same-bucket behavior are unchanged. V2 requires both `version: 2` and a
nonempty, bounded, literal `output_bucket`. Missing pairs, explicit nulls,
unknown versions and unknown fields are rejected rather than downgraded to v1.
The immutable record envelope carries version 1 or 2 to match its restriction
profile. Both remain in the existing IAM record namespace; no data migration
or mutable record update is introduced. Readers reject mismatched envelope
and restriction versions. Older readers reject v2, so deploy compatible
readers before creating v2 records.
V2 does not grant the original user additional permissions. Issuance retains
its current-parent checks and post-persistence recheck/invalidation, and each
S3 request still requires both the original user's current IAM policy and the
attempt session policy to allow it. An explicit parent Deny or removal of
output permission prevents writes even with an already issued token. No
administrator credential substitutes for missing parent permission.
Focused tests are `delegation_v1_json_remains_compatible`,
`delegation_v2_requires_explicit_output_bucket`,
`attempt_v2_policy_keeps_input_version_and_output_bucket` and
`delegation_v2_persists_and_vends_with_real_parent_intersection`.
The last uses the actual issuance handler, persistent IAM and four-disk
storage, including a real input version and a third bucket allowed by the
parent but forbidden by the delegation. Removing only the output-bucket
comparison must fail `A03_CROSS_BUCKET_OUTPUT_BOUND`; parent intersection is
checked independently with issued temporary credentials. These are internal
handler/IAM tests, not signed HTTP or complete cross-bucket S3 execution.
This profile adds no bucket-creation API, Runner binding support, credential
endpoint or GC permission. Coordinating Runner's pinned binding with its
result storage bucket remains a separate integration requirement.
@@ -143,6 +143,39 @@ Rules that override the table: a candidate result is actionable only when the `A
### CPU and memory evidence
#### Local shard batch attribution
The existing GET-stage metric gate also controls local batch attribution. This
path performs buffered positioned reads, even if O_DIRECT or io_uring is enabled
for another backend. Keep the gate, exporter, and build features identical
between compared binaries; calibrate instrumentation overhead separately.
| Metric suffix after `rustfs_io_get_object_local_batch_` | Meaning |
| --- | --- |
| `decisions_total` | Reader-setup decisions, labelled by a fixed decision enum; this is not an S3 request counter. |
| `prepare_seconds` | Eligibility and path preparation, including pre-worker path checks; absent when preparation was skipped. |
| `stage_seconds` | Queue wait, whole-worker execution, and per-shard metadata/open/read durations, labelled by stage and operation outcome. |
| `shards` / `shards_total` | Batch size and completed shard outcomes. |
| `requested_bytes_total` / `read_bytes_total` | Requested bytes and bytes actually returned by positioned reads, split by shard outcome. |
Worker handles are captured before dispatch and remain owned by the worker, so
completion samples can arrive after the caller stops waiting. A stage that did
not execute contributes no sample. Queue wait ends when the worker starts;
execution includes allocation and instrumentation, while read timing excludes
buffer allocation. These nested and per-shard durations cannot be added to
request latency, and stage p99 values cannot be summed. Match request-boundary
samples and successful-request counters to the same workload window when
normalizing costs. Keep failed requests and partial-shard failures visible.
Inline GET and HEAD can bypass this reader-setup entry entirely; absence of a
decision sample does not imply a failed request. The two local duration metrics
use dedicated 0.1-microsecond-to-10-second histogram boundaries in the OTLP view;
request-duration histogram boundaries are separate.
For a bounded, four-shard instrumentation microbenchmark, run
`cargo bench -p rustfs-io-metrics --bench metrics_pipeline -- local_batch_attribution_4_shards_debug_recorder`.
It performs no disk I/O and uses a debugging recorder, so its result calibrates
the sampling code rather than production exporter overhead or S3 latency.
Warp output says whether throughput or latency changed; host telemetry says why. Collect it for the whole run and stop the collectors after the script exits.
| Tool | Command | Answers |
+7 -3
View File
@@ -529,17 +529,21 @@ The OTLP exporter records these node-local histograms for completed manager admi
The two new histograms aggregate all observed admission sources and results without per-request labels. An ordinary uninterrupted admission partitions the phase into wait and hold. Admin forceStart releases these guards while cancelling overlaps, then reacquires them: the cancellation gap is included only in the original total phase, and the second acquisition contributes to wait. Cancellation subroutine work is outside these outer admission guard intervals. Recording happens after all three guards are released. They measure manager admission ownership, not namespace/object-lock waiting during repair. The status field `maxLockPhaseMicros` remains a manager-lifetime maximum, not a p95 sample. Keep same-window histogram counts and bucket resolution when deriving percentiles; missing observations do not mean zero latency. These metrics do not establish performance acceptance by themselves.
## Experimental heal node admission
## Experimental shared node admission
`RUSTFS_HEAL_ADAPTIVE_ADMISSION=true` enables Linux cgroup-v2 feedback for the four object-heal entry points owned by the heal manager. It is off by default while full SSD performance acceptance is pending. Unsupported platforms reject an enabled setting. This does not yet control scanner walks, bucket/format repair or physical devices, and it is not a replacement for existing worker, disk or byte budgets.
`RUSTFS_HEAL_ADAPTIVE_ADMISSION=true` enables Linux cgroup-v2 feedback shared by the heal manager’s four object-heal entry points and local Scanner namespace reads and object accounting. It is off by default while full SSD performance acceptance is pending. Unsupported platforms reject an enabled setting. This does not yet control the abandoned-child streaming list coordinator, bucket/format repair or physical devices, and it is not a replacement for existing worker, disk or byte budgets.
The node samples its effective CPU quota/cpuset, ancestor memory headroom, CPU usage/throttling and CPU/memory/I/O pressure once per second. Sampling uses one blocking worker at a time. Resource reads retain their real start/end times: reads spanning over 20 ms or older than one second after all inputs return cannot renew admission. Counter rates use both read brackets conservatively. The concurrency cap is at most the rounded-up effective CPU count and 256 scheduling units; existing operator limits still apply independently. At most 1% memory headroom blocks new work. This is a pressure safeguard, not a memory reservation or a claim that concurrent repair buffers fit in the remaining bytes.
Each complete sample leases admission for three seconds. Missing resource caps block new work; missing or low-confidence feedback retains at most one maintenance unit. CPU use above 90%, throttling or pressure above 1% of elapsed time, memory headroom at most 10%, foreground errors/cancellations, a foreground queue or a comparable foreground p99 regression reduce concurrency immediately. Recovery requires CPU use at most 70%, throttling/stalls at most 0.1% of elapsed time, at least 20% memory headroom, fresh foreground evidence and actual successful work. The existing controller probes one additional unit only after eight consecutive healthy seconds and cooldown. Limits affect new admission and do not revoke active work.
The Scanner captures the same node owner once per bucket walk. Directory open/refill, entry type and size lookup, and object accounting try admission before their first storage poll; each unit ends when its operation future finishes. On contention, local and remote coordinators suspend the same traversal, release the bucket cache lease, and join the shared FIFO queue. They reacquire the cache lease and verify both cache revisions before resuming. A changed cache rejects the suspended result. Cancellation also drops the queued request; the existing cycle budget still bounds the walk. This retains directory and recursion state while allowing Heal to advance at capacity one, without repeatedly replaying a partially classified directory.
Calls without a coordinating cache lease retain immediate resource deferral and useful partial-cache handling. Existing remote single-session ownership and local-disk admission shares remain in force. Disabled admission creates no request channel. Enabled walks use one bounded channel, with a response allocation and cache revision reads only on contention; the uncontended per-entry path adds no global lookup or task. The streaming abandoned-child coordinator requires a separate ownership boundary: retaining a node unit while it waits for a bounded-channel consumer that needs the same unit would deadlock at capacity one.
Foreground comparison uses the existing complete response-body windows and shared admission snapshots. Each active class needs at least 100 completions and a finite p99 interval; this permits the percentile rank but is not a statistical confidence guarantee. Comparisons require the same active classes and rates within 80–125% of a retained reference. A changed workload resets recovery. The reference is an observed workload window, not an independent background-off baseline. A p99 interval can justify stability only when its upper bound is within 5% of the reference lower bound; a lower bound above 105% of the reference upper bound is pressure. Overlapping/uncertain comparisons cannot justify growth. Missing queues, stale or repeated cuts, uncertain cut boundaries and low counts are not idle evidence.
Admission waits at most 250 ms before returning the existing typed transient deferral, before polling storage or taking namespace locks. No-lock callers never wait. Queued read repair uses the same node gate; direct foreground ECStore calls remain outside this heal-manager adapter. Cancellation releases the non-cloneable node scheduling unit. Native work retains separate physical-I/O units, and unsuccessful/cancelled/dry-run work cannot increment the node's completed-work counter. This scheduling counter is not an authoritative heal receipt and cannot discharge repair responsibility.
Heal admission waits at most 250 ms before returning the existing typed transient deferral, before polling storage or taking namespace locks. No-lock callers never wait. Queued read repair uses the same node gate; direct foreground ECStore calls remain outside this heal-manager adapter. Cancellation releases the non-cloneable node scheduling unit. Native work retains separate physical-I/O units, and unsuccessful/cancelled/dry-run work cannot increment the node's completed-work counter. This scheduling counter is not an authoritative heal receipt and cannot discharge repair responsibility.
The `rustfs_background_node_admission_limit` and `rustfs_background_node_admission_active` gauges describe the last published sample, not a live reservation proof. A blocked sampler can leave these exports stale even though the admission lease has expired. Full disk-owner feedback, shared scanner scheduling, escaping-buffer byte ownership and the matched-release SSD matrix/hot-shrink/72-hour soak remain required before enabling this by default or claiming optimal performance.
@@ -30,7 +30,7 @@ The same thing was missing at every layer: an inventory of what remote targets m
- **Change design.** A default that applies to every target was changed to satisfy one target class. The PR text considered checksums only for objects that already carried one ("object-level `x-amz-checksum-*` forwarding is unaffected") and never asked the inverse question: which target-side rules depend on a checksum being present.
- **Tests.** The new unit test asserted the fix ("no `x-amz-trailer`"), not the contract ("the target accepted the PUT"). The fake target modeled neither failure mode: it did not store `aws-chunked` verbatim and it did not demand a checksum on locked PUTs. `replication-check` has an ObjectLock phase, but its probe PUT carries no retention headers, so it would have reported OK against the customer's target.
- **Review.** The compatibility lens (`.agents/skills/adversarial-validation/references/compatibility.md`) listed only inbound and on-disk concerns: `xl.meta`, proto, MinIO fixtures. It contained no line about outbound target behavior, so the lens passed while blind to the class.
- **Review.** The compatibility review guidance listed only inbound and on-disk concerns: `xl.meta`, proto, MinIO fixtures. It contained no line about outbound target behavior, so the review passed while blind to the class.
- **Release.** Two new environment escape hatches shipped undocumented. The customer's second question in rustfs#7082 was whether a configuration option exists; it did, and it was unfindable.
## Corrective actions
@@ -39,7 +39,7 @@ The same thing was missing at every layer: an inventory of what remote targets m
| --- | --- |
| Outbound target matrix e2e: every target failure mode times every object shape, with an explicit expectation table. Known-red cells are pinned to an open issue and fail loudly when they start passing. | `crates/e2e_test/src/replication_target_matrix_test.rs` |
| Fake target gains the three failure modes a real fleet has shown: rejects `aws-chunked` uploads, requires a checksum on Object Lock PUTs (and verifies `Content-MD5`), mints its own version ids. | `crates/e2e_test/src/fake_s3_target/mod.rs` |
| Compatibility lens gains an outbound target section. | `.agents/skills/adversarial-validation/references/compatibility.md` |
| Review policy covers outbound target behavior. | `.agents/references/adversarial-validation.md` |
| `AGENTS.md` names outbound client defaults as high risk and requires the matrix plus documented escape hatches. | `AGENTS.md`, Adversarial Validation |
| The two escape hatches from rustfs#6895 are documented. | `docs/operations/replication-outbound-transport.md` |
| Product fix for rustfs#7082: a locked PUT carries `Content-MD5` derived from the source ETag when that ETag is the MD5 of the wire bytes, and an SDK CRC32 checksum otherwise; the two matrix cells flipped to `Completed` in the same change. | `crates/ecstore/src/bucket/bucket_target_sys.rs` (`object_lock_put_integrity_for`), `crates/replication/src/object.rs` (`object_lock_put_integrity`) |
+2
View File
@@ -34,6 +34,8 @@ The [Pool layout compatibility reference](pool-layout-compatibility.md) defines
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.
The [immutable validation candidate reference](validation-candidates.md) describes isolated package builds and exact candidate resolution for the current auto-testing fleet.
The [distributed multipart RPC delay gates](multipart-rpc-delay.md) define authenticated method-entry barriers, latency and peer-loss oracles, and the boundary between hook evidence and production incident attribution.
## Naming conventions

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