Compare commits

...

51 Commits

Author SHA1 Message Date
Hauser 4e138a7464 Merge branch 'main' into cursor/veeam-sosapi-6aa2 2026-10-28 14:45:28 +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
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 1c04789193 Merge branch 'main' into cursor/veeam-sosapi-6aa2 2026-10-28 11:38:57 +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
Hauser 3ec56f5da8 fix(s3): bind Veeam capacity to bucket identity
Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>
2026-10-28 10:59:40 +08:00
Hauser 40fbfa267c merge: integrate latest main
Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>
2026-10-28 09:00:25 +08:00
Hauser 4412577622 fix(s3): harden Veeam SOSAPI capacity and reads
Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>
2026-10-28 09:00:17 +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 3f933fbd65 Merge branch 'main' into cursor/veeam-sosapi-6aa2 2026-10-28 08:59:26 +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 dd9052ef70 fix(s3): simplify Veeam quota metadata lookup
Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>
2026-10-28 07:56:29 +08:00
Hauser 815f8e6cff Merge branch 'main' into cursor/veeam-sosapi-6aa2 2026-10-28 07:56:01 +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
RustFS 4795f83105 feat(s3): serve Veeam SOSAPI system and capacity XML
Authenticated GET and HEAD of the two well-known SOSAPI keys return
generated XML, and read preconditions use that generated ETag and
Last-Modified. Mutations of those keys are rejected while the feature
is on.

Co-authored-by: RustFS <hello@rustfs.com>
2026-10-27 23:10:09 +00:00
Chris An f879946ead chore: remove redundant review skills (#9022) 2026-10-28 07:03:23 +08:00
Chris An d88f524d9f fix(ecstore): serve the latest readable version past dangling versions (#9004)
* 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.

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

The rebalance control and meta modules 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.

* fix(ecstore): preserve canonical order in latest-version fallback
2026-10-28 06:16:19 +08:00
Chris An 54ac37ec0a test(decommission): cover held target lock and partial-copy resume (#9016)
* test(decommission): cover held target lock and partial-copy resume

Port two tests from the closed lock-recovery branch onto the deferral
that defers any typed namespace lock timeout on a version copy.

- A version commits, then every attempt at the next one times out on
  the target lock: the entry is deferred as a lock-timeout deferral, no
  failed item is counted, both source versions stay, and the resumed
  entry moves both versions with their exact bytes.
- A real write lock held on the target key makes target selection
  return a typed object-lock timeout, the entry defers instead of
  failing, and once the lock is released selection recovers and the
  resumed entry completes the move.

* 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 06:06:54 +08:00
Chris An 2b56666123 fix(lock): retain state during acquisition (#9014) 2026-10-28 04:57:46 +08:00
Chris An a24d305665 fix(scanner): summarize bucket root collection (#9013) 2026-10-28 04:05:44 +08:00
Martin Claesson c47c8f9f74 fix(sftp): allow bucket-scoped root listings (#8998)
* fix(sftp): allow bucket-scoped root listings

Listing the SFTP root required s3:ListAllMyBuckets. Without it a
bucket-scoped user got PermissionDenied on `ls /`; with it they saw every
bucket on the instance. S3 ListBuckets and the WebDAV root (#6298)
already fall back to a per-bucket filtered listing.

When the global ListAllMyBuckets check is denied, list buckets through
the session-aware ListBuckets path instead, so IAM filters the result to
buckets the principal may ListBucket or GetBucketLocation. Principals
with ListAllMyBuckets keep the unfiltered listing, and IAM outages and
timeouts still fail as before.

list_buckets_for_session and its request builder were WebDAV-only; they
are now also compiled with the sftp feature.

* fix(protocols): re-check the session key for bucket-scoped listings

list_buckets_for_session built its request from the identity captured at
login. Through the SFTP root-listing fallback, a service account disabled
or expired after login could keep listing bucket names, unfiltered when
its parent may list every bucket.

Validate the session key with check_key_valid_with_context, as
authorize_protocol_request does for every other protocol operation, build
the request from the fresh credentials and is_owner, map an invalid key
to AccessDenied, and attach the server context.

The SFTP fallback now passes the session's request headers and transport
security, like every other SFTP authorization. DummyBackend fails closed
when no session listing is queued, matching the trait default, and gains
a stall_authorization hook. New tests cover a service account disabled
mid-session, IAM outage and timeout on the global check (no fallback),
non-AccessDenied backend errors, truncation of fallback output and the
fail-closed default.

* test(protocols): compare error code without referencing s3s

The session-listing test compared against s3s::S3ErrorCode, which made
policy_test.rs a new s3s-referencing file and tripped the s3s footprint
ratchet (210 files against a baseline of 209). Compare the code's string
form instead.

* fix(protocols): refresh session bucket discovery without stale copies

Build bucket listing requests directly from freshly validated credentials
and server-scoped ownership instead of cloning and replacing login state.
Reuse the same authorization path for WebDAV capacity discovery.

Preserve typed access-denied checks through the protocol facade, and cover
request conditions, session-policy updates, revocation, listing bounds,
and fallback deadlines with regression tests.

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

---------

Co-authored-by: Hauser <housemecn@gmail.com>
Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-10-28 03:53:56 +08:00
Chris An 3a00e9bd42 fix(scanner): add context to set cache failures (#9010) 2026-10-28 03:53:45 +08:00
cxymds c0c60dd91d fix(admin): make pool diagnostics partial-outage safe (#8995) 2026-10-28 03:53:39 +08:00
Chris An b5c253f3ed docs(listing): describe degraded strict quorum limits (#8981) 2026-10-28 03:53:24 +08:00
Ramakrishna Chilaka 63d97f0877 perf(ecstore): coalesce rename_part and multipart completion I/O (#9001) 2026-10-28 03:50:06 +08:00
Chris An 4f04338ee1 test(connect): report drive probe timeout phases (#8977) 2026-10-28 03:27:49 +08:00
Chris An f96c94f950 fix(listing): treat key markers as literal keys (#8978)
* fix(listing): treat version markers as literal keys

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

* docs(listing): state cursor compatibility removal condition

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

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

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

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

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

Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-10-28 01:37:31 +08:00
Chris An 5ab1d5191e fix(heal): pace and parallelize replacement recovery (#9005) 2026-10-28 01:10:44 +08:00
Chris An acebee4ea3 fix(ecstore): select the latest version at read quorum like MinIO (#8982)
* fix(ecstore): select the latest version at read quorum like MinIO

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

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

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

Review of the read-quorum selection found three gaps:

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

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

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

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

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

Route reads through select_fileinfo_for_read: S3 buckets keep MinIO's
read-quorum rule, while is_meta_bucketname reads use the previous
algorithm unchanged as select_committed_fileinfo, so a split stays
unreadable until native recovery resolves it.
2026-10-28 00:47:50 +08:00
Chris An 268a5519f1 test(ecstore): stabilize metadata fanout lifecycle checks (#8999) 2026-10-28 00:07:05 +08:00
Chris An e452d837dd docs(scanner): clarify incomplete census diagnostics (#9002) 2026-10-27 23:33:28 +08:00
Chris An 1451f3189b fix(listing): leave delete recovery out of list requests (#8980)
* fix(listing): leave delete recovery out of list requests

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

* docs: require receiving peers to revalidate source claims

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

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

---------

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

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

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

* test(tools): retain native reference failure diagnostics

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

Document that sampled list quorum modes can also omit committed objects
whose copies mostly sit outside the sample, and recommend `strict` for
backup, sync, replication and lifecycle tooling.
2026-10-27 08:16:34 +08:00
212 changed files with 22251 additions and 3742 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,20 +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.
- 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 -2
View File
@@ -1,2 +1,2 @@
sha256-linux=14becc458b9eb24add510d94ee6cd04ba1fb19907d598effe1524e5242400d10
sha256-darwin=14becc458b9eb24add510d94ee6cd04ba1fb19907d598effe1524e5242400d10
sha256-linux=eb33067d90ee66c0a97e8f3658e46c152e47f897d2fbfdc6cb5ad93cfa55414b
sha256-darwin=eb33067d90ee66c0a97e8f3658e46c152e47f897d2fbfdc6cb5ad93cfa55414b
+1 -1
View File
@@ -229,7 +229,7 @@
{
"invariant": "list-objects-shared-corpus-orphan-cleanup-requires-authoritative-evidence",
"suite": "rustfs-ecstore",
"name": "store::list_objects::differential_tests::list_objects_shared_corpus_orphan_cleanup_requires_authoritative_evidence"
"name": "store::list_objects::differential_tests::list_objects_shared_corpus_orphan_listing_preserves_inputs"
},
{
"invariant": "list-objects-shared-corpus-consumer-drop-joins-real-disk-producer",
+1
View File
@@ -52,6 +52,7 @@ script-tests: ## Run shell script tests
./scripts/check_embedded_secrets.sh --self-test
$(RUSTFS_PYTHON_BIN) ./scripts/check_test_wiring.py --self-test
$(RUSTFS_PYTHON_BIN) ./scripts/test_e2e_binary.py
$(RUSTFS_PYTHON_BIN) ./scripts/test_inspect_btf_prototypes.py
$(RUSTFS_PYTHON_BIN) ./scripts/test_release_build_workflow.py
$(RUSTFS_PYTHON_BIN) ./scripts/test_release_glibc.py
$(RUSTFS_PYTHON_BIN) ./scripts/test_upgrade_workflow.py
+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]]
@@ -74,7 +74,6 @@ Notes:
- `.github/workflows/e2e-s3tests.yml` (weekly full s3-tests sweep)
- `.github/workflows/mint.yml` (weekly multi-SDK mint run)
- `.github/workflows/fuzz.yml` (nightly fuzz corpus jobs)
- `.github/workflows/performance-ab.yml` (nightly warp A/B gate)
- **Pending:** the ci-7 e2e nightly-full workflow does not exist yet
(backlog#1149 ci-7). When it lands, wire it up with the snippet above.
-1
View File
@@ -18,7 +18,6 @@
"never_ran_grace_until": "2026-09-08T00:00:00Z"
},
{ "workflow": ".github/workflows/nightly-gnu.yml", "max_age_hours": 36 },
{ "workflow": ".github/workflows/performance-ab.yml", "max_age_hours": 36 },
{
"workflow": ".github/workflows/runner-hygiene.yml",
"max_age_hours": 792,
+10 -6
View File
@@ -7,7 +7,7 @@
This repository is public. For a public repository, GitHub's standard hosted runners (`ubuntu-latest`, `windows-latest`, `macos-latest`) cost nothing and have no minute cap, whatever plan the organisation is on. GitHub bills only two things: larger hosted runners, which have custom labels and are not used here, and standard runners in private repositories.
Every other label in this repository is capacity RustFS pays for by the hour whether or not a job is running well:
Every other label in this repository is capacity RustFS pays for. ARC pods and lab machines accrue cost while the machine exists. A Ubicloud label is metered for the minutes a job holds its VM:
| Label | Paid by | What it is | Use it for |
|---|---|---|---|
@@ -15,8 +15,8 @@ Every other label in this repository is capacity RustFS pays for by the hour whe
| `windows-latest`, `macos-latest` | nobody | GitHub-hosted standard runners | Windows and macOS targets |
| `sm-standard-2` | RustFS | ARC pod, Ubuntu 24.04, minimal image: no `gh`, no C toolchain; Docker only on the `dind-` label | only a job that a hosted runner cannot run (see the placement rule) |
| `sm-standard-4` | RustFS | ARC pod, Ubuntu 24.04, 14 CPUs and 28 GB (the label is not the core count), large disk | workspace-scale Cargo builds |
| `sm-standard-4-arm` | RustFS | ARC pod, aarch64; reached through the `build.yml` matrix, so `actionlint.yaml` does not list it | native aarch64 release builds |
| `dind-sm-standard-2` | RustFS | ARC pod with Docker-in-Docker | image builds that need `buildx` and a registry push |
| `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 | 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
@@ -40,7 +44,7 @@ A hosted runner is also the only choice for a few things the pods cannot do: `mo
- Every `shared-key` has exactly one writer. `cache-warm.yml` owns `ci-dev`, `ci-feat-rio`, `ci-feat-proto` and `ci-uring`; every other `./.github/actions/setup` call states `cache-save-if` explicitly, and `scripts/security/check_cache_save_if.sh` fails the build when one does not.
- All readers of a key run on the same image as its writer. Keys that live on hosted runners say so in their name (`ci-uring` is warmed on `ubuntu-latest`; `ci-e2e-distributed-hosted`, `e2e-upgrade-server-hosted`, `e2e-upgrade-tests-hosted`).
- Moving a job between pools therefore means renaming its key. The new key is empty until a run from `main` (a scheduled run, or a manual dispatch of that workflow from `main`) saves it; until then that lane does a cold build.
- Moving a job between pools therefore means renaming its key. The new key is empty until a run from `main` (a scheduled run, or a manual dispatch of that workflow from `main`) saves it; until then that lane does a cold build. The `build.yml` aarch64 release legs append `-ubicloud-arm` for this reason. `cache-warm.yml` does not write those keys.
- `CARGO_BUILD_JOBS` and every other `CARGO*`, `CC*`, `CFLAGS*`, `CXX*`, `CMAKE*` and `RUST*` variable is hashed into the key. `ci.yml` sets `CARGO_BUILD_JOBS` at step level for that reason; promoting it to job level rotates the key for every lane that shares it.
## Where self-hosted minutes leak
@@ -62,7 +66,7 @@ gh api "repos/rustfs/rustfs/actions/runs/<run_id>/jobs?per_page=100" \
--jq '.jobs[] | "\(.conclusion)\t\(.labels | join(","))\t\(.started_at)\t\(.completed_at)\t\(.name)"'
```
Only rows whose label starts with `sm-`, `dind-`, `smoke-` or `pf-` cost anything. At the October 2026 review, a full pull-request run of `ci.yml` spent about 150 self-hosted minutes, a third of it in jobs that never compiled; treat that as the scale of what a placement mistake costs, not as a current figure.
Only rows whose label starts with `sm-`, `dind-`, `ubicloud-`, `smoke-` or `pf-` cost anything. At the October 2026 review, a full pull-request run of `ci.yml` spent about 150 self-hosted minutes, a third of it in jobs that never compiled; treat that as the scale of what a placement mistake costs, not as a current figure.
## Checklist for a new or moved job
-5
View File
@@ -29,7 +29,6 @@ on:
- 'scripts/test_package_service_scripts.sh'
- 'scripts/test_package_versions.sh'
- 'scripts/test_docker_workflow.py'
- 'scripts/security/check_performance_ab_workflow.sh'
- 'scripts/security/check_preview_release_workflow.sh'
- 'scripts/security/check_tier_artifact_workflow.sh'
- 'scripts/security/check_workflow_pins.sh'
@@ -48,7 +47,6 @@ on:
- 'scripts/test_package_service_scripts.sh'
- 'scripts/test_package_versions.sh'
- 'scripts/test_docker_workflow.py'
- 'scripts/security/check_performance_ab_workflow.sh'
- 'scripts/security/check_preview_release_workflow.sh'
- 'scripts/security/check_tier_artifact_workflow.sh'
- 'scripts/security/check_workflow_pins.sh'
@@ -155,9 +153,6 @@ jobs:
- name: Check preview release workflow policy
run: ./scripts/security/check_preview_release_workflow.sh
- name: Check performance A/B workflow trust boundary
run: ./scripts/security/check_performance_ab_workflow.sh
- name: Check tier evidence workflow isolation
run: ./scripts/security/check_tier_artifact_workflow.sh
@@ -0,0 +1,75 @@
name: BTF prototype reference
on:
pull_request:
paths:
- "scripts/inspect_btf_prototypes.py"
- "scripts/test_inspect_btf_prototypes.py"
- "scripts/fixtures/btf_prototypes.c"
- ".github/workflows/btf-prototype-reference.yml"
workflow_dispatch:
permissions:
contents: read
concurrency:
group: btf-prototype-reference-${{ github.ref }}
cancel-in-progress: true
jobs:
reference:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
- name: Check prototype and CLI contracts
run: python3 -B scripts/test_inspect_btf_prototypes.py
- name: Install only missing native reference tools
run: |
select_bpftool() {
local candidate
local candidates=()
if command -v bpftool >/dev/null; then
candidates+=("$(command -v bpftool)")
fi
for candidate in /usr/lib/linux-tools-*/bpftool /usr/lib/linux-tools/*/bpftool; do
if [[ -x "$candidate" ]]; then
candidates+=("$candidate")
fi
done
while IFS= read -r candidate; do
if [[ -x "$candidate" ]] && file -Lb "$candidate" | grep -q 'ELF' \
&& "$candidate" version >/dev/null 2>&1; then
printf '%s\n' "$candidate"
return 0
fi
done < <(printf '%s\n' "${candidates[@]}" | sort -Vr)
return 1
}
packages=()
command -v clang >/dev/null || packages+=(clang)
command -v llvm-objcopy >/dev/null || packages+=(llvm)
command -v gcc >/dev/null || packages+=(gcc)
select_bpftool >/dev/null || packages+=(linux-tools-generic)
if (( ${#packages[@]} )); then
sudo apt-get update
sudo apt-get install --no-install-recommends -y "${packages[@]}"
fi
# File parsing needs a working binary, not a host-kernel wrapper.
selected_bpftool="$(select_bpftool)"
"$selected_bpftool" version
printf 'BPFTOOL=%s\n' "$selected_bpftool" >> "$GITHUB_ENV"
- name: Compare declarations and layouts with independent references
run: |
clang -target bpf -O2 -g -c scripts/fixtures/btf_prototypes.c -o "$RUNNER_TEMP/prototypes.o"
llvm-objcopy --dump-section ".BTF=$RUNNER_TEMP/prototypes.btf" "$RUNNER_TEMP/prototypes.o"
gcc -std=c11 -Wall -Wextra -Werror -DRUSTFS_BTF_LAYOUT_REFERENCE \
scripts/fixtures/btf_prototypes.c -o "$RUNNER_TEMP/layout-reference"
"$RUNNER_TEMP/layout-reference" > "$RUNNER_TEMP/layout-reference.json"
python3 -B scripts/test_inspect_btf_prototypes.py --native-reference "$RUNNER_TEMP/prototypes.btf" \
--layout-reference "$RUNNER_TEMP/layout-reference.json"
+17 -7
View File
@@ -181,12 +181,18 @@ jobs:
selected="all"
fi
# linux-aarch64-* need a real aarch64 host (placement rule: hardware)
# so the packaged binary and console smoke execute the artifact.
# ubicloud-standard-16-arm is that host: Ubuntu 24.04 arm64, alias of
# ubicloud-standard-16-arm-ubuntu-2404 (16 vCPU, 48 GB). linux-x86_64-* stay on the
# ARC sm-standard-4 pool. GNU rows keep cross=true so cargo-zigbuild
# pins the glibc 2.31 floor on the same architecture; musl stays a
# native cargo build.
all='{"include":[
{"target_id":"linux-x86_64-musl","os":"sm-standard-4","target":"x86_64-unknown-linux-musl","cross":false,"platform":"linux","rustflags":"","features":"","glibc":""},
{"target_id":"linux-aarch64-musl","os":"sm-standard-4-arm","target":"aarch64-unknown-linux-musl","cross":false,"platform":"linux","rustflags":"","features":"","glibc":""},
{"target_id":"linux-aarch64-musl","os":"ubicloud-standard-16-arm","target":"aarch64-unknown-linux-musl","cross":false,"platform":"linux","rustflags":"","features":"","glibc":""},
{"target_id":"linux-x86_64-gnu","os":"sm-standard-4","target":"x86_64-unknown-linux-gnu","cross":true,"platform":"linux","rustflags":"","features":"pyroscope","glibc":"2.31"},
{"target_id":"linux-aarch64-gnu","os":"sm-standard-4-arm","target":"aarch64-unknown-linux-gnu","cross":true,"platform":"linux","rustflags":"","features":"pyroscope","glibc":"2.31"},
{"target_id":"linux-aarch64-gnu","os":"ubicloud-standard-16-arm","target":"aarch64-unknown-linux-gnu","cross":true,"platform":"linux","rustflags":"","features":"pyroscope","glibc":"2.31"},
{"target_id":"macos-aarch64","os":"macos-latest","target":"aarch64-apple-darwin","cross":false,"platform":"macos","rustflags":"","features":"pyroscope","glibc":""},
{"target_id":"windows-x86_64","os":"windows-latest","target":"x86_64-pc-windows-msvc","cross":false,"platform":"windows","rustflags":"","features":"","glibc":""}
]}'
@@ -244,7 +250,11 @@ jobs:
with:
rust-version: stable
target: ${{ matrix.target }}
cache-shared-key: build-${{ matrix.target }}${{ matrix.glibc && format('-glibc-{0}', matrix.glibc) || '' }}
# rust-cache keys include OS and architecture but not the runner image.
# The Ubicloud aarch64 legs therefore use their own suffix so they
# cannot restore a cache written on a different arm64 image.
# x86_64, macOS, and Windows keys stay unsuffixed.
cache-shared-key: build-${{ matrix.target }}${{ matrix.glibc && format('-glibc-{0}', matrix.glibc) || '' }}${{ matrix.os == 'ubicloud-standard-16-arm' && '-ubicloud-arm' || '' }}
# main only. A cache saved on refs/tags/X is scoped to that tag: no
# other tag, no main run and no PR can restore it, so every release
# cycle wrote up to 12 entries of 1-2GB (preview tag plus final tag,
@@ -764,9 +774,9 @@ jobs:
exit 0
fi
# The self-hosted Linux runners do not ship the aws CLI (GitHub-hosted
# images did). Install the official CLI bundle on demand so R2 uploads
# survive a fresh runner without relying on a distro package.
# ARC pods (the x86_64 legs) do not ship the aws CLI. Ubicloud arm64
# images follow the GitHub runner image and often do. Install the
# official CLI bundle when it is missing so R2 uploads work on either.
if ! command -v aws >/dev/null 2>&1; then
echo "aws CLI not found on runner; installing..."
if command -v apt-get >/dev/null 2>&1; then
+1 -1
View File
@@ -45,7 +45,7 @@ on:
# 07:00 UTC Sunday — staggered clear of the other Sunday crons: ci (00:00),
# build (01:00), e2e-s3tests (02:00), audit (03:00), nix-flake-update
# (05:00), mint (06:00), and the daily fuzz (02:00), minio-interop (03:17),
# e2e-replication-nightly (04:00) and performance-ab (06:00) lanes.
# and e2e-replication-nightly (04:00) lanes.
- cron: "43 7 * * 0"
concurrency:
+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
@@ -38,8 +38,8 @@ name: e2e-nightly
on:
workflow_dispatch:
schedule:
# 04:00 UTC nightly — staggered clear of fuzz/e2e-s3tests (02:00),
# stale (01:30) and performance-ab (06:00).
# 04:00 UTC nightly — staggered clear of fuzz/e2e-s3tests (02:00)
# and stale (01:30).
- cron: "29 4 * * *"
# Only alert-on-failure needs more than read access; it declares its own
-384
View File
@@ -1,384 +0,0 @@
# Copyright 2024 RustFS Team
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# Warp A/B budget gate for the hotpath series (rustfs/backlog#935 HP-14, item 4).
#
# Two entry points, honestly scoped:
# * schedule (nightly, on main): post-merge detection — catches a regression
# within 24h of landing, not before merge.
# * workflow_dispatch: an explicitly selected trusted ref.
# The dispatch input can run the gate with --allow-regression so a deliberate
# correctness cost (e.g. the #4221 fsync durability fix) is recorded, not
# blocked (rustfs/backlog#935 correction 1).
name: Performance A/B
on:
schedule:
- cron: "31 6 * * *" # 06:31 UTC nightly, against main
workflow_dispatch:
inputs:
duration:
description: "warp duration per round"
required: false
default: "12s"
type: string
allow_regression:
description: "Pass the gate despite a FAIL (deliberate tradeoff)"
required: false
default: false
type: boolean
permissions:
actions: read
contents: read
env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1
jobs:
warp-ab:
if: github.repository == 'rustfs/rustfs'
name: Warp A/B budget gate
runs-on: sm-standard-2
# A normal nightly restores the last successful binary and builds only the
# candidate; daily access keeps that cache warm. A cache miss may build both
# and needs room for the A/B run plus artifact and cache publication.
timeout-minutes: 180
steps:
- name: Checkout repository
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
fetch-depth: 0 # baseline may be an earlier successful scheduled head
- name: Setup Rust environment
uses: ./.github/actions/setup
with:
rust-version: stable
cache-shared-key: warp-ab-${{ hashFiles('**/Cargo.lock') }}
cache-save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Install warp
run: |
set -euo pipefail
WARP_VERSION="1.0.0"
curl -fsSL "https://github.com/minio/warp/releases/download/v${WARP_VERSION}/warp_Linux_x86_64.tar.gz" \
| sudo tar -xz -C /usr/local/bin warp
warp --version
- name: Decide exemption
id: exempt
env:
INPUT_ALLOW_REGRESSION: ${{ github.event.inputs.allow_regression }}
run: |
allow="false"
if [[ "$INPUT_ALLOW_REGRESSION" == "true" ]]; then
allow="true"
fi
echo "allow_regression=$allow" >> "$GITHUB_OUTPUT"
# A failed regression run must keep comparing against the last known-good
# scheduled head. Otherwise the next nightly would absorb the regression
# into its baseline and turn green without a fix.
- name: Find last successful scheduled baseline
id: scheduled_baseline
if: github.event_name == 'schedule'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
result-encoding: string
script: |
const { data } = await github.rest.actions.listWorkflowRuns({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: "performance-ab.yml",
event: "schedule",
status: "success",
per_page: 1,
});
return data.workflow_runs[0]?.head_sha ?? "";
# Manual runs compare a selected ref with current main. Scheduled runs
# compare current main with the last successful scheduled head. With no
# history, the first run measures the candidate against itself and seeds
# that head only if the complete rig succeeds.
- name: Resolve baseline / candidate commits
id: commits
env:
SCHEDULED_BASELINE_SHA: ${{ steps.scheduled_baseline.outputs.result }}
run: |
set -euo pipefail
candidate_sha="$(git rev-parse HEAD)"
if [[ "${{ github.event_name }}" == "schedule" ]]; then
baseline_sha="${SCHEDULED_BASELINE_SHA:-$candidate_sha}"
else
baseline_sha="$(git rev-parse origin/main)"
fi
git cat-file -e "${baseline_sha}^{commit}"
if ! git merge-base --is-ancestor "$baseline_sha" "$candidate_sha"; then
echo "::error::baseline $baseline_sha is not an ancestor of candidate $candidate_sha; update the selected ref before comparing" >&2
exit 1
fi
echo "baseline_sha=$baseline_sha" >> "$GITHUB_OUTPUT"
echo "candidate_sha=$candidate_sha" >> "$GITHUB_OUTPUT"
echo "baseline commit: $baseline_sha"
echo "candidate commit: $candidate_sha"
# Exact-key restore of the candidate binary saved by its successful
# scheduled run. A miss leaves cache-hit unset and falls back to a source
# build of that known-good head.
- name: Restore cached baseline binary
id: baseline_cache
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: baseline-bin/rustfs
key: rustfs-baseline-${{ steps.commits.outputs.baseline_sha }}
# Self-heal: on a nightly/dispatch run where the candidate commit IS the
# baseline commit, a cache miss would make the rig build the same commit
# twice (~65min per side with the post-#4806 LTO profile — no job budget
# fits that). Build it once here, reuse it for both phases, and save it
# back to the cache so the next run hits.
- name: Build baseline on cache miss (same-commit self-heal)
id: selfheal
if: >-
steps.baseline_cache.outputs.cache-hit != 'true' &&
steps.commits.outputs.baseline_sha == steps.commits.outputs.candidate_sha
run: |
set -euo pipefail
cargo build --release --bin rustfs
mkdir -p baseline-bin
cp target/release/rustfs baseline-bin/rustfs
echo "built=true" >> "$GITHUB_OUTPUT"
- name: Build baseline on cache miss (different candidate)
id: baseline_build
if: >-
steps.baseline_cache.outputs.cache-hit != 'true' &&
steps.commits.outputs.baseline_sha != steps.commits.outputs.candidate_sha
run: |
set -euo pipefail
baseline_root="$RUNNER_TEMP/rustfs-baseline-${{ github.run_id }}"
baseline_target="$RUNNER_TEMP/rustfs-baseline-target-${{ github.run_id }}"
git worktree add --detach "$baseline_root" "${{ steps.commits.outputs.baseline_sha }}"
cargo build --release --manifest-path "$baseline_root/Cargo.toml" --bin rustfs --target-dir "$baseline_target"
mkdir -p baseline-bin
cp "$baseline_target/release/rustfs" baseline-bin/rustfs
git worktree remove --force "$baseline_root"
echo "built=true" >> "$GITHUB_OUTPUT"
- name: Build candidate binary
id: candidate_build
if: steps.commits.outputs.baseline_sha != steps.commits.outputs.candidate_sha
run: |
set -euo pipefail
cargo build --release --bin rustfs
mkdir -p candidate-bin
cp target/release/rustfs candidate-bin/rustfs
echo "built=true" >> "$GITHUB_OUTPUT"
- name: Save self-healed baseline to cache
if: steps.selfheal.outputs.built == 'true' || steps.baseline_build.outputs.built == 'true'
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: baseline-bin/rustfs
key: rustfs-baseline-${{ steps.commits.outputs.baseline_sha }}
- name: Run warp A/B and gate
id: ab
env:
INPUT_DURATION: ${{ github.event.inputs.duration }}
run: |
set -euo pipefail
# The formal runner executes A1 baseline -> B1 candidate -> B2 candidate
# -> A2 baseline for each workload and drive-sync cell. It requires three
# rounds per leg to emit tail latency and error-rate evidence.
# --health-timeout 180 outlasts the server's own 120s startup-readiness
# budget, which the rig's previous 60s health poll undershot (the first
# two nightly failures). perf-6 recalibrates these once the noise study
# lands.
duration="${INPUT_DURATION:-12s}"
baseline_sha="${{ steps.commits.outputs.baseline_sha }}"
candidate_sha="${{ steps.commits.outputs.candidate_sha }}"
baseline_hit="${{ steps.baseline_cache.outputs.cache-hit }}"
selfheal_built="${{ steps.selfheal.outputs.built }}"
baseline_built="${{ steps.baseline_build.outputs.built }}"
candidate_built="${{ steps.candidate_build.outputs.built }}"
args=(--duration "$duration" --rounds 3 --cooldown 5 --health-timeout 180 --baseline-revision "$baseline_sha" --candidate-revision "$candidate_sha")
if [[ "$baseline_hit" == "true" || "$selfheal_built" == "true" || "$baseline_built" == "true" ]]; then
chmod +x baseline-bin/rustfs
base_bin="$PWD/baseline-bin/rustfs"
args+=(--baseline-bin "$base_bin")
if [[ "$baseline_hit" == "true" ]]; then
base_src="actions-cache (rustfs-baseline-$baseline_sha)"
elif [[ "$selfheal_built" == "true" ]]; then
base_src="source build (cache self-heal, saved as rustfs-baseline-$baseline_sha)"
else
base_src="isolated baseline source build (saved as rustfs-baseline-$baseline_sha)"
fi
if [[ "$candidate_sha" == "$baseline_sha" ]]; then
# No commits landed since the last successful baseline, so reuse
# the one binary for both phases and measure only rig drift.
args+=(--candidate-bin "$base_bin")
cand_src="same binary as baseline (same commit)"
elif [[ "$candidate_built" == "true" ]]; then
chmod +x candidate-bin/rustfs
args+=(--candidate-bin "$PWD/candidate-bin/rustfs")
cand_src="source build of the checked-out ref"
else
echo "::error::candidate binary was not built" >&2
exit 2
fi
else
echo "::error::baseline binary was not restored or built" >&2
exit 2
fi
echo "baseline binary: $base_src"
echo "candidate binary: $cand_src"
if [[ "${{ steps.exempt.outputs.allow_regression }}" == "true" ]]; then
args+=(--allow-regression --exemption-reason "workflow dispatch override")
fi
# Do not let a gate FAIL abort the job here; capture status and surface
# it after the step summary is written.
set +e
bash scripts/run_hotpath_warp_abba.sh "${args[@]}"
echo "status=$?" >> "$GITHUB_OUTPUT"
set -e
# Locate the newest run dir + gate.md for the summary/comment/artifact
# steps. On a startup failure there is no gate.md, but the run dir still
# holds server-logs/ for diagnosis.
# Run dirs are UTC-timestamp names (no special chars); ls is safe here.
# shellcheck disable=SC2012
run_dir="$(ls -td target/hotpath-abba/*/ 2>/dev/null | head -n1 || true)"
echo "run_dir=${run_dir%/}" >> "$GITHUB_OUTPUT"
# shellcheck disable=SC2012
gate_md="$(ls -t target/hotpath-abba/*/candidate_gate.md 2>/dev/null | head -n1 || true)"
echo "gate_md=$gate_md" >> "$GITHUB_OUTPUT"
- name: Upload A/B results
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: hotpath-warp-ab-${{ github.run_number }}
# Includes per-cell median_summary.csv / baseline_compare.csv, both gates,
# and server-logs/ (rustfs.log + startup env per phase) so a failed run
# is diagnosable. Short retention: this is churny nightly debug data.
path: target/hotpath-abba/
if-no-files-found: warn
retention-days: 14
- name: Write gate summary
if: always()
run: |
set -euo pipefail
status="${{ steps.ab.outputs.status }}"
gate_md="${{ steps.ab.outputs.gate_md }}"
run_dir="${{ steps.ab.outputs.run_dir }}"
{
echo "## Hotpath warp A/B — run ${{ github.run_number }}"
echo
if [[ "$status" == "0" ]]; then
echo "Rig/gate exit: \`0\` (pass or warn)."
else
echo "Rig/gate exit: \`${status:-unknown}\` — **FAILED**."
fi
echo
if [[ -n "$gate_md" && -f "$gate_md" ]]; then
cat "$gate_md"
else
echo "No \`gate.md\` produced — the rig failed **before** the gate"
echo "(most likely server startup / health). Failing phase(s) below;"
echo "full logs in the \`hotpath-warp-ab-${{ github.run_number }}\` artifact."
if [[ -n "$run_dir" && -d "$run_dir/server-logs" ]]; then
for f in "$run_dir"/server-logs/*.log; do
[[ -f "$f" ]] || continue
echo
echo "<details><summary>$(basename "$f")</summary>"
echo
echo '```'
tail -n 30 "$f"
echo '```'
echo "</details>"
done
fi
fi
} >> "$GITHUB_STEP_SUMMARY"
- name: Stage successful candidate baseline
if: >-
steps.ab.outputs.status == '0' &&
steps.commits.outputs.baseline_sha != steps.commits.outputs.candidate_sha
run: |
set -euo pipefail
cp candidate-bin/rustfs baseline-bin/rustfs
- name: Cache successful candidate baseline
if: >-
steps.ab.outputs.status == '0' &&
steps.commits.outputs.baseline_sha != steps.commits.outputs.candidate_sha
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: baseline-bin/rustfs
key: rustfs-baseline-${{ steps.commits.outputs.candidate_sha }}
# Scheduled failure alerting is handled by the alert-on-failure job below
# (perf-2 consuming ci-8's schedule-failure-issue composite action).
- name: Enforce gate
if: always()
run: |
status="${{ steps.ab.outputs.status }}"
if [[ -z "$status" ]]; then
echo "::error::warp A/B setup failed before the rig ran. Check the first failed workflow step." >&2
exit 1
fi
if [[ "$status" != "0" ]]; then
echo "::error::warp A/B budget gate failed (exit $status). See the step summary / gate.md artifact." >&2
exit "$status"
fi
echo "warp A/B budget gate passed."
alert-on-failure:
name: Alert on scheduled failure
needs: [warp-ab]
# `always()` is required: without it this job is skipped when a needed
# job fails. Alerts only for scheduled (nightly) runs (backlog#1149
# ci-8); manual dispatch failures are already watched by a human.
# `cancelled` is included alongside `failure` on purpose: a job that hits
# timeout-minutes ends as `cancelled`, and the 2026-07-11..07-14 nightly
# timeouts went silent precisely because the guard was failure-only. The
# composite action already reports cancelled/timed-out jobs in the issue
# body.
if: >-
github.repository == 'rustfs/rustfs' && (
always() && github.event_name == 'schedule' &&
(contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled'))
)
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
issues: write
steps:
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
- name: Open or update failure-tracking issue
uses: ./.github/actions/schedule-failure-issue
with:
github-token: ${{ secrets.BACKLOG_ISSUE_TOKEN }}
+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
@@ -42,7 +42,7 @@ concurrency:
jobs:
prepare:
if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.event == 'schedule' }}
runs-on: smoke-testing
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
manifest: ${{ steps.candidate.outputs.manifest }}
@@ -188,7 +188,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
@@ -16,8 +16,8 @@
#
# Forces a job failure and then runs .github/actions/schedule-failure-issue
# through the exact `needs` + `always() && contains(needs.*.result,
# 'failure')` wiring used by the real consumers (e2e-s3tests, mint, fuzz,
# performance-ab). Dispatch it twice to verify both the issue-creation and
# 'failure')` wiring used by the real consumers (e2e-s3tests, mint, fuzz).
# Dispatch it twice to verify both the issue-creation and
# the dedupe-comment paths, then close the resulting
# "[scheduled-failure] Schedule Failure Alert Drill" issue in rustfs/backlog
# (the default target repository). Requires the BACKLOG_ISSUE_TOKEN secret
@@ -28,7 +28,6 @@ on:
- "mint"
- "minio-interop"
- "Nightly GNU Build"
- "Performance A/B"
- "Runner Hygiene"
types: [completed]
+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 }
+1
View File
@@ -100,6 +100,7 @@ when restoring `/rustfs/console` for a custom OEM build.
## S3 API environment variables
- `RUSTFS_API_OBJECT_MAX_VERSIONS` caps the number of retained versions for a single object. It defaults to `9223372036854775807`, matching MinIO's practical-unlimited default. Set a positive integer to enforce a lower per-object metadata bound.
- `RUSTFS_VEEAM_SOSAPI_ENABLE` serves Veeam SOSAPI `system.xml` and `capacity.xml` for an authenticated GET or HEAD of those exact bucket-root keys. It defaults to `true`. Explicit version reads use the normal object path. Capacity requests return `ServiceUnavailable` until the scanner publishes a complete snapshot certified for the current bucket incarnation and authoritative quota metadata can be read. Legacy or mixed-version snapshots without an incarnation proof cannot serve capacity until a compatible scanner publishes a certified snapshot. Capacity also returns `ServiceUnavailable` when cluster or disk observations are missing, stale, or degraded. Retry after scanner convergence and storage or metadata recovery. Capacity assumes shard paths have independent backing space; shared physical storage can make the estimate optimistic. Set the flag to `false` to restore the normal object path, including deletion of a real object previously stored at that key.
- `MINIO_API_OBJECT_MAX_VERSIONS` is accepted as a compatibility alias when the canonical RustFS variable is not set.
## Distributed endpoint locality
+10
View File
@@ -222,6 +222,16 @@ pub const ENV_RUSTFS_TLS_PATH: &str = "RUSTFS_TLS_PATH";
/// Environment variable for server KMS enable.
pub const ENV_RUSTFS_KMS_ENABLE: &str = "RUSTFS_KMS_ENABLE";
/// Environment variable for Veeam SOSAPI virtual system objects.
///
/// When true (the default), authenticated GET and HEAD of the two Veeam
/// SOSAPI keys return generated XML. When false, those keys use the normal
/// object path so an operator can remove a real object stored there earlier.
pub const ENV_VEEAM_SOSAPI_ENABLE: &str = "RUSTFS_VEEAM_SOSAPI_ENABLE";
/// Default Veeam SOSAPI enablement.
pub const DEFAULT_VEEAM_SOSAPI_ENABLE: bool = true;
/// Default KMS enable for server-side encryption
/// This is the default value for enabling KMS encryption for server-side encryption.
/// Default value: false
+31
View File
@@ -466,6 +466,9 @@ pub struct BucketTargetUsageInfo {
#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct BucketUsageInfo {
pub size: u64,
/// Bucket identity certified by every set contributing to this snapshot.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bucket_incarnation: Option<String>,
// Following five fields suffixed with V1 are here for backward compatibility
// Total Size for objects that have not yet been replicated
pub replication_pending_size_v1: u64,
@@ -2316,6 +2319,9 @@ impl BucketUsageInfo {
/// Add size summary to this bucket usage
/// Merge another BucketUsageInfo into this one
pub fn merge(&mut self, other: &BucketUsageInfo) {
if self.bucket_incarnation != other.bucket_incarnation {
self.bucket_incarnation = None;
}
self.size += other.size;
self.objects_count += other.objects_count;
self.versions_count += other.versions_count;
@@ -3299,6 +3305,31 @@ mod tests {
assert_eq!(usage1.versions_count, 15);
}
#[test]
fn bucket_usage_merge_discards_incompatible_incarnation_proofs() {
let id = "00000000-0000-4000-8000-000000000001";
let mut same = BucketUsageInfo {
bucket_incarnation: Some(id.into()),
..Default::default()
};
same.merge(&BucketUsageInfo {
bucket_incarnation: Some(id.into()),
..Default::default()
});
assert_eq!(same.bucket_incarnation.as_deref(), Some(id));
same.merge(&BucketUsageInfo::default());
assert!(same.bucket_incarnation.is_none());
let mut different = BucketUsageInfo {
bucket_incarnation: Some(id.into()),
..Default::default()
};
different.merge(&BucketUsageInfo {
bucket_incarnation: Some("00000000-0000-4000-8000-000000000002".into()),
..Default::default()
});
assert!(different.bucket_incarnation.is_none());
}
#[test]
fn size_summary_add_saturates_instead_of_overflowing() {
// The scanner folds one summary per object into a per-prefix total, so a
+132 -3
View File
@@ -13,18 +13,20 @@
// limitations under the License.
use super::harness::{
DistCluster, DistLayout, TestResult, assert_object_bytes, payload_for, put_object, retrying_get_equals, unique_bucket,
wait_for_ready, wait_until,
DistCluster, DistLayout, TestResult, assert_object_bytes, cluster_admin_ok, get_object_bytes, payload_for, put_object,
retrying_get_equals, retrying_put, s3_probe_client, s3_probe_error_code, unique_bucket, wait_for_ready, wait_until,
};
use crate::chaos::{census_object_version_on_disk, signed_admin_post};
use crate::common::{build_test_s3_config, init_logging};
use crate::fault_proxy::FaultMode;
use aws_sdk_s3::Client;
use aws_sdk_s3::error::SdkError;
use aws_sdk_s3::operation::{get_object::GetObjectError, put_object::PutObjectError};
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::{Barrier, mpsc};
use tokio::time::timeout;
use tokio::time::{Instant, timeout};
#[tokio::test]
async fn kill_and_restart_node_preserves_objects() -> TestResult {
@@ -44,6 +46,133 @@ async fn kill_and_restart_node_preserves_objects() -> TestResult {
Ok(())
}
#[tokio::test]
async fn ec4_partial_node_return_restores_read_write_quorum() -> TestResult {
init_logging();
let mut dist = DistCluster::start_with_env(
DistLayout::FourByFour,
&[
("RUSTFS_ERASURE_SET_DRIVE_COUNT", "16"),
("RUSTFS_STORAGE_CLASS_STANDARD", "EC:4"),
],
)
.await?;
let storage = cluster_admin_ok(&dist.cluster, http::Method::GET, "/rustfs/admin/v3/storageinfo", None).await?;
let storage: serde_json::Value = serde_json::from_str(&storage)?;
let storage: rustfs_madmin::StorageInfo = serde_json::from_value(storage["info"].clone())?;
assert_eq!(storage.disks.len(), 16);
assert_eq!(storage.backend.total_sets, vec![1]);
assert_eq!(storage.backend.drives_per_set, vec![16]);
assert_eq!(storage.backend.standard_sc_parity, Some(4));
let bucket = unique_bucket("partial-ec4");
dist.create_bucket(&bucket).await?;
let client = s3_probe_client(&dist.client(0)?);
let baseline = payload_for("partial-recovery/baseline", 8 * 1024 * 1024);
put_object(&client, &bucket, "baseline.bin", baseline.clone()).await?;
wait_until(
Duration::from_secs(30),
|| async {
for node in &dist.cluster.nodes {
for directory in &node.data_dirs {
let census = census_object_version_on_disk(std::path::Path::new(directory), &bucket, "baseline.bin", None)?;
if !census.is_complete() {
return Ok(false);
}
assert_eq!(census.data_blocks, Some(12), "the object must require twelve data shards");
assert_eq!(census.parity_blocks, Some(4), "the object must have four parity shards");
assert!(!census.expected_part_numbers.is_empty(), "the baseline must use physical shards");
}
}
Ok(true)
},
"the baseline is complete on all sixteen EC:4 drives",
)
.await?;
dist.cluster.stop_node(2)?;
dist.cluster.stop_node(3)?;
let read_error = timeout(Duration::from_secs(60), get_object_bytes(&client, &bucket, "baseline.bin"))
.await?
.expect_err("eight unavailable drives must prevent the baseline GET");
assert!(
matches!(
s3_probe_error_code(read_error.as_ref()),
Some("SlowDown" | "SlowDownRead" | "ServiceUnavailable")
),
"quorum loss must produce an availability error: {read_error:?}"
);
assert_eq!(
read_error
.downcast_ref::<SdkError<GetObjectError>>()
.and_then(SdkError::raw_response)
.map(|response| response.status().as_u16()),
Some(503),
"read quorum loss must remain a service availability failure"
);
let write_error = timeout(Duration::from_secs(60), put_object(&client, &bucket, "unavailable.bin", baseline.clone()))
.await?
.expect_err("eight unavailable drives must prevent a successful PUT");
assert!(
matches!(s3_probe_error_code(write_error.as_ref()), Some("SlowDown" | "ServiceUnavailable")),
"quorum loss must produce an availability error: {write_error:?}"
);
assert_eq!(
write_error
.downcast_ref::<SdkError<PutObjectError>>()
.and_then(SdkError::raw_response)
.map(|response| response.status().as_u16()),
Some(503),
"write quorum loss must remain a service availability failure"
);
// Recover only four drives. The remaining node must stay stopped while
// every live coordinator serves old data and acknowledges new writes.
dist.cluster.start_node_process(2).await?;
let recovery_deadline = Instant::now() + Duration::from_secs(90);
let mut recovered_objects = Vec::new();
for node in 0..3 {
assert!(dist.cluster.nodes[3].process.is_none(), "the fourth node must remain unavailable");
let client = s3_probe_client(&dist.client(node)?);
retrying_get_equals(
&client,
&bucket,
"baseline.bin",
&baseline,
recovery_deadline.saturating_duration_since(Instant::now()),
)
.await?;
let key = format!("partial-{node}.bin");
let body = payload_for(&key, 1024 * 1024);
retrying_put(
&client,
&bucket,
&key,
body.clone(),
recovery_deadline.saturating_duration_since(Instant::now()),
)
.await?;
recovered_objects.push((key, body));
}
for node in 0..3 {
assert!(dist.cluster.nodes[3].process.is_none(), "reads must complete before all drives return");
let client = s3_probe_client(&dist.client(node)?);
assert_object_bytes(&client, &bucket, "baseline.bin", &baseline).await?;
for (key, body) in &recovered_objects {
assert_object_bytes(&client, &bucket, key, body).await?;
}
}
dist.cluster.start_node(3).await?;
wait_for_ready(&dist.cluster).await?;
let client = s3_probe_client(&dist.client(3)?);
assert_object_bytes(&client, &bucket, "baseline.bin", &baseline).await?;
for (key, body) in &recovered_objects {
assert_object_bytes(&client, &bucket, key, body).await?;
}
Ok(())
}
#[tokio::test]
async fn full_cluster_restart_preserves_objects() -> TestResult {
init_logging();
+66 -5
View File
@@ -1181,11 +1181,16 @@ pub(crate) async fn retrying_get_equals(
)
.into()),
Err(error) => {
if matches!(s3_probe_error_code(error.as_ref()), Some("SlowDown" | "ServiceUnavailable")) {
Ok(false)
} else {
Err(error)
}
let retryable = match s3_probe_error_code(error.as_ref()) {
Some("SlowDown" | "ServiceUnavailable") => true,
// GET preserves this compatibility code for a lost data quorum.
Some("SlowDownRead") => error
.downcast_ref::<SdkError<GetObjectError>>()
.and_then(SdkError::raw_response)
.is_some_and(|response| response.status().as_u16() == 503),
_ => false,
};
if retryable { Ok(false) } else { Err(error) }
}
}
},
@@ -1324,6 +1329,62 @@ mod retry_tests {
Ok(())
}
#[tokio::test(start_paused = true)]
async fn retrying_get_allows_read_quorum_recovery() -> TestResult {
use aws_sdk_s3::config::Region;
use aws_smithy_http_client::test_util::{ReplayEvent, StaticReplayClient};
use aws_smithy_types::body::SdkBody;
let endpoint = "http://read-quorum.example.invalid";
let event = |status: u16, body: &'static str| {
ReplayEvent::new(
http::Request::builder()
.method(Method::GET)
.uri(format!("{endpoint}/retry-probe/key?x-id=GetObject"))
.body(SdkBody::empty())
.expect("build replay request"),
http::Response::builder()
.status(status)
.header("content-type", "application/xml")
.header("content-length", body.len())
.body(SdkBody::from(body))
.expect("build replay response"),
)
};
for status in [503, 403, 500] {
let transport = StaticReplayClient::new(vec![
event(status, "<Error><Code>SlowDownRead</Code></Error>"),
event(200, "expected bytes"),
]);
let client = Client::from_conf(
aws_sdk_s3::Config::builder()
.behavior_version_latest()
.credentials_provider(Credentials::new(FAKE_ACCESS_KEY, FAKE_SECRET_KEY, None, None, "read-quorum-test"))
.region(Region::new("us-east-1"))
.endpoint_url(endpoint)
.force_path_style(true)
.retry_config(RetryConfig::disabled())
.http_client(transport.clone())
.build(),
);
let result = retrying_get_equals(&client, "retry-probe", "key", b"expected bytes", Duration::from_secs(5)).await;
if status == 503 {
result?;
assert_eq!(
transport.actual_requests().count(),
2,
"the probe must retry the read-quorum error exactly once"
);
transport.assert_requests_match(&[]);
} else {
let error = result.expect_err("the compatibility code must not hide a non-503 failure");
assert_eq!(s3_probe_error_code(error.as_ref()), Some("SlowDownRead"));
assert_eq!(transport.actual_requests().count(), 1, "non-availability errors must fail immediately");
}
}
Ok(())
}
#[tokio::test]
async fn object_polls_distinguish_committed_and_replicated_keys() -> TestResult {
let target = FakeS3Target::start().await?;
+1
View File
@@ -40,5 +40,6 @@ mod scanner_heal_topology_fixture;
mod scanner_heal_topology_test;
mod scanner_heal_topology_tier_test;
mod site_replication_test;
mod table_catalog_test;
mod upgrade_test;
mod versioning_test;
@@ -1638,6 +1638,191 @@ async fn late_complete_packets_cannot_publish_after_abort_and_bucket_recreation(
"refused late packets must not recreate staging"
);
}
late_part_packets_preserve_retired_and_fresh_uploads().await?;
Ok(())
}
async fn late_part_packets_preserve_retired_and_fresh_uploads() -> TestResult {
for (method, settles) in [("PreparePartTransaction", false), ("SettlePartTransaction", true)] {
for recreate_bucket in [false, true] {
let (cluster, proxies, clients, bucket) = fixture(1, "EC:2").await?;
let key = "late-part-transaction";
let previous = payload_for("committed-before-late-part", 4096);
clients[0]
.put_object()
.bucket(&bucket)
.key(key)
.body(ByteStream::from(previous.clone()))
.send()
.await?;
let upload = clients[0].create_multipart_upload().bucket(&bucket).key(key).send().await?;
let upload_id = upload.upload_id().ok_or("missing original upload ID")?;
clients[0]
.upload_part()
.bucket(&bucket)
.key(key)
.upload_id(upload_id)
.part_number(1)
.body(ByteStream::from(vec![0x41; 9 * 1024 * 1024]))
.send()
.await?;
let path = upload_path_scope(&bucket, key, upload_id)?;
let hash = sha256_hex(format!("{bucket}/{key}").as_bytes());
let roots = cluster
.nodes
.iter()
.flat_map(|node| &node.data_dirs)
.map(|root| Path::new(root).join(RUSTFS_META_MULTIPART_BUCKET).join(&path))
.collect::<Vec<_>>();
assert_eq!(roots.len(), 4);
let original = multipart_sources(&cluster, Some(&hash))?;
assert_eq!(original.len(), 4);
assert!(
original.values().all(|source| source
.files
.keys()
.any(|path| path.file_name().and_then(|name| name.to_str()) == Some("part.1"))),
"all selected peers originally own the acknowledged part"
);
let scope = upload_scope(upload_id)?;
// Retain only the original attempt; recovery of the same upload
// must be able to issue fresh settlement requests through the gate.
let pauses = proxies[1..3]
.iter()
.map(|proxy| proxy.pause_late(&[method], 1, &scope))
.collect::<Result<Vec<_>, _>>()?;
let replacement = clients[0]
.upload_part()
.bucket(&bucket)
.key(key)
.upload_id(upload_id)
.part_number(1)
.body(ByteStream::from(vec![0x62; 8 * 1024 * 1024]));
let caller = tokio::spawn(async move { replacement.send().await });
wait_for_faults(&pauses, &[method]).await?;
let failure = tokio::time::timeout(DEADLINE, caller)
.await??
.expect_err("two retained peers cannot acknowledge part replacement");
assert_unavailable(&failure, "storage_write_quorum", None);
assert!(pauses.iter().all(|pause| pause.hits() == 1 && pause.responses() == 0));
let expected_actions = if settles { (1, 0) } else { (0, 0) };
assert!(
pauses.iter().all(|pause| pause.retained_settle_actions() == expected_actions),
"settlement capture must be a real Commit, not Rollback"
);
abort_after_fault(&clients[0], &bucket, key, upload_id, false).await?;
assert!(
roots.iter().all(|root| !root.exists()),
"ordinary Abort physically retires all original roots"
);
assert!(multipart_sources(&cluster, Some(&hash))?.is_empty());
for client in &clients {
assert_object_bytes_after_fault(client, &bucket, key, &previous).await?;
}
let protected = if recreate_bucket {
clients[0].delete_object().bucket(&bucket).key(key).send().await?;
clients[0].delete_bucket().bucket(&bucket).send().await?;
clients[1].create_bucket().bucket(&bucket).send().await?;
let fresh = payload_for("recreated-before-late-part", 4096);
clients[1]
.put_object()
.bucket(&bucket)
.key(key)
.body(ByteStream::from(fresh.clone()))
.send()
.await?;
fresh
} else {
previous
};
let fresh = clients[2].create_multipart_upload().bucket(&bucket).key(key).send().await?;
let fresh_id = fresh.upload_id().ok_or("missing fresh upload ID")?;
let fresh_body = payload_for("fresh-active-part", 4096);
let fresh_part = clients[2]
.upload_part()
.bucket(&bucket)
.key(key)
.upload_id(fresh_id)
.part_number(1)
.body(ByteStream::from(fresh_body.clone()))
.send()
.await?;
let before = multipart_sources(&cluster, Some(&hash))?;
assert_eq!(before.len(), 4, "the new upload exists on every original participant");
for pause in &pauses {
pause.release();
}
wait_until(
DEADLINE,
|| async {
Ok(pauses.iter().all(|pause| {
pause.responses() == pause.hits()
&& if settles {
pause.successful_mutation_replies() == pause.hits() && pause.refused_mutations() == 0
} else {
pause.refused_mutations() == pause.hits() && pause.successful_mutation_replies() == 0
}
}))
},
"every original part packet returns its typed authenticated terminal outcome",
)
.await?;
assert!(roots.iter().all(|root| !root.exists()), "late part packets cannot recreate retired roots");
assert!(
multipart_sources(&cluster, Some(&hash))? == before,
"late packets cannot change a fresh upload's shard or control bytes"
);
for client in &clients {
assert_object_bytes_after_fault(client, &bucket, key, &protected).await?;
let listed = client
.list_parts()
.bucket(&bucket)
.key(key)
.upload_id(fresh_id)
.send()
.await?;
assert_eq!(listed.parts().len(), 1);
assert_eq!(listed.parts()[0].e_tag(), fresh_part.e_tag());
let missing = client
.list_parts()
.bucket(&bucket)
.key(key)
.upload_id(upload_id)
.send()
.await
.expect_err("old upload remains retired");
assert_eq!(missing.raw_response().expect("old upload service response").status().as_u16(), 404);
assert_eq!(missing.as_service_error().and_then(|error| error.meta().code()), Some("NoSuchUpload"));
}
clients[2]
.complete_multipart_upload()
.bucket(&bucket)
.key(key)
.upload_id(fresh_id)
.multipart_upload(
CompletedMultipartUpload::builder()
.parts(
CompletedPart::builder()
.part_number(1)
.e_tag(fresh_part.e_tag().ok_or("missing fresh part ETag")?)
.build(),
)
.build(),
)
.send()
.await?;
for client in &clients {
assert_object_bytes_after_fault(client, &bucket, key, &fresh_body).await?;
}
assert!(multipart_sources(&cluster, Some(&hash))?.is_empty());
assert_no_uploads_after_fault(&clients, &bucket).await?;
eprintln!(
"MPU late part: method={method}, recreate_bucket={recreate_bucket}, retired_roots=4, delivered=2, refused={}, successful_replies={}, fresh_sources_unchanged=4, verified_readers=4",
pauses.iter().map(RpcPause::refused_mutations).sum::<usize>(),
pauses.iter().map(RpcPause::successful_mutation_replies).sum::<usize>()
);
}
}
Ok(())
}
@@ -0,0 +1,140 @@
// Copyright 2026 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use super::harness::{DistCluster, DistLayout, TestResult, get_object_bytes, put_object, unique_bucket};
use crate::common::{init_logging, signed_request};
use http::{Method, StatusCode};
use serde_json::{Value, json};
async fn catalog_request(
dist: &DistCluster,
node: usize,
method: Method,
path: &str,
body: Option<Value>,
expected: StatusCode,
) -> TestResult<Value> {
let response = signed_request(
method.clone(),
&format!("{}{path}", dist.cluster.nodes[node].url),
&dist.cluster.access_key,
&dist.cluster.secret_key,
body.map(|value| serde_json::to_vec(&value)).transpose()?,
Some("application/json"),
)
.await?;
let status = response.status();
let text = response.text().await?;
assert_eq!(status, expected, "node {node}: {method} {path}: {text}");
if text.is_empty() {
Ok(Value::Null)
} else {
Ok(serde_json::from_str(&text)?)
}
}
async fn assert_bucket_state(dist: &DistCluster, bucket: &str, enabled: bool) -> TestResult {
for node in 0..dist.cluster.nodes.len() {
let response =
catalog_request(dist, node, Method::GET, &format!("/iceberg/v1/buckets/{bucket}"), None, StatusCode::OK).await?;
assert_eq!(response["enabled"], enabled, "node {node}: {response}");
assert_eq!(response["table-bucket"], bucket);
let expected = if enabled { StatusCode::OK } else { StatusCode::BAD_REQUEST };
catalog_request(dist, node, Method::GET, &format!("/iceberg/v1/{bucket}/namespaces"), None, expected).await?;
assert_eq!(
get_object_bytes(&dist.client(node)?, bucket, "ordinary.txt").await?,
b"ordinary bucket object",
"node {node}: toggling table support changed an ordinary object"
);
}
Ok(())
}
async fn assert_table_state(dist: &DistCluster, table_path: &str, created: &Value, pointer: &Value) -> TestResult {
for node in 0..dist.cluster.nodes.len() {
let loaded = catalog_request(dist, node, Method::GET, table_path, None, StatusCode::OK).await?;
assert_eq!(loaded["metadata-location"], created["metadata-location"], "node {node}");
assert_eq!(loaded["metadata"], created["metadata"], "node {node}");
let actual_pointer =
catalog_request(dist, node, Method::GET, &format!("{table_path}/metadata-location"), None, StatusCode::OK).await?;
assert_eq!(&actual_pointer, pointer, "node {node}: catalog pointer changed");
}
Ok(())
}
#[tokio::test]
async fn table_bucket_toggles_reload_cached_metadata_on_every_peer() -> TestResult {
init_logging();
let dist = DistCluster::start_with_env(DistLayout::FourByFour, &[("RUSTFS_TABLE_CATALOG_BACKING", "object")]).await?;
let bucket = unique_bucket("table-peer-reload");
dist.create_bucket(&bucket).await?;
put_object(&dist.client(0)?, &bucket, "ordinary.txt", b"ordinary bucket object".to_vec()).await?;
let bucket_path = format!("/iceberg/v1/buckets/{bucket}");
// Warm each node's false cache before changing the marker. Do not poll:
// successful toggles must notify peers before their response is returned.
assert_bucket_state(&dist, &bucket, false).await?;
for (node, method, enabled) in [(0, Method::PUT, true), (1, Method::DELETE, false), (2, Method::PUT, true)] {
let response = catalog_request(&dist, node, method, &bucket_path, None, StatusCode::OK).await?;
assert_eq!(response["enabled"], enabled);
assert_bucket_state(&dist, &bucket, enabled).await?;
}
let namespaces = format!("/iceberg/v1/{bucket}/namespaces");
catalog_request(&dist, 3, Method::POST, &namespaces, Some(json!({"namespace": ["lab"]})), StatusCode::OK).await?;
let tables = format!("{namespaces}/lab/tables");
let created = catalog_request(
&dist,
0,
Method::POST,
&tables,
Some(json!({
"name": "events",
"schema": {
"type": "struct",
"fields": [{"id": 1, "name": "id", "required": false, "type": "long"}]
},
"stage-create": false
})),
StatusCode::OK,
)
.await?;
let uuid = created["metadata"]["table-uuid"]
.as_str()
.ok_or("create response omitted table UUID")?;
assert!(!uuid::Uuid::parse_str(uuid)?.is_nil());
assert!(
created["metadata-location"]
.as_str()
.is_some_and(|location| location.starts_with(&format!("s3://{bucket}/")))
);
let table_path = format!("{tables}/events");
let pointer =
catalog_request(&dist, 3, Method::GET, &format!("{table_path}/metadata-location"), None, StatusCode::OK).await?;
assert_eq!(pointer["metadata-location"], created["metadata-location"]);
assert!(pointer["version-token"].as_str().is_some_and(|token| !token.is_empty()));
assert!(pointer["generation"].as_u64().is_some());
assert_eq!(pointer["warehouse-location"], created["metadata"]["location"]);
assert_table_state(&dist, &table_path, &created, &pointer).await?;
// A rejected toggle must preserve both peer protections and catalog state.
catalog_request(&dist, 1, Method::DELETE, &bucket_path, None, StatusCode::CONFLICT).await?;
assert_bucket_state(&dist, &bucket, true).await?;
assert_table_state(&dist, &table_path, &created, &pointer).await?;
catalog_request(&dist, 2, Method::DELETE, &table_path, None, StatusCode::NO_CONTENT).await?;
catalog_request(&dist, 3, Method::DELETE, &format!("{namespaces}/lab"), None, StatusCode::NO_CONTENT).await?;
// The cluster fixture owns all remaining catalog objects and data roots.
Ok(())
}
+175 -11
View File
@@ -27,8 +27,8 @@ use hyper_util::{
};
use prost::Message as _;
use rustfs_protos::proto_gen::node_service::{
BatchGenerallyLockRequest, DeleteRequest, GenerallyLockRequest, ReadVersionRequest, RecoverMultipartSourceRequest,
RenameDataResponse,
BatchGenerallyLockRequest, DeleteRequest, GenerallyLockRequest, PreparePartTransactionResponse, ReadVersionRequest,
RecoverMultipartSourceRequest, RenameDataResponse, SettlePartTransactionRequest, SettlePartTransactionResponse,
};
use std::{
collections::HashSet,
@@ -56,6 +56,35 @@ enum Disposition {
Discard,
}
#[derive(Clone, Copy)]
enum MutationReply {
None,
Rename,
PreparePart,
SettlePart,
}
impl MutationReply {
fn outcome(self, bytes: &[u8]) -> io::Result<Option<(bool, bool)>> {
let result = match self {
Self::None => return Ok(None),
Self::Rename => {
let reply = RenameDataResponse::decode(unary_payload(bytes)?).map_err(io::Error::other)?;
(reply.success, reply.error.is_some())
}
Self::PreparePart => {
let reply = PreparePartTransactionResponse::decode(unary_payload(bytes)?).map_err(io::Error::other)?;
(reply.success, reply.error.is_some())
}
Self::SettlePart => {
let reply = SettlePartTransactionResponse::decode(unary_payload(bytes)?).map_err(io::Error::other)?;
(reply.success, reply.error.is_some())
}
};
Ok(Some(result))
}
}
struct PauseState {
methods: Vec<&'static str>,
scope: Option<Vec<u8>>,
@@ -64,6 +93,9 @@ struct PauseState {
limit: usize,
retain_after_disconnect: bool,
refused_mutations: AtomicUsize,
successful_mutation_replies: AtomicUsize,
settle_commits: AtomicUsize,
settle_rollbacks: AtomicUsize,
responses: AtomicUsize,
matched: AtomicUsize,
blocked: AtomicUsize,
@@ -122,6 +154,17 @@ impl RpcPause {
self.state.refused_mutations.load(Ordering::SeqCst)
}
pub(crate) fn successful_mutation_replies(&self) -> usize {
self.state.successful_mutation_replies.load(Ordering::SeqCst)
}
pub(crate) fn retained_settle_actions(&self) -> (usize, usize) {
(
self.state.settle_commits.load(Ordering::SeqCst),
self.state.settle_rollbacks.load(Ordering::SeqCst),
)
}
pub(crate) fn unsupported_source_inspection_routes(&self) -> HashSet<String> {
self.state
.unsupported_inspection_routes
@@ -394,6 +437,9 @@ impl RpcFaultProxy {
limit,
retain_after_disconnect,
refused_mutations: AtomicUsize::new(0),
successful_mutation_replies: AtomicUsize::new(0),
settle_commits: AtomicUsize::new(0),
settle_rollbacks: AtomicUsize::new(0),
responses: AtomicUsize::new(0),
matched: AtomicUsize::new(0),
blocked: AtomicUsize::new(0),
@@ -509,6 +555,7 @@ async fn forward_request(
if let Some(method) = method.filter(|name| pause.methods.contains(name)) {
let mut route = None;
let mut lock_owners = Vec::new();
let mut settle_action = None;
let matches = if let Some(scope) = pause.scope.as_deref() {
// Inspect bounded control routing while preserving the exact
// signed bytes and trailers forwarded to the backend.
@@ -522,12 +569,19 @@ async fn forward_request(
inspection = Some((pause.clone(), disk));
}
let mut matches = scope_matches(method, &bytes, scope)?;
if matches && method == "SettlePartTransaction" {
settle_action = Some(
SettlePartTransactionRequest::decode(unary_payload(&bytes)?)
.map_err(io::Error::other)?
.rollback,
);
}
if matches && (pause.record_lock_owners || pause.lock_owner.is_some()) {
lock_owners = scoped_lock_owners(method, &bytes, scope)?;
if let Some(owner) = &pause.lock_owner {
matches = lock_owners.iter().any(|candidate| candidate == owner);
if !matches && pause.record_lock_owners {
observed = Some((pause.clone(), None, std::mem::take(&mut lock_owners), false));
observed = Some((pause.clone(), None, std::mem::take(&mut lock_owners), MutationReply::None));
}
}
}
@@ -549,6 +603,14 @@ async fn forward_request(
true
};
if matches && pause.matched.fetch_add(1, Ordering::SeqCst) < pause.limit {
if let Some(rollback) = settle_action {
let counter = if rollback {
&pause.settle_rollbacks
} else {
&pause.settle_commits
};
counter.fetch_add(1, Ordering::SeqCst);
}
let mut released = pause.release.subscribe();
pause.blocked.fetch_add(1, Ordering::SeqCst);
pause.reached.notify_one();
@@ -568,10 +630,14 @@ async fn forward_request(
pause,
route,
lock_owners,
matches!(
method,
"RenameData" | "RenameMultipartData" | "RenameDataAtIncarnation" | "RenameDataWithQuotaFence"
),
match method {
"RenameData" | "RenameMultipartData" | "RenameDataAtIncarnation" | "RenameDataWithQuotaFence" => {
MutationReply::Rename
}
"PreparePartTransaction" => MutationReply::PreparePart,
"SettlePartTransaction" => MutationReply::SettlePart,
_ => MutationReply::None,
},
));
}
}
@@ -604,7 +670,7 @@ async fn forward_request(
.expect("inspection response routes")
.insert(disk);
}
if let Some((pause, route, lock_owners, rename)) = observed {
if let Some((pause, route, lock_owners, mutation_reply)) = observed {
pause.responses.fetch_add(1, Ordering::SeqCst);
if response.status().is_success() && response.headers().get("grpc-status").is_none_or(|status| status == "0") {
pause.forwarded.fetch_add(1, Ordering::SeqCst);
@@ -626,10 +692,14 @@ async fn forward_request(
.headers
.get("grpc-status")
.or_else(|| trailers.as_ref()?.get("grpc-status"));
if rename && parts.status.is_success() && status.is_none_or(|status| status == "0") {
let result = RenameDataResponse::decode(unary_payload(&bytes)?).map_err(io::Error::other)?;
if !result.success && result.error.is_some() {
if parts.status.is_success()
&& status.is_some_and(|status| status == "0")
&& let Some((success, has_error)) = mutation_reply.outcome(&bytes)?
{
if !success && has_error {
pause.refused_mutations.fetch_add(1, Ordering::SeqCst);
} else if success && !has_error {
pause.successful_mutation_replies.fetch_add(1, Ordering::SeqCst);
}
}
let body = Full::new(bytes)
@@ -676,6 +746,10 @@ mod tests {
}
async fn backend_with_status(status: Option<&'static str>) -> io::Result<Backend> {
backend_with_reply(status, None).await
}
async fn backend_with_reply(status: Option<&'static str>, reply: Option<Bytes>) -> io::Result<Backend> {
let listener = TcpListener::bind("127.0.0.1:0").await?;
let address = listener.local_addr()?;
let received = Arc::new(AtomicUsize::new(0));
@@ -687,9 +761,11 @@ mod tests {
accepted = listener.accept() => {
let Ok((socket, _)) = accepted else { break };
let received = received_task.clone();
let reply = reply.clone();
children.spawn(async move {
let service = service_fn(move |request: Request<Incoming>| {
let received = received.clone();
let reply = reply.clone();
async move {
received.fetch_add(1, Ordering::SeqCst);
let authority = request.headers().get(http::header::HOST).cloned().or_else(|| {
@@ -698,6 +774,7 @@ mod tests {
})
});
let body = request.into_body().collect().await?.to_bytes();
let body = reply.unwrap_or(body);
let mut trailers = http::HeaderMap::new();
trailers.insert("grpc-status", http::HeaderValue::from_static(status.unwrap_or("0")));
trailers.insert("x-proof", http::HeaderValue::from_static("unchanged-proof"));
@@ -1434,6 +1511,93 @@ mod tests {
.await
.expect("scoped discard deadline");
assert_late_packet_ownership().await;
assert_late_part_reply_refusal().await;
}
async fn assert_late_part_reply_refusal() {
use rustfs_protos::proto_gen::node_service::{Error, PreparePartTransactionRequest};
tokio::time::timeout(Duration::from_secs(10), async {
// method, success, error, gRPC status, rollback, expected successes/refusals
for (method, success, has_error, status, rollback, expected) in [
("PreparePartTransaction", false, true, None, false, (0, 1)),
("PreparePartTransaction", true, false, None, false, (1, 0)),
("PreparePartTransaction", false, false, None, false, (0, 0)),
("SettlePartTransaction", true, false, None, false, (1, 0)),
("SettlePartTransaction", false, true, None, true, (0, 1)),
("SettlePartTransaction", true, true, None, false, (0, 0)),
("SettlePartTransaction", false, true, Some("16"), false, (0, 0)),
("PreparePartTransaction", false, true, Some("16"), false, (0, 0)),
("RenameMultipartData", false, true, None, false, (0, 1)),
] {
let error = has_error.then(|| Error {
code: 1,
error_info: "retired source".to_owned(),
});
let reply = match method {
"PreparePartTransaction" => unary_frame(&PreparePartTransactionResponse { success, error }),
"SettlePartTransaction" => unary_frame(&SettlePartTransactionResponse { success, error }),
_ => unary_frame(&RenameDataResponse {
success,
error,
..Default::default()
}),
};
// An unknown field must survive typed inspection byte-for-byte.
let mut framed = reply.to_vec();
framed.extend_from_slice(&[0xfa, 0x03, 0x03, b'x', b'y', b'z']);
let payload_len = u32::try_from(framed.len() - 5).expect("small reply");
framed[1..5].copy_from_slice(&payload_len.to_be_bytes());
let reply = Bytes::from(framed);
let backend = backend_with_reply(status, Some(reply.clone()))
.await
.expect("typed reply backend");
let proxy = RpcFaultProxy::start_on("127.0.0.1:0".parse().expect("loopback"), backend.address)
.await
.expect("proxy");
let pause = proxy.pause_late(&[method], 1, "late-upload").expect("part gate");
let request = match method {
"PreparePartTransaction" => unary_frame(&PreparePartTransactionRequest {
disk: "disk".to_owned(),
dst_path: "late-upload/part.1".to_owned(),
..Default::default()
}),
"SettlePartTransaction" => unary_frame(&SettlePartTransactionRequest {
disk: "disk".to_owned(),
path: "late-upload/part.1".to_owned(),
rollback,
..Default::default()
}),
_ => Bytes::from_static(b"late-upload"),
};
let address = proxy.local_addr();
let caller = tokio::spawn(async move {
let client: ProxyClient = Client::builder(TokioExecutor::new()).http2_only(true).build_http();
let response = call_body(&client, address, method, request).await.expect("retained reply");
response.into_body().collect().await.expect("typed response frames")
});
pause.wait_for_hits(1).await.expect("actual retained request");
pause.release();
let response = caller.await.expect("caller task");
let trailers = response.trailers().expect("reply trailers");
assert_eq!(trailers["x-proof"], "unchanged-proof");
assert_eq!(trailers["grpc-status"], status.unwrap_or("0"));
assert_eq!(response.to_bytes(), reply);
assert_eq!(pause.responses(), 1);
assert_eq!(
(pause.successful_mutation_replies(), pause.refused_mutations()),
expected,
"typed {method} outcome"
);
let actions = if method == "SettlePartTransaction" {
if rollback { (0, 1) } else { (1, 0) }
} else {
(0, 0)
};
assert_eq!(pause.retained_settle_actions(), actions);
}
})
.await
.expect("part reply deadline");
}
async fn assert_late_packet_ownership() {
tokio::time::timeout(Duration::from_secs(10), async {
@@ -600,6 +600,154 @@ mod tests {
info!("✅ PASSED: Veeam backup workflow simulation completed successfully");
}
/// Veeam B&R discovers SOSAPI by GET/HEAD of two well-known keys.
#[tokio::test]
async fn test_veeam_sosapi_system_and_capacity() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
use crate::common::local_http_client;
const SYSTEM_KEY: &str = ".system-d26a9498-cb7c-4a87-a44a-8ae204f5ba6c/system.xml";
const CAPACITY_KEY: &str = ".system-d26a9498-cb7c-4a87-a44a-8ae204f5ba6c/capacity.xml";
init_logging();
info!("🧪 TEST: Veeam SOSAPI system.xml and capacity.xml");
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server_with_env(vec![], crate::common::FAST_DATA_USAGE_SCANNER_ENV)
.await?;
let client = create_s3_client(&env);
let bucket = "veeam-sosapi";
create_bucket(&client, bucket).await?;
let system = client.get_object().bucket(bucket).key(SYSTEM_KEY).send().await?;
let system_etag = system.e_tag().map(str::to_string);
let system_len = system.content_length();
assert_eq!(system.content_type(), Some("application/xml"));
let system_body = system.body.collect().await?.into_bytes();
let system_xml = std::str::from_utf8(&system_body)?;
assert!(system_xml.contains("<ProtocolVersion>\"1.0\"</ProtocolVersion>"), "{system_xml}");
assert!(
system_xml.contains("<ModelName>\"RustFS ") && system_xml.contains("\"</ModelName>"),
"{system_xml}"
);
assert!(system_xml.contains("<CapacityInfo>true</CapacityInfo>"));
assert!(system_xml.contains("<UploadSessions>false</UploadSessions>"));
assert!(system_xml.contains("<IAMSTS>false</IAMSTS>"));
assert!(!system_xml.contains("SystemRecommendations"));
assert!(!system_xml.contains("APIEndpoints"));
assert!(!system_xml.contains("&quot;"));
let head = client.head_object().bucket(bucket).key(SYSTEM_KEY).send().await?;
assert_eq!(head.content_type(), Some("application/xml"));
assert_eq!(head.content_length(), system_len);
assert_eq!(head.e_tag().map(str::to_string), system_etag);
assert!(head.last_modified().is_some());
let ranged = client
.get_object()
.bucket(bucket)
.key(SYSTEM_KEY)
.range("bytes=0-3")
.send()
.await?;
assert_eq!(ranged.content_length(), Some(4));
let ranged_header = ranged.content_range().unwrap_or("");
assert!(ranged_header.starts_with("bytes 0-3/"), "{ranged_header}");
let ranged_body = ranged.body.collect().await?.into_bytes();
assert_eq!(&ranged_body[..], &system_body[..4]);
let capacity = tokio::time::timeout(std::time::Duration::from_secs(180), async {
loop {
match client.get_object().bucket(bucket).key(CAPACITY_KEY).send().await {
Ok(capacity) => break Ok(capacity),
Err(err) if err.code() == Some("ServiceUnavailable") => {
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
}
Err(err) => break Err(err),
}
}
})
.await??;
assert_eq!(capacity.content_type(), Some("application/xml"));
let capacity_body = capacity.body.collect().await?.into_bytes();
let capacity_xml = std::str::from_utf8(&capacity_body)?;
let capacity_value = xml_u64(capacity_xml, "Capacity")?;
let available_value = xml_u64(capacity_xml, "Available")?;
let used_value = xml_u64(capacity_xml, "Used")?;
assert!(used_value <= capacity_value, "{capacity_xml}");
assert!(available_value <= capacity_value.saturating_sub(used_value), "{capacity_xml}");
let put_err = client
.put_object()
.bucket(bucket)
.key(SYSTEM_KEY)
.body(ByteStream::from_static(b"overwrite"))
.send()
.await
.expect_err("SOSAPI put must fail");
assert_eq!(put_err.code(), Some("MethodNotAllowed"));
let delete_err = client
.delete_object()
.bucket(bucket)
.key(CAPACITY_KEY)
.send()
.await
.expect_err("SOSAPI delete must fail");
assert_eq!(delete_err.code(), Some("MethodNotAllowed"));
let normal_key = "Veeam/Backup/job.dat";
client
.put_object()
.bucket(bucket)
.key(normal_key)
.body(ByteStream::from_static(b"backup-bytes"))
.send()
.await?;
let normal = client.get_object().bucket(bucket).key(normal_key).send().await?;
let normal_body = normal.body.collect().await?.into_bytes();
assert_eq!(&normal_body[..], b"backup-bytes");
let anon = local_http_client()
.get(format!("{}/{bucket}/{SYSTEM_KEY}", env.url))
.send()
.await?;
assert_eq!(anon.status(), reqwest::StatusCode::FORBIDDEN);
let anon_body = anon.text().await?;
assert!(!anon_body.contains("ProtocolVersion"), "{anon_body}");
let now = time::OffsetDateTime::now_utc();
let scope_date = format!("{:04}{:02}{:02}", now.year(), u8::from(now.month()), now.day());
let amz_date = format!("{scope_date}T{:02}{:02}{:02}Z", now.hour(), now.minute(), now.second());
let bad = local_http_client()
.get(format!("{}/{bucket}/{SYSTEM_KEY}", env.url))
.header("x-amz-date", &amz_date)
.header("x-amz-content-sha256", "UNSIGNED-PAYLOAD")
.header(
"authorization",
format!(
"AWS4-HMAC-SHA256 Credential=bad/{scope_date}/us-east-1/s3/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=0000000000000000000000000000000000000000000000000000000000000000"
),
)
.send()
.await?;
assert_eq!(bad.status(), reqwest::StatusCode::FORBIDDEN);
let bad_body = bad.text().await?;
assert!(!bad_body.contains("ProtocolVersion"), "{bad_body}");
info!("✅ PASSED: Veeam SOSAPI system.xml and capacity.xml");
Ok(())
}
fn xml_u64(xml: &str, element: &str) -> Result<u64, Box<dyn std::error::Error + Send + Sync>> {
let start = format!("<{element}>");
let end = format!("</{element}>");
let start_idx = xml.find(&start).ok_or_else(|| format!("missing {element} in {xml}"))? + start.len();
let end_idx = xml[start_idx..]
.find(&end)
.ok_or_else(|| format!("unclosed {element} in {xml}"))?
+ start_idx;
Ok(xml[start_idx..end_idx].parse()?)
}
#[tokio::test]
async fn test_terraform_put_after_delete() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
+3
View File
@@ -12,6 +12,9 @@ Applies to `crates/ecstore/`.
- Be careful with allocations and locking in hot paths.
- Keep network and disk operations async-friendly; avoid introducing unnecessary blocking.
- Coalesce related `open`, `read`/`write`, `fsync`, and `close` filesystem steps into a single per-disk blocking closure rather than chaining multiple async or blocking dispatches, and route any closure that performs file or directory sync to the disk's dedicated fsync lane so stalled syncs cannot starve foreground metadata reads.
- Pin a parent directory once (`openat2(BENEATH | NO_SYMLINKS)` on Linux) and reuse the pinned handle across sibling reads or same-directory renames instead of re-walking ancestor components per file.
- 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.
- Benchmark-sensitive changes should include measurable rationale.
## Allocation Discipline in Hot Paths
+17 -7
View File
@@ -252,7 +252,7 @@ pub mod bucket {
pub mod quota {
pub use crate::bucket::quota::{
BucketQuota, QUOTA_RESERVATION_PROTOCOL_V1, QUOTA_RESERVATION_PROTOCOL_V2, QuotaCheckResult, QuotaError,
QuotaMigrationSkipReason, QuotaOperation, QuotaProtocolMigration,
QuotaMigrationSkipReason, QuotaOperation, QuotaProtocolMigration, QuotaType,
};
pub mod checker {
@@ -341,9 +341,9 @@ pub mod cache {
pub mod capacity {
pub use crate::core::pools::{
DecommissionUnresolvedEntry, PoolDecommissionInfo, PoolMeta, PoolStatus, get_total_usable_capacity,
get_total_usable_capacity_free, is_pool_activation_fleet_proof_error, path2_bucket_object,
path2_bucket_object_with_base_path,
DecommissionUnresolvedEntry, PoolAdminDiagnostic, PoolAvailability, PoolCapacityStatus, PoolDecommissionInfo,
PoolDiagnosticStatus, PoolMeta, PoolStatus, get_total_usable_capacity, get_total_usable_capacity_free,
is_pool_activation_fleet_proof_error, path2_bucket_object, path2_bucket_object_with_base_path,
};
pub use crate::store::utils::is_reserved_or_invalid_bucket;
}
@@ -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,
@@ -416,9 +425,10 @@ pub mod data_usage {
DATA_USAGE_CACHE_NAME, apply_bucket_usage_memory_overlay, compute_bucket_usage,
init_compression_total_memory_from_backend, invalidate_admin_data_usage_snapshot_cache,
invalidate_data_usage_snapshot_cache, live_bucket_usage_computations, load_admin_data_usage_from_backend_cached,
load_compression_total_from_memory, load_data_usage_from_backend, load_data_usage_from_backend_cached, quota_object_size,
record_bucket_delete_marker_memory, record_bucket_object_delete_memory, record_bucket_object_version_write_memory,
record_bucket_object_write_memory, record_bucket_object_write_unknown_previous_memory, record_compression_total_memory,
load_bucket_usage_from_backend_cached, load_compression_total_from_memory, load_data_usage_from_backend,
load_data_usage_from_backend_cached, quota_object_size, record_bucket_delete_marker_memory,
record_bucket_object_delete_memory, record_bucket_object_version_write_memory, record_bucket_object_write_memory,
record_bucket_object_write_unknown_previous_memory, record_compression_total_memory,
refresh_bucket_usage_from_object_layer, refresh_versioned_bucket_usage_from_object_layer,
remove_bucket_usage_from_backend, replace_bucket_usage_memory_from_info, store_compression_total_in_backend,
store_data_usage_in_backend,
@@ -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() {
@@ -41,6 +41,10 @@ use tonic::service::interceptor::InterceptedService;
use tonic::{Request, Response};
use tracing::{debug, info, warn};
const EVENT_NAMESPACE_LOCK_RELEASE: &str = "namespace_lock_release";
const LOG_COMPONENT_ECSTORE: &str = "ecstore";
const LOG_SUBSYSTEM_REMOTE_LOCK: &str = "remote_lock";
fn attach_lock_mutation_body_digest<T: rustfs_protos::CanonicalMutationBody>(request: &mut Request<T>) -> std::io::Result<()> {
set_tonic_rolling_mutation_body_digest(request)
}
@@ -233,6 +237,20 @@ pub struct RemoteClient {
impl RemoteClient {
const ONLINE_CHECK_RESOURCE: &'static str = "health-lock-online";
fn record_release_ack(&self, lock_id: &LockId) {
tracing::trace!(
target: "rustfs_ecstore::object_lock_diag",
event = EVENT_NAMESPACE_LOCK_RELEASE,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_REMOTE_LOCK,
state = "acknowledged",
peer_kind = "remote",
lock_id = %lock_id,
peer = %self.addr,
"Remote namespace lock release acknowledged"
);
}
pub fn new(endpoint: String) -> Self {
Self { addr: endpoint }
}
@@ -1204,8 +1222,6 @@ impl LockClient for RemoteClient {
}
async fn release(&self, lock_id: &LockId) -> Result<bool> {
info!("remote release for {}", lock_id);
let unlock_request = Self::create_unlock_request(lock_id);
let request_string = serde_json::to_string(&unlock_request)
.map_err(|e| LockError::internal(format!("Failed to serialize request: {e}")))?;
@@ -1226,6 +1242,9 @@ impl LockClient for RemoteClient {
if let Some(error_info) = resp.error_info {
return Err(LockError::internal(error_info));
}
if resp.success {
self.record_release_ack(lock_id);
}
Ok(resp.success)
}
@@ -1261,7 +1280,13 @@ impl LockClient for RemoteClient {
Ok(lock_ids
.iter()
.enumerate()
.map(|(idx, _)| resp.results.get(idx).map(|result| result.success).unwrap_or(false))
.map(|(idx, lock_id)| {
let result = resp.results.get(idx);
if result.is_some_and(|result| result.success && result.error_info.is_none()) {
self.record_release_ack(lock_id);
}
result.map(|result| result.success).unwrap_or(false)
})
.collect())
}
+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<
+593 -16
View File
@@ -3720,6 +3720,46 @@ fn is_decommission_copy_quorum_deferral(err: &Error) -> bool {
data_movement::data_movement_stage_source_as::<DecommissionCopyQuorumFailure>(err).is_some()
}
/// The storage error of a version copy that kept timing out on a namespace
/// lock through its in-place retries, kept typed so the entry failure policy
/// defers it.
#[derive(Debug, thiserror::Error)]
#[error("{0}")]
struct DecommissionCopyLockTimeout(#[source] Error);
fn is_decommission_copy_lock_timeout_error(err: &Error) -> bool {
let err = data_movement::data_movement_stage_source(err).unwrap_or(err);
matches!(err, Error::Lock(rustfs_lock::LockError::Timeout { .. }))
}
/// A version copy that timed out acquiring a namespace lock leaves the source
/// in place; the next attempt copies the version again or finds an equivalent
/// committed target. The lock is held by another mutation of the key or, when
/// this node restarted mid-copy, by its previous incarnation until that lock's
/// lease expires on the peers. Either clears by waiting, so the entry is
/// deferred instead of being counted as a permanent per-object failure.
fn decommission_copy_lock_timeout_deferral(stage: &str, bucket: &str, object: &str, err: Error) -> Error {
data_movement::data_movement_context_error(
format!("decommission {stage} timed out on a namespace lock for {bucket}/{object}: {err}"),
DecommissionCopyLockTimeout(err),
)
}
fn is_decommission_copy_lock_timeout_deferral(err: &Error) -> bool {
data_movement::data_movement_stage_source_as::<DecommissionCopyLockTimeout>(err).is_some()
}
/// The deferral of a version copy whose in-place retries ended on `error`, if
/// that failure belongs to the pools at that moment rather than to the object.
fn take_decommission_copy_deferral(stage: &str, bucket: &str, object: &str, error: &mut Option<Error>) -> Option<Error> {
if let Some(err) = error.take_if(|err| is_decommission_copy_quorum_error(err)) {
return Some(decommission_copy_quorum_deferral(stage, bucket, object, err));
}
error
.take_if(|err| is_decommission_copy_lock_timeout_error(err))
.map(|err| decommission_copy_lock_timeout_deferral(stage, bucket, object, err))
}
fn resolve_decommission_entry_reload_result(result: Result<()>, bucket: &str, object_name: &str) -> Result<()> {
result.map_err(|err| Error::other(format!("decommission reload_pool_meta failed for {bucket}/{object_name}: {err}")))
}
@@ -5782,11 +5822,14 @@ enum DecommissionEntryOutcome {
/// entry is done and must not be counted as a failure or escalated. A quorum
/// failure of the source cleanup is deferred too: the copied source is still
/// in place and the next attempt cleans it up again. So is a version copy that
/// lost drive quorum through its in-place retries: the source still holds the
/// version and the next attempt copies it again. Every other failure stays
/// terminal.
/// lost drive quorum or timed out on a namespace lock through its in-place
/// retries: the source still holds the version and the next attempt copies it
/// again. Every other failure stays terminal.
fn resolve_decommission_entry_failure(err: Error) -> Result<DecommissionEntryOutcome> {
if is_decommission_source_cleanup_retryable_error(&err) || is_decommission_copy_quorum_deferral(&err) {
if is_decommission_source_cleanup_retryable_error(&err)
|| is_decommission_copy_quorum_deferral(&err)
|| is_decommission_copy_lock_timeout_deferral(&err)
{
return Ok(DecommissionEntryOutcome::Deferred(err));
}
match classify_decommission_capacity_failure(&err) {
@@ -11702,6 +11745,61 @@ pub struct PoolSpaceInfo {
pub used: usize,
}
/// Read-only observations used by the admin pool diagnostic surface. The HTTP
/// layer converts these stable values to strings without changing the pool
/// lifecycle status contract.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PoolAvailability {
Online,
Degraded,
Offline,
Unknown,
}
impl PoolAvailability {
pub fn as_str(self) -> &'static str {
match self {
Self::Online => "online",
Self::Degraded => "degraded",
Self::Offline => "offline",
Self::Unknown => "unknown",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PoolCapacityStatus {
Live,
Cached,
Partial,
Unknown,
}
impl PoolCapacityStatus {
pub fn as_str(self) -> &'static str {
match self {
Self::Live => "live",
Self::Cached => "cached",
Self::Partial => "partial",
Self::Unknown => "unknown",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PoolDiagnosticStatus {
pub availability: PoolAvailability,
pub capacity_status: PoolCapacityStatus,
}
#[derive(Debug, Clone)]
pub struct PoolAdminDiagnostic {
pub id: usize,
pub cmd_line: String,
pub pool: Option<PoolStatus>,
pub diagnostic: PoolDiagnosticStatus,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct DecommissionPoolCapacityInfo {
pool_index: usize,
@@ -11710,6 +11808,7 @@ pub(crate) struct DecommissionPoolCapacityInfo {
physical_free: usize,
physical_total: usize,
physical_used: usize,
diagnostic: PoolDiagnosticStatus,
}
impl DecommissionPoolCapacityInfo {
@@ -11732,6 +11831,10 @@ impl DecommissionPoolCapacityInfo {
physical_free,
physical_total,
physical_used,
diagnostic: PoolDiagnosticStatus {
availability: PoolAvailability::Unknown,
capacity_status: PoolCapacityStatus::Unknown,
},
}
}
}
@@ -11753,6 +11856,121 @@ where
Ok(capacities)
}
fn pool_diagnostic_status(
disks: &[rustfs_madmin::Disk],
pool_index: usize,
set_count: usize,
set_drive_count: usize,
read_quorum: usize,
) -> PoolDiagnosticStatus {
if set_count == 0 || set_drive_count == 0 || read_quorum == 0 || read_quorum > set_drive_count {
return PoolDiagnosticStatus {
availability: PoolAvailability::Unknown,
capacity_status: PoolCapacityStatus::Unknown,
};
}
let mut live_capacity = true;
let mut cached_capacity = false;
let mut partial_capacity = false;
let mut online_by_set = vec![0usize; set_count];
let mut observed_disks = 0usize;
let mut seen_slots = HashSet::new();
for disk in disks.iter().filter(|disk| disk.pool_index == pool_index as i32) {
observed_disks = observed_disks.saturating_add(1);
match disk.capacity_observation_source.as_deref() {
Some("live_probe") => {}
Some("snapshot") => {
live_capacity = false;
cached_capacity = true;
}
Some("missing") | None => {
live_capacity = false;
partial_capacity = true;
}
Some(_) => {
live_capacity = false;
partial_capacity = true;
}
}
let Some(set_index) = usize::try_from(disk.set_index).ok() else {
continue;
};
let Some(disk_index) = usize::try_from(disk.disk_index).ok() else {
continue;
};
if set_index >= set_count
|| disk_index >= set_drive_count
|| !seen_slots.insert((set_index, disk_index))
|| !disk_is_available_for_pool_diagnostic(disk)
{
continue;
}
online_by_set[set_index] = online_by_set[set_index].saturating_add(1);
}
let availability = if online_by_set.iter().any(|&online| online < read_quorum) {
PoolAvailability::Offline
} else if online_by_set.iter().all(|&online| online == set_drive_count) {
PoolAvailability::Online
} else {
PoolAvailability::Degraded
};
let capacity_status = if observed_disks == 0 {
PoolCapacityStatus::Unknown
} else if partial_capacity {
PoolCapacityStatus::Partial
} else if cached_capacity {
PoolCapacityStatus::Cached
} else if live_capacity {
PoolCapacityStatus::Live
} else {
PoolCapacityStatus::Unknown
};
PoolDiagnosticStatus {
availability,
capacity_status,
}
}
fn disk_is_available_for_pool_diagnostic(disk: &rustfs_madmin::Disk) -> bool {
let state_ok = disk.state.eq_ignore_ascii_case("ok") || disk.state.eq_ignore_ascii_case(rustfs_madmin::ITEM_ONLINE);
let runtime_ok = disk
.runtime_state
.as_deref()
.map(|state| state.eq_ignore_ascii_case(rustfs_madmin::ITEM_ONLINE))
.unwrap_or(state_ok);
state_ok && runtime_ok
}
fn pool_diagnostic_metadata_status(err: &Error) -> &'static str {
if let Some(failure) = err.pool_metadata_failure() {
return match failure.kind {
crate::error::PoolMetadataFailure::ReadUnavailable => "stale",
crate::error::PoolMetadataFailure::RecoveryRequired
| crate::error::PoolMetadataFailure::TransactionUnknown
| crate::error::PoolMetadataFailure::FenceLost => "recovery_required",
};
}
match err {
Error::Timeout
| Error::Lock(_)
| Error::DiskNotFound
| Error::FaultyDisk
| Error::FaultyRemoteDisk
| Error::RemoteClientUnavailable(_)
| Error::RemoteNotInitialized
| Error::ErasureReadQuorum
| Error::InsufficientReadQuorum(_, _)
| Error::NamespaceLockQuorumUnavailable { .. } => "stale",
_ => "recovery_required",
}
}
#[cfg(test)]
static DECOMMISSION_START_GATE_WAIT_OVERRIDES: std::sync::OnceLock<std::sync::Mutex<HashMap<uuid::Uuid, std::time::Duration>>> =
std::sync::OnceLock::new();
@@ -15125,6 +15343,70 @@ impl ECStore {
Ok(snapshot)
}
/// Admin-only diagnostic snapshot. A transient metadata read failure is
/// reported as stale without publishing the fallback or relaxing any
/// mutation/recovery path.
pub async fn admin_pool_diagnostic_snapshot(
&self,
target_pool_index: Option<usize>,
) -> (Vec<PoolAdminDiagnostic>, &'static str) {
let fallback = self.pool_meta.read().await.clone();
let (pool_meta, mut metadata_status) = match self.pool_status_meta_snapshot().await {
Ok(pool_meta) => (pool_meta, "fresh"),
Err(err) => (fallback, pool_diagnostic_metadata_status(&err)),
};
if metadata_status == "stale" && pool_meta.pools.len() != self.pools.len() {
metadata_status = "unknown";
}
let backend = rustfs_madmin::StorageInfo {
backend: StorageAdminApi::backend_info(self).await,
..Default::default()
};
let indices = target_pool_index.map_or_else(|| (0..self.pools.len()).collect(), |idx| vec![idx]);
let backend = &backend;
let samples = futures::future::join_all(indices.into_iter().map(|idx| async move {
(
idx,
self.get_decommission_pool_capacity_info_with_backend(idx, false, backend)
.await,
)
}))
.await;
let mut diagnostics = Vec::with_capacity(samples.len());
for (idx, sample) in samples {
let Some(sets) = self.pools.get(idx) else {
continue;
};
let (pool, diagnostic) = match sample {
Ok(capacity) => (
pool_meta
.pools
.get(idx)
.cloned()
.map(|pool| apply_decommission_status_space_info(pool, capacity.space)),
capacity.diagnostic,
),
Err(_) => (
pool_meta.pools.get(idx).cloned(),
PoolDiagnosticStatus {
availability: PoolAvailability::Unknown,
capacity_status: PoolCapacityStatus::Unknown,
},
),
};
diagnostics.push(PoolAdminDiagnostic {
id: idx,
cmd_line: sets.endpoints.cmd_line.clone(),
pool,
diagnostic,
});
}
(diagnostics, metadata_status)
}
/// Load and publish under the movement writer, so no movement read sees
/// the transition mid-operation and no transition interleaves the load.
async fn publish_pool_status_refresh(&self, _movement_guard: tokio::sync::OwnedRwLockWriteGuard<()>) -> Result<()> {
@@ -15201,6 +15483,7 @@ impl ECStore {
};
let (physical_total, physical_free, physical_used) =
decommission_physical_pool_capacity(&info.disks, idx, layout, space, owned_physical_used.as_ref());
let diagnostic = pool_diagnostic_status(&info.disks, idx, sets.set_count, sets.set_drive_count, layout.data);
Ok(DecommissionPoolCapacityInfo {
pool_index: idx,
@@ -15209,6 +15492,7 @@ impl ECStore {
physical_free,
physical_total,
physical_used,
diagnostic,
})
} else {
Err(invalid_decommission_pool_index_error(self.pools.len(), idx))
@@ -18150,13 +18434,11 @@ impl ECStore {
}
}
if failure && let Some(err) = error.take_if(|err| is_decommission_copy_quorum_error(err)) {
return Err(decommission_copy_quorum_deferral(
"delete_marker_copy",
bucket.as_str(),
version.name.as_str(),
err,
));
if failure
&& let Some(err) =
take_decommission_copy_deferral("delete_marker_copy", bucket.as_str(), version.name.as_str(), &mut error)
{
return Err(err);
}
if ignore {
@@ -18542,14 +18824,14 @@ impl ECStore {
if failure
&& !source_read_failed
&& let Some(err) = error.take_if(|err| is_decommission_copy_quorum_error(err))
{
return Err(decommission_copy_quorum_deferral(
&& let Some(err) = take_decommission_copy_deferral(
DECOMMISSION_STAGE_MIGRATE_OBJECT,
bucket.as_str(),
version.name.as_str(),
err,
));
&mut error,
)
{
return Err(err);
}
if ignore {
@@ -21803,6 +22085,85 @@ mod tests {
use crate::storage_api_contracts::multipart::MultipartOperations as _;
use serde::Serialize;
fn diagnostic_disk(set_index: i32, disk_index: i32, online: bool) -> rustfs_madmin::Disk {
rustfs_madmin::Disk {
pool_index: 0,
set_index,
disk_index,
state: if online { "ok" } else { "offline" }.to_string(),
runtime_state: Some(if online { "online" } else { "offline" }.to_string()),
capacity_observation_source: Some("live_probe".to_string()),
..Default::default()
}
}
#[test]
fn pool_diagnostic_status_tracks_read_quorum_and_capacity_source() {
let online = vec![
diagnostic_disk(0, 0, true),
diagnostic_disk(0, 1, true),
diagnostic_disk(1, 0, true),
diagnostic_disk(1, 1, true),
];
assert_eq!(
pool_diagnostic_status(&online, 0, 2, 2, 1),
PoolDiagnosticStatus {
availability: PoolAvailability::Online,
capacity_status: PoolCapacityStatus::Live,
}
);
let mut degraded = online.clone();
degraded[0] = diagnostic_disk(0, 0, false);
degraded[1].capacity_observation_source = Some("snapshot".to_string());
assert_eq!(
pool_diagnostic_status(&degraded, 0, 2, 2, 1),
PoolDiagnosticStatus {
availability: PoolAvailability::Degraded,
capacity_status: PoolCapacityStatus::Cached,
}
);
degraded[1] = diagnostic_disk(0, 1, false);
assert_eq!(pool_diagnostic_status(&degraded, 0, 2, 2, 1).availability, PoolAvailability::Offline);
assert_eq!(
pool_diagnostic_status(&[], 0, 2, 2, 1),
PoolDiagnosticStatus {
availability: PoolAvailability::Offline,
capacity_status: PoolCapacityStatus::Unknown,
}
);
assert_eq!(pool_diagnostic_status(&online, 0, 0, 2, 1).availability, PoolAvailability::Unknown);
}
#[tokio::test]
#[serial_test::serial]
async fn admin_pool_diagnostic_snapshot_lists_an_unavailable_nonfirst_pool() {
let (_temp_dirs, store, _other_store) = Box::pin(crate::services::rebalance::test_two_pool_stores(None)).await;
for set in &store.pools[1].disk_set {
let mut disks = set.disks.write().await;
let disk_count = disks.len();
*disks = vec![None; disk_count];
}
let (snapshot, metadata_status) =
tokio::time::timeout(std::time::Duration::from_secs(10), store.admin_pool_diagnostic_snapshot(None))
.await
.expect("pool diagnostics should remain bounded");
assert_eq!(metadata_status, "stale");
assert_eq!(snapshot.len(), 2, "configured topology must remain visible");
assert_eq!(snapshot[0].id, 0);
assert_eq!(snapshot[1].id, 1);
assert_eq!(snapshot[1].diagnostic.availability, PoolAvailability::Offline);
store
.pool_meta_save_gate
.lock()
.await
.ensure_write_safe("ordinary object write")
.expect("a failed diagnostic read must not latch the writer gate");
}
#[test]
fn decommission_set_local_usage_cache_classification_is_exact() {
for object in [
@@ -27939,6 +28300,167 @@ mod tests {
);
}
/// One version commits, then every in-place attempt to copy the next one
/// times out on the target namespace lock. The entry is deferred, not
/// failed, with both source versions retained; the resumed entry finds the
/// committed version and moves both with their exact bytes.
#[tokio::test]
#[serial_test::serial]
async fn decommission_defers_target_lookup_lock_timeout_and_retries_without_losing_source() {
use tokio::io::AsyncReadExt as _;
let fixture = NullVersionDecommission::new(NullVersionTop::NewerVersion).await;
let object = fixture.object;
let injected = Arc::new(AtomicUsize::new(0));
let attempted = Arc::new(AtomicUsize::new(0));
let fault = DecommissionTestFaultGuard::install_with_error(
Arc::new({
let injected = Arc::clone(&injected);
let attempted = Arc::clone(&attempted);
move |stage, _, faulted, _, success| {
let target = stage == "before_target_commit" && faulted == object && success;
let should_inject = target && attempted.fetch_add(1, Ordering::SeqCst) > 0;
if should_inject {
injected.fetch_add(1, Ordering::SeqCst);
}
should_inject
}
}),
Some(Arc::new(|_, bucket, object| {
data_movement::data_movement_stage_error_for_test(
"decommission_object",
"prepare_put_object",
bucket,
object,
Error::Lock(rustfs_lock::LockError::timeout(
format!("{bucket}/{object}@latest"),
std::time::Duration::from_secs(5),
)),
)
})),
);
let (outcome, _) = fixture.run_entry().await;
assert_eq!(injected.load(Ordering::SeqCst), DECOMMISSION_VERSION_COPY_ATTEMPTS);
fixture.assert_source_retained().await;
assert_eq!(
fixture
.target_versions()
.await
.expect("the first version was copied")
.versions
.len(),
1,
"one version must commit before the next version times out"
);
assert!(
matches!(&outcome, DecommissionEntryOutcome::Deferred(err) if is_decommission_copy_lock_timeout_deferral(err)),
"{outcome:?}"
);
assert_eq!(
fixture.store.pool_meta.read().await.pools[0]
.decommission
.as_ref()
.expect("active decommission")
.items_decommission_failed,
0,
"a deferred entry must not be counted as a permanent failure"
);
drop(fault);
let (outcome, _) = fixture.run_entry().await;
assert!(matches!(outcome, DecommissionEntryOutcome::Complete), "{outcome:?}");
fixture.assert_moved().await;
let mut reader = fixture.store.pools[1]
.get_object_reader(
fixture.bucket,
fixture.object,
None,
HeaderMap::new(),
&ObjectOptions {
version_id: Some(fixture.seeded.versions[0].version_id.expect("newer version").to_string()),
..Default::default()
},
)
.await
.expect("the newer version must remain readable");
let mut actual = Vec::new();
reader.stream.read_to_end(&mut actual).await.expect("read the newer version");
assert_eq!(actual, b"written after versioning was enabled");
}
/// A namespace lock still held on the target key, as by a copy whose node
/// was killed mid-write, makes target selection fail with a typed
/// object-lock timeout. A real held lock, not an injected error, must defer
/// the entry rather than fail it; once the lock is released selection
/// recovers and the resumed entry moves both versions.
#[tokio::test]
#[serial_test::serial]
async fn decommission_target_selection_returns_timeout_for_a_held_object_lock() {
temp_env::async_with_vars([(rustfs_config::ENV_OBJECT_LOCK_ACQUIRE_TIMEOUT, Some("1"))], async {
let fixture = NullVersionDecommission::new(NullVersionTop::NewerVersion).await;
let target_set = fixture.store.pools[1].get_disks_by_key(fixture.object);
let lock = target_set
.new_ns_lock(fixture.bucket, fixture.object)
.await
.expect("target lock");
let guard = lock
.get_write_lock(std::time::Duration::from_secs(30))
.await
.expect("hold target lock");
let opts = ObjectOptions {
data_movement: true,
src_pool_idx: 0,
version_id: Some(uuid::Uuid::nil().to_string()),
..Default::default()
};
let select = || {
fixture
.store
.select_data_movement_pool_idx(fixture.bucket, fixture.object, 1, &opts, false)
};
let result = tokio::time::timeout(get_lock_acquire_timeout() + std::time::Duration::from_secs(15), select())
.await
.expect("target selection must terminate");
match result {
Err(Error::Lock(rustfs_lock::LockError::Timeout { resource, .. })) => {
assert!(resource.contains(fixture.bucket) && resource.contains(fixture.object), "{resource}");
}
other => panic!("expected an object lock timeout, got {other:?}"),
}
let (outcome, _) = fixture.run_entry().await;
assert!(
matches!(&outcome, DecommissionEntryOutcome::Deferred(err) if is_decommission_copy_lock_timeout_deferral(err)),
"{outcome:?}"
);
assert_eq!(
fixture.store.pool_meta.read().await.pools[0]
.decommission
.as_ref()
.expect("active decommission")
.items_decommission_failed,
0,
"a held target lock must not be counted as a permanent failure"
);
fixture.assert_source_retained().await;
assert!(
fixture.target_versions().await.is_none(),
"no version may commit while the target lock is held"
);
drop(guard);
assert_eq!(
select()
.await
.expect("target selection must recover once the lock is released"),
1
);
let (outcome, _) = fixture.run_entry().await;
assert!(matches!(outcome, DecommissionEntryOutcome::Complete), "{outcome:?}");
fixture.assert_moved().await;
})
.await;
}
#[test]
fn decommission_object_attempt_metric_keeps_the_peak() {
let mut attempts = 0;
@@ -28296,6 +28818,61 @@ mod tests {
));
}
/// A restarted node's previous incarnation keeps its namespace locks on the
/// peers until their lease expires, so a resumed copy of a key that was in
/// flight at the crash times out acquiring the lock. That copy never
/// committed: it is deferred, never counted as a failed object.
#[test]
fn decommission_copy_lock_timeout_is_deferred_not_failed() {
let timeout = || {
Error::Lock(rustfs_lock::LockError::timeout(
"bucket-a/object-a@latest",
std::time::Duration::from_secs(5),
))
};
let prepare = data_movement::data_movement_stage_error_for_test(
"decommission_object",
"prepare_put_object",
"bucket-a",
"object-a",
timeout(),
);
for err in [timeout(), prepare] {
assert!(is_decommission_copy_lock_timeout_error(&err), "{err}");
assert!(!is_decommission_copy_quorum_error(&err), "{err}");
let mut error = Some(err);
let deferred = take_decommission_copy_deferral("migrate_object", "bucket-a", "object-a", &mut error)
.expect("a lock timeout copy must be deferred");
assert!(error.is_none());
assert!(is_decommission_copy_lock_timeout_deferral(&deferred));
assert!(!is_decommission_copy_quorum_deferral(&deferred));
let failure = data_movement::data_movement_stage_source_as::<DecommissionCopyLockTimeout>(&deferred)
.expect("the deferred error must keep its lock-timeout origin");
assert!(is_decommission_copy_lock_timeout_error(&failure.0));
assert!(matches!(
resolve_decommission_entry_failure(deferred),
Ok(DecommissionEntryOutcome::Deferred(_))
));
}
let mut quorum = Some(Error::ErasureWriteQuorum);
let deferred = take_decommission_copy_deferral("migrate_object", "bucket-a", "object-a", &mut quorum)
.expect("a lost drive quorum copy stays deferred");
assert!(is_decommission_copy_quorum_deferral(&deferred));
for other in [
Error::Lock(rustfs_lock::LockError::internal("lock backend failed")),
Error::other("Lock acquisition timeout for resource 'bucket-a/object-a@latest' after 5s"),
] {
let mut error = Some(other);
assert!(
take_decommission_copy_deferral("migrate_object", "bucket-a", "object-a", &mut error).is_none(),
"{error:?}"
);
assert!(error.is_some(), "a per-object failure keeps its error");
}
}
#[test]
fn decommission_target_capacity_error_accepts_wrapped_capacity_errors() {
let disk_full = Error::other(format!("decommission_object: put_object failed for bucket/object: {}", Error::DiskFull));
+87
View File
@@ -2480,6 +2480,93 @@ mod decommission_lock_order_tests {
});
}
/// A node killed mid-copy leaves its namespace lock on the peers until the
/// lock lease expires, so the resumed decommission times out acquiring the
/// lock of the key it was copying through every in-place retry. The entry
/// is deferred and copied once the lease is gone; it is never counted as a
/// failed object, which would fail the whole decommission.
#[test]
#[serial_test::serial]
fn decommission_defers_a_copy_that_timed_out_on_a_stale_namespace_lock() {
run_large_stack_async_test("decommission-copy-lock-timeout", || async {
let (_temp_dirs, store, _other_store) = test_three_pool_stores_with_isolated_node_contexts(None).await;
let bucket = test_bucket("copy-lock-timeout");
let object = "external/005.bin";
let payload = vec![0x3c; 2048];
store
.make_bucket(&bucket, &MakeBucketOptions::default())
.await
.expect("create the bucket");
put_source_object(&store, &bucket, object, payload.clone()).await;
// The stale lease outlives every in-place attempt of the first
// pass and has expired by the deferred replay.
let stale_lease_calls = crate::core::pools::DECOMMISSION_VERSION_COPY_ATTEMPTS;
let copy_calls = Arc::new(AtomicUsize::new(0));
let _fault = DecommissionTestFaultGuard::install_with_error(
Arc::new({
let copy_calls = Arc::clone(&copy_calls);
let bucket = bucket.clone();
move |stage, entry_bucket, entry_object, _, _| {
stage == "before_target_commit"
&& entry_bucket == bucket
&& entry_object == object
&& copy_calls.fetch_add(1, Ordering::AcqRel) < stale_lease_calls
}
}),
Some(Arc::new(|_, bucket, object| {
// The shape the target write reports when the lock domain
// still holds the previous incarnation's lock.
data_movement::data_movement_stage_error_for_test(
"decommission_object",
"prepare_put_object",
bucket,
object,
crate::error::Error::Lock(rustfs_lock::LockError::timeout(
format!("{bucket}/{object}@latest"),
Duration::from_secs(5),
)),
)
})),
);
start_transitioned_entry_decommission(&store, &bucket).await;
spawn_decommission_routine(&store, 1)
.await
.expect("the decommission routine task should join")
.expect("the decommission routine should return");
assert_eq!(
copy_calls.load(Ordering::Acquire),
stale_lease_calls + 1,
"the first pass exhausts its in-place retries and the replay copies the object once"
);
let local = store.pool_meta.read().await;
let info = local.pools[0]
.decommission
.as_ref()
.expect("the decommission state should remain present");
assert_eq!(
info.items_decommission_failed, 0,
"a lock held until its lease expires is not a permanent failure: {info:?}"
);
assert!(
info.complete && !info.failed && !info.canceled && info.capacity_blocked_reason.is_none(),
"the replayed entry must complete the decommission: {info:?}"
);
drop(local);
assert!(exact_versions(&store, 0, &bucket, object).await.is_empty(), "the source is cleaned up");
let mut reader = store
.get_object_reader(&bucket, object, None, HeaderMap::new(), &ObjectOptions::default())
.await
.expect("the migrated object stays readable");
let mut body = Vec::new();
reader.stream.read_to_end(&mut body).await.expect("read the migrated object");
assert_eq!(body, payload);
});
}
/// Seed `object` in pool 0 with three 2 KiB data versions and a newest
/// delete marker; returns the data versions oldest first.
async fn seed_versioned_entry_with_marker(
+58 -16
View File
@@ -28,7 +28,7 @@ use crate::{
error::{Error, classify_system_path_failure_reason},
object_api::{ObjectInfo, ObjectOptions, PutObjReader},
runtime::sources as runtime_sources,
store::{ECStore, list_objects::list_marker_key},
store::ECStore,
};
pub use local_snapshot::{LocalUsageSnapshot, read_snapshot as read_local_snapshot, snapshot_path};
use rustfs_data_usage::{
@@ -1493,6 +1493,48 @@ pub async fn load_data_usage_from_backend_cached(store: Arc<ECStore>) -> Result<
project_data_usage_from_backend_cached(store, DataUsageInfo::clone).await
}
/// Read one bucket's persisted usage without cloning the complete snapshot.
pub async fn load_bucket_usage_from_backend_cached(
store: Arc<ECStore>,
bucket: &str,
incarnation: uuid::Uuid,
) -> Result<Option<u64>, Error> {
project_data_usage_from_backend_cached(store, |info| certified_bucket_usage(info, bucket, incarnation)).await
}
fn certified_bucket_usage(info: &DataUsageInfo, bucket: &str, incarnation: uuid::Uuid) -> Option<u64> {
if incarnation.is_nil()
|| !info.is_complete_bucket_usage_snapshot()
|| info.usage_snapshot_observational
|| info.scanner_cycle.is_none()
|| info.scanner_epoch.is_none()
{
return None;
}
let pending_count = u64::try_from(info.usage_snapshot_pending_buckets.len()).ok()?;
if pending_count != info.usage_snapshot_pending_bucket_count {
return None;
}
match info.usage_snapshot_converged {
Some(true) if pending_count == 0 => {}
Some(false)
if pending_count > 0
&& info.usage_snapshot_pending_buckets.windows(2).all(|pair| pair[0] < pair[1])
&& info
.usage_snapshot_pending_buckets
.binary_search_by(|pending| pending.as_str().cmp(bucket))
.is_err() => {}
_ => return None,
}
let usage = info.buckets_usage.get(bucket)?;
let certified = usage
.bucket_incarnation
.as_deref()
.and_then(|id| uuid::Uuid::parse_str(id).ok())
.filter(|id| !id.is_nil())?;
(certified == incarnation).then_some(usage.size)
}
// Project under the cache guard so scalar readers never copy the snapshot's
// bucket maps. The owned API shares the same TTL, refresh and invalidation path.
async fn project_data_usage_from_backend_cached<T>(
@@ -1966,8 +2008,8 @@ fn advance_version_listing_cursor(
let next_version_marker = next_version_marker
.filter(|next_version_marker| !next_version_marker.is_empty())
.ok_or_else(|| Error::other(format!("bucket {bucket} version listing was truncated without a version marker")))?;
let current_key_marker = marker.as_deref().map(list_marker_key);
let next_key_marker = list_marker_key(&next_marker);
let current_key_marker = marker.as_deref();
let next_key_marker = next_marker.as_str();
if current_key_marker == Some(next_key_marker) && version_marker.as_deref() == Some(next_version_marker.as_str()) {
return Err(Error::other(format!(
"bucket {bucket} version listing returned a repeated continuation marker"
@@ -5167,20 +5209,20 @@ mod tests {
);
assert!(ensure_truncated_version_page_has_entries("bucket-a", 0).is_err());
ensure_truncated_version_page_has_entries("bucket-a", 1).expect("a truncated page with an entry can advance");
}
let mut tagged_marker = Some("object-a[rustfs_cache:v2,id:old]".to_string());
let mut tagged_version_marker = Some("version-a".to_string());
assert!(
advance_version_listing_cursor(
"bucket-a",
&mut tagged_marker,
&mut tagged_version_marker,
Some("object-a[rustfs_cache:v2,id:new]".to_string()),
Some("version-a".to_string()),
)
.is_err(),
"different cache tags for the same logical marker must not count as progress"
);
#[test]
fn version_listing_cursor_compares_literal_cache_shaped_keys() {
let mut marker = Some("object-a".to_string());
let mut version_marker = Some("version-a".to_string());
advance_version_listing_cursor(
"bucket-a",
&mut marker,
&mut version_marker,
Some("object-a[rustfs_cache:v2,id:old]".to_string()),
Some("version-a".to_string()),
)
.expect("a distinct literal key advances the cursor");
}
#[test]
@@ -129,6 +129,41 @@ fn projected_publication_moves_bucket_maps_into_cache() {
);
}
#[test]
fn certified_bucket_usage_rejects_unproven_and_pending_data() {
let id = uuid::Uuid::from_u128(7);
let mut info = data_usage_info_for_test("known", 1, 42, SystemTime::UNIX_EPOCH);
info.usage_snapshot_converged = Some(true);
info.scanner_cycle = Some(1);
info.scanner_epoch = Some(1);
info.buckets_usage.get_mut("known").unwrap().bucket_incarnation = Some(id.to_string());
assert_eq!(certified_bucket_usage(&info, "known", id), Some(42));
assert_eq!(certified_bucket_usage(&info, "known", uuid::Uuid::from_u128(8)), None);
assert_eq!(certified_bucket_usage(&info, "known", uuid::Uuid::nil()), None);
let mut legacy = info.clone();
legacy.buckets_usage.get_mut("known").unwrap().bucket_incarnation = None;
assert_eq!(certified_bucket_usage(&legacy, "known", id), None);
legacy.buckets_usage.get_mut("known").unwrap().bucket_incarnation = Some("corrupt".into());
assert_eq!(certified_bucket_usage(&legacy, "known", id), None);
let mut pending = info.clone();
pending.usage_snapshot_converged = Some(false);
pending.usage_snapshot_pending_buckets = vec!["other".into()];
pending.usage_snapshot_pending_bucket_count = 1;
assert_eq!(certified_bucket_usage(&pending, "known", id), Some(42));
pending.usage_snapshot_pending_buckets = vec!["known".into()];
assert_eq!(certified_bucket_usage(&pending, "known", id), None);
pending.usage_snapshot_pending_bucket_count = 0;
assert_eq!(certified_bucket_usage(&pending, "known", id), None);
pending.usage_snapshot_pending_buckets = vec!["z".into(), "a".into()];
pending.usage_snapshot_pending_bucket_count = 2;
assert_eq!(certified_bucket_usage(&pending, "known", id), None);
let mut observational = info.clone();
observational.usage_snapshot_observational = true;
assert_eq!(certified_bucket_usage(&observational, "known", id), None);
info.usage_snapshot_converged = None;
assert_eq!(certified_bucket_usage(&info, "known", id), None);
}
#[tokio::test]
#[serial]
async fn persisted_scalar_projection_shares_cache_refresh_and_invalidation() {
@@ -140,6 +175,13 @@ async fn persisted_scalar_projection_shares_cache_refresh_and_invalidation() {
info.buckets_usage.insert("empty".to_string(), BucketUsageInfo::default());
info.bucket_sizes.insert("empty".to_string(), 0);
info.buckets_count = 2;
info.usage_snapshot_converged = Some(true);
info.scanner_cycle = Some(1);
info.scanner_epoch = Some(1);
let incarnation = uuid::Uuid::from_u128(7);
for usage in info.buckets_usage.values_mut() {
usage.bucket_incarnation = Some(incarnation.to_string());
}
save_config(
store.clone(),
DATA_USAGE_OBJ_NAME_PATH.as_str(),
@@ -151,11 +193,29 @@ async fn persisted_scalar_projection_shares_cache_refresh_and_invalidation() {
for (bucket, expected) in [("known", Some(42)), ("empty", Some(0)), ("missing", None)] {
assert_eq!(persisted_bucket_usage(store.clone(), bucket).await, expected);
assert_eq!(
load_bucket_usage_from_backend_cached(store.clone(), bucket, incarnation)
.await
.expect("project bucket usage"),
expected
);
let owned = load_data_usage_from_backend_cached(store.clone())
.await
.expect("owned cached snapshot");
assert_eq!(expected, owned.buckets_usage.get(bucket).map(|usage| usage.size));
}
assert_eq!(
load_bucket_usage_from_backend_cached(store.clone(), "known", uuid::Uuid::from_u128(8))
.await
.unwrap(),
None
);
assert_eq!(
load_bucket_usage_from_backend_cached(store.clone(), "known", uuid::Uuid::nil())
.await
.unwrap(),
None
);
info.buckets_usage.get_mut("known").expect("known bucket").size = 84;
info.bucket_sizes.insert("known".to_string(), 84);
save_config(
+249 -2
View File
@@ -19,6 +19,7 @@ use tracing::Span;
use uuid::Uuid;
const EVENT_OBJECT_NAMESPACE_LOCK: &str = "object_namespace_lock";
const EVENT_OBJECT_NAMESPACE_LOCK_PEER: &str = "object_namespace_lock_peer";
const LOG_COMPONENT_ECSTORE: &str = "ecstore";
const LOG_SUBSYSTEM_OBJECT_LOCK: &str = "object_lock";
@@ -56,6 +57,7 @@ struct AttemptContext {
requested_version_id: Option<String>,
namespace: String,
owner: String,
lock_id: Option<String>,
mode: &'static str,
timeout: Duration,
started_at: Instant,
@@ -85,6 +87,7 @@ impl ObjectLockAttempt {
requested_version_id: requested_version_id.map(str::to_owned),
namespace: lock.namespace().to_owned(),
owner: lock.owner().to_owned(),
lock_id: None,
mode,
timeout,
started_at: Instant::now(),
@@ -105,9 +108,29 @@ impl ObjectLockAttempt {
context.finished = true;
context.trace("disabled");
}
Ok(_) => {
Ok(guard) => {
context.lock_id = guard.lock_id().map(ToString::to_string);
context.acquired_at = Some(Instant::now());
context.trace("acquired");
if let NamespaceLockGuard::Standard(guard) = guard {
for (peer_index, peer_lock_id) in guard.granted_lock_ids().enumerate() {
context.parent.in_scope(|| {
tracing::trace!(
target: "rustfs_ecstore::object_lock_diag",
parent: &context.parent,
event = EVENT_OBJECT_NAMESPACE_LOCK_PEER,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_OBJECT_LOCK,
state = "granted",
lock_attempt_id = %context.id,
lock_id = context.lock_id.as_deref(),
peer_lock_id = %peer_lock_id,
peer_index,
"Object namespace peer owner granted"
);
});
}
}
}
Err(error) => {
context.finished = true;
@@ -171,6 +194,7 @@ impl AttemptContext {
mode = self.mode,
owner = %self.owner,
lock_attempt_id = %self.id,
lock_id = self.lock_id.as_deref(),
acquire_ms = duration_ms(acquired_at.duration_since(self.started_at)),
hold_ms = duration_ms(now.duration_since(acquired_at)),
timeout_ms = duration_ms(self.timeout),
@@ -235,15 +259,238 @@ pub(crate) mod tests {
}
pub(crate) fn rows(&self) -> Vec<serde_json::Value> {
self.all_rows()
.into_iter()
.filter(|row| row["fields"]["event"] == EVENT_OBJECT_NAMESPACE_LOCK)
.collect()
}
fn all_rows(&self) -> Vec<serde_json::Value> {
let bytes = self.0.lock().expect("lock diagnostic capture mutex");
let text = std::str::from_utf8(&bytes).expect("UTF-8 diagnostic events");
text.lines()
.map(|line| serde_json::from_str::<serde_json::Value>(line).expect("JSON diagnostic event"))
.filter(|row| row["fields"]["event"] == EVENT_OBJECT_NAMESPACE_LOCK)
.collect()
}
}
#[test]
#[serial_test::serial]
fn object_lock_diagnostics_join_guard_identity_to_physical_release() {
use rustfs_lock::{
DisabledLockManager, FastObjectLockManager, GlobalLockManager, LocalClient, LockClient, LockRequest, LockType,
NamespaceLock, ObjectKey,
};
temp_env::with_vars([(rustfs_config::ENV_OBJECT_LOCK_DIAG_ENABLE, Some("true"))], || {
let capture = CapturedLockEvents::default();
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("diagnostic release runtime");
tracing::subscriber::with_default(capture.subscriber(), || {
runtime.block_on(async {
let clients = (0..4)
.map(|_| Arc::new(LocalClient::with_manager(Arc::new(GlobalLockManager::new()))) as Arc<dyn LockClient>)
.collect::<Vec<_>>();
let lock = NamespaceLockWrapper::new(
NamespaceLock::with_clients_and_quorum("diagnostics".to_owned(), clients.clone(), 4),
ObjectKey::new("bucket", "object"),
"coordinator".to_owned(),
);
let request = tracing::info_span!("request", request_id = "original-multipart-request");
let mut attempt = request.in_scope(|| {
ObjectLockAttempt::start(
"abort_multipart_upload",
"bucket",
"object",
None,
&lock,
"write",
Duration::from_secs(5),
)
});
let result = lock.get_write_lock(Duration::from_secs(5)).await;
attempt.observe(&result);
let guard = result.expect("four real namespace owners");
let id = guard.lock_id().expect("distributed guard identity").clone();
let peer_ids = match &guard {
NamespaceLockGuard::Standard(guard) => {
assert_eq!(guard.granted_lock_ids().count(), 4);
guard.granted_lock_ids().cloned().collect::<std::collections::HashSet<_>>()
}
NamespaceLockGuard::Fast(_) => panic!("real distributed namespace required"),
};
let rows = capture.rows();
assert_eq!(rows.last().expect("acquired event")["fields"]["lock_id"], id.to_string());
let rows = capture.all_rows();
let mappings = rows
.iter()
.filter(|row| row["fields"]["event"] == EVENT_OBJECT_NAMESPACE_LOCK_PEER)
.collect::<Vec<_>>();
assert_eq!(mappings.len(), 4, "every granted peer must map to its logical guard");
assert!(mappings.iter().all(|row| {
row["fields"]["lock_id"] == id.to_string()
&& peer_ids
.iter()
.any(|peer_id| row["fields"]["peer_lock_id"] == peer_id.to_string())
}));
for client in &clients {
let mut owned = 0;
for peer_id in &peer_ids {
owned += usize::from(
client
.check_status(peer_id)
.await
.expect("actual peer owner status")
.is_some(),
);
}
assert_eq!(owned, 1, "each client must hold exactly one actual granted identity");
}
drop((guard, attempt));
assert!(
capture.all_rows().iter().all(|row| row["fields"]["state"] != "released"),
"enqueue is not release acknowledgement"
);
tokio::time::timeout(Duration::from_secs(5), async {
while capture
.all_rows()
.iter()
.filter(|row| {
row["fields"]["event"] == "namespace_lock_release" && row["fields"]["state"] == "released"
})
.count()
< 4
{
tokio::task::yield_now().await;
}
})
.await
.expect("all physical release callbacks finish");
for client in &clients {
for peer_id in &peer_ids {
assert!(
client
.check_status(peer_id)
.await
.expect("released peer owner status")
.is_none()
);
assert!(!client.release(peer_id).await.expect("duplicate release is absent"));
}
}
let disabled = LocalClient::with_manager(Arc::new(GlobalLockManager::Disabled(DisabledLockManager::new())));
let disabled_request =
LockRequest::new(ObjectKey::new("bucket", "disabled"), LockType::Exclusive, "disabled-owner".to_owned());
assert!(
disabled
.acquire_lock(&disabled_request)
.await
.expect("disabled acquire bypass")
.success
);
assert!(
disabled
.release(&disabled_request.lock_id)
.await
.expect("disabled release bypass")
);
let rows = capture.all_rows();
let releases = rows
.iter()
.filter(|row| row["fields"]["event"] == "namespace_lock_release" && row["fields"]["state"] == "released")
.collect::<Vec<_>>();
assert_eq!(
releases.len(),
4,
"duplicates and disabled guards cannot acknowledge another physical release"
);
let physical_mappings = rows
.iter()
.filter(|row| row["fields"]["event"] == "namespace_lock_release" && row["fields"]["state"] == "mapped")
.collect::<Vec<_>>();
assert_eq!(physical_mappings.len(), 4);
assert!(releases.iter().all(|row| physical_mappings.iter().any(|mapped| {
mapped["fields"]["physical_guard_id"] == row["fields"]["physical_guard_id"]
&& mapped["fields"]["process_id"] == row["fields"]["process_id"]
&& peer_ids
.iter()
.any(|peer_id| mapped["fields"]["lock_id"] == peer_id.to_string())
})));
let dropped = capture.rows().pop().expect("guard drop event");
assert_eq!(dropped["fields"]["lock_id"], id.to_string());
assert!(
dropped["spans"]
.as_array()
.expect("retained request span")
.iter()
.any(|span| span["request_id"] == "original-multipart-request")
);
let manager = Arc::new(FastObjectLockManager::new());
let stale_client = LocalClient::with_manager(Arc::new(GlobalLockManager::Enabled(manager.clone())));
let stale_request = LockRequest::new(
ObjectKey::new("bucket", "externally-released"),
LockType::Exclusive,
"stale-owner".to_owned(),
);
assert!(
stale_client
.acquire_lock(&stale_request)
.await
.expect("real stale owner")
.success
);
assert_eq!(manager.force_unlock(&stale_request.resource), 1);
let mut successor = manager
.acquire_write_lock(stale_request.resource.clone(), "successor-owner")
.await
.expect("real successor guard");
assert!(
stale_client
.release(&stale_request.lock_id)
.await
.expect("retire stale client record")
);
assert_eq!(
manager
.get_lock_info(&stale_request.resource)
.expect("successor remains locked")
.owner
.as_ref(),
"successor-owner"
);
assert_eq!(
capture
.all_rows()
.iter()
.filter(
|row| row["fields"]["event"] == "namespace_lock_release" && row["fields"]["state"] == "released"
)
.count(),
4,
"a stale guard's failed physical release must not produce an ACK"
);
let successor_id = successor.guard_id();
assert!(successor.release(), "the actual successor release succeeds");
assert!(!successor.release(), "a duplicate explicit release cannot complete again");
drop(successor);
assert_eq!(
capture
.all_rows()
.iter()
.filter(|row| row["fields"]["event"] == "namespace_lock_release"
&& row["fields"]["state"] == "released"
&& row["fields"]["physical_guard_id"] == successor_id)
.count(),
1,
"explicit release followed by Drop records one physical completion"
);
});
});
});
}
fn namespace() -> NamespaceLockWrapper {
let manager = rustfs_lock::GlobalLockManager::Enabled(Arc::new(rustfs_lock::fast_lock::FastObjectLockManager::new()));
NamespaceLockWrapper::new(
+449 -197
View File
@@ -7762,10 +7762,6 @@ impl LocalDisk {
// }
let multipart_check = self.multipart_delete_check(delete_path, true)?;
if let Some(check) = &multipart_check {
self.validate_multipart_delete_admission(check.clone(), namespace_owner.clone())
.await?;
}
let err = if let Some(check) = &multipart_check {
os::rename_all_with_precondition_and_parent_sync(
delete_path,
@@ -7842,13 +7838,15 @@ impl LocalDisk {
namespace_owner: Option<Arc<dyn Send + Sync>>,
) -> Result<()> {
multipart_staging::reject_native_control_write(&self.root, self.io_root(), volume, path)?;
let source_owner = self.acquire_multipart_delete_owner(volume, path, namespace_owner).await?;
let mut source_owner = self.acquire_multipart_delete_owner(volume, path, namespace_owner).await?;
let namespace_owner = source_owner.namespace_owner();
if volume == RUSTFS_META_MULTIPART_BUCKET
&& let Some(check) = self.multipart_delete_check(&self.io_get_object_path(volume, path)?, opt.recursive)?
&& (opt.orphan_data_dir || (opt.recursive && opt.immediate))
{
self.validate_multipart_delete_admission(check, namespace_owner.clone())
.await?;
source_owner.mark_validated();
}
if pool_meta_native::reject_raw_path(&self.root, volume, path).is_err() {
let owner = self.pool_meta_cleanup_owner(volume, path, &opt, namespace_owner).await?;
@@ -8066,7 +8064,10 @@ impl LocalDisk {
let file_path = self.io_get_object_path(volume, path)?;
check_path_length(file_path.to_string_lossy().as_ref())?;
let multipart_check = self.multipart_delete_check(&file_path, opt.recursive)?;
if let Some(check) = &multipart_check {
if !source_owner.is_validated()
&& (opt.orphan_data_dir || opt.old_data_dir.is_some() || !opt.recursive)
&& let Some(check) = &multipart_check
{
self.validate_multipart_delete_admission(check.clone(), namespace_owner.clone())
.await?;
}
@@ -8313,8 +8314,6 @@ impl LocalDisk {
// debug!("delete_file remove_dir done {:?}", &delete_path);
} else if let Err(err) = {
if let Some(check) = self.multipart_delete_check(delete_path, false)? {
self.validate_multipart_delete_admission(check.clone(), namespace_owner.clone())
.await?;
let path = delete_path.clone();
let lease = os::acquire_namespace_mutation_lease_with_owner(delete_path, namespace_owner.clone()).await;
os::run_blocking_namespace_operation(lease, move || {
@@ -8327,6 +8326,10 @@ impl LocalDisk {
}
} {
// debug!("remove_file err {:?} when {:?}", &err, &delete_path);
let err = match err.downcast::<DiskError>() {
Ok(disk_err) => return Err(disk_err),
Err(err) => err,
};
match err.kind() {
ErrorKind::NotFound => (),
_ => {
@@ -8447,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
@@ -10009,9 +10029,7 @@ impl LocalDisk {
} {
meta.name.push_str(SLASH_SEPARATOR);
// Conservative listings verify physical prefixes. Never-versioned
// buckets use the bounded fast path, which only has to rule out
// the data dirs a deleted version leaves behind; an empty listing
// of such a prefix then reclaims committed residue.
// buckets first rule out the data dirs a deleted version leaves behind.
let listable = if opts.recursive || opts.incl_deleted {
true
} else if opts.skip_hidden_prefix_check {
@@ -10165,17 +10183,19 @@ impl LocalDisk {
/// Reads stay small on genuine prefixes: each directory is sampled with one
/// bounded batch, the sampled children are probed depth-first, and the
/// directory is only read in full once every sampled child proved to hold
/// nothing listable. A total read budget makes a residue tree larger than
/// the budget surface and be hidden one level down instead, so the cost on
/// a genuine prefix never exceeds its depth in small directory reads. A
/// directory that vanishes mid-probe holds nothing listable.
/// nothing listable. If the read budget is exhausted, verify the subtree
/// before exposing the prefix. A directory that vanishes mid-probe holds
/// nothing listable.
async fn directory_is_delete_residue(&self, bucket: &str, dir_name: &str, stall: Option<Duration>) -> Result<bool> {
let dir_name = dir_name.trim_end_matches(SLASH_SEPARATOR);
let mut reads = 0usize;
let mut pending = vec![ResidueProbeStep::Sample(dir_name.to_owned())];
while let Some(step) = pending.pop() {
if reads >= DELETE_RESIDUE_PROBE_READ_BUDGET {
return Ok(false);
return self
.directory_has_listing_entry(bucket, dir_name, false, stall)
.await
.map(|listable| !listable);
}
reads += 1;
@@ -11306,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
@@ -11343,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<()> {
@@ -12016,20 +12073,16 @@ impl DiskAPI for LocalDisk {
// Publish the immutable generation before preparing the part switch.
// Rollback retains the old generation; an unreferenced new index is
// reclaimed with the upload directory if preparation is interrupted.
os::rename_all_with_precondition(
os::rename_all_with_precondition_and_parent_sync(
&source,
&destination,
&dst_volume_dir,
&self.publication_root,
source_owner.clone(),
admission.as_ref().map(|check| check.precondition()),
durability.syncs_commit_metadata(),
)
.await?;
if durability.syncs_commit_metadata() {
os::fsync_dir_with_owner(destination.parent().ok_or(DiskError::FileCorrupt)?, source_owner.clone())
.await
.map_err(to_file_error)?;
}
}
let retained_owner = source_owner.clone();
tokio::task::spawn_blocking(move || {
@@ -12157,17 +12210,16 @@ impl DiskAPI for LocalDisk {
PartTransactionAction::Commit => (PART_TRANSACTION_NEW_META, PART_TRANSACTION_OLD_META),
PartTransactionAction::Rollback => (PART_TRANSACTION_OLD_META, PART_TRANSACTION_NEW_META),
};
if let (Ok(current), Ok(retained), Ok(obsolete)) = (
std::fs::read(&current_meta_path),
std::fs::read(transaction_path.join(retained_name)),
std::fs::read(transaction_path.join(obsolete_name)),
) && current == retained
if let Ok(obsolete) = std::fs::read(transaction_path.join(obsolete_name))
&& let Ok(obsolete) = ObjectPartInfo::unmarshal(&obsolete)
&& let Some(integrity) = obsolete.integrity
&& integrity.validate().is_ok()
&& usize::try_from(integrity.number).ok() == Some(obsolete.number)
&& current_data_path.file_name().and_then(|name| name.to_str())
== Some(format!("part.{}", obsolete.number).as_str())
&& let (Ok(current), Ok(retained)) =
(std::fs::read(&current_meta_path), std::fs::read(transaction_path.join(retained_name)))
&& current == retained
&& ObjectPartInfo::unmarshal(&retained)
.ok()
.and_then(|part| part.integrity)
@@ -12208,16 +12260,6 @@ impl DiskAPI for LocalDisk {
let source_owner = self.multipart_source_owner(dst_volume, dst_path, true, false, None).await?;
multipart_staging::reject_reserved_anchor(dst_path)?;
let admission = self.multipart_source_admission(dst_volume, dst_path, true, false)?;
if let Some(check) = admission.clone() {
let retained = source_owner.clone();
tokio::task::spawn_blocking(move || {
let _owner = retained;
check.validate()
})
.await
.map_err(DiskError::from)?
.map_err(to_file_error)?;
}
pool_meta_native::reject_raw_path(&self.root, src_volume, src_path)?;
pool_meta_native::reject_raw_path(&self.root, dst_volume, dst_path)?;
let src_volume_dir = self.io_get_bucket_path(src_volume)?;
@@ -12253,158 +12295,186 @@ impl DiskAPI for LocalDisk {
check_path_length(src_file_path.to_string_lossy().as_ref())?;
check_path_length(dst_file_path.to_string_lossy().as_ref())?;
if src_is_dir {
let meta_op = match lstat_std(&src_file_path).map_err(|e| to_file_error(e).into()) {
Ok(meta) => Some(meta),
Err(e) => {
return Err(e);
}
};
if let Some(meta) = meta_op
&& !meta.is_dir()
{
warn!(
event = EVENT_DISK_LOCAL_RENAME_REJECTED,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_DISK_LOCAL,
reason = "src_expected_dir_missing",
path = ?src_file_path,
"Disk local rename rejected"
);
return Err(DiskError::FileAccessDenied);
}
// Clear any stale destination before the directory rename. An absent
// destination is the normal case when renaming a directory to a new
// location, so tolerate NotFound instead of aborting the whole rename
// (MinIO's RenameFile ignores osIsNotExist here).
if let Err(e) = remove_std(&dst_file_path)
&& e.kind() != ErrorKind::NotFound
{
return Err(to_file_error(e).into());
}
} else {
let meta = lstat_std(&src_file_path).map_err(|e| -> DiskError { to_file_error(e).into() })?;
if meta.is_dir() {
warn!(
event = EVENT_DISK_LOCAL_RENAME_REJECTED,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_DISK_LOCAL,
reason = "src_unexpected_dir",
path = ?src_file_path,
"Disk local rename rejected"
);
return Err(DiskError::FileAccessDenied);
}
}
let transaction_publish_meta = if src_is_dir {
let dst_meta_path = if src_is_dir {
None
} else {
let transaction_path = self.io_get_object_path(dst_volume, &crate::disk::part_transaction_path(dst_path))?;
let transaction_meta_path = transaction_path.join(PART_TRANSACTION_NEW_META);
match fs::read(&transaction_meta_path).await {
Ok(expected_meta) => {
if expected_meta.as_slice() != meta.as_ref() {
return Err(DiskError::FileCorrupt);
}
let publish_meta_path = transaction_path.join(PART_TRANSACTION_PUBLISH_META);
let source_meta_path = transaction_meta_path.clone();
let publish_path = publish_meta_path.clone();
let retained_owner = source_owner.clone();
tokio::task::spawn_blocking(move || {
let _owner = retained_owner;
remove_file_if_exists(&publish_path)?;
std::fs::hard_link(source_meta_path, &publish_path)
})
.await
.map_err(DiskError::from)?
.map_err(to_file_error)?;
Some(publish_meta_path)
}
// Old peers know only RenamePart. The new coordinator never
// reaches rename unless prepare succeeded, so an absent
// transaction directory identifies the rolling-upgrade legacy
// path. A present directory without new.meta is corruption.
Err(err) if err.kind() == ErrorKind::NotFound => match fs::metadata(&transaction_path).await {
Ok(_) => return Err(DiskError::FileCorrupt),
Err(meta_err) if meta_err.kind() == ErrorKind::NotFound => None,
Err(meta_err) => return Err(to_file_error(meta_err).into()),
},
Err(err) => return Err(to_file_error(err).into()),
}
let path = self.io_get_object_path(dst_volume, &format!("{dst_path}.meta"))?;
check_path_length(path.to_string_lossy().as_ref())?;
Some(path)
};
let transaction_path = if src_is_dir {
None
} else {
Some(self.io_get_object_path(dst_volume, &crate::disk::part_transaction_path(dst_path))?)
};
// UploadPart is acknowledged once this rename lands, so the part data and
// its directory entry must be durable before we return. Relaxed keeps the
// part payload fdatasync but leaves the directory entry to the page cache.
let durability = effective_durability(dst_volume);
if durability.syncs_data_shards() && !src_is_dir {
let src = src_file_path.clone();
let retained_owner = source_owner.clone();
tokio::task::spawn_blocking(move || {
let _owner = retained_owner;
os::sync_file(&src)
let sync = (durability.syncs_data_shards() && !src_is_dir) || durability.syncs_commit_metadata();
let publication_root = self.publication_root.clone();
let src_file_for_cleanup = src_file_path.clone();
let src_volume_for_cleanup = src_volume_dir.clone();
let meta_for_legacy = meta.clone();
let lease = os::acquire_namespace_mutation_lease_with_owner(&dst_file_path, source_owner.clone()).await;
let published_transaction_meta =
os::run_blocking_namespace_operation_with_sync(lease, sync, move || -> std::io::Result<bool> {
if let Some(check) = &admission {
check.validate().map_err(to_file_error)?;
}
if src_is_dir {
let meta_op = lstat_std(&src_file_path).map_err(to_file_error)?;
if !meta_op.is_dir() {
warn!(
event = EVENT_DISK_LOCAL_RENAME_REJECTED,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_DISK_LOCAL,
reason = "src_expected_dir_missing",
path = ?src_file_path,
"Disk local rename rejected"
);
return Err(DiskError::FileAccessDenied.into());
}
// Clear any stale destination before the directory rename. An absent
// destination is the normal case when renaming a directory to a new
// location, so tolerate NotFound instead of aborting the whole rename
// (MinIO's RenameFile ignores osIsNotExist here).
if let Err(e) = remove_std(&dst_file_path)
&& e.kind() != ErrorKind::NotFound
{
return Err(to_file_error(e));
}
} else {
let meta_op = lstat_std(&src_file_path).map_err(to_file_error)?;
if meta_op.is_dir() {
warn!(
event = EVENT_DISK_LOCAL_RENAME_REJECTED,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_DISK_LOCAL,
reason = "src_unexpected_dir",
path = ?src_file_path,
"Disk local rename rejected"
);
return Err(DiskError::FileAccessDenied.into());
}
}
let transaction_publish_meta = if let Some(transaction_path) = transaction_path.as_ref() {
let transaction_meta_path = transaction_path.join(PART_TRANSACTION_NEW_META);
match std::fs::File::open(&transaction_meta_path) {
Ok(mut file) => {
let file_len = file.metadata().map_err(to_file_error)?.len();
if file_len != meta.len() as u64 {
return Err(DiskError::FileCorrupt.into());
}
let mut expected_meta = Vec::with_capacity(meta.len());
std::io::Read::read_to_end(&mut std::io::Read::take(&mut file, file_len), &mut expected_meta)
.map_err(to_file_error)?;
drop(file);
if expected_meta.as_slice() != meta.as_ref() {
return Err(DiskError::FileCorrupt.into());
}
let publish_meta_path = transaction_path.join(PART_TRANSACTION_PUBLISH_META);
remove_file_if_exists(&publish_meta_path).map_err(to_file_error)?;
std::fs::hard_link(&transaction_meta_path, &publish_meta_path).map_err(to_file_error)?;
Some(publish_meta_path)
}
// Old peers know only RenamePart. The new coordinator never
// reaches rename unless prepare succeeded, so an absent
// transaction directory identifies the rolling-upgrade legacy
// path. A present directory without new.meta is corruption.
Err(err) if err.kind() == ErrorKind::NotFound => match std::fs::metadata(transaction_path) {
Ok(_) => return Err(DiskError::FileCorrupt.into()),
Err(meta_err) if meta_err.kind() == ErrorKind::NotFound => None,
Err(meta_err) => return Err(to_file_error(meta_err)),
},
Err(err) => return Err(to_file_error(err)),
}
} else {
None
};
if durability.syncs_data_shards() && !src_is_dir {
os::sync_file(&src_file_path).map_err(to_file_error)?;
}
#[cfg(all(any(test, feature = "test-util"), not(windows)))]
let precondition = admission.as_ref().map(|check| check.precondition());
#[cfg(not(all(any(test, feature = "test-util"), not(windows))))]
let precondition: Option<Arc<dyn Fn() -> std::io::Result<()> + Send + Sync>> = None;
os::rename_all_with_precondition_and_parent_sync_std(
&src_file_path,
&dst_file_path,
&dst_volume_dir,
&publication_root,
precondition.as_deref(),
durability.syncs_commit_metadata() && transaction_publish_meta.is_none(),
)
.map_err(to_file_error)?;
let dst_meta = lstat_std(&dst_file_path).map_err(to_file_error)?;
if src_is_dir != dst_meta.is_dir() {
warn!(
event = EVENT_DISK_LOCAL_RENAME_REJECTED,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_DISK_LOCAL,
reason = "dst_type_changed_after_rename",
path = ?dst_file_path,
"Disk local rename rejected"
);
return Err(DiskError::FileAccessDenied.into());
}
if let Some(transaction_publish_meta) = transaction_publish_meta {
let dst_meta_path = dst_meta_path.as_ref().ok_or(DiskError::InvalidPath)?;
os::rename_all_with_precondition_and_parent_sync_std(
&transaction_publish_meta,
dst_meta_path,
&dst_volume_dir,
&publication_root,
precondition.as_deref(),
durability.syncs_commit_metadata(),
)
.map_err(to_file_error)?;
for dir in src_file_path.ancestors().skip(1) {
if dir == src_volume_dir.as_path() || !dir.starts_with(&src_volume_dir) || is_root_path(dir) {
break;
}
match std::fs::remove_dir(dir) {
Ok(()) => {}
Err(err) if is_dir_not_empty_error(&err) || is_benign_object_rmdir_error(&err) => break,
Err(err) => {
return Err(Error::other(FileAccessDeniedWithContext {
path: dir.to_path_buf(),
source: err,
})
.into());
}
}
}
Ok(true)
} else {
Ok(false)
}
})
.await
.map_err(DiskError::from)?
.map_err(to_file_error)?;
}
.map_err(DiskError::from)?;
os::rename_all_with_precondition(
&src_file_path,
&dst_file_path,
&dst_volume_dir,
&self.publication_root,
source_owner.clone(),
admission.as_ref().map(|check| check.precondition()),
)
.await?;
if durability.syncs_commit_metadata()
&& let Some(parent) = dst_file_path.parent()
{
os::fsync_dir_with_owner(parent, source_owner.clone())
.await
.map_err(to_file_error)?;
}
let dst_meta = lstat_std(&dst_file_path).map_err(|e| -> DiskError { to_file_error(e).into() })?;
if src_is_dir != dst_meta.is_dir() {
warn!(
event = EVENT_DISK_LOCAL_RENAME_REJECTED,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_DISK_LOCAL,
reason = "dst_type_changed_after_rename",
path = ?dst_file_path,
"Disk local rename rejected"
);
return Err(DiskError::FileAccessDenied);
}
if let Some(transaction_publish_meta) = transaction_publish_meta {
let dst_meta_path = self.io_get_object_path(dst_volume, &format!("{dst_path}.meta"))?;
os::rename_all_with_precondition(
&transaction_publish_meta,
&dst_meta_path,
&dst_volume_dir,
&self.publication_root,
if !published_transaction_meta {
self.write_all_meta_with_namespace_owner(
dst_volume,
&format!("{dst_path}.meta"),
&meta_for_legacy,
true,
source_owner.clone(),
admission.as_ref().map(|check| check.precondition()),
)
.await?;
if durability.syncs_commit_metadata()
&& let Some(parent) = dst_meta_path.parent()
{
os::fsync_dir_with_owner(parent, source_owner.clone())
.await
.map_err(to_file_error)?;
}
} else {
self.write_all_meta_with_namespace_owner(dst_volume, &format!("{dst_path}.meta"), &meta, true, source_owner.clone())
.await?;
}
if src_is_dir {
@@ -12418,8 +12488,9 @@ impl DiskAPI for LocalDisk {
.await;
}
if let Some(parent) = src_file_path.parent() {
self.delete_file(&src_volume_dir, &parent.to_path_buf(), false, false).await?;
if !published_transaction_meta && let Some(parent) = src_file_for_cleanup.parent() {
self.delete_file(&src_volume_for_cleanup, &parent.to_path_buf(), false, false)
.await?;
}
Ok(())
@@ -12895,12 +12966,26 @@ impl DiskAPI for LocalDisk {
.map_err(|e| to_access_error(e, DiskError::VolumeAccessDenied))?;
}
let mut cached_owner: Option<(String, Option<Arc<dyn Send + Sync>>)> = None;
for path in paths.iter() {
let file_path = self.io_get_object_path(volume, path)?;
check_path_length(file_path.to_string_lossy().as_ref())?;
let owner = self.multipart_source_owner(volume, path, true, true, None).await?;
let owner = if volume == RUSTFS_META_MULTIPART_BUCKET {
let scope_key = multipart_staging::upload_scope_root(path, true).to_ascii_lowercase();
if let Some((cached_key, cached)) = &cached_owner
&& cached_key == &scope_key
{
cached.clone()
} else {
let acquired = self.multipart_source_owner(volume, path, true, true, None).await?;
cached_owner = Some((scope_key, acquired.clone()));
acquired
}
} else {
self.multipart_source_owner(volume, path, true, true, None).await?
};
self.move_to_trash_with_namespace_owner(&file_path, false, false, owner)
.await?;
@@ -25245,11 +25330,13 @@ mod test {
)
.await;
// A prefix with more residue directories than the probe budget stays
// visible rather than costing an unbounded walk.
// A prefix larger than the fast-probe budget still needs a complete
// read-only check before it can appear in a delimiter listing.
for hour in 0..(DELETE_RESIDUE_PROBE_READ_BUDGET + 1) {
write_residue(&bucket_dir.join(format!("bulk/2026/08/28/{hour:02}/a.parquet")), false).await;
write_residue(&bucket_dir.join(format!("bulk-live/2026/08/28/{hour:02}/a.parquet")), false).await;
}
write_object(&bucket_dir.join("bulk-live/live.parquet"), "bulk-live/live.parquet").await;
// Exactly at the budget: `edge/` with N object dirs costs one sampled
// read of `edge`, two reads per object dir (its own and its data dir),
// and one remainder read of `edge` once its 8-entry sample was full.
@@ -25330,10 +25417,8 @@ mod test {
vec!["logs/default/2026/08/28/23/".to_owned()]
);
assert_eq!(scan_names(&disk, bucket, "logs/").await, vec!["logs/default/".to_owned()]);
assert_eq!(scan_names(&disk, bucket, "bulk/2026/08/").await, vec!["bulk/2026/08/28/".to_owned()]);
// Listed directly, every object dir is probed on its own and hidden;
// the budget only bites where the whole tree hangs off one entry, so
// the root listing below shows `edge-show/` but not `edge-hide/`.
assert_eq!(scan_names(&disk, bucket, "bulk/2026/08/").await, Vec::<String>::new());
assert_eq!(scan_names(&disk, bucket, "bulk-live/").await, vec!["bulk-live/live.parquet".to_owned()]);
assert_eq!(scan_names(&disk, bucket, "edge-hide/").await, Vec::<String>::new());
assert_eq!(scan_names(&disk, bucket, "edge-show/").await, Vec::<String>::new());
assert_eq!(scan_names(&disk, bucket, "stray/").await, vec!["stray/2026/".to_owned()]);
@@ -25341,8 +25426,7 @@ mod test {
assert_eq!(
scan_names(&disk, bucket, "").await,
vec![
"bulk/".to_owned(),
"edge-show/".to_owned(),
"bulk-live/".to_owned(),
"logs/".to_owned(),
"odd/".to_owned(),
"stray/".to_owned(),
@@ -26504,6 +26588,71 @@ mod test {
}
}
#[tokio::test]
async fn rename_part_leaves_metadata_reads_on_foreground_lane() {
let dir = tempfile::tempdir().expect("disk root");
let endpoint = Endpoint::try_from(dir.path().to_string_lossy().as_ref()).expect("endpoint");
let disk = LocalDisk::new(&endpoint, false).await.expect("local disk");
ensure_test_volume(&disk, "tmp").await;
ensure_test_volume(&disk, "bucket").await;
disk.write_metadata(
"",
"bucket",
"unrelated/object",
test_file_info("unrelated/object", Uuid::new_v4(), Some(Uuid::new_v4()), None),
)
.await
.expect("write unrelated object metadata");
let payload = Bytes::from_static(b"part payload");
let meta = Bytes::from(
ObjectPartInfo {
number: 1,
size: payload.len(),
..Default::default()
}
.marshal_msg()
.expect("part metadata"),
);
disk.write_all("tmp", "upload/part.1", payload.clone())
.await
.expect("stage part");
disk.prepare_part_transaction("tmp", "upload/part.1", "bucket", "object/part.1", meta.clone())
.await
.expect("prepare part transaction");
let (started_tx, started_rx) = tokio::sync::oneshot::channel();
let (release_tx, release_rx) = tokio::sync::oneshot::channel();
let queue = Arc::clone(&disk.blocking_queue);
let blocker = tokio::spawn(async move {
queue
.run_fsync_cancellable(move |_| {
let _ = started_tx.send(());
let _ = release_rx.blocking_recv();
Ok(())
})
.await
});
started_rx.await.expect("fsync lane blocked");
let mut rename = std::pin::pin!(disk.rename_part("tmp", "upload/part.1", "bucket", "object/part.1", meta.clone()));
assert!(futures::FutureExt::now_or_never(&mut rename).is_none());
let read = tokio::time::timeout(
Duration::from_secs(5),
disk.read_version("", "bucket", "unrelated/object", "", &ReadOptions::default()),
)
.await
.expect("read_version on unrelated key must not be blocked by rename_part sync")
.expect("unrelated object metadata should be readable");
assert_eq!(read.name, "unrelated/object");
release_tx.send(()).expect("release fsync lane");
blocker.await.expect("blocker joined").expect("blocker completed");
rename.await.expect("rename_part completed");
}
#[tokio::test]
async fn metadata_read_job_retains_resource_unit_until_native_completion() {
use crate::disk::with_background_resource_unit;
@@ -30862,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,6 +6,7 @@ use super::{
LocalDisk,
multipart_source_state::{
LoadedSource, SourceHead, SourceState, SourceStore, decode, encode, load_source, raw_sha, read_optional,
read_optional_in_pinned,
},
};
use crate::disk::{
@@ -127,20 +128,6 @@ pub(super) fn validate_retired_head(
}
impl SourceStore {
pub async fn root_missing(&self) -> Result<bool> {
let source = self.source.clone();
let base = self.base.clone();
let root = self.publication_root.clone();
os::run_blocking_namespace_operation(self.lease.clone(), move || {
match os::pin_multipart_source_directory(&source, &base, &root) {
Ok(_) => Ok(false),
Err(error) if error.kind() == ErrorKind::NotFound => Ok(true),
Err(error) => Err(error),
}
})
.await
.map_err(DiskError::from)
}
fn verify_abort_cohort(&self, certificate: &MultipartAbortCertificate) -> Result<()> {
certificate.verify_with_authority(&self.authority)?;
if certificate.disk_ids != self.disk_ids
@@ -265,7 +252,7 @@ impl SourceStore {
let certificate = self.read_abort_certificate(&loaded).await?;
Ok(MultipartSourceInspection {
disk_id: self.disk_id,
root_missing: self.root_missing().await?,
root_missing: loaded.root_missing,
abort_round: loaded
.state
.as_ref()
@@ -561,11 +548,18 @@ impl AbortDeletePermit {
pub(super) struct MultipartDeleteOwner {
owner: Option<Arc<dyn Send + Sync>>,
source_key: Option<PathBuf>,
admission_validated: bool,
}
impl MultipartDeleteOwner {
pub(super) fn namespace_owner(&self) -> Option<Arc<dyn Send + Sync>> {
self.owner.clone()
}
pub(super) fn mark_validated(&mut self) {
self.admission_validated = true;
}
pub(super) fn is_validated(&self) -> bool {
self.admission_validated
}
pub(super) fn chain(self, owner: Option<Arc<dyn Send + Sync>>) -> Self {
let owner = match (self.owner, owner) {
(None, owner) | (owner, None) => owner,
@@ -575,6 +569,7 @@ impl MultipartDeleteOwner {
Self {
owner,
source_key: self.source_key,
admission_validated: self.admission_validated,
}
}
}
@@ -595,7 +590,11 @@ impl LocalDisk {
) -> Result<MultipartDeleteOwner> {
let source_key = self.multipart_delete_owner_key(volume, path)?;
let owner = self.multipart_source_owner(volume, path, true, true, owner).await?;
Ok(MultipartDeleteOwner { owner, source_key })
Ok(MultipartDeleteOwner {
owner,
source_key,
admission_validated: false,
})
}
pub(super) fn validate_multipart_delete_owner(
&self,
@@ -620,12 +619,17 @@ pub(super) struct OrdinaryMultipartDeleteCheck {
}
impl OrdinaryMultipartDeleteCheck {
pub(super) fn validate(&self) -> Result<()> {
if let Some(raw) = read_optional(&self.source.join(STATE), &self.base, &self.root)? {
let pinned = match os::pin_multipart_source_directory(&self.source, &self.base, &self.root) {
Ok(pinned) => pinned,
Err(error) if error.kind() == ErrorKind::NotFound => return Ok(()),
Err(error) => return Err(error.into()),
};
if let Some(raw) = read_optional_in_pinned(&pinned, &self.source, STATE, &self.base, &self.root)? {
let state: SourceState = decode(&raw)?;
if state.abort.is_some() || state.phase == MultipartSourcePhase::Aborting {
return Err(DiskError::ConditionalDeleteConflict);
}
} else if os::multipart_source_has_native_controls(&self.source, &self.base, &self.root)? {
} else if os::multipart_source_has_native_controls_in_pinned(&self.source, &pinned)? {
return Err(DiskError::ConditionalDeleteConflict);
}
Ok(())
@@ -170,7 +170,7 @@ impl LocalDisk {
recovery: loaded.state.as_ref().and_then(|state| state.recovery.clone()),
negative_certificate,
ready_certificate,
root_missing: store.root_missing().await?,
root_missing: loaded.root_missing,
abort_round: loaded
.state
.as_ref()
@@ -552,25 +552,33 @@ impl LocalDisk {
let target = self.get_object_path(&publication.lineage.bucket, &publication.lineage.object)?;
let _target_owner = os::acquire_metadata_mutation_lease(&target, store.owner.clone()).await;
if require_target {
let raw = store
.read_target(publication)
.await?
.ok_or(DiskError::ConditionalDeleteConflict)?;
if !store.target_matches(publication, &raw)? {
return Err(DiskError::ConditionalDeleteConflict);
}
store.sync_target(publication).await?;
let expected_publication = publication.clone();
let physical_slot = store.physical_slot;
let disk_count = store.disk_ids.len();
store
.read_and_sync_target(publication, move |raw| {
if !super::multipart_source_state::SourceStore::target_matches_for_slot(
&expected_publication,
raw,
physical_slot,
disk_count,
)? {
return Err(DiskError::ConditionalDeleteConflict);
}
Ok(())
})
.await?;
}
if !matches!(
let snapshot = if !matches!(
state.phase,
MultipartSourcePhase::LocalTerminalSeal | MultipartSourcePhase::GlobalTerminalCertified
) {
state.capability_sha256 = None;
state.advance(MultipartSourcePhase::LocalTerminalSeal)?;
store.persist(&state).await?;
}
let current = store.load().await?;
let snapshot = current.snapshot.ok_or(DiskError::ConditionalDeleteConflict)?;
store.persist_with_source_sync(&state, false).await?
} else {
loaded.snapshot.ok_or(DiskError::ConditionalDeleteConflict)?
};
if snapshot.head_sha256.is_some()
|| snapshot.capability_sha256.is_some()
|| snapshot.publication.as_ref() != Some(publication)
@@ -106,6 +106,7 @@ impl SourceHead {
}
pub(super) struct LoadedSource {
pub root_missing: bool,
pub state: Option<SourceState>,
pub head: Option<SourceHead>,
pub original: Option<Bytes>,
@@ -224,11 +225,31 @@ pub(super) fn read_optional(path: &Path, base: &Path, root: &os::PublicationRoot
Err(error) => Err(error.into()),
}
}
pub(super) fn read_optional_in_pinned(
pinned: &os::ExistingBaseDirectoryGuard,
parent: &Path,
name: &str,
base: &Path,
root: &os::PublicationRoot,
) -> Result<Option<Bytes>> {
match os::read_multipart_source_metadata_in_pinned(pinned, parent, std::ffi::OsStr::new(name), base, root) {
Ok(bytes) => Ok(Some(Bytes::from(bytes))),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(error) => Err(error.into()),
}
}
/// Canonical source mutations are admitted under SourceRW at the syscall,
/// not on the earlier async caller that may have timed out.
pub(super) fn validate_source_mutation(source: &Path, base: &Path, root: &os::PublicationRoot, initial: bool) -> Result<()> {
let state = read_optional(&source.join(STATE), base, root)?;
let head = read_optional(&source.join(STORAGE_FORMAT_FILE), base, root)?;
let pinned = match os::pin_multipart_source_directory(source, base, root) {
Ok(pinned) => pinned,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return if initial { Ok(()) } else { Err(DiskError::FileNotFound) };
}
Err(error) => return Err(error.into()),
};
let state = read_optional_in_pinned(&pinned, source, STATE, base, root)?;
let head = read_optional_in_pinned(&pinned, source, STORAGE_FORMAT_FILE, base, root)?;
let Some(head) = head else {
return if initial && state.is_none() {
Ok(())
@@ -314,6 +335,48 @@ fn target_backup_path(source: &Path, owner: Uuid) -> PathBuf {
source.join(".mpu-completions").join(owner.to_string()).join("target.xl.meta")
}
fn terminal_record_path(source: &Path, state: &SourceState) -> Option<PathBuf> {
matches!(
state.phase,
MultipartSourcePhase::LocalTerminalSeal | MultipartSourcePhase::GlobalTerminalCertified
)
.then(|| {
source
.join(".mpu-completions")
.join(state.backup_owner.to_string())
.join(format!("terminal-{}.msgpack", state.revision))
})
}
fn snapshot_for_state(
disk_id: Uuid,
state: &SourceState,
state_raw: &[u8],
head_sha256: Option<[u8; 32]>,
) -> Result<MultipartSourceSnapshot> {
let state_sha256 = raw_sha(state_raw);
let inventory = raw_sha(&encode(&(
Some(state_sha256),
head_sha256,
state.original_sha256,
state.negative_certificate_sha256,
state.ready_certificate_sha256,
))?);
Ok(MultipartSourceSnapshot {
disk_id,
physical_slot: state.physical_slot,
lineage: state.lineage.clone(),
generation: state.generation,
revision: state.revision,
phase: state.phase,
state_sha256,
head_sha256,
inventory_sha256: inventory,
capability_sha256: state.capability_sha256,
publication: state.publication.clone(),
})
}
impl SourceStore {
pub fn validate_certificate_topology(&self, certificate: &MultipartCertificate) -> Result<()> {
if certificate.disk_ids != self.disk_ids || certificate.disk_ids.get(self.physical_slot) != Some(&self.disk_id) {
@@ -348,31 +411,72 @@ impl SourceStore {
.map_err(DiskError::from)?
}
pub async fn persist(&self, state: &SourceState) -> Result<()> {
self.persist_with_source_sync(state, false).await.map(|_| ())
}
pub(super) async fn persist_with_source_sync(
&self,
state: &SourceState,
sync_source_before: bool,
) -> Result<MultipartSourceSnapshot> {
state.validate(self.disk_id, &self.path)?;
let raw = encode(state)?;
let snapshot = snapshot_for_state(self.disk_id, state, &raw, None)?;
let source = self.source.clone();
let base = self.base.clone();
let root = self.publication_root.clone();
let terminal = terminal_record_path(&source, state);
os::run_blocking_namespace_operation_with_sync(self.lease.clone(), true, move || {
Self::persist_state_std(&source, &base, &root, &raw, terminal.as_deref(), sync_source_before)
})
.await
.map_err(DiskError::from)?;
Ok(snapshot)
}
fn persist_state_std(
source: &Path,
base: &Path,
root: &os::PublicationRoot,
raw: &[u8],
terminal: Option<&Path>,
sync_source_before: bool,
) -> std::io::Result<()> {
#[cfg(unix)]
let mut pinned = os::pin_multipart_source_directory(source, base, root)?;
#[cfg(not(unix))]
let _pinned = os::pin_multipart_source_directory(source, base, root)?;
if sync_source_before {
#[cfg(unix)]
{
let opened = std::fs::File::from(pinned.pop().ok_or_else(|| IoError::other("empty source directory guard"))?);
os::fsync_directory_handle_std(source, Some(&opened))?;
}
#[cfg(not(unix))]
os::fsync_dir_std(source)?;
}
if let Some(path) = terminal {
os::replace_multipart_source_metadata(path, raw, base, root)?;
}
os::replace_multipart_source_metadata(&source.join(STATE), raw, base, root)
}
async fn write_owned_backup_and_persist(&self, owner: Uuid, target: bool, bytes: &[u8], state: &SourceState) -> Result<()> {
if owner.is_nil() {
return Err(DiskError::FileCorrupt);
}
state.validate(self.disk_id, &self.path)?;
let raw = encode(state)?;
let source = self.source.clone();
let base = self.base.clone();
let root = self.publication_root.clone();
let terminal = matches!(
state.phase,
MultipartSourcePhase::LocalTerminalSeal | MultipartSourcePhase::GlobalTerminalCertified
)
.then(|| {
source
.join(".mpu-completions")
.join(state.backup_owner.to_string())
.join(format!("terminal-{}.msgpack", state.revision))
});
os::run_blocking_namespace_operation(self.lease.clone(), move || {
let _pin = os::pin_multipart_source_directory(&source, &base, &root)?;
if let Some(path) = terminal {
os::replace_multipart_source_metadata(&path, &raw, &base, &root)?;
}
os::replace_multipart_source_metadata(&source.join(STATE), &raw, &base, &root)
let terminal = terminal_record_path(&source, state);
let bytes = bytes.to_vec();
os::run_blocking_namespace_operation_with_sync(self.lease.clone(), true, move || {
Self::write_owned_backup_std(&source, &base, &root, owner, target, &bytes)?;
Self::persist_state_std(&source, &base, &root, &raw, terminal.as_deref(), false)
})
.await
.map_err(DiskError::from)
}
#[cfg(test)]
pub async fn original(&self, state: &SourceState) -> Result<Bytes> {
let path = original_path(&self.source, state.backup_owner);
let base = self.base.clone();
@@ -421,54 +525,65 @@ impl SourceStore {
restore_from: None,
abort: None,
};
self.write_owned_backup(state.backup_owner, false, original).await?;
self.persist(&state).await?;
self.write_owned_backup_and_persist(state.backup_owner, false, original, &state)
.await?;
Ok(state)
}
#[cfg(test)]
pub async fn write_owned_backup(&self, owner: Uuid, target: bool, bytes: &[u8]) -> Result<()> {
if owner.is_nil() {
return Err(DiskError::FileCorrupt);
}
let path = if target {
target_backup_path(&self.source, owner)
} else {
original_path(&self.source, owner)
};
let source = self.source.clone();
let base = self.base.clone();
let root = self.publication_root.clone();
let bytes = bytes.to_vec();
os::run_blocking_namespace_operation(self.lease.clone(), move || {
let _pin = os::pin_multipart_source_directory(&source, &base, &root)?;
let parent = path
.parent()
.ok_or_else(|| std::io::Error::other("source backup has no parent"))?;
let _parents = os::mkdir_all_below_existing_base_std(parent, &base, &root)?;
let existed = if target {
match os::observe_multipart_source_metadata(&path, &base, &root, bytes.len() as u64) {
Ok((length, sha)) if length == bytes.len() as u64 && sha == raw_sha(&bytes) => true,
Ok(_) => {
return Err(IoError::new(std::io::ErrorKind::AlreadyExists, "immutable target backup changed"));
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
Err(error) => return Err(error),
}
} else if let Some(previous) = read_optional(&path, &base, &root).map_err(IoError::other)? {
if previous.as_ref() != bytes.as_slice() {
return Err(IoError::new(std::io::ErrorKind::AlreadyExists, "immutable source backup changed"));
}
true
} else {
false
};
if !existed {
os::replace_multipart_source_metadata(&path, &bytes, &base, &root)?;
}
os::sync_multipart_owned_record_parents(&source, parent, existed, &base, &root)
Self::write_owned_backup_std(&source, &base, &root, owner, target, &bytes)
})
.await
.map_err(DiskError::from)
}
fn write_owned_backup_std(
source: &Path,
base: &Path,
root: &os::PublicationRoot,
owner: Uuid,
target: bool,
bytes: &[u8],
) -> std::io::Result<()> {
let path = if target {
target_backup_path(source, owner)
} else {
original_path(source, owner)
};
let _pin = os::pin_multipart_source_directory(source, base, root)?;
let parent = path
.parent()
.ok_or_else(|| std::io::Error::other("source backup has no parent"))?;
let _parents = os::mkdir_all_below_existing_base_std(parent, base, root)?;
let existed = if target {
match os::observe_multipart_source_metadata(&path, base, root, bytes.len() as u64) {
Ok((length, sha)) if length == bytes.len() as u64 && sha == raw_sha(bytes) => true,
Ok(_) => {
return Err(IoError::new(std::io::ErrorKind::AlreadyExists, "immutable target backup changed"));
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
Err(error) => return Err(error),
}
} else if let Some(previous) = read_optional(&path, base, root).map_err(IoError::other)? {
if previous.as_ref() != bytes {
return Err(IoError::new(std::io::ErrorKind::AlreadyExists, "immutable source backup changed"));
}
true
} else {
false
};
if !existed {
os::replace_multipart_source_metadata(&path, bytes, base, root)?;
}
os::sync_multipart_owned_record_parents(source, parent, existed, base, root)
}
pub async fn replace_head(&self, bytes: &[u8]) -> Result<()> {
let path = self.source.join(STORAGE_FORMAT_FILE);
let base = self.base.clone();
@@ -490,7 +605,7 @@ impl SourceStore {
if encode(actual)? != encode(state)? {
return Err(DiskError::ConditionalDeleteConflict);
}
let raw = self.original(state).await?;
let raw = loaded.original.as_ref().ok_or(DiskError::FileCorrupt)?;
let allowed = match loaded.head.as_ref() {
None => {
state.phase == MultipartSourcePhase::Recovering
@@ -551,7 +666,7 @@ impl SourceStore {
if !allowed {
return Err(DiskError::ConditionalDeleteConflict);
}
let mut info = file_info(&raw, RUSTFS_META_MULTIPART_BUCKET, &self.path, state.lineage.original_version_id)?;
let mut info = file_info(raw, RUSTFS_META_MULTIPART_BUCKET, &self.path, state.lineage.original_version_id)?;
strip_native_controls(&mut info.metadata);
mark_native_source(&mut info);
set_source_status(
@@ -649,6 +764,14 @@ impl SourceStore {
Ok(metadata.map(|_| bytes))
}
pub fn target_matches(&self, publication: &PublicationIdentity, raw: &[u8]) -> Result<bool> {
Self::target_matches_for_slot(publication, raw, self.physical_slot, self.disk_ids.len())
}
pub(super) fn target_matches_for_slot(
publication: &PublicationIdentity,
raw: &[u8],
physical_slot: usize,
disk_count: usize,
) -> Result<bool> {
let info = file_info(raw, &publication.lineage.bucket, &publication.lineage.object, publication.version_id)?;
let slot = publication
.lineage
@@ -670,8 +793,8 @@ impl SourceStore {
&& info.erasure.parity_blocks == publication.lineage.geometry.parity_blocks
&& info.erasure.block_size == publication.lineage.geometry.block_size
&& info.erasure.distribution == publication.lineage.geometry.distribution
&& slot == self.physical_slot
&& publication.lineage.geometry.disks() == self.disk_ids.len())
&& slot == physical_slot
&& publication.lineage.geometry.disks() == disk_count)
}
pub async fn restore_exact_target(&self, state: &SourceState, owner: Option<Arc<dyn Send + Sync>>) -> Result<()> {
let publication = state.publication.as_ref().ok_or(DiskError::FileCorrupt)?;
@@ -772,13 +895,32 @@ pub(super) fn load_source(
physical_slot: usize,
disk_ids: &[Uuid],
) -> Result<LoadedSource> {
let state_raw = read_optional(&source.join(STATE), base, root)?;
let pinned = match os::pin_multipart_source_directory(source, base, root) {
Ok(pinned) => pinned,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Ok(LoadedSource {
root_missing: true,
state: None,
head: None,
original: None,
info: None,
snapshot: None,
head_matches_state: false,
head_matches_activation: false,
});
}
Err(error) => return Err(error.into()),
};
let state_raw = read_optional_in_pinned(&pinned, source, STATE, base, root)?;
let state: Option<SourceState> = state_raw.as_deref().map(decode).transpose()?;
if state.is_none() && os::multipart_source_has_native_controls(source, base, root)? {
if state.is_none() && os::multipart_source_has_native_controls_in_pinned(source, &pinned)? {
return Err(DiskError::ConditionalDeleteConflict);
}
let head_path = source.join(STORAGE_FORMAT_FILE);
let head = if let Some(state) = state.as_ref() {
let head = if let Some(state) = state
.as_ref()
.filter(|state| state.prepared_length.is_some_and(|len| len > 64 * 1024))
{
let head_path = source.join(STORAGE_FORMAT_FILE);
let maximum = state
.prepared_length
.unwrap_or(0)
@@ -789,7 +931,8 @@ pub(super) fn load_source(
let raw = if is_proxy {
None
} else {
let raw = read_optional(&head_path, base, root)?.ok_or(DiskError::FileCorrupt)?;
let raw = read_optional_in_pinned(&pinned, source, STORAGE_FORMAT_FILE, base, root)?
.ok_or(DiskError::FileCorrupt)?;
if raw_sha(&raw) != sha256 {
return Err(DiskError::FileCorrupt);
}
@@ -801,10 +944,17 @@ pub(super) fn load_source(
Err(error) => return Err(error.into()),
}
} else {
read_optional(&head_path, base, root)?.map(|raw| SourceHead {
sha256: raw_sha(&raw),
length: raw.len() as u64,
raw: Some(raw),
read_optional_in_pinned(&pinned, source, STORAGE_FORMAT_FILE, base, root)?.map(|raw| {
let sha256 = raw_sha(&raw);
let length = raw.len() as u64;
let is_proxy = state
.as_ref()
.is_some_and(|state| Some(sha256) == state.prepared_sha256 && Some(length) == state.prepared_length);
SourceHead {
sha256,
length,
raw: (!is_proxy).then_some(raw),
}
})
};
let mut original = None;
@@ -870,26 +1020,12 @@ pub(super) fn load_source(
}
}
let head_sha = head.as_ref().map(|head| head.sha256);
let inventory = raw_sha(&encode(&(
state_raw.as_ref().map(|bytes| raw_sha(bytes)),
head_sha,
state.original_sha256,
state.negative_certificate_sha256,
state.ready_certificate_sha256,
))?);
snapshot = Some(MultipartSourceSnapshot {
snapshot = Some(snapshot_for_state(
disk_id,
physical_slot: state.physical_slot,
lineage: state.lineage.clone(),
generation: state.generation,
revision: state.revision,
phase: state.phase,
state_sha256: raw_sha(state_raw.as_ref().ok_or(DiskError::FileCorrupt)?),
head_sha256: head_sha,
inventory_sha256: inventory,
capability_sha256: state.capability_sha256,
publication: state.publication.clone(),
});
state,
state_raw.as_ref().ok_or(DiskError::FileCorrupt)?,
head_sha,
)?);
} else if let Some(observed) = &head {
let raw = observed.raw().ok_or(DiskError::FileCorrupt)?;
let current = get_file_info(
@@ -950,6 +1086,7 @@ pub(super) fn load_source(
head_matches_state = true;
}
Ok(LoadedSource {
root_missing: false,
state,
head,
original,
@@ -1195,11 +1332,7 @@ impl SourceStore {
directory: &Path,
lease: Arc<os::NamespaceMutationLease>,
) -> Result<()> {
let loaded = self.load().await?;
let current = loaded.state.as_ref().ok_or(DiskError::ConditionalDeleteConflict)?;
if encode(current)? != encode(state)?
|| !loaded.head_matches_state
|| state.lineage != publication.lineage
if state.lineage != publication.lineage
|| state.generation != publication.source_generation
|| state.disk_id != self.disk_id
|| state.physical_slot != self.physical_slot
@@ -1436,8 +1569,7 @@ impl LocalDisk {
};
// Copy the small source FI to an independent immutable inode. Its
// canonical inode is never truncated to build the destination proxy.
store.write_owned_backup(owner, false, original).await?;
store.persist(&state).await?;
store.write_owned_backup_and_persist(owner, false, original, &state).await?;
let prepared_path = store.source.join(format!(".mpu-prepared-{owner}"));
let context = NativeMultipartPublication {
store,
@@ -1480,14 +1612,19 @@ impl NativeMultipartPublication {
length: new_metadata.len() as u64,
};
if let Some(previous) = old_metadata {
self.store.write_owned_backup(state.backup_owner, true, previous).await?;
state.previous_target_sha256 = Some(raw_sha(previous));
state.previous_target_length = Some(previous.len() as u64);
}
state.prepared_sha256 = Some(validated.sha256);
state.prepared_length = Some(validated.length);
state.advance(MultipartSourcePhase::Prepared)?;
self.store.persist(&state).await?;
if let Some(previous) = old_metadata {
self.store
.write_owned_backup_and_persist(state.backup_owner, true, previous, &state)
.await?;
} else {
self.store.persist(&state).await?;
}
*self.state.lock() = state;
*self.validated_prepared.lock() = Some(validated);
Ok(())
@@ -1569,31 +1706,28 @@ impl NativeMultipartPublication {
if current.head.is_some() || encode(current_state)? != encode(&state)? {
return Err(DiskError::ConditionalDeleteConflict);
}
let target = self
.store
.read_target(&self.publication)
.await?
.ok_or(DiskError::ConditionalDeleteConflict)?;
{
let (expected_sha256, expected_length) = {
let guard = self.validated_prepared.lock();
let validated = guard.as_ref().ok_or(DiskError::ConditionalDeleteConflict)?;
if validated.publication != self.publication
|| state.prepared_sha256 != Some(validated.sha256)
|| state.prepared_length != Some(validated.length)
|| target.len() as u64 != validated.length
|| raw_sha(&target) != validated.sha256
{
return Err(DiskError::ConditionalDeleteConflict);
}
}
self.store.sync_target_with_lease(&self.publication, target_lease).await?;
let source = self.store.source.clone();
os::run_blocking_namespace_operation(self.store.lease.clone(), move || os::fsync_dir_std(&source)).await?;
(validated.sha256, validated.length)
};
self.store
.read_and_sync_target_with_lease(&self.publication, target_lease, move |target| {
if target.len() as u64 != expected_length || raw_sha(target) != expected_sha256 {
return Err(DiskError::ConditionalDeleteConflict);
}
Ok(())
})
.await?;
state.advance(MultipartSourcePhase::ConsumedUnsealed)?;
self.store.persist(&state).await?;
let snapshot = self.store.persist_with_source_sync(&state, true).await?;
*self.state.lock() = state;
let loaded = self.store.load().await?;
let snapshot = loaded.snapshot.ok_or(DiskError::FileCorrupt)?;
let proof = self
.store
.issue_proof(MultipartProofKind::Published, self.publication.attempt, None, snapshot)?;
@@ -1632,6 +1766,46 @@ impl SourceStore {
.await
.map_err(DiskError::from)
}
pub async fn read_and_sync_target(
&self,
publication: &PublicationIdentity,
validate: impl FnOnce(&[u8]) -> Result<()> + Send + 'static,
) -> Result<()> {
let io = self.target_path(publication)?;
let logical = self
.root
.join(io.strip_prefix(&self.base).map_err(|_| DiskError::InvalidPath)?);
let lease = os::acquire_namespace_mutation_lease_with_owner(&logical, Some(self.lease.clone())).await;
self.read_and_sync_target_with_lease(publication, lease, validate).await
}
pub async fn read_and_sync_target_with_lease(
&self,
publication: &PublicationIdentity,
lease: Arc<os::NamespaceMutationLease>,
validate: impl FnOnce(&[u8]) -> Result<()> + Send + 'static,
) -> Result<()> {
let directory = self.target_path(publication)?;
let base = self.base.clone();
let root = self.publication_root.clone();
os::run_blocking_namespace_operation_with_sync(lease, true, move || {
os::read_and_sync_multipart_target_metadata(
&directory,
std::ffi::OsStr::new(STORAGE_FORMAT_FILE),
&base,
&root,
|bytes| validate(bytes).map_err(IoError::other),
)
.map_err(|error| {
if error.kind() == std::io::ErrorKind::NotFound {
IoError::other(DiskError::ConditionalDeleteConflict)
} else {
error
}
})
})
.await
.map_err(DiskError::from)
}
async fn sync_source_absence(&self) -> Result<()> {
let source = self.source.clone();
let base = self.base.clone();
@@ -411,15 +411,23 @@ impl LocalDisk {
}
let _attempt = os::mkdir_all_below_existing_base_std(&private_object, &base, &publication_root)?;
if incoming.data.is_none() && !incoming.is_remote() {
let source_data_dir = source_object.join(source_dir.to_string());
let pinned_source_data = (!incoming.parts.is_empty())
.then(|| os::pin_multipart_source_directory(&source_data_dir, &base, &publication_root))
.transpose()?;
let mut names = Vec::with_capacity(incoming.parts.len().saturating_mul(3));
for part in &incoming.parts {
if cancelled() {
return Err(std::io::Error::new(std::io::ErrorKind::Interrupted, "multipart staging cancelled"));
}
let path = source_object
.join(source_dir.to_string())
.join(format!("part.{}.meta", part.number));
let bytes = os::read_multipart_source_metadata(&path, &base, &publication_root)?;
let meta_name = format!("part.{}.meta", part.number);
let bytes = os::read_multipart_source_metadata_in_pinned(
pinned_source_data.as_ref().expect("pinned source data directory"),
&source_data_dir,
std::ffi::OsStr::new(&meta_name),
&base,
&publication_root,
)?;
let actual = rustfs_filemeta::ObjectPartInfo::unmarshal(&bytes).map_err(std::io::Error::other)?;
let normalized = i64::try_from(actual.size)
.map_err(std::io::Error::other)?
@@ -441,13 +449,13 @@ impl LocalDisk {
// Preserve the native completed-MPU directory format. This
// immutable, validated generation record is distinct from
// the upload's mutable top-level xl.meta.
names.push(format!("part.{}.meta", part.number));
names.push(meta_name);
if let Some(integrity) = &part.integrity {
names.push(integrity.file_name());
}
}
os::hardlink_multipart_source_files(
&source_object.join(source_dir.to_string()),
&source_data_dir,
&private_object.join(final_dir.to_string()),
&names,
&base,
+242 -95
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::<()>();
@@ -3047,17 +3053,6 @@ pub(crate) async fn rename_all_with_lease(
/// A source admission must run before destination preparation can mkdir and
/// again before the consuming rename, inside the actual owned callback.
pub(crate) async fn rename_all_with_precondition(
source: &Path,
destination: &Path,
base: &Path,
publication_root: &PublicationRoot,
owner: Option<Arc<dyn Send + Sync>>,
precondition: Option<Arc<dyn Fn() -> io::Result<()> + Send + Sync>>,
) -> Result<()> {
rename_all_with_precondition_and_parent_sync(source, destination, base, publication_root, owner, precondition, false).await
}
pub(crate) async fn rename_all_with_precondition_and_parent_sync(
source: &Path,
destination: &Path,
@@ -3075,45 +3070,63 @@ pub(crate) async fn rename_all_with_precondition_and_parent_sync(
let base = base.to_path_buf();
let root = publication_root.clone();
let lease = acquire_namespace_mutation_lease_with_owner(&destination, owner).await;
run_blocking_namespace_operation(lease, move || {
if let Some(check) = precondition.as_ref() {
check()?;
}
let preparation = prepare_rename_with_retry(&source, &destination, &base, &root)?;
#[cfg(all(test, not(windows)))]
prepared_publication_test_hooks::run_rename_destination(&source, &destination);
#[cfg(all(any(test, feature = "test-util"), not(windows)))]
{
prepared_publication_test_hooks::run(prepared_publication_test_hooks::Stage::Rename, &source);
prepared_publication_test_hooks::run(prepared_publication_test_hooks::Stage::Rename, &destination);
}
if let Some(check) = precondition.as_ref() {
check()?;
}
rename_prepared(&source, &destination, &preparation)?;
if sync_parent {
let parent = destination
.parent()
.ok_or_else(|| io::Error::other("source metadata parent is missing"))?;
#[cfg(unix)]
{
let pinned = preparation
.parent_guard
.as_ref()
.and_then(|guard| guard.last())
.ok_or_else(|| io::Error::other("source metadata parent was not pinned"))?;
let opened = std::fs::File::from(pinned.try_clone()?);
fsync_directory_handle_std(parent, Some(&opened))?;
}
#[cfg(not(unix))]
fsync_dir_std(parent)?;
}
Ok(())
run_blocking_namespace_operation_with_sync(lease, sync_parent, move || {
rename_all_with_precondition_and_parent_sync_std(
&source,
&destination,
&base,
&root,
precondition.as_deref(),
sync_parent,
)
})
.await
.map_err(|error| to_file_error(error).into())
}
pub(crate) fn rename_all_with_precondition_and_parent_sync_std(
source: &Path,
destination: &Path,
base: &Path,
publication_root: &PublicationRoot,
precondition: Option<&(dyn Fn() -> io::Result<()> + Send + Sync)>,
sync_parent: bool,
) -> io::Result<()> {
if let Some(check) = precondition {
check()?;
}
let preparation = prepare_rename_with_retry(source, destination, base, publication_root)?;
#[cfg(all(test, not(windows)))]
prepared_publication_test_hooks::run_rename_destination(source, destination);
#[cfg(all(any(test, feature = "test-util"), not(windows)))]
{
prepared_publication_test_hooks::run(prepared_publication_test_hooks::Stage::Rename, source);
prepared_publication_test_hooks::run(prepared_publication_test_hooks::Stage::Rename, destination);
if let Some(check) = precondition {
check()?;
}
}
rename_prepared(source, destination, &preparation)?;
if sync_parent {
let parent = destination
.parent()
.ok_or_else(|| io::Error::other("source metadata parent is missing"))?;
#[cfg(unix)]
{
let pinned = preparation
.parent_guard
.as_ref()
.and_then(|guard| guard.last())
.ok_or_else(|| io::Error::other("source metadata parent was not pinned"))?;
let opened = std::fs::File::from(pinned.try_clone()?);
fsync_directory_handle_std(parent, Some(&opened))?;
}
#[cfg(not(unix))]
fsync_dir_std(parent)?;
}
Ok(())
}
/// Publish a conditional repair and sync its directory in one owned executor.
/// Cancellation cannot release its metadata/fleet/tier leases between rename
/// and fsync. The last authority check runs after destination preparation.
@@ -3186,26 +3199,32 @@ pub(crate) struct DurableFileReceipt {
#[cfg(unix)]
fn open_anchored_metadata(path: &Path, publication_root: &PublicationRoot) -> io::Result<std::fs::File> {
use rustix::fs::{Mode, OFlags, openat};
#[cfg(target_os = "linux")]
let logical_path = publication_root.path().join(publication_root.relative_path(path)?);
#[cfg(target_os = "linux")]
let path = logical_path.as_path();
let parents = open_existing_directory_below_base_std(
path.parent().ok_or_else(|| io::Error::other("metadata has no parent"))?,
publication_root.path(),
publication_root,
)?;
let directory = parents
.last()
.ok_or_else(|| io::Error::other("missing metadata directory guard"))?;
Ok(openat(
directory,
path.file_name().ok_or_else(|| io::Error::other("metadata has no name"))?,
OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
Mode::empty(),
)?
.into())
{
let parent = path.parent().ok_or_else(|| io::Error::other("metadata has no parent"))?;
existing_base_directory_suffix(parent, publication_root.path())
.or_else(|_| existing_base_directory_suffix(parent, publication_root.io_path()))?;
publication_root.open_readonly(path)
}
#[cfg(not(target_os = "linux"))]
{
use rustix::fs::{Mode, OFlags, openat};
let parents = open_existing_directory_below_base_std(
path.parent().ok_or_else(|| io::Error::other("metadata has no parent"))?,
publication_root.path(),
publication_root,
)?;
let directory = parents
.last()
.ok_or_else(|| io::Error::other("missing metadata directory guard"))?;
Ok(openat(
directory,
path.file_name().ok_or_else(|| io::Error::other("metadata has no name"))?,
OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
Mode::empty(),
)?
.into())
}
}
#[cfg(unix)]
@@ -3372,6 +3391,29 @@ fn open_existing_directory_below_base_std(
use rustix::fs::{Mode, OFlags, openat};
let relative = existing_base_directory_suffix(directory, base)?;
#[cfg(target_os = "linux")]
{
use rustix::fs::{ResolveFlags, openat2};
use std::os::fd::AsFd;
let flags = OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC;
let owned_base;
let base_fd = if base == publication_root.io_path.as_path() || base == publication_root.path.as_path() {
publication_root.directory.as_fd()
} else {
owned_base = std::os::fd::OwnedFd::from(publication_root.open_directory(base)?);
owned_base.as_fd()
};
let target = if relative.as_os_str().is_empty() {
Path::new(".")
} else {
relative
};
match openat2(base_fd, target, flags, Mode::empty(), ResolveFlags::BENEATH | ResolveFlags::NO_SYMLINKS) {
Ok(leaf) => return Ok(vec![leaf]),
Err(rustix::io::Errno::NOSYS) => {}
Err(err) => return Err(err.into()),
}
}
#[cfg(target_os = "linux")]
let base = publication_root.open_directory(base)?.into();
#[cfg(not(target_os = "linux"))]
let base = {
@@ -3658,8 +3700,10 @@ pub(crate) fn read_destination_file_with_commit_guard(
};
match open_linux_relative(&parent.file, Path::new(name), OFlags::RDONLY, Mode::empty()) {
Ok(mut file) => {
let mut data = Vec::new();
std::io::Read::read_to_end(&mut file, &mut data)?;
let len = file.metadata()?.len();
let cap = usize::try_from(len).map_or(0, |len| len.saturating_add(1));
let mut data = Vec::with_capacity(cap);
std::io::Read::read_to_end(&mut std::io::Read::take(&mut file, len), &mut data)?;
Ok(Some(data))
}
Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(None),
@@ -4925,12 +4969,16 @@ impl LinuxCommitDirectories {
use std::os::unix::fs::MetadataExt;
// A pinned directory may have been unlinked or replaced. Do not report a
// successful publication into a detached object tree.
for (path, directory) in [
(&self.source_path, &self.source),
(&self.destination_path, self.prepare_destination()?),
] {
let current = self.root.open_directory(path)?.metadata()?;
if (current.dev(), current.ino()) != (directory.device, directory.inode) {
let current = self.root.open_directory(&self.source_path)?.metadata()?;
if (current.dev(), current.ino()) != (self.source.device, self.source.inode) {
return Err(io::Error::new(io::ErrorKind::InvalidData, "commit directory identity changed"));
}
let destination = self.prepare_destination()?;
if self.source_path != self.destination_path
|| (self.source.device, self.source.inode) != (destination.device, destination.inode)
{
let current = self.root.open_directory(&self.destination_path)?.metadata()?;
if (current.dev(), current.ino()) != (destination.device, destination.inode) {
return Err(io::Error::new(io::ErrorKind::InvalidData, "commit directory identity changed"));
}
}
@@ -5223,12 +5271,20 @@ pub(crate) fn prepare_rename_commit_guard(
existing_base_directory_suffix(destination_parent, destination_base)?;
let source = LinuxCommitDirectory::new(publication_root.open_directory(source_parent)?)?;
let destination = std::sync::OnceLock::new();
match publication_root.open_directory(destination_parent) {
Ok(directory) => {
let _ = destination.set(LinuxCommitDirectory::new(directory)?);
if source_parent == destination_parent {
let _ = destination.set(LinuxCommitDirectory {
file: Arc::clone(&source.file),
device: source.device,
inode: source.inode,
});
} else {
match publication_root.open_directory(destination_parent) {
Ok(directory) => {
let _ = destination.set(LinuxCommitDirectory::new(directory)?);
}
Err(err) if err.kind() == io::ErrorKind::NotFound => {}
Err(err) => return Err(err),
}
Err(err) if err.kind() == io::ErrorKind::NotFound => {}
Err(err) => return Err(err),
}
Ok(RenameCommitGuard {
directories: Arc::new(LinuxCommitDirectories {
@@ -6088,12 +6144,20 @@ fn existing_base_directory_suffix<'a>(dir_path: &'a Path, base_dir: &Path) -> io
/// Missing state never downgrades surviving native control records to legacy.
/// Inspect only this pinned upload root; parts and proof trees are not traversed.
#[cfg(test)]
pub(crate) fn multipart_source_has_native_controls(source: &Path, base: &Path, root: &PublicationRoot) -> io::Result<bool> {
let _pin = match pin_multipart_source_directory(source, base, root) {
let pin = match pin_multipart_source_directory(source, base, root) {
Ok(pin) => pin,
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(false),
Err(error) => return Err(error),
};
multipart_source_has_native_controls_in_pinned(source, &pin)
}
pub(crate) fn multipart_source_has_native_controls_in_pinned(
source: &Path,
_pin: &ExistingBaseDirectoryGuard,
) -> io::Result<bool> {
let mut entries = std::fs::read_dir(source)?;
for _ in 0..READ_DIR_PROBE_RAW_LIMIT {
let Some(entry) = entries.next() else {
@@ -6216,6 +6280,7 @@ pub(crate) fn replace_multipart_source_metadata(
publication_root: &PublicationRoot,
) -> io::Result<()> {
let parent = path.parent().ok_or_else(|| io::Error::other("MPU record has no parent"))?;
#[cfg(not(target_os = "linux"))]
let _pinned = pin_multipart_source_directory(parent, base, publication_root)?;
let guard = prepare_rename_commit_guard(parent, parent, base, publication_root)?;
let staged = parent.join(format!(".mpu-record-{}", uuid::Uuid::new_v4()));
@@ -6397,15 +6462,10 @@ pub(crate) fn sync_multipart_owned_record_parents(
if !directory.starts_with(source) {
return Err(io::Error::new(io::ErrorKind::InvalidInput, "native record barrier escaped source"));
}
let pinned = pin_multipart_source_directory(directory, base, publication_root)?;
let mut pinned = pin_multipart_source_directory(directory, base, publication_root)?;
#[cfg(unix)]
{
let opened = std::fs::File::from(
pinned
.last()
.ok_or_else(|| io::Error::other("empty native directory guard"))?
.try_clone()?,
);
let opened = std::fs::File::from(pinned.pop().ok_or_else(|| io::Error::other("empty native directory guard"))?);
fsync_directory_handle_std(directory, Some(&opened))?;
}
#[cfg(not(unix))]
@@ -6436,6 +6496,7 @@ pub(crate) fn install_multipart_prepared_head(
if original_path.parent() != Some(parent) || original_path == canonical_head {
return Err(io::Error::new(io::ErrorKind::InvalidInput, "MPU proxy must be a private sibling"));
}
#[cfg(not(target_os = "linux"))]
let _pinned = pin_multipart_source_directory(parent, base, publication_root)?;
let guard = prepare_rename_commit_guard(parent, parent, base, publication_root)?;
rename_prepared_source_std(&prepared, &original_path, canonical_head, base, publication_root, &guard)?;
@@ -6517,6 +6578,16 @@ pub(crate) fn read_multipart_source_metadata(
read_multipart_metadata(path, base, publication_root, Some(16 * 1024 * 1024))
}
pub(crate) fn read_multipart_source_metadata_in_pinned(
pinned: &ExistingBaseDirectoryGuard,
parent: &Path,
name: &std::ffi::OsStr,
base: &Path,
publication_root: &PublicationRoot,
) -> io::Result<Vec<u8>> {
read_multipart_metadata_in_pinned(pinned, parent, name, base, publication_root, Some(16 * 1024 * 1024))
}
/// Target FI histories have no source-control size limit. Keep the same
/// pinned-parent and no-follow checks when inspecting every referenced DD.
pub(crate) fn read_multipart_reclaim_target_metadata(
@@ -6558,8 +6629,10 @@ fn read_multipart_metadata_bytes(
Ok(bytes)
}
fn read_multipart_metadata(
path: &Path,
fn read_multipart_metadata_in_pinned(
pinned: &ExistingBaseDirectoryGuard,
parent: &Path,
name: &std::ffi::OsStr,
base: &Path,
publication_root: &PublicationRoot,
maximum: Option<u64>,
@@ -6567,16 +6640,10 @@ fn read_multipart_metadata(
#[cfg(unix)]
{
use rustix::fs::{Mode, OFlags, openat};
let parent = path
.parent()
.ok_or_else(|| io::Error::other("multipart metadata has no parent"))?;
let guards = open_existing_directory_below_base_std(parent, base, publication_root)?;
let directory = guards
let _ = (parent, base, publication_root);
let directory = pinned
.last()
.ok_or_else(|| io::Error::other("missing multipart source directory"))?;
let name = path
.file_name()
.ok_or_else(|| io::Error::other("multipart metadata has no name"))?;
let mut file = std::fs::File::from(openat(
directory,
name,
@@ -6589,6 +6656,86 @@ fn read_multipart_metadata(
}
read_multipart_metadata_bytes(&mut file, Some(metadata.len()), maximum)
}
#[cfg(not(unix))]
{
let _ = pinned;
read_multipart_metadata(&parent.join(name), base, publication_root, maximum)
}
}
pub(crate) fn read_and_sync_multipart_target_metadata(
directory: &Path,
name: &std::ffi::OsStr,
base: &Path,
publication_root: &PublicationRoot,
validate: impl FnOnce(&[u8]) -> io::Result<()>,
) -> io::Result<()> {
#[cfg(unix)]
{
use rustix::fs::{Mode, OFlags, openat};
let mut pinned = pin_multipart_source_directory(directory, base, publication_root)?;
let dir_fd = pinned
.last()
.ok_or_else(|| io::Error::other("missing multipart target directory"))?;
let mut file =
std::fs::File::from(openat(dir_fd, name, OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::CLOEXEC, Mode::empty())?);
let metadata = file.metadata()?;
if !metadata.is_file() {
return Err(io::Error::new(io::ErrorKind::InvalidData, "multipart metadata is not a regular file"));
}
let bytes = read_multipart_metadata_bytes(&mut file, Some(metadata.len()), None)?;
validate(&bytes)?;
file.sync_data()?;
let opened = std::fs::File::from(pinned.pop().ok_or_else(|| io::Error::other("empty target directory guard"))?);
fsync_directory_handle_std(directory, Some(&opened))
}
#[cfg(not(unix))]
{
let _pinned = pin_multipart_source_directory(directory, base, publication_root)?;
let file = directory.join(name);
let bytes = read_multipart_metadata(&file, base, publication_root, None)?;
validate(&bytes)?;
sync_file(&file)?;
fsync_dir_std(directory)
}
}
fn read_multipart_metadata(
path: &Path,
base: &Path,
publication_root: &PublicationRoot,
maximum: Option<u64>,
) -> io::Result<Vec<u8>> {
#[cfg(target_os = "linux")]
{
let parent = path
.parent()
.ok_or_else(|| io::Error::other("multipart metadata has no parent"))?;
let name = path
.file_name()
.ok_or_else(|| io::Error::other("multipart metadata has no name"))?;
if name.is_empty() {
return Err(io::Error::new(io::ErrorKind::InvalidInput, "multipart metadata has no name"));
}
existing_base_directory_suffix(parent, base)?;
let mut file = publication_root.open_readonly(path)?;
let metadata = file.metadata()?;
if !metadata.is_file() {
return Err(io::Error::new(io::ErrorKind::InvalidData, "multipart metadata is not a regular file"));
}
read_multipart_metadata_bytes(&mut file, Some(metadata.len()), maximum)
}
#[cfg(all(unix, not(target_os = "linux")))]
{
let parent = path
.parent()
.ok_or_else(|| io::Error::other("multipart metadata has no parent"))?;
let name = path
.file_name()
.ok_or_else(|| io::Error::other("multipart metadata has no name"))?;
let guards = open_existing_directory_below_base_std(parent, base, publication_root)?;
read_multipart_metadata_in_pinned(&guards, parent, name, base, publication_root, maximum)
}
#[cfg(windows)]
{
use windows_sys::{
@@ -1,8 +1,8 @@
use super::meta::{
RebalanceMetaMergeOutcome, clone_first_arc, clone_rebalance_pool_stats, defer_bucket_in_rebalance_queue,
ensure_valid_rebalance_pool_index, invalid_rebalance_pool_index_error, is_rebalance_actively_running,
is_rebalance_conflicting_with_decommission, mark_rebalance_bucket_done, merge_rebalance_meta, percent_free_ratio,
rebalance_metadata_not_initialized_error, record_rebalance_cleanup_warning_in_meta,
RebalanceMetaMergeOutcome, RebalanceMetadataLoadError, clone_first_arc, clone_rebalance_pool_stats,
defer_bucket_in_rebalance_queue, ensure_valid_rebalance_pool_index, invalid_rebalance_pool_index_error,
is_rebalance_actively_running, is_rebalance_conflicting_with_decommission, mark_rebalance_bucket_done, merge_rebalance_meta,
percent_free_ratio, rebalance_metadata_not_initialized_error, record_rebalance_cleanup_warning_in_meta,
record_rebalance_stop_propagation_snapshot, resolve_next_rebalance_bucket, rollback_rebalance_start_meta_snapshot_for_id,
should_accept_rebalance_stats_update, should_pool_participate, stop_rebalance_meta_snapshot_for_id,
validate_init_rebalance_state,
@@ -853,6 +853,26 @@ impl ECStore {
Ok(snapshot)
}
/// Admin-only rebalance status snapshot. Diagnostic reads may use the last
/// published state when the persisted record is temporarily unavailable;
/// mutation paths continue to use the authoritative loaders above.
pub async fn rebalance_admin_diagnostic_snapshot(&self) -> (Option<RebalanceMeta>, &'static str) {
let fallback = self.rebalance_meta.read().await.clone();
match self.rebalance_status_meta_snapshot().await {
Ok(snapshot) => (snapshot, "fresh"),
Err(err) => {
let status = if error_chain_has_rebalance_metadata_load_error(&err) {
"recovery_required"
} else if fallback.is_some() {
"stale"
} else {
"unknown"
};
(fallback, status)
}
}
}
/// Persisted rebalance metadata, or `None` when no rebalance was recorded.
async fn load_rebalance_status_meta(&self) -> Result<Option<RebalanceMeta>> {
let pool = clone_first_arc(&self.pools, "refresh_rebalance_status_meta: no pools available")?;
@@ -860,7 +880,7 @@ impl ECStore {
match persisted.load(pool).await {
Ok(()) => Ok(Some(persisted)),
Err(Error::ConfigNotFound) => Ok(None),
Err(err) => Err(Error::other(format!("rebalance metadata refresh failed during pool status: {err}"))),
Err(err) => Err(Error::other_with_context("rebalance metadata refresh failed during pool status", err)),
}
}
@@ -1447,6 +1467,21 @@ impl ECStore {
}
}
fn error_chain_has_rebalance_metadata_load_error(err: &(dyn std::error::Error + 'static)) -> bool {
let mut current = Some(err);
while let Some(error) = current {
if error.downcast_ref::<RebalanceMetadataLoadError>().is_some() {
return true;
}
current = if let Some(io) = error.downcast_ref::<std::io::Error>() {
io.get_ref().map(|source| source as &(dyn std::error::Error + 'static))
} else {
error.source()
};
}
false
}
#[cfg(test)]
mod tests {
use super::*;
@@ -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
+17 -4
View File
@@ -154,7 +154,8 @@ impl RebalanceMeta {
ver => return Err(rebalance_meta_load_unknown_version_error(ver)),
}
let meta: Self = rmp_serde::from_read(Cursor::new(&data[4..]))?;
let meta: Self = rmp_serde::from_read(Cursor::new(&data[4..]))
.map_err(|source| Error::other(RebalanceMetadataLoadError::Decode(source)))?;
*self = meta;
self.last_refreshed_at = Some(OffsetDateTime::now_utc());
@@ -259,16 +260,28 @@ pub(super) fn first_rebalance_bucket(pool_stat: &RebalanceStats) -> Option<Strin
pool_stat.buckets.first().cloned()
}
#[derive(Debug, thiserror::Error)]
pub(super) enum RebalanceMetadataLoadError {
#[error("rebalance metadata load failed: metadata payload is too short")]
PayloadTooShort,
#[error("rebalance metadata load failed: unknown format {0}")]
UnknownFormat(u16),
#[error("rebalance metadata load failed: unknown version {0}")]
UnknownVersion(u16),
#[error("rebalance metadata load failed: payload decode failed: {0}")]
Decode(#[source] rmp_serde::decode::Error),
}
pub(super) fn rebalance_meta_load_no_data_error() -> Error {
Error::other("rebalance metadata load failed: metadata payload is too short")
Error::other(RebalanceMetadataLoadError::PayloadTooShort)
}
pub(super) fn rebalance_meta_load_unknown_format_error(fmt: u16) -> Error {
Error::other(format!("rebalance metadata load failed: unknown format {fmt}"))
Error::other(RebalanceMetadataLoadError::UnknownFormat(fmt))
}
pub(super) fn rebalance_meta_load_unknown_version_error(ver: u16) -> Error {
Error::other(format!("rebalance metadata load failed: unknown version {ver}"))
Error::other(RebalanceMetadataLoadError::UnknownVersion(ver))
}
pub fn encode_rebalance_stop_propagation_record(record: &RebalanceStopPropagationRecord) -> String {
+290 -51
View File
@@ -1234,16 +1234,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 +1258,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 +1280,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 +1329,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 +1493,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,
@@ -5752,6 +5777,19 @@ impl SetDisks {
return Ok(());
}
// A metadata update that misses write quorum is not rolled back, so
// refuse it before writing when the target disks cannot reach quorum
// even if every write succeeds; otherwise the change a caller reports
// as failed would be served and spread by heal.
let write_quorum = fi.write_quorum(self.default_write_quorum());
let reachable = disks
.iter()
.map(|disk| disk.is_none().then_some(DiskError::DiskNotFound))
.collect::<Vec<_>>();
if let Some(err) = reduce_write_quorum_errs(&reachable, OBJECT_OP_IGNORED_ERRS, write_quorum) {
return Err(err);
}
self.invalidate_get_object_metadata_cache(bucket, object).await;
let mut futures = Vec::with_capacity(disks.len());
@@ -5781,7 +5819,7 @@ impl SetDisks {
}
}
if let Some(err) = reduce_write_quorum_errs(&errs, OBJECT_OP_IGNORED_ERRS, fi.write_quorum(self.default_write_quorum())) {
if let Some(err) = reduce_write_quorum_errs(&errs, OBJECT_OP_IGNORED_ERRS, write_quorum) {
return Err(err);
}
@@ -6338,40 +6376,6 @@ impl SetDisks {
Ok(purged)
}
/// Reclaim metadata-less directory trees discovered by a recursive empty
/// bucket listing. The initial directory read fails closed if any slot is
/// unavailable; per-prefix purges then reuse the full cross-disk scan.
pub(crate) async fn purge_orphan_dir_objects_in_bucket(&self, bucket: &str) -> bool {
let disks = self.get_disks_internal().await;
if disks.is_empty() || disks.iter().any(Option::is_none) {
return false;
}
let mut prefixes = HashSet::new();
for disk in disks.iter().flatten() {
let entries = match disk.list_dir("", bucket, "", 0).await {
Ok(entries) => entries,
Err(_) => return false,
};
prefixes.extend(
entries
.into_iter()
.filter(|entry| entry.ends_with(SLASH_SEPARATOR) && is_safe_orphan_dir_entry(entry)),
);
}
let mut prefixes = prefixes.into_iter().collect::<Vec<_>>();
prefixes.sort_unstable();
let mut purged = false;
for prefix in prefixes {
match self.purge_orphan_dir_object(bucket, &prefix).await {
Ok(prefix_purged) => purged |= prefix_purged,
Err(_) => return false,
}
}
purged
}
/// Orphan cleanup must not infer a complete scan from only the online
/// subset of a set's disks.
pub(crate) async fn orphan_purge_has_complete_disk_set(&self) -> bool {
@@ -7885,6 +7889,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");
@@ -9474,7 +9700,7 @@ mod tests {
"lifecycle-inline-data-get-fanout-bucket",
"lifecycle-inline-data-get-object",
GET_OBJECT_PATH_LEGACY_DUPLEX,
None,
false,
);
}
@@ -9485,7 +9711,7 @@ mod tests {
"lifecycle-bounded-inline-data-get-fanout-bucket",
"lifecycle-bounded-inline-data-get-object",
GET_OBJECT_PATH_LEGACY_DUPLEX,
Some("true"),
true,
);
}
@@ -9496,7 +9722,7 @@ mod tests {
RUSTFS_META_BUCKET,
"buckets/.usage-cache/lifecycle-inline-data-get-object",
GET_OBJECT_PATH_INTERNAL_META,
None,
false,
);
}
@@ -9597,7 +9823,7 @@ mod tests {
bucket: &'static str,
object_prefix: &str,
expected_path: &'static str,
bounded_fanout_env: Option<&'static str>,
bounded_fanout: bool,
) {
const DISKS: usize = 4;
let object = object_with_initial_data_shards(bucket, object_prefix, 2, 3);
@@ -9617,16 +9843,26 @@ mod tests {
[
("RUSTFS_GET_METADATA_EARLY_STOP_ENABLE", Some("true")),
("RUSTFS_GET_METADATA_DATA_READ_EARLY_STOP_ENABLE", Some("true")),
("RUSTFS_GET_METADATA_EARLY_STOP_BOUNDED_FANOUT", bounded_fanout_env),
(
"RUSTFS_GET_METADATA_EARLY_STOP_BOUNDED_FANOUT",
Some(if bounded_fanout { "true" } else { "false" }),
),
],
async {
let barrier_disk = if bounded_fanout_env.is_some() {
bounded_spare_disk_index(bucket, &object, 2, 2)
} else {
3
};
let barrier_disk = bounded_spare_disk_index(bucket, &object, 2, 2);
let barrier =
rename_fanout_barrier::arm(&object, barrier_disk, rename_fanout_barrier::PHASE_READ_VERSION);
// Keep one initial bounded read pending so its hedge is scheduled;
// eager fanout needs all quorum reads parked until the spare starts.
let quorum_disks = if bounded_fanout {
vec![bounded_metadata_fanout_order(bucket, &object, DISKS, 2)[0]]
} else {
(0..DISKS).filter(|&index| index != barrier_disk).collect()
};
let quorum_barriers = quorum_disks
.into_iter()
.map(|index| rename_fanout_barrier::arm(&object, index, rename_fanout_barrier::PHASE_READ_VERSION))
.collect::<Vec<_>>();
let disks_for_read = disks.clone();
let object_for_read = object.clone();
let mut read = tokio::spawn(async move {
@@ -9648,6 +9884,9 @@ mod tests {
tokio::time::timeout(BARRIER_PAUSE_GUARD, barrier.wait_until_paused())
.await
.expect("metadata fanout should pause the spare metadata task");
for quorum_barrier in &quorum_barriers {
quorum_barrier.release();
}
let (parts_metadata, errs, diagnostics) = tokio::time::timeout(BARRIER_PAUSE_GUARD, &mut read)
.await
.expect("verified inline quorum should abort the paused eager fanout")
@@ -597,10 +597,8 @@ mod tests {
.iter()
.map(|slot| slot.file_info().expect("every disk must have arrived").clone())
.collect::<Vec<_>>();
let (_, selected, selection_quorum) =
SetDisks::select_valid_fileinfo(&vec![None; 4], &completed_metadata, &vec![None; 4], "", 2, 3)
.expect("three matching B disks must select the current identity");
assert_eq!(selection_quorum, 3);
let (_, selected) = SetDisks::select_valid_fileinfo(&vec![None; 4], &completed_metadata, &vec![None; 4], 2)
.expect("three matching B disks must select the current identity");
assert_eq!(selected.version_id, expected_current.version_id);
assert_eq!(selected.mod_time, expected_current.mod_time);
assert_eq!(selected.data_dir, expected_current.data_dir);
+108 -24
View File
@@ -665,14 +665,20 @@ impl SetDisks {
read_quorum
}
pub(super) fn select_valid_fileinfo(
/// The commit-proof rule kept for internal metadata in the system bucket.
/// Pool metadata's PREPARE/COMMIT recovery and identity epochs treat an
/// ordinary read as proof that a record committed: when enough copies
/// answer to prove write quorum, the latest version must hold it, and a
/// degraded read never serves a newer version below write quorum. A split
/// write therefore stays unreadable until native recovery resolves it.
fn select_committed_fileinfo(
disks: &[Option<DiskStore>],
parts_metadata: &[FileInfo],
errs: &[Option<DiskError>],
version_id: &str,
read_quorum: usize,
write_quorum: usize,
) -> disk::error::Result<(Vec<Option<DiskStore>>, FileInfo, usize)> {
) -> disk::error::Result<(Vec<Option<DiskStore>>, FileInfo)> {
let selection_quorum =
Self::latest_fileinfo_selection_quorum(version_id, parts_metadata, errs, read_quorum, write_quorum);
let (usable_metadata, has_read_error) = Self::usable_fileinfo_count(parts_metadata, errs);
@@ -683,14 +689,71 @@ impl SetDisks {
&& usable_metadata >= write_quorum
&& selection_quorum == read_quorum
{
let (online_disks, fi) = Self::pick_degraded_latest_fileinfo(disks, parts_metadata, errs, read_quorum, write_quorum)?;
return Ok((online_disks, fi, read_quorum));
return Self::pick_degraded_latest_fileinfo(disks, parts_metadata, errs, read_quorum, write_quorum);
}
let (online_disks, mod_time, etag) = Self::list_online_disks(disks, parts_metadata, errs, selection_quorum);
let fi = Self::pick_valid_fileinfo(parts_metadata, mod_time, etag, selection_quorum)?;
Ok((online_disks, fi, selection_quorum))
Ok((online_disks, fi))
}
/// Select the version a read of `bucket` serves: S3 buckets use MinIO's
/// read-quorum rule (`select_valid_fileinfo`); the system bucket keeps the
/// commit-proof rule (`select_committed_fileinfo`).
#[allow(clippy::too_many_arguments)]
pub(super) fn select_fileinfo_for_read(
bucket: &str,
disks: &[Option<DiskStore>],
parts_metadata: &[FileInfo],
errs: &[Option<DiskError>],
version_id: &str,
read_quorum: usize,
write_quorum: usize,
) -> disk::error::Result<(Vec<Option<DiskStore>>, FileInfo)> {
if crate::bucket::utils::is_meta_bucketname(bucket) {
Self::select_committed_fileinfo(disks, parts_metadata, errs, version_id, read_quorum, write_quorum)
} else {
Self::select_valid_fileinfo(disks, parts_metadata, errs, read_quorum)
}
}
/// Select the version an S3 read serves with MinIO's rule (`getObjectFileInfo`
/// -> `listOnlineDisks` -> `pickValidFileInfo`): the modification time
/// shared by the most copies wins when it reaches `read_quorum`, a tie
/// goes to the newer time, and one content identity at that time must
/// itself reach `read_quorum`. Heal picks the latest version the same
/// way, so a read returns the version heal converges the set to; a write
/// that failed after reaching exactly `read_quorum` copies is readable
/// instead of failing until heal runs, as listing already shows it.
///
/// `read_quorum` follows the parity most copies agree on, which delete
/// markers and empty versions can lower below the selected version's own
/// data blocks; like MinIO's `getLatestFileInfo`, a version with local
/// shards also needs that many copies, or HEAD would report a version
/// GET cannot decode.
pub(super) fn select_valid_fileinfo(
disks: &[Option<DiskStore>],
parts_metadata: &[FileInfo],
errs: &[Option<DiskError>],
read_quorum: usize,
) -> disk::error::Result<(Vec<Option<DiskStore>>, FileInfo)> {
let (online_disks, mod_time, etag) = Self::list_online_disks(disks, parts_metadata, errs, read_quorum);
let (fi, copies) = Self::find_file_info_in_quorum_counted(parts_metadata, &mod_time, &etag, read_quorum)?;
if !fi.deleted && !fi.is_remote() && fi.size > 0 && copies < fi.erasure.data_blocks {
// The shortcut counts byte-identical copies only; count every copy
// of the content identity before refusing the version.
let identity = Self::file_info_quorum_hash(&fi);
let copies = parts_metadata
.iter()
.filter(|meta| file_info_is_valid_for_metadata(meta) && Self::file_info_quorum_hash(meta) == identity)
.count();
if copies < fi.erasure.data_blocks {
return Err(DiskError::ErasureReadQuorum);
}
}
Ok((online_disks, fi))
}
pub(super) fn pick_valid_fileinfo(
@@ -1025,6 +1088,23 @@ impl SetDisks {
etag: &Option<String>,
quorum: usize,
) -> disk::error::Result<FileInfo> {
Self::find_file_info_in_quorum_counted(metas, mod_time, etag, quorum).map(|(fi, _)| fi)
}
/// `find_file_info_in_quorum`, also returning how many copies carry the
/// selected content identity.
///
/// Two identities can share the top count only in a data = parity set
/// split exactly in half at one modification time, such as a metadata
/// update that failed after reaching half the drives. The identity held
/// by the lowest-index disk wins, so every read and heal picks the same
/// side and a retried metadata write can repair the split.
fn find_file_info_in_quorum_counted(
metas: &[FileInfo],
mod_time: &Option<OffsetDateTime>,
etag: &Option<String>,
quorum: usize,
) -> disk::error::Result<(FileInfo, usize)> {
if quorum < 1 {
warn!("find_file_info_in_quorum: quorum < 1");
return Err(DiskError::ErasureReadQuorum);
@@ -1053,7 +1133,7 @@ impl SetDisks {
if same_count >= quorum && same_count > total_valid - same_count {
let mut fi = first.clone();
fi.is_latest = fi.successor_mod_time.is_none();
return Ok(fi);
return Ok((fi, same_count));
}
}
@@ -1106,15 +1186,12 @@ impl SetDisks {
*count_map.entry(hash).or_insert(0) += 1;
}
let mut max_val = None;
let mut max_count = 0;
for (&val, &count) in &count_map {
if count > max_count {
max_val = Some(val);
max_count = count;
}
}
let max_count = count_map.values().copied().max().unwrap_or(0);
let max_val = meta_hashes
.iter()
.flatten()
.find(|hash| count_map.get(*hash) == Some(&max_count))
.copied();
if max_count < quorum {
warn!(
@@ -1155,17 +1232,24 @@ impl SetDisks {
if found {
let mut fi = found_fi.expect("operation should succeed");
for (val, &count) in &valid_obj_map {
if count >= quorum {
fi.successor_mod_time = val.successor_mod_time;
fi.num_versions = val.num_versions;
fi.is_latest = val.successor_mod_time.is_none();
break;
}
// Scan in disk order so that two version-stack shapes with equal
// counts resolve the same way on every read.
let quorum_props = metas
.iter()
.zip(meta_hashes.iter())
.filter(|(meta, hash)| hash.is_some() && **hash == max_val && file_info_is_valid_for_metadata(meta))
.map(|(meta, _)| ObjProps {
successor_mod_time: meta.successor_mod_time,
num_versions: meta.num_versions,
})
.find(|props| valid_obj_map.get(props).is_some_and(|&count| count >= quorum));
if let Some(props) = quorum_props {
fi.successor_mod_time = props.successor_mod_time;
fi.num_versions = props.num_versions;
fi.is_latest = props.successor_mod_time.is_none();
}
return Ok(fi);
return Ok((fi, max_count));
}
warn!("find_file_info_in_quorum: fileinfo not found");
+314 -112
View File
@@ -129,7 +129,7 @@ use rustfs_filemeta::{
};
use rustfs_heal_contracts::heal_channel::{
DriveState, HealAdmissionResult, HealChannelPriority, HealItemType, HealOpts, HealRequestSource, HealScanMode,
send_heal_replacement_disk, send_heal_request_with_admission,
send_heal_request_with_admission,
};
use rustfs_io_metrics::{
record_object_lock_diag_acquire_duration, record_object_lock_diag_enabled, record_object_lock_diag_hold_duration,
@@ -1024,6 +1024,19 @@ impl GetObjectFileInfo {
self.as_parts().0
}
/// Marks an exact-version selection as the object's latest version after
/// the versions above it were skipped as dangling.
fn mark_selected_as_latest(&mut self) {
if let Some(OwnedGetObjectFileInfo {
payload: OwnedGetObjectFileInfoPayload::Direct { fi, .. },
..
}) = self.owned.as_mut()
{
fi.is_latest = true;
fi.successor_mod_time = None;
}
}
fn parts_metadata(&self) -> &[FileInfo] {
self.as_parts().1
}
@@ -1790,7 +1803,7 @@ mod prepared_get_object_metadata_tests {
assert!(original.is_complete());
let (original_metadata, original_errors, _) = original.into_legacy();
assert!(original_errors.iter().all(Option::is_none));
let (_, generation_a, _) = SetDisks::select_valid_fileinfo(&disks, &original_metadata, &original_errors, "", 2, 3)
let (_, generation_a) = SetDisks::select_valid_fileinfo(&disks, &original_metadata, &original_errors, 2)
.expect("generation A must have real metadata quorum");
assert!(!generation_a.inline_data(), "fixture must use non-inline shard files");
let order = bounded_metadata_fanout_order(bucket, &object, 4, 2);
@@ -1851,8 +1864,8 @@ mod prepared_get_object_metadata_tests {
assert!(replacement.is_complete());
let (replacement_metadata, replacement_errors, _) = replacement.into_legacy();
assert!(replacement_errors.iter().all(Option::is_none));
let (online, generation_b, _) =
SetDisks::select_valid_fileinfo(&disks, &replacement_metadata, &replacement_errors, "", 2, 3)
let (online, generation_b) =
SetDisks::select_valid_fileinfo(&disks, &replacement_metadata, &replacement_errors, 2)
.expect("replacement must have healthy real metadata quorum");
assert_eq!(online.iter().flatten().count(), 4);
assert_ne!(generation_a.data_dir, generation_b.data_dir);
@@ -4232,14 +4245,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,
@@ -6829,7 +6851,7 @@ impl SetDisks {
let (metadata, errors) = Self::read_all_fileinfo(&disks, "", bucket, object, version_id, false, false, false).await?;
// Match delete_object_version_with_purge, independently of data parity or online inventory size.
let quorum = self.set_drive_count / 2 + 1;
let (_, marker, _) = Self::select_valid_fileinfo(&disks, &metadata, &errors, version_id, quorum, quorum)
let (_, marker) = Self::select_valid_fileinfo(&disks, &metadata, &errors, quorum)
.map_err(|error| to_object_err(error.into(), vec![bucket, object, version_id]))?;
if !marker.deleted || marker.tier_free_version() || marker.version_id.unwrap_or_else(Uuid::nil) != requested_version {
return Err(Error::DataMovementOverwriteErr(
@@ -6841,6 +6863,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
@@ -10475,13 +10508,6 @@ mod tests {
assert!(!purged, "an incomplete disk scan must not claim the tree is an orphan");
assert!(prefix_dir.join("nested/leaf").exists(), "online disk contents must remain untouched");
let bucket_purged = set.purge_orphan_dir_objects_in_bucket("bucket").await;
assert!(!bucket_purged, "bucket-wide cleanup must fail closed with an unavailable disk slot");
assert!(
prefix_dir.join("nested/leaf").exists(),
"bucket-wide cleanup must preserve the online tree"
);
}
// Cross-disk safety: if any drive still holds object data under the prefix, refuse
@@ -11976,41 +12002,121 @@ mod tests {
}
#[test]
fn test_latest_fileinfo_selection_quorum_requires_write_quorum_when_full_metadata_is_available() {
fn test_latest_fileinfo_selection_breaks_same_time_tie_in_disk_order() {
let mod_time = OffsetDateTime::now_utc();
let data_dir = Uuid::new_v4();
let metas = vec![
quorum_test_fileinfo(mod_time, data_dir, "part-etag-a", 1),
quorum_test_fileinfo(mod_time, data_dir, "part-etag-a", 2),
quorum_test_fileinfo(mod_time, data_dir, "part-etag-b", 3),
quorum_test_fileinfo(mod_time, data_dir, "part-etag-b", 4),
];
let tagged = |index, tag: &str| {
let mut fi = quorum_test_fileinfo(mod_time, data_dir, "part-etag", index);
fi.metadata.insert("x-amz-tagging".to_string(), tag.to_string());
fi
};
let errs = vec![None, None, None, None];
let quorum = SetDisks::latest_fileinfo_selection_quorum("", &metas, &errs, 2, 3);
// A tagging write that failed after reaching half the drives.
for (metas, expected) in [
(vec![tagged(1, "a=1"), tagged(2, "a=1"), tagged(3, "a=2"), tagged(4, "a=2")], "a=1"),
(vec![tagged(1, "a=2"), tagged(2, "a=1"), tagged(3, "a=1"), tagged(4, "a=2")], "a=2"),
] {
// Each call hashes into fresh maps; a map-order choice would flip.
for _ in 0..32 {
let (_, selected) = SetDisks::select_valid_fileinfo(&vec![None; metas.len()], &metas, &errs, 2)
.expect("a same-time half split serves the side on the lowest-index disk");
let healed =
SetDisks::find_file_info_in_quorum(&metas, &Some(mod_time), &None, 2).expect("heal resolves the same split");
assert_eq!(quorum, 3);
assert_eq!(selected.metadata.get("x-amz-tagging").map(String::as_str), Some(expected));
assert_eq!(healed.metadata.get("x-amz-tagging").map(String::as_str), Some(expected));
}
}
}
#[test]
fn test_latest_fileinfo_selection_quorum_preserves_read_quorum_for_version_or_degraded_reads() {
let mod_time = OffsetDateTime::now_utc();
let data_dir = Uuid::new_v4();
let metas = vec![
quorum_test_fileinfo(mod_time, data_dir, "part-etag-a", 1),
quorum_test_fileinfo(mod_time, data_dir, "part-etag-a", 2),
FileInfo::default(),
FileInfo::default(),
];
let degraded_errs = vec![None, None, Some(DiskError::DiskNotFound), Some(DiskError::DiskNotFound)];
let clean_errs = vec![None, None, None, None];
fn test_latest_fileinfo_selection_requires_the_versions_own_data_blocks() {
let old_mod_time = OffsetDateTime::now_utc();
let new_mod_time = old_mod_time + time::Duration::seconds(1);
let old = |index| quorum_test_fileinfo(old_mod_time, Uuid::from_u128(1), "part-etag-old", index);
let new = |index| {
let mut fi = quorum_test_fileinfo(new_mod_time, Uuid::from_u128(2), "part-etag-new", index);
fi.erasure.data_blocks = 3;
fi.erasure.parity_blocks = 1;
fi
};
let errs = vec![None, None, None, None];
assert_eq!(SetDisks::latest_fileinfo_selection_quorum("", &metas, &degraded_errs, 2, 3), 2);
assert_eq!(SetDisks::latest_fileinfo_selection_quorum("version-id", &metas, &clean_errs, 2, 3), 2);
// Read quorum two comes from the 2+2 copies; the newer 3+1 version
// wins the time tie but cannot be decoded from two shards.
let metas = vec![old(1), old(2), new(3), new(4)];
let result = SetDisks::select_valid_fileinfo(&vec![None; metas.len()], &metas, &errs, 2);
assert!(matches!(result, Err(DiskError::ErasureReadQuorum)));
let metas = vec![old(1), new(2), new(3), new(4)];
let (_, selected) = SetDisks::select_valid_fileinfo(&vec![None; metas.len()], &metas, &errs, 2)
.expect("three copies decode a 3+1 version");
assert_eq!(selected.mod_time, Some(new_mod_time));
// Replication-status drift does not change the content identity, so
// the drifted copy still counts toward the version's data blocks.
let replication_status_key = format!(
"{}{}",
rustfs_utils::http::RUSTFS_INTERNAL_PREFIX,
rustfs_utils::http::SUFFIX_REPLICATION_STATUS
);
let mut drifted = new(4);
drifted
.metadata
.insert(replication_status_key, "target-a=PENDING;".to_string());
let metas = vec![old(1), new(2), new(3), drifted];
let (_, selected) = SetDisks::select_valid_fileinfo(&vec![None; metas.len()], &metas, &errs, 2)
.expect("a replication-status drift still leaves three copies of the version");
assert_eq!(selected.mod_time, Some(new_mod_time));
}
#[tokio::test]
async fn update_object_meta_below_write_quorum_writes_nothing() {
use crate::storage_api_contracts::bucket::{BucketOperations as _, MakeBucketOptions};
use crate::storage_api_contracts::object::ObjectIO as _;
let (_dirs, set) = crate::ecstore_validation_blackbox::make_local_set_disks(4, 2).await;
let (bucket, object) = ("metadata-write-quorum", "object");
set.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("bucket should be created");
set.put_object(
bucket,
object,
&mut crate::object_api::PutObjReader::from_vec(b"body".to_vec()),
&ObjectOptions::default(),
)
.await
.expect("object should commit on every drive");
let disks = set.get_disks_internal().await;
let (metadata, _) = SetDisks::read_all_fileinfo(&disks, "", bucket, object, "", false, false, false)
.await
.expect("committed metadata should be readable");
let mut update = metadata[0].clone();
update.metadata.insert("x-amz-meta-probe".to_string(), "written".to_string());
// A read that selected one side of a 2+2 split returns two disks.
let mut targets = disks.clone();
targets[2] = None;
targets[3] = None;
let err = set
.update_object_meta(bucket, object, update, &targets)
.await
.expect_err("two of four disks cannot reach write quorum");
assert_eq!(err, DiskError::ErasureWriteQuorum);
let (after, _) = SetDisks::read_all_fileinfo(&disks, "", bucket, object, "", false, false, false)
.await
.expect("metadata should stay readable");
assert!(
after.iter().all(|fi| !fi.metadata.contains_key("x-amz-meta-probe")),
"a refused update must not reach any disk"
);
}
#[test]
fn test_latest_fileinfo_selection_quorum_keeps_read_quorum_for_partial_overwrite_with_read_error() {
fn test_latest_fileinfo_selection_keeps_older_majority_over_partial_overwrite() {
let old_mod_time = OffsetDateTime::now_utc();
let new_mod_time = old_mod_time + time::Duration::seconds(1);
let old_data_dir = Uuid::new_v4();
@@ -12023,26 +12129,15 @@ mod tests {
];
let errs = vec![None, None, None, Some(DiskError::DiskNotFound)];
let quorum = SetDisks::latest_fileinfo_selection_quorum("", &metas, &errs, 2, 3);
let (online_disks, mod_time, etag) = SetDisks::list_online_disks(&vec![None; metas.len()], &metas, &errs, quorum);
let fi = SetDisks::pick_valid_fileinfo(&metas, mod_time, etag, quorum)
.expect("old metadata should remain readable with read quorum");
assert_eq!(quorum, 2);
assert_eq!(online_disks.len(), metas.len());
assert_eq!(fi.data_dir, Some(old_data_dir));
assert_eq!(fi.parts[0].etag, "part-etag-old");
let (_, selected, selected_quorum) = SetDisks::select_valid_fileinfo(&vec![None; metas.len()], &metas, &errs, "", 2, 3)
let (_, selected) = SetDisks::select_valid_fileinfo(&vec![None; metas.len()], &metas, &errs, 2)
.expect("old metadata should remain selectable with read quorum");
assert_eq!(selected_quorum, 2);
assert_eq!(selected.data_dir, Some(old_data_dir));
assert_eq!(selected.parts[0].etag, "part-etag-old");
assert!(selected.is_latest);
}
#[test]
fn test_latest_fileinfo_selection_rejects_partial_latest_read_quorum_with_read_error() {
fn test_latest_fileinfo_selection_serves_newer_read_quorum_with_read_error() {
let old_mod_time = OffsetDateTime::now_utc();
let new_mod_time = old_mod_time + time::Duration::seconds(1);
let old_data_dir = Uuid::new_v4();
@@ -12055,9 +12150,120 @@ mod tests {
];
let errs = vec![None, None, None, Some(DiskError::DiskNotFound)];
let result = SetDisks::select_valid_fileinfo(&vec![None; metas.len()], &metas, &errs, "", 2, 3);
// The new version may hold write quorum through the missing disk; the
// old one cannot, and heal converges the set to the new version.
let (_, selected) = SetDisks::select_valid_fileinfo(&vec![None; metas.len()], &metas, &errs, 2)
.expect("the newer version reaches read quorum");
assert!(matches!(result, Err(DiskError::ErasureReadQuorum)));
assert_eq!(selected.data_dir, Some(new_data_dir));
assert_eq!(selected.mod_time, Some(new_mod_time));
}
#[test]
fn test_latest_fileinfo_selection_serves_newer_side_of_complete_split() {
let old_mod_time = OffsetDateTime::now_utc();
let new_mod_time = old_mod_time + time::Duration::seconds(1);
let old_data_dir = Uuid::new_v4();
let new_data_dir = Uuid::new_v4();
let old = |index| quorum_test_fileinfo(old_mod_time, old_data_dir, "part-etag-old", index);
let new = |index| quorum_test_fileinfo(new_mod_time, new_data_dir, "part-etag-new", index);
let errs = vec![None, None, None, None];
for metas in [vec![old(1), old(2), new(3), new(4)], vec![new(1), old(2), new(3), old(4)]] {
let (_, selected) = SetDisks::select_valid_fileinfo(&vec![None; metas.len()], &metas, &errs, 2)
.expect("a 2+2 split with every disk answering serves the newer version");
assert_eq!(selected.data_dir, Some(new_data_dir));
assert_eq!(selected.parts[0].etag, "part-etag-new");
assert!(selected.is_latest);
}
}
#[test]
fn test_system_bucket_reads_keep_the_commit_proof_rule() {
let old_mod_time = OffsetDateTime::now_utc();
let new_mod_time = old_mod_time + time::Duration::seconds(1);
let old_data_dir = Uuid::new_v4();
let new_data_dir = Uuid::new_v4();
let old = |index| quorum_test_fileinfo(old_mod_time, old_data_dir, "part-etag-old", index);
let new = |index| quorum_test_fileinfo(new_mod_time, new_data_dir, "part-etag-new", index);
// A PREPARE/COMMIT split of pool metadata must stay unreadable so that
// native recovery, not an ordinary read, decides whether it committed.
for (metas, errs) in [
(vec![old(1), old(2), new(3), new(4)], vec![None, None, None, None]),
(
vec![new(1), new(2), old(3), FileInfo::default()],
vec![None, None, None, Some(DiskError::DiskNotFound)],
),
] {
let disks = vec![None; metas.len()];
let (_, served) = SetDisks::select_fileinfo_for_read("bucket", &disks, &metas, &errs, "", 2, 3)
.expect("an S3 read serves the newer side at read quorum");
assert_eq!(served.data_dir, Some(new_data_dir));
let internal = SetDisks::select_fileinfo_for_read(crate::disk::RUSTFS_META_BUCKET, &disks, &metas, &errs, "", 2, 3);
assert!(matches!(internal, Err(DiskError::ErasureReadQuorum)));
}
let committed = vec![new(1), new(2), new(3), old(4)];
let (_, internal) = SetDisks::select_fileinfo_for_read(
crate::disk::RUSTFS_META_BUCKET,
&vec![None; committed.len()],
&committed,
&[None, None, None, None],
"",
2,
3,
)
.expect("a write-quorum version stays readable in the system bucket");
assert_eq!(internal.data_dir, Some(new_data_dir));
}
#[test]
fn test_latest_fileinfo_selection_picks_version_stack_shape_in_disk_order() {
let mod_time = OffsetDateTime::now_utc();
let data_dir = Uuid::new_v4();
let successor = mod_time + time::Duration::seconds(1);
let shape = |index, successor_mod_time: Option<OffsetDateTime>, num_versions| {
let mut fi = quorum_test_fileinfo(mod_time, data_dir, "part-etag", index);
fi.successor_mod_time = successor_mod_time;
fi.is_latest = successor_mod_time.is_none();
fi.num_versions = num_versions;
fi
};
let errs = vec![None, None, None, None];
for (metas, expected) in [
(
vec![
shape(1, Some(successor), 2),
shape(2, None, 1),
shape(3, Some(successor), 2),
shape(4, None, 1),
],
Some(successor),
),
(
vec![
shape(1, None, 1),
shape(2, Some(successor), 2),
shape(3, None, 1),
shape(4, Some(successor), 2),
],
None,
),
] {
// Each call hashes into fresh maps; a map-order choice would flip.
for _ in 0..32 {
let (_, selected) = SetDisks::select_valid_fileinfo(&vec![None; metas.len()], &metas, &errs, 2)
.expect("one content identity reaches read quorum");
assert_eq!(selected.successor_mod_time, expected);
assert_eq!(selected.is_latest, expected.is_none());
assert_eq!(selected.num_versions, if expected.is_none() { 1 } else { 2 });
}
}
}
#[test]
@@ -12075,11 +12281,9 @@ mod tests {
let metas = vec![first, second, FileInfo::default(), FileInfo::default()];
let errs = vec![None, None, Some(DiskError::DiskNotFound), Some(DiskError::DiskNotFound)];
let (_, mut selected, selected_quorum) =
SetDisks::select_valid_fileinfo(&vec![None; metas.len()], &metas, &errs, "", 2, 3)
.expect("read quorum should remain enough when no competing latest is visible");
let (_, mut selected) = SetDisks::select_valid_fileinfo(&vec![None; metas.len()], &metas, &errs, 2)
.expect("read quorum should remain enough when no competing latest is visible");
assert_eq!(selected_quorum, 2);
assert_eq!(selected.data_dir, Some(data_dir));
assert_eq!(selected.parts[0].etag, "part-etag-old");
assert!(selected.parts[0].checksums.is_none());
@@ -12106,7 +12310,7 @@ mod tests {
let metas = vec![first, second, FileInfo::default(), FileInfo::default()];
let errs = vec![None, None, Some(DiskError::DiskNotFound), Some(DiskError::DiskNotFound)];
let (_, mut selected, _) = SetDisks::select_valid_fileinfo(&vec![None; metas.len()], &metas, &errs, "", 2, 3)
let (_, mut selected) = SetDisks::select_valid_fileinfo(&vec![None; metas.len()], &metas, &errs, 2)
.expect("winner selection should defer sidecar decoding");
let err = SetDisks::hydrate_selected_fileinfo_part_checksums(&mut selected)
.expect_err("a malformed degraded winner must fail closed when checksums are requested");
@@ -12173,10 +12377,9 @@ mod tests {
let metas = vec![latest_meta, stale_stack_meta, newer_stack_meta, FileInfo::default()];
let errs = vec![None, None, None, Some(DiskError::DiskNotFound)];
let (_, selected, selected_quorum) = SetDisks::select_valid_fileinfo(&vec![None; metas.len()], &metas, &errs, "", 2, 3)
let (_, selected) = SetDisks::select_valid_fileinfo(&vec![None; metas.len()], &metas, &errs, 2)
.expect("same object version should stay readable despite derived version stack drift");
assert_eq!(selected_quorum, 3);
assert_eq!(selected.data_dir, Some(data_dir));
assert_eq!(selected.parts[0].etag, "part-etag");
assert_eq!(selected.mod_time, Some(mod_time));
@@ -12269,81 +12472,77 @@ mod tests {
let metas = vec![meta_a, meta_b, meta_c, FileInfo::default()];
let errs = vec![None, None, None, Some(DiskError::DiskNotFound)];
let (_, selected, selected_quorum) = SetDisks::select_valid_fileinfo(&vec![None; metas.len()], &metas, &errs, "", 2, 3)
let (_, selected) = SetDisks::select_valid_fileinfo(&vec![None; metas.len()], &metas, &errs, 2)
.expect("replication status drift should not split readable object identity");
assert_eq!(selected_quorum, 3);
assert_eq!(selected.data_dir, Some(data_dir));
assert_eq!(selected.parts[0].etag, "part-etag");
}
#[test]
fn test_latest_fileinfo_selection_rejects_same_modtime_metadata_split_without_write_quorum() {
fn test_latest_fileinfo_selection_serves_same_time_metadata_majority() {
let mod_time = OffsetDateTime::now_utc();
let data_dir = Uuid::new_v4();
let mut old_meta_a = quorum_test_fileinfo(mod_time, data_dir, "part-etag", 1);
let mut old_meta_b = quorum_test_fileinfo(mod_time, data_dir, "part-etag", 2);
let mut partial_meta = quorum_test_fileinfo(mod_time, data_dir, "part-etag", 3);
old_meta_a.metadata.insert("x-amz-meta-color".to_string(), "blue".to_string());
old_meta_b.metadata.insert("x-amz-meta-color".to_string(), "blue".to_string());
partial_meta
.metadata
.insert("x-amz-meta-color".to_string(), "red".to_string());
let metas = vec![old_meta_a, old_meta_b, partial_meta, FileInfo::default()];
let colored = |index, color: &str| {
let mut fi = quorum_test_fileinfo(mod_time, data_dir, "part-etag", index);
fi.metadata.insert("x-amz-meta-color".to_string(), color.to_string());
fi
};
let errs = vec![None, None, None, Some(DiskError::DiskNotFound)];
let quorum = SetDisks::latest_fileinfo_selection_quorum("", &metas, &errs, 2, 3);
let result = SetDisks::select_valid_fileinfo(&vec![None; metas.len()], &metas, &errs, "", 2, 3);
// With one disk missing, only the two-copy side can hold write quorum,
// whichever disk order the copies arrive in.
for (metas, expected) in [
(
vec![colored(1, "blue"), colored(2, "blue"), colored(3, "red"), FileInfo::default()],
"blue",
),
(vec![colored(1, "blue"), colored(2, "red"), colored(3, "red"), FileInfo::default()], "red"),
] {
let (_, selected) = SetDisks::select_valid_fileinfo(&vec![None; metas.len()], &metas, &errs, 2)
.expect("the read-quorum side of a same-time metadata split is served");
assert_eq!(quorum, 2);
assert!(matches!(result, Err(DiskError::ErasureReadQuorum)));
assert_eq!(selected.metadata.get("x-amz-meta-color").map(String::as_str), Some(expected));
}
}
#[test]
fn test_latest_fileinfo_selection_rejects_same_modtime_partial_metadata_read_quorum() {
fn test_latest_fileinfo_selection_serves_same_time_transition_majority() {
let mod_time = OffsetDateTime::now_utc();
let data_dir = Uuid::new_v4();
let mut old_meta = quorum_test_fileinfo(mod_time, data_dir, "part-etag", 1);
let mut partial_meta_a = quorum_test_fileinfo(mod_time, data_dir, "part-etag", 2);
let mut partial_meta_b = quorum_test_fileinfo(mod_time, data_dir, "part-etag", 3);
old_meta.metadata.insert("x-amz-meta-color".to_string(), "blue".to_string());
partial_meta_a
.metadata
.insert("x-amz-meta-color".to_string(), "red".to_string());
partial_meta_b
.metadata
.insert("x-amz-meta-color".to_string(), "red".to_string());
let metas = vec![old_meta, partial_meta_a, partial_meta_b, FileInfo::default()];
let local = |index| quorum_test_fileinfo(mod_time, data_dir, "part-etag", index);
let remote = |index| {
let mut fi = quorum_test_fileinfo(mod_time, data_dir, "part-etag", index);
fi.transition_status = TRANSITION_COMPLETE.to_string();
fi.transition_tier = "WARM".to_string();
fi.transitioned_objname = "remote/object".to_string();
fi.transition_version_id = Some(Uuid::from_u128(7));
fi
};
let errs = vec![None, None, None, Some(DiskError::DiskNotFound)];
let result = SetDisks::select_valid_fileinfo(&vec![None; metas.len()], &metas, &errs, "", 2, 3);
for (metas, expect_remote) in [
(vec![local(1), local(2), remote(3), FileInfo::default()], false),
(vec![local(1), remote(2), remote(3), FileInfo::default()], true),
] {
let (_, selected) = SetDisks::select_valid_fileinfo(&vec![None; metas.len()], &metas, &errs, 2)
.expect("the read-quorum side of a same-time transition split is served");
assert!(matches!(result, Err(DiskError::ErasureReadQuorum)));
assert_eq!(selected.is_remote(), expect_remote);
}
for (tied, expect_remote) in [
(vec![local(1), local(2), remote(3), remote(4)], false),
(vec![remote(1), local(2), local(3), remote(4)], true),
] {
let (_, selected) = SetDisks::select_valid_fileinfo(&vec![None; tied.len()], &tied, &[None, None, None, None], 2)
.expect("a same-time half split serves the side on the lowest-index disk");
assert_eq!(selected.is_remote(), expect_remote);
}
}
#[test]
fn test_latest_fileinfo_selection_rejects_same_modtime_transition_split_without_write_quorum() {
let mod_time = OffsetDateTime::now_utc();
let data_dir = Uuid::new_v4();
let old_meta_a = quorum_test_fileinfo(mod_time, data_dir, "part-etag", 1);
let old_meta_b = quorum_test_fileinfo(mod_time, data_dir, "part-etag", 2);
let mut partial_meta = quorum_test_fileinfo(mod_time, data_dir, "part-etag", 3);
partial_meta.transition_status = TRANSITION_COMPLETE.to_string();
partial_meta.transition_tier = "WARM".to_string();
partial_meta.transitioned_objname = "remote/object".to_string();
partial_meta.transition_version_id = Some(Uuid::new_v4());
let metas = vec![old_meta_a, old_meta_b, partial_meta, FileInfo::default()];
let errs = vec![None, None, None, Some(DiskError::DiskNotFound)];
let quorum = SetDisks::latest_fileinfo_selection_quorum("", &metas, &errs, 2, 3);
let result = SetDisks::select_valid_fileinfo(&vec![None; metas.len()], &metas, &errs, "", 2, 3);
assert_eq!(quorum, 2);
assert!(matches!(result, Err(DiskError::ErasureReadQuorum)));
}
#[test]
fn test_latest_fileinfo_selection_quorum_uses_write_quorum_for_degraded_committed_identity() {
fn test_latest_fileinfo_selection_serves_degraded_committed_identity() {
let mod_time = OffsetDateTime::now_utc();
let data_dir = Uuid::new_v4();
let metas = vec![
@@ -12354,7 +12553,10 @@ mod tests {
];
let errs = vec![None, None, None, Some(DiskError::DiskNotFound)];
assert_eq!(SetDisks::latest_fileinfo_selection_quorum("", &metas, &errs, 2, 3), 3);
let (_, selected) = SetDisks::select_valid_fileinfo(&vec![None; metas.len()], &metas, &errs, 2)
.expect("a committed identity stays readable with one disk missing");
assert_eq!(selected.parts[0].etag, "part-etag-a");
}
#[test]
+228 -30
View File
@@ -21,19 +21,22 @@
use super::super::{
Arc, DiskError, DiskInfo, DiskInfoOptions, DiskOption, DiskStore, Endpoint, Error, FormatV3, HealChannelPriority, LockResult,
NamespaceLock, NamespaceLockWrapper, ObjectKey, Result, SetDisks, StorageError, debug, disk, info, load_format_erasure,
send_heal_replacement_disk, warn,
NamespaceLock, NamespaceLockWrapper, ObjectKey, Result, SetDisks, StorageError, debug, disk, load_format_erasure, warn,
};
use crate::disk::health_state::DriveMembershipSnapshot;
use crate::disk::{DiskAPI, new_disk};
use crate::layout::disks_layout::MAX_ERASURE_SET_DRIVE_COUNT;
use crate::runtime::sources as runtime_sources;
use futures::{StreamExt, stream};
use futures::{StreamExt, stream, stream::FuturesUnordered};
use rand::prelude::SliceRandom;
use rustfs_heal_contracts::heal_channel::{create_auto_replacement_disks_request, send_heal_request};
use rustfs_utils::path::object_name_disk_identity;
#[cfg(test)]
use uuid::Uuid;
// Bound batching when another reconnect is still pending.
const REPLACEMENT_BATCH_WAIT: std::time::Duration = std::time::Duration::from_secs(1);
#[async_trait::async_trait]
impl crate::storage_api_contracts::namespace::NamespaceLocking for SetDisks {
type Error = Error;
@@ -329,33 +332,82 @@ impl SetDisks {
async fn connect_disks_with_active_monitoring(&self, active_monitoring: bool) {
let disks = self.get_disks_internal().await;
// A slow failed endpoint must not hold back other returning drives.
// Keep the fanout within one set and release the inventory lock before I/O.
stream::iter(disks.into_iter().enumerate())
.for_each_concurrent(MAX_ERASURE_SET_DRIVE_COUNT, |(i, opdisk)| async move {
// Reconnect drives concurrently without holding the inventory lock.
let mut reconnects = stream::iter(disks.into_iter().enumerate())
.map(|(i, opdisk)| async move {
if let Some(disk) = opdisk {
if !active_monitoring
&& disk.get_disk_location().set_idx.is_some()
&& let disk::Disk::Local(local) = disk.as_ref()
&& local.can_skip_background_probe().await
{
return;
return None;
}
if disk.is_online().await && disk.get_disk_location().set_idx.is_some() {
return;
return None;
}
let _ = disk.close().await;
}
if let Some(endpoint) = self.set_endpoints.get(i) {
self.renew_disk(endpoint).await;
}
let endpoint = self.set_endpoints.get(i)?;
self.reconnect_disk(endpoint).await.then(|| endpoint.to_string())
})
.await;
.buffer_unordered(MAX_ERASURE_SET_DRIVE_COUNT);
let mut submissions = FuturesUnordered::new();
let mut batch = Vec::new();
let mut deadline = None;
let mut reconnects_done = false;
loop {
if reconnects_done && submissions.is_empty() {
break;
}
tokio::select! {
result = reconnects.next(), if !reconnects_done => match result {
Some(Some(endpoint)) => {
batch.push(endpoint);
deadline.get_or_insert_with(|| tokio::time::Instant::now() + REPLACEMENT_BATCH_WAIT);
}
Some(None) => {}
None => {
reconnects_done = true;
if !batch.is_empty() {
submissions.push(self.submit_replacement_heal(std::mem::take(&mut batch)));
}
deadline = None;
}
},
_ = tokio::time::sleep_until(deadline.unwrap_or_else(tokio::time::Instant::now)), if deadline.is_some() => {
submissions.push(self.submit_replacement_heal(std::mem::take(&mut batch)));
deadline = None;
}
_ = submissions.next(), if !submissions.is_empty() => {}
}
}
}
async fn submit_replacement_heal(&self, replacements: Vec<String>) {
if replacements.is_empty() {
return;
}
// Queue admission deduplicates by set, so publish ready targets together.
let request = create_auto_replacement_disks_request(
self.pool_index,
self.set_index,
replacements,
Some(HealChannelPriority::Normal),
);
let _ = send_heal_request(request).await;
}
pub async fn renew_disk(&self, ep: &Endpoint) {
if self.reconnect_disk(ep).await {
self.submit_replacement_heal(vec![ep.to_string()]).await;
}
}
/// Return whether this reconnect attached a verified unformatted target.
async fn reconnect_disk(&self, ep: &Endpoint) -> bool {
debug!("renew_disk: start {:?}", ep);
let previous_disk = {
@@ -375,28 +427,17 @@ impl SetDisks {
Err(e) => {
warn!("renew_disk: connect_endpoint err {:?}", &e);
if !matches!(e, DiskError::UnformattedDisk | DiskError::Io(_)) {
return;
return false;
}
let attached = match self.attach_unformatted_replacement_disk(ep).await {
Ok(attached) => attached,
Err(err) => {
warn!(endpoint = %ep, error = ?err, "renew_disk: unformatted replacement probe failed");
return;
return false;
}
};
if !attached {
return;
}
info!("renew_disk attached unformatted replacement and will trigger heal_disk, {:?}", ep);
let (Ok(pool_index), Ok(set_index)) = (usize::try_from(ep.pool_idx), usize::try_from(ep.set_idx)) else {
warn!("renew_disk: replacement target has invalid pool or set index, {:?}", ep);
return;
};
let _ =
send_heal_replacement_disk(pool_index, set_index, ep.to_string(), Some(HealChannelPriority::Normal)).await;
return;
return attached;
}
};
@@ -404,7 +445,7 @@ impl SetDisks {
Ok(res) => res,
Err(e) => {
warn!("renew_disk: find_disk_index err {:?}", e);
return;
return false;
}
};
@@ -422,7 +463,7 @@ impl SetDisks {
expected_set_index = self.set_index,
"renew_disk rejected a drive whose endpoint and format do not identify the same topology slot"
);
return;
return false;
}
let _ = new_disk.set_disk_id(Some(fm.erasure.this)).await;
@@ -442,7 +483,7 @@ impl SetDisks {
new_disk.enable_health_check();
*slot = Some(new_disk);
}
return;
return false;
}
new_disk.enable_health_check();
@@ -464,6 +505,7 @@ impl SetDisks {
let mut disk_lock = self.disks.write().await;
disk_lock[disk_idx] = Some(new_disk);
false
}
/// Attach a replacement target only after proving that the exact local slot
@@ -1102,6 +1144,162 @@ mod tests {
drop(temp_dirs);
}
#[tokio::test]
async fn reconnect_sweep_collects_all_blank_targets_and_excludes_original_disks() {
use rustfs_heal_contracts::heal_channel::{HealAdmissionResult, HealChannelCommand, init_heal_channel};
const CHILD: &str = "RUSTFS_TEST_GROUPED_RECONNECT_CHILD";
if std::env::var_os(CHILD).is_none() {
// The heal sender is process-global; keep this receiver isolated.
let output = std::process::Command::new(std::env::current_exe().expect("test executable"))
.args([
"--exact",
"set_disk::ops::locking::tests::reconnect_sweep_collects_all_blank_targets_and_excludes_original_disks",
"--nocapture",
])
.env(CHILD, "1")
.output()
.expect("isolated reconnect test should run");
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(output.status.success(), "isolated reconnect test failed: {stdout} {stderr}");
assert!(stdout.contains("1 passed"), "child must execute the reconnect test: {stdout}");
return;
}
let mut heal_rx = init_heal_channel().expect("isolated heal receiver");
let disk_count = 4;
let format = FormatV3::new(1, disk_count);
let mut directories = Vec::new();
let mut endpoints = Vec::new();
let mut disks = Vec::new();
for disk_idx in 0..2 {
let (directory, endpoint, disk) = make_formatted_local_disk(disk_idx, &format).await;
directories.push(directory);
endpoints.push(endpoint);
disks.push(Some(disk));
}
for disk_idx in 2..disk_count {
let directory = tempfile::tempdir().expect("replacement directory");
let mut endpoint =
Endpoint::try_from(directory.path().to_str().expect("UTF-8 replacement path")).expect("replacement endpoint");
endpoint.set_pool_index(0);
endpoint.set_set_index(0);
endpoint.set_disk_index(disk_idx);
directories.push(directory);
endpoints.push(endpoint);
disks.push(None);
}
let mut ready_targets = endpoints[2..].iter().map(ToString::to_string).collect::<Vec<_>>();
ready_targets.sort_unstable();
// Disk 1 returns with its format and data; disks 2 and 3 are blank.
disks[1]
.take()
.expect("original disk handle")
.close()
.await
.expect("close original disk");
let retained_data = directories[1].path().join("retained-data");
tokio::fs::write(&retained_data, b"original data")
.await
.expect("write original data");
let set = Arc::new(
SetDisks::new(
"reconnect-sweep".to_string(),
Arc::new(RwLock::new(disks)),
disk_count,
2,
0,
0,
endpoints.clone(),
format,
Vec::new(),
)
.await,
);
// Block disk 0's format check, then make it a late blank replacement.
let slow = set.get_disks_internal().await[0].clone().expect("original disk");
assert!(slow.is_online().await, "initialize original disk identity");
let disk::Disk::Local(wrapper) = slow.as_ref() else {
panic!("fixture must use a local disk");
};
let slow_local = wrapper.get_disk();
let mut slow_format_guard = slow_local.format_info.write().await;
slow_format_guard.last_check = None;
std::fs::remove_file(
directories[0]
.path()
.join(disk::RUSTFS_META_BUCKET)
.join(disk::FORMAT_CONFIG_FILE),
)
.expect("make the blocked disk a blank replacement");
let sweep = tokio::spawn({
let set = set.clone();
async move { set.connect_disks_with_active_monitoring(true).await }
});
tokio::time::timeout(std::time::Duration::from_secs(10), async {
loop {
let attached = set.get_disks_internal().await;
if attached[1..].iter().all(Option::is_some) {
break;
}
tokio::task::yield_now().await;
}
})
.await
.expect("other drives must reconnect while the first drive is blocked");
let command = tokio::time::timeout(std::time::Duration::from_secs(10), heal_rx.recv())
.await
.expect("ready targets must be submitted while a drive is blocked")
.expect("heal receiver should remain open");
let HealChannelCommand::Start { request, response_tx } = command else {
panic!("expected a replacement heal request");
};
assert_eq!(request.heal_endpoints, ready_targets, "ready blank disks must share one request");
assert!(!sweep.is_finished(), "the blocked drive must still be pending");
drop(slow_format_guard);
// Do not acknowledge the first request yet: a delayed admission
// response must not stop the late drive reconnecting and submitting.
let late_command = tokio::time::timeout(std::time::Duration::from_secs(10), heal_rx.recv())
.await
.expect("late target must be submitted while the first response is pending")
.expect("heal receiver should remain open");
let HealChannelCommand::Start {
request: late_request,
response_tx: late_response,
} = late_command
else {
panic!("expected a late replacement heal request");
};
assert_eq!(late_request.heal_endpoints, [endpoints[0].to_string()]);
response_tx
.send(Ok(HealAdmissionResult::Accepted))
.expect("acknowledge ready targets");
late_response
.send(Ok(HealAdmissionResult::Accepted))
.expect("acknowledge late target");
sweep.await.expect("reconnect sweep should finish");
assert!(heal_rx.try_recv().is_err(), "no duplicate request should be sent");
let attached = set.get_disks_internal().await;
for (index, disk) in attached.iter().enumerate() {
let disk = disk.as_ref().expect("each verified endpoint should be attached");
let format = load_format_erasure(disk, false).await;
if index == 1 {
assert!(format.is_ok(), "original disk must retain its format");
} else {
assert_eq!(format.expect_err("replacement must remain unformatted"), DiskError::UnformattedDisk);
assert!(!disk.health_check_enabled_for_test(), "format must precede replacement health checks");
}
disk.close().await.expect("close test disk");
}
assert_eq!(tokio::fs::read(&retained_data).await.expect("read original data"), b"original data");
let local_map = runtime_sources::local_disk_map_handle();
let mut local_map = local_map.write().await;
for endpoint in endpoints {
local_map.remove(&endpoint.to_string());
}
}
#[tokio::test]
async fn renew_disk_attaches_only_a_verified_local_unformatted_replacement() {
let disk_count = 4;
+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!(
+105 -56
View File
@@ -8839,7 +8839,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();
@@ -9294,36 +9294,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_valid_fileinfo(&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 +9481,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]))?;
@@ -11147,44 +11177,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_valid_fileinfo(&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]));
}
File diff suppressed because it is too large Load Diff
+8
View File
@@ -448,6 +448,14 @@ impl ECStore {
await_orphan_bucket_recovery(bucket, &publication_guard, &lifecycle_guard, &metadata_guard, &ns_guard, recover).await
}
/// Read quota and bucket identity from one authoritative metadata snapshot.
pub async fn bucket_quota_and_incarnation_from_disk(
&self,
bucket: &str,
) -> Result<(Option<crate::bucket::quota::BucketQuota>, Uuid, OffsetDateTime)> {
metadata_sys::get_quota_config_and_incarnation_from_disk_in(&self.ctx, bucket).await
}
pub async fn get_bucket_metadata(&self, bucket: &str) -> Result<Arc<BucketMetadata>> {
let sys = metadata_sys::require_bucket_metadata_sys_in(&self.ctx)?;
sys.read().await.get(bucket).await
+466 -226
View File
@@ -22,9 +22,7 @@ fn to_filemeta_err(err: Error) -> rustfs_filemeta::Error {
}
use crate::bucket::lifecycle::bucket_lifecycle_ops::free_version_remote_tuple_matches;
use crate::bucket::metadata_sys::{
get_versioning_config, has_authoritative_never_versioned_state, has_authoritative_never_versioned_state_in,
};
use crate::bucket::metadata_sys::{get_versioning_config, has_authoritative_never_versioned_state};
use crate::bucket::utils::{check_list_objs_args, has_bad_path_component};
use crate::bucket::versioning::VersioningApi;
use crate::cache_value::metacache_set::{
@@ -373,49 +371,6 @@ async fn can_skip_hidden_prefix_check(options: &ListPathOptions) -> bool {
.unwrap_or(false)
}
/// Whether an empty listing of `prefix` is the proof that lets the caller
/// reclaim delete residue under it. Any first page that scanned the whole
/// prefix without finding an object or a sub-prefix qualifies, with or without
/// a delimiter: that is the shape clients issue when they stat, browse, or
/// recursively remove a phantom folder. The purge itself re-verifies every
/// directory on every disk before deleting anything.
fn should_purge_empty_directory_listing(
prefix: &str,
marker: Option<&str>,
max_keys: i32,
incl_deleted: bool,
result: &ListObjectsInfo,
) -> bool {
// list_path rejects leading slashes, so an empty result cannot justify cleanup.
!prefix.is_empty()
&& !prefix.starts_with(SLASH_SEPARATOR)
&& prefix.ends_with(SLASH_SEPARATOR)
&& marker.is_none()
&& max_keys > 0
&& !incl_deleted
&& !result.is_truncated
&& result.objects.is_empty()
&& result.prefixes.is_empty()
}
fn should_purge_empty_recursive_bucket_listing(
prefix: &str,
delimiter: Option<&str>,
marker: Option<&str>,
max_keys: i32,
incl_deleted: bool,
result: &ListObjectsInfo,
) -> bool {
prefix.is_empty()
&& delimiter.is_none()
&& marker.is_none()
&& max_keys > 0
&& !incl_deleted
&& !result.is_truncated
&& result.objects.is_empty()
&& result.prefixes.is_empty()
}
const MARKER_TAG_VERSION: &str = "v2";
const LEGACY_MARKER_TAG_VERSIONS: &[&str] = &["v1", MARKER_TAG_VERSION];
const LIST_CACHE_MARKER_PREFIX: &str = "[rustfs_cache:";
@@ -552,18 +507,6 @@ impl ListContinuationV2 {
}
}
/// Returns the key part of a marker that may still carry the legacy cursor tag.
/// The data-usage scanner compares successive version-listing markers through
/// it so two differently tagged markers for the same key never count as progress.
// RUSTFS_COMPAT_TODO(listing-cursor-legacy-tag): markers tagged by 1.0.x servers. Remove after every supported
// direct-upgrade release issues plain key markers; callers can then compare markers directly.
pub(crate) fn list_marker_key(marker: &str) -> &str {
marker
.rfind(LIST_CACHE_MARKER_PREFIX)
.filter(|start_idx| marker[*start_idx..].ends_with(']'))
.map_or(marker, |start_idx| &marker[..start_idx])
}
fn normalize_list_quorum(value: &str) -> &'static str {
let value = value.trim();
if value.eq_ignore_ascii_case("disk") {
@@ -631,8 +574,7 @@ fn versioned_prefix_page_boundary_key<'a>(
/// old-readable escape until legacy tag parsing can be removed.
// RUSTFS_COMPAT_TODO(listing-cursor-legacy-tag): Remove after the minimum upgrade source stops issuing tagged cursors.
fn escape_list_marker(marker: String) -> String {
// Both the legacy request parser and scanner cursor comparison recognize
// cache-shaped suffixes. One old-readable outer tag protects the literal key.
// One old-readable outer tag protects a literal cache-shaped key.
if marker
.rfind(LIST_CACHE_MARKER_PREFIX)
.is_some_and(|start| marker[start..].contains(']'))
@@ -669,9 +611,9 @@ fn build_list_versions_next_marker(objects: &[ObjectInfo], prefixes: &[String])
.unwrap_or_else(|| "null".to_string()),
)
};
(Some(escape_list_marker(last.name.clone())), version_marker)
(Some(last.name.clone()), version_marker)
} else if let Some(last_prefix) = prefixes.last() {
(Some(escape_list_marker(last_prefix.clone())), None)
(Some(last_prefix.clone()), None)
} else {
(None, None)
}
@@ -734,7 +676,7 @@ fn list_objects_paginate(
&& let Some(last_scanned) = last_scanned_key
{
is_truncated = true;
next_marker = Some(escape_list_marker(last_scanned.to_owned()));
next_marker = Some(last_scanned.to_owned());
} else if should_truncate {
is_truncated = true;
if include_version_id {
@@ -802,7 +744,7 @@ where
pagination.last_scanned_key,
);
if is_truncated && let Some(raw_key) = prefix_at_page_boundary {
next_marker = Some(escape_list_marker(raw_key));
next_marker = Some(raw_key);
next_version_idmarker = None;
}
@@ -894,10 +836,8 @@ impl ListPathOptions {
/// Releases up to 1.0.x appended `[rustfs_cache:v2,id:...,src:walker,gen:live]`
/// to every internal continuation marker and exposed it through the V2
/// continuation token and the ListObjectVersions `NextKeyMarker`. Current
/// servers emit plain keys, but a client that paginates across the upgrade
/// still sends the tagged form back as `continuation-token`, `key-marker`
/// or `marker`, and the opt-in index provider still carries its `src`/`gen`
/// cursor fields in the same tag.
/// servers still accept the tagged form in V2 continuation tokens and
/// legacy object-list markers. Version-listing key markers are literal keys.
///
/// Newly issued cursors protect cache-shaped literal keys with one legacy
/// outer tag. Parse only at the listing entry point; parsing the resulting
@@ -953,7 +893,9 @@ impl ECStore {
) -> Result<ListObjectsV2Info> {
let marker = {
if continuation_token.is_none() {
start_after
// StartAfter is a literal object key, even if it resembles a
// legacy cursor. Protect it from the compatibility parser.
start_after.map(escape_list_marker)
} else {
continuation_token.clone()
}
@@ -1022,39 +964,15 @@ impl ECStore {
let get_objects =
ObjectInfo::from_meta_cache_entries_sorted_infos(&page.entries, bucket, prefix, delimiter.clone()).await;
let (objects, prefixes, is_truncated, next_marker, next_version_idmarker) =
let (objects, prefixes, is_truncated, next_marker, _) =
list_objects_paginate(get_objects, &delimiter, max_keys, disk_has_more, false, last_scanned_key.as_deref());
let _ = next_version_idmarker;
let result = ListObjectsInfo {
Ok(ListObjectsInfo {
is_truncated,
next_marker,
objects,
prefixes,
};
let purge_exact_prefix =
should_purge_empty_directory_listing(prefix, opts.marker.as_deref(), max_keys, incl_deleted, &result);
let purge_empty_bucket = should_purge_empty_recursive_bucket_listing(
prefix,
delimiter.as_deref(),
opts.marker.as_deref(),
max_keys,
incl_deleted,
&result,
);
if (purge_exact_prefix || purge_empty_bucket)
&& has_authoritative_never_versioned_state_in(&self.ctx, bucket)
.await
.unwrap_or(false)
{
if purge_exact_prefix {
self.purge_orphan_dir_object(bucket, prefix).await;
} else {
self.purge_orphan_dir_objects_in_bucket(bucket).await;
}
}
Ok(result)
})
}
pub async fn inner_list_object_versions(
@@ -1198,7 +1116,7 @@ impl ECStore {
};
let effective_max_keys = list_versions_scan_limit(max_keys, has_version_marker);
let mut opts = ListPathOptions {
let opts = ListPathOptions {
bucket: bucket.to_owned(),
caller: if include_version_purge {
ListObjectsGatherCaller::Lifecycle
@@ -1215,10 +1133,6 @@ impl ECStore {
include_marker: has_version_marker,
..Default::default()
};
// Strip a legacy cursor tag before any name comparison (notably
// `forward_past`), see backlog#1047.
opts.parse_marker();
let mut page = self
.list_path(&opts)
.await
@@ -2416,7 +2330,7 @@ impl SetDisks {
let has_version_marker = version_marker.is_some();
let version_marker = version_marker.map(parse_version_marker).transpose()?;
let effective_max_keys = list_versions_scan_limit(max_keys, has_version_marker);
let mut opts = ListPathOptions {
let opts = ListPathOptions {
bucket: bucket.to_owned(),
prefix: prefix.to_owned(),
limit: effective_max_keys,
@@ -2427,8 +2341,6 @@ impl SetDisks {
include_marker: has_version_marker,
..Default::default()
};
opts.parse_marker();
let mut page = self
.list_path_result(&opts)
.await
@@ -2900,13 +2812,12 @@ mod lifecycle_exact_tests;
#[cfg(test)]
mod test {
use super::{
ENV_API_LIST_OBJECTS_QUORUM, GatherResultsState, ListObjectsInfo, ListPathOptions, ListSourceMode, MAX_OBJECT_LIST,
RUSTFS_META_BUCKET, VersionMarker, gather_results, list_marker_key, list_merged_entry_channel,
list_metadata_resolution_params, list_objects_paginate, list_objects_quorum_from_env, max_keys_plus_one,
merge_entry_channels, normalize_list_quorum, observe_scanner_namespace_mutations, parse_version_marker,
recursive_walk_list_path_options, reset_scanner_namespace_mutation_generation_for_test,
scanner_namespace_mutation_generation, send_or_cancel, should_purge_empty_directory_listing, version_marker_for_entries,
walk_result_from_set_errors,
ENV_API_LIST_OBJECTS_QUORUM, GatherResultsState, ListPathOptions, ListSourceMode, MAX_OBJECT_LIST, RUSTFS_META_BUCKET,
VersionMarker, gather_results, list_merged_entry_channel, list_metadata_resolution_params, list_objects_paginate,
list_objects_quorum_from_env, max_keys_plus_one, merge_entry_channels, normalize_list_quorum,
observe_scanner_namespace_mutations, parse_version_marker, recursive_walk_list_path_options,
reset_scanner_namespace_mutation_generation_for_test, scanner_namespace_mutation_generation, send_or_cancel,
version_marker_for_entries, walk_result_from_set_errors,
};
use crate::bucket::replication::ReplicationState;
use crate::cache_value::metacache_set::ListPathRawOutcome;
@@ -4411,7 +4322,10 @@ mod test {
list_objects_paginate(vec![plain_object(key), plain_object("zz")], &None, 1, false, include_version_id, None);
assert!(truncated);
let marker = marker.expect("truncated page needs a cursor");
assert_eq!(list_marker_key(&marker), key, "scanner must compare the complete literal key");
if include_version_id {
assert_eq!(marker, key, "version markers must be literal keys");
continue;
}
let mut opts = ListPathOptions {
marker: Some(marker),
..Default::default()
@@ -4456,6 +4370,7 @@ mod test {
.expect("seed literal object");
}
let mut marker = None;
let mut v1_marker = None;
let mut key_marker = None;
let mut version_marker = None;
for (index, key) in keys.into_iter().enumerate() {
@@ -4468,6 +4383,16 @@ mod test {
assert_eq!(page.is_truncated, index + 1 < keys.len());
marker = page.next_continuation_token;
// V1 sends the literal NextMarker back as StartAfter.
let v1_page = store
.clone()
.inner_list_objects_v2(bucket, "", None, None, 1, false, v1_marker, false)
.await
.expect("list next object through the V1 marker path");
assert_eq!(v1_page.objects.iter().map(|object| object.name.as_str()).collect::<Vec<_>>(), [key]);
assert_eq!(v1_page.is_truncated, index + 1 < keys.len());
v1_marker = v1_page.is_truncated.then(|| key.to_owned());
let versions = store
.clone()
.inner_list_object_versions(bucket, "", key_marker, version_marker, None, 1)
@@ -4475,11 +4400,44 @@ mod test {
.expect("list next version");
assert_eq!(versions.objects.iter().map(|object| object.name.as_str()).collect::<Vec<_>>(), [key]);
assert_eq!(versions.is_truncated, index + 1 < keys.len());
if versions.is_truncated {
assert_eq!(versions.next_marker.as_deref(), Some(key), "NextKeyMarker must be the literal key");
}
key_marker = versions.next_marker;
version_marker = versions.next_version_idmarker;
}
assert!(marker.is_none());
assert!(v1_marker.is_none());
assert!(key_marker.is_none());
let literal = "b[rustfs_cache:v1,return:]";
let after_literal = store
.clone()
.inner_list_objects_v2(bucket, "", None, None, 1, false, Some(literal.to_owned()), false)
.await
.expect("start after a caller-supplied literal key");
assert_eq!(after_literal.objects[0].name, "c[rustfs_cache:v2,id:x]suffix");
let resumed = store
.clone()
.inner_list_object_versions(bucket, "", Some(literal.to_owned()), None, None, 1)
.await
.expect("resume from a caller-supplied literal key marker");
assert_eq!(resumed.objects[0].name, "c[rustfs_cache:v2,id:x]suffix");
let resumed_version = store
.clone()
.inner_list_object_versions(bucket, "", Some(literal.to_owned()), Some("null".to_owned()), None, 1)
.await
.expect("resume after the literal key's null version");
assert_eq!(resumed_version.objects[0].name, "c[rustfs_cache:v2,id:x]suffix");
let recursive_delete_page = store.pools[0].disk_set[0]
.clone()
.inner_list_object_versions_for_recursive_delete(bucket, "", Some(literal.to_owned()), None, 1)
.await
.expect("resume recursive-delete validation from the literal key");
assert_eq!(recursive_delete_page.objects[0].name, "c[rustfs_cache:v2,id:x]suffix");
}
#[tokio::test]
@@ -4645,6 +4603,32 @@ mod test {
assert!(next_version_idmarker.is_none());
}
#[test]
fn versioned_delimiter_boundaries_preserve_cache_shaped_raw_keys() {
let delimiter = Some("-".to_owned());
let raw_key = "data-0002[rustfs_cache:v2,return:]";
let (_, _, truncated, marker, version_marker) =
list_objects_paginate(vec![folded_prefix_object("data-")], &delimiter, 2, true, true, Some(raw_key));
assert!(truncated);
assert_eq!(marker.as_deref(), Some(raw_key));
assert!(version_marker.is_none());
let (_, _, truncated, marker, version_marker) = super::list_objects_paginate_versions(
vec![folded_prefix_object("data-")],
super::VersionedListPagination {
delimiter: &delimiter,
max_keys: 1,
disk_has_more: true,
marker: None,
last_scanned_key: Some(raw_key),
raw_keys: std::iter::once(raw_key),
},
);
assert!(truncated);
assert_eq!(marker.as_deref(), Some(raw_key));
assert!(version_marker.is_none());
}
// End-to-end simulation of versioned pagination with `max_keys=1`, where the
// next page resumes via a bare key marker (`forward_past` keeps `name > marker`
// only, matching `inner_list_object_versions_with_projection` without a version
@@ -4935,77 +4919,114 @@ mod test {
assert_eq!(scanner_namespace_mutation_generation(), 2);
}
#[test]
fn empty_directory_listing_purge_requires_complete_first_page_of_prefix() {
let empty = ListObjectsInfo::default();
assert!(should_purge_empty_directory_listing("ghost/", None, 1, false, &empty));
assert!(should_purge_empty_directory_listing("ghost/", None, 1000, false, &empty));
assert!(!should_purge_empty_directory_listing("ghost", None, 1, false, &empty));
assert!(!should_purge_empty_directory_listing("ghost/", Some("marker"), 1, false, &empty));
assert!(!should_purge_empty_directory_listing("ghost/", None, 0, false, &empty));
assert!(!should_purge_empty_directory_listing("ghost/", None, 1, true, &empty));
#[tokio::test]
async fn listing_preserves_valid_legacy_residue_until_explicit_delete() {
use crate::bucket::metadata_sys::{init_bucket_metadata_sys, test_support::isolated_store_over_temp_disks};
use crate::storage_api_contracts::bucket::{BucketOperations as _, DeleteBucketOptions, MakeBucketOptions};
let mut live = ListObjectsInfo::default();
live.objects.push(ObjectInfo::default());
assert!(!should_purge_empty_directory_listing("ghost/", None, 1, false, &live));
use crate::storage_api_contracts::object::{ObjectIO as _, ObjectOperations as _};
let mut prefixed = ListObjectsInfo::default();
prefixed.prefixes.push("ghost/child/".to_owned());
assert!(!should_purge_empty_directory_listing("ghost/", None, 1, false, &prefixed));
let (dirs, store) = isolated_store_over_temp_disks().await;
let bucket = "stage-e-exact-delete-legacy";
init_bucket_metadata_sys(store.clone(), Vec::new()).await;
store
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("bucket should be created with authoritative metadata");
let transaction = uuid::Uuid::new_v4();
let mut backups = Vec::new();
for object in ["repro/obj-00001.bin", "repro/obj-00002.bin"] {
let mut reader = crate::object_api::PutObjReader::from_vec(vec![0x62; 1024 * 1024]);
store
.put_object(
bucket,
object,
&mut reader,
&crate::object_api::ObjectOptions {
write_completion: crate::object_api::WriteCompletion::TailDrained,
..Default::default()
},
)
.await
.expect("create genuine per-drive metadata and shards");
for dir in &dirs {
let object_dir = dir.path().join(bucket).join(object);
let mut entries = tokio::fs::read_dir(&object_dir).await.expect("read actual object root");
let mut marked = 0;
while let Some(entry) = entries.next_entry().await.expect("read object entry") {
if uuid::Uuid::parse_str(&entry.file_name().to_string_lossy()).is_ok() {
tokio::fs::write(
entry
.path()
.join(format!("{}{}", crate::disk::local::DELETE_DATA_DIR_MARKER_PREFIX, transaction)),
[],
)
.await
.expect("mark actual data dir as historical delete residue");
marked += 1;
}
}
assert_eq!(marked, 1, "the real shard must have exactly one data directory");
let backup = object_dir.join(transaction.to_string());
tokio::fs::create_dir_all(&backup).await.expect("create legacy rollback root");
tokio::fs::rename(
object_dir.join(crate::disk::STORAGE_FORMAT_FILE),
backup.join(crate::disk::STORAGE_FORMAT_FILE_BACKUP),
)
.await
.expect("retain the actual valid metadata as a historical backup");
let backup = backup.join(crate::disk::STORAGE_FORMAT_FILE_BACKUP);
let bytes = tokio::fs::read(&backup).await.expect("read the historical backup");
backups.push((backup, bytes));
}
}
let truncated = ListObjectsInfo {
is_truncated: true,
..Default::default()
};
assert!(!should_purge_empty_directory_listing("ghost/", None, 1, false, &truncated));
}
let delimited = store
.clone()
.list_objects_generic(bucket, "", None, Some("/".to_owned()), 1000, false)
.await
.expect("delimiter listing should succeed");
assert!(delimited.objects.is_empty());
assert!(delimited.prefixes.is_empty(), "residue must not appear as a common prefix");
#[test]
fn empty_directory_listing_does_not_purge_rejected_leading_slash_prefixes() {
let empty = ListObjectsInfo::default();
for prefix in ["/", "//", "/ghost/"] {
let result = store
.clone()
.list_objects_generic(bucket, "", None, None, 1000, false)
.await
.expect("recursive bucket listing should succeed");
assert!(result.objects.is_empty());
let exact_prefix = store
.clone()
.list_objects_generic(bucket, "repro/", None, None, 1000, false)
.await
.expect("recursive prefix listing should succeed");
assert!(exact_prefix.objects.is_empty());
for (path, bytes) in backups {
assert_eq!(tokio::fs::read(path).await.expect("LIST must preserve backup"), bytes);
}
for dir in &dirs {
assert!(
!should_purge_empty_directory_listing(prefix, None, 1000, false, &empty),
"rejected prefix {prefix:?} must not initiate orphan cleanup"
dir.path().join(bucket).join("repro").exists(),
"LIST must preserve the historical residue"
);
}
assert!(should_purge_empty_directory_listing("ghost/", None, 1000, false, &empty));
}
#[test]
fn recursive_bucket_orphan_purge_requires_an_empty_complete_root_scan() {
let empty = ListObjectsInfo::default();
assert!(super::should_purge_empty_recursive_bucket_listing("", None, None, 1, false, &empty));
assert!(!super::should_purge_empty_recursive_bucket_listing(
"ghost/", None, None, 1, false, &empty
));
assert!(!super::should_purge_empty_recursive_bucket_listing("", Some("/"), None, 1, false, &empty));
assert!(!super::should_purge_empty_recursive_bucket_listing(
"",
None,
Some("marker"),
1,
false,
&empty
));
assert!(!super::should_purge_empty_recursive_bucket_listing("", None, None, 0, false, &empty));
assert!(!super::should_purge_empty_recursive_bucket_listing("", None, None, 1, true, &empty));
let live = ListObjectsInfo {
objects: vec![ObjectInfo::default()],
..Default::default()
};
assert!(!super::should_purge_empty_recursive_bucket_listing("", None, None, 1, false, &live));
let incomplete = ListObjectsInfo {
is_truncated: true,
..Default::default()
};
assert!(!super::should_purge_empty_recursive_bucket_listing("", None, None, 1, false, &incomplete));
for object in ["repro/obj-00001.bin", "repro/obj-00002.bin"] {
store
.delete_object(bucket, object, crate::object_api::ObjectOptions::default())
.await
.expect("fresh exact-key DELETE should reclaim eligible legacy residue");
for dir in &dirs {
assert!(!dir.path().join(bucket).join(object).exists(), "exact DELETE must reclaim the root");
}
}
store
.delete_bucket(bucket, &DeleteBucketOptions::default())
.await
.expect("the bucket is empty once the residue is reclaimed");
}
#[tokio::test]
async fn empty_recursive_listing_purges_committed_delete_residue() {
async fn empty_recursive_listing_preserves_committed_delete_residue() {
use crate::bucket::metadata_sys::{init_bucket_metadata_sys, test_support::isolated_store_over_temp_disks};
use crate::storage_api_contracts::bucket::{BucketOperations as _, MakeBucketOptions};
@@ -5048,20 +5069,73 @@ mod test {
assert!(result.objects.is_empty());
assert!(result.prefixes.is_empty());
for dir in &dirs {
let residue = dir.path().join(bucket).join("ghost/nested/object").join(data_dir.to_string());
assert_eq!(tokio::fs::read(residue.join("part.1")).await.expect("LIST preserves part"), b"stale");
assert!(
!dir.path().join(bucket).join("ghost").exists(),
"the empty listing should reclaim its committed delete residue"
residue
.join(format!("{}{}", crate::disk::local::DELETE_DATA_DIR_MARKER_PREFIX, transaction))
.exists()
);
}
}
// #6898: what an abandoned DeleteObjects left on every disk (rollback
// backup plus marked data dir) no longer blocks DeleteBucket after an empty
// recursive listing of the bucket.
#[tokio::test]
async fn empty_recursive_listing_purges_stranded_delete_rollback_backups() {
async fn large_preserved_residue_does_not_create_a_common_prefix() {
use crate::bucket::metadata_sys::{init_bucket_metadata_sys, test_support::isolated_store_over_temp_disks};
use crate::storage_api_contracts::bucket::{BucketOperations as _, MakeBucketOptions};
let (dirs, store) = isolated_store_over_temp_disks().await;
let bucket = "large-listing-residue";
init_bucket_metadata_sys(store.clone(), Vec::new()).await;
store
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("create bucket");
for object in 0..20 {
let data_dir = uuid::Uuid::new_v4();
let transaction = uuid::Uuid::new_v4();
for dir in &dirs {
let residue = dir
.path()
.join(bucket)
.join(format!("ghost/{object:02}"))
.join(data_dir.to_string());
tokio::fs::create_dir_all(&residue).await.expect("create residue");
tokio::fs::write(residue.join("part.1"), b"stale")
.await
.expect("write residue part");
tokio::fs::write(
residue.join(format!("{}{}", crate::disk::local::DELETE_DATA_DIR_MARKER_PREFIX, transaction)),
[],
)
.await
.expect("write residue marker");
}
}
let recursive = store
.clone()
.list_objects_generic(bucket, "", None, None, 1000, false)
.await
.expect("list recursively");
assert!(recursive.objects.is_empty());
let delimited = store
.list_objects_generic(bucket, "", None, Some("/".to_owned()), 1000, false)
.await
.expect("list with delimiter");
assert!(delimited.objects.is_empty());
assert!(delimited.prefixes.is_empty(), "residue must not produce a common prefix");
for dir in &dirs {
assert!(dir.path().join(bucket).join("ghost/00").exists(), "listing must not remove residue");
}
}
// A corrupt historical backup must survive LIST and a failed exact DELETE.
#[tokio::test]
async fn empty_recursive_listing_preserves_corrupt_delete_rollback_backups() {
use crate::bucket::metadata_sys::{init_bucket_metadata_sys, test_support::isolated_store_over_temp_disks};
use crate::storage_api_contracts::bucket::{BucketOperations as _, DeleteBucketOptions, MakeBucketOptions};
use crate::storage_api_contracts::object::ObjectOperations as _;
let (dirs, store) = isolated_store_over_temp_disks().await;
let bucket = "listing-purge-rollback-backup";
@@ -5105,21 +5179,38 @@ mod test {
.expect("recursive bucket listing should succeed");
assert!(result.objects.is_empty());
for dir in &dirs {
assert!(
!dir.path().join(bucket).join("repro").exists(),
"the empty listing should reclaim the stranded delete residue"
let backup = dir
.path()
.join(bucket)
.join("repro/obj-00001.bin")
.join(transaction.to_string())
.join(crate::disk::STORAGE_FORMAT_FILE_BACKUP);
assert_eq!(tokio::fs::read(backup).await.expect("LIST preserves backup"), b"rollback metadata");
}
let err = store
.delete_object(bucket, "repro/obj-00001.bin", crate::object_api::ObjectOptions::default())
.await
.expect_err("corrupt metadata must not be reclaimed");
assert!(matches!(err, StorageError::FileCorrupt));
assert!(store.delete_bucket(bucket, &DeleteBucketOptions::default()).await.is_err());
for dir in &dirs {
let backup = dir
.path()
.join(bucket)
.join("repro/obj-00001.bin")
.join(transaction.to_string())
.join(crate::disk::STORAGE_FORMAT_FILE_BACKUP);
assert_eq!(
tokio::fs::read(backup).await.expect("failed cleanup preserves backup"),
b"rollback metadata"
);
}
store
.delete_bucket(bucket, &DeleteBucketOptions::default())
.await
.expect("the bucket is empty once the residue is reclaimed");
}
#[tokio::test]
async fn empty_recursive_bucket_listing_purges_orphan_directory_prefixes() {
async fn empty_recursive_bucket_listing_preserves_empty_directory_prefixes() {
use crate::bucket::metadata_sys::{init_bucket_metadata_sys, test_support::isolated_store_over_temp_disks};
use crate::storage_api_contracts::bucket::{BucketOperations as _, MakeBucketOptions};
use crate::storage_api_contracts::bucket::{BucketOperations as _, DeleteBucketOptions, MakeBucketOptions};
let (dirs, store) = isolated_store_over_temp_disks().await;
let bucket = "recursive-bucket-orphan-purge";
@@ -5145,10 +5236,14 @@ mod test {
assert!(result.prefixes.is_empty(), "a delimiter-less listing has no CommonPrefixes");
for dir in &dirs {
assert!(
!dir.path().join(bucket).join("ghost").exists(),
"an empty recursive bucket scan should reclaim its metadata-less orphan prefix"
dir.path().join(bucket).join("ghost/nested/leaf").exists(),
"LIST must preserve empty directories"
);
}
store
.delete_bucket(bucket, &DeleteBucketOptions::default())
.await
.expect("bucket deletion removes empty directories");
let versioned_bucket = "recursive-bucket-versioned-orphan";
store
@@ -5300,7 +5395,7 @@ mod test {
}
#[tokio::test]
async fn empty_delimiter_listing_hides_and_purges_committed_delete_residue() {
async fn empty_delimiter_listing_hides_and_preserves_committed_delete_residue() {
use crate::bucket::metadata_sys::{init_bucket_metadata_sys, test_support::isolated_store_over_temp_disks};
use crate::storage_api_contracts::bucket::{BucketOperations as _, MakeBucketOptions};
@@ -5345,15 +5440,22 @@ mod test {
assert!(result.objects.is_empty());
assert!(result.prefixes.is_empty(), "delete residue must not surface as a prefix");
for dir in &dirs {
let residue = dir
.path()
.join(bucket)
.join("metrics/2026/object.parquet")
.join(data_dir.to_string());
assert_eq!(tokio::fs::read(residue.join("part.1")).await.expect("LIST preserves part"), b"stale");
assert!(
!dir.path().join(bucket).join("metrics").join("2026").exists(),
"the empty delimiter listing should reclaim the committed delete residue under it"
residue
.join(format!("{}{}", crate::disk::local::DELETE_DATA_DIR_MARKER_PREFIX, transaction))
.exists()
);
}
}
#[tokio::test]
async fn empty_delimiter_listing_of_residue_ancestor_hides_and_purges_whole_tree() {
async fn empty_delimiter_listing_of_residue_ancestor_hides_and_preserves_whole_tree() {
use crate::bucket::metadata_sys::{init_bucket_metadata_sys, test_support::isolated_store_over_temp_disks};
use crate::storage_api_contracts::bucket::{BucketOperations as _, MakeBucketOptions};
@@ -5386,9 +5488,7 @@ mod test {
.expect("committed delete marker should be written");
}
// Browsing an ancestor of the deleted key must not show the empty
// date folders, and the empty result reclaims the whole residue tree
// in one pass instead of one level per listing.
// Browsing an ancestor must not expose the deleted key's date folders.
let result = store
.clone()
.list_objects_generic(bucket, "metrics/", None, Some("/".to_owned()), 1000, false)
@@ -5397,15 +5497,22 @@ mod test {
assert!(result.objects.is_empty());
assert!(result.prefixes.is_empty(), "ancestors of delete residue must not surface as prefixes");
for dir in &dirs {
let residue = dir
.path()
.join(bucket)
.join("metrics/kubelet/2026/08/28/23/74992556388248657933757.parquet")
.join(data_dir.to_string());
assert_eq!(tokio::fs::read(residue.join("part.1")).await.expect("LIST preserves part"), b"stale");
assert!(
!dir.path().join(bucket).join("metrics").exists(),
"the empty delimiter listing should reclaim the committed delete residue tree under it"
residue
.join(format!("{}{}", crate::disk::local::DELETE_DATA_DIR_MARKER_PREFIX, transaction))
.exists()
);
}
}
#[tokio::test]
async fn empty_delimiter_listing_of_mixed_residue_ancestor_reclaims_only_committed_subtree() {
async fn empty_delimiter_listing_of_mixed_residue_ancestor_preserves_every_subtree() {
use crate::bucket::metadata_sys::{init_bucket_metadata_sys, test_support::isolated_store_over_temp_disks};
use crate::storage_api_contracts::bucket::{BucketOperations as _, MakeBucketOptions};
@@ -5478,9 +5585,18 @@ mod test {
assert!(result.prefixes.is_empty(), "residue subtrees may not surface as prefixes");
for dir in &dirs {
let metrics = dir.path().join(bucket).join("metrics");
assert!(
!metrics.join("cpu").exists(),
"the committed subtree must be reclaimed even though a sibling subtree is blocked"
let committed = metrics.join("cpu/2026/08/28/22/a.parquet").join(committed_dir.to_string());
assert_eq!(
tokio::fs::read(committed.join("part.1"))
.await
.expect("LIST preserves committed part"),
b"stale"
);
assert_eq!(
tokio::fs::read(committed.join(crate::disk::STORAGE_FORMAT_FILE_BACKUP))
.await
.expect("LIST preserves backup"),
b"rollback metadata"
);
assert!(
metrics
@@ -5488,7 +5604,7 @@ mod test {
.join(unmarked_dir.to_string())
.join("part.1")
.exists(),
"residue without a committed marker must survive the purge"
"residue without a committed marker must survive the listing"
);
assert_eq!(
tokio::fs::read(
@@ -6405,6 +6521,147 @@ mod test {
}
}
#[tokio::test]
async fn latest_get_follows_read_quorum_on_torn_writes() {
use crate::storage_api_contracts::bucket::{BucketOperations as _, MakeBucketOptions};
use crate::storage_api_contracts::object::ObjectOperations as _;
// HEAD and GET serve the version with the most copies once it reaches
// the object's data blocks, and a tie goes to the newer version, as in
// MinIO's getObjectFileInfo and as heal converges the set. GET used to
// demand write quorum whenever enough metadata answered, so a 2+2 or
// 8+8 split, or a newer side that could still hold write quorum
// through an offline drive, listed but answered 503.
let opts = ObjectOptions {
suppress_read_repair: true,
..Default::default()
};
for (drives, parity) in [(4_usize, 2_usize), (16, 8), (16, 4)] {
let data = drives - parity;
let bucket = format!("torn-write-get-{drives}-{parity}");
let (dirs, set) = crate::ecstore_validation_blackbox::make_local_set_disks(drives, parity).await;
set.make_bucket(&bucket, &MakeBucketOptions::default())
.await
.expect("bucket should be created");
// Two committed generations of one key give every drive its own
// inline shard of each generation; the cases copy them per drive.
let mut generations = Vec::new();
for body in [b"old generation".as_slice(), b"new generation".as_slice()] {
let info = set
.put_object(
&bucket,
"template",
&mut PutObjReader::from_vec(body.to_vec()),
&ObjectOptions {
no_lock: true,
..Default::default()
},
)
.await
.expect("generation PUT should commit on every drive");
let mut metadata = Vec::with_capacity(drives);
for dir in &dirs {
metadata.push(
tokio::fs::read(dir.path().join(&bucket).join("template").join(STORAGE_FORMAT_FILE))
.await
.expect("generation metadata should carry the inline shard"),
);
}
generations.push((body, info.etag.expect("PUT should report an etag"), metadata));
}
let (new_body, new_etag, new) = generations.pop().expect("new generation");
let (old_body, old_etag, old) = generations.pop().expect("old generation");
// New copies occupy the first drives, so the offline drive always
// removes one new copy. Each phase reads its own keys so the GET
// metadata cache never answers for the other phase.
let mut cases = Vec::new();
for offline in [false, true] {
for copies in 1..drives {
for overwrite in [false, true] {
let name = format!(
"{}-{}-{copies:02}",
if offline { "offline" } else { "online" },
if overwrite { "overwrite" } else { "new" }
);
for slot in 0..drives {
let bytes = if slot < copies {
&new[slot]
} else if overwrite {
&old[slot]
} else {
continue;
};
let path = dirs[slot].path().join(&bucket).join(&name);
tokio::fs::create_dir_all(&path).await.expect("create object directory");
tokio::fs::write(path.join(STORAGE_FORMAT_FILE), bytes)
.await
.expect("write generation metadata");
}
cases.push((offline, name, copies, overwrite));
}
}
}
for offline in [false, true] {
let parked = if offline { set.disks.write().await[0].take() } else { None };
for (_, name, copies, overwrite) in cases.iter().filter(|case| case.0 == offline) {
let new_copies = copies - usize::from(offline);
let old_copies = if *overwrite { drives - copies } else { 0 };
let expected = if new_copies >= data && new_copies >= old_copies {
Some((&new_etag, new_body))
} else if old_copies >= data {
Some((&old_etag, old_body))
} else {
None
};
let context = format!("{drives} drives EC {data}+{parity}, {name}: {new_copies} new, {old_copies} old");
let head = set.get_object_info(&bucket, name, &opts).await;
let get = set.get_object_reader(&bucket, name, None, HeaderMap::new(), &opts).await;
match (expected, head, get) {
(Some((etag, body)), Ok(info), Ok(mut reader)) => {
assert_eq!(info.etag.as_ref(), Some(etag), "{context}: HEAD");
assert_eq!(reader.object_info.etag.as_ref(), Some(etag), "{context}: GET");
let mut streamed = Vec::new();
reader
.stream
.read_to_end(&mut streamed)
.await
.unwrap_or_else(|err| panic!("{context}: GET body: {err:?}"));
assert_eq!(streamed, body, "{context}: GET body");
}
(Some((etag, _)), head, get) => panic!(
"{context}: expected {etag}, HEAD {:?}, GET {:?}",
head.map(|info| info.etag),
get.map(|reader| reader.object_info.etag)
),
(None, head, get) => {
for (method, result) in [
("HEAD", head.map(|info| info.etag)),
("GET", get.map(|reader| reader.object_info.etag)),
] {
let err = result.expect_err(&format!("{context}: {method} expected 404 or unavailable"));
assert!(
matches!(
err,
StorageError::ObjectNotFound(..)
| StorageError::FileNotFound
| StorageError::ErasureReadQuorum
| StorageError::InsufficientReadQuorum(..)
),
"{context}: {method} expected 404 or unavailable, got {err:?}"
);
}
}
}
}
if offline {
set.disks.write().await[0] = parked;
}
}
}
}
#[tokio::test]
async fn latest_listing_and_get_agree_on_hermetic_set_states() {
#[derive(Clone, Copy)]
@@ -6477,6 +6734,10 @@ mod test {
("d-torn-two", [Some(Generation::New), Some(Generation::New), None, None]),
("e-torn-one", [Some(Generation::New), None, None, None]),
("f-stale-one", [None, None, None, Some(Generation::Old)]),
(
"fa-overwrite-two-of-three",
[Some(Generation::New), Some(Generation::New), Some(Generation::Old), None],
),
(
"g-overwrite-torn-one",
[
@@ -6521,6 +6782,7 @@ mod test {
("b-copied", &new_etag),
("c-split", &new_etag),
("d-torn-two", &new_etag),
("fa-overwrite-two-of-three", &new_etag),
("g-overwrite-torn-one", &old_etag),
]
.into_iter()
@@ -6547,12 +6809,6 @@ mod test {
"{name}: GET must serve the version LIST selected"
);
}
// Known divergence: when every drive answers, GET of the latest
// version demands write quorum (three of four), so a 2+2 split
// answers 503 although LIST shows the newer generation, as
// MinIO's listing does. F.0's dangling classification does not
// cover a split; a GET change is a separate decision.
(Some(_), Err(StorageError::InsufficientReadQuorum(..))) if name == "c-split" => {}
(Some(_), Err(err)) => assert!(
matches!(
err,
@@ -6755,22 +7011,6 @@ mod test {
assert_eq!(parsed.set_idx, Some(7));
}
#[test]
fn list_marker_key_strips_only_trailing_cache_tag() {
assert_eq!(
list_marker_key("photos/2026/image.jpg[rustfs_cache:v2,id:list-cache-id,src:walker,gen:live]"),
"photos/2026/image.jpg"
);
assert_eq!(
list_marker_key("photos/[rustfs_cache:v2]/image.jpg"),
"photos/[rustfs_cache:v2]/image.jpg"
);
assert_eq!(
list_marker_key("photos/image.jpg[rustfs_cache:v2,id:list-cache-id"),
"photos/image.jpg[rustfs_cache:v2,id:list-cache-id"
);
}
#[test]
fn list_path_marker_parser_return_tag_forces_cache_refresh() {
let mut parsed = ListPathOptions {
@@ -755,7 +755,7 @@ async fn list_objects_shared_corpus_versions_delete_markers_and_free_versions()
}
#[tokio::test]
async fn list_objects_shared_corpus_orphan_cleanup_requires_authoritative_evidence() {
async fn list_objects_shared_corpus_orphan_listing_preserves_inputs() {
for authoritative in [false, true] {
let fixture = Fixture::new(
&format!("list-corpus-orphan-{authoritative}"),
@@ -774,10 +774,9 @@ async fn list_objects_shared_corpus_orphan_cleanup_requires_authoritative_eviden
let result = fixture.page("ghost/", None, None, 1).await;
assert!(result.objects.is_empty() && result.prefixes.is_empty() && !result.is_truncated);
for dir in &fixture.dirs {
assert_eq!(
dir.path().join(&fixture.bucket).join("ghost").exists(),
!authoritative,
"unknown metadata must fail closed"
assert!(
dir.path().join(&fixture.bucket).join("ghost/nested/leaf").exists(),
"LIST must preserve orphan input"
);
assert!(
dir.path()
@@ -785,7 +784,7 @@ async fn list_objects_shared_corpus_orphan_cleanup_requires_authoritative_eviden
.join("legal/child")
.join(STORAGE_FORMAT_FILE)
.exists(),
"purge must preserve legal siblings"
"LIST must preserve legal siblings"
);
}
fixture.assert_walk("legal/", None, None, 1).await;
@@ -796,7 +795,7 @@ async fn list_objects_shared_corpus_orphan_cleanup_requires_authoritative_eviden
.join("legal/child")
.join(STORAGE_FORMAT_FILE)
.exists(),
"an orphan in the same subtree must not cause the legitimate child to be purged"
"an orphan in the same subtree must not affect a legitimate child"
);
}
if authoritative {
@@ -811,17 +810,15 @@ async fn list_objects_shared_corpus_orphan_cleanup_requires_authoritative_eviden
.bucket_metadata_sys()
.expect("authoritative fixture has a metadata system");
let guard = metadata_sys.write().await;
assert!(
timeout(Duration::from_millis(100), fixture.page("timed-out/", None, None, 1))
.await
.is_err(),
"blocked authoritative evidence must time out before cleanup can classify the prefix as empty"
);
let result = timeout(Duration::from_secs(2), fixture.page("timed-out/", None, None, 1))
.await
.expect("LIST must not wait for cleanup authorization");
assert!(result.objects.is_empty() && result.prefixes.is_empty() && !result.is_truncated);
drop(guard);
for dir in &fixture.dirs {
assert!(
dir.path().join(&fixture.bucket).join("timed-out").exists(),
"a timed-out evidence check must preserve its orphan input"
"LIST must preserve its orphan input while metadata is locked"
);
}
}
+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;
+37 -23
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(
@@ -5139,27 +5171,6 @@ impl ECStore {
purged
}
pub(super) async fn purge_orphan_dir_objects_in_bucket(&self, bucket: &str) -> bool {
if is_meta_bucketname(bucket) {
return false;
}
for pool in self.pools.iter() {
for set in pool.disk_set.iter() {
if !set.orphan_purge_has_complete_disk_set().await {
return false;
}
}
}
let mut purged = false;
for pool in self.pools.iter() {
for set in pool.disk_set.iter() {
purged |= set.purge_orphan_dir_objects_in_bucket(bucket).await;
}
}
purged
}
/// Delete one object (or a prefix) on behalf of a caller that may be
/// dropped mid-way, such as an S3 request whose client disconnects.
///
@@ -5441,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)?
}
+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)
}))
+31 -7
View File
@@ -683,14 +683,17 @@ fn create_auto_heal_disk_request(set_disk_id: String, priority: Option<HealChann
}
}
fn create_auto_replacement_disk_request(
/// Build one automatic replacement request for the verified targets of a set.
pub fn create_auto_replacement_disks_request(
pool_index: usize,
set_index: usize,
replacement_endpoint: String,
mut replacement_endpoints: Vec<String>,
priority: Option<HealChannelPriority>,
) -> HealChannelRequest {
replacement_endpoints.sort_unstable();
replacement_endpoints.dedup();
let mut request = create_auto_heal_disk_request(format!("pool_{pool_index}_set_{set_index}"), priority);
request.heal_endpoints = vec![replacement_endpoint];
request.heal_endpoints = replacement_endpoints;
request.pool_index = Some(pool_index);
request.set_index = Some(set_index);
request
@@ -711,10 +714,10 @@ pub async fn send_heal_replacement_disk(
replacement_endpoint: String,
priority: Option<HealChannelPriority>,
) -> Result<(), String> {
send_heal_request(create_auto_replacement_disk_request(
send_heal_request(create_auto_replacement_disks_request(
pool_index,
set_index,
replacement_endpoint,
vec![replacement_endpoint],
priority,
))
.await
@@ -726,8 +729,12 @@ mod auto_heal_disk_request_tests {
#[test]
fn replacement_disk_request_carries_its_exact_endpoint() {
let request =
create_auto_replacement_disk_request(2, 3, "http://node2:9000/drive3".to_string(), Some(HealChannelPriority::Normal));
let request = create_auto_replacement_disks_request(
2,
3,
vec!["http://node2:9000/drive3".to_string()],
Some(HealChannelPriority::Normal),
);
assert_eq!(request.disk.as_deref(), Some("pool_2_set_3"));
assert_eq!(request.heal_endpoints, ["http://node2:9000/drive3"]);
@@ -736,6 +743,23 @@ mod auto_heal_disk_request_tests {
assert_eq!(request.source, HealRequestSource::AutoHeal);
}
#[test]
fn replacement_disk_request_keeps_every_distinct_target() {
let request = create_auto_replacement_disks_request(
2,
3,
vec!["drive-b".to_string(), "drive-a".to_string(), "drive-b".to_string()],
Some(HealChannelPriority::Normal),
);
assert_eq!(request.disk.as_deref(), Some("pool_2_set_3"));
assert_eq!(request.heal_endpoints, ["drive-a", "drive-b"]);
assert_eq!(request.pool_index, Some(2));
assert_eq!(request.set_index, Some(3));
assert_eq!(request.priority, HealChannelPriority::Normal);
assert_eq!(request.source, HealRequestSource::AutoHeal);
}
#[test]
fn legacy_auto_heal_disk_request_has_no_replacement_endpoint() {
let request = create_auto_heal_disk_request("pool_2_set_3".to_string(), None);
+2
View File
@@ -19,6 +19,7 @@ use thiserror::Error;
use super::heal::{DiskError, EcstoreError};
const HEAL_DANGLING_DELETE_GRACE_MESSAGE: &str = "dangling object deletion deferred by heal grace window";
pub(crate) const MRF_MISSING_PROOF_PREFIX: &str = "Missing exact storage proof for durable MRF repair ";
/// Custom error type for heal operations
/// This enum defines various error variants that can occur during
@@ -221,6 +222,7 @@ pub(crate) fn classify_heal_error(error: &Error) -> HealErrorClass {
Error::Storage(error) => classify_storage_error(error),
Error::Disk(error) => classify_disk_error(error),
Error::Io(error) => classify_io_error(error),
Error::TaskExecutionFailed { message } if message.starts_with(MRF_MISSING_PROOF_PREFIX) => HealErrorClass::Permanent,
Error::TaskExecutionFailed { message } | Error::Other(message) => {
classify_error_display(message).unwrap_or(HealErrorClass::Permanent)
}
+497 -92
View File
@@ -58,6 +58,8 @@ fn result_object_size_u64(result: &HealResultItem) -> u64 {
const NEW_VERSION_SKIP_GRACE_SECS: u64 = 60;
const NANOS_PER_SECOND: i128 = 1_000_000_000;
const DEFAULT_REPLACEMENT_PAGE_OBJECT_CONCURRENCY: usize = 2;
const REPLACEMENT_OBJECT_MAX_RETRIES: u32 = 2;
fn should_skip_new_version(mod_time_unix_nanos: Option<i128>, started_at_secs: u64) -> bool {
let Some(mod_time_unix_nanos) = mod_time_unix_nanos else {
@@ -177,7 +179,9 @@ mod mainline_pacing_tests {
.await
.expect("existing object owns capacity");
let provider = Arc::new(TestPressure::new(WorkloadClass::ForegroundRead, 0));
let pacer = Arc::new(MainlinePacer::new(provider.clone(), 80, 80, Duration::from_millis(250)).expect("pacer"));
let pacer = Arc::new(
MainlinePacer::new(provider.clone(), 80, 80, Duration::from_millis(250), HealRequestSource::Admin).expect("pacer"),
);
let cancel = CancellationToken::new();
let waiting = tokio::spawn({
let semaphore = semaphore.clone();
@@ -216,7 +220,7 @@ mod mainline_pacing_tests {
let occupied = semaphore.clone().acquire_owned().await.expect("queue both waiters");
let provider = Arc::new(TestPressure::new(WorkloadClass::ForegroundRead, 0));
let pause = Duration::from_millis(250);
let pacer = MainlinePacer::new(provider.clone(), 80, 80, pause).expect("pacer");
let pacer = MainlinePacer::new(provider.clone(), 80, 80, pause, HealRequestSource::Admin).expect("pacer");
let cancel = CancellationToken::new();
let mut first = Box::pin(acquire_page_permit(semaphore.clone(), Some(&pacer), &cancel));
let mut second = Box::pin(acquire_page_permit(semaphore.clone(), Some(&pacer), &cancel));
@@ -304,9 +308,21 @@ impl ErasureSetHealer {
}
}
fn effective_heal_page_object_concurrency_for_source(source: HealRequestSource, scan_mode: HealScanMode) -> usize {
fn effective_heal_page_object_concurrency_for_source(
source: HealRequestSource,
scan_mode: HealScanMode,
replacement: bool,
) -> usize {
if matches!(source, HealRequestSource::AutoHeal) {
1
if !replacement || matches!(scan_mode, HealScanMode::Deep) || !Self::page_parallel_enabled() {
1
} else {
rustfs_utils::get_env_usize(
rustfs_config::ENV_HEAL_PAGE_OBJECT_CONCURRENCY,
DEFAULT_REPLACEMENT_PAGE_OBJECT_CONCURRENCY,
)
.clamp(1, Semaphore::MAX_PERMITS)
}
} else {
Self::effective_heal_page_object_concurrency_for_scan_mode(scan_mode)
}
@@ -1467,7 +1483,16 @@ impl ErasureSetHealer {
// transient cache id that changes between identical pages.
let mut previous_page_last: Option<(String, Option<String>)> = None;
let page_concurrency_limit =
Self::effective_heal_page_object_concurrency_for_source(self.source, self.heal_opts.scan_mode);
if self.source == HealRequestSource::AutoHeal && self.replacement_task_id.is_some() && self.heal_opts.no_lock {
// Versions of the same object must not race without its namespace lock.
1
} else {
Self::effective_heal_page_object_concurrency_for_source(
self.source,
self.heal_opts.scan_mode,
self.replacement_task_id.is_some(),
)
};
let in_flight = Arc::new(AtomicUsize::new(0));
// Per-bucket sample caps for per-object warn! lines: a flapping rebuild
// disk can fail/skip hundreds of thousands of versions in one sweep, so
@@ -1689,6 +1714,7 @@ impl ErasureSetHealer {
let semaphore = semaphore.clone();
let target_endpoints = self.target_endpoints.clone();
let replacement_commit_evidence_required = self.replacement_task_id.is_some();
let retry_replacement = replacement_commit_evidence_required && self.source == HealRequestSource::AutoHeal;
let mainline_pacer = self.mainline_pacer.clone();
let admin_identity = self.admin_identity(bucket, &object_name, version_id.as_deref(), incarnation);
@@ -1698,107 +1724,125 @@ impl ErasureSetHealer {
.as_ref()
.map(|_| (dedup_key.clone(), object_name.clone(), version_id.clone()));
let work = async move {
let permit = acquire_page_permit(semaphore, mainline_pacer.as_deref(), &cancel_token).await;
let mut retries = 0;
let mut admin_identity = admin_identity;
let mut admin_task_id = admin_task_id;
loop {
let permit = acquire_page_permit(semaphore.clone(), mainline_pacer.as_deref(), &cancel_token).await;
let _permit = match permit {
Ok(permit) => permit,
Err(err) => return (dedup_key, object_name, version_id, (0, Err(err)), None),
};
let permit = match permit {
Ok(permit) => permit,
Err(err) => return (dedup_key, object_name, version_id, (0, Err(err)), None),
};
let _in_flight_guard = PageConcurrencyGuard::new(in_flight, set_label);
let in_flight_guard = PageConcurrencyGuard::new(in_flight.clone(), set_label.clone());
if let (Some(identity), Some(task_id)) = (admin_identity, admin_task_id) {
let (result, canonical) =
admin::heal_object(storage.as_ref(), &heal_opts, identity, &task_id, &cancel_token).await;
return (dedup_key, object_name, version_id, result, canonical);
}
if let (Some(identity), Some(task_id)) = (admin_identity.take(), admin_task_id.take()) {
let (result, canonical) =
admin::heal_object(storage.as_ref(), &heal_opts, identity, &task_id, &cancel_token).await;
return (dedup_key, object_name, version_id, result, canonical);
}
// Always go through heal_object. Genuine absence flows through
// heal_object -> FileVersionNotFound/FileNotFound ->
// classify_heal_object_error -> Absent, so gone versions are
// recorded as skipped-ok rather than failed. The delete-marker
// vs data path is chosen internally in ops/heal.rs.
let result = if cancel_token.is_cancelled() {
(0, Err(Error::TaskCancelled))
} else {
match storage
.heal_object(&bucket_name, &object_name, version_id.as_deref(), &heal_opts)
.await
{
Ok((result, None)) if target_outcomes_complete(&result, &target_endpoints) => {
let object_size = result_object_size_u64(&result);
if !replacement_commit_evidence_required {
(object_size, Ok(true))
} else {
match storage
.replacement_targets_have_version(
&bucket_name,
&object_name,
version_id.as_deref(),
&heal_opts,
&target_endpoints,
)
.await
{
Ok(true) => (object_size, Ok(true)),
Ok(false) => (
object_size,
Err(Error::transient_skip(format!(
"Skipped heal for {bucket_name}/{object_name} because replacement target readback did not confirm the committed version"
))),
),
Err(err) => (
object_size,
Err(Error::transient_skip(format!(
"Skipped heal for {bucket_name}/{object_name} because replacement target readback failed: {err}"
))),
),
// Always go through heal_object. Genuine absence flows through
// heal_object -> FileVersionNotFound/FileNotFound ->
// classify_heal_object_error -> Absent, so gone versions are
// recorded as skipped-ok rather than failed. The delete-marker
// vs data path is chosen internally in ops/heal.rs.
let result = if cancel_token.is_cancelled() {
(0, Err(Error::TaskCancelled))
} else {
match storage
.heal_object(&bucket_name, &object_name, version_id.as_deref(), &heal_opts)
.await
{
Ok((result, None)) if target_outcomes_complete(&result, &target_endpoints) => {
let object_size = result_object_size_u64(&result);
if !replacement_commit_evidence_required {
(object_size, Ok(true))
} else {
match storage
.replacement_targets_have_version(
&bucket_name,
&object_name,
version_id.as_deref(),
&heal_opts,
&target_endpoints,
)
.await
{
Ok(true) => (object_size, Ok(true)),
Ok(false) => (
object_size,
Err(Error::transient_skip(format!(
"Skipped heal for {bucket_name}/{object_name} because replacement target readback did not confirm the committed version"
))),
),
Err(err) => (
object_size,
Err(Error::transient_skip(format!(
"Skipped heal for {bucket_name}/{object_name} because replacement target readback failed: {err}"
))),
),
}
}
}
}
Ok((result, None)) if !target_endpoints.is_empty() => (
result_object_size_u64(&result),
Err(Error::transient_skip(format!(
"Skipped heal for {bucket_name}/{object_name} because a replacement target was not committed"
))),
),
Ok((result, None)) => (result_object_size_u64(&result), Ok(true)),
Ok((result, Some(err))) if is_missing_object_dir_heal_result(&object_name, &err) => {
(result_object_size_u64(&result), Ok(false))
}
Ok((result, Some(err))) => {
let object_size = result_object_size_u64(&result);
match Self::classify_heal_object_error(&err) {
HealObjectOutcome::Absent => (object_size, Ok(false)),
Ok((result, None)) if !target_endpoints.is_empty() => (
result_object_size_u64(&result),
Err(Error::transient_skip(format!(
"Skipped heal for {bucket_name}/{object_name} because a replacement target was not committed"
))),
),
Ok((result, None)) => (result_object_size_u64(&result), Ok(true)),
Ok((result, Some(err))) if is_missing_object_dir_heal_result(&object_name, &err) => {
(result_object_size_u64(&result), Ok(false))
}
Ok((result, Some(err))) => {
let object_size = result_object_size_u64(&result);
match Self::classify_heal_object_error(&err) {
HealObjectOutcome::Absent => (object_size, Ok(false)),
HealObjectOutcome::DanglingDeleteDeferred { retry_not_before } => {
(object_size, Err(Error::DanglingDeleteDeferred { retry_not_before }))
}
HealObjectOutcome::Transient => (
object_size,
Err(Error::transient_skip(format!(
"Skipped heal for {bucket_name}/{object_name} due to transient error: {err}"
))),
),
HealObjectOutcome::Failed => (object_size, Err(err)),
}
}
Err(err) => match Self::classify_heal_object_error(&err) {
HealObjectOutcome::Absent => (0, Ok(false)),
HealObjectOutcome::DanglingDeleteDeferred { retry_not_before } => {
(object_size, Err(Error::DanglingDeleteDeferred { retry_not_before }))
(0, Err(Error::DanglingDeleteDeferred { retry_not_before }))
}
HealObjectOutcome::Transient => (
object_size,
0,
Err(Error::transient_skip(format!(
"Skipped heal for {bucket_name}/{object_name} due to transient error: {err}"
))),
),
HealObjectOutcome::Failed => (object_size, Err(err)),
}
HealObjectOutcome::Failed => (0, Err(err)),
},
}
Err(err) => match Self::classify_heal_object_error(&err) {
HealObjectOutcome::Absent => (0, Ok(false)),
HealObjectOutcome::DanglingDeleteDeferred { retry_not_before } => {
(0, Err(Error::DanglingDeleteDeferred { retry_not_before }))
}
HealObjectOutcome::Transient => (
0,
Err(Error::transient_skip(format!(
"Skipped heal for {bucket_name}/{object_name} due to transient error: {err}"
))),
),
HealObjectOutcome::Failed => (0, Err(err)),
},
}
};
};
(dedup_key, object_name, version_id, result, None)
drop(in_flight_guard);
drop(permit);
if retry_replacement
&& retries < REPLACEMENT_OBJECT_MAX_RETRIES
&& matches!(&result.1, Err(Error::TransientSkip { .. }))
{
retries += 1;
tokio::select! {
_ = cancel_token.cancelled() => return (dedup_key, object_name, version_id, (0, Err(Error::TaskCancelled)), None),
_ = tokio::time::sleep(Duration::from_millis(250 * u64::from(retries))) => {}
}
continue;
}
return (dedup_key, object_name, version_id, result, None);
}
};
page_tasks.push(async move {
if let (Some(execution), Some((key, object, version))) = (execution, failure_identity) {
@@ -2129,6 +2173,7 @@ mod tests {
ErasureSetHealer::effective_heal_page_object_concurrency_for_source(
HealRequestSource::AutoHeal,
HealScanMode::Normal,
false,
),
1
);
@@ -2136,6 +2181,73 @@ mod tests {
);
}
#[test]
fn automatic_replacement_uses_a_conservative_default_and_respects_serial_controls() {
temp_env::with_vars(
[
(rustfs_config::ENV_HEAL_PAGE_PARALLEL_ENABLE, Some("true")),
(rustfs_config::ENV_HEAL_PAGE_OBJECT_CONCURRENCY, None),
],
|| {
assert_eq!(
ErasureSetHealer::effective_heal_page_object_concurrency_for_source(
HealRequestSource::AutoHeal,
HealScanMode::Normal,
true,
),
2
);
assert_eq!(
ErasureSetHealer::effective_heal_page_object_concurrency_for_source(
HealRequestSource::AutoHeal,
HealScanMode::Deep,
true,
),
1
);
},
);
temp_env::with_vars(
[
(rustfs_config::ENV_HEAL_PAGE_PARALLEL_ENABLE, Some("false")),
(rustfs_config::ENV_HEAL_PAGE_OBJECT_CONCURRENCY, Some("8")),
],
|| {
assert_eq!(
ErasureSetHealer::effective_heal_page_object_concurrency_for_source(
HealRequestSource::AutoHeal,
HealScanMode::Normal,
true,
),
1
);
},
);
}
#[test]
fn automatic_replacement_concurrency_fits_the_semaphore() {
let maximum = usize::MAX.to_string();
for (value, expected) in [("0", 1), (maximum.as_str(), tokio::sync::Semaphore::MAX_PERMITS)] {
temp_env::with_vars(
[
(rustfs_config::ENV_HEAL_PAGE_PARALLEL_ENABLE, Some("true")),
(rustfs_config::ENV_HEAL_PAGE_OBJECT_CONCURRENCY, Some(value)),
],
|| {
assert_eq!(
ErasureSetHealer::effective_heal_page_object_concurrency_for_source(
HealRequestSource::AutoHeal,
HealScanMode::Normal,
true,
),
expected
);
},
);
}
}
#[test]
fn non_auto_normal_scan_page_object_concurrency_uses_effective_limit() {
temp_env::with_vars(
@@ -2148,6 +2260,7 @@ mod tests {
ErasureSetHealer::effective_heal_page_object_concurrency_for_source(
HealRequestSource::Admin,
HealScanMode::Normal,
false,
),
11
);
@@ -2520,6 +2633,8 @@ mod resume_loop_tests {
lifecycle_expired: Mutex<HashSet<String>>,
/// every heal_object call recorded as (name, version_id)
heal_calls: Mutex<Vec<(String, Option<String>)>>,
heal_gate: Mutex<Option<Arc<tokio::sync::Semaphore>>>,
heal_started: tokio::sync::Notify,
list_include_lifecycle_object_info: Mutex<Vec<bool>>,
replacement_target_identity_sequences: Mutex<VecDeque<Vec<ReplacementTargetIdentity>>>,
replacement_targets_unready: AtomicBool,
@@ -2704,6 +2819,11 @@ mod resume_loop_tests {
.lock()
.unwrap()
.push((object.to_string(), version_id.map(str::to_string)));
self.heal_started.notify_one();
let heal_gate = self.heal_gate.lock().unwrap().clone();
if let Some(gate) = heal_gate {
gate.acquire().await.unwrap().forget();
}
let key = compose_key(object, version_id);
let outcome = {
let mut outcomes = self.outcomes.lock().unwrap();
@@ -3898,6 +4018,287 @@ mod resume_loop_tests {
assert_eq!(unique.len(), 50, "no version healed twice");
}
#[tokio::test]
async fn auto_replacement_runs_two_object_repairs_within_the_configured_limit() {
temp_env::async_with_vars(
[
(rustfs_config::ENV_HEAL_PAGE_PARALLEL_ENABLE, Some("true")),
(rustfs_config::ENV_HEAL_PAGE_OBJECT_CONCURRENCY, Some("2")),
],
async {
let mut env = make_env().await;
env.healer.source = HealRequestSource::AutoHeal;
env.healer = env
.healer
.with_replacement_targets(vec!["replacement-a".to_string()], Some(env.task_id.clone()));
let gate = Arc::new(tokio::sync::Semaphore::new(0));
*env.storage.heal_gate.lock().unwrap() = Some(gate.clone());
let objects = ["a", "b", "c"];
for name in objects {
env.storage
.set_result(name, None, replacement_target_ok_result("replacement-a", name));
}
env.storage.set_page(
None,
Page {
items: objects.into_iter().map(|name| item(name, None, false)).collect(),
next: None,
truncated: false,
},
);
let observe = async {
let two_started = tokio::time::timeout(std::time::Duration::from_secs(2), async {
loop {
let notified = env.storage.heal_started.notified();
if env.storage.calls().len() >= 2 {
break;
}
notified.await;
}
})
.await
.is_ok();
let started = env.storage.calls().len();
gate.add_permits(objects.len());
assert!(two_started, "automatic replacement should start two repairs before either finishes");
assert_eq!(started, 2, "configured concurrency must bound in-flight repairs");
};
let ((processed, successful, failed, skipped, result), ()) = tokio::join!(run(&env), observe);
result.expect("all replacement objects must be verified");
assert_eq!((processed, successful, failed, skipped), (3, 3, 0, 0));
assert_eq!(env.storage.calls().len(), 3);
},
)
.await;
}
#[tokio::test]
async fn auto_replacement_without_object_locks_stays_serial() {
temp_env::async_with_vars(
[
(rustfs_config::ENV_HEAL_PAGE_PARALLEL_ENABLE, Some("true")),
(rustfs_config::ENV_HEAL_PAGE_OBJECT_CONCURRENCY, Some("2")),
],
async {
let mut env = make_env().await;
env.healer.source = HealRequestSource::AutoHeal;
env.healer.heal_opts.no_lock = true;
env.healer = env
.healer
.with_replacement_targets(vec!["replacement-a".to_string()], Some(env.task_id.clone()));
let gate = Arc::new(tokio::sync::Semaphore::new(0));
*env.storage.heal_gate.lock().unwrap() = Some(gate.clone());
for version in ["v1", "v2"] {
env.storage
.set_result("object", Some(version), replacement_target_ok_result("replacement-a", "object"));
}
env.storage.set_page(
None,
Page {
items: vec![item("object", Some("v1"), false), item("object", Some("v2"), false)],
next: None,
truncated: false,
},
);
let observe = async {
let first_started = tokio::time::timeout(std::time::Duration::from_secs(5), async {
loop {
let notified = env.storage.heal_started.notified();
if !env.storage.calls().is_empty() {
break;
}
notified.await;
}
})
.await
.is_ok();
let two_started = tokio::time::timeout(std::time::Duration::from_secs(2), async {
loop {
let notified = env.storage.heal_started.notified();
if env.storage.calls().len() >= 2 {
break;
}
notified.await;
}
})
.await
.is_ok();
gate.add_permits(2);
assert!(first_started, "the first object repair must start");
assert!(!two_started, "unlocked object repairs must not overlap");
};
let ((processed, successful, failed, skipped, result), ()) = tokio::join!(run(&env), observe);
result.expect("the serial replacement must finish");
assert_eq!((processed, successful, failed, skipped), (2, 2, 0, 0));
},
)
.await;
}
#[tokio::test]
async fn auto_replacement_retries_a_transient_object_before_rewinding_the_scan() {
let mut env = make_env().await;
env.healer.source = HealRequestSource::AutoHeal;
env.healer = env
.healer
.with_replacement_targets(vec!["replacement-a".to_string()], Some(env.task_id.clone()));
env.storage.set_page(
None,
Page {
items: vec![item("object", Some("version"), false)],
next: None,
truncated: false,
},
);
env.storage
.set_outcome("object", Some("version"), HealOutcome::RpcCancelled(1));
env.storage
.set_result("object", Some("version"), replacement_target_ok_result("replacement-a", "object"));
let (processed, successful, failed, skipped, result) = run(&env).await;
result.expect("the transient repair should succeed during the current pass");
assert_eq!((processed, successful, failed, skipped), (1, 1, 0, 0));
assert_eq!(env.storage.calls().len(), 2);
}
#[tokio::test]
async fn auto_replacement_releases_capacity_during_a_bounded_retry() {
temp_env::async_with_vars(
[
(rustfs_config::ENV_HEAL_PAGE_PARALLEL_ENABLE, Some("true")),
(rustfs_config::ENV_HEAL_PAGE_OBJECT_CONCURRENCY, Some("1")),
],
async {
let mut env = make_env().await;
env.healer.source = HealRequestSource::AutoHeal;
env.healer = env
.healer
.with_replacement_targets(vec!["replacement-a".to_string()], Some(env.task_id.clone()));
env.storage.set_page(
None,
Page {
items: vec![item("retry", None, false), item("ready", None, false)],
next: None,
truncated: false,
},
);
env.storage.set_outcome("retry", None, HealOutcome::Transient);
env.storage
.set_result("ready", None, replacement_target_ok_result("replacement-a", "ready"));
let (processed, successful, failed, skipped, result) = run(&env).await;
result.expect("a failed object is retained as a transient skip");
assert_eq!((processed, successful, failed, skipped), (2, 1, 0, 1));
let calls = env.storage.calls();
let retries: Vec<_> = calls.iter().enumerate().filter(|(_, call)| call.0 == "retry").collect();
let ready = calls.iter().position(|call| call.0 == "ready").expect("ready object ran");
assert_eq!(retries.len(), 3, "only two retries are allowed");
assert!(ready < retries[1].0, "the backoff must release the only page permit");
},
)
.await;
}
#[tokio::test]
async fn auto_replacement_cancellation_interrupts_object_retry() {
let mut env = make_env().await;
env.healer.source = HealRequestSource::AutoHeal;
env.healer = env
.healer
.with_replacement_targets(vec!["replacement-a".to_string()], Some(env.task_id.clone()));
env.storage.set_page(
None,
Page {
items: vec![item("retry", None, false)],
next: None,
truncated: false,
},
);
env.storage.set_outcome("retry", None, HealOutcome::Transient);
let cancel = env.healer.cancel_token.clone();
let observe = async {
loop {
let notified = env.storage.heal_started.notified();
if !env.storage.calls().is_empty() {
break;
}
notified.await;
}
cancel.cancel();
};
let ((_, successful, _, _, result), ()) = tokio::join!(run(&env), observe);
assert!(matches!(result, Err(Error::TaskCancelled)));
assert_eq!(successful, 0);
assert_eq!(env.storage.calls().len(), 1);
assert!(env.checkpoint.get_checkpoint().await.processed_objects.is_empty());
}
#[tokio::test]
async fn auto_replacement_cancel_retains_workers_until_inflight_repairs_settle() {
temp_env::async_with_vars(
[
(rustfs_config::ENV_HEAL_PAGE_PARALLEL_ENABLE, Some("true")),
(rustfs_config::ENV_HEAL_PAGE_OBJECT_CONCURRENCY, Some("2")),
],
async {
let mut env = make_env().await;
env.healer.source = HealRequestSource::AutoHeal;
let execution = crate::heal::storage::ReplacementExecution::for_test(Vec::new(), Vec::new());
env.healer = env
.healer
.with_replacement_targets(vec!["replacement-a".to_string()], Some(env.task_id.clone()))
.with_replacement_execution(Some(execution.clone()));
let gate = Arc::new(tokio::sync::Semaphore::new(0));
*env.storage.heal_gate.lock().unwrap() = Some(gate.clone());
for name in ["a", "b", "c"] {
env.storage
.set_result(name, None, replacement_target_ok_result("replacement-a", name));
}
env.storage.set_page(
None,
Page {
items: ["a", "b", "c"].into_iter().map(|name| item(name, None, false)).collect(),
next: None,
truncated: false,
},
);
let cancel = env.healer.cancel_token.clone();
let observe = async {
tokio::time::timeout(std::time::Duration::from_secs(5), async {
loop {
let notified = env.storage.heal_started.notified();
if env.storage.calls().len() >= 2 {
break;
}
notified.await;
}
})
.await
.expect("two repairs must start");
cancel.cancel();
};
let ((_, successful, _, _, result), ()) = tokio::join!(run(&env), observe);
assert!(matches!(result, Err(Error::TaskCancelled)));
assert_eq!(successful, 0);
assert_eq!(env.storage.calls().len(), 2, "queued work must not start after cancellation");
assert!(env.checkpoint.get_checkpoint().await.processed_objects.is_empty());
assert!(Arc::strong_count(&execution) >= 4, "in-flight workers must retain execution ownership");
gate.add_permits(2);
tokio::time::timeout(std::time::Duration::from_secs(5), async {
while Arc::strong_count(&execution) > 2 {
tokio::task::yield_now().await;
}
})
.await
.expect("workers must release execution ownership after their storage work settles");
},
)
.await;
}
#[tokio::test]
async fn test_resume_after_version_deleted_midheal_no_skip() {
let env = make_env().await;
@@ -4312,7 +4713,11 @@ mod resume_loop_tests {
assert!(!state.completed, "readback errors must leave the replacement task incomplete");
assert_eq!(state.retry_count, 1, "readback errors must arm the bounded retry path");
assert!(env.checkpoint.get_checkpoint().await.processed_objects.is_empty());
assert_eq!(env.storage.calls(), vec![("object".to_string(), Some("dm-v1".to_string()))]);
assert_eq!(
env.storage.calls(),
vec![("object".to_string(), Some("dm-v1".to_string())); 3],
"unverified readback must exhaust only the bounded object retries"
);
}
#[tokio::test]
+84 -3
View File
@@ -825,6 +825,21 @@ fn admin_heal_options_compatible(left: &HealOptions, right: &HealOptions) -> boo
&& left.no_lock == right.no_lock
}
fn replacement_request_compatible(
request: &HealRequest,
owner_type: &HealType,
owner_options: &HealOptions,
owner_source: HealRequestSource,
) -> bool {
request.source == HealRequestSource::AutoHeal
&& owner_source == HealRequestSource::AutoHeal
&& matches!(request.heal_type, HealType::ErasureSet { .. })
&& request.heal_type == *owner_type
&& request.options.pool_index == owner_options.pool_index
&& request.options.set_index == owner_options.set_index
&& admin_heal_options_compatible(&request.options, owner_options)
}
fn admin_overlap_rejection(
request: &HealRequest,
owner_type: &HealType,
@@ -1276,7 +1291,9 @@ impl HealManager {
request: HealRequest,
config: &HealConfig,
context: &'static str,
replacement_targets_frozen: bool,
) -> HealAdmissionDecision {
let replacement_targets_frozen = replacement_targets_frozen || request.retry_attempts != 0;
let queue_len = queue.len();
publish_heal_queue_length(queue);
let queue_capacity = config.queue_size;
@@ -1284,6 +1301,25 @@ impl HealManager {
let best_effort_reserve = best_effort_queue_reserve(queue_capacity);
let best_effort_source_reserves = best_effort_source_reserves(queue_capacity);
// Scanner and retry admission bypass submit_heal_request's duplicate
// checks. Keep their target ownership checks ahead of queue capacity.
if !request.force_start
&& request.source == HealRequestSource::AutoHeal
&& !request.heal_endpoints.is_empty()
&& let Some(owner_id) = queue
.queued_request_id_for_dedup_key(&PriorityHealQueue::make_dedup_key(&request))
.map(ToOwned::to_owned)
{
let mut admission = Self::duplicate_admission_for_request(&request, config);
if admission == HealAdmissionResult::Merged
&& !queue.merge_replacement_targets(&owner_id, &request, true, replacement_targets_frozen)
{
admission = HealAdmissionResult::Dropped(HealAdmissionDropReason::AlreadyRunning);
}
Self::record_admission_metric(request.source, admission, context);
return HealAdmissionDecision::new(admission);
}
if queue_len >= queue_capacity && !request.force_start {
if Self::can_displace_queued_work(&request)
&& queue.can_displace_lower_priority_where(request.priority, Self::queued_request_can_be_displaced)
@@ -1443,7 +1479,7 @@ impl HealManager {
let priority = request.priority;
let source = request.source;
match queue.push(request) {
match queue.push_with_replacement_scope(request, replacement_targets_frozen) {
QueuePushOutcome::Accepted => {
publish_heal_queue_length(queue);
Self::record_admission_metric(source, HealAdmissionResult::Accepted, context);
@@ -2260,7 +2296,7 @@ impl HealManager {
// HS-06: under the minio_error overlap policy an exact duplicate
// admin start reports the typed AlreadyRunning rejection instead
// of the silent merge (MinIO's ErrHealAlreadyRunning).
let admission = if (request.source == HealRequestSource::Admin
let mut admission = if (request.source == HealRequestSource::Admin
&& config.overlap_policy == HealOverlapPolicy::MinioError)
|| (duplicate_state != "queued" && mrf_notice_target.as_ref().is_some_and(|target| target.kind.is_durable()))
{
@@ -2270,6 +2306,44 @@ impl HealManager {
} else {
Self::duplicate_admission_for_request(&request, &config)
};
if matches!(admission, HealAdmissionResult::Merged)
&& request.source == HealRequestSource::AutoHeal
&& !request.heal_endpoints.is_empty()
{
let (owner_recovered, incoming_recovered) = {
// Match queue -> recovery-anchor ordering in the scanner.
let anchors = self
.replacement_recovery_anchors
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
(anchors.contains_key(&merged_task_id), anchors.contains_key(&request.id))
};
let incoming_frozen = incoming_recovered || request.retry_attempts != 0;
let covered = if incoming_frozen && request.id != merged_task_id {
false
} else if duplicate_state == "queued" {
queue.merge_replacement_targets(&merged_task_id, &request, !owner_recovered, incoming_frozen)
} else {
let owner = active_heals
.get(&merged_task_id)
.map(|task| (&task.heal_type, &task.options, task.source, &task.heal_endpoints))
.or_else(|| {
retrying_heals.get(&merged_task_id).map(|retry| {
let owner = &retry.request;
(&owner.heal_type, &owner.options, owner.source, &owner.heal_endpoints)
})
});
owner.is_some_and(|(heal_type, options, source, endpoints)| {
replacement_request_compatible(&request, heal_type, options, source)
&& request.heal_endpoints.iter().all(|endpoint| endpoints.contains(endpoint))
})
};
if !covered {
// A frozen generation cannot certify newly arriving disks.
// Leave them discoverable by the scanner for the next pass.
admission = HealAdmissionResult::Dropped(HealAdmissionDropReason::AlreadyRunning);
}
}
if matches!(admission, HealAdmissionResult::Merged)
&& let Some(target) = mrf_notice_target
{
@@ -2426,7 +2500,14 @@ impl HealManager {
let request_id = request.id.clone();
let request_heal_type = request.heal_type.clone();
let request_source = request.source;
let admission_decision = Self::admit_request_to_queue(&mut queue, request, &config, "submit");
let recovered_replacement = request.source == HealRequestSource::AutoHeal
&& !request.heal_endpoints.is_empty()
&& self
.replacement_recovery_anchors
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.contains_key(&request.id);
let admission_decision = Self::admit_request_to_queue(&mut queue, request, &config, "submit", recovered_replacement);
let admission = admission_decision.result;
if durable_handoff_required && !admission.is_admitted() {
self.root_recovery
+3 -1
View File
@@ -682,6 +682,7 @@ impl HealManager {
HealPriority::Low,
);
let recovery_anchor = recovery.as_ref().map(|(_, _, _, anchor)| anchor.clone());
let recovered_replacement = recovery.is_some();
if let Some((task_id, _, _, _)) = recovery {
req.id = task_id;
}
@@ -691,7 +692,8 @@ impl HealManager {
let endpoint_count = req.heal_endpoints.len();
let config = config.read().await;
let mut queue = heal_queue.lock().await;
let admission_decision = Self::admit_request_to_queue(&mut queue, req, &config, "auto_scan");
let admission_decision =
Self::admit_request_to_queue(&mut queue, req, &config, "auto_scan", recovered_replacement);
let admission = admission_decision.result;
let should_notify =
matches!(admission, HealAdmissionResult::Accepted) && config.event_driven_scheduler_enable;
+52
View File
@@ -70,6 +70,8 @@ pub(super) struct PriorityQueueItem {
pub(super) sequence: u64,
pub(super) dedup_key: String,
pub(super) request: HealRequest,
/// Execution or durable recovery fixed these targets; grace waits can have zero retries.
pub(super) replacement_targets_frozen: bool,
}
impl Eq for PriorityQueueItem {}
@@ -316,6 +318,10 @@ impl PriorityHealQueue {
}
pub(super) fn push(&mut self, request: HealRequest) -> QueuePushOutcome {
self.push_with_replacement_scope(request, false)
}
pub(super) fn push_with_replacement_scope(&mut self, request: HealRequest, frozen: bool) -> QueuePushOutcome {
let key = Self::make_dedup_key(&request);
// Check for duplicates unless the caller explicitly forces admission.
@@ -340,6 +346,7 @@ impl PriorityHealQueue {
sequence: self.sequence,
dedup_key: key,
request,
replacement_targets_frozen: frozen,
});
if is_best_effort_source(source) {
self.best_effort_queued += 1;
@@ -680,6 +687,51 @@ impl PriorityHealQueue {
self.heap.iter().map(|item| &item.request)
}
pub(super) fn merge_replacement_targets(
&mut self,
owner_id: &str,
request: &HealRequest,
allow_expand: bool,
incoming_frozen: bool,
) -> bool {
let Some(item) = self.heap.iter().find(|item| item.request.id == owner_id) else {
return false;
};
let owner = &item.request;
let same_id = request.id == owner.id;
let eligible = replacement_request_compatible(request, &owner.heal_type, &owner.options, owner.source)
&& (!incoming_frozen || same_id);
let covered = eligible
&& request
.heal_endpoints
.iter()
.all(|endpoint| owner.heal_endpoints.contains(endpoint));
let mark_frozen = incoming_frozen && same_id && !item.replacement_targets_frozen;
let can_expand = eligible
&& allow_expand
&& !item.replacement_targets_frozen
&& !incoming_frozen
&& owner.retry_attempts == 0
&& request.retry_attempts == 0
&& !owner.heal_endpoints.is_empty();
if !mark_frozen && (covered || !can_expand) {
return covered;
}
// Neither targets nor their frozen flag affect heap ordering.
let mut items = std::mem::take(&mut self.heap).into_vec();
if let Some(item) = items.iter_mut().find(|item| item.request.id == owner_id) {
item.replacement_targets_frozen |= mark_frozen;
if !covered && can_expand {
item.request.heal_endpoints.extend(request.heal_endpoints.iter().cloned());
item.request.heal_endpoints.sort_unstable();
item.request.heal_endpoints.dedup();
}
}
self.heap = BinaryHeap::from(items);
covered || can_expand
}
#[cfg(test)]
pub(super) fn contains_request_id(&self, request_id: &str) -> bool {
self.heap.iter().any(|item| item.request.id == request_id)
+26 -14
View File
@@ -30,6 +30,30 @@ pub(super) fn retry_admission_delay(
Some(delay)
}
pub(super) fn running_mainline_pacer(
request: &HealRequest,
config: &HealConfig,
provider: &Option<WorkloadSnapshotProviderRef>,
) -> Option<Arc<crate::heal::pacing::MainlinePacer>> {
let pace_running = request.source == HealRequestSource::Admin
|| (request.source == HealRequestSource::AutoHeal
&& matches!(&request.heal_type, HealType::ErasureSet { .. })
&& !request.heal_endpoints.is_empty());
if !pace_running || !config.mainline_throttle_enable {
return None;
}
provider.as_ref().and_then(|provider| {
crate::heal::pacing::MainlinePacer::new(
provider.clone(),
config.mainline_read_utilization_high_percent,
config.mainline_write_utilization_high_percent,
config.mainline_max_sleep,
request.source,
)
.map(Arc::new)
})
}
impl HealManager {
/// Start scheduler
pub(super) async fn start_scheduler(&self) -> Result<()> {
@@ -202,19 +226,7 @@ impl HealManager {
.unwrap_or_else(|poisoned| poisoned.into_inner())
.get(&request.id)
.cloned();
let mainline_pacer = if request.source == HealRequestSource::Admin && config.mainline_throttle_enable {
workload_provider.as_ref().and_then(|provider| {
crate::heal::pacing::MainlinePacer::new(
provider.clone(),
config.mainline_read_utilization_high_percent,
config.mainline_write_utilization_high_percent,
config.mainline_max_sleep,
)
.map(Arc::new)
})
} else {
None
};
let mainline_pacer = running_mainline_pacer(&request, &config, workload_provider);
let task = Arc::new(
HealTask::from_replacement_recovery_request(request, storage.clone(), replacement_resume_endpoint)
.with_mainline_pacer(mainline_pacer)
@@ -582,7 +594,7 @@ impl HealManager {
return;
}
let admission_decision =
Self::admit_request_to_queue(&mut queue, retry_request.clone(), &retry_config, "retry");
Self::admit_request_to_queue(&mut queue, retry_request.clone(), &retry_config, "retry", true);
let admission = admission_decision.result;
let should_notify = matches!(admission, HealAdmissionResult::Accepted)
&& retry_config.event_driven_scheduler_enable;
+295
View File
@@ -2505,6 +2505,301 @@ fn test_heal_request_and_task_metric_labels_match() {
assert_eq!(task.metric_set_label(), "pool_0_set_1");
}
#[tokio::test]
async fn automatic_replacement_queued_targets_merge_without_changing_fifo_or_token() {
let manager = HealManager::new_without_root_recovery_for_test(Arc::new(MockStorage), None);
let owner = automatic_replacement_request(&["replacement-a"]);
let owner_id = owner.id.clone();
let created_at = owner.created_at;
let first = manager
.submit_heal_request_with_receipt(owner)
.await
.expect("queue first target");
assert_eq!(first.result, HealAdmissionResult::Accepted);
let other = HealRequest::object("bucket".to_string(), "unrelated".to_string(), None);
let other_id = other.id.clone();
manager.submit_heal_request(other).await.expect("queue unrelated work");
let merged = manager
.submit_heal_request_with_receipt(automatic_replacement_request(&["replacement-b", "replacement-a"]))
.await
.expect("merge all fresh targets");
assert_eq!(merged.result, HealAdmissionResult::Merged);
assert_eq!(merged.task_id, owner_id);
let mut queue = manager.heal_queue.lock().await;
assert_eq!(queue.len(), 2);
let owner = queue.pop_next().expect("original task remains first");
assert_eq!(owner.id, owner_id);
assert_eq!(owner.created_at, created_at);
assert_eq!(owner.heal_endpoints, ["replacement-a", "replacement-b"]);
assert_eq!(queue.pop_next().expect("unrelated task remains queued").id, other_id);
}
fn automatic_replacement_request(targets: &[&str]) -> HealRequest {
let mut request = HealRequest::new(
HealType::ErasureSet {
buckets: Vec::new(),
set_disk_id: "pool_0_set_1".to_string(),
},
HealOptions {
pool_index: Some(0),
set_index: Some(1),
..Default::default()
},
HealPriority::Normal,
);
request.source = HealRequestSource::AutoHeal;
request.heal_endpoints = targets.iter().map(|target| (*target).to_string()).collect();
request
}
#[tokio::test]
async fn automatic_replacement_frozen_targets_reject_late_disks_but_cover_duplicates() {
for state in ["active", "retrying", "grace", "recovered", "recovery_anchor"] {
let storage: Arc<dyn HealStorageAPI> = Arc::new(MockStorage);
let manager = HealManager::new_without_root_recovery_for_test(storage.clone(), None);
let mut owner = automatic_replacement_request(&["replacement-a"]);
let owner_id = owner.id.clone();
if state == "grace" {
let now = SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("test clock")
.as_secs();
let task = HealTask::from_request(owner, storage.clone());
owner = retry_request_for_result_with_budget(
&task,
&Err(Error::DanglingDeleteDeferred {
retry_not_before: now + 60,
}),
)
.await
.expect("grace wait keeps the generation replayable")
.0;
assert_eq!(owner.retry_attempts, 0, "grace must not consume a failure retry");
}
match state {
"active" => {
manager
.active_heals
.lock()
.await
.insert(owner_id.clone(), Arc::new(HealTask::from_request(owner, storage.clone())));
}
"retrying" => {
manager.retrying_heals.lock().await.insert(
owner_id.clone(),
RetryingHeal {
request: owner,
error: "target unavailable".to_string(),
cancel_token: CancellationToken::new(),
},
);
}
"grace" | "recovered" => {
let config = manager.config.read().await.clone();
let mut queue = manager.heal_queue.lock().await;
let context = if state == "grace" { "retry" } else { "auto_scan" };
assert_eq!(
HealManager::admit_request_to_queue(&mut queue, owner, &config, context, true).result,
HealAdmissionResult::Accepted
);
assert!(
queue
.heap
.iter()
.any(|item| item.request.id == owner_id && item.replacement_targets_frozen),
"zero retries cannot mean the scope is fresh"
);
}
_ => {
manager.submit_heal_request(owner).await.expect("queue frozen generation");
if state == "recovery_anchor" {
manager
.replacement_recovery_anchors
.lock()
.expect("recovery anchors")
.insert(owner_id.clone(), "survivor".to_string());
}
}
}
let late = manager
.submit_heal_request(automatic_replacement_request(&["replacement-b"]))
.await
.expect("classify late target");
assert_eq!(late, HealAdmissionResult::Dropped(HealAdmissionDropReason::AlreadyRunning), "{state}");
let covered = manager
.submit_heal_request_with_receipt(automatic_replacement_request(&["replacement-a"]))
.await
.expect("covered target remains owned");
assert_eq!(covered.result, HealAdmissionResult::Merged, "{state}");
assert_eq!(covered.task_id, owner_id, "{state}");
let targets = match state {
"active" => manager.active_heals.lock().await[&owner_id].heal_endpoints.clone(),
"retrying" => manager.retrying_heals.lock().await[&owner_id].request.heal_endpoints.clone(),
_ => {
manager
.heal_queue
.lock()
.await
.pop_next()
.expect("retained generation")
.heal_endpoints
}
};
assert_eq!(targets, ["replacement-a"], "{state}: frozen scope must not grow");
}
}
#[tokio::test]
async fn automatic_replacement_merge_preserves_options_policy_and_recovery_identity() {
let manager = HealManager::new_without_root_recovery_for_test(Arc::new(MockStorage), None);
manager
.submit_heal_request(automatic_replacement_request(&["replacement-a"]))
.await
.expect("queue fresh generation");
for variant in [
"dry_run",
"bucket_scope",
"pool_scope",
"retried_incoming",
"recovery_anchor",
"merge_disabled",
] {
let mut incoming = automatic_replacement_request(&["replacement-b"]);
match variant {
"dry_run" => incoming.options.dry_run = true,
"bucket_scope" => {
if let HealType::ErasureSet { buckets, .. } = &mut incoming.heal_type {
buckets.push("other-bucket".to_string());
}
}
"pool_scope" => incoming.options.pool_index = Some(2),
"retried_incoming" => {
incoming.heal_endpoints = vec!["replacement-a".to_string()];
incoming.retry_attempts = 1;
}
"recovery_anchor" => {
incoming.heal_endpoints = vec!["replacement-a".to_string()];
manager
.replacement_recovery_anchors
.lock()
.expect("recovery anchors")
.insert(incoming.id.clone(), "survivor".to_string());
}
"merge_disabled" => {
manager.config.write().await.low_priority_merge_enable = false;
incoming.priority = HealPriority::Low;
}
_ => unreachable!("test variant"),
}
let result = manager
.submit_heal_request(incoming)
.await
.expect("classify incompatible generation");
let reason = if variant == "merge_disabled" {
HealAdmissionDropReason::PolicyDropped
} else {
HealAdmissionDropReason::AlreadyRunning
};
assert_eq!(result, HealAdmissionResult::Dropped(reason), "{variant}");
let queue = manager.heal_queue.lock().await;
assert_eq!(queue.len(), 1, "{variant}");
assert_eq!(
queue.requests().next().expect("original generation").heal_endpoints,
["replacement-a"],
"{variant}"
);
}
}
#[test]
fn automatic_replacement_direct_admission_checks_frozen_targets_before_capacity() {
let config = HealConfig {
queue_size: 1,
..HealConfig::default()
};
let mut queue = PriorityHealQueue::new();
let owner = automatic_replacement_request(&["replacement-a"]);
let owner_id = owner.id.clone();
assert_eq!(queue.push(owner), QueuePushOutcome::Accepted);
let admit = |queue: &mut PriorityHealQueue, request, frozen| {
HealManager::admit_request_to_queue(queue, request, &config, "auto_scan", frozen).result
};
assert_eq!(
admit(&mut queue, automatic_replacement_request(&["replacement-b"]), false),
HealAdmissionResult::Merged
);
let mut replay = automatic_replacement_request(&["replacement-a"]);
replay.id = owner_id.clone();
assert_eq!(admit(&mut queue, replay, true), HealAdmissionResult::Merged);
assert!(
queue
.heap
.iter()
.any(|item| item.request.id == owner_id && item.replacement_targets_frozen)
);
assert_eq!(
admit(&mut queue, automatic_replacement_request(&["replacement-c"]), false),
HealAdmissionResult::Dropped(HealAdmissionDropReason::AlreadyRunning)
);
assert_eq!(
admit(&mut queue, automatic_replacement_request(&["replacement-a"]), false),
HealAdmissionResult::Merged
);
assert_eq!(
admit(&mut queue, automatic_replacement_request(&["replacement-a"]), true),
HealAdmissionResult::Dropped(HealAdmissionDropReason::AlreadyRunning)
);
assert_eq!(queue.len(), 1);
let owner = queue.pop_next().expect("frozen owner retained");
assert_eq!(owner.id, owner_id);
assert_eq!(owner.heal_endpoints, ["replacement-a", "replacement-b"]);
let owner = automatic_replacement_request(&["replacement-a"]);
let owner_id = owner.id.clone();
assert_eq!(queue.push(owner), QueuePushOutcome::Accepted);
let mut conflicting_replay = automatic_replacement_request(&["replacement-b"]);
conflicting_replay.id = owner_id.clone();
assert_eq!(
admit(&mut queue, conflicting_replay, true),
HealAdmissionResult::Dropped(HealAdmissionDropReason::AlreadyRunning)
);
assert!(
queue
.heap
.iter()
.any(|item| item.request.id == owner_id && item.replacement_targets_frozen)
);
assert_eq!(
admit(&mut queue, automatic_replacement_request(&["replacement-c"]), false),
HealAdmissionResult::Dropped(HealAdmissionDropReason::AlreadyRunning)
);
let mut queue = PriorityHealQueue::new();
let owner = automatic_replacement_request(&["replacement-a"]);
let owner_id = owner.id.clone();
assert_eq!(queue.push(owner), QueuePushOutcome::Accepted);
let mut incompatible_replay = automatic_replacement_request(&["replacement-a"]);
incompatible_replay.id = owner_id.clone();
incompatible_replay.options.dry_run = true;
assert_eq!(
admit(&mut queue, incompatible_replay, true),
HealAdmissionResult::Dropped(HealAdmissionDropReason::AlreadyRunning)
);
assert!(
queue
.heap
.iter()
.any(|item| item.request.id == owner_id && item.replacement_targets_frozen)
);
assert_eq!(
admit(&mut queue, automatic_replacement_request(&["replacement-b"]), false),
HealAdmissionResult::Dropped(HealAdmissionDropReason::AlreadyRunning)
);
}
#[tokio::test]
async fn test_submit_heal_request_returns_merged_for_duplicate() {
let storage: Arc<dyn HealStorageAPI> = Arc::new(MockStorage);
@@ -258,6 +258,16 @@ impl ScannerFixture {
assert_eq!(request.id, self.task_id, "scanner must replay the durable UUID");
assert_eq!(request.heal_endpoints, vec![self.local_targets[0].clone()]);
assert_eq!(request.source, HealRequestSource::AutoHeal);
assert!(
self.manager
.heal_queue
.lock()
.await
.heap
.iter()
.any(|item| item.request.id == request.id && item.replacement_targets_frozen),
"replayed durable targets cannot expand"
);
assert!(matches!(
request.heal_type,
HealType::ErasureSet { set_disk_id, buckets }
@@ -6,6 +6,41 @@ use crate::heal::storage::HealListItem;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use tokio::sync::Semaphore;
#[tokio::test(start_paused = true)]
async fn automatic_replacement_uses_running_foreground_pacing() {
let provider = Arc::new(super::super::super::pacing::TestPressure::new(WorkloadClass::ForegroundRead, 100));
let provider: Option<WorkloadSnapshotProviderRef> = Some(provider);
let mut config = HealConfig {
mainline_throttle_enable: true,
mainline_read_utilization_high_percent: 80,
mainline_max_sleep: Duration::from_millis(250),
..HealConfig::default()
};
let mut request = HealRequest::new(
HealType::ErasureSet {
buckets: Vec::new(),
set_disk_id: "pool_0_set_0".to_owned(),
},
HealOptions::default(),
HealPriority::Low,
);
request.source = HealRequestSource::AutoHeal;
request.heal_endpoints = vec!["replacement-a".to_owned()];
let pacer = super::super::scheduler::running_mainline_pacer(&request, &config, &provider)
.expect("automatic replacement must pace under foreground pressure");
let started = tokio::time::Instant::now();
pacer.wait(&CancellationToken::new()).await.expect("bounded progress");
assert_eq!(started.elapsed(), Duration::from_millis(250));
assert!(super::super::scheduler::running_mainline_pacer(&request, &config, &None).is_none());
config.mainline_throttle_enable = false;
assert!(super::super::scheduler::running_mainline_pacer(&request, &config, &provider).is_none());
config.mainline_throttle_enable = true;
request.heal_endpoints.clear();
assert!(super::super::scheduler::running_mainline_pacer(&request, &config, &provider).is_none());
}
#[derive(Default)]
struct PressureProbe {
active: AtomicUsize,
+15 -7
View File
@@ -6,6 +6,7 @@ use rustfs_concurrency::{
WorkloadAdmissionSnapshotProvider,
workload::{ForegroundPressure, foreground_pressure},
};
use rustfs_heal_contracts::heal_channel::HealRequestSource;
use std::{sync::Arc, time::Duration};
use tokio::{sync::Mutex, time::Instant};
use tokio_util::sync::CancellationToken;
@@ -21,12 +22,13 @@ pub(crate) enum PacingDecision {
Wait(Option<ForegroundPressure>),
}
/// Cooperative pacing for one admin execution, not a storage admission permit.
/// Cooperative pacing for one heal execution, not a storage admission permit.
pub(crate) struct MainlinePacer {
provider: Arc<dyn WorkloadAdmissionSnapshotProvider + Send + Sync>,
read_high: usize,
write_high: usize,
pause: Duration,
source: HealRequestSource,
state: Mutex<PacingState>,
}
@@ -36,6 +38,7 @@ impl MainlinePacer {
read_high: usize,
write_high: usize,
pause: Duration,
source: HealRequestSource,
) -> Option<Self> {
if (read_high == 0 && write_high == 0) || pause.is_zero() {
return None;
@@ -45,6 +48,7 @@ impl MainlinePacer {
read_high: read_high.min(100),
write_high: write_high.min(100),
pause: pause.min(Duration::from_secs(1)),
source,
state: Mutex::new(PacingState::default()),
})
}
@@ -108,7 +112,7 @@ impl MainlinePacer {
}
metrics::counter!(
"rustfs_heal_mainline_throttle_total",
"source" => "admin",
"source" => self.source.as_str(),
"result" => "delayed",
"reason" => pressure.or(observed).map_or("recovery_window", |pressure| pressure.reason())
)
@@ -170,6 +174,7 @@ mod tests {
if class == WorkloadClass::ForegroundRead { 40 } else { 0 },
if class == WorkloadClass::ForegroundWrite { 40 } else { 0 },
pause,
HealRequestSource::Admin,
)
.expect("enabled pacer");
let cancel = CancellationToken::new();
@@ -192,9 +197,9 @@ mod tests {
#[tokio::test(start_paused = true)]
async fn running_mainline_huge_pause_is_capped_and_disabled_classes_do_not_sleep() {
let provider = Arc::new(TestPressure::new(WorkloadClass::ForegroundRead, 100));
assert!(MainlinePacer::new(provider.clone(), 0, 0, Duration::from_secs(1)).is_none());
assert!(MainlinePacer::new(provider.clone(), 80, 80, Duration::ZERO).is_none());
let pacer = MainlinePacer::new(provider, 80, 80, Duration::from_secs(3600)).expect("pacer");
assert!(MainlinePacer::new(provider.clone(), 0, 0, Duration::from_secs(1), HealRequestSource::Admin).is_none());
assert!(MainlinePacer::new(provider.clone(), 80, 80, Duration::ZERO, HealRequestSource::Admin).is_none());
let pacer = MainlinePacer::new(provider, 80, 80, Duration::from_secs(3600), HealRequestSource::Admin).expect("pacer");
let before = Instant::now();
pacer.wait(&CancellationToken::new()).await.expect("hard-capped pause");
assert_eq!(Instant::now() - before, Duration::from_secs(1));
@@ -203,7 +208,9 @@ mod tests {
#[tokio::test(start_paused = true)]
async fn running_mainline_waiters_cancel_and_task_latches_are_isolated() {
let provider = Arc::new(TestPressure::new(WorkloadClass::ForegroundRead, 100));
let paced = Arc::new(MainlinePacer::new(provider.clone(), 80, 80, Duration::from_secs(1)).expect("pacer"));
let paced = Arc::new(
MainlinePacer::new(provider.clone(), 80, 80, Duration::from_secs(1), HealRequestSource::Admin).expect("pacer"),
);
let cancel_first = CancellationToken::new();
let first = tokio::spawn({
let paced = paced.clone();
@@ -221,7 +228,8 @@ mod tests {
cancel_second.cancel();
assert!(matches!(second.await.expect("queued waiter"), Err(Error::TaskCancelled)));
provider.active.store(0, Ordering::SeqCst);
let other_task = MainlinePacer::new(provider.clone(), 80, 80, Duration::from_secs(1)).expect("independent task");
let other_task = MainlinePacer::new(provider.clone(), 80, 80, Duration::from_secs(1), HealRequestSource::Admin)
.expect("independent task");
let before = Instant::now();
other_task
.wait(&CancellationToken::new())
+8 -4
View File
@@ -489,9 +489,13 @@ impl HealTask {
))
.await?;
if let Some(error) = storage_result.error {
return Err(Error::TaskExecutionFailed {
message: format!("Failed to recreate missing object {bucket}/{object}: {error}"),
});
if error.is_dangling_delete_grace() {
// Preserve the existing MRF grace retry ownership.
return Err(Error::TaskExecutionFailed {
message: format!("Failed to recreate missing object {bucket}/{object}: {error}"),
});
}
return Err(error);
}
let object_size = storage_result.item.object_size as u64;
@@ -526,7 +530,7 @@ impl HealTask {
}
} else {
Error::TaskExecutionFailed {
message: format!("Missing exact storage proof for durable MRF repair {bucket}/{object}"),
message: format!("{}{bucket}/{object}", crate::error::MRF_MISSING_PROOF_PREFIX),
}
});
}
+38
View File
@@ -2912,6 +2912,44 @@ fn mrf_task_for_item(object: &str, item: HealResultItem) -> (HealTask, Arc<MockS
(HealTask::from_request(request, storage.clone()), storage)
}
#[tokio::test]
async fn durable_mrf_slowdown_remains_retryable_without_a_storage_receipt() {
let (task, storage) = mrf_task_for_item("object", HealResultItem::default());
*storage.heal_object_outcome.lock().unwrap() = Some(MockHealObjectOutcome::RetryableSlowDown);
let error = task
.execute()
.await
.expect_err("a failed repair cannot discharge the responsibility");
assert!(matches!(error, Error::Storage(EcstoreError::SlowDown)), "{error:?}");
assert!(error.is_recoverable_heal());
assert_eq!(task.get_outcome().await.counters.healed, 0);
}
#[tokio::test]
async fn durable_mrf_grace_remains_owned_by_the_responsibility() {
let (task, storage) = mrf_task_for_item("object", HealResultItem::default());
*storage.heal_object_outcome.lock().unwrap() = Some(MockHealObjectOutcome::DanglingGraceDeferred);
let error = task.execute().await.expect_err("grace must retain the responsibility");
assert!(error.is_dangling_delete_grace());
assert!(!error.is_recoverable_heal());
assert_eq!(task.get_outcome().await.counters.healed, 0);
}
#[tokio::test]
async fn durable_mrf_unverified_object_names_cannot_change_retry_classification() {
for object in ["ordinary", "timed out", "network error"] {
let (task, _) = mrf_task_for_item(object, HealResultItem::default());
let error = task
.execute()
.await
.expect_err("missing proof must retain the responsibility");
assert!(!error.is_recoverable_heal(), "object name must not change retry classification: {object}");
assert_eq!(task.get_outcome().await.counters.healed, 0);
}
}
#[tokio::test]
async fn mrf_legacy_object_names_cannot_change_retry_classification() {
for object in [
@@ -0,0 +1,29 @@
// Copyright 2026 RustFS Team
// Licensed under the Apache License, Version 2.0.
use rustfs_heal::{HealOptions, HealPriority, HealRequest, HealType};
use rustfs_heal_contracts::heal_channel::HealRequestSource;
use std::time::SystemTime;
#[test]
fn existing_heal_request_literal_remains_constructible() {
let now = SystemTime::now();
let request = HealRequest {
id: "request".to_string(),
heal_type: HealType::ErasureSet {
set_disk_id: "pool_0_set_0".to_string(),
buckets: Vec::new(),
},
bucket_incarnation_id: None,
expected_mrf_bucket_incarnation_id: None,
options: HealOptions::default(),
priority: HealPriority::Normal,
source: HealRequestSource::AutoHeal,
force_start: false,
retry_attempts: 0,
heal_endpoints: vec!["drive-a".to_string()],
created_at: now,
enqueued_at: now,
};
assert_eq!(request.heal_endpoints, ["drive-a"]);
}
+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"));
}
}
}
}

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