Compare commits

..

384 Commits

Author SHA1 Message Date
overtrue 1f8d013b8d merge: sync main and preserve prepared delete ownership 2026-10-25 22:13:43 +08:00
Ramakrishna Chilaka ceddcdd878 perf(ecstore): coalesce delete inspection, settlement, and durability (#8932)
Eliminate redundant delete recovery inspections, inline-object data-dir scans, redundant directory fsyncs, and Fsync/Foreground worker-lane ping-pong during single-key and batch S3 DELETE commits.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: d85aaf47-2a4b-4c2c-97a3-dbe8c7717e2a
2026-10-25 21:52:11 +08:00
overtrue b8858d0c70 test(ecstore): preserve control progress during physical pause 2026-10-25 21:46:10 +08:00
Chris An 7728eeab1a feat(heal): advertise MRF lifecycle reader capability (#8916)
* feat(heal): read retained MRF lifecycle v2 checkpoints

* feat(heal): advertise MRF lifecycle reader capability
2026-10-25 21:08:43 +08:00
overtrue 4fd444852c fix(ecstore): compile legacy delete helpers only in tests 2026-10-25 20:56:56 +08:00
Hauser 13dc635d51 feat(scanner): expose startup phases and attempt deadlines (#8931)
* feat(scanner): expose startup phases and attempt deadlines

Share one node-local startup snapshot between native status and metrics.
Track actual waits and first attempts without changing admission timing,
coverage acknowledgement, or publication semantics.

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

* fix(scanner): observe startup recovery before initialization

Create the observation owner at enabled startup entry and move it through
the existing initializer, preserving admission timing and disabled cleanup.
Cover blocked recovery discovery and cancellation through the public entry.

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-25 20:54:41 +08:00
overtrue 05f84793a3 test(ecstore): use the scoped metadata fixture setter 2026-10-25 20:46:44 +08:00
overtrue 2fa9a433ad fix(ecstore): compare multipart metadata keys in commit scope 2026-10-25 20:46:28 +08:00
overtrue 08811ec243 fix(ecstore): fail closed on multipart recovery lock contention 2026-10-25 20:43:41 +08:00
overtrue 0f89e9b8b8 chore: merge fixed main ead77d7 into plugin integration 2026-10-25 20:33:46 +08:00
overtrue de8b465c41 fix(ecstore): batch concurrent fresh authority reads 2026-10-25 20:15:49 +08:00
Jim Chen ead77d799f fix(s3): normalize UploadPartCopy source versionId and populate source versioning state (#8890)
* fix(s3): normalize UploadPartCopy source versionId and populate source versioning state

execute_upload_part_copy forwards CopySource::Bucket.version_id verbatim without normalizing 'null' to Uuid::nil() or validating UUID syntax up front (returning 500 InternalError instead of succeeding on versionId=null or returning 400 InvalidArgument on malformed versionId), and neither execute_upload_part_copy nor execute_copy_object populates src_opts.versioned / src_opts.version_suspended from BucketVersioningSys.

Root Cause:
Unlike execute_copy_object (issue #4238) and get_opts, execute_upload_part_copy in rustfs/src/app/multipart_usecase.rs extracts version_id from CopySource::Bucket without trimming, converting 'null' to Uuid::nil().to_string(), or validating UUID syntax via Uuid::parse_str, causing prepare_get_object_reader to fail with 500 InternalError when versionId=null or an invalid UUID is passed in x-amz-copy-source. Additionally, neither execute_upload_part_copy nor execute_copy_object populates src_opts.versioned and src_opts.version_suspended from BucketVersioningSys, and execute_upload_part_copy returns x-amz-copy-source-version-id without gating on whether versioning is enabled or suspended on the source bucket (issue #4976).

Fix:
Add normalize_copy_source_version_id() and format_copy_source_version_id() in rustfs/src/app/multipart_usecase.rs, populate src_opts.versioned and src_opts.version_suspended from BucketVersioningSys on both UploadPartCopy and CopyObject, and gate x-amz-copy-source-version-id on source bucket versioning state.

Signed-off-by: czcbangkai <czcbangkai@google.com>

* fix(s3): keep copy version validation within footprint guard

---------

Signed-off-by: czcbangkai <czcbangkai@google.com>
Co-authored-by: overtrue <anzhengchao@gmail.com>
Co-authored-by: Hauser <housemecn@gmail.com>
2026-10-25 20:06:43 +08:00
Chris An 67db0f241f fix(test): follow scanner schedules during multi-pool recovery (#8929)
* fix(test): follow scanner schedules during multi-pool recovery

* fix(test): retain active scanner completion budget

* test: preserve E2E membership for scanner wait regression
2026-10-25 20:00:13 +08:00
Chris An a94f625bc8 test(rio): cover maximum index varint totals (#8915) 2026-10-25 18:40:41 +08:00
Hauser b7835f0f9a test(ecstore): pin historical compression index reads (#8925)
* test(ecstore): pin historical compression index reads

Exercise fixed pre-varint index bytes through the production reader,
including full and ranged reads, index fallback, and body CRC failure.

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

* test(ecstore): use typed compression plan offsets

Use the existing usize storage offsets directly in the compatibility fixtures, fixing the same-type conversion errors reported by Clippy. Keep the planned-offset and decoded-byte assertions unchanged.

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-25 18:33:25 +08:00
Hauser 299609bb83 fix(quota): keep reads available without snapshot-wide copies (#8928)
* fix(quota): keep quota reads available during scans

Use optional scanner usage for read-only quota statistics across all
quota configurations. Preserve strict legacy write admission and durable
reservation enforcement while keeping unknown usage distinct from zero.

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

* perf(usage): avoid snapshot copies for single-bucket reads

Project owned snapshots, scalar bucket sizes and refresh-only results
through the same TTL-bounded cache loader. Move loaded snapshots into
the cache without changing refresh serialization or generation fencing.

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-25 18:06:35 +08:00
Hauser 1800b90d06 chore(deps): update flake.lock (#8922)
Flake lock file updates:

• Updated input 'nixpkgs':
    'github:NixOS/nixpkgs/a32edd7' (2026-09-17)
  → 'github:NixOS/nixpkgs/674a202' (2026-10-24)
• Updated input 'rust-overlay':
    'github:oxalica/rust-overlay/26a71e6' (2026-09-19)
  → 'github:oxalica/rust-overlay/5b9f119' (2026-10-24)
2026-10-25 15:50:27 +08:00
Hauser 2ccc8d8647 fix(admin): decouple target inventory from runtime convergence (#8921)
* fix(admin): list destinations without runtime reconciliation

Read configured notification destinations directly from persisted config and
merge existing runtime health without starting target reconciliation.

Cover blocked reconciliation, absent and live-only runtimes, corrupt config,
and authorization precedence through the real admin handler.

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

* refactor(admin): unify passive target inventory reads

Read one durable configuration alongside existing runtime observations for
event, audit, plugin, and extension inventories. Share the configuration
across plugin domains and retain strict mutation readiness separately.

Propagate module-state read failures and cover fresh configuration, secret
redaction, concurrent reads, cancellation, and runtime independence.

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

* fix(admin): route inventory errors through the S3 facade

Keep the shared target reader behind the existing admin protocol boundary
and reuse its error factory without changing error codes or messages.

Tighten the error-macro ratchet to the actual reduced footprint.

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-25 15:11:27 +08:00
Chris An 124cbfc00a feat(heal): read retained MRF lifecycle v2 checkpoints (#8913) 2026-10-25 14:34:57 +08:00
Jim Chen 3aa7964c58 fix(concurrency): prevent premature Workers::wait drain completion on slot handoff (#8892)
Workers::give() decrements in_use to 0 before calling semaphore.add_permits(1), while a queued Workers::take() task only increments in_use after its woken future is scheduled and polled, allowing Workers::wait() to return prematurely while a woken worker is still in flight.

Root Cause:
In crates/concurrency/src/workers.rs, Workers::give() decrements in_use inside self.in_use.send_modify() before calling self.semaphore.add_permits(1), while Workers::take() only increments in_use after self.semaphore.acquire().await returns and the woken task is scheduled and polled. During the handoff window when the last active worker calls give() and transfers its semaphore permit to a queued take() waiter (so semaphore.available_permits() < limit), in_use is 0, causing Workers::wait() (rx.wait_for(|&held| held == 0)) to complete prematurely while work is still in flight and Workers::available() to over-report available capacity.

Fix:
Require both held == 0 and self.semaphore.available_permits() == self.limit in Workers::wait(), notify self.in_use watchers after self.semaphore.add_permits(1) in Workers::give() and via an AcquireCancelGuard in Workers::take(), and bound Workers::available() by self.semaphore.available_permits().

Signed-off-by: czcbangkai <czcbangkai@google.com>
Co-authored-by: Hauser <housemecn@gmail.com>
2026-10-25 14:22:35 +08:00
Ramakrishna Chilaka 42d376c6b7 perf(ecstore): isolate durable I/O and streamline disk dispatch (#8902)
* perf(ecstore): unify per-disk blocking workers with spin keep-alive and coalesce DELETE hops

Unify per-disk blocking I/O and fsync workers under a 3-lane DiskBlockingQueue
(Foreground, Isolated, Fsync) with MAX_NAMESPACE_WORKERS_PER_DISK = 1 and a
bounded user-space spin/yield keep-alive (WORKER_IDLE_SPIN_ITERS = 256), and
coalesce redundant blocking dispatches along the DELETE hot path:

- Expand DiskBlockingQueue to 3 lanes (Foreground, Isolated, Fsync) with 1
  warm worker per disk/lane and a 256-iteration spin/yield keep-alive before
  exiting spawn_blocking, preventing Tokio BlockingPool shared.idle futex
  wake/wait thrashing between back-to-back stages of PUT and DELETE.
- Route fsync_dir, fsync_dir_with_owner, and namespace file-sync barriers
  through the per-disk Fsync lane on FSYNC_RUNTIME, and replace LocalDisk's
  duplicate MetadataReadQueue with LocalDisk::run_metadata_read backed by
  self.blocking_queue.
- Route write_all_internal(Owned), open_file(O_TRUNC), remove_file_with_owner,
  and remove_dir_with_owner through DiskBlockingQueue with pre-execution
  cancellation checks.
- Coalesce DELETE directory scans in commit_reserved_version_delete and
  finish_version_delete into single blocking passes with periodic cancellation
  polling, batch rollback file writes in write_delete_rollback_files, batch
  rollback unlinks + directory fsync in remove_files_and_fsync_with_owner, and
  coalesce post-rename directory fsyncs in activate_delete_preparation.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: d85aaf47-2a4b-4c2c-97a3-dbe8c7717e2a

* perf(ecstore): isolate durable work and reduce idle queue locking

Route coalesced writes and delete settlement through the fsync lane while preserving namespace leases, cancellation ownership, and durability ordering. Keep relaxed work on the foreground lane and replace idle mutex polling with an advisory atomic hint; dequeue and worker retirement remain mutex-protected.

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

* fix(ecstore): yield disk workers across shared blocking pools

Limit each draining task to 64 completed jobs and reuse the worker guard to reschedule remaining work behind other disks. Preserve per-disk FIFO, worker limits, and per-operation ownership. Add a deterministic two-disk regression with one blocking thread.

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

* fix(ecstore): release queued jobs when workers cannot start

Construct the worker guard before submission and track whether execution began. Release pending jobs outside the lane mutex when the last worker is rejected, so waiters, resource units, and self-owning namespace leases cannot remain stranded. Cover closed runtimes and shutdown during batch handoff.

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

* test(connect): synchronize restart with observed inventory

Wait for the received POST instead of treating transport backoff as proof of delivery. Inject a pre-HTTP connection drop and use a verified loopback IP fixture without changing timeout or replay assertions.

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

* refactor(ecstore): unify disk worker lifecycle and drain loop

Represent submitted, running, and retired workers explicitly. Share dequeue, execution, idle recheck, and retirement in one loop while preserving bounded handoff and rejected-task ownership cleanup.

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

* test(storage): separate real I/O from paused-clock assertions

Complete durable fixtures before pausing virtual time, drive the immediate scanner tick before freezing time during filesystem waits, and keep checkpoint-backed retry accounting on a real clock. Preserve default fsync isolation, disk deadlines, retry counts, and all outcome assertions.

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Hauser <housemecn@gmail.com>
Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
Copilot-Session: d85aaf47-2a4b-4c2c-97a3-dbe8c7717e2a
2026-10-25 14:22:27 +08:00
Hauser 017b85a5bf fix(ecstore): preserve inactive multipart abort authority (#8920)
Retain per-disk recovery metadata through partial publication and require write-quorum proof for completion replay. Keep completed uploads terminal and strengthen RPC recovery acceptance.

Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-10-25 14:05:19 +08:00
Chris An 55023ecd86 fix(rebalance): move a version a delete-all expiry would apply inline (#8917)
A delete-all lifecycle expiry is a prefix delete that takes the bucket
lifecycle write lock, while the rebalance entry holds that lock for read
through the inline apply and its bucket incarnation fence. The write
waited out the lock timeout, stalled every lifecycle read in the bucket,
and failed the entry on every retry.

Like decommission (#8777), rebalance now moves such a version live and
lifecycle expires it on the target.

Refs rustfs/backlog#2873
2026-10-25 11:41:31 +08:00
overtrue 03f77844b7 chore: sync main listing fixes for plugin integration 2026-10-25 11:36:42 +08:00
Chris An 1cd62c4249 fix(listing): make page exhaustion explicit, carry limit stops in-band (#8908)
The listing pipeline signalled "this page exhausted the namespace" by
setting `MetaCacheEntriesSortedResult.err = Some(Error::Unexpected)` and
had every consumer filter that variant, so any future `Unexpected` on the
producer path would have been read as a complete listing. A second truth
source, the shared `producer_limit_reached` atomic, was last-writer-wins
across the disks of a page and was never serialized to remote peers.

Collectors now return `ListPage { entries, exhausted }`, errors surface as
errors, and the early empty-listing exits return an exhausted empty page.
The producer's stop reason travels in the metacache stream itself:
`MetacacheWriter::close_with_limit_reached` appends it after the `false`
terminator as repeated terminator markers (the only trailing bytes a
deployed reader tolerates, because `list_path_raw_inner` keeps polling
readers after EOF), `MetacacheReader::limit_reached` decodes it, and
`list_path_raw_inner` folds every reader's trailer into a
`ListPathRawOutcome` that reaches the collector. Remote disks therefore
report their per-disk limit stops as well, and the `producer_limit_reached`
fields on `WalkDirOptions`, `ListPathRawOptions` and `ListPathOptions` are
removed.
2026-10-25 11:07:32 +08:00
overtrue b44c76db52 test(e2e): simplify inventory request error propagation 2026-10-25 10:51:20 +08:00
overtrue d0096af742 test(rpc): bound unavailable recovery assertion frame 2026-10-25 10:25:19 +08:00
overtrue 35e07b44f4 test(ecstore): retain lifecycle ownership in Select fixture 2026-10-25 10:00:05 +08:00
overtrue 7d91176cea fix(ecstore): include integrity indexes in artifact inventory 2026-10-25 09:50:25 +08:00
overtrue 001d935569 test(windows): cover artifact prefix admission 2026-10-25 09:32:54 +08:00
overtrue cc157adca8 fix(ecstore): retain typed lifecycle owner after upload cancellation 2026-10-25 08:57:12 +08:00
overtrue e0917fb617 fix(ecstore): allow sibling multipart publications 2026-10-25 08:57:12 +08:00
overtrue 6a30694765 perf(rpc): avoid duplicate delete recovery authority reads 2026-10-25 08:57:12 +08:00
overtrue 991d647c78 chore: merge main into plugin runner integration 2026-10-25 08:57:02 +08:00
overtrue aafa85c6e5 test(ecstore): observe Linux physical rename tails 2026-10-25 08:45:06 +08:00
overtrue 01f5aa9c3a fix(ecstore): retain lifecycle owner during migration cleanup 2026-10-25 08:43:45 +08:00
overtrue e27bfb2006 test: remove redundant values in plugin boundary tests 2026-10-25 08:43:45 +08:00
overtrue f095c6a476 refactor(plugins): reuse object references and align inventory tests 2026-10-25 05:22:00 +08:00
overtrue a223e74c83 fix(ecstore): bound the bucket creation handler future 2026-10-25 05:10:30 +08:00
overtrue 1d1964215d fix(storage): validate artifact namespace write limits 2026-10-25 05:10:30 +08:00
overtrue 5b23462652 chore: sync integration with main 2026-10-25 05:05:16 +08:00
overtrue 02f6d87201 refactor(ecstore): simplify prepared deletion arguments 2026-10-25 05:05:13 +08:00
overtrue 37a54d3ff2 fix(storage): reject empty artifact prefix components 2026-10-25 05:05:13 +08:00
overtrue d1aaaee9bf refactor(ecstore): group storage targets and simplify assertions 2026-10-25 04:59:16 +08:00
Hauser 0f24511535 test(e2e): harden multipart fault and TLS acceptance (#8907)
* test(e2e): tighten scoped multipart fault acceptance

Target Abort root deletion instead of child transaction cleanup, require
actual metadata peer hits, and keep faulted APIs single-attempt. Validate
bounded ordinary recovery and safe quorum refusal for partial mutations,
with the recovery enhancement tracked in backlog#2940.

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

* fix(e2e): drive concurrent TLS reads independently

Give each coordinator its own supervised task and cooperative polling
budget while retaining the existing workload, deadline and byte/hash
assertions. Preserve node logs after fixture teardown and report the
operation, response phase and received bytes when a transfer times out.

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-25 04:54:35 +08:00
Hauser 9517db6cf7 fix(listing): honor per-object quorum with borrowed metadata (#8901)
* fix(listing): honor persisted erasure quorum

Separate metadata admission and primary-reader sizing from each version's
persisted erasure requirements. Propagate agreed-entry uncertainty and
report LIST read-quorum failures as storage unavailability.

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

* perf(listing): borrow metadata during quorum resolution

Keep validated metadata inspection and agreed-version selection in
filemeta. Resolve independent reader votes through borrowed slots and
retain owned buffers until a filtered result is required.

Preserve fixed-baseline legacy comparisons, cached latest fast paths,
quorum policy, and disk fan-out. Add ownership regressions and paired
release benchmarks for cold and cached version histories.

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

* test(ecstore): remove redundant fallback clone

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-25 04:49:31 +08:00
Hauser 3bcba78ccb chore(deps): refresh allocator, profiling, and archive crates (#8910)
Run two cargo update and cargo upgrade rounds from origin/main.
Update mimalloc to 0.7.2, hotpath to 0.29.1, and ZIP to 9.1.0,
with compatible crypto, compression, and smallvec updates.
Retain the generic-array version required by crypto-common 0.1.7.

Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-10-25 04:49:20 +08:00
overtrue bebd5d0631 test(policy): remove redundant artifact grant clones 2026-10-25 04:48:39 +08:00
overtrue 0de18a3577 test(ecstore): isolate Windows batch delete alias locks 2026-10-25 04:33:14 +08:00
Hauser af4ce32528 perf(ecstore): reduce scanner metadata path allocation overhead (#8899)
* fix(scripts): collect scanner host telemetry on macOS

Use platform-supported iostat arguments and expose optional collector
failures without interrupting scanner-status collection.

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

* perf(ecstore): reuse validated metadata read path buffers

Share the checked bucket/object tuple across metadata read consumers and borrow clean normalization inputs without skipping filesystem validation. Keep Windows reconstruction and the second bucket check intact.

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

* fix(scripts): finalize scanner telemetry for failed runs

Initialize host telemetry artifacts before external captures so reused output directories cannot retain prior successes. Share normal and error-path finalization, reap collectors on request failure, and preserve the primary exit status even if status recording fails.

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

* test(ecstore): distinguish unreadable recovery inventories

Treat only the exact system-prefix read-quorum result as incomplete during bounded polling. Preserve strict final reads and the shutdown/drain assertions, record failed observations, and cover interrupted empty streaks and unrelated errors.

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-25 04:23:05 +08:00
overtrue 04756b0a93 test(admin): preserve plugin copy trait resolution 2026-10-25 04:19:18 +08:00
overtrue 6fc62edcf7 fix(admin): route plugin errors through the existing S3 facade 2026-10-25 04:12:32 +08:00
Chris An 32c4593587 fix(heal): retain MRF state when bucket identity is unresolved (#8905)
* fix(heal): retain MRF state when bucket identity is unresolved

* test(heal): give real-storage replay fixture a bounded stack
2026-10-25 03:58:46 +08:00
Jim Chen 2f90fb19f5 fix(rio): ZigZag-encode signed delta varints and widen scratch buffer in compress_index (#8893)
* fix(rio): ZigZag-encode signed delta varints and widen scratch buffer in compress_index

Index::append_to passes signed delta residuals (c_off and u_off, which are negative whenever a compressed block is smaller than est_block_uncomp / 2) to write_varint(buf, v: i64), which checks while v >= 0x80 without ZigZag encoding and truncates every negative delta to a single byte, while tmp = [0u8; 8] panics out-of-bounds on 9-10 byte varints.

Root Cause:
In crates/rio/src/compress_index.rs, Index::append_to delta-encodes u_off and c_off (c_off -= prev.compressed_offset + c_predict, where c_predict starts at est_block_uncomp / 2) as signed i64 residuals and passes them to write_varint(buf, v: i64). However, write_varint checks while v >= 0x80 on the signed i64 without ZigZag encoding (unlike Go binary.PutVarint / binary.Varint in the S2 index format): for any negative v, v >= 0x80 is false, so write_varint writes a single truncated byte v as u8 without continuation bits, either silently corrupting compressed_offset on Index::load (when (v as u8) < 0x80) or desynchronizing the varint stream (when (v as u8) >= 0x80). Additionally, tmp = [0u8; 8] at line 223 panics with an out-of-bounds index when encoding 9-10 byte varints, and read_varint lacks an overflow check on 10-byte inputs.

Fix:
Use ZigZag encoding and decoding in write_varint and read_varint, reject 64-bit varint overflows in read_varint, and widen the scratch buffer tmp in Index::append_to from [0u8; 8] to [0u8; 10].

Signed-off-by: czcbangkai <czcbangkai@google.com>

* test(rio): use est_block_uncomp field and compare IndexInfo fields in compress_index test

---------

Signed-off-by: czcbangkai <czcbangkai@google.com>
2026-10-25 03:58:33 +08:00
overtrue 68d1790b74 test(rpc): isolate guarded recovery request state 2026-10-25 03:41:30 +08:00
overtrue 6fde372f5b fix(ecstore): retain bootstrap receivers through format publication 2026-10-25 03:39:25 +08:00
overtrue 503a852214 merge(main): sync listing fixes before plugin integration 2026-10-25 03:38:01 +08:00
overtrue ab7a8c400f test(storage): split RPC cases and initialize metadata fixtures 2026-10-25 03:21:21 +08:00
overtrue 85663e9647 fix(ecstore): isolate large reader and startup futures 2026-10-25 02:48:36 +08:00
Chris An 17aa75a175 fix(listing): keep the limit flag monotonic and fix metacache helpers (#8906)
`LocalDisk::walk_dir` stored its own `limit_reached` into the
`producer_limit_reached` flag that every producer of a page shares, so a
walk reaching EOF after a sibling stopped at its per-disk limit cleared the
signal and the collector reported a complete page. Use `fetch_or` so the
flag answers "did any bounded walk stop early", whatever the finish order.

`MetaCacheEntry::is_in_dir` stripped the directory prefix repeatedly, so
`a/a/b` counted as a direct child of `a/`. `MetaCacheEntriesSorted::
forward_past` kept every entry when none sorted after the marker and split
on an index computed over the flattened entries. Both are masked today by
the collector's pre-filtering; fix them and pin the contracts with tests.
2026-10-25 02:24:41 +08:00
overtrue 61e4cfbc2a merge(main): preserve multipart recovery and plugin fences 2026-10-25 02:22:18 +08:00
overtrue 675722e5f8 fix(storage): reduce request future stack residency 2026-10-25 02:12:43 +08:00
Hauser d0f8475924 fix(multipart): recover aborts without retaining topology locks (#8904)
* fix(multipart): allow abort after partial commit

Read staging metadata at read quorum during Abort while retaining the
delete write quorum and generation checks. Bound post-fault lock recovery,
isolate quorum rows from peer breaker history, and cover the metadata and
deletion boundaries.

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

* perf(multipart): release topology guards before metadata I/O

Read multipart metadata from owned disk snapshots so slow I/O does not
block registry renewal. Cover read and write lookups with a deterministic
paused-I/O regression.

Reuse one cluster per quorum matrix after observing fresh responses from
every peer. Preserve fault-hit and quorum assertions without repeatedly
starting clusters or disabling peer request breakers.

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-25 02:01:12 +08:00
overtrue f6dfe86c58 merge: sync main and preserve delete recovery ownership 2026-10-25 01:58:45 +08:00
overtrue 3c9b9589f2 fix(ecstore): bound nested storage futures 2026-10-25 01:40:55 +08:00
Hauser 7130f25af5 fix(ecstore): unblock delete replay and parallelize preparation (#8896)
* perf(ecstore): avoid head-of-line blocking in delete recovery

Recover independent keys through a bounded rolling window while preserving per-key hint generations, admission budgets, cancellation retry, and the existing transaction decision engine.

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

* perf(ecstore): bound durable delete preparation and bootstrap recovery

Prepare independent Linux delete transactions with bounded concurrency while retaining per-key result order and serial fallback for overlapping, rollback, and legacy requests. Preserve startup discovery until bucket metadata is initialized, and add release performance and process-crash acceptance runners.

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

* test(ecstore): verify clean exits in recovery acceptance

Reject forced or OOM exits when measuring clean startup and verify original ETags across mixed-layout crash recovery and a second restart.

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

* test(ecstore): distinguish prepared and decided batch crashes

Observe real preparation and receipt states separately, retain non-vacuous acknowledged-delete sentinels, and require read-only physical convergence before S3 reads can trigger cleanup.

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

* test(ecstore): move final delete recovery options

Move the options value into the batch-ordering regression's final case
instead of cloning it. Keep the earlier clone needed for reuse.

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

* fix(ecstore): replay delete receipts after interrupted proof GC

Retire only the exact empty transaction leaf before journal activation. Preserve occupied directories and prefix children, and cover partial cross-disk proof GC without a fresh delete. Tighten crash evidence to distinguish directory-only remnants from retained content.

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-25 01:12:34 +08:00
Hauser 900d082764 fix(scanner): make startup responsive and skip redundant config work (#8898)
* fix(scanner): bound implicit startup delay and publish config early

Limit warm-cache startup to the smaller of the cycle interval and one
minute before jitter, while preserving explicit startup settings.
Publish configuration gauges when applying runtime settings and retain
timeout diagnostics until an actual new cycle begins.

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

* fix(scanner): unify startup waits and skip unchanged configuration

Bound read-only startup inspection and make startup and pre-leadership
retries respond to cancellation and configuration changes. Preserve one
startup deadline and discover replication behind lifecycle-only buckets.
Avoid irrelevant bucket listings and repeated per-disk reconfiguration.

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

* fix(scanner): retain effective startup policy and fallback metrics

Keep the sampled startup deadline when configuration changes leave its
normalized policy unchanged. Initialize retained runtime metrics after
an invalid first refresh without suppressing the validation error or
replacing a newer accepted configuration.

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-25 01:01:37 +08:00
overtrue a5ad283c8c merge: sync main for plugin integration validation 2026-10-25 00:49:27 +08:00
overtrue dcdd53ccf3 fix(plugins): admit parallel artifact writes under shared fences 2026-10-25 00:42:53 +08:00
Chris An b23723a51f feat(heal): add live node admission feedback (#8897)
* feat(heal): add live node admission feedback

* test(heal): use storage facade in admission regression

* fix(heal): preserve retryable node admission deferrals
2026-10-25 01:42:02 +09:00
Hauser 07b7d4fc18 fix(s3): preserve legacy plaintext multipart ETags in listings (#8900)
* fix(s3): preserve legacy plaintext multipart ETags in listings

Use stored object encryption markers for keyless LIST classification and rebuild persistent snapshots that discarded plaintext multipart ETags.

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

* perf(s3): classify listing encryption metadata without allocations

Use fixed SSE header matching with the historical Unicode fallback. Fuse encrypted ETag proof validation into one metadata pass and clone snapshot ETags only after visibility filtering.

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-25 01:41:03 +09:00
Jim Chen d60ef4873a fix(ecstore): avoid usize underflow when root_disk drives are offline or unknown (#8879)
get_online_offline_disks_stats() counts and demotes all root_disk entries regardless of drive state, underflowing online_disks[ep] (0usize - 1) and double-counting offline_disks[ep] when a root_disk drive is offline or unknown alongside healthy non-root drives.

Root Cause:
In crates/ecstore/src/diagnostics/admin_server_info.rs: (1) root_disk_count counts every drive with root_disk = true even if the drive was already classified into offline_disks or unknown_disks; (2) the subsequent demotion loop unconditionally decrements online_disks[ep] and increments offline_disks[ep] for every disk.root_disk without checking whether disk.state was classified as online (ok or unformatted). When an endpoint has an offline or unknown root_disk drive and zero online drives, *online_disks.get_mut(ep) -= 1 underflows 0usize (panicking in debug/checked builds or wrapping to usize::MAX) and inflates offline_disks.

Fix:
Introduce is_online_drive_state() and only count and demote root_disk drives whose state was classified as online (ok or unformatted), preserving the offline and unknown bucket tallies and keeping online + offline + unknown equal to disks_info.len().

Signed-off-by: czcbangkai <czcbangkai@google.com>
2026-10-25 00:29:15 +08:00
overtrue bdd7efc96d fix(plugins): reconcile runner integration with current main 2026-10-25 00:14:43 +08:00
Hauser aaeedcd1d7 perf(rpc): remove stream boxing and verify distributed TLS reads (#8885)
* perf(rpc): remove redundant boxing from read streams

Keep the capped read stream concrete until StreamingBlob owns its type erasure. Add verified four-node TLS coverage for full, unaligned range and concurrent reads, with bounded client memory and explicit integrity checks.

Document protocol comparison commands and the old-node workaround during rolling upgrades.

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

* test(e2e): preserve TLS trust for alternate credentials

Derive credential-specific clients from the cluster configuration so they retain the generated CA. Exercise successful trusted reads and reject an invalid replacement identity instead of inheriting root credentials.

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

* test(e2e): collapse acceptance report path guard

Combine the optional report path and existing-file checks into a let chain
without changing the report or PID sidecar collision behavior.

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-25 00:12:59 +08:00
Jim Chen 8ebec9e71c fix(server): preserve UTF-8 boundaries in Content-Disposition filename parsing (#8880)
HttpCompressionConfig::extract_filename_from_content_disposition() calls header_value.to_lowercase() and slices &header_value[start..] using the byte index from lower.find("filename="), panicking on multi-byte Unicode characters whose UTF-8 byte length changes under case mapping and matching unanchored *filename= parameter suffixes.

Root Cause:
In rustfs/src/server/compress.rs, extract_filename_from_content_disposition() computes lower = header_value.to_lowercase() and uses the byte offset idx from lower.find("filename=") to slice &header_value[start..]. Because Unicode case mapping can expand or shrink UTF-8 byte lengths (e.g., U+0130 "İ" is 2 bytes in UTF-8 but lowercases to 3 bytes; U+212A "K" is 3 bytes in UTF-8 but lowercases to 1 byte), start can land inside a multi-byte UTF-8 codepoint or past the end of header_value, causing a runtime panic. In addition, lower.find("filename=") matches any parameter ending in filename= (such as x-filename=).

Fix:
Use header_value.to_ascii_lowercase() so byte offsets remain 1:1 with header_value, and require filename= to appear either at the start of the header or after a semicolon parameter delimiter.

Signed-off-by: czcbangkai <czcbangkai@google.com>
2026-10-24 23:49:24 +09:00
Jim Chen cced213b66 fix(ecstore): invalidate FdCache on in-place writes, rename_part, and directory rename_file (#8889)
StdBackend::pread_bytes caches FdCacheEntry { file, len } and skips fstat on cache hits, passing cached len directly to unsafe { mmap_options.map(file) }. However, StdBackend::open_write / UringBackend::open_write (create_file, append_file), LocalDisk::write_all_private (write_all), LocalDisk::rename_part, and directory LocalDisk::rename_file fail to invalidate FdCache, causing mmap past EOF (SIGBUS), false DiskError::FileCorrupt on grown files, or stale reads after renames.

Root Cause:
In crates/ecstore/src/disk/local.rs, StdBackend::pread_bytes caches open File handles and their initial fstat byte length in FdCache and uses the cached len directly for bounds checks and unsafe { mmap_options.map(file) }. While LocalDisk::rename_file (non-directory) and LocalDisk::delete invalidate FdCache, five mutation paths omit invalidation: (1) StdBackend::open_write (called by create_file with O_TRUNC and append_file), (2) LocalDisk::write_all_private (called by write_all with O_TRUNC), (3) LocalDisk::rename_part, (4) LocalDisk::rename_file when src_is_dir is true, and (5) UringBackend::open_write / unlink / rename which fail to forward invalidations to self.inner. Truncating a cached file across a page boundary causes mmap to map beyond EOF and trigger SIGBUS; growing a cached file causes false DiskError::FileCorrupt.

Fix:
Invalidate FdCache in StdBackend::open_write, UringBackend::open_write/unlink/rename, LocalDisk::write_all_private, LocalDisk::rename_part (both src and dst), and directory LocalDisk::rename_file (via invalidate_prefix on src and dst).

Signed-off-by: czcbangkai <czcbangkai@google.com>
2026-10-24 23:49:20 +09:00
Hauser 882217221e fix(lock): reach quorum behind silent voters (#8895)
* test(e2e): add MPU RPC quorum and recovery acceptance

Exercise separate storage and lock RPC delays across multipart phases,
including quorum boundaries, cancellation, bucket recreation, and bounded
eight-node load. Honor TMPDIR for isolated cluster drive directories.

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

* fix(lock): probe healthy voters behind silent peers

Spread additional probes across the existing acquisition attempt budget
when the initial quorum-plus-spare wave stalls. Preserve quorum thresholds,
fast-path fan-out, and existing ownership and late-response cleanup.

Cover every four-node read-voter ordering, a stalled eight-node write
quorum, and normal fan-out. Extend MPU acceptance to EC12+4 at twelve and
eleven metadata acknowledgements.

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-24 22:30:31 +08:00
Chris f279cefa9c feat(observability): track foreground response-body completion windows (#8894)
* feat(observability): track foreground response-body completion windows

* docs: correct foreground ownership spelling
2026-10-24 23:14:07 +09:00
Chris 8aba025bd6 feat(concurrency): retain resource leases through native I/O (#8886)
feat(concurrency): retain resource leases across owned disk work
2026-10-24 21:19:06 +08:00
Hauser 40837f67f8 perf(ecstore): avoid head-of-line blocking in delete recovery (#8884)
Recover independent keys through a bounded rolling window while preserving per-key hint generations, admission budgets, cancellation retry, and the existing transaction decision engine.

Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-10-24 20:35:29 +08:00
cxymds 042f6ba3aa fix(quota): persist lazily created populated shards (#8883) 2026-10-24 21:02:52 +09:00
Chris 63eac7a387 feat(observability): sample live cgroup resource limits (#8882) 2026-10-24 20:42:20 +09:00
Chris e13336a116 feat(scanner): expose publication and heal observation status (#8878) 2026-10-24 20:04:57 +09:00
Hauser 8d5af5981d fix(s3): preserve aws-chunked error responses (#8875)
* fix(s3): preserve aws-chunked error responses

Map named aws-chunked failures through the shared bounded body classifier,
keeping typed source precedence and the dependency's S3 response contract.
Cover exact responses, upload publication, and valid signed/trailer limits.

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

* perf(s3): classify upload stream errors once per cause

Reuse the typed UploadStreamError result for digest and incomplete-body
classification while preserving the shared classifier's marker priorities.

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

* test(e2e): refresh aws-chunked profile selections

Record the two new aws-chunked regression cases in smoke and full profiles.
Verify exact additions against the CI Linux inventory and native Darwin
listings, recovering each previous digest when those two cases are removed.

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-24 20:04:18 +09:00
Hauser f53c5d41d4 perf(ecstore): avoid heap allocation for shard descriptors (#8873)
Keep inline shard references through both encode backends and reconstruction
verification, retaining heap spill for wider layouts and existing codec bytes.

Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-10-24 18:11:09 +08:00
Chris 4096d05ca7 test(ilm): isolate fixture recovery loop ownership (#8881) 2026-10-24 18:47:50 +09:00
Hauser d2c1434a02 fix: isolate internode reads and migrate crypto dependencies (#8872)
* fix(rio): isolate internode streams with pooled HTTP/1.1

Default the internode HTTP data plane to reusable HTTP/1.1 connections so paused shard bodies cannot consume shared HTTP/2 receive credit. Retain explicit auto negotiation and the existing TLS, pooling, and recovery paths.

Refs rustfs/rustfs#8843 and rustfs/backlog#2934.

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

* perf(rpc): frame bounded shard reads for connection reuse

Preserve the bounded read cap and stream-to-EOF sentinel while making connection completion independent of a chunked terminator. Cover exact reads without EOF polling, pooled TLS reuse, and partial-body truncation.

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

* fix(deps): migrate signer and OIDC to patched crypto

Pin the requested signer fix and upstream OIDC/OAuth2 crypto migrations to immutable commits. Preserve registry SDK type identity, remove the old bigint and elliptic-curve chains, and verify independent login state, nonce, and PKCE values.

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-24 17:41:55 +08:00
Chris c8f14dc337 test(scanner): attest running benchmark process identity (#8876)
* test(scanner): attest running benchmark process identity

* fix(scanner): reject aliased process receipt data roots

* fix(scanner): attest scheduled scan configuration
2026-10-24 18:22:11 +09:00
Chris 90c5050a0f fix(scanner): preserve unknown catch-up state on ledger failure (#8877) 2026-10-24 18:16:42 +09:00
Chris 334e8d9d3e test(scanner): verify retained versions with an independent oracle (#8870)
* test(scanner): add independent version content oracle

* test(ecstore): drain guarded delete fixture rename tails

* docs(scanner): record SDK seeding requirements
2026-10-24 18:12:06 +09:00
Ramakrishna Chilaka d8e8f6bfa4 perf(ecstore): coalesce inline PUT commit and bound disk namespace workers (#8871)
* perf(ecstore): coalesce inline PUT commit and bound disk namespace workers

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: d85aaf47-2a4b-4c2c-97a3-dbe8c7717e2a

* fix(ecstore): drop PreparedRenameSource before post-commit rollback on Windows

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: d85aaf47-2a4b-4c2c-97a3-dbe8c7717e2a

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: d85aaf47-2a4b-4c2c-97a3-dbe8c7717e2a
2026-10-24 17:25:53 +09:00
Chris 4e64e86a29 feat(scanner): count object metadata visits (#8874)
* feat(scanner): count object metadata visits

* test(ecstore): drain guarded delete fixture rename tails
2026-10-24 17:24:17 +09:00
cozima d229c79e38 fix(s3): apply bucket CORS to virtual-hosted-style requests (#8860)
ConditionalCorsLayer took the bucket from the first path segment only.
With RUSTFS_SERVER_DOMAINS set, a virtual-hosted-style request such as
OPTIONS http://mybucket.localhost:9000/x.jpg was evaluated against a
bucket named "x.jpg", so a preflight never matched the bucket's CORS
configuration and returned 200 without Access-Control-* headers, while
the same request in path style succeeded.

Resolve the bucket from the host prefix first, reusing the virtual-host
matching that the API rate limiter already uses, and fall back to the
first path segment for path-style requests. A bucket-level request on a
virtual host ("/") is now treated as a bucket request instead of the
server root.

Co-authored-by: cozima <8464261+cozima@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 16:53:36 +09:00
Hauser 0c8b05585e fix(scanner): classify erasure candidates in one budgeted walk (#8869)
* test(scanner): isolate async checkpoint log capture

Scope captured tracing subscribers to each future poll and cover the
competing-dispatcher failure without weakening checkpoint assertions.
Verify exact bounded erasure-probe accounting around the 64-entry limit.

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

* fix(scanner): classify erasure candidates in one budgeted walk

Continue directory classification in cooperative 64-entry batches until
part evidence or EOF, retaining normal checkpoint and namespace work.
Defer metadata accounting and namespace reuse until classification ends.
Distinguish vanished opens from incomplete iteration and preserve quota,
sampling, failed-object and non-destructive heal behavior.

Pin test callsite interest alongside poll-scoped capture to prevent an
unsubscribed sibling from caching the checkpoint callsite as disabled.

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-24 15:49:00 +08:00
Hauser 911ea265c5 fix(storage): apply and index explicit delete-marker removals (#8854)
* fix(ecstore): purge every explicit delete-marker version

Keep explicit marker removals on the canonical version-delete path so
same-key batches are not truncated and pending replication does not retain
source markers. Preserve marker identity and timestamps in the response.

Add real four-disk regressions for mixed version cleanup, duplicate marker
IDs, pending replication, and null/UUID marker removal.

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

* perf(filemeta): index explicit delete-marker batches

Reuse the batch index and final compaction for ordinary marker removals.
Keep small marker-only histories on the scalar path and preserve fallback
for free versions and complex mutations.

Add scalar-equivalence, boundary, corruption and deterministic scan-count
coverage, a real-disk mixed-history regression, and reproducible marker
benchmark cases. Preserve existing benchmark defaults while honoring CLI
sampling overrides.

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

* test(filemeta): remove redundant marker fixture clone

Move the original metadata into its final test fixture after the last
borrow, avoiding the redundant clone reported by strict Clippy checks.

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

* test(storage): validate explicit marker deletion and recovery

Exercise UUID/null and duplicate explicit marker removals over real HTTP
with retries disabled, including immediate four-node visibility and exact
retained payloads. Cover all-drive PREPARED/APPLIED/DECISION process
termination, rollback/commit recovery, and old-ID retries across a second
restart without deleting a fresh marker.

Use flat sampling and fixed per-case fixture batches in the metadata
benchmark. Update profile selection digests for the added regressions.

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

* test(heal): synchronize crash fixture startup and flush readiness

Give disk setup and the durable flush boundary separate readiness gates,
keeping each phase bounded to ten seconds. Preserve the real SIGKILL and
restart assertions while reporting early child exits and retaining output.

Reap fixture children on assertion failure and cover early-exit diagnostics.

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-24 16:34:31 +09:00
Chris 8c7b3996cb feat(scanner): extract OTLP histogram and counter windows (#8862)
* test(scanner): validate OTLP histogram measurement windows

* feat(scanner): extract exact integer counter windows

* test(ecstore): drain guarded delete fixture rename tails

* test(ilm): retain transition timeout evidence
2026-10-24 16:33:03 +09:00
Chris 68a5b2c89c fix(heal): measure admission lock wait and hold separately (#8863)
* fix(heal): measure admission lock wait and hold separately

* fix(heal): exclude force-start cancellation from lock hold

* test(ecstore): drain guarded delete fixture rename tails
2026-10-24 16:17:41 +09:00
Chris 7f1795f275 test(scanner): add recoverable benchmark service control (#8868) 2026-10-24 14:58:44 +09:00
Chris 3092792c3c fix(scanner): recover an interleaved pause backlog wedge after expansion (#8867)
* fix(scanner): roll a wedged pause backlog forward to its proven ledger

For rustfs/backlog#2873 defect E.

Two interleaved writers could leave a replica state that no claim could
leave. Writer B read writer A's partial commit and selected the stable
rollback point. A's commit then completed and A verified it. B's rollback
stabilization overwrote every member it had read after the commit, and A's
stabilization reached only the remaining member. No commit survived and the
stable fields differed, so every claim and every decommission retirement
failed with "neither a surviving membership commit nor a stable rollback
point".

- When no commit survives and stable consensus fails, select the unique
  newest stable ledger by (writer epoch, generation). A stable field is
  written only after its ledger was selected as authority, so that ledger is
  the last proven one. The next claim stabilizes it and advances normally.
- Keep failing closed for an invalid or unreadable replica, for two
  different stable ledgers at the newest key, and for a missing replica that
  a stored commit names as a member.
- A rollback proof written without an authoritative commit names only
  members that already store a record, so a failed first write to a new
  member cannot turn it into a named member that reads as lost.
- Retirement refuses a rolled-forward ledger until the writer has
  stabilized it, as a retryable handoff conflict rather than a terminal
  authority conflict.
- Tests replay the interleaving on real storage: it converges and retires,
  it converges after another expansion with a failed first write to a new
  member, and a same-key fork or a lost named member still refuses.

* fix(scanner): let retirement stabilize a rolled-forward pause backlog

Follow-up to the defect E roll-forward for rustfs/backlog#2873.

- Retirement no longer waits for a scanner writer. When no commit
  survives and the native selector over every read member (survivors,
  retiring source and queued pools) rolls forward, the planner commits and
  stabilizes that ledger on the survivors under the writers' object fence.
  The previous retryable handoff could exhaust its ~13.5 s budget long
  before a writer re-claimed, and failed the decommission terminally.
- A writer's roll-forward refuses when a stored commit names a member that
  is missing or outside its read, such as a retiring source excluded from
  the writable set. Retirement, which reads that member, decides instead.
- A writer stabilizing a rolled-forward ledger writes missing members
  first and stops at the first failure. A member that only read as missing
  rejects the create, so no surviving proof can omit its newer record.
- Docs describe the actual roll-forward preconditions and the retirement
  exit.
- Tests: retirement converges the wedge with no writer; a writer refuses to
  roll forward past an unread source that holds the newest ledger and
  retirement keeps it; a spurious missing read changes no record and the
  next claim converges on the newest ledger.

* fix(decommission): create missing pause backlog survivors first

Follow-up to the defect E retirement roll-forward for rustfs/backlog#2873.

- Retirement creates survivors that read as missing one at a time, before
  any stored survivor, and stops at the first failed create. A transient
  create failure on a new pool's set no longer leaves every other survivor
  holding a commit that names a still-missing member, which no later
  selection could leave.
- A rolled-forward retirement first seeds a rollback proof that names only
  the survivors that store a record; the commit still names every survivor.
- Retirement rolls forward only when the survivors cannot select a ledger
  on their own or select the same one. Survivors that agree on a different
  ledger keep the source-authority check.
- The scanner writer applies the same missing-first, stop-at-first-failure
  order to every stabilization with a missing member, including a seed from
  an old commit selected as the rollback proof.
- Tests: a failed new-pool create during retirement retries to convergence;
  a spurious missing read cannot seed an old commit over a newer member;
  the rolled-forward precedence over source stable records is pinned.

* docs(decommission): state the all-missing survivor limit of backlog roll-forward
2026-10-24 14:52:30 +09:00
Chris e542e62f07 feat(heal): record namespace lock acquisition waits (#8866) 2026-10-24 14:49:59 +09:00
Chris d90419f4dc feat(scanner): count cache save payload bytes per attempt (#8865) 2026-10-24 14:23:02 +09:00
Chris bbe2f5a3d3 fix(ecstore): skip orphan cleanup for leading-slash listings (#8864) 2026-10-24 13:22:06 +09:00
Chris b5f275ca3f fix(scanner): collect benchmark metrics from realtime route (#8859)
* fix(scanner): collect benchmark metrics from realtime route

* docs(scanner): align benchmark help with realtime route

* test(e2e): bound parallel seeding for scanner usage checks
2026-10-24 12:45:42 +09:00
Chris c2e6b974e1 test(e2e): bound parallel seeding for scanner usage checks (#8861) 2026-10-24 12:44:45 +09:00
Chris b39fcb2e85 test(heal): cover rc.5 upgrades and refresh ctutils (#8847)
* test(e2e): use the resumed decommission completion budget

* test(heal): exercise checkpoints written by rc.5

* test(heal): route upgrade fixtures through the storage facade

* fix(deps): update ctutils to address constant-time advisory

* test(lifecycle): gate recovery I/O while advancing test time
2026-10-24 11:20:05 +09:00
Chris 301e3a9f34 fix(decommission): count settled entries once, back off repeat pauses (#8848)
* fix(decommission): count settled entries once and back off repeated pauses

Follow-up to rustfs/rustfs#8824 for rustfs/backlog#2873 defect F.

- Count an entry's versions when the entry settles (copied and cleaned up,
  or retained) instead of as each version copies. A partially moved entry
  that is deferred, paused and resumed, or interrupted, no longer counts its
  already-moved versions again when the resumed pass finds them on the
  target.
- Hold the watchdog relaunch of a decommission that pauses again on the
  same cause without progress, doubling from 30 s up to 15 min, and warn with
  the cause. A first pause still relaunches on the next pass; the pause never
  becomes a terminal failure.
- Log each drive's error when a delete version loses write quorum, so the
  cause of a sub-quorum delete-marker copy is recorded instead of only its
  rollback.
- Tests: pause/resume counting, a target whose metadata is below read
  quorum, the copy-quorum pause cause, the relaunch backoff, and a rollback
  test that asserts the restore attempt instead of a tracing log.

* fix(decommission): measure pause progress by cleaned entries

Review follow-up on the pause/resume counting and relaunch backoff.

- A pause now carries this generation's progress counters into the
  pool metadata it persists, like a progress checkpoint. Before, everything
  counted since the last checkpoint was dropped at the pause.
- A retained entry (one whose source stays) is relisted by every resumed
  pass. A per-instance ledger counts its versions once per decommission
  generation, so retained versions no longer grow per round in one process.
- The relaunch backoff measures progress by entries moved off the pool and
  completed buckets, which a relisted retained entry cannot advance. Before,
  the recounted retained entry looked like progress and reset the backoff.
- Tests: exact totals across a pause and a resume from persisted metadata;
  a target kept below quorum defers the entry with nothing counted; a
  retained entry plus a repeated cause escalates the backoff over three
  pauses, with the watchdog path relaunching a first pause and holding a
  repeat.

* fix(ecstore): bound retained decommission accounting
2026-10-24 10:11:44 +09:00
Chris 6535c28f1a fix(scanner): recover backlog across interrupted pool expansions (#8858)
fix(scanner): recover stable membership after repeated expansion
2026-10-24 10:01:10 +09:00
Chris 76f4342ae2 feat(scanner): observe canonical heal task terminals (#8851) 2026-10-24 08:50:13 +09:00
Hauser 128634f102 fix(notify): preserve marker IDs and reduce event allocations (#8853)
* fix(notify): preserve delete marker version identities

Use the committed marker identity for batch creation notifications and map
null versions at the notification boundary. Keep explicit marker removals
and synthetic directory identities consistent with the S3 response.

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

* refactor(notify): remove redundant delete version fallback

Single-object storage results already normalize absent version IDs to nil
UUIDs for versioned and suspended buckets. Keep the existing response
projection and reserve marker fallback handling for batch results.

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

* perf(notify): avoid redundant delete and event allocations

Skip successful response projection for quiet batches and move committed
names directly into lightweight notification data. Traverse committed
results through the single-object cache invalidator, removing the unused
batch wrapper and temporary key copies.

Consume owned event fields and use borrowed response-ID entries while
preserving version selection, metadata filtering, and dispatch semantics.

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-24 04:14:26 +09:00
Chris abe14e2f97 feat(scanner): configure startup recovery backoff cap (#8845)
* feat(scanner): configure startup recovery backoff cap

* test(scanner): compare recovery retry count without narrowing

* docs(scanner): document recovery backoff cap and expose it in Helm

List scanner.recovery_backoff_cap in the runtime controls table, drop the
fixed ten-minute wording from the follower recovery section, and map
config.rustfs.scanner.recovery_backoff_cap_secs to
RUSTFS_SCANNER_RECOVERY_BACKOFF_CAP_SECS in the chart.
2026-10-24 03:09:39 +09:00
Hauser 7d6172e666 chore(deps): refresh crates and upgrade hotpath to 0.29.0 (#8852)
Run two cargo update and cargo upgrade rounds from origin/main.
Upgrade the hotpath crate family to 0.29.0 and refresh compatible
AWS, Redis, profiling, crypto, UUID, and certificate dependencies.

Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-10-24 03:09:34 +09:00
Chris 3254e79efd perf(scanner): transfer owned caches without redundant clones (#8849) 2026-10-24 00:49:07 +08:00
Chris da7dc74147 fix(quota): retire degraded floors from durable usage reports (#8832)
* fix(quota): retire degraded floors from durable usage reports

* docs(quota): clarify retained usage cache refresh behavior

* fix(admin): report unknown bucket usage as null in GetBucketQuota

GetBucketQuota serialized missing usage as size 0, which contradicted the
D3 rule that unknown usage is never reported as zero and, after retiring
degraded floors, made a bucket near its limit look empty. Make size
optional and stop collapsing a missing or unreadable usage snapshot to 0.
2026-10-24 01:46:02 +09:00
Chris db55098aa6 fix(scanner): recover dual corruption with retained epoch fencing (#8833)
* fix(scanner): recover dual corruption with retained epoch fencing

* fix(scanner): preserve recovery markers and legacy quota floors

* test(scanner): expect retained usage recovery marker state

* docs(scanner): register dual-corruption recovery evidence objects

Add the .bloomcycle.bin.dual-recovery/ evidence family to the persisted
objects table and describe its owner, write-once lifecycle and recovery
rule in Deletion And Recovery Rules.
2026-10-24 01:43:59 +09:00
Chris 47e1850d7e fix(scanner): preserve remote checkpoint coverage identity (#8827)
* fix(scanner): preserve remote checkpoint coverage identity

* test(scanner): verify remote plan identity propagation

* test(rpc): disambiguate scanner capability body collection
2026-10-24 00:22:49 +09:00
Chris 4e88ebcf30 fix(heal): preserve typed replacement readiness deferrals (#8823)
* fix(heal): preserve typed replacement readiness deferrals

* docs(heal): align retry compatibility markers with guards
2026-10-24 00:22:41 +09:00
Ramakrishna Chilaka cfee2ffaba perf(ecstore): bound xl.meta read workers and enable HEAD early-stop (#8846)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: d85aaf47-2a4b-4c2c-97a3-dbe8c7717e2a
2026-10-23 22:32:48 +08:00
Chris 623601d2f9 fix(test): select the current G09 upgrade case (#8841) 2026-10-23 23:04:59 +09:00
Chris 2474adc50f feat(heal): inspect blocked checkpoint validation without mutation (#8838) 2026-10-23 23:03:57 +09:00
Hauser c8cb1d86d3 fix(scanner): streamline budgeted metadata walks (#8837)
* fix(scanner): budget directory enumeration I/O

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

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

* refactor(scanner): skip redundant metadata walk work

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

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

* refactor(scanner): share budgeted directory traversal

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

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

---------

Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-10-23 21:07:09 +09:00
Hauser 607393e6fa fix(ecstore): unify multipart generation fence lifetimes (#8830)
* fix(s3): expose typed causes for unavailable responses

Keep the existing S3 retry contract while distinguishing namespace lock
timeouts, other lock refusals, and storage write quorum failures. Reuse one
bounded typed-source walk and document multipart session attribution.

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

* fix(s3): preserve XML type on attributed errors

Keep application/xml when the pinned serializer replaces generated headers
with the custom cause map. Assert the media type on direct and wrapped lock
timeouts and namespace/write-quorum error responses.

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

* fix(ecstore): unify multipart generation fence lifetimes

Own registry registration before asynchronous incarnation validation so
cancellation cannot preserve a memo beyond real lifecycle-lock coverage.
Reuse successful validation for overlapping PUT and multipart operations,
and retain the full owner through detached part commits and orphan cleanup.

Keep cold validation concurrent and preserve authoritative snapshot,
borrowed-fence, and disabled-lock checks.

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

* test(ecstore): remove redundant pending closure

Pass std::future::pending directly to the incarnation validator in the
cancellation regression test so strict Clippy accepts the test target.

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-23 20:14:34 +09:00
Chris daa9529fc6 fix(decommission): keep partially moved versions readable (#8824)
* fix(decommission): keep partially moved versions readable and defer lost-quorum copies

A version copy whose target write kept losing drive quorum was counted as a
permanent per-object failure, and a partially moved entry made every copied
version unreadable: the retained source lists all versions while the target
lists only the copied ones, and the cross-pool identity check treated that
version-list difference as a same-time conflict (SlowDownRead).

- Compare the version-list fields (num_versions, successor_mod_time,
  is_latest) only for exact identities; data-movement equivalent copies of a
  version stay readable while the entry is partially moved.
- Defer a delete marker, object or tiered version copy that exhausts its
  in-place retries on a drive quorum error to the end-of-round replay instead
  of counting it failed; a persistent loss pauses the decommission, which the
  local decommission watchdog resumes.
- Skip the backup rename when rolling back a delete transaction whose journal
  recorded no original metadata, so a marker created on a missing key no
  longer logs a NotFound that reads as the cause of the failed write.

Refs rustfs/backlog#2873

* fix(ecstore): close copy quorum and tiered read gaps

* test(ecstore): synchronize capacity fixtures by object
2026-10-23 16:33:16 +09:00
Chris 1820a4c40b fix(heal): list local blocks and quiet legacy ambiguity logs (#8826)
* fix(heal): list local blocks and quiet legacy ambiguity logs

* test(heal): cover storage and scheduler ambiguity logs
2026-10-23 13:40:00 +08:00
Chris 631cd0ceca fix(ecstore): preserve migrated reads and retry busy pool heals (#8821) 2026-10-23 12:09:38 +09:00
Hauser 5778384b68 chore(deps): refresh ICU and profiling dependencies (#8820)
Update Pyroscope to 2.1.4 and compatible ICU, yoke, and zerovec crates.
Cover normalized Unicode hostnames and loopback rejection in egress tests.

Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-10-23 11:03:04 +08:00
Hauser 44a73b0bef fix(s3): preserve typed upload errors across body readers (#8816)
* fix(s3): report payload SHA256 mismatches accurately

Distinguish signed payload SHA256 failures from MD5 and additional
checksum failures at the shared API error boundary. Preserve the
payload error through archive extraction and use typed s3s errors.

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

* refactor(s3): classify body failures in one bounded pass

Share typed body-error classification across direct and storage I/O.
Retain marker precedence while replacing repeated source-chain scans,
and map storage-wrapped short HashReader bodies to IncompleteBody.

Cover nested priorities, real short reads, and one traversal budget.

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

* test(s3): route upload stream errors through the app facade

Keep the real stream-error fixtures behind the existing test-only S3
boundary without increasing the direct s3s import footprint.

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

* fix(s3): preserve typed body failures across archive reads

Route TAR entry failures through the shared archive error adapter and
preserve every classified body error. Keep generic archive parsing as
InvalidArgument without formatting discarded transport diagnostics.

Use the existing timeout marker for PUT and Snowball body guards. Add
regressions for real stream verification after sniffing and timeout
responses, and tighten the reduced S3 macro footprint baseline.

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-23 05:07:43 +09:00
唐小鸭 91007466c9 fix(site-replication): keep stored name on endpoint-only edits (#8817)
An edit that omits the name, as mc admin replicate update --endpoint
does, was normalized with a name inferred from the new endpoint host and
overwrote the stored peer, renaming the site on every peer map while its
own state name stayed unchanged.

update_peer now keeps the stored name for a known deployment ID when the
edit carries none, covering the public edit, the endpoint-refresh commit
and internal peer deliveries. The public handler also fills the stored
name before planning so the fan-out and refresh journal carry it to
receivers running older versions. Add and join of unknown peers still
infer the name from the endpoint.
2026-10-23 05:06:54 +09:00
唐小鸭 6e3484916f fix(replication): reject connection edits on site-replication targets (#8814)
A set-remote-target update with the creds group on a bucket target that
belongs to a site-replication peer skipped the connection change, logged a
warning, and still returned 200. The Console's bucket replication form sends
exactly that request when an endpoint changes, so operators saw success while
the stored endpoint, credentials, and TLS settings stayed unchanged.

Return 400 InvalidRequest instead, naming the target and the evidence that
makes it peer-owned, and pointing at a site-replication edit. The ownership
probes are unchanged; the decision is extracted so it can be unit tested.

Document how to change a site's address: the local endpoint is recorded once
at setup and never re-derived, edits must reach every other peer at its stored
endpoint, so sites move one at a time (or old addresses are restored
temporarily), followed by a resync. Commands use rc because mc's resync does
not work against RustFS and its update renames the site.
2026-10-23 02:29:13 +09:00
唐小鸭 e1e710cb5d fix(admin): serve bare madmin info bodies on the MinIO prefix (#8815)
GET /v3/info and /v3/storageinfo have wrapped the madmin InfoMessage and
StorageInfo under an `info` key since the cluster snapshot view (#3803).
madmin decodes these bodies directly, so `mc admin info` printed empty
values and `mc admin replicate resync start` rejected every alias because
the local deploymentID read as empty.

Requests on the MinIO-compatible /minio/admin prefix now receive the bare
madmin body, following the existing is_compat_admin_request convention.
The /rustfs/admin prefix keeps the envelope that rc (which requires the
`info` key), the console, e2e tests, and auto-testing scripts read.
2026-10-23 02:25:32 +09:00
Chris d9ab8760ee test(e2e): accept retried rebalance deferrals; retry busy decom preflight (#8813)
test(e2e): accept retried rebalance deferrals and retry busy decommission preflight
2026-10-23 02:10:04 +09:00
Ramakrishna Chilaka 67442a5ba3 perf(filemeta): fast-path canonical V3 header matching and borrowed version merges (#8811) 2026-10-23 00:39:23 +09:00
Hauser df10dc7864 fix(scanner): fence backlog writes against stale pool membership (#8799)
* perf(s3): borrow options during delete preflight

Pass the existing request options by reference to the metadata preflight instead of cloning them solely for that borrow. Keep the getter's mutable snapshot copy and the later owned-delete copy.

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

* fix(scanner): fence backlog writes against stale pool membership

Reject native scanner publications unless their captured writable pool cohort
matches the fenced durable topology. A smaller startup view must not replace
an expanded membership commit with its older stable rollback.

Keep single-replica test injection fenced by the complete current cohort and
cover rejected startup claims, unchanged CAS revisions, and recovery of the
acknowledged expanded commit.

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

* test(ecstore): use current cohort for healthy scanner CAS writes

Keep the writer's cached topology stale while explicitly capturing the
surviving membership for healthy-target publication and CAS checks. Also
assert that the old cohort rejects healthy-target writes without publishing.

Preserve retiring-source rejection, payload and ETag checks, and canceled
source membership repair without changing production admission.

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-22 23:17:15 +08:00
Ramakrishna Chilaka 5c463ff8d3 perf(filemeta): avoid per-replica cloning during metacache quorum resolution (#8810) 2026-10-22 22:01:58 +09:00
Hauser 1e36e18265 feat(metrics): classify terminal remote walk failures (#8809)
Record a bounded failure boundary for each non-successful logical remote
walk while preserving the existing outcome, error source and retry behavior.
Distinguish stream open/read, downstream write/flush, wrapper admission and
deadline failures, and unfinished operations dropped before completion.

Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-10-22 20:12:51 +08:00
cxymds 0b25604503 fix(config): preserve persisted compatibility fields (#8803)
* fix(config): preserve persisted compatibility fields

Bound ignored configuration diagnostics, retain unsupported aliases and KVS attributes during saves, and prevent stale shadow values from resurfacing. Add a pinned MinIO fixture and regression coverage for decode, reset, encryption, and logging behavior.

* fix(config): satisfy ecstore clippy

* test(config): ignore JSON object key order in roundtrip

* fix(log-analyzer): recognize persisted config decode failures
2026-10-22 20:05:21 +08:00
Hauser 89c3282ae2 test(scanner): reserve capacity for all native backlog fixtures (#8802)
Include single-pool, status, and expansion fixtures in the existing
native scanner reservation without changing budgets or assertions.

Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-10-22 17:54:05 +08:00
Hauser 0d0e6d8497 fix(ecstore): preserve scanner authority during decommission (#8807)
Fence native backlog writes against their captured pool membership and retain
queued replicas as read-only proof members during retirement. Stabilize the
topology test workload and bound pre-ACK recovery separately.

Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-10-22 17:42:20 +08:00
Ramakrishna Chilaka f1a0263ab1 perf(utils,filemeta): vectorize path and metadata key hot loops with SWAR (#8806) 2026-10-22 17:40:07 +08:00
Hauser bea4be68a2 chore(deps): upgrade workspace parking_lot to 0.13 (#8801)
* chore(deps): upgrade workspace parking_lot to 0.13

Move all six direct workspace consumers to parking_lot 0.13 while
retaining the 0.12 dependency line required by upstream crates.

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

* test(connect): stabilize client failure classification

Use an ephemeral loopback listener that explicitly rejects proxy CONNECT
and direct TLS connection establishment instead of assuming fixed ports
will promptly refuse connections. Give classification its own bounded
transport and measurement budget while retaining the timeout tests.

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-22 17:35:02 +08:00
Hauser 90a39a5083 perf(heal): borrow intents during retirement proof checks (#8800)
Share the existing canonical identity resolver with a non-authoritative source eligibility query. Group borrowed intents and create owned anchors only for retired generations, preserving cache and responsibility contracts and moving proof keys.

Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-10-22 17:22:37 +09:00
Hauser 11381c260f fix(ecstore): restore marker write quorum on decommission resume (#8793)
* fix(ecstore): restore marker write quorum on decommission resume

Verify equivalent target markers at write quorum and republish partial
copies before completion. Preserve exact marker creation semantics and
legacy birth identities while retaining ordinary delete and purge guards.

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

* test(odm): run delete marker regression on a bounded stack

Construct and run the deep real-storage future on the existing large-stack
test worker so the default libtest stack does not overflow. Preserve the
registered test name, serial guard, and single-part/multipart assertions.

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

* fix(ecstore): use marker majority for resumed copies

Confirm existing and republished delete markers at the configured-set
majority used by their delete transaction instead of the EC data quorum.
For a twelve-drive EC4 set this accepts seven equivalent markers while
still rejecting six, including when the online inventory is shortened.

Cover named/null markers on a real rebalance retry with retained source
history. The regression fails with InsufficientReadQuorum before the fix.

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

* test(scanner): keep catch-up fixture timestamps consistent

Admit the seeded catch-up using its observation timestamp so a durable
writer claim crossing a wall-clock second cannot spuriously rate-limit it.
Model an older claim timestamp to exercise that boundary deterministically.

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-22 17:20:56 +09:00
cxymds 89b98d65c2 fix(ecstore): skip empty tier recovery scans (#8804) 2026-10-22 15:53:50 +08:00
Hauser 335612f452 perf(heal): skip replay anchor clones for empty repair queues (#8798)
Use a conservative work hint for verified and typed legacy events, retaining the original capacity-maintenance pass and metrics publication. Preserve durable identities, journal retention, retries, and shutdown behavior, with isolated real-runtime and queue lifecycle regressions.

Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-10-22 16:38:06 +09:00
Ramakrishna Chilaka 0bade0ca3e perf(ecstore,filemeta): batch listings and buffer metacache streams (#8791)
* perf(ecstore,filemeta): batch local walk_dir metadata reads and buffer metacache streams

* fix(ecstore): stream completed listing metadata reads

Publish each result before starting the next blocking read, preserving
per-object progress and cancellation without a timed chunk queue.
Keep dispatch batching, byte limits, and namespace/error stops intact.
Cover multiple batches, page boundaries, and oversized-envelope resumption.

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

* test(ecstore,filemeta): verify production listing streams

Replace the simulated directory-walk benchmark with the public local disk
path. Validate fixture output before timing and retain metacache-only
stream benchmarks. Pin fragmented reads, read coalescing, and canonical
UUID namespace probing against the reference behavior.

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

* fix(ecstore): route listing benchmark through storage API

Centralize local listing benchmark imports in the shared benchmark
storage API boundary required by the architecture migration rules.

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

* test(scanner): keep catch-up fixture timestamps consistent

Admit the seeded catch-up using its observation timestamp so a durable
writer claim crossing a wall-clock second cannot spuriously rate-limit it.
Model an older claim timestamp to exercise that boundary deterministically.

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-22 14:54:34 +08:00
Hauser 5749752217 chore(deps): refresh cloud SDKs, runtime and profiling (#8787)
* chore(deps): refresh GCS, profiling and allocator dependencies

Update published GCS, Pyroscope, mimalloc, and PPMd crate versions.
Align the pinned erasure-fixture test recursion limit with ECStore
to compile GCS-enabled test targets.

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

* chore(deps): update AWS SDKs, Tokio and memmap2

Refresh compatible AWS service and Smithy dependencies, Tokio timers,
and memory mapping support with the regenerated lockfile.

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-22 12:17:47 +08:00
Hauser 03c65bcba8 fix(ecstore): identify scanner retirement planner failure phases (#8796)
* fix(ecstore): identify scanner retirement planner failure phases

Bind the existing native planner error context to each retirement phase while preserving typed retry classification and all storage fences. Exercise the actual wrapper in an isolated process across unavailable and typed failures.

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

* fix(ecstore): type the unavailable retirement planner error

Replace the new formatted other error with a private typed error so the error-construction ratchet remains intact. Preserve all phase messages and the existing native retry classification.

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

* test(ecstore): cover unavailable planner error identity

Check the private unavailable cause for every phase through the existing IO boundary. Retain native handoff classification and verify same-phase quorum bucketing after fallible disk narrowing.

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-22 12:17:16 +08:00
Hauser 2feb09b8eb test(obs): cover contended identity under netlink denial (#8797)
* test(obs): cover contended identity under netlink denial

Check that the production identity entry stays nonblocking while a name
writer is held and real netlink discovery is denied, then observes the
published name after release. Release the writer before joining so a
blocking regression reports a bounded failure.

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

* fix(obs): keep identity contention behind common helpers

Move the held-writer fixture behind a test-only common helper and enable
it through the Linux x86_64 obs dev-dependency. Preserve the timeout,
writer release before reader join, and identity restoration ordering.
Exercise callback completion and unwind cleanup in the common regression.

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-22 12:09:53 +08:00
Hauser be8357f815 feat(metrics): observe HTTP entry-to-dispatch latency (#8788)
Record the first dispatch or undispatched termination at the existing
external request boundary, with entry-gated timing and owned task scope.

Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-10-22 11:27:53 +08:00
Hauser 8eb67ac38c perf(heal): reuse the verified repair proof queue (#8790)
* perf(heal): reuse the verified repair proof queue

Retain proof events in place under the existing registry lock and keep
the pre-consumption capacity target and exact identity matching.

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

* test(common): move the final proof mismatch fixture

Avoid a redundant clone in the last owned mismatch fixture.

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-22 11:12:20 +08:00
Hauser a47649fb60 feat(metrics): expose allocator reclaim statistic totals (#8782)
Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-10-22 09:15:15 +08:00
Ramakrishna Chilaka c0f3f9a108 perf(filemeta,ecstore): index metadata batches and trim decodes (#8784)
* perf(filemeta,ecstore): avoid redundant decodes and clones on xl.meta write and delete paths

* perf(filemeta): remove restore parsing allocations and add guards

Borrow restore header fields instead of allocating temporary vectors and
reuse the canonical dual-prefix metadata key guard. Cover scanner/full
decoder equivalence on historical and mutated records, restore grammar,
and the complete old-current observation tuple. Add the missing Criterion
entry for unshared data-directory selection.

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

* bench(filemeta): isolate metadata mutation and batch deletion costs

Move the metadata-only Criterion target to filemeta so it does not build
ECStore. Add deterministic physical and inline dense/sparse deletion
cases with the same fixtures available to baseline and candidate builds.

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

* perf(filemeta): index ordinary metadata deletion batches

Use a batch-local version lookup, compact directory summaries, and lazy
physical reference counts for larger ordinary V2 deletion batches. Decode
each indexed body at most once and compact the version vector once.

Flush pending removals before canonical fallback and preserve per-request
validation, partial failures, inline-key aliases, and cleanup decisions.
Keep small batches and transition/legacy/ambiguous records on the scalar
path. Cover indexed/scalar state equivalence and fail-closed cache errors.

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

* test(filemeta): enforce linear batch decoding and safe fallbacks

Count V2 scans in tests to guard the indexed path's linear work contract.
Exercise marker, retention, restore, transition, free-version, and pending
purge requests against the canonical sequential path. Compare logical
state and deterministic headers for re-encoded maps while retaining exact
byte equality for ordinary indexed deletion and opaque metadata.

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-22 09:14:14 +08:00
Hauser 956395a61b fix(diagnostics): preserve context across task handoffs (#8785)
Capture existing diagnostic operation, caller and first-entry context at bucket, background and tracked-mutation handoffs. Keep absent context absent and retain deferred work and admission ownership through the established storage facades.

Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-10-22 09:14:01 +08:00
Hauser 4597bb91a8 feat(metrics): count scanner wakeups and cycle attempts (#8786)
Expose selected outer-wait reasons separately from runner-call attempts.
Preserve scanner scheduling, pause, rate-limit and budget boundaries.

Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-10-22 09:13:37 +08:00
Hauser 87caf45cfe perf(s3): remove unused multi-delete result clones (#8789)
Keep the original storage results and all required request/replication
ownership while dropping two bindings with no consumers.

Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-10-22 09:13:21 +08:00
Hauser 15b7040e78 perf(scanner): move owned usage snapshot keys (#8792)
Consume completed aggregation entries at their terminal conversion so
bucket and replication target keys can move into the output maps.
Preserve validation, rejection and publication behavior.

Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-10-22 09:13:06 +08:00
Chris ffd94cfe1b fix(decommission): finish multipart null versions superseded on target (#8774)
* fix(decommission): finish multipart null versions superseded on target

A decommission entry for an unversioned multipart source whose key
already holds a newer null version on the target settles as superseded
and aborts its staged upload. The cleanup's target proof saw the newer
object, could not match it to the staged identity, and failed closed
with a capacity-blocked error, so the routine paused, the source was
never cleaned up, and the error read like a minimum-free-drive block.

The staged upload is already proven absent and the target lookup is
exact: a different version id, or a null target with another
modification time (one null slot per key), is a different object, so
the staged copy holds nothing on the target and the pending intent is
cleared. Only a versioned target with the staged version id but another
modification time stays fail-closed. The remaining identity-proof
failures use a typed error without capacity wording.

Add three-pool decommission regressions for restored-transitioned and
plain multipart null sources superseded on the target.

* test(decommission): pin multipart target proof failures as terminal

Document the accepted residual of the null-slot proof: a metadata-only
self-copy keeps a published but uncharged staged copy's data while
moving its modification time, so its marker is released and one
object's bytes stay uncharged.

Add a three-pool routine test where the target already holds an
unowned null object with the source's exact identity: the cleanup's
ownership-proof failure fails the decommission with no capacity-blocked
reason instead of pausing it.
2026-10-22 09:22:24 +09:00
Chris 831029e5b6 docs(agents): add three-verdict finding contract to security skill (#8795)
Require every security candidate to name the lower-trust principal, the crossed boundary, the affected resource, and the observable result, and to land under exactly one of `confirmed`, `needs_validation`, or `rejected`. Only confirmed findings carry severity; a trace blocked on an unresolved deployment fact is reported as that fact instead of an assumption.
2026-10-22 08:06:34 +09:00
唐小鸭 1e817524e8 fix(replication): keep replicated multipart null versions as null (#8763)
A versioned multipart completion minted a new version when the staged version id was absent or nil, so an inbound replica of a source null version written by multipart landed under a fresh UUID. Keep the null version for authorized inbound replicas, and address the null version as versionId=null on the worker convergence HEAD like the other target reads.

Refs rustfs/rustfs#8747
2026-10-22 02:46:39 +08:00
Hauser 66bf2c0a75 perf(heal): aggregate MRF lifecycle metrics in one pass (#8773)
Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-10-22 02:46:26 +08:00
Hauser f06ae3dc34 feat(metrics): observe local walk lifecycle and output bytes (#8775)
Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-10-22 02:45:58 +08:00
Chris 8af4070d19 fix(decommission): move the free version of an expired tier version (#8777)
* fix(decommission): move the free version of an expired tier version

Decommission only queued the lifecycle expiry of a transitioned version, so a source cleanup that ran first deleted the version itself and appended its tier free version after the entry's last pass. The post-check then failed the decommission until an operator re-ran it.

Apply the expiry of a transitioned version synchronously, as rebalance does. Its free version is on the source before the cleanup preflight, which reports the entry changed, and the retried entry moves the record to a target in the same pass. An expiry that cannot be applied leaves the version live, so it moves like any other version.

Refs rustfs/backlog#2873

* fix(decommission): move a delete-all expired tier version live

Applying a delete-all expiry inline made its prefix delete wait for the bucket lifecycle write lock behind the entry's own read lock until the lock timeout, blocking every write in the bucket meanwhile. Move such a transitioned version live and leave the delete-all to lifecycle on the target.

Cover a genuinely unapplied expiry and a free-version worker that consumes the source record before the entry moves it.

Refs rustfs/backlog#2873
2026-10-22 02:45:47 +08:00
Hauser c1de42af2c fix(heal): type legacy MRF decisions and defer diagnostics (#8778)
* fix(heal): explain unverified legacy heal outcomes

Preserve storage diagnostics in canonical unknown outcomes and explain
legacy payload identity limits through the shared local/peer status path.
Borrow expected receipt identities while preserving all proof checks.

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

* fix(heal): keep diagnostics from pausing merged MRF repairs

Require the actual MRF executor before publishing legacy pause events.
An ordinary queued owner can retain a merged durable MRF notice without
having performed the specialized deep check. Preserve its diagnostic
without granting it authority to park the repair responsibility.

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

* fix(heal): type MRF outcomes and defer status diagnostics

Carry storage-owned legacy observations in process and return a typed
terminal result only after the qualified MRF Deep check. Publish pause
events from that result and exact identities instead of source or text.
Keep object names out of retry classification and compute only the
status diagnostic selected by the canonical execution state.

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-22 02:45:31 +08:00
Hauser 179e58d5ef fix(ecstore): preserve diagnostic context in config tasks (#8779)
Capture an existing ReadVersion caller and earliest entry before spawning config transactions. Restore them with the existing scoped wrapper while retaining default-feature operation propagation and detached task ownership.

Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-10-22 02:45:17 +08:00
Hauser 5f5485e332 fix(s3): return InvalidPart and streamline multipart completion (#8780)
* fix(s3): classify empty multipart completion as InvalidPart

Treat absent per-drive multipart transaction directories as empty listings so
completion reaches the existing InvalidPart validation. Preserve quorum and
transaction recovery, and cover the HTTP and degraded-drive boundaries.

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

* perf(ecstore): move multipart completion metadata in one pass

Reuse quorum-validated part metadata instead of repeatedly inserting, sorting,
finding and cloning parts. Keep legacy cleanup identities and ordering while
moving the original part allocation into the completion manifest. Compare ETags
by borrowing their normalized values.

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-22 02:45:05 +08:00
Hauser 65f0599c70 feat(metrics): track usage outcomes and trim decommission probes (#8781)
* feat(metrics): expose usage candidate rejection outcomes

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

* test(scanner): borrow usage candidate fixtures

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

* test(ecstore): reserve capacity for decommission probes

Reserve all nextest slots for the two eight-disk decommission fixtures under both default and CI profiles. Keep their internal concurrency, production lock budgets, assertions and zero-retry policy intact.

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

* perf(ecstore): scope prewrite capacity probes to the target pool

After persisting a new capacity intent, refresh only the target pool baseline. Preserve the global admission and finalization scans, lock order, fencing, and accounting formulas. Verify the reduced probe count with real disk metrics and preserve injected snapshot/error behavior.

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-22 02:44:49 +08:00
Hauser d5e037faac fix(ecstore): retain operation in detached mutations (#8776)
Capture the caller operation before the tracked mutation task is spawned. Keep detached completion, owned guards, result errors and panic join failures intact.

Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-10-22 00:41:03 +09:00
Chris 97e600ae69 fix(heal): hold unobserved legacy MRF responsibilities (#8772) 2026-10-22 00:04:16 +09:00
Chris 84e96512bd fix(ecstore): honor idle drive monitoring opt-out (#8770) 2026-10-21 23:29:41 +09:00
Ramakrishna Chilaka 62aafc6b95 perf(filemeta,ecstore): avoid intermediate clones in FileInfo decode (#8767) 2026-10-21 22:35:34 +09:00
Chris b06923beaf test(e2e): add scanner/heal topology protection matrix (#8705)
* test(e2e): add scanner/heal topology protection matrix

Add the S00 protection baseline for the Scanner/Heal program
(rustfs/backlog#2873, program rustfs/backlog#2872): seven
scanner_heal_topology_ cases in the distributed e2e lane that pin pool
expansion, rebalance and decommission behavior before the runtime changes.

The fixture seeds a deterministic inventory (empty, inline, external,
multipart, special and nested keys; a versioned bucket with an explicit
null version, three history versions and a delete marker per key; an
Object Lock bucket with legal hold and COMPLIANCE retention), records
every version with its requested and listed version id, length and
SHA-256, verifies the manifest through every node, reads physical
placement from xl.meta on the pool drives, and reads the strict
durability mode back from the admin API and the node logs.

Fault cases hold the mover at proven windows through file barriers that
compile only with the e2e-test-hooks feature: before the multipart target
commit and before source cleanup (shared by decommission and rebalance),
plus the existing pool-metadata before_publish cutpoint. An unreached
window fails the case. DistCluster gains capture_logs_under so a case can
read node startup events without a global environment variable.

docs/testing/scanner-heal-topology-gates.md maps the gates to the tests
and records the timeout basis.

* test(e2e): keep the barrier failure message static

The error(format!) ratchet rejects new formatted io::Error::other call
sites in ecstore. The reached observation already records the stage and
object, so the injected failure needs no per-call detail.

* test(e2e): require e2e-test-hooks for topology fault cases

The four scanner_heal_topology_ fault cases inject at server barriers that
exist only with the e2e-test-hooks feature. Against a default server they
waited for the barrier timeout and reported it as not reached. Check the
verified binary's features first so the missing prerequisite fails
immediately and is named.

* ci(e2e): build the distributed lane with e2e-test-hooks

Every other e2e lane builds and runs the fault-test hooks server. The
distributed lane alone used the default server, so the topology fault
cases could never reach their barriers there. Build and run with the same
feature so the receipt matches, and update the documented local commands.
The upgrade cases spawn the pinned previous release directly and do not
compare feature sets.

* test(e2e): keep topology artifacts under RUSTFS_E2E_LOG_DIR

The topology fixture captured node logs, barrier observations and evidence
in a temp dir removed on drop, so the distributed workflow uploaded nothing
for these cases. Fall back to RUSTFS_E2E_LOG_DIR, which the workflow
uploads, when no explicit evidence directory is set.

* test(e2e): remove retired pools before restart; race the pre-ACK start

The queue case restarted with decommissioned pools 0 and 1 still on the
command line, which the server rightly refuses. Add
remove_single_node_pools and restart without them, then expect the
remaining pools renumbered to 0 and 1.

The pre-ACK case now races the start request against the barrier. A start
that returns first, or stays in flight past the documented 60 s budget, is
reported as such instead of as an unreached barrier, and the evidence
records the reach latency.

capture_logs_under now overrides a log path assigned from
RUSTFS_E2E_LOG_DIR, so the seed node logs into the case directory too.

* test(e2e): bound fault-window waits by the movement budget

On Linux CI a busy target capacity gate delayed the first fault object by
about two minutes, past the 120 s wait borrowed from the server's release
poll. Wait for a movement window within the 240 s movement budget instead,
and fail at once when the decommission or rebalance ends without entering
the window, so an uninjected fault is never counted.

* test(e2e): bound status probes while waiting for a fault window

The admin decommission status request can wait on a mover held at a test
barrier until the barrier's own 120 s release timeout. The window wait
issued that request each loop, so a reached barrier went unnoticed until
the server timed it out and failed the decommission, which surfaced as a
false resume failure. Bound each status probe to 5 s and treat a slow probe
as still running.

* test(e2e): retry a busy decommission start

A decommission start that meets a busy movement gate can answer a
retryable 503 instead of waiting. The harness treated any non-proof
failure as fatal. Share one start helper that retries 503 within the
case's movement budget and the fleet-proof 500 for 45 s, and keeps every
other status fatal. The pre-ACK case retries 503 within its window budget.
2026-10-21 22:08:17 +09:00
hector 023f2e7258 ci(pool): finalize decommission removal and verify surviving pools (#8749)
ci(pool): finalize decommission removal and verify surviving pools (rustfs#8726)

Select suite step 10 together with step 9 and pass
--finalize-decommission so the nightly exercises the post-removal
survivor path: pool ids must stay unique, bucket metadata writes must
keep succeeding across a restart, and no node may log 'pool metadata
layout changed'. Step 10 self-skips when decommission is disabled.

Co-authored-by: Hauser <housemecn@gmail.com>
2026-10-21 22:07:58 +09:00
Hauser 9fad6104c4 feat(metrics): observe remote directory walk lifecycles (#8765)
Track active walks, scratch capacity and accepted bytes while preserving the existing 1 MiB stream copy behavior.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-10-21 20:49:13 +08:00
Chris d2eda195e6 fix(decommission): defer source cleanup quorum failures (#8760)
* fix(decommission): defer source cleanup quorum failures

A quorum failure while deleting the source of an already copied entry
was fatal and marked the whole pool failed, although the copied source
is still in place and an uncommitted delete transaction is settled by
delete recovery. Carry the cleanup error typed and resolve a quorum
failure as Deferred: the entry is replayed at the end of the round, and
a deferral that persists takes the existing capacity-blocked pause path
instead of a terminal failure. Other cleanup errors and quorum errors
from other stages stay terminal.

Add set-level regressions for parent and child keys (`a/b`, `a/b/c`):
parent cleanup leaves every child file untouched, and parent cleanup
reaches quorum while the child is cleaned up or rewritten concurrently.

* test(ecstore): keep source cleanup errors typed in prefix cleanup tests

Return SourceCleanupError from the test helper instead of formatting it
into Error::other, and shrink the error(format!) ratchet baseline for
core/pools.rs, which lost one call site in the cleanup failure change.

* fix(decommission): retry cleanup quorum failures and count replays once

Retry a source cleanup quorum failure in place, up to three attempts
with the existing source-cleanup backoff, before deferring the entry.

Carry the versions an entry already counted on its deferral, so the
end-of-round replay does not add a fully counted entry to
items_decommissioned and bytes_done again.

A deferral that persists pauses the pool through the existing deferred
drain path. When source cleanup caused it, persist a reason led by
"source cleanup blocked" without the carrier error's free-drive wording,
and report waitingReason "source_cleanup" instead of "capacity".

Add a source_cleanup test fault stage and a routine test that drives a
cleanup quorum fault through deferral and replay to completion.

* fix(decommission): wait seconds between cleanup quorum retries

Give the in-place source cleanup quorum retry a fixed floor of one and
then two seconds, with the existing jitter on top, so it can ride out a
drive reconnect or health-check blip instead of retrying within a few
hundred milliseconds. The wait stays bounded and cancel-safe.

Find a source cleanup pause reason through stage wrappers, as the
capacity-blocked check does, and add a routine test where cleanup keeps
losing quorum: the decommission pauses with a source cleanup reason, the
bucket stays queued and the source stays in place.

Document the additive waitingReason value source_cleanup and its
compatibility with older nodes reading the same pool.bin.

* fix(decommission): never retry a lost source cleanup fence

The cleanup retry classifier used is_quorum_error(), which also matches
NamespaceLockQuorumUnavailable. The delete commit fence reports a lost
namespace lock or mutation fence with that variant, so a lost fence was
retried in place and only stopped later at the pre-cleanup fence check.

A lost fence means this worker no longer owns the key; it must stop
without retrying. Retry and defer only drive quorum failures:
ErasureReadQuorum, ErasureWriteQuorum, InsufficientReadQuorum and
InsufficientWriteQuorum.

---------

Co-authored-by: Hauser <housemecn@gmail.com>
2026-10-21 20:30:22 +09:00
唐小鸭 311f27baa1 fix(ecstore): handle Windows trash cleanup deletion races (#8754)
Co-authored-by: Hauser <housemecn@gmail.com>
2026-10-21 20:02:39 +09:00
Hauser 1ac35917c2 chore(deps): refresh dependencies and patch certificate trust (#8761)
* chore(deps): refresh dependencies and patch certificate trust

Update published dependencies and certificate verification/root data, and
upgrade rustfs-mimalloc to 0.7.0. Retain the known webpki-roots 0.26.11
compatibility-wrapper advisory without adding an audit exception.

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

* remove hyper dependency

* remove hyper dependency

---------

Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-10-21 16:40:33 +08:00
shurkus 73ff9a87e8 fix(s3): avoid mutation locks for existing buckets (#8745)
fix(s3): answer CreateBucket for existing buckets without locks

Co-authored-by: Hauser <housemecn@gmail.com>
Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-10-21 16:12:43 +09:00
Hauser 102cc18767 test(heal): retain unknown same-key repair across replay (#8759)
Cover actual journal and lifecycle checkpoint replay when an unknown historical source coexists with an exact source-bound repair. Reject a pre-restart lease and retain the complete unresolved record after the new repair is discharged and replayed again.

Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-10-21 15:02:01 +08:00
Chris 77a36d92a3 fix(decommission): let a failed start roll back its own activation (#8752)
* fix(decommission): let a failed start roll back its own activation

A fresh decommission start whose peer reload failed could not roll back:
the pool metadata stale-write guard from #6604 refuses any save that
clears a persisted active or queued decommission, so the pool stayed
active with no worker until the watchdog or a leader restart picked it
up.

The rollback save now carries the activation that start committed. The
guard exempts a pool only while its persisted record still equals that
activation field for field, so a worker checkpoint or a later start
keeps the rejection. A canceled record is not an active decommission
and is cleared by the rollback as well. Every other save path passes no
activation and is guarded as before.

A peer may already have reloaded the activation and started a worker
before the rollback. Saves are now fenced by decommission generation: a
write carrying a start_time is rejected when the persisted record
belongs to a later start, or has no decommission state and was updated
after that start_time, so such a worker cannot make the pool active
again. Its queued promotion and progress checkpoint also adopt the
persisted record and stop the worker.

Refs rustfs/backlog#2873

* fix(decommission): fence terminal writes of a withdrawn decommission

Terminal records carry no start_time, so the generation fence did not
cover them: after a start rollback, a peer worker of the rolled back
generation that failed (for example on a bucket completion) could still
persist a failed record over the restored pool. The merge now rejects a
failed, canceled or complete record over a persisted record without
decommission state; every attempt persists decommission state first, so
no legitimate path writes a terminal record there.

Peers also drop a withdrawn decommission sooner. A pool metadata reload
replaces a running decommission with a newer record that withdrew it,
even under an active worker; bucket completion adopts such a record like
promotion and progress checkpoints do; and a withdrawn progress
checkpoint stops the worker instead of logging a failed save and moving
more entries.

Tests cover the peer failure mark, a rollback retry that sees a worker
checkpoint land between attempts, and the reload withdrawal. A heal
fixture that wrote complete records over a fresh pool.bin now persists
the active attempt first.

Refs rustfs/backlog#2873

* fix(decommission): fence peer writes after a retry start rollback

A retry start replaces a failed or canceled record that keeps its
unresolved ledger, so its rollback restores that terminal record rather
than clearing the pool. The withdrawal checks only recognized a record
without decommission state, and the generation fence ignored a persisted
record without a start_time. A peer worker of the rolled back retry
could therefore checkpoint the pool back to active.

The generation fence now compares the persisted last_update whenever
the persisted record has no start_time of its own (cleared, terminal or
queued). The progress checkpoint also checks the generation against the
record read under the pool metadata lock, as bucket completion already
does, and the save-path withdrawal check treats a terminal record newer
than the running view as a withdrawal, so the peer adopts it and stops.

A reload still never lets a newer terminal record replace an active
worker's view, so after a retry's rollback a peer worker stops at its
next bucket completion or progress checkpoint rather than on reload.

Tests cover the retry rollback against a peer checkpoint and direct
write, and a peer bucket completion after a fresh start's rollback.

Refs rustfs/backlog#2873

* fix(decommission): keep a retry rollback's restored record intact

After a retry start rolls back, the restored record is failed, so a
peer worker's own failure mark passed every merge guard and replaced
it. A failure mark now saves through save_current_pool_meta_checked,
which runs the withdrawal check against the record read under the pool
metadata lock and adopts that record instead of writing.

The withdrawal check compared the restored record with the worker's
in-memory last_update, which a rejected save had already raised, so the
peer never adopted the rollback and the watchdog kept resuming it. It
now compares against the running generation (start_time), and a
rejected unresolved-entry save undoes its in-memory change.

save_current_pool_meta_for_decommission_start is also compiled for the
test-util feature again; a test-util helper still calls it, which broke
the workspace clippy and Connect builds.

Refs rustfs/backlog#2873

* fix(decommission): keep an unresolved entry when its own save fails

The rollback of a rejected unresolved-entry save also dropped the entry
on transient failures such as a quorum or lock-fence error, so the
following failure mark persisted a failed record without it and an
operator could clear a decommission that skipped an unreadable object.
Keep the entry and undo only the last_update bump, which is all a
withdrawn worker needs to still see the withdrawal.

Refs rustfs/backlog#2873
2026-10-21 15:45:48 +09:00
唐小鸭 444eb5ddba fix(scanner): admit dirty scope across the bucket-listing window and pace full-walk fallbacks (#8690)
Under sustained writes nearly every cycle fell back to a full walk: scope
admission required the global dirty generation to stay unchanged across the
bucket listing, and after a Complete cycle the dirty-usage wake ran the next
full walk immediately, back to back.

- Track dirty-map removals with a process-local removal generation and admit
  a scoped walk when no entry was removed or replayed during the listing
  (`covers_listed_pending`). Writes to listed buckets in the window join the
  start snapshot; unlisted buckets dirtied after the cutoff stay pending and
  are never cleared by compare-and-remove certification. The strict
  `covers_all_pending` still gates segment-reuse evidence.
- Classify scope fallbacks (removal during listing, generation exhausted,
  dirty-bucket overflow, no listed dirty bucket, baseline unavailable, peer
  scope unavailable), log `scoped_scan_fallback`, and report them in
  usage_freshness as additive serde(default) fields.
- Pace the cycle after a Complete fallback while dirty buckets remain on the
  capped retry schedule clamped to the cycle interval, with the dirty wake
  disabled; the paced timer wake still requests a scope.
- Update the per-bucket convergence design doc.

No persisted format or internode protocol change.

Co-authored-by: Chris <anzhengchao@gmail.com>
2026-10-21 13:55:18 +08:00
唐小鸭 bd7b7321a2 fix(replication): read source delete markers on multi-pool stores (#8756)
Delete-marker replication re-reads the source marker by version before sending. Multi-pool get_object_info rejects explicit marker version reads with MethodNotAllowed, so every marker failed verification and was never replicated. Add a defaulted ObjectOperations::get_object_info_for_delete, override it on ECStore with the marker-tolerant snapshot, and route both replication marker reads through it.

Fixes rustfs/rustfs#8746
2026-10-21 13:54:50 +08:00
Chris d122e8e6bc fix(ecstore): ignore child-key prefixes in delete settlement fingerprint (#8751)
* fix(ecstore): ignore child-key prefixes in delete settlement fingerprint

A batch delete of prefix-sharing keys (foo/bar and foo/bar/xyzzy) could
leave foo/bar's commit proof on disk: the child's finalization removed
foo/bar/xyzzy/ between the parent's GC inspection and settlement, the
fingerprint mismatch surfaced as OutdatedXLMeta, and publish_delete_commit
dropped the error. An immediate DeleteBucket then returned BucketNotEmpty.

Fingerprint only this key's protocol evidence (files, UUID dirs and the
transaction dir), retry published cleanup on OutdatedXLMeta with a fresh
inspection, and warn when cleanup is deferred to the recovery hint.

* test(ecstore): cover delete fingerprint narrowing and cleanup retry

Only canonical hyphenated UUID directories count as protocol evidence, so
a child key named as a simple-hex UUID cannot recreate the race. Add
negative fingerprint checks for this key's own changes, and tests for the
published-cleanup retry: success on retry, the attempt bound with the
deferred warning, and no retry after a lost fence.

* fix(ecstore): require canonical UUID names across delete recovery

Discovery, explicit legacy discard and staged-dir checks still accepted
simple, braced and URN UUID spellings. A child key named as the simple
form of a referenced data dir could then be deleted by DiscardLegacy.
Use one canonical-name predicate everywhere and register exactly one
test hook per expected cleanup attempt.
2026-10-21 14:47:34 +09:00
唐小鸭 30c1dffe48 fix(replication): replicate null versions written before versioning (#8757)
The scanner heal gate dropped Some(Uuid::nil()) along with None, so null versions in versioned buckets never reached existing-object replication. Skip only unversioned listings, matching MinIO, and recognize the nil UUID as the null version in the newer-target guard and version comparison.

Fixes rustfs/rustfs#8747
2026-10-21 13:35:47 +08:00
Hauser 2eeaca4cd0 fix(iam): derive bucket location conditions from XML (#8744)
* fix(iam): derive bucket location conditions from XML

Capture the parsed CreateBucket LocationConstraint at the access boundary
and move it into shared authorization conditions independently of signing
and server regions. Cover conditional allow/deny across user, session and
anonymous requests, including absent and spoofed values.

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

* refactor(iam): pass bucket location directly to authorization

Replace temporary request-extension storage with an explicit owned XML
argument to the single authorization core, keeping the public entry point
compatible. Remove the marker allocation and typemap insert/remove path.

Cover Object Lock and Versioning dependent authorizations with an absent
location condition and verify their enabled state through real HTTP.

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

* test(connect): isolate bounded object probe storage

Use an independent temporary volume on Linux shared memory when available
so the real endpoint/CLI fixture does not depend on contended CI disk IO.
Await server shutdown before dropping the caller-owned storage directory.
Retain the production cleanup deadline, measurement budget, assertions,
and existing nextest capacity reservation without adding retries.

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-21 13:35:35 +08:00
Ramakrishna Chilaka 001e92226e test(filemeta,ecstore): cover listing metadata and msgpack decode paths (#8755) 2026-10-21 14:03:23 +09:00
cxymds 65a721d896 fix(ecstore): reindex pool metadata ids after removal (#8733)
* fix(ecstore): reindex pool metadata ids after removal

* ci(nextest): reserve resources for table catalog lock probe

* fix(ecstore): reindex retained decommission references

* fix(ecstore): use typed pool metadata rebuild errors
2026-10-21 13:36:48 +09:00
Hauser f1850f54d4 fix(runtime): isolate configured region per store instance (#8743)
* fix(runtime): isolate configured region per store instance

Resolve the default RegionInterface through the owning ECStore context
and keep ambient fallback only for requests without an explicit context.
Preserve ambient constructors and injected interfaces, with store and
real HTTP multi-instance regression coverage.

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

* test(storage): stabilize quorum and heal crash fixtures

Keep forced-offline disks attached across reconnects and reconnect before every delete retry. Bound SIGKILL fixture I/O while preserving recovery scope, receipts, data integrity, and deadlines; include child execution phases in timeout diagnostics.

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

* docs(test): clarify forced-offline disk fixture behavior

Describe handle retention instead of claiming a newly reconnected health
tracker inherits the test outage. Execution and assertions are unchanged.

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-21 13:06:54 +09:00
cxymds 2c55d9c8f0 fix(ecstore): allow repeated marker deletes at write quorum (#8750) 2026-10-21 11:31:59 +08:00
Chris 5efa6c2173 fix(rebalance): move tier free versions with their entry (#8753)
* fix(rebalance): move tier free versions with their entry

Rebalance built its version snapshot with MetaCacheEntry::file_info_versions,
which drops tier free versions, while the source-cleanup preflight reads the
exact snapshot that keeps them inline. Any entry holding a free version (left
by deleting a transitioned version) therefore failed the preflight on every
pass and was deferred forever, so the source pool could never be drained of it.

Mirror decommission: keep free versions in the snapshot, skip lifecycle and
the last-delete-marker policy for them, and move them to the target as tier
metadata through decommission_tiered_object. A free version reads as a delete
marker with no transition status, so the migrator routes it explicitly;
written as a delete marker it would drop the remote owner and orphan the
remote object. A moved free version may be consumed from the source before
cleanup, like decommission; free versions hold no pool data and stay out of
the rebalance stats.

Refs rustfs/backlog#2873

* test(rebalance): cover tier free version movement edge cases

Pin the lifecycle skip, the delete-marker count, the allowed-missing and
not-found consumption paths, the stats exclusion, multiple free versions per
entry, and the unversioned superseded source's free version moving on the
next pass.

Refs rustfs/backlog#2873
2026-10-21 12:25:30 +09:00
Hauser 2110348e58 fix(metrics): expose allocator observation availability (#8748)
Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-10-21 10:34:27 +08:00
cxymds 2253ba9009 fix(tier): restore private endpoints and forced config removal (#8725)
* fix(tier): restore private endpoints and forced config removal

* test(tier): align proof failure with force semantics
2026-10-21 10:21:27 +08:00
GatewayJ 72d5c5bf2f fix(table-catalog): support manifest-only rewrites (#8731)
* fix(table-catalog): support manifest-only rewrites

* fix(table-catalog): preserve manifest rewrite metadata
2026-10-21 09:29:23 +08:00
Hauser 13c37bf8b9 fix(s3): validate bucket location constraints (#8739)
* fix(s3): validate bucket location constraints

Reject explicit bucket locations outside the configured server region
before mutation admission, preserve site-default and EU alias compatibility,
and return an empty GetBucketLocation element for us-east-1.

Cover matching and rejected locations over real S3 HTTP requests, including
rejection without creating a bucket.

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

* test(e2e): refresh bucket region profile selection digests

Include both new region regression tests in the smoke and Darwin/Linux
full selection pins without changing filters or checker behavior.

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-21 03:28:23 +09:00
Hauser 38595c1909 fix(metrics): preserve S3 operation across detached tasks (#8738)
Establish the first bucket operation before fresh-task dispatch and carry its bounded value into nested config and background work. Preserve cancellation, permit ownership, lock order, and error mapping without importing diagnostic owner or sampling instrumentation.

Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-10-21 01:22:28 +09:00
cxymds 9b41835580 fix(decommission): allow retry after unresolved failure (#8737) 2026-10-20 23:07:06 +08:00
cxymds a5ae294f42 fix(quota): refresh peer metadata after admin quota changes (#8735)
* fix(quota): refresh peer metadata after admin quota changes

* test(e2e): update distributed selection for quota regression
2026-10-20 23:06:18 +08:00
overtrue 9fbf5d0553 feat(storage): retain managed artifact versions for database backups 2026-10-20 23:51:32 +09:00
GatewayJ 4dba635d48 fix(table-catalog): authorize warehouse location claims (#8736) 2026-10-20 22:05:29 +08:00
Chris 7cc4a9dbd0 fix(ecstore): leave no source free version for a moved tier version (#8732)
* fix(ecstore): leave no source free version for a moved tier version

Data-movement source cleanup deletes each source version separately and
never set skip_tier_free_version, so every transitioned version that
decommission or rebalance had already copied to a target pool left a new
tier free version on the source. Decommission then failed its post-check
for any pool with tiered objects; rebalance left the record behind. The
record claims the remote object the target copy still reads and stays
blocked behind that live reference.

Decommission and rebalance now pass the identities of transitioned
versions whose copy committed on a target, and the cleanup skips the
free version for exactly those. A transitioned version that did not
move, such as one lifecycle expired during the move, still gets its free
version because that record is its only remote-delete owner.

Refs rustfs/backlog#2873, rustfs/backlog#2872

* fix(ecstore): keep the free version of a superseded moved source

Data movement treats a source null version as complete when a newer
unversioned object already sits on the target, without copying it.
That outcome returned the same Ok as a committed copy, so a restored
transitioned null version superseded this way was recorded as
transferred and lost its source free version, leaving its remote object
with no owner.

The copy paths now return DataMovementCopy::Committed or ::Superseded,
and decommission and rebalance record only committed copies of
transitioned versions. A superseded source keeps its free version.

Tests cover the superseded restored null version for rebalance and
decommission, a restored and a null transitioned version moving by
rebalance, an entry with one lifecycle-expired and one moved version,
the per-version skip with two versions of one key, and a directory
object. The tiered stale-target routine test from #8724 now expects the
decommission to complete. The ILM ownership contract and the
decommission notes describe the moved, superseded, expired and legacy
cases.

Refs rustfs/backlog#2873, rustfs/backlog#2872

* test(ecstore): build the decommission ILM fixtures without s3s

The superseded and expiry-mix decommission tests named s3s request and
lifecycle types directly, which added pools_test.rs to the ecstore
files that reference s3s and failed the footprint ratchet. Set the
restore request through ObjectOptions, and store the lifecycle rule as
bucket metadata XML and load it with get_expiry_configs, as the
decommission routine does.

* test(ecstore): pin restored decommission copies and multipart supersession

Decommission recorded a committed data copy of a restored transitioned
version without any test failing if the record were dropped; add a
routine-level decommission of a restored version that checks the post-
check passes, the source keeps nothing, and the target keeps the full
remote tuple. Add the multipart variant of the superseded restored null
version (rebalance), and a unit test that separates committed,
superseded and mismatched targets in the overwrite resume adjudication.
2026-10-20 22:20:12 +09:00
Hauser 928a369445 fix(obs): skip address discovery for initialized node identity (#8730)
* fix(obs): skip address discovery for initialized node identity

Check the nonblocking node name before resolving the local IP, retaining the final name recheck after fallback. Add portable publication regressions and Linux worker-local netlink fault coverage.

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

* test(obs): keep identity contention checks in common

Exercise the real held-writer getter inside its owning module. Publish through the existing runtime helper from obs tests, retaining the kernel netlink failure and recovery coverage without bypassing architecture guards.

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

* fix(obs): scope unsafe allowance to seccomp test helper

Document the bounded prctl FFI safety contract and allow unsafe code only
inside the Linux fault-filter installer so workspace unsafe-code denial
continues to apply everywhere else.

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-20 20:17:55 +09:00
overtrue e7bebb0fc5 chore: sync main through 5313b72ba into integration 2026-10-20 20:06:41 +09:00
overtrue cacdaa65a0 feat(storage): add fenced exact artifact version deletion 2026-10-20 20:05:56 +09:00
Chris 5313b72baa fix(decommission): run the leader-side start to completion (#8723)
* fix(decommission): run the leader-side start to completion

A forwarded decommission start awaits `ECStore::decommission` inline in
the RPC handler. When the forwarding node's RPC times out, the stream is
reset and the handler future is dropped wherever it is. Dropped after
`pool.bin` records the pool as active but before the peers reload and
the worker is spawned, the start left the pool active with no worker and
peers on the old metadata, and a retry was rejected as already active.
An admin client that disconnects can do the same.

Run the start in its own task and let the caller await only its result.
The start then always reaches its end: it fails before persisting
anything, or persists the pool, has the peers reload and spawns the
local workers, rolling back if the reload fails. A retry still cannot
start twice: it waits on the start gate held by the running start and
either reports the busy gate or is rejected because the pool is active.

`decommission` now takes `self: &Arc<Self>` and spawns workers on that
store instead of looking up the global object store, which is the same
store in production and makes the path testable; the unused
`require_decommission_store` helper is removed.

Refs rustfs/backlog#2873

* fix(decommission): detach the leader-side start from its caller

9ac4c10de carried only the reproduction (the testable entry point, the
peer-reload pause point and the proof test). This adds the fix it
describes: `ECStore::decommission` spawns the start as its own task and
awaits only its result, so a caller that stops waiting, such as a
forwarded start whose internode RPC timed out, can no longer drop the
start between persisting the pool as active and having the peers reload
and the local worker start.

Also prove a retry while the first start runs cannot start the pool
twice, update the operator and architecture docs for the new behavior,
and shrink the error(format!) ratchet baseline for the removed helper.

Refs rustfs/backlog#2873

* fix(decommission): single-flight start, recheck pool state under gate

Review of 3a5276c5b:

- A start re-checks the pool state under the start gate before it
  cancels routines, so a start racing another one can no longer cancel
  the worker the other start just spawned.
- Only one start runs per instance at a time: another request, including
  the retry of an operator whose request timed out, is rejected as
  already running instead of stacking a second detached start and its
  preflight heals. The start runs through `run_detached_mutation`, and a
  failure nobody waits for any more is logged.
- Tests: the proof test pins its drop after the save and requires a
  worker that persists a cancellation; the retry test really drops the
  first caller; new tests cover the under-gate recheck and a peer reload
  failure after the caller is gone. That last one shows the start
  rollback cannot clear a fresh start because the pool metadata
  stale-write guard refuses it; the gap is asserted and documented and
  tracked in rustfs/backlog#2873.
- The start tests run on their own large-stack thread: the io_uring lane
  (`uring_`, which also selects `during_`) runs them with libtest's 2 MiB
  default stack, where the proof test overflowed.
- Docs: single-flight starts, status and cancel during preflight, the
  rollback gap and its recovery, and the release requirement.

Refs rustfs/backlog#2873

* fix(decommission): turn away local start retries at the start slot

Second review of 29253430a:

- The leader-local admin pre-check refreshed rebalance metadata under
  the start gate before the start slot was checked, so a local retry
  waited out its 20 s gate budget behind a running start (which holds
  the gate through its peer reload) and then got a 503. The pre-check
  now checks the slot first and returns `DecommissionAlreadyRunning`,
  which the handler reports as a 400 that keeps the reason.
- Prove the reload-failure path really ran in the detached start (the
  injection is consumed), add a test that a fresh start's failed
  rollback reports both the reload error and the stale-write refusal,
  and assert an abandoned start stays tracked for shutdown.

Refs rustfs/backlog#2873
2026-10-20 18:20:13 +09:00
Chris f98d268f84 fix(decommission): retry every stale target selection without failing (#8724)
* fix(decommission): retry every stale target selection without failing

The tier free-version copy path counted a version as failed as soon as
a capacity failure came back and then handed the error to the entry
retry, so a retry that succeeded left the decommission failed. Return
the failure without counting it, like the other copy paths.

A selected pool can also stop fitting the mutation before admission
without going over a budget: the reservation estimate is exhausted, the
pending intent no longer fits the committed-copy budget, or a temporary
write no longer fits the headroom. Report these as "no longer fits the
selected mutation" and treat them like an over-budget pool under one
StaleTargetSelection class: retry, select again, defer once the retry
budget is spent.

Also refresh the over-budget gauge of every allocation before rejecting
growth, correct the sticky-target doc on stranded markers, and cover
each copy path, the pinned path and both retry branches with tests.

Refs rustfs/backlog#2873

* fix(decommission): name the last deferral cause in the pause reason

A round that ends with deferred entries pauses the routine with a
generic "deferred entries remaining" reason, so a pinned entry that no
longer fits its pool hides its cause in the logs. The pause reason now
appends the last deferral's error.

Keep the committed-copy check on the pending intent fatal: growth and
owner admission reject any mutation that would fail it first, so it
only fires on an inconsistent ledger.

Tests:
- a multipart part that does not fit target 1's temporary-copy
  headroom is deferred and pauses with the headroom shape;
- a pinned object whose remaining allocation is below its peak pauses
  with the exhausted-estimate cause, and the over-budget pinned test
  checks its own cause;
- the stale-target tests assert where the pause lands: the held entry
  is on neither target, the anchor is already on target 1, and target
  1 holds exact inflight bytes in the multipart cases;
- share the stale-target store, barrier and pinned-copy setup.

* fix(decommission): prefer a stale selection as the pause cause

The pause reason named whichever deferral of the set finished last, so
a transient busy gate could hide another entry's permanent shortfall.
A stale target selection, which waiting does not clear, now outranks
transient contention, and the reason reads "; deferral cause: <error>".

Tests:
- a pinned object that no longer fits and a later object whose gate is
  busy, in the same set, pause with the pinned cause; a unit test checks
  both orders;
- the tiered and intent-conflict tests check where the pause lands, and
  the tiered test asserts the known post-check failure so that fixing
  the source cleanup defect flips it.
2026-10-20 17:40:16 +09:00
Ramakrishna Chilaka f4f1743d04 perf(ecstore,filemeta): avoid inline-data reads and redundant decodes in list_objects (#8706) 2026-10-20 16:33:02 +09:00
Chris 8acfde3842 test(quota): cover early-reload routing and snapshot age boundaries (#8722) 2026-10-20 14:05:55 +09:00
overtrue f320f791a4 test(decommission): prove the shared gate budget and each bounded wait
Second review of 652c56875..0ef02a485:

- The operator doc and the budget comment claimed a forwarded start that
  outlives the internode RPC timeout still completes on the leader. The
  leader drops it wherever it is, possibly after pool.bin records the
  pool as active but before its worker starts or the peers reload.
  Document that state, the watchdog that resumes the worker, and the
  checks to run; the cancel-safety fix is tracked in rustfs/backlog#2873.
- Prove the budget is shared across waits (paused clock: a 60 ms wait
  leaves 40 ms for the next gate).
- Prove the rebalance-refresh start gate and activation gate waits and
  the decommission() entry are bounded and named.

Refs rustfs/backlog#2873
2026-10-20 13:29:14 +09:00
overtrue 4d2e362e23 fix(decommission): count only gate waiting in the start budget
Review of 652c56875 found that the 30 s start budget was a wall-clock
deadline fixed when the start began, so slow preflight work (bucket heals,
capacity scans) could spend it before the activation gates were reached.
A gate held for a few milliseconds then failed the start at once, and a
retry repeated the preflight.

- Replace the deadline with a budget that only counts time spent actually
  waiting for a gate; a free gate is always taken, and the lock timeout
  reports the time waited.
- Lower the budget to 20 s, below the default 30 s internode RPC timeout,
  so a forwarded start gets the leader's answer instead of an RPC timeout.
- Name each bounded wait (rebalance metadata refresh or decommission
  activation, and which gate), and route every start-gate rebalance load
  through one load_rebalance_meta_within.
- Move the admin pre-check error mapping into a function and test it.
- Add proof tests: a slow preflight followed by a brief gate wait still
  starts; the activation wait and the admin pre-check are bounded and
  named; the rebalance status read does not wait for an in-flight
  rebalance entry.
- Correct the operator doc: preflight bucket heals are outside the budget,
  and forwarded starts report a generic internal error.

Refs rustfs/backlog#2873
2026-10-20 13:29:14 +09:00
overtrue a55f2f671b fix(decommission): keep status reads and starts off the movement gate
The data-movement operation gate is a write-preferring RwLock. A scanner
publication lease stores a read guard on the leasing node, and the
coordinator's lease-scoped rename, delete and validation on that node
queued a second read. A movement writer queued in between (a decommission
start, a status refresh, a peer reload) then waited for the lease while
the coordinator's second read waited for the writer, so both stalled
until the 60 s lease TTL. Admin status reads also took the movement
writer, so every status call waited for the slowest in-flight copy or
cleanup and stalled new movement reads while it queued.

- Lease-scoped target admission shares the read admission the lease
  already holds instead of queueing a new read.
- Pool and rebalance status reads publish under the gates only when they
  are free; otherwise they merge the persisted metadata into a private
  copy and publish nothing.
- A decommission start bounds its start, activation and movement gate
  waits with one 30 s budget and fails with a retryable lock timeout
  before persisting anything; the admin handler maps it to 503 with
  Retry-After.

Refs rustfs/backlog#2873
2026-10-20 13:29:14 +09:00
Chris e7e53f07b2 fix(decommission): scope temporary-copy budget checks to the growth pool (#8721) 2026-10-20 13:28:51 +09:00
Chris da7fde0e62 fix(quota): stop pinning quota e2e readiness to the scanner leader (#8719) 2026-10-20 13:28:22 +09:00
Chris 6127deaa83 test(obs): pin daily rotation boundary test to fixed timestamps (#8716)
The test derived its inputs from Zoned::now() and assumed that now - 60s is still on the same local calendar day, so it failed deterministically in the first minute after local midnight. Build the inputs from fixed local wall-clock times instead, covering same day, previous day and the 23:59:59 -> 00:00:00 crossing.
2026-10-20 11:52:26 +09:00
Chris ea596b2e2d fix(decommission): adopt target intents left by a killed leader (#8710)
* fix(decommission): adopt target intents left by a killed leader

A decommission leader killed after persisting a target capacity intent and
before finalizing it left that intent in the exclusive pending slot of the
still-active reservation. The resumed routine derives the same reservation
identity, but every other mutation on that target was rejected as "another
mutation" until the interrupted object happened to be retried on the same
target, so the resumed decommission exhausted its retries and stalled.

At routine start, before any worker runs, the resumed routine now adopts the
reservation's pending intents: under the target fence (or the legacy pool
metadata fence) it moves each one into a mutation-scoped inflight marker that
keeps its identity and bytes. The interrupted mutation's retry settles the
marker through the existing commit, temporary-release and equivalent-target
paths, and an equivalent committed target now settles a mutation's own marker
when no pending intent remains. Adoption is pinned to the persisted
decommission start time and reservation identity, never runs mid-incarnation,
and a failure pauses the routine instead of marking the decommission failed.

* fix(decommission): keep adopted intents within budget and settle them once

Address the adversarial review of the leftover intent adoption.

- Merge an adopted intent into its mutation's staged marker by summing only
  while the target's temporary-copy budget allows, never below the larger of
  the two. A multipart completion moves its staged parts into the object, so
  summing them could exceed the budget and pause every later routine start;
  the adopted marker now exceeds the budget only where the completion's own
  finalize would have. Reuse resolve and record_inflight for the move.
- Distinguish a temporary release that proved the exact committed target from
  one that could not prove the target identity. When the committed mutation no
  longer has a pending intent, release only the bytes the cleanup freed and
  keep the adopted completion for the equivalent-target reconciliation.
- Let an equivalent target settle a mutation's own marker while another
  mutation holds the pending slot, instead of failing on that unrelated intent.
- Cover real leader kills before and after the target commit at routine level
  for both lock models, an injected adoption failure that pauses and then
  completes, an adopted multipart completion surviving a background abort, and
  a live legacy intent. Routine tests reuse the large-stack test helper.

* fix(decommission): pause instead of failing on target slot contention

The S00 leader-kill run with the null-version fix merged ended the resumed
decommission as failed. A re-run of a versioned key reconciled its delete
marker against an already charged target copy while another worker of the
same incarnation held the target's pending slot between a failed write and
its retry. The reconciliation rejected that unrelated intent, the entry
context wrapper flattened the error to a string the intent-conflict retry
could not recognize, and the bucket runner reported a sibling bucket's
OperationCanceled instead of the capacity block, so the routine marked the
decommission failed.

- An equivalent-target reconciliation, including the temporary checkpoint
  variant, no longer depends on another mutation's pending intent: a target
  holds one pending intent at a time, so this mutation has none to settle.
- The entry context keeps the typed cause, and the capacity-block and
  intent-conflict classifiers also recognize the rendered message that an
  error clone keeps.
- Concurrent bucket and listing failures keep the root cause over sibling
  cancellations, so a capacity block pauses the routine.

* test(decommission): delete marker copy waits for a busy target gate

The Linux run of the S00 matrix showed the delete marker copy meeting a busy
target capacity gate without any leader kill. The copy already waits on that
gate like an ordinary object copy; pin it with an entry-level test that holds
the gate, checks the entry keeps waiting, and expects the key to migrate once
the gate is released. Share the versioned delete marker fixture with the
foreign pending slot test.

* fix(decommission): keep an adopted completion unless the target is absent

Close the remaining path where an adopted completion could lose its record
before being charged. A multipart cleanup that found a target it could not
prove as the exact owned copy still dropped the whole marker of a mutation
without a pending intent, after which the equivalent-target reconciliation
would treat the commit as already charged.

Any cleanup that did not prove the exact target absent now releases only the
bytes it freed when the mutation has no pending intent, and leaves the rest
for the reconciliation. This makes the three-state release unnecessary, so
the release proof goes back to the existing clear-pending flag.

* chore(ci): shrink the error format ratchet baseline for pools.rs

9eae47ce8 removed one Error::other(format!) call site from crates/ecstore/src/core/pools.rs, so the ratchet requires the baseline to drop from 178 to 177.

* fix(decommission): keep adopted completions in their own capacity marker

An adopted completion shared the mutation's staged marker, so whether
it survived a cleanup depended on the pool-wide free-space delta, and a
cleanup that freed little space left staged bytes behind and repeated
forever.

Record each adopted intent under a mutation id derived from the
interrupted mutation (decommission_adopted_intent_mutation_id) as a
separate temporaryMutations entry. A commit or an equivalent-target
reconciliation settles both markers, a temporary cleanup releases the
staged marker as before, and only a cleanup that proved the exact
target absent drops the adopted one. While the target holds only that
adopted completion, a cleanup keeps the delayed-release exemption of
the published pending intent it replaced. Older nodes see an ordinary
temporary mutation they never release, which only over-reserves.

Also log capacity_intents_adopted as soon as the adopted ledger is
durable, merge the root-cause helpers into decommission_root_cause,
drop the decommission_capacity_blocked: prefix fallback, and log the
already-charged equivalent-target branch at debug level.

Refs rustfs/backlog#2873, rustfs/backlog#2872

* fix(decommission): return uncommitted retries to their adopted target

A retry that had not committed yet went to the reserved target with the
most remaining allocation. When that was not the target holding the
mutation's adopted completion, the commit settled only its own target
and the adopted marker stayed inflight until the reservation was
released.

Prefer a reserved target that holds the mutation's adopted marker while
it still fits the object (decommission_reserved_target_choice).

Also cover the remaining settlement paths of the derived marker
(non-growing replacement, equivalent temporary reconciliation and its
discovery gate, scanner backlog retirement), repeated adoption of one
mutation and the zero-byte staging record in both lock models, drop two
conditions that always equal another one, and correct the adopted-id
doc comment about nodes without adoption.

Refs rustfs/backlog#2873, rustfs/backlog#2872

* fix(decommission): count a completion adopted twice only once

A mutation killed twice leaves an adopted completion and then a new
intent for the same object version. Adoption added the two, so an
11-byte object counted 22 inflight bytes and could trip the capacity
check once later commits shrank the budget. Both reserve the same
completion, so merge them by their maximum before adding staged parts;
a single adoption is unchanged.

Also assert in the three-pool sticky test that no entry failed, the
retry is charged on its adopted target and the source is cleaned, run
it for the legacy lock model too, stop saying operators rely on the
adoption log before rollback, and document that the selection fallback
cannot help an adopted completion.

Refs rustfs/backlog#2873, rustfs/backlog#2872

* fix(decommission): log where an adopted retry is placed

An e2e run could not show whether a resumed retry went back to the
target holding its adopted completion. Target selection now logs
adopted_target_reused when it does, and adopted_target_bypassed with
the adopted target pools when the object no longer fits there and the
adopted marker stays behind. Both carry the mutation id and the source
and target pools.

Refs rustfs/backlog#2873, rustfs/backlog#2872
2026-10-20 11:28:22 +09:00
Chris 10a9b158d7 fix(ecstore): address the null version exactly during data movement (#8711)
* test(ecstore): pin null-version migration behind a delete marker

Decommission and rebalance must move a pre-versioning null version that
sits behind a delete marker, with its content and SHA-256 intact, before
removing the source entry (rustfs/backlog#2873).

* fix(ecstore): address the null version exactly during data movement

Decommission and rebalance read each source version with
`version.version_id.map(to_string)`. A null version, written before
versioning was enabled, has no version id, so the read used `None`,
which selects the latest version. Behind a delete marker that read
returned not found, the mover counted the version as already gone, and
source cleanup deleted the still-present null version with the entry.

Address the null version by the nil UUID, the existing exact selector,
on every data-movement lookup: the source read, the target put, new
multipart and complete options, the target resume lookup, and the
source revalidation before publication. The stale-target replacement
check treats the nil selector and an id-less null version as the same
identity, so unversioned buckets keep their current behavior. Target
pool selection still places a null version with the object's latest
version, as before, so a later null-slot overwrite replaces it there.

A version the mover skips as not found must also really be gone before
cleanup. It is dropped from the expected cleanup snapshot, so the
preflight reports SourceChanged while that exact version still exists
and the existing retry and exhaustion path retains the source.

Refs rustfs/backlog#2873, rustfs/backlog#2872

* fix(ecstore): prove multipart null-version staging absence exactly

The multipart staging cleanup proof in
abort_multipart_uploads_for_data_movement looked up the target with the
abort options' version id. Those options keep an id-less null version
as `None`, because the staged upload identity is derived from them, so
the proof resolved the target's latest version. Versions move newest
first, so it found the delete marker or newer version already moved and
refused every attempt with "cannot prove the exact staged identity is
absent". The legacy capacity model runs this proof on every attempt and
the exact model after any interrupted upload, so a multipart null
version behind a newer version could never be decommissioned. Look up
the exact null version for the proof and keep the abort options as they
are.

Cover the paths review found unprotected: multipart null versions under
the legacy model and after an interrupted upload, null versions behind
a newer version (lost without any not-found), the cleanup guard for
versions reported as not found while they still exist (decommission
read and delete-marker copy, rebalance read, and a control where the
version really vanished), and a retry after the null version committed
but before cleanup. The decommission fault hook can now inject a chosen
error, and rebalance gains a test-only source read fault.

Refs rustfs/backlog#2873, rustfs/backlog#2872
2026-10-20 10:12:59 +09:00
overtrue efe2255ccf chore: sync main through 6f582b054 into integration 2026-10-20 04:51:06 +09:00
overtrue f4b694ed19 chore: sync main through 05b483b31 into integration 2026-10-19 19:38:08 +09:00
overtrue aa95a32ea2 chore: sync main rebalance status fix into plugin integration 2026-10-19 18:14:28 +09:00
overtrue 3f3e892d86 feat(admin): stream sealed artifact inventories 2026-10-19 16:39:50 +09:00
overtrue 874d2babcc chore: sync main CI scheduling into plugin integration 2026-10-19 16:27:42 +09:00
overtrue 7c85783a08 chore: merge main into plugin runner integration 2026-10-19 15:28:53 +09:00
overtrue 541fbbe74a feat(storage): authenticate all-member artifact inventory streams 2026-10-19 14:46:05 +09:00
overtrue 08ba7a7976 feat(storage): inventory sealed artifact versions on local disks 2026-10-19 13:19:30 +09:00
overtrue 81f214c99a merge: sync upstream deletion recovery and rollout fixes 2026-10-19 12:14:05 +09:00
overtrue dff05785de merge: sync upstream auth and deployment fixes 2026-10-19 11:12:56 +09:00
overtrue cf2e3af4d9 merge: sync main into plugin runner integration 2026-10-19 10:29:29 +09:00
overtrue e0f0628bc1 feat(storage): seal artifact writes across one disk inventory 2026-10-19 08:15:20 +09:00
overtrue 6de3034e36 fix(storage): reject ordinary deletes from managed artifact buckets
Reject single, batch, and exact-version deletes before staging mutations.
Transfer real lifecycle ownership through detached deletion and reuse the
cleaner's existing owner to avoid a queued-writer deadlock.

Correct the physical-tail cancellation observer and cover disconnected PUT
publication plus managed delete refusal with four-node tests.
2026-10-19 06:57:35 +09:00
overtrue ae259e127d feat(storage): seal distributed artifact repair writes 2026-10-19 05:49:40 +09:00
overtrue 8233e3fe4c feat(storage): enable distributed artifact PUT sealing 2026-10-19 02:38:26 +09:00
overtrue f88b7b6ba5 fix(storage): bind volume creation to the receiving instance 2026-10-19 01:52:02 +09:00
overtrue 0307c9a7e2 fix(storage): retain admission through HTTP stream writes 2026-10-19 01:44:38 +09:00
overtrue 6a8d0aba58 fix(storage): retain admission through deletion cleanup 2026-10-19 01:06:40 +09:00
overtrue ee6c2afe43 fix(storage): serialize multipart publication and rollback 2026-10-19 00:09:40 +09:00
overtrue 9ca8bc3ca6 fix(storage): record failed rename admission metrics 2026-10-18 23:21:59 +09:00
overtrue 3678703a98 fix(storage): retain dual-volume admission through file rename 2026-10-18 23:11:43 +09:00
overtrue fe798c13e7 fix(rpc): admit grouped version deletion before classification 2026-10-18 22:35:09 +09:00
overtrue 24b77349aa feat(storage): admit legacy bulk version deletion 2026-10-18 22:22:18 +09:00
overtrue 8c55396bfe fix(rpc): admit staged single-object version deletion 2026-10-18 22:04:43 +09:00
overtrue 5be8badb20 fix(storage): retain admission across staged version deletion 2026-10-18 21:48:17 +09:00
overtrue 46daaadbb3 fix(rpc): admit legacy single-object delete rollback 2026-10-18 21:40:48 +09:00
overtrue f3b6c4df6b fix(storage): retain admission through single-object delete rollback 2026-10-18 21:38:26 +09:00
overtrue 7183849227 fix(storage): admit ordinary version deletion before classification 2026-10-18 18:41:13 +08:00
overtrue 942ce79c4e fix(storage): admit ordinary undo before artifact classification 2026-10-18 06:08:50 +08:00
overtrue fec1262f1a fix(storage): admit metadata updates before bucket classification 2026-10-18 05:39:45 +08:00
overtrue 5eb46cb29d fix(rpc): admit both volumes before legacy rename classification 2026-10-18 05:30:33 +08:00
overtrue 2cd904d0e7 feat(storage): prepare legacy renames with dual-volume ownership 2026-10-18 05:18:24 +08:00
overtrue 61eec6da18 fix(storage): reject contended rollback backup publication 2026-10-18 04:53:45 +08:00
overtrue 5b8cd2a5dc test(storage): initialize real lifecycle owners in fixtures 2026-10-18 04:51:11 +08:00
overtrue d7824049b2 fix(storage): bind metadata RPCs to prepared volume leases 2026-10-18 04:32:19 +08:00
overtrue d46e4c54a6 fix(storage): bind legacy file RPCs to volume ownership 2026-10-18 04:00:46 +08:00
overtrue 3ca0b5f35e fix(storage): retain volume locks through physical mutations 2026-10-18 03:26:41 +08:00
overtrue 00b5ddc0ad feat(storage): persist artifact receiver write fences 2026-10-18 03:01:03 +08:00
overtrue ad4c2bc9b1 docs(storage): describe explicit attempt repair sealing 2026-10-17 23:24:07 +08:00
overtrue ef5fe69d52 feat(admin): expose attempt repair sealing control 2026-10-17 23:23:52 +08:00
overtrue ba6f37e58d test(ecstore): verify durable attempt repair seals 2026-10-17 23:23:52 +08:00
overtrue 31eb2e5a4a feat(ecstore): persist attempt repair admission seals 2026-10-17 23:22:11 +08:00
overtrue 2f3654793d feat(admin): inspect managed artifact bucket identity 2026-10-17 18:42:22 +08:00
overtrue ce09e7817c test(iam): use valid broker policy in cross-bucket fixture 2026-10-17 18:00:39 +08:00
overtrue 31dc8a0fea test(storage): reload physical artifact faults from fresh stores 2026-10-17 17:49:25 +08:00
overtrue d243a4a29e test(storage): verify artifact creation observation boundaries 2026-10-17 17:35:48 +08:00
overtrue 1048374cbd feat(admin): create dedicated versioned artifact buckets 2026-10-17 17:30:38 +08:00
overtrue de32dece41 feat(iam): add explicit cross-bucket delegation v2 restrictions 2026-10-17 17:27:30 +08:00
overtrue f79f318ec4 test(admin): register artifact handler integration checks 2026-10-17 17:11:06 +08:00
overtrue 60fe68a04a test(admin): complete plugin delegation route inventory 2026-10-17 16:47:13 +08:00
overtrue 498be1fa53 test(admin): exercise artifact controls with real IAM and storage 2026-10-17 16:43:52 +08:00
overtrue b798d291d7 feat(admin): expose scoped artifact seal and retirement controls 2026-10-17 16:43:52 +08:00
overtrue 57c4d4c719 test(policy): distinguish attempt namespace from object scope 2026-10-17 16:36:19 +08:00
overtrue 560b083278 feat(policy): scope artifact control actions to IAM resources 2026-10-17 16:34:13 +08:00
overtrue 36afe8bb57 docs(storage): register and describe artifact retirement checks 2026-10-17 15:47:38 +08:00
overtrue 57963574b7 feat(ecstore): retire exact artifact versions before repair 2026-10-17 15:47:38 +08:00
overtrue c56a597374 test(ecstore): cover exact artifact version retirement 2026-10-17 15:46:46 +08:00
overtrue 5354e6dccf test(ecstore): establish repair ownership before inventory races 2026-10-17 15:30:48 +08:00
overtrue 7608c5af11 docs(storage): document the ecstore regression stack setting 2026-10-17 15:29:36 +08:00
overtrue c926e10de0 test(ecstore): create an authoritative multipart restore bucket 2026-10-17 15:25:11 +08:00
overtrue 972dafccde test(ecstore): expose owned fixtures through the set boundary 2026-10-17 15:21:59 +08:00
overtrue 5116bb2bbb test(ecstore): restore with an authoritative Object Lock snapshot 2026-10-17 15:19:56 +08:00
overtrue 08d6e4343d test(ecstore): import lifecycle fixture for shard identity cases 2026-10-17 15:19:56 +08:00
overtrue 7d3a6acc57 test(ecstore): resolve legacy identity in owned disk fixtures 2026-10-17 15:19:56 +08:00
overtrue aabb26b519 test(ecstore): retain lifecycle owners in raw write regressions 2026-10-17 15:19:56 +08:00
overtrue 163e6064da test(ecstore): align fixture visibility with its shared constructor 2026-10-17 15:01:40 +08:00
overtrue c7a9b76543 test(ecstore): preserve slice iteration in multipart checks 2026-10-17 14:58:42 +08:00
overtrue 7cc4ce5447 test(ecstore): preserve live fixture owners when retaining roots 2026-10-17 14:57:45 +08:00
overtrue 44fe49e5fa fix(ecstore): bound repair scope futures and clarify deadlines 2026-10-17 13:31:40 +08:00
overtrue dde2c50309 fix(s3): expose the opaque bucket lifecycle owner 2026-10-17 07:58:26 +08:00
overtrue d5a8f022bd test(ecstore): distinguish repair caller and disk deadlines 2026-10-17 05:58:01 +08:00
overtrue 150d349ade test(ecstore): exercise real metadata repair disk timeouts 2026-10-17 05:58:01 +08:00
overtrue 38056f3783 test(ecstore): acquire real owners in borrowed write fixtures 2026-10-17 05:58:01 +08:00
overtrue 0b40e35e76 test(ecstore): give raw disk fixtures a real metadata owner 2026-10-17 05:58:01 +08:00
overtrue 458b144ac7 fix(s3): retain validated lifecycle owners for copy writes 2026-10-16 23:40:01 +08:00
overtrue d58b80d54e test(ecstore): retain ordinary repair lifecycle through physical tail 2026-10-16 23:40:01 +08:00
overtrue f8d0174fa7 test(ecstore): register artifact repair coverage and document scope 2026-10-16 23:33:13 +08:00
overtrue a57cc0d44c fix(ecstore): carry bucket generation owners into raw PUT tails 2026-10-16 23:33:04 +08:00
overtrue 8006cb7eae test(ecstore): cover artifact repair admission and physical tails 2026-10-16 23:30:11 +08:00
overtrue 8d40a995a3 fix(ecstore): require authoritative targets for raw artifact PUTs 2026-10-16 23:30:11 +08:00
overtrue a451b53bcc feat(ecstore): retain repair owners through artifact mutation tails 2026-10-16 23:27:55 +08:00
overtrue a6127c8fde test(ecstore): require real lifecycle owners for managed writes 2026-10-16 10:12:26 +08:00
overtrue f346c13e5b fix(ecstore): validate lifecycle owners before PUT and Copy binding 2026-10-16 10:12:16 +08:00
overtrue 2634e829ca docs(ecstore): clarify lifecycle ownership for managed writes 2026-10-16 10:08:47 +08:00
overtrue dc15444256 fix(ecstore): require owned lifecycle fences for artifact admission 2026-10-16 10:08:20 +08:00
overtrue ebb19907a9 test(ecstore): remove unused artifact fixture import 2026-10-16 10:04:47 +08:00
overtrue d29babf251 docs(ecstore): define managed artifact write admission boundary 2026-10-16 10:04:01 +08:00
overtrue d8a9522919 test(ecstore): reject unsupported artifact write entrypoints 2026-10-16 10:03:35 +08:00
overtrue 60dc84bf0e feat(ecstore): reject unsupported artifact bucket write paths 2026-10-16 10:03:35 +08:00
overtrue 061e89ed2b test(ecstore): run seal corruption fixture on existing large stack 2026-10-16 10:00:35 +08:00
overtrue 3065142735 test(ecstore): isolate physical timeout runtime and topology fixtures 2026-10-16 09:55:43 +08:00
overtrue c6a8e30f5a fix(ecstore): route artifact validation through storage contracts 2026-10-16 09:55:04 +08:00
overtrue f51b52d7e9 test(ecstore): reject artifact PUT seals on distributed topology 2026-10-16 09:50:06 +08:00
overtrue c54160c22c fix(ecstore): restrict artifact PUT seals to local storage 2026-10-16 09:50:06 +08:00
overtrue 1f67b65ab8 docs(storage): bound artifact PUT sealing to local stores 2026-10-16 09:47:22 +08:00
overtrue a7806e65f0 fix(ecstore): adapt seal digest and multipart owner types 2026-10-16 09:43:45 +08:00
overtrue 6a3678997a test(ecstore): retain artifact seal lock through physical timeout 2026-10-16 09:43:37 +08:00
overtrue 64ea8b8e09 docs(storage): clarify failed PUT seal outcomes 2026-10-16 09:42:17 +08:00
overtrue 7773aac0fd fix(ecstore): bind seal bucket and preserve ordinary PUT allocation 2026-10-16 09:42:17 +08:00
overtrue 8763f87ca7 feat(ecstore): seal ordinary artifact PUT attempts durably 2026-10-16 09:40:51 +08:00
overtrue 9bbac59977 test(ecstore): assert attempt lock ownership through PUT tails 2026-10-16 09:36:06 +08:00
overtrue 3bd57517ff docs(storage): define artifact PUT sealing boundary 2026-10-16 09:35:15 +08:00
overtrue 8ee78c6891 test(ecstore): cover durable artifact PUT seal boundaries 2026-10-16 09:34:45 +08:00
overtrue 21c594e625 test(ecstore): reject artifact incarnation spoofing 2026-10-16 09:24:40 +08:00
overtrue 748095280a fix(ecstore): reject forged artifact bucket incarnations 2026-10-16 09:24:28 +08:00
overtrue 960195458f feat(ecstore): preserve artifact bucket creation identity 2026-10-16 09:20:17 +08:00
overtrue de58fdfc7d docs(storage): define dedicated artifact bucket invariants 2026-10-16 09:19:12 +08:00
overtrue 869d5ec24b test(ecstore): cover artifact bucket recovery and cache boundaries 2026-10-16 09:19:12 +08:00
overtrue 19902438a6 test(ecstore): cover immutable artifact bucket creation 2026-10-16 09:15:10 +08:00
overtrue bd244d13f5 fix(iam): ignore policy statement ordering during recheck 2026-10-15 18:08:51 +08:00
overtrue 38395d35ec fix(example): make plugin output writable 2026-10-15 10:56:32 +08:00
overtrue 68064b5ad9 feat(example): add Rust libvips image plugin template 2026-10-15 10:47:58 +08:00
overtrue c1da0c361b feat(admin): proxy plugin run state history 2026-10-15 08:58:15 +08:00
overtrue cb7626c1f1 feat(admin): replay plugin runs 2026-10-15 07:43:25 +08:00
overtrue 8186a7175c feat(admin): page plugin run history 2026-10-15 07:18:25 +08:00
overtrue c9e1446126 feat(admin): cancel plugin runs 2026-10-15 07:00:15 +08:00
overtrue 11d21ec6f4 feat(admin): query plugin run status 2026-10-15 06:43:32 +08:00
overtrue 974ab6f785 feat(admin): submit plugin jobs to runner 2026-10-15 05:48:19 +08:00
overtrue c3f4cdf27c refactor: remove native capture raft prototype 2026-10-14 07:02:17 +08:00
overtrue e82d7c107a fix(capture): check instance readiness for empty source pages 2026-10-14 06:15:20 +08:00
overtrue 9d2d6a8e46 fix(capture): gate source delivery on object publication 2026-10-14 05:15:33 +08:00
overtrue ff2cbc3c47 feat(capture-authority): mint exact committed Created authorization 2026-10-14 04:57:26 +08:00
overtrue d65ef3e29a fix(capture): respect startup storage facade boundaries 2026-10-14 04:56:57 +08:00
overtrue 0a2f58c7a9 feat(capture): wire native Raft authority into startup 2026-10-14 04:43:26 +08:00
overtrue 4d97501327 feat(ecstore): add authenticated native Raft TCP transport 2026-10-14 03:42:24 +08:00
overtrue 3b2af12c64 feat(capture-authority): add real native Raft owner lifecycle 2026-10-14 02:47:52 +08:00
overtrue c73ddde848 feat(capture-authority): add durable Raft state machine snapshots 2026-10-14 02:30:43 +08:00
overtrue ef72a37fd7 feat(capture): adapt Raft log storage and authorize prefix purge 2026-10-14 02:06:07 +08:00
overtrue e46cf750dd feat(capture): expose bounded local native source reader 2026-10-14 01:21:11 +08:00
overtrue fe955fa3ee feat(iam): issue plugin credentials from original-user delegations 2026-10-13 22:16:46 +08:00
overtrue 64578c067e feat(capture): persist local checkpoints and compact covered logs 2026-10-13 05:03:29 +08:00
overtrue 78715f0921 feat(ecstore): add durable native object preparation
Encode actual reader data into private local shards and recover matching write-quorum receipts. Preserve original metadata and namespace owners across retries and cancellation, without granting publication authority.

Validate with 13 focused tests and two rejected digest/quorum mutations. Document the preparation and recovery boundary.
2026-10-13 03:56:13 +08:00
overtrue 3284aafd73 feat(ecstore): persist private native preparation receipts 2026-10-13 01:02:05 +08:00
overtrue 9286442f87 feat(capture): add rebuildable committed event pagination 2026-10-12 08:31:29 +08:00
overtrue b8aad1c168 fix(capture): validate checkpoint cut against captured committed log 2026-10-11 23:48:08 +08:00
overtrue 5fcb229760 feat(ecstore): observe bounded config under retained write owner 2026-10-11 22:59:16 +08:00
overtrue 94baef7462 feat(capture): add explicit schema-bound checkpoint capabilities 2026-10-11 21:30:55 +08:00
overtrue 60d90f085a feat(ecstore): add bounded persisted config observations 2026-10-11 20:29:27 +08:00
overtrue dfa5d50565 feat(capture): persist first-decision provenance 2026-10-11 18:58:12 +08:00
overtrue a902886737 feat(capture): atomically migrate legacy Created history 2026-10-11 16:39:10 +08:00
overtrue 8e53b87421 test(capture): specify atomic populated native migration 2026-10-11 16:39:10 +08:00
overtrue f7631e91b1 feat(capture): stream an owned coherent snapshot view 2026-10-11 16:30:12 +08:00
overtrue f9bc4e921f test(capture): specify coherent snapshot view ownership 2026-10-11 16:30:12 +08:00
overtrue 781584307a feat(capture): apply committed native entries on the ordered worker 2026-10-11 15:35:02 +08:00
overtrue 59d641a5fe docs(capture): define recoverable checkpoint and purge contracts 2026-10-11 15:00:38 +08:00
overtrue c618b57870 feat(capture): stream snapshot rows with a permanent failure fence 2026-10-11 14:44:05 +08:00
overtrue bbe1d54098 feat(capture): add bounded streaming snapshot codec 2026-10-11 14:21:30 +08:00
overtrue 9e8ea4dcdb feat(capture-authority): serialize native async writes 2026-10-11 13:35:15 +08:00
overtrue 63cf8e2f2f feat(capture-authority): add native OpenRaft log reader 2026-10-11 13:01:43 +08:00
overtrue b7fe40f4b3 feat(capture): support complete native log range bounds 2026-10-11 12:28:23 +08:00
overtrue 5b872b8e56 feat(capture): apply native entries atomically with durable replay 2026-10-11 11:50:32 +08:00
overtrue ca5be1ef95 feat(capture): persist native Raft entries in explicitly enabled stores 2026-10-11 05:49:17 +08:00
overtrue 9373e8fa3e Bind capture commands and responses to native Raft entries 2026-10-11 03:45:17 +08:00
overtrue cb32965522 feat(capture): preserve exact raft values and membership codec 2026-10-11 02:04:47 +08:00
overtrue fb6cf49a67 feat(capture): validate peer metadata and CNOD codec 2026-10-11 01:25:21 +08:00
overtrue 8c97049e68 build(capture): pin OpenRaft and guard resolved features 2026-10-11 00:56:30 +08:00
overtrue 17d4fe27cc feat(capture): apply committed Created decisions atomically 2026-10-10 23:35:52 +08:00
overtrue fb0c7972b2 feat(capture): persist committed log boundary 2026-10-10 23:10:28 +08:00
overtrue eba7967755 feat(capture): persist atomic log suffix truncation 2026-10-10 22:59:26 +08:00
overtrue 451c1c31ad feat(capture): add durable local authority storage 2026-10-10 22:33:44 +08:00
overtrue 8780dd670e test(ecstore): verify publication across process kills 2026-10-10 20:19:46 +08:00
overtrue f29bf2c902 fix(iam): preserve empty identity read failures 2026-10-10 11:37:07 +08:00
overtrue fd03436090 feat(ecstore): expose unbounded preserve-empty config reads 2026-10-10 11:10:27 +08:00
overtrue 1f1e7b1c97 fix(iam): preserve identity decryption errors 2026-10-10 10:37:15 +08:00
overtrue 45a952609e test(iam): add real identity storage fixtures 2026-10-10 10:01:03 +08:00
overtrue 323190a95a test(test-utils): support caller-owned instance contexts 2026-10-10 09:43:01 +08:00
overtrue 38cdda9292 fix(ecstore): expose config reader without private bounds 2026-10-10 09:11:47 +08:00
overtrue 85c84fa73d feat(policy): add dormant principal incarnation records 2026-10-10 08:12:14 +08:00
overtrue 51402087c5 feat(ecstore): expose bounded config snapshot reader 2026-10-10 08:03:27 +08:00
overtrue a994984fe2 build(policy): prepare identity incarnation UUID dependency 2026-10-10 06:57:51 +08:00
overtrue 48eb0e402c test(iam): use identity constructor in existing fixtures 2026-10-10 06:52:04 +08:00
500 changed files with 132472 additions and 17493 deletions
@@ -36,6 +36,21 @@ changed lesson.
## Finding Standard
Each finding includes severity, `file:line`, attacker prerequisites, concrete
input/path, impact, smallest safe fix, and a regression check. Do not exaggerate
unauthenticated impact when the actual issue requires authenticated low privilege.
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.
+2 -2
View File
@@ -1,2 +1,2 @@
sha256-linux=233bd7a68777eda1f044b6cb77128056be2b6492583c0dfd114b21e98a6102ef
sha256-darwin=233bd7a68777eda1f044b6cb77128056be2b6492583c0dfd114b21e98a6102ef
sha256-linux=74a1d6975e2d6dfe01363585cb9bfa3232d79556a154d4f599653fe2ed1ce696
sha256-darwin=74a1d6975e2d6dfe01363585cb9bfa3232d79556a154d4f599653fe2ed1ce696
+2 -2
View File
@@ -1,2 +1,2 @@
sha256-darwin=3a7f52dd884ed269746029e983006ce69b62f260db2ad1acdcffdb2196c65d11
sha256-linux=4af4bb607919b96e63e2a3fb7a78bd18fcb08e265f4ca4ce6afb09a8be798305
sha256-darwin=b53fe6d46cdffedfad6a83da0dfffa9aec10cd7c8ace53469f129587bc9f4d50
sha256-linux=6be90f5c3886e9852ac27d51f2d47e6d2fa8bb424e0d03ee931268dcd0020db8
+1 -1
View File
@@ -1 +1 @@
sha256=ce16185f87c6cd30aa0891ebd22be3352aedc10040d48cc5b00652d72f7be3b5
sha256=002ec5d973f82288e50a096a2c8b3b38749f62a7a3e169775566874d8ee98b4a
+36 -2
View File
@@ -83,12 +83,29 @@ threads-required = "num-test-threads"
filter = '(package(rustfs-ecstore) & test(=core::pools_test::decommission_lock_order_tests::active_decommission_migration_does_not_invert_capacity_and_delete_object_tag_locks)) | (package(rustfs-heal) & test(=heal::mrf_queue::partial_write::tests::durable_retry_submission_error_keeps_checkpoint_and_uses_backoff))'
threads-required = "num-test-threads"
# These eight-disk decommission fixtures exercise concurrent retries,
# free-version cleanup, and real capacity probes under production lock deadlines.
# Reserve the run's capacity so unrelated IO cannot exhaust those budgets.
[[profile.default.overrides]]
filter = 'package(rustfs-ecstore) & (test(/^store::init::tests::decommission_entry_(retries_source_changed_without_canceling_other_bucket|skips_cleanup_only_marker_when_free_version_is_present)$/) | test(=core::pools::tests::decommission_capacity_mutation_limits_prewrite_sampling_to_target))'
threads-required = "num-test-threads"
# This registration/backup lock probe uses an isolated eight-disk store.
# Reserve capacity so sibling disk-heavy tests cannot consume its 2s
# registration budget; retain the queued backup and deadlock assertions.
[[profile.default.overrides]]
filter = 'package(rustfs) & test(=admin::handlers::table_catalog::tests::cold_strong_registration_completes_while_other_bucket_backup_waits)'
threads-required = "num-test-threads"
[[profile.default.overrides]]
filter = 'package(rustfs-heal) & (binary(mrf_partial_write_test) | (binary(mrf_pipeline_test) & test(/^journal_replay_survives_/)) | test(=admin_selector_survives_sigkill_with_original_scope_and_receipts))'
threads-required = "num-test-threads"
# Native scanner backlog fixtures include 24-set and pool-expansion cases.
# Reserve capacity across test processes while preserving each scenario's fixed
# budget, assertions, and internal concurrency.
[[profile.default.overrides]]
filter = 'package(rustfs-scanner) & (test(/^scanner::backlog::tests::native_(writer|retirement)_/) | test(=scanner_folder::tests::mrf_ownership::mrf_ownership_manager_completion_preserves_scanner_pending) | test(/^scanner_io::tests::service_cohort::/))'
filter = 'package(rustfs-scanner) & (test(/^scanner::backlog::tests::native_/) | test(=scanner_folder::tests::mrf_ownership::mrf_ownership_manager_completion_preserves_scanner_pending) | test(/^scanner_io::tests::service_cohort::/))'
threads-required = "num-test-threads"
[[profile.default.scripts]]
@@ -349,12 +366,29 @@ threads-required = "num-test-threads"
filter = '(package(rustfs-ecstore) & test(=core::pools_test::decommission_lock_order_tests::active_decommission_migration_does_not_invert_capacity_and_delete_object_tag_locks)) | (package(rustfs-heal) & test(=heal::mrf_queue::partial_write::tests::durable_retry_submission_error_keeps_checkpoint_and_uses_backoff))'
threads-required = "num-test-threads"
# These eight-disk decommission fixtures exercise concurrent retries,
# free-version cleanup, and real capacity probes under production lock deadlines.
# Reserve the run's capacity so unrelated IO cannot exhaust those budgets.
[[profile.ci.overrides]]
filter = 'package(rustfs-ecstore) & (test(/^store::init::tests::decommission_entry_(retries_source_changed_without_canceling_other_bucket|skips_cleanup_only_marker_when_free_version_is_present)$/) | test(=core::pools::tests::decommission_capacity_mutation_limits_prewrite_sampling_to_target))'
threads-required = "num-test-threads"
# This registration/backup lock probe uses an isolated eight-disk store.
# Reserve capacity so sibling disk-heavy tests cannot consume its 2s
# registration budget; retain the queued backup and deadlock assertions.
[[profile.ci.overrides]]
filter = 'package(rustfs) & test(=admin::handlers::table_catalog::tests::cold_strong_registration_completes_while_other_bucket_backup_waits)'
threads-required = "num-test-threads"
[[profile.ci.overrides]]
filter = 'package(rustfs-heal) & (binary(mrf_partial_write_test) | (binary(mrf_pipeline_test) & test(/^journal_replay_survives_/)) | test(=admin_selector_survives_sigkill_with_original_scope_and_receipts))'
threads-required = "num-test-threads"
# Native scanner backlog fixtures include 24-set and pool-expansion cases.
# Reserve capacity across test processes while preserving each scenario's fixed
# budget, assertions, and internal concurrency.
[[profile.ci.overrides]]
filter = 'package(rustfs-scanner) & (test(/^scanner::backlog::tests::native_(writer|retirement)_/) | test(=scanner_folder::tests::mrf_ownership::mrf_ownership_manager_completion_preserves_scanner_pending) | test(/^scanner_io::tests::service_cohort::/))'
filter = 'package(rustfs-scanner) & (test(/^scanner::backlog::tests::native_/) | test(=scanner_folder::tests::mrf_ownership::mrf_ownership_manager_completion_preserves_scanner_pending) | test(/^scanner_io::tests::service_cohort::/))'
threads-required = "num-test-threads"
[[profile.ci.scripts]]
+3 -3
View File
@@ -153,7 +153,7 @@ jobs:
- name: Build rustfs binary
run: |
python3 scripts/e2e_binary.py build --bins
python3 scripts/e2e_binary.py build --bins --features e2e-test-hooks
- name: Verify distributed e2e membership
env:
@@ -170,9 +170,9 @@ jobs:
set -euo pipefail
rm -f target/nextest/e2e-distributed/junit.xml
if [ -n "${FILTER}" ]; then
python3 scripts/e2e_binary.py run -- cargo nextest run --profile e2e-distributed -p e2e_test -E "${FILTER}"
python3 scripts/e2e_binary.py run --features e2e-test-hooks -- cargo nextest run --profile e2e-distributed -p e2e_test -E "${FILTER}"
else
python3 scripts/e2e_binary.py run -- cargo nextest run --profile e2e-distributed -p e2e_test --no-tests=fail
python3 scripts/e2e_binary.py run --features e2e-test-hooks -- cargo nextest run --profile e2e-distributed -p e2e_test --no-tests=fail
fi
test -s target/nextest/e2e-distributed/junit.xml
+2
View File
@@ -25,6 +25,7 @@ on:
- "crates/e2e_test/src/upgrade_compatibility_test.rs"
- "crates/ecstore/**"
- "crates/filemeta/**"
- "crates/heal/**"
- "crates/kms/**"
- "crates/storage-api/**"
- "rustfs/**"
@@ -160,6 +161,7 @@ jobs:
)
if [[ "$UPGRADE_SOURCE_VERSION" == "1.0.0-rc.5" ]]; then
cases+=(odm-config-rollback:rc5_rollback_requires_restoring_odm_configuration)
cases+=(checkpoint-upgrade:rc5_checkpoint_upgrade_rewinds_signed_progress_without_losing_objects)
else
cases+=(legacy-delete-reservation:stable_upgrade_deletes_foreign_reservations_without_losing_live_objects)
fi
+5 -436
View File
@@ -20,65 +20,13 @@ on:
workflow_dispatch:
inputs:
build_run_id:
description: Successful nightly or validation package run; empty for package-only build
description: Successful nightly build run on main
type: string
required: false
default: ''
required: true
build_run_attempt:
description: Exact successful build attempt
type: string
required: false
default: ''
validation_package_sha:
description: Exact source SHA for an isolated package-only build
type: string
required: false
default: ''
validation_case:
description: Canonical case ID, environment.inspect for read-only resources, or empty for functional chain
type: string
required: false
default: ''
auto_testing_sha:
description: Exact auto-testing commit for a validation case
type: string
required: false
default: ''
validation_tools_only:
description: Build candidate-bound validation tools without running a fleet case
type: boolean
required: false
default: false
validation_native_tools:
description: Include exact-source native regressions in a tools-only build
type: boolean
required: false
default: false
validation_shared_tools:
description: Include the controlled C10 shared-reference encoder in a tools-only build
type: boolean
required: false
default: false
validation_manager_tools:
description: Include exact checkpoint/MRF/incarnation regressions in a tools-only build
type: boolean
required: false
default: false
validation_cleanup_run_id:
description: Failed validation run whose supported retained environment needs evidence-based finalization
type: string
required: false
default: ''
validation_tools_run_id:
description: Successful tool build run; required by Heal physical metadata checks
type: string
required: false
default: ''
validation_tools_run_attempt:
description: Exact tool build attempt
type: string
required: false
default: '1'
required: true
workflow_run:
workflows: ["Nightly GNU Build"]
types: [completed]
@@ -88,47 +36,12 @@ permissions:
actions: read
concurrency:
group: ${{ github.event_name == 'workflow_dispatch' && inputs.validation_package_sha != '' && format('rustfs-functional-validation-package-{0}', inputs.validation_package_sha) || github.event_name == 'workflow_dispatch' && inputs.validation_tools_only && 'rustfs-functional-validation-tools' || 'rustfs-functional-chain-runs' }}
group: rustfs-functional-chain-runs
cancel-in-progress: false
jobs:
validation-package-request:
if: ${{ github.event_name == 'workflow_dispatch' && inputs.validation_package_sha != '' }}
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Validate exclusive package-only request
env:
PACKAGE_INPUTS: ${{ toJSON(inputs) }}
run: |
python3 - <<'PY'
import json
import os
import re
value = json.loads(os.environ["PACKAGE_INPUTS"])
if re.fullmatch(r"[0-9a-f]{40}", value["validation_package_sha"]) is None:
raise ValueError("package build requires an exact source SHA")
exclusive = ("build_run_id", "build_run_attempt", "validation_case", "auto_testing_sha",
"validation_tools_only", "validation_native_tools", "validation_shared_tools",
"validation_manager_tools", "validation_cleanup_run_id", "validation_tools_run_id")
if any(value.get(name) for name in exclusive):
raise ValueError("package-only build cannot select existing candidate, tools, fleet or recovery execution")
PY
validation-package:
needs: validation-package-request
if: ${{ github.event_name == 'workflow_dispatch' && inputs.validation_package_sha != '' }}
uses: ./.github/workflows/rustfs-validation-package.yml
with:
source_sha: ${{ inputs.validation_package_sha }}
secrets:
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
R2_BUCKET: ${{ secrets.R2_BUCKET }}
prepare:
if: ${{ (github.event_name == 'workflow_dispatch' && inputs.validation_package_sha == '' && inputs.validation_case == '' && !inputs.validation_tools_only && !inputs.validation_native_tools && !inputs.validation_shared_tools && !inputs.validation_manager_tools && inputs.validation_cleanup_run_id == '') || github.event.workflow_run.event == 'schedule' }}
if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.event == 'schedule' }}
runs-on: smoke-testing
timeout-minutes: 10
outputs:
@@ -176,350 +89,6 @@ jobs:
echo "performance_ready=offline" >> "$GITHUB_OUTPUT"
fi
validation:
if: ${{ github.event_name == 'workflow_dispatch' && inputs.validation_package_sha == '' && (inputs.validation_case != '' || inputs.validation_tools_only || inputs.validation_native_tools || inputs.validation_shared_tools || inputs.validation_manager_tools || inputs.validation_cleanup_run_id != '') }}
runs-on: ${{ inputs.validation_tools_only && 'ubuntu-latest' || 'smoke-testing' }}
timeout-minutes: 90
outputs:
source_sha: ${{ steps.candidate.outputs.source_sha }}
concurrency:
group: ${{ inputs.validation_tools_only && 'rustfs-validation-tools-resolver' || 'rustfs-shared-functional-tests-v2' }}
cancel-in-progress: false
env:
RUSTFS_ACCESS_KEY: ${{ !inputs.validation_tools_only && secrets.RUSTFS_ACCESS_KEY || '' }}
RUSTFS_SECRET_KEY: ${{ !inputs.validation_tools_only && secrets.RUSTFS_SECRET_KEY || '' }}
RUSTFS_API_ENDPOINT: ${{ !inputs.validation_tools_only && (secrets.RUSTFS_API_ENDPOINT || vars.RUSTFS_API_ENDPOINT || vars.RUSTFS_RC_ENDPOINT) || '' }}
RUSTFS_NODES: ${{ !inputs.validation_tools_only && (secrets.RUSTFS_NODES || vars.RUSTFS_NODES) || '' }}
RUSTFS_SSH_USER: ${{ !inputs.validation_tools_only && (secrets.RUSTFS_SSH_USER || vars.RUSTFS_SSH_USER) || '' }}
steps:
- name: Checkout candidate resolver
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
- name: Initialize validation evidence
run: |
set -euo pipefail
umask 077
VALIDATION_ROOT="${RUNNER_TEMP}/rustfs-validation-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
mkdir -- "${VALIDATION_ROOT}"
printf 'VALIDATION_ROOT=%s\n' "${VALIDATION_ROOT}" >> "${GITHUB_ENV}"
- name: Resolve immutable candidate
id: candidate
env:
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
BUILD_RUN_ID: ${{ inputs.build_run_id }}
BUILD_RUN_ATTEMPT: ${{ inputs.build_run_attempt }}
AUTO_TESTING_SHA: ${{ inputs.auto_testing_sha }}
CLEANUP_RUN_ID: ${{ inputs.validation_cleanup_run_id }}
SELECTED_CASE: ${{ inputs.validation_case }}
TOOLS_ONLY: ${{ inputs.validation_tools_only }}
NATIVE_TOOLS: ${{ inputs.validation_native_tools }}
SHARED_TOOLS: ${{ inputs.validation_shared_tools }}
MANAGER_TOOLS: ${{ inputs.validation_manager_tools }}
run: |
set -euo pipefail
python3 - <<'PY'
import json
import os
from pathlib import Path
import re
from scripts.resolve_functional_candidate import resolve
revision = os.environ["AUTO_TESTING_SHA"]
cleanup = os.environ["CLEANUP_RUN_ID"]
if os.environ["TOOLS_ONLY"] == "true" and (cleanup or os.environ["SELECTED_CASE"]):
raise ValueError("tools-only execution cannot select a fleet case or cleanup")
if os.environ["NATIVE_TOOLS"] == "true" and (os.environ["TOOLS_ONLY"] != "true" or cleanup or os.environ["SELECTED_CASE"]):
raise ValueError("native regressions require tools-only execution, without a fleet case or cleanup")
if os.environ["SHARED_TOOLS"] == "true" and (os.environ["TOOLS_ONLY"] != "true" or cleanup or os.environ["SELECTED_CASE"]):
raise ValueError("shared fixture builds require tools-only execution, without a fleet case or cleanup")
if os.environ["MANAGER_TOOLS"] == "true" and (os.environ["TOOLS_ONLY"] != "true" or cleanup or os.environ["SELECTED_CASE"]):
raise ValueError("manager regressions require tools-only execution, without a fleet case or cleanup")
if cleanup and (not cleanup.isdigit() or int(cleanup) <= 0 or os.environ["SELECTED_CASE"] or os.environ["TOOLS_ONLY"] == "true"):
raise ValueError("cleanup requires one exact failed run and is exclusive with case/tool execution")
if re.fullmatch(r"[0-9a-f]{40}", revision) is None:
raise ValueError("validation requires an exact auto-testing commit SHA")
candidate = resolve(int(os.environ["BUILD_RUN_ID"]), int(os.environ["BUILD_RUN_ATTEMPT"]), allow_validation=True)
manifest = candidate["manifest"]
root = Path(os.environ["VALIDATION_ROOT"])
(root / "candidate-source.json").write_text(json.dumps(candidate, sort_keys=True) + "\n")
(root / "candidate.json").write_text(json.dumps({
"package_url": manifest["package_url"],
"package_sha256": manifest["package_sha256"],
"source_sha": manifest["source_sha"],
}, sort_keys=True) + "\n")
with open(os.environ["GITHUB_OUTPUT"], "a") as output:
output.write("source_sha=" + manifest["source_sha"] + "\n")
PY
- name: Checkout exact auto-testing revision
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
repository: rustfs/auto-testing
ref: ${{ inputs.auto_testing_sha }}
token: ${{ secrets.PF_TESTING_GH_TOKEN }}
path: auto-testing
persist-credentials: false
- name: Install validation Python client
if: ${{ !inputs.validation_tools_only && (startsWith(inputs.validation_case, 'ilm.') || startsWith(inputs.validation_case, 'pool.') || startsWith(inputs.validation_case, 'bucket-replication.') || startsWith(inputs.validation_case, 'site-replication.') || startsWith(inputs.validation_case, 'heal.')) }}
run: |
set -euo pipefail
python3 -m venv "${RUNNER_TEMP}/rustfs-validation-python"
"${RUNNER_TEMP}/rustfs-validation-python/bin/python" -m pip install \
-r auto-testing/scripts/validation_ilm/requirements.txt
printf '%s\n' "${RUNNER_TEMP}/rustfs-validation-python/bin" >> "${GITHUB_PATH}"
- name: Validate Heal tool build identity
if: ${{ !inputs.validation_tools_only && startsWith(inputs.validation_case, 'heal.') }}
env:
GH_TOKEN: ${{ github.token }}
TOOL_RUN_ID: ${{ inputs.validation_tools_run_id }}
TOOL_RUN_ATTEMPT: ${{ inputs.validation_tools_run_attempt }}
run: |
set -euo pipefail
[[ "${TOOL_RUN_ID}" =~ ^[1-9][0-9]*$ ]]
[[ "${TOOL_RUN_ATTEMPT}" =~ ^[1-9][0-9]*$ ]]
gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${TOOL_RUN_ID}/attempts/${TOOL_RUN_ATTEMPT}" \
> "${VALIDATION_ROOT}/tool-run.json"
python3 - <<'PY'
import json
import os
from pathlib import Path
value = json.loads((Path(os.environ["VALIDATION_ROOT"]) / "tool-run.json").read_text())
if (value.get("conclusion") != "success" or value.get("status") != "completed"
or value.get("run_attempt") != int(os.environ["TOOL_RUN_ATTEMPT"])
or value.get("path") not in {".github/workflows/rustfs-functional-chain.yml", ".github/workflows/rustfs-validation-tools.yml"}):
raise ValueError("Heal tool run is not an exact successful validation build attempt")
PY
- name: Download pinned Heal metadata inspector
if: ${{ !inputs.validation_tools_only && startsWith(inputs.validation_case, 'heal.') }}
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
github-token: ${{ github.token }}
run-id: ${{ inputs.validation_tools_run_id }}
name: rustfs-validation-tools-${{ inputs.validation_tools_run_id }}-${{ inputs.validation_tools_run_attempt }}
path: ${{ runner.temp }}/rustfs-validation-tools-${{ github.run_id }}-${{ github.run_attempt }}
- name: Verify Heal metadata inspector
if: ${{ !inputs.validation_tools_only && startsWith(inputs.validation_case, 'heal.') }}
run: |
set -euo pipefail
TOOL_DIRECTORY="${RUNNER_TEMP}/rustfs-validation-tools-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
python3 -B auto-testing/scripts/validation_heal/inspector.py \
--directory "${TOOL_DIRECTORY}" --candidate "${VALIDATION_ROOT}/candidate.json" \
> "${VALIDATION_ROOT}/tool-manifest.json"
printf 'VALIDATION_HEAL_TOOL_DIRECTORY=%s\n' "${TOOL_DIRECTORY}" >> "${GITHUB_ENV}"
- name: Select exact-source race native evidence
id: race_native
if: ${{ inputs.validation_case == 'heal.race-unversioned-overwrite' || inputs.validation_case == 'heal.race-versioned-put' || inputs.validation_case == 'heal.race-version-delete' || inputs.validation_case == 'heal.race-markers-and-batch-delete' }}
env:
CANDIDATE_SHA: ${{ steps.candidate.outputs.source_sha }}
VALIDATION_CASE_ID: ${{ inputs.validation_case }}
run: |
set -euo pipefail
required=false
if [ -f auto-testing/scripts/validation_heal/race_native.py ]; then
status=0
python3 -B auto-testing/scripts/validation_heal/race_native.py supports \
--commit "${CANDIDATE_SHA}" --case-id "${VALIDATION_CASE_ID}" || status=$?
if [ "${status}" -eq 0 ]; then
required=true
elif [ "${status}" -ne 2 ]; then
exit "${status}"
fi
fi
reviewed=false
case "${CANDIDATE_SHA}:${VALIDATION_CASE_ID}" in
6afdec9fe1b0b88007f2e5b445c19bf73d51d3ca:heal.race-unversioned-overwrite | \
bd0671ba781d8ebf509f4f03533228dfb44496f1:heal.race-unversioned-overwrite | \
bd0671ba781d8ebf509f4f03533228dfb44496f1:heal.race-versioned-put | \
bd0671ba781d8ebf509f4f03533228dfb44496f1:heal.race-version-delete | \
8380466f3a27c3c3ef4115084178a0ab99a78f32:heal.race-unversioned-overwrite | \
8380466f3a27c3c3ef4115084178a0ab99a78f32:heal.race-versioned-put | \
8380466f3a27c3c3ef4115084178a0ab99a78f32:heal.race-version-delete | \
8380466f3a27c3c3ef4115084178a0ab99a78f32:heal.race-markers-and-batch-delete) reviewed=true ;;
esac
if [ "${reviewed}" = true ] && [ "${required}" != true ]; then
printf 'Race candidate requires its reviewed native collector before fleet execution\n' >&2
exit 1
fi
printf 'required=%s\n' "${required}" >> "${GITHUB_OUTPUT}"
- name: Download pinned native storage evidence
if: ${{ inputs.validation_case == 'heal.conflicting-subquorum-authority' || inputs.validation_case == 'heal.bucket-incarnation-recreation' || steps.race_native.outputs.required == 'true' }}
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
github-token: ${{ github.token }}
run-id: ${{ inputs.validation_tools_run_id }}
name: rustfs-validation-native-${{ inputs.validation_tools_run_id }}-${{ inputs.validation_tools_run_attempt }}
path: ${{ runner.temp }}/rustfs-validation-native-${{ github.run_id }}-${{ github.run_attempt }}
- name: Verify C07 native source and exact test evidence
if: ${{ inputs.validation_case == 'heal.conflicting-subquorum-authority' }}
run: |
set -euo pipefail
NATIVE_DIRECTORY="${RUNNER_TEMP}/rustfs-validation-native-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
cp "${VALIDATION_ROOT}/tool-run.json" "${NATIVE_DIRECTORY}/tool-run.json"
python3 -B auto-testing/scripts/validation_heal/authority.py \
--directory "${NATIVE_DIRECTORY}" --candidate "${VALIDATION_ROOT}/candidate.json" \
> "${VALIDATION_ROOT}/native-authority-manifest.json"
printf 'VALIDATION_HEAL_NATIVE_DIRECTORY=%s\n' "${NATIVE_DIRECTORY}" >> "${GITHUB_ENV}"
- name: Verify C11 physical storage and exact final-rename barrier
if: ${{ inputs.validation_case == 'heal.bucket-incarnation-recreation' }}
run: |
set -euo pipefail
NATIVE_DIRECTORY="${RUNNER_TEMP}/rustfs-validation-native-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
cp "${VALIDATION_ROOT}/tool-run.json" "${NATIVE_DIRECTORY}/tool-run.json"
python3 -B auto-testing/scripts/validation_heal/incarnation_native.py \
--directory "${NATIVE_DIRECTORY}" --candidate "${VALIDATION_ROOT}/candidate.json" \
--testing-commit "${{ inputs.auto_testing_sha }}" \
> "${VALIDATION_ROOT}/native-incarnation-manifest.json"
printf 'VALIDATION_HEAL_NATIVE_DIRECTORY=%s\n' "${NATIVE_DIRECTORY}" >> "${GITHUB_ENV}"
- name: Verify exact-source race native and runtime evidence
if: ${{ steps.race_native.outputs.required == 'true' }}
env:
AUTO_TESTING_SHA: ${{ inputs.auto_testing_sha }}
VALIDATION_CASE_ID: ${{ inputs.validation_case }}
run: |
set -euo pipefail
NATIVE_DIRECTORY="${RUNNER_TEMP}/rustfs-validation-native-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
cp "${VALIDATION_ROOT}/tool-run.json" "${NATIVE_DIRECTORY}/tool-run.json"
python3 -B auto-testing/scripts/validation_heal/race_native_evidence.py \
--directory "${NATIVE_DIRECTORY}" --candidate "${VALIDATION_ROOT}/candidate.json" \
--testing-commit "${AUTO_TESTING_SHA}" --case-id "${VALIDATION_CASE_ID}" \
> "${VALIDATION_ROOT}/native-race-manifest.json"
VALIDATION_HEAL_NATIVE_DIRECTORY="${NATIVE_DIRECTORY}" \
python3 -B auto-testing/scripts/validation_heal/race.py preflight \
--candidate "${VALIDATION_ROOT}/candidate.json" --testing-commit "${AUTO_TESTING_SHA}" \
--case-id "${VALIDATION_CASE_ID}" \
> "${VALIDATION_ROOT}/native-race-runtime-preflight.json"
printf 'VALIDATION_HEAL_NATIVE_DIRECTORY=%s\n' "${NATIVE_DIRECTORY}" >> "${GITHUB_ENV}"
- name: Download pinned manager persistence and incarnation evidence
if: ${{ inputs.validation_case == 'heal.checkpoint-and-mrf-corruption' || inputs.validation_case == 'heal.bucket-incarnation-recreation' || inputs.validation_case == 'heal.outcome-counters-and-error-truth' }}
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
github-token: ${{ github.token }}
run-id: ${{ inputs.validation_tools_run_id }}
name: rustfs-validation-manager-native-${{ inputs.validation_tools_run_id }}-${{ inputs.validation_tools_run_attempt }}
path: ${{ runner.temp }}/rustfs-validation-manager-native-${{ github.run_id }}-${{ github.run_attempt }}
- name: Verify exact manager native prerequisites before fleet mutation
if: ${{ inputs.validation_case == 'heal.checkpoint-and-mrf-corruption' || inputs.validation_case == 'heal.bucket-incarnation-recreation' || inputs.validation_case == 'heal.outcome-counters-and-error-truth' }}
env:
VALIDATION_CASE: ${{ inputs.validation_case }}
run: |
set -euo pipefail
MANAGER_DIRECTORY="${RUNNER_TEMP}/rustfs-validation-manager-native-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
cp "${VALIDATION_ROOT}/tool-run.json" "${MANAGER_DIRECTORY}/tool-run.json"
python3 -B auto-testing/scripts/validation_heal/manager_fixture.py \
--directory "${MANAGER_DIRECTORY}" --candidate "${VALIDATION_ROOT}/candidate.json" \
--case-id "${VALIDATION_CASE}" > "${VALIDATION_ROOT}/manager-prerequisite-manifest.json"
printf 'VALIDATION_HEAL_MANAGER_DIRECTORY=%s\n' "${MANAGER_DIRECTORY}" >> "${GITHUB_ENV}"
- name: Download pinned C10 shared-reference encoder
if: ${{ inputs.validation_case == 'heal.dangling-and-orphan-safety' }}
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
github-token: ${{ github.token }}
run-id: ${{ inputs.validation_tools_run_id }}
name: rustfs-validation-shared-${{ inputs.validation_tools_run_id }}-${{ inputs.validation_tools_run_attempt }}
path: ${{ runner.temp }}/rustfs-validation-shared-${{ github.run_id }}-${{ github.run_attempt }}
- name: Verify C10 exact-source codec and original tests
if: ${{ inputs.validation_case == 'heal.dangling-and-orphan-safety' }}
run: |
set -euo pipefail
SHARED_DIRECTORY="${RUNNER_TEMP}/rustfs-validation-shared-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
cp "${VALIDATION_ROOT}/tool-run.json" "${SHARED_DIRECTORY}/tool-run.json"
python3 -B auto-testing/scripts/validation_heal/shared_fixture.py \
--directory "${SHARED_DIRECTORY}" --candidate "${VALIDATION_ROOT}/candidate.json" \
> "${VALIDATION_ROOT}/shared-fixture-manifest.json"
printf 'VALIDATION_HEAL_SHARED_DIRECTORY=%s\n' "${SHARED_DIRECTORY}" >> "${GITHUB_ENV}"
- name: Run selected canonical validation case
if: ${{ !inputs.validation_tools_only && inputs.validation_cleanup_run_id == '' && inputs.validation_case != 'environment.inspect' }}
env:
VALIDATION_CASE: ${{ inputs.validation_case }}
AUTO_TESTING_SHA: ${{ inputs.auto_testing_sha }}
run: |
set -euo pipefail
test "$(git -C auto-testing rev-parse HEAD)" = "${AUTO_TESTING_SHA}"
./auto-testing/scripts/run_validation_case.sh \
"${VALIDATION_CASE}" "${VALIDATION_ROOT}/candidate.json" \
"${AUTO_TESTING_SHA}" "${VALIDATION_ROOT}/run"
- name: Inspect current Heal fleet resources without mutation
if: ${{ !inputs.validation_tools_only && inputs.validation_case == 'environment.inspect' }}
env:
AUTO_TESTING_SHA: ${{ inputs.auto_testing_sha }}
run: |
set -euo pipefail
test "$(git -C auto-testing rev-parse HEAD)" = "${AUTO_TESTING_SHA}"
python3 -B auto-testing/scripts/validation_heal/environment.py \
--candidate "${VALIDATION_ROOT}/candidate.json" \
--testing-commit "${AUTO_TESTING_SHA}" --evidence "${VALIDATION_ROOT}/environment"
- name: Audit and finalize retained validation environment
if: ${{ inputs.validation_cleanup_run_id != '' }}
env:
GH_TOKEN: ${{ github.token }}
FAILED_RUN_ID: ${{ inputs.validation_cleanup_run_id }}
AUTO_TESTING_SHA: ${{ inputs.auto_testing_sha }}
run: |
set -euo pipefail
test "$(git -C auto-testing rev-parse HEAD)" = "${AUTO_TESTING_SHA}"
gh run view "${FAILED_RUN_ID}" --repo "${GITHUB_REPOSITORY}" \
--json status,conclusion,headBranch,workflowName > "${VALIDATION_ROOT}/failed-run.json"
python3 - <<'PY'
import json
import os
from pathlib import Path
run = json.loads((Path(os.environ["VALIDATION_ROOT"]) / "failed-run.json").read_text())
if (run["status"] != "completed" or run["conclusion"] not in {"failure", "cancelled"}
or run["workflowName"] != "RustFS Functional Chain" or run["headBranch"] != os.environ["GITHUB_REF_NAME"]):
raise ValueError("cleanup source must be a completed failed or cancelled Functional Chain run")
PY
gh run download "${FAILED_RUN_ID}" --repo "${GITHUB_REPOSITORY}" \
--pattern "rustfs-validation-${FAILED_RUN_ID}-*" --dir "${VALIDATION_ROOT}/failed-artifact"
python3 -B auto-testing/scripts/validation_heal/recover_environment.py \
--failed-run-id "${FAILED_RUN_ID}" --artifact "${VALIDATION_ROOT}/failed-artifact" \
--candidate "${VALIDATION_ROOT}/candidate.json" --evidence "${VALIDATION_ROOT}/recovery"
- name: Show validation report
if: ${{ always() }}
run: |
if [ -f "${VALIDATION_ROOT}/run/report.md" ]; then
cat "${VALIDATION_ROOT}/run/report.md" >> "${GITHUB_STEP_SUMMARY}"
fi
- name: Upload validation evidence
if: ${{ always() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: rustfs-validation-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/rustfs-validation-${{ github.run_id }}-${{ github.run_attempt }}
if-no-files-found: error
validation-tools:
needs: [validation]
if: ${{ github.event_name == 'workflow_dispatch' && inputs.validation_package_sha == '' && inputs.validation_tools_only }}
uses: ./.github/workflows/rustfs-validation-tools.yml
with:
candidate_sha: ${{ needs.validation.outputs.source_sha }}
auto_testing_sha: ${{ inputs.auto_testing_sha }}
build_native_heal: ${{ inputs.validation_native_tools }}
build_shared_heal: ${{ inputs.validation_shared_tools }}
build_manager_native: ${{ inputs.validation_manager_tools }}
secrets: inherit
upgrade:
needs: [prepare]
if: ${{ always() && needs.prepare.result == 'success' }}
@@ -279,7 +279,9 @@ jobs:
if [ "${{ inputs.pools || '3' }}" = "3" ]; then
STEPS="$STEPS,7,8"
if [ "${{ inputs.run_decommission != 'false' }}" = "true" ]; then
STEPS="$STEPS,9"
# Step 10 asserts the surviving pools after the removal
# (rustfs#8726), so it only runs together with step 9.
STEPS="$STEPS,9,10"
fi
fi
ARGS=(--steps "$STEPS" --with-warp -y \
@@ -293,6 +295,10 @@ jobs:
if [ -n "${RUSTFS_POOL_PROXY_ENDPOINT}" ]; then
ARGS+=(--proxy-endpoint "${RUSTFS_POOL_PROXY_ENDPOINT}")
fi
# Finalize the removal so step 10 can exercise the surviving
# pools; passing it without step 9 selected would run step 10
# against a topology where nothing was removed.
case ",${STEPS}," in *,9,*) ARGS+=(--finalize-decommission) ;; esac
if [ -n "${{ inputs.package_url }}" ]; then
ARGS+=(--package-url "${{ inputs.package_url }}")
elif [ -n "${{ inputs.rustfs_version }}" ]; then
@@ -1,244 +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.
name: RustFS Validation Package
on:
workflow_call:
inputs:
source_sha:
description: Exact RustFS commit SHA to build
required: true
type: string
secrets:
R2_ACCESS_KEY_ID:
required: true
R2_SECRET_ACCESS_KEY:
required: true
R2_ENDPOINT:
required: true
R2_BUCKET:
required: true
workflow_dispatch:
inputs:
source_sha:
description: Exact RustFS commit SHA to build
required: true
type: string
permissions:
contents: read
concurrency:
group: rustfs-validation-package-${{ inputs.source_sha }}
cancel-in-progress: false
jobs:
resolve-source:
if: github.repository == 'rustfs/rustfs'
name: Resolve exact validation source
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
source_sha: ${{ steps.source.outputs.sha }}
source_ref: ${{ steps.source.outputs.ref }}
steps:
- name: Validate requested source
id: source
env:
REQUESTED_SHA: ${{ inputs.source_sha }}
run: |
set -euo pipefail
[[ "${REQUESTED_SHA}" =~ ^[0-9a-f]{40}$ ]]
source_directory="$(mktemp -d "${RUNNER_TEMP}/validation-source.XXXXXX")"
trap 'rm -rf "${source_directory}"' EXIT
git init -q "${source_directory}"
git -C "${source_directory}" fetch --no-tags --depth=1 https://github.com/rustfs/rustfs.git "${REQUESTED_SHA}"
test "$(git -C "${source_directory}" rev-parse FETCH_HEAD)" = "${REQUESTED_SHA}"
echo "sha=${REQUESTED_SHA}" >> "${GITHUB_OUTPUT}"
echo "ref=${REQUESTED_SHA}" >> "${GITHUB_OUTPUT}"
build:
needs: resolve-source
name: Build immutable validation DEB
runs-on: ubuntu-latest
timeout-minutes: 150
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
VALIDATION_SOURCE_SHA: ${{ needs.resolve-source.outputs.source_sha }}
steps:
- name: Checkout exact source
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
ref: ${{ needs.resolve-source.outputs.source_sha }}
- name: Confirm checked out source
env:
EXPECTED_SHA: ${{ needs.resolve-source.outputs.source_sha }}
run: test "$(git rev-parse HEAD)" = "${EXPECTED_SHA}"
- name: Setup Rust environment
uses: ./.github/actions/setup
with:
cache-shared-key: validation-package-hosted-x86_64-unknown-linux-gnu
cache-save-if: "false"
install-build-packaging-tools: "false"
install-test-tools: "false"
- name: Build RustFS
run: cargo build --release --locked --target x86_64-unknown-linux-gnu -p rustfs --bins
- name: Build validation DEB
shell: bash
run: |
set -euo pipefail
command -v fakeroot >/dev/null 2>&1 || sudo apt-get install -y -qq fakeroot
package_dir="rustfs-validation-${VALIDATION_SOURCE_SHA}"
package_file="${RUNNER_TEMP}/validation-package/rustfs.deb"
mkdir -p "${RUNNER_TEMP}/validation-package"
binary="target/x86_64-unknown-linux-gnu/release/rustfs"
test -x "${binary}"
mkdir -p "${package_dir}/DEBIAN" "${package_dir}/usr/bin" "${package_dir}/etc/default" \
"${package_dir}/lib/systemd/system" "${package_dir}/usr/share/doc/rustfs"
cp "${binary}" "${package_dir}/usr/bin/rustfs"
chmod 755 "${package_dir}/usr/bin/rustfs"
cp deploy/build/rustfs.service "${package_dir}/lib/systemd/system/"
cat > "${package_dir}/etc/default/rustfs" <<'ENVEOF'
# RustFS Environment Configuration
# RUSTFS_ACCESS_KEY=<changeme>
# RUSTFS_SECRET_KEY=<changeme>
# RUSTFS_VOLUMES=""
# RUSTFS_ADDRESS=":9000"
ENVEOF
cat > "${package_dir}/DEBIAN/control" <<'CONTROLEOF'
Package: rustfs
Version: 0.0.0~validation
Section: utils
Priority: optional
Architecture: amd64
Depends: libc6 (>= 2.31)
Maintainer: RustFS Team <support@rustfs.com>
Description: RustFS immutable validation package
Candidate package for RustFS validation only.
CONTROLEOF
printf '%s\n' /etc/default/rustfs > "${package_dir}/DEBIAN/conffiles"
./scripts/release/package_service_scripts.sh after-install > "${package_dir}/DEBIAN/postinst"
./scripts/release/package_service_scripts.sh before-remove > "${package_dir}/DEBIAN/prerm"
./scripts/release/package_service_scripts.sh after-remove > "${package_dir}/DEBIAN/postrm"
chmod 755 "${package_dir}/DEBIAN/postinst" "${package_dir}/DEBIAN/prerm" "${package_dir}/DEBIAN/postrm"
cp LICENSE README.md "${package_dir}/usr/share/doc/rustfs/"
fakeroot dpkg-deb --build "${package_dir}" "${package_file}"
test -s "${package_file}"
- name: Upload opaque validation package
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: validation-package-bytes-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/validation-package/rustfs.deb
if-no-files-found: error
publish:
needs: [resolve-source, build]
name: Publish immutable validation DEB
# Candidate-controlled build scripts never run on this fresh hosted runner.
runs-on: ubuntu-latest
timeout-minutes: 20
env:
VALIDATION_SOURCE_SHA: ${{ needs.resolve-source.outputs.source_sha }}
VALIDATION_SOURCE_REF: ${{ needs.resolve-source.outputs.source_ref }}
steps:
- name: Download opaque validation package
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: validation-package-bytes-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/validation-package-input
- name: Validate and stage opaque package bytes
shell: bash
run: |
set -euo pipefail
python3 -I - <<'PYTHON'
import os
from pathlib import Path
import shutil
import stat
root = Path(os.environ["RUNNER_TEMP"])
incoming = root / "validation-package-input"
if incoming.is_symlink() or not incoming.is_dir() or sorted(p.name for p in incoming.iterdir()) != ["rustfs.deb"]:
raise SystemExit("Validation artifact must contain exactly one rustfs.deb file")
fd = os.open(incoming / "rustfs.deb", os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
with os.fdopen(fd, "rb") as source:
info = os.fstat(source.fileno())
if not stat.S_ISREG(info.st_mode) or info.st_size == 0:
raise SystemExit("Validation package must be a nonempty regular file")
staged = root / "validation-publication"
staged.mkdir(mode=0o700)
with (staged / "rustfs.deb").open("xb") as target:
shutil.copyfileobj(source, target)
PYTHON
- name: Prepare trusted publication tools
id: tools
env:
AWS_EC2_METADATA_DISABLED: true
shell: bash
run: |
set -euo pipefail
AWS_CLI=aws
if ! "${AWS_CLI}" s3api put-object --generate-cli-skeleton input | jq -e 'has("IfNoneMatch")' >/dev/null; then
sudo apt-get update
sudo apt-get install -y -qq python3-venv
AWS_CLI_DIR="$(mktemp -d "${RUNNER_TEMP}/validation-awscli.XXXXXX")"
python3 -m venv "${AWS_CLI_DIR}"
"${AWS_CLI_DIR}/bin/python" -m pip install --disable-pip-version-check 'awscli==1.44.79'
AWS_CLI="${AWS_CLI_DIR}/bin/aws"
fi
"${AWS_CLI}" s3api put-object --generate-cli-skeleton input | jq -e 'has("IfNoneMatch")' >/dev/null
echo "aws_cli=${AWS_CLI}" >> "${GITHUB_OUTPUT}"
- name: Upload validation package to immutable R2 path
id: publish
env:
PACKAGE_FILE: ${{ runner.temp }}/validation-publication/rustfs.deb
AWS_CLI: ${{ steps.tools.outputs.aws_cli }}
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
R2_BUCKET: ${{ secrets.R2_BUCKET }}
AWS_EC2_METADATA_DISABLED: true
shell: bash
run: |
set -euo pipefail
if [[ -z "${R2_ACCESS_KEY_ID}" || -z "${R2_SECRET_ACCESS_KEY}" || -z "${R2_ENDPOINT}" || -z "${R2_BUCKET}" ]]; then
echo "Validation package publication requires complete R2 configuration" >&2
exit 1
fi
[[ "${VALIDATION_SOURCE_SHA}" =~ ^[0-9a-f]{40}$ ]]
test "${VALIDATION_SOURCE_REF}" = "${VALIDATION_SOURCE_SHA}"
test -s "${PACKAGE_FILE}" && test ! -L "${PACKAGE_FILE}"
export AWS_ACCESS_KEY_ID="${R2_ACCESS_KEY_ID}" AWS_SECRET_ACCESS_KEY="${R2_SECRET_ACCESS_KEY}" AWS_DEFAULT_REGION=auto
package_sha256="$(sha256sum "${PACKAGE_FILE}" | cut -d ' ' -f 1)"
key="artifacts/rustfs/packages/validation/runs/${GITHUB_RUN_ID}/${GITHUB_RUN_ATTEMPT}/${package_sha256}/rustfs.deb"
url="https://dl.rustfs.com/${key}"
"${AWS_CLI}" s3api put-object --bucket "${R2_BUCKET}" --key "${key}" --body "${PACKAGE_FILE}" \
--if-none-match '*' --endpoint-url "${R2_ENDPOINT}"
downloaded_sha256="$(curl -fsSL --retry 3 --connect-timeout 15 --max-time 300 "${url}" | sha256sum | cut -d ' ' -f 1)"
test "${downloaded_sha256}" = "${package_sha256}"
manifest="${RUNNER_TEMP}/validation-candidate-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}.json"
jq -n --arg workflow_sha "${GITHUB_SHA}" --arg source_ref "${VALIDATION_SOURCE_REF}" \
--arg source_sha "${VALIDATION_SOURCE_SHA}" --arg channel validation \
--argjson build_run_id "${GITHUB_RUN_ID}" --argjson build_run_attempt "${GITHUB_RUN_ATTEMPT}" \
--arg package_url "${url}" --arg package_sha256 "${package_sha256}" \
'{schema: 3, workflow_sha: $workflow_sha, source_ref: $source_ref, source_sha: $source_sha, channel: $channel, build_run_id: $build_run_id, build_run_attempt: $build_run_attempt, package_url: $package_url, package_sha256: $package_sha256}' > "${manifest}"
echo "candidate_file=${manifest}" >> "${GITHUB_OUTPUT}"
- name: Upload validation candidate manifest
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: validation-candidate-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.publish.outputs.candidate_file }}
if-no-files-found: error
@@ -1,343 +0,0 @@
# Build source-bound helpers separately so live validation needs only artifacts.
name: RustFS Validation Tools
on:
workflow_call:
inputs:
candidate_sha:
type: string
required: true
auto_testing_sha:
type: string
required: true
build_native_heal:
type: boolean
required: false
default: false
build_shared_heal:
type: boolean
required: false
default: false
build_manager_native:
type: boolean
required: false
default: false
secrets:
PF_TESTING_GH_TOKEN:
required: true
workflow_dispatch:
inputs:
candidate_sha:
description: Exact RustFS source SHA from the validation candidate manifest
type: string
required: true
auto_testing_sha:
description: Exact auto-testing SHA containing the Heal metadata inspector
type: string
required: true
build_native_heal:
description: Also compile and run separate exact-source native Heal regressions
type: boolean
required: false
default: false
build_shared_heal:
description: Build the controlled C10 shared-reference fixture encoder
type: boolean
required: false
default: false
build_manager_native:
description: Build and run exact candidate checkpoint/MRF/incarnation regressions
type: boolean
required: false
default: false
permissions:
contents: read
jobs:
manager-native-regressions:
if: ${{ inputs.build_manager_native }}
runs-on: ubuntu-22.04
timeout-minutes: 180
env:
CANDIDATE_SHA: ${{ inputs.candidate_sha }}
AUTO_TESTING_SHA: ${{ inputs.auto_testing_sha }}
steps:
- name: Validate immutable manager native inputs
run: |
set -euo pipefail
[[ "${CANDIDATE_SHA}" =~ ^[0-9a-f]{40}$ ]]
[[ "${AUTO_TESTING_SHA}" =~ ^[0-9a-f]{40}$ ]]
- name: Checkout exact manager candidate source
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
ref: ${{ inputs.candidate_sha }}
path: candidate
persist-credentials: false
- name: Checkout exact manager collector source
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
repository: rustfs/auto-testing
ref: ${{ inputs.auto_testing_sha }}
token: ${{ secrets.PF_TESTING_GH_TOKEN }}
path: auto-testing
persist-credentials: false
- name: Install manager native build dependencies
run: |
set -euo pipefail
sudo apt-get update
sudo apt-get install -y clang lld cmake pkg-config libssl-dev protobuf-compiler
df -h .
- name: Build exact unmodified candidate manager test executable
run: |
set -euo pipefail
test "$(git -C auto-testing rev-parse HEAD)" = "${AUTO_TESTING_SHA}"
python3 -B auto-testing/scripts/validation_heal/manager_native.py build \
--source candidate --commit "${CANDIDATE_SHA}" --out manager-tools
- name: Run exact manager regressions independently
run: |
set -euo pipefail
python3 -B auto-testing/scripts/validation_heal/manager_native.py run \
--directory manager-tools --out manager-evidence
- name: Upload manager executable identities and original diagnostics
if: ${{ always() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: rustfs-validation-manager-native-${{ github.run_id }}-${{ github.run_attempt }}
path: |
manager-tools/heal-manager-native-tests
manager-tools/*.json
manager-tools/*.jsonl
manager-tools/*.stderr
manager-tools/*.log
manager-evidence/
if-no-files-found: warn
native-heal-regressions:
if: ${{ inputs.build_native_heal }}
runs-on: ubuntu-22.04
timeout-minutes: 180
env:
CANDIDATE_SHA: ${{ inputs.candidate_sha }}
AUTO_TESTING_SHA: ${{ inputs.auto_testing_sha }}
steps:
- name: Validate immutable native source inputs
run: |
set -euo pipefail
[[ "${CANDIDATE_SHA}" =~ ^[0-9a-f]{40}$ ]]
[[ "${AUTO_TESTING_SHA}" =~ ^[0-9a-f]{40}$ ]]
- name: Checkout exact native candidate source
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
ref: ${{ inputs.candidate_sha }}
path: candidate
persist-credentials: false
- name: Checkout exact native collector source
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
repository: rustfs/auto-testing
ref: ${{ inputs.auto_testing_sha }}
token: ${{ secrets.PF_TESTING_GH_TOKEN }}
path: auto-testing
persist-credentials: false
- name: Install native build dependencies
run: |
set -euo pipefail
sudo apt-get update
sudo apt-get install -y clang lld cmake pkg-config libssl-dev protobuf-compiler
df -h .
- name: Build and enumerate the unmodified candidate test executable
run: |
set -euo pipefail
test "$(git -C auto-testing rev-parse HEAD)" = "${AUTO_TESTING_SHA}"
use_race_native=false
if [[ -f auto-testing/scripts/validation_heal/race_native.py ]]; then
support_rc=0
python3 -B auto-testing/scripts/validation_heal/race_native.py supports \
--commit "${CANDIDATE_SHA}" || support_rc=$?
case "${support_rc}" in
0) use_race_native=true ;;
2) ;; # The original exact-source collector handles legacy candidates.
*) exit "${support_rc}" ;;
esac
fi
if [[ "${use_race_native}" == true ]]; then
python3 -B auto-testing/scripts/validation_heal/race_native.py build \
--source candidate --commit "${CANDIDATE_SHA}" --testing-commit "${AUTO_TESTING_SHA}" --out native-tools
else
python3 -B auto-testing/scripts/validation_heal/native_regressions.py build \
--source candidate --commit "${CANDIDATE_SHA}" --out native-tools
fi
- name: Run exact native regressions separately from fleet validation
run: |
set -euo pipefail
if [[ -f native-tools/race-native.json ]]; then
test ! -e native-tools/native-regressions.json
python3 -B auto-testing/scripts/validation_heal/race_native.py run \
--directory native-tools --out native-evidence
else
python3 -B auto-testing/scripts/validation_heal/native_regressions.py run \
--directory native-tools --out native-evidence
fi
- name: Run existing F07 directory fsync failure regressions
if: ${{ inputs.candidate_sha == '1aa9be87d18cf941e5020f19e8622c26ba4b10ac' }}
run: |
set -euo pipefail
# Older immutable harness pins retain their original native suite;
# absence of this additive collector must never fabricate its proof.
if [[ ! -f auto-testing/scripts/validation_heal/persistence_native.py ]]; then
mkdir native-persistence-evidence
echo '{"complete":false,"reason":"pinned harness predates F07 directory-fsync collector","requiredNativeCoverageComplete":false,"canonicalCasePassClaimed":false}' \
> native-persistence-evidence/not-available.json
exit 0
fi
python3 -B auto-testing/scripts/validation_heal/persistence_native.py run \
--directory native-tools --native-evidence native-evidence --out native-persistence-evidence \
--testing-commit "${AUTO_TESTING_SHA}"
python3 -B auto-testing/scripts/validation_heal/persistence_native.py verify \
--directory native-tools --native-evidence native-evidence --out native-persistence-evidence \
--testing-commit "${AUTO_TESTING_SHA}"
- name: Upload native executable identities and original diagnostics
if: ${{ always() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: rustfs-validation-native-${{ github.run_id }}-${{ github.run_attempt }}
path: |
native-tools/ecstore-native-tests
native-tools/ecstore-race-native-tests
native-tools/*.json
native-tools/*.jsonl
native-tools/*.stderr
native-tools/*.log
native-evidence/
native-persistence-evidence/
if-no-files-found: warn
heal-metadata-inspector:
runs-on: ubuntu-22.04
timeout-minutes: 30
env:
CANDIDATE_SHA: ${{ inputs.candidate_sha }}
AUTO_TESTING_SHA: ${{ inputs.auto_testing_sha }}
steps:
- name: Validate immutable source inputs
run: |
set -euo pipefail
[[ "${CANDIDATE_SHA}" =~ ^[0-9a-f]{40}$ ]]
[[ "${AUTO_TESTING_SHA}" =~ ^[0-9a-f]{40}$ ]]
- name: Checkout exact candidate source
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
ref: ${{ inputs.candidate_sha }}
path: candidate
persist-credentials: false
- name: Checkout exact inspector source
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
repository: rustfs/auto-testing
ref: ${{ inputs.auto_testing_sha }}
token: ${{ secrets.PF_TESTING_GH_TOKEN }}
path: auto-testing
persist-credentials: false
- name: Build the read-only candidate metadata decoder
run: |
set -euo pipefail
test "$(git -C candidate rev-parse HEAD)" = "${CANDIDATE_SHA}"
test "$(git -C auto-testing rev-parse HEAD)" = "${AUTO_TESTING_SHA}"
df -h .
python3 auto-testing/scripts/validation_heal/prepare_heal_meta_inspector.py \
--source candidate --commit "${CANDIDATE_SHA}" --out inspector-build
# Seed the helper's separate graph from the candidate's frozen graph.
# Cargo adds the helper package; both lock identities are retained.
cp candidate/Cargo.lock inspector-build/Cargo.lock
cargo build --release --manifest-path inspector-build/Cargo.toml
python3 auto-testing/scripts/validation_heal/check_bitrot_tool.py \
--binary inspector-build/target/release/heal-meta-inspect --source candidate
mkdir validation-tools
cp inspector-build/target/release/heal-meta-inspect validation-tools/
cp inspector-build/Cargo.lock validation-tools/inspector-Cargo.lock
python3 - <<'PY'
import hashlib
import json
import os
from pathlib import Path
import subprocess
source = json.loads(Path("inspector-build/source.json").read_text())
binary = Path("validation-tools/heal-meta-inspect")
source.update(built=True, testingCommit=os.environ["AUTO_TESTING_SHA"],
binarySha256=hashlib.sha256(binary.read_bytes()).hexdigest(),
inspectorLockSha256=hashlib.sha256(Path("validation-tools/inspector-Cargo.lock").read_bytes()).hexdigest(),
toolchain=subprocess.check_output(["rustc", "-vV"], text=True),
target="x86_64-unknown-linux-gnu", runId=os.environ["GITHUB_RUN_ID"],
runAttempt=os.environ["GITHUB_RUN_ATTEMPT"])
Path("validation-tools/heal-meta-inspect.json").write_text(json.dumps(source, indent=2) + "\n")
PY
git -C candidate diff --exit-code
- name: Build and test the controlled C10 metadata encoder
if: ${{ inputs.build_shared_heal }}
run: |
set -euo pipefail
python3 -B auto-testing/scripts/validation_heal/prepare_heal_shared_fixture.py \
--source candidate --commit "${CANDIDATE_SHA}" --out shared-build
cp candidate/Cargo.lock shared-build/Cargo.lock
export CARGO_TARGET_DIR="${GITHUB_WORKSPACE}/inspector-build/target"
cargo build --release --manifest-path shared-build/Cargo.toml
mkdir shared-tools
if ! cargo test --release --manifest-path shared-build/Cargo.toml -- --test-threads=1 \
> shared-tools/shared-fixture-tests.log 2>&1; then
cat shared-tools/shared-fixture-tests.log
exit 1
fi
cp "${CARGO_TARGET_DIR}/release/heal-shared-fixture" shared-tools/
cp shared-build/Cargo.lock shared-tools/shared-Cargo.lock
python3 - <<'PY'
import hashlib
import json
import os
from pathlib import Path
import subprocess
source = json.loads(Path("shared-build/source.json").read_text())
source.update(schemaVersion=1, built=True, sourceUnmodified=True,
candidateSourceTree=subprocess.check_output(["git", "-C", "candidate", "rev-parse", "HEAD^{tree}"], text=True).strip(),
testingCommit=os.environ["AUTO_TESTING_SHA"],
binarySha256=hashlib.sha256(Path("shared-tools/heal-shared-fixture").read_bytes()).hexdigest(),
sharedLockSha256=hashlib.sha256(Path("shared-tools/shared-Cargo.lock").read_bytes()).hexdigest(),
toolchain=subprocess.check_output(["rustc", "-vV"], text=True), target="x86_64-unknown-linux-gnu",
runId=os.environ["GITHUB_RUN_ID"], runAttempt=os.environ["GITHUB_RUN_ATTEMPT"])
Path("shared-tools/heal-shared-fixture.json").write_text(json.dumps(source, indent=2) + "\n")
PY
git -C candidate diff --exit-code
- name: Upload controlled C10 fixture encoder evidence
if: ${{ always() && inputs.build_shared_heal }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: rustfs-validation-shared-${{ github.run_id }}-${{ github.run_attempt }}
path: shared-tools/
if-no-files-found: warn
- name: Upload immutable validation tools
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: rustfs-validation-tools-${{ github.run_id }}-${{ github.run_attempt }}
path: validation-tools/
if-no-files-found: error
+15
View File
@@ -25,6 +25,8 @@ on:
- "crates/ecstore/src/object_api/write_commit_context.rs"
- "crates/ecstore/src/bucket/utils.rs"
- "crates/ecstore/Cargo.toml"
- "crates/storage-api/src/bucket.rs"
- "crates/storage-api/Cargo.toml"
- "crates/utils/src/dunce.rs"
- "crates/utils/src/os/**"
- "crates/utils/Cargo.toml"
@@ -48,6 +50,8 @@ on:
- "crates/ecstore/src/object_api/write_commit_context.rs"
- "crates/ecstore/src/bucket/utils.rs"
- "crates/ecstore/Cargo.toml"
- "crates/storage-api/src/bucket.rs"
- "crates/storage-api/Cargo.toml"
- "crates/utils/src/dunce.rs"
- "crates/utils/src/os/**"
- "crates/utils/Cargo.toml"
@@ -140,6 +144,17 @@ jobs:
shell: pwsh
run: "cargo test -p rustfs-ecstore --lib bucket::utils:: -- --nocapture"
- name: Test artifact prefix Windows file-name rules
shell: pwsh
run: |
$testName = 'bucket::tests::artifact_prefix_preserves_host_file_name_rules'
cargo test --locked -p rustfs-storage-api --lib $testName -- --exact --nocapture --color never 2>&1 |
Tee-Object -Variable testOutput
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
if (($testOutput -join "`n") -notmatch 'test result: ok\. 1 passed; 0 failed; 0 ignored;') {
throw 'Expected exactly one passing artifact prefix test on Windows'
}
- name: Test Windows file-name rules
shell: pwsh
run: "cargo test -p rustfs-utils --no-default-features --features path --lib dunce:: -- --nocapture"
+3
View File
@@ -19,12 +19,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Single-part source checksums are forwarded as `x-amz-checksum-*` headers instead of user metadata, so the replica preserves checksum responses ([#7313](https://github.com/rustfs/rustfs/pull/7313)). This changes the default outbound headers for checksummed objects.
- Site-replication outage recovery now uses a bounded 30-second retry drain plus the 600-second full reconciliation pass, persists destructive liabilities before local deletion, and fences replay settlement and peer edits ([#7148](https://github.com/rustfs/rustfs/pull/7148)). Persisted additions are optional and ignored by older readers.
- IAM snapshot/deletion replay, target-assigned delete-marker purges, timestamp ordering, and best-effort peer broadcast now close the control-plane gaps found by the R6 review ([#7195](https://github.com/rustfs/rustfs/pull/7195)).
- A `set-remote-target?update=true&creds=true` request on a site-replication peer's bucket target is now rejected with `400 InvalidRequest`, naming the target and why it counts as peer-owned. It used to return success while keeping the stored endpoint, credentials, and TLS settings, so an endpoint edit from the Console's bucket replication form appeared to apply but did not. Change a peer's address with a site-replication edit instead; see "Changing a site's address" in `docs/operations/site-replication-operations.md`. The `sync`, `bandwidth`, `proxy`, and `path` update groups are unchanged.
- Site-replication edits that omit the site name, such as `mc admin replicate update --deployment-id <ID> --endpoint <URL>`, now keep the stored name on the editing site and on every receiver instead of renaming the site after the new endpoint host. No persisted format changes.
- Upgrade and rollback: upgrade every node in one site consecutively and verify reconciliation before moving to the next site; do not intentionally run a site mixed-version. Target-version ledger keys are harmless on rollback, although old code cannot use their routing. Before rolling back past [#7307](https://github.com/rustfs/rustfs/pull/7307), drain or repair every pending version purge: older code can free a retained version's data directory before its remote purge is acknowledged. See `docs/operations/site-replication-operations.md`.
### Security
- **Presigned URLs honour only signed headers** (GHSA-g8w9-qw9q-fghr): a SigV4 presigned request that carries an `x-amz-*` request header not listed in `X-Amz-SignedHeaders` is now rejected with `403 AccessDenied` ("There were headers present in the request which were not signed"), matching AWS S3. Previously the holder of a presigned `PutObject` URL could add unsigned `x-amz-tagging`, `x-amz-storage-class`, `x-amz-website-redirect-location`, ACL, metadata, Object Lock or SSE headers and have them applied. Presigners that intend a property must set it before signing so the SDK lists the header in `SignedHeaders`; `x-amz-cf-id` (CloudFront) remains tolerated unsigned. Header-signed SigV4 and SigV2 requests are unchanged.
### Fixed
- **`mc admin info` and `mc admin replicate resync` work again**: since the cluster snapshot view (#3803), `GET /v3/info` and `GET /v3/storageinfo` returned the madmin `InfoMessage` / `StorageInfo` nested under an `info` key next to `admin_discovery` (and, for `/info`, `bitrotSelftest`). madmin decodes those bodies directly, so `mc admin info` printed empty values and `mc admin replicate resync start/status` rejected every alias with "alias provided is not part of cluster replication" because the local `deploymentID` read as empty. Requests on the MinIO-compatible `/minio/admin/v3` prefix now receive the bare madmin body again; `/rustfs/admin/v3`, which rc and the console use, keeps the envelope unchanged.
- **Replication status writebacks no longer invalidate scanner cycles**: every replication status writeback (`persist_replication_state_if_current` in `Update` mode, including the heals the scanner queues during its own walk) marked the bucket dirty on the namespace path, advanced the dirty-usage generation and woke the scanner, so a site-replication resync or any steadily replicated bucket ended every cycle `superseded`, the authoritative usage baseline was never certified, and the console reported usage freshness as stale for as long as replication ran (#8375). Those writebacks are now status class: they do not touch the dirty-usage generation, the activity snapshot, the dirty-usage wake or the durable dirty journal, and only the process-local producer coverage is recorded. They need no rescan queue, because a deployment with an active replication rule already walks every bucket on every cycle and that walk refreshes `replication_stats`. Tier-expiration cleanup, which removes data, stays on the namespace path. No persisted format, protocol, admin API or configuration changes. Design: `docs/architecture/scanner-per-bucket-convergence.md`.
- **A bucket written during a scanner walk no longer supersedes the whole cycle**: the post-walk cluster activity proof required the entire activity snapshot and the dirty-usage generation to be unchanged, so a single-node deployment with one bucket written every few seconds never reached a `Complete` cycle, `usage_snapshot_converged` never became true and the authoritative usage baseline was never certified (#8494). Dirty usage is now certified per bucket: a structurally complete walk that the guardrail did not veto is `Complete`, buckets whose dirty generation still matches the start snapshot are cleared, and buckets written during the walk stay dirty and are published as the pending set, so `usage_snapshot_converged` means "no bucket pending" and the next cycle is scoped to the pending buckets. Such a cycle writes the authoritative usage object; the observed object is now selected by an explicit observational mark that vetoed cycles carry. The guardrail itself vetoes only on the fields that change which namespace the walk described (peer set, process instance, protocol version, topology, maintenance generation, data movement, movement generation, publication blocked) and reports the field and host in the scanner log and in the new `usage_freshness` fields `guardrail_veto_total`, `last_guardrail_veto_unix_secs` and `last_guardrail_veto_reason`. In a distributed deployment a remote peer whose dirty usage moved during the walk still supersedes the cycle until scoped acknowledgements can name its buckets. `.usage.v2.json` gains the additive, default-when-absent fields `usage_snapshot_pending_buckets`, `usage_snapshot_pending_bucket_count` and `usage_snapshot_observational`; older readers ignore them and treat the snapshot as an authoritative but unconverged publication. Quota is unchanged: an uncertified bucket keeps its last usage value as the floor and live writes do not advance it. No protocol or configuration changes. Design: `docs/architecture/scanner-per-bucket-convergence.md`.
- **SSE-C TLS requirement now covers copy-source keys**: with `RUSTFS_SSE_C_REQUIRE_TLS=true`, a `CopyObject` or `UploadPartCopy` that carried only the `x-amz-copy-source-server-side-encryption-customer-*` headers (an SSE-C source copied to a destination that is not SSE-C) was accepted on a plaintext transport and was not counted in `rustfs_ssec_plaintext_requests_total`, although those headers carry the source object's customer key. They now count as SSE-C headers: such requests are counted, and refused with `400 InvalidRequest` when the switch is on. Requests over TLS are unaffected.
Generated
+383 -604
View File
File diff suppressed because it is too large Load Diff
+45 -29
View File
@@ -146,7 +146,7 @@ rustfs-zip = { path = "./crates/zip", version = "1.0.2" }
async-channel = "2.5.0"
async_zip = { default-features = false, version = "0.0.19" }
mysql_async = { default-features = false, version = "0.37.1" }
async-compression = { version = "0.4.50" }
async-compression = { version = "0.4.51" }
async-trait = "0.1.92"
async-nats = { version = "0.50.0", default-features = false }
axum = "0.8.9"
@@ -156,7 +156,7 @@ futures-lite = "2.6.1"
futures-util = "0.3.34"
pollster = "1.0.1"
pulsar = { default-features = false, version = "=6.9.0" }
lapin = { default-features = false, version = "4.12.2" }
lapin = { default-features = false, version = "4.12.3" }
hyper = { version = "1.12.0" }
h2 = { version = "0.4.20" }
hyper-rustls = { default-features = false, version = "0.27.10" }
@@ -169,7 +169,7 @@ minlz = "1.2.3"
reqwest = "0.13.5"
rustfs-kafka-async = { version = "1.4.0" }
socket2 = { version = "0.6.5" }
tokio = { version = "1.53.3" }
tokio = { version = "1.54.1" }
tokio-rustls = { default-features = false, version = "0.26.6" }
tokio-stream = { version = "0.1.19" }
tokio-test = "0.4.6"
@@ -243,22 +243,22 @@ tokio-postgres-rustls = "0.14.0"
# Utilities and Tools
anyhow = "1.0.104"
arc-swap = "1.9.2"
arc-swap = "1.9.3"
rustfs-tokio-tar = { version = "0.7.1" }
# Candidate Snowball parser versions exercised by rustfs-zip compatibility fixtures.
tar-codec = "0.0.15"
tar-framing = "0.0.15"
atoi = "3.1.0"
atoi = "3.1.1"
atomic_enum = "0.3.0"
aws-config = { version = "1.12.0" }
aws-credential-types = { version = "1.3.0" }
aws-sdk-kms = { default-features = false, version = "1.123.0" }
aws-sdk-s3 = { default-features = false, version = "1.152.0" }
aws-sdk-sts = { default-features = false, version = "1.119.0" }
aws-smithy-async = { version = "1.3.0" }
aws-smithy-http-client = { default-features = false, version = "1.5.0" }
aws-smithy-runtime-api = { version = "1.19.0" }
aws-smithy-types = { version = "1.8.1" }
aws-config = { version = "1.13.0" }
aws-credential-types = { version = "1.3.1" }
aws-sdk-kms = { default-features = false, version = "1.126.0" }
aws-sdk-s3 = { default-features = false, version = "1.155.0" }
aws-sdk-sts = { default-features = false, version = "1.122.0" }
aws-smithy-async = { version = "1.3.1" }
aws-smithy-http-client = { default-features = false, version = "1.5.1" }
aws-smithy-runtime-api = { version = "1.19.1" }
aws-smithy-types = { version = "1.9.0" }
base64-simd = "0.8.0"
brotli = "9.0.0"
clap = { version = "4.6.7" }
@@ -272,12 +272,12 @@ crossbeam-utils = "0.8.23"
datafusion = { default-features = false, version = "55.2.0" }
derive_builder = "0.20.2"
ed25519-dalek = "3.0.0"
enumset = "1.1.14"
enumset = "1.1.15"
faster-hex = "1.0.0"
flate2 = "1.1.10"
glob = "0.3.4"
google-cloud-storage = "1.20.0"
google-cloud-auth = "1.17.0"
google-cloud-storage = "1.21.0"
google-cloud-auth = "1.18.0"
hashbrown = { version = "0.17.1" }
# Base32 for RFC 6238 TOTP shared secrets (RFC 4648 unpadded, the alphabet
# every authenticator app expects). Already in the graph transitively.
@@ -287,11 +287,11 @@ highway = { version = "1.3.0" }
hostname = "0.4.2"
ipnetwork = { version = "0.21.1" }
lazy_static = "1.5.1"
libc = "0.2.190"
libc = "0.2.191"
libsystemd = "0.7.2"
local-ip-address = "0.6.13"
log = "0.4"
memmap2 = "0.9.11"
memmap2 = "0.9.12"
lz4 = "1.28.1"
matchit = "0.9.2"
md-5 = "0.11.0"
@@ -301,7 +301,7 @@ netif = "0.1.6"
num_cpus = { version = "1.17.0" }
nvml-wrapper = "0.13.0"
object = { version = "=0.40.0", default-features = false }
parking_lot = "0.12.5"
parking_lot = "0.13.0"
path-absolutize = "4.0.1"
percent-encoding = "2.3.2"
# Server-side QR rendering for TOTP enrollment, so neither the console nor the
@@ -317,7 +317,7 @@ rustfs-erasure-codec = { version = "9.0.0" }
reed-solomon-simd = "3.1.0"
regex = { version = "1.13.1" }
rumqttc = { package = "rumqttc-next", version = "0.34.0" }
redis = { version = "1.7.1" }
redis = { version = "1.8.0" }
rustify = { version = "0.7", default-features = false }
rustix = { version = "1.1.5" }
rust-embed = { version = "8.13.0" }
@@ -327,7 +327,7 @@ s3s-sigv4 = { git = "https://github.com/s3s-project/s3s", rev = "5761ddfe4c509fc
serial_test = "4.0.1"
shadow-rs = { default-features = false, version = "2.0.0" }
siphasher = "1.0.4"
smallvec = { version = "1.16.3" }
smallvec = { version = "1.16.4" }
compact_str = "0.10.0"
snap = "1.1.2"
starshard = { version = "2.4.0" }
@@ -346,7 +346,7 @@ tracing-subscriber = { version = "0.3.23" }
transform-stream = "0.3.1"
url = "2.5.8"
urlencoding = "2.1.3"
uuid = { version = "1.28.0" }
uuid = { version = "1.29.0" }
vaultrs = { version = "0.8.0" }
tar = "0.4.46"
walkdir = "2.5.0"
@@ -354,13 +354,13 @@ winapi-util = "0.1.11"
windows = { version = "0.62.2" }
windows-sys = "0.61.2"
xxhash-rust = { version = "0.8.19" }
zip = "9.0.3"
zstd = "0.14.2"
zip = "9.1.0"
zstd = "0.14.3"
# Observability and Metrics
metrics = "0.24.6"
metrics-util = "0.20"
dial9 = { version = "0.5.4", default-features = false }
dial9 = { version = "0.5.5", default-features = false }
opentelemetry = { version = "0.33.1" }
opentelemetry-appender-tracing = { version = "0.33.1" }
opentelemetry-otlp = { version = "0.33.1" }
@@ -368,7 +368,7 @@ opentelemetry-proto = { version = "0.33.1", default-features = false, features =
opentelemetry_sdk = { version = "0.33.1" }
opentelemetry-semantic-conventions = { version = "0.33.1" }
opentelemetry-stdout = { version = "0.33.1" }
pyroscope = { version = "2.1.2" }
pyroscope = { version = "2.1.5" }
# FTP and SFTP
libunftp = { version = "0.23.1" }
@@ -382,9 +382,9 @@ russh-sftp = "3.0.1"
dav-server = "0.11.0"
# Performance Analysis and Memory Profiling
rustfs-mimalloc = { version = "0.5.6" }
rustfs-mimalloc = { version = "0.7.2" }
# Preserve Unicode focus filters until rustfs/backlog#2302 is resolved.
hotpath = { version = "0.28.6", default-features = false }
hotpath = { version = "0.29.1", default-features = false }
# Snapshot testing for output format regression detection
insta = { version = "1.49" }
@@ -421,3 +421,19 @@ inherits = "release"
inherits = "release"
debug = true
strip = "none"
# Pin upstream fixes until compatible crates.io releases are available.
[patch.crates-io]
# Preserve RustFS OIDC flows while migrating the old elliptic-curve dependency chain.
# Upstream: openidconnect-rs#244 and oauth2-rs#352.
openidconnect = { git = "https://github.com/ramosbugs/openidconnect-rs", rev = "2f77ee73bc4554e1cb6279adb386005429fb4e41" }
oauth2 = { git = "https://github.com/ramosbugs/oauth2-rs", rev = "89585f4a280afd7b285eaf1c9be0068874e03d22" }
aws-sigv4 = { git = "https://github.com/smithy-lang/smithy-rs", rev = "5c1c4bca2ea7af97496e077c9f1a718eaac64bf8" }
# Keep the signer's path dependencies on the SDK's existing registry types.
[patch."https://github.com/smithy-lang/smithy-rs"]
aws-credential-types = { version = "=1.3.1" }
aws-smithy-eventstream = { version = "=0.61.6" }
aws-smithy-http = { version = "=0.64.2" }
aws-smithy-runtime-api = { version = "=1.19.1" }
aws-smithy-types = { version = "=1.9.0" }
+1
View File
@@ -29,6 +29,7 @@ workspace = true
[features]
default = []
test-util = []
hotpath = ["hotpath/hotpath", "hotpath/tokio"]
hotpath-alloc = ["hotpath", "hotpath/hotpath-alloc"]
hotpath-cpu = ["hotpath", "hotpath/hotpath-cpu"]
+39
View File
@@ -97,6 +97,45 @@ pub fn try_get_global_local_node_name() -> Option<String> {
.filter(|name| !name.is_empty())
}
/// Run a synchronous test callback while publishing a name under its writer lock.
/// Call from a blocking worker; returning or unwinding releases the writer.
#[cfg(any(test, feature = "test-util"))]
#[doc(hidden)]
pub fn with_local_node_name_write_lock_for_test<T>(name: &str, work: impl FnOnce() -> T) -> T {
let mut writer = GLOBAL_LOCAL_NODE_NAME.blocking_write();
*writer = name.to_string();
let result = work();
drop(writer);
result
}
#[cfg(test)]
mod node_identity_tests {
use super::*;
#[tokio::test]
async fn local_node_name_nonblocking_read_recovers_after_writer_release() {
let previous = get_global_local_node_name().await;
let (unavailable, published, panicked) = tokio::task::spawn_blocking(|| {
let unavailable = with_local_node_name_write_lock_for_test("published-node", try_get_global_local_node_name);
let published = try_get_global_local_node_name();
let panicked = std::panic::catch_unwind(|| {
with_local_node_name_write_lock_for_test("unwound-node", || panic!("identity callback failed"));
});
(unavailable, published, panicked)
})
.await
.expect("identity writer worker must complete");
let after_unwind = try_get_global_local_node_name();
set_global_local_node_name(&previous).await;
assert!(unavailable.is_none(), "a held writer must make the nonblocking getter unavailable");
assert_eq!(published.as_deref(), Some("published-node"));
assert!(panicked.is_err(), "the callback panic must propagate after releasing the lock");
assert_eq!(after_unwind.as_deref(), Some("unwound-node"));
}
}
/// Set the global RustFS address used for gRPC connections.
///
/// # Arguments
+73 -14
View File
@@ -172,11 +172,41 @@ pub struct MrfUnverifiedLegacyEvent {
pub anchor: MrfDurableRepairAnchor,
}
#[derive(Clone, Copy)]
struct ResolvedMrfRepairIdentity {
version_id: Option<[u8; 16]>,
scope: Option<MrfScope>,
delete_marker_purge: Option<MrfDeleteMarkerPurgeIdentity>,
lease: MrfIngressLease,
bucket_incarnation_id: Uuid,
}
impl MrfDurableRepairAnchor {
/// Build a dischargeable anchor only when the caller supplies the storage
/// incarnation and the original ingress lease. Legacy replay records lack
/// both pieces and therefore remain fail-closed.
pub fn from_intent(intent: &MrfIntent, bucket_incarnation_id: Uuid) -> Option<Self> {
let identity = Self::resolve_identity(intent, bucket_incarnation_id)?;
Some(Self {
kind: intent.kind,
bucket: intent.bucket.clone(),
object: intent.object.clone(),
version_id: identity.version_id,
scope: identity.scope,
delete_marker_purge: identity.delete_marker_purge,
lease: identity.lease,
bucket_incarnation_id: identity.bucket_incarnation_id,
})
}
/// Check whether the intent can form an anchor for this exact source,
/// without retaining its bucket/object handles. This checks identity shape,
/// not storage verification or retirement authority.
pub fn is_eligible_for_source(intent: &MrfIntent, source: Uuid) -> bool {
Self::resolve_identity(intent, source).is_some_and(|identity| identity.bucket_incarnation_id == source)
}
fn resolve_identity(intent: &MrfIntent, bucket_incarnation_id: Uuid) -> Option<ResolvedMrfRepairIdentity> {
let lease = intent.lease?;
let (version_id, scope) = canonical_identity(intent.kind, intent.version_id, intent.scope);
let delete_marker_purge = canonical_delete_marker_purge_identity(intent)?;
@@ -186,10 +216,7 @@ impl MrfDurableRepairAnchor {
if bucket_incarnation_id.is_nil() {
return None;
}
Some(Self {
kind: intent.kind,
bucket: intent.bucket.clone(),
object: intent.object.clone(),
Some(ResolvedMrfRepairIdentity {
version_id,
scope,
delete_marker_purge,
@@ -233,24 +260,32 @@ pub fn consume_recorded_verified_mrf_repair_events_for(bucket: &str, anchors: &m
let Ok(mut events) = registry.lock() else {
return 0;
};
consume_verified_mrf_repair_events_from_queue(bucket, anchors, &mut events)
}
fn consume_verified_mrf_repair_events_from_queue(
bucket: &str,
anchors: &mut Vec<MrfDurableRepairAnchor>,
events: &mut std::collections::VecDeque<MrfVerifiedRepairEvent>,
) -> usize {
let before = anchors.len();
let mut retained = std::collections::VecDeque::with_capacity(events.len());
while let Some(event) = events.pop_front() {
let previous_event_count = events.len();
events.retain(|event| {
if event.bucket.as_ref() != bucket {
retained.push_back(event);
continue;
return true;
}
let mut matched = false;
anchors.retain(|anchor| {
let proven = anchor.is_proven_by(&event);
let proven = anchor.is_proven_by(event);
matched |= proven;
!proven
});
if !matched {
retained.push_back(event);
}
}
*events = retained;
!matched
});
// The old queue rebuild reserved the pre-consumption event count. Reuse
// that buffer when possible without extending its capacity lifetime:
// an emptied queue releases its backing storage on the next consumption.
events.shrink_to(previous_event_count);
before.saturating_sub(anchors.len())
}
@@ -821,6 +856,21 @@ static MRF_VERIFIED_REPAIR_EVENTS: OnceLock<std::sync::Mutex<std::collections::V
static MRF_UNVERIFIED_LEGACY_EVENTS: OnceLock<std::sync::Mutex<std::collections::VecDeque<MrfUnverifiedLegacyEvent>>> =
OnceLock::new();
/// Whether durable repair events may need consumption. This is only a work
/// hint: either queue can receive an event after its empty observation, and a
/// bounded queue can evict that event before the next consumer invocation.
/// An unavailable lock is unknown, so callers must keep their normal path.
/// An allocated empty verified queue also needs the normal consumer pass to
/// release its buffer; this hint must not extend that buffer's lifetime.
pub fn mrf_durable_repair_events_may_be_pending() -> bool {
MRF_VERIFIED_REPAIR_EVENTS
.get()
.is_some_and(|registry| registry.try_lock().map_or(true, |events| events.capacity() != 0))
|| MRF_UNVERIFIED_LEGACY_EVENTS
.get()
.is_some_and(|registry| registry.try_lock().map_or(true, |events| !events.is_empty()))
}
/// Record a legacy notification for compatibility. This is not an
/// acknowledgement of storage verification or durable repair completion.
pub fn note_mrf_repaired(bucket: &str, object: &str, version_id: Option<[u8; 16]>) {
@@ -935,6 +985,15 @@ pub fn take_mrf_unverified_legacy_events_for(bucket: &str) -> Vec<MrfUnverifiedL
taken
}
#[cfg(test)]
mod event_presence_tests;
#[cfg(test)]
mod proof_queue_tests;
#[cfg(test)]
mod repair_identity_tests;
#[cfg(test)]
mod tests {
use super::*;
@@ -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::*;
fn isolated(name: &str) -> bool {
const CHILD: &str = "RUSTFS_MRF_EVENT_PRESENCE_TEST";
let test = format!("mrf_channel::event_presence_tests::{name}");
if let Ok(current) = std::env::var(CHILD) {
assert_eq!(current, test, "only the selected registry test may run in the child");
return false;
}
let output = std::process::Command::new(std::env::current_exe().expect("test executable"))
.args([&test, "--exact", "--test-threads=1"])
.env(CHILD, &test)
.output()
.expect("run isolated global registry test");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
output.status.success() && stdout.contains("1 passed; 0 failed"),
"registry child must execute exactly once: {stdout}{}",
String::from_utf8_lossy(&output.stderr)
);
true
}
#[test]
fn event_presence_covers_both_queues_and_contention_without_consuming() {
if isolated("event_presence_covers_both_queues_and_contention_without_consuming") {
return;
}
assert!(!mrf_durable_repair_events_may_be_pending());
let anchor = MrfDurableRepairAnchor {
kind: MrfKind::PartialWrite,
bucket: Arc::from("presence-test"),
object: Arc::from("object"),
version_id: Some([7; 16]),
scope: Some(MrfScope {
pool_index: 0,
set_index: 0,
}),
delete_marker_purge: None,
lease: MrfIngressLease::new(21),
bucket_incarnation_id: Uuid::new_v4(),
};
let event = MrfVerifiedRepairEvent {
kind: anchor.kind,
bucket: anchor.bucket.clone(),
object: anchor.object.clone(),
version_id: anchor.version_id,
scope: anchor.scope,
delete_marker_purge: None,
lease: Some(anchor.lease),
bucket_incarnation_id: anchor.bucket_incarnation_id,
disposition: MrfVerifiedRepairDisposition::VerifiedHealthy,
};
note_mrf_verified_repair(event.clone());
assert!(mrf_durable_repair_events_may_be_pending());
assert!(mrf_durable_repair_events_may_be_pending(), "the hint must not consume a proof");
assert_eq!(take_mrf_verified_repair_events_for(&anchor.bucket), vec![event]);
assert!(
mrf_durable_repair_events_may_be_pending(),
"the empty allocated verified buffer still needs its original maintenance pass"
);
let mut retained = vec![anchor.clone()];
assert_eq!(consume_recorded_verified_mrf_repair_events_for(&anchor.bucket, &mut retained), 0);
assert_eq!(retained, vec![anchor.clone()], "buffer maintenance cannot discharge an anchor");
assert_eq!(
MRF_VERIFIED_REPAIR_EVENTS
.get()
.expect("verified registry")
.lock()
.expect("verified storage")
.capacity(),
0,
"the actual consumer releases empty proof storage before subsequent idle passes can skip it"
);
assert!(!mrf_durable_repair_events_may_be_pending());
let legacy = MrfUnverifiedLegacyEvent { anchor };
note_mrf_unverified_legacy(legacy.clone());
assert!(mrf_durable_repair_events_may_be_pending(), "legacy-only work must not be skipped");
assert_eq!(take_mrf_unverified_legacy_events_for(&legacy.anchor.bucket), vec![legacy]);
assert!(!mrf_durable_repair_events_may_be_pending());
let verified = MRF_VERIFIED_REPAIR_EVENTS.get().expect("verified registry initialized");
let guard = verified.lock().expect("hold verified registry");
assert!(mrf_durable_repair_events_may_be_pending(), "contended verified is unknown");
drop(guard);
let legacy = MRF_UNVERIFIED_LEGACY_EVENTS.get().expect("legacy registry initialized");
let guard = legacy.lock().expect("hold legacy registry");
assert!(mrf_durable_repair_events_may_be_pending(), "contended legacy is unknown");
drop(guard);
assert!(!mrf_durable_repair_events_may_be_pending());
}
#[test]
fn event_presence_keeps_poisoned_verified_unknown() {
if isolated("event_presence_keeps_poisoned_verified_unknown") {
return;
}
assert!(!mrf_durable_repair_events_may_be_pending());
let registry = MRF_VERIFIED_REPAIR_EVENTS.get_or_init(|| Mutex::new(Default::default()));
assert!(
std::panic::catch_unwind(|| {
let _guard = registry.lock().expect("unpoisoned registry");
panic!("poison the isolated verified registry");
})
.is_err()
);
assert!(mrf_durable_repair_events_may_be_pending());
assert!(registry.is_poisoned(), "the hint must not clear or recover poisoned state");
}
#[test]
fn event_presence_keeps_poisoned_legacy_unknown() {
if isolated("event_presence_keeps_poisoned_legacy_unknown") {
return;
}
assert!(!mrf_durable_repair_events_may_be_pending());
let registry = MRF_UNVERIFIED_LEGACY_EVENTS.get_or_init(|| Mutex::new(Default::default()));
assert!(
std::panic::catch_unwind(|| {
let _guard = registry.lock().expect("unpoisoned registry");
panic!("poison the isolated legacy registry");
})
.is_err()
);
assert!(mrf_durable_repair_events_may_be_pending());
assert!(registry.is_poisoned(), "the hint must not clear or recover poisoned state");
}
@@ -0,0 +1,304 @@
// 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::*;
use std::collections::VecDeque;
fn proof_fixture() -> (MrfDurableRepairAnchor, MrfVerifiedRepairEvent) {
let anchor = MrfDurableRepairAnchor {
kind: MrfKind::PartialWrite,
bucket: Arc::from("proof-queue"),
object: Arc::from("matched"),
version_id: Some([7; 16]),
scope: Some(MrfScope {
pool_index: 1,
set_index: 2,
}),
delete_marker_purge: None,
lease: MrfIngressLease::new(21),
bucket_incarnation_id: Uuid::new_v4(),
};
let event = MrfVerifiedRepairEvent {
kind: anchor.kind,
bucket: anchor.bucket.clone(),
object: anchor.object.clone(),
version_id: anchor.version_id,
scope: anchor.scope,
delete_marker_purge: anchor.delete_marker_purge,
lease: Some(anchor.lease),
bucket_incarnation_id: anchor.bucket_incarnation_id,
disposition: MrfVerifiedRepairDisposition::Repaired,
};
(anchor, event)
}
#[test]
fn proof_queue_reuses_unmatched_storage_in_contiguous_and_wrapped_layouts() {
for wrapped in [false, true] {
for foreign in [false, true] {
let (anchor, mut event) = proof_fixture();
if foreign {
event.bucket = Arc::from("another-bucket");
} else {
event.lease = None;
}
let mut events = VecDeque::with_capacity(8);
events.extend(std::iter::repeat_n(event, events.capacity()));
if wrapped {
let first = events.pop_front().expect("full local proof queue");
events.push_back(first);
assert!(!events.as_slices().1.is_empty(), "exercise a wrapped producer queue");
}
let capacity = events.capacity();
let storage = events
.iter()
.map(std::ptr::from_ref)
.min()
.expect("nonempty local proof storage");
let expected = events.clone();
let mut anchors = vec![anchor.clone()];
for _ in 0..4 {
assert_eq!(
consume_verified_mrf_repair_events_from_queue(&anchor.bucket, &mut anchors, &mut events),
0
);
assert_eq!(events, expected);
assert_eq!(anchors, vec![anchor.clone()]);
assert_eq!(events.capacity(), capacity);
assert_eq!(
events.iter().map(std::ptr::from_ref).min(),
Some(storage),
"a full unmatched queue must reuse its buffer instead of rebuilding it for every bucket"
);
}
}
}
}
#[test]
fn proof_queue_consumes_all_exact_anchor_copies_and_preserves_duplicate_proof_order() {
let (anchor, event) = proof_fixture();
let waiting = MrfDurableRepairAnchor {
object: Arc::from("waiting"),
..anchor.clone()
};
let foreign = MrfVerifiedRepairEvent {
bucket: Arc::from("foreign"),
..event.clone()
};
let unleased = MrfVerifiedRepairEvent {
lease: None,
..event.clone()
};
let future = MrfVerifiedRepairEvent {
object: Arc::from("future"),
..event.clone()
};
let mut events = VecDeque::from(vec![
foreign.clone(),
event.clone(),
unleased.clone(),
event.clone(),
future.clone(),
]);
let first = events.pop_front().expect("foreign proof starts the queue");
events.push_back(first);
assert!(!events.as_slices().1.is_empty(), "match and retain proofs across the wrap boundary");
let mut anchors = vec![anchor.clone(), waiting.clone(), anchor.clone()];
assert_eq!(
consume_verified_mrf_repair_events_from_queue(&anchor.bucket, &mut anchors, &mut events),
2
);
assert_eq!(anchors, vec![waiting]);
assert_eq!(
events,
VecDeque::from(vec![unleased, event, future, foreign]),
"the later duplicate proof remains available after the first proof consumes every exact anchor"
);
let retained = events.clone();
assert_eq!(
consume_verified_mrf_repair_events_from_queue(&anchor.bucket, &mut anchors, &mut events),
0
);
assert_eq!(events, retained, "a later consumer must still be able to match the retained proofs");
}
#[test]
fn proof_queue_keeps_every_incomplete_or_mismatched_identity() {
let (anchor, event) = proof_fixture();
let different_purge = MrfDeleteMarkerPurgeIdentity {
bucket_incarnation_id: anchor.bucket_incarnation_id,
marker_incarnation_id: anchor.bucket_incarnation_id,
marker_identity: [3; 32],
};
for rejected in [
MrfVerifiedRepairEvent {
kind: MrfKind::DecodeFailure,
..event.clone()
},
MrfVerifiedRepairEvent {
bucket: Arc::from("foreign"),
..event.clone()
},
MrfVerifiedRepairEvent {
object: Arc::from("other"),
..event.clone()
},
MrfVerifiedRepairEvent {
version_id: None,
..event.clone()
},
MrfVerifiedRepairEvent {
version_id: Some([8; 16]),
..event.clone()
},
MrfVerifiedRepairEvent {
scope: None,
..event.clone()
},
MrfVerifiedRepairEvent {
scope: Some(MrfScope {
pool_index: 2,
set_index: 2,
}),
..event.clone()
},
MrfVerifiedRepairEvent {
scope: Some(MrfScope {
pool_index: 1,
set_index: 3,
}),
..event.clone()
},
MrfVerifiedRepairEvent {
lease: None,
..event.clone()
},
MrfVerifiedRepairEvent {
lease: Some(MrfIngressLease::new(22)),
..event.clone()
},
MrfVerifiedRepairEvent {
bucket_incarnation_id: Uuid::new_v4(),
..event.clone()
},
MrfVerifiedRepairEvent {
delete_marker_purge: Some(different_purge),
..event
},
] {
let mut anchors = vec![anchor.clone()];
let mut events = VecDeque::from([rejected.clone()]);
assert_eq!(
consume_verified_mrf_repair_events_from_queue(&anchor.bucket, &mut anchors, &mut events),
0
);
assert_eq!(anchors, vec![anchor.clone()]);
assert_eq!(events, VecDeque::from([rejected]));
}
}
#[test]
fn proof_queue_requires_the_complete_delete_marker_purge_identity() {
let (mut anchor, mut event) = proof_fixture();
let purge = MrfDeleteMarkerPurgeIdentity {
bucket_incarnation_id: anchor.bucket_incarnation_id,
marker_incarnation_id: anchor.bucket_incarnation_id,
marker_identity: [3; 32],
};
anchor.kind = MrfKind::DeleteMarkerPurge;
anchor.delete_marker_purge = Some(purge);
event.kind = anchor.kind;
event.delete_marker_purge = anchor.delete_marker_purge;
for rejected in [
None,
Some(MrfDeleteMarkerPurgeIdentity {
bucket_incarnation_id: Uuid::new_v4(),
..purge
}),
Some(MrfDeleteMarkerPurgeIdentity {
marker_incarnation_id: Uuid::new_v4(),
..purge
}),
Some(MrfDeleteMarkerPurgeIdentity {
marker_identity: [4; 32],
..purge
}),
] {
let rejected = MrfVerifiedRepairEvent {
delete_marker_purge: rejected,
..event.clone()
};
let mut anchors = vec![anchor.clone()];
let mut events = VecDeque::from([rejected.clone(), event.clone()]);
assert_eq!(
consume_verified_mrf_repair_events_from_queue(&anchor.bucket, &mut anchors, &mut events),
1
);
assert!(anchors.is_empty());
assert_eq!(events, VecDeque::from([rejected]));
}
}
#[test]
fn proof_queue_capacity_follows_the_preconsumption_length_and_releases_on_the_next_empty_call() {
let (anchor, event) = proof_fixture();
let future_events = (0..3)
.map(|index| MrfVerifiedRepairEvent {
object: Arc::from(format!("future-{index}")),
..event.clone()
})
.collect::<Vec<_>>();
let mut events = VecDeque::with_capacity(64);
events.push_back(event);
events.extend(future_events.iter().cloned());
let previous_event_count = events.len();
let mut anchors = vec![anchor.clone()];
assert_eq!(
consume_verified_mrf_repair_events_from_queue(&anchor.bucket, &mut anchors, &mut events),
1
);
assert_eq!(events.iter().cloned().collect::<Vec<_>>(), future_events);
assert_eq!(
events.capacity(),
VecDeque::<MrfVerifiedRepairEvent>::with_capacity(previous_event_count).capacity(),
"preserve the former queue rebuild's capacity lifetime instead of retaining the producer high water"
);
let previous_event_count = events.len();
let mut anchors = future_events
.iter()
.map(|event| MrfDurableRepairAnchor {
object: event.object.clone(),
..anchor.clone()
})
.collect();
assert_eq!(
consume_verified_mrf_repair_events_from_queue(&anchor.bucket, &mut anchors, &mut events),
3
);
assert!(events.is_empty());
assert_eq!(
events.capacity(),
VecDeque::<MrfVerifiedRepairEvent>::with_capacity(previous_event_count).capacity()
);
assert_eq!(
consume_verified_mrf_repair_events_from_queue(&anchor.bucket, &mut anchors, &mut events),
0
);
assert_eq!(
events.capacity(),
0,
"the next empty consumption releases backing storage just as a rebuild did"
);
}
@@ -0,0 +1,256 @@
// 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::*;
// Compatibility reference from a47649fb60c0492cc0fb02bff28439c5e357f8e4.
// Keep this independent of the shared resolver and its canonical helpers.
fn original_anchor(intent: &MrfIntent, bucket_incarnation_id: Uuid) -> Option<MrfDurableRepairAnchor> {
let lease = intent.lease?;
let version_id = intent.version_id.filter(|bytes| *bytes != [0; 16]);
let (version_id, scope) = match intent.kind {
MrfKind::MetadataCorruption => (None, None),
MrfKind::DecodeFailure | MrfKind::PartialWrite | MrfKind::DeleteMarkerPurge => (version_id, intent.scope),
};
let delete_marker_purge = match intent.kind {
MrfKind::DeleteMarkerPurge => intent
.delete_marker_purge
.as_ref()
.filter(|payload| {
!payload.bucket_incarnation_id.is_nil()
&& payload.marker_incarnation_id == payload.bucket_incarnation_id
&& !payload.marker.is_empty()
&& payload.marker.len() <= MRF_MAX_DELETE_MARKER_BYTES
})
.map(|payload| {
Some(MrfDeleteMarkerPurgeIdentity {
bucket_incarnation_id: payload.bucket_incarnation_id,
marker_incarnation_id: payload.marker_incarnation_id,
marker_identity: payload.marker_identity,
})
})?,
_ if intent.delete_marker_purge.is_none() => None,
_ => return None,
};
let bucket_incarnation_id = delete_marker_purge
.map(|identity| identity.bucket_incarnation_id)
.unwrap_or(bucket_incarnation_id);
if bucket_incarnation_id.is_nil() {
return None;
}
Some(MrfDurableRepairAnchor {
kind: intent.kind,
bucket: intent.bucket.clone(),
object: intent.object.clone(),
version_id,
scope,
delete_marker_purge,
lease,
bucket_incarnation_id,
})
}
#[test]
fn shared_repair_identity_matches_the_original_constructor_and_source_filter() {
let source = Uuid::from_u128(1);
let foreign = Uuid::from_u128(2);
let purge = MrfDeleteMarkerPurge {
bucket_incarnation_id: source,
marker_incarnation_id: source,
marker_identity: [9; 32],
marker: Arc::from([7_u8]),
};
let payloads = [
None,
Some(purge.clone()),
Some(MrfDeleteMarkerPurge {
bucket_incarnation_id: foreign,
marker_incarnation_id: foreign,
..purge.clone()
}),
Some(MrfDeleteMarkerPurge {
marker: Arc::from([]),
..purge.clone()
}),
Some(MrfDeleteMarkerPurge {
marker: Arc::from(vec![7; MRF_MAX_DELETE_MARKER_BYTES]),
..purge.clone()
}),
Some(MrfDeleteMarkerPurge {
marker: Arc::from(vec![7; MRF_MAX_DELETE_MARKER_BYTES + 1]),
..purge.clone()
}),
Some(MrfDeleteMarkerPurge {
marker_incarnation_id: foreign,
..purge.clone()
}),
Some(MrfDeleteMarkerPurge {
bucket_incarnation_id: Uuid::nil(),
marker_incarnation_id: Uuid::nil(),
..purge
}),
];
let mut cases = 0;
for kind in [
MrfKind::DecodeFailure,
MrfKind::MetadataCorruption,
MrfKind::PartialWrite,
MrfKind::DeleteMarkerPurge,
] {
for lease in [None, Some(MrfIngressLease::new(0)), Some(MrfIngressLease::new(7))] {
for version_id in [None, Some([0; 16]), Some([7; 16])] {
for scope in [
None,
Some(MrfScope {
pool_index: u32::MAX,
set_index: 3,
}),
] {
for input_source in [Uuid::nil(), source, foreign] {
for (payload_index, payload) in payloads.iter().enumerate() {
let intent = MrfIntent {
bucket: Arc::from("source-shape"),
object: Arc::from("object"),
version_id,
kind,
delete_marker_purge: payload.clone(),
scope,
lease,
enqueued_at_ms: 0,
attempts: 0,
};
let expected = original_anchor(&intent, input_source);
assert_eq!(
MrfDurableRepairAnchor::from_intent(&intent, input_source),
expected,
"kind={kind:?}, lease={lease:?}, version={version_id:?}, scope={scope:?}, source={input_source}, payload={payload_index}"
);
assert_eq!(
MrfDurableRepairAnchor::is_eligible_for_source(&intent, input_source),
expected
.as_ref()
.is_some_and(|anchor| anchor.bucket_incarnation_id == input_source),
"source eligibility must preserve the exact source: kind={kind:?}, lease={lease:?}, version={version_id:?}, scope={scope:?}, source={input_source}, payload={payload_index}"
);
cases += 1;
}
}
}
}
}
}
assert_eq!(cases, 1728, "exercise the complete explicit identity-shape matrix");
}
#[test]
fn repair_identity_preserves_literal_purge_source_lease_and_metadata_boundaries() {
let source = Uuid::from_u128(1);
let purge_source = Uuid::from_u128(2);
let mut intent = MrfIntent {
bucket: Arc::from("literal-bucket"),
object: Arc::from("marker"),
version_id: None,
kind: MrfKind::DeleteMarkerPurge,
delete_marker_purge: Some(MrfDeleteMarkerPurge {
bucket_incarnation_id: purge_source,
marker_incarnation_id: purge_source,
marker_identity: [9; 32],
marker: Arc::from([7_u8]),
}),
scope: None,
lease: Some(MrfIngressLease::new(17)),
enqueued_at_ms: 0,
attempts: 0,
};
assert!(
!valid_intent_payload(&intent),
"constructor compatibility is broader than durable admission"
);
assert_eq!(
MrfDurableRepairAnchor::from_intent(&intent, Uuid::nil()),
Some(MrfDurableRepairAnchor {
kind: MrfKind::DeleteMarkerPurge,
bucket: Arc::from("literal-bucket"),
object: Arc::from("marker"),
version_id: None,
scope: None,
delete_marker_purge: Some(MrfDeleteMarkerPurgeIdentity {
bucket_incarnation_id: purge_source,
marker_incarnation_id: purge_source,
marker_identity: [9; 32],
}),
lease: MrfIngressLease::new(17),
bucket_incarnation_id: purge_source,
})
);
assert!(
!MrfDurableRepairAnchor::is_eligible_for_source(&intent, source),
"a purge cannot borrow a conflicting entry source"
);
assert!(!MrfDurableRepairAnchor::is_eligible_for_source(&intent, Uuid::nil()));
assert!(MrfDurableRepairAnchor::is_eligible_for_source(&intent, purge_source));
intent.lease = None;
assert!(MrfDurableRepairAnchor::from_intent(&intent, purge_source).is_none());
assert!(!MrfDurableRepairAnchor::is_eligible_for_source(&intent, purge_source));
intent.lease = Some(MrfIngressLease::new(17));
intent
.delete_marker_purge
.as_mut()
.expect("purge fixture")
.marker_incarnation_id = Uuid::nil();
assert!(MrfDurableRepairAnchor::from_intent(&intent, purge_source).is_none());
intent
.delete_marker_purge
.as_mut()
.expect("purge fixture")
.bucket_incarnation_id = Uuid::nil();
assert!(
!MrfDurableRepairAnchor::is_eligible_for_source(&intent, source),
"nil marker identity cannot borrow a non-nil source"
);
intent.kind = MrfKind::PartialWrite;
assert!(
MrfDurableRepairAnchor::from_intent(&intent, source).is_none(),
"ordinary intents cannot carry a purge payload"
);
intent.delete_marker_purge = None;
intent.kind = MrfKind::MetadataCorruption;
intent.version_id = Some([7; 16]);
intent.scope = Some(MrfScope {
pool_index: 8,
set_index: 9,
});
assert_eq!(
MrfDurableRepairAnchor::from_intent(&intent, source),
Some(MrfDurableRepairAnchor {
kind: MrfKind::MetadataCorruption,
bucket: Arc::from("literal-bucket"),
object: Arc::from("marker"),
version_id: None,
scope: None,
delete_marker_purge: None,
lease: MrfIngressLease::new(17),
bucket_incarnation_id: source,
})
);
assert!(MrfDurableRepairAnchor::is_eligible_for_source(&intent, source));
assert!(MrfDurableRepairAnchor::from_intent(&intent, Uuid::nil()).is_none());
intent.lease = Some(MrfIngressLease::new(0));
assert_eq!(
MrfDurableRepairAnchor::from_intent(&intent, source)
.expect("preserve the existing opaque lease shape")
.lease,
MrfIngressLease::new(0)
);
}
+957
View File
@@ -0,0 +1,957 @@
// Copyright 2026 RustFS Team
// Licensed under the Apache License, Version 2.0.
//! Mutable admission capacity without replacing or revoking held permits.
use std::num::NonZeroU16;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
use std::time::Duration;
use tokio::sync::{Notify, Semaphore};
use tokio::time::Instant;
/// A resource owner publishes a capacity; running units retain their permits
/// after a reduction. Lease slots are reserved once, with no per-unit Arc
/// allocation. Tokio wait queues may lazily initialize platform mutex storage
/// on first contention. It does not discover devices, start
/// workers, or decide resource policy. Cancellation drops the acquire future.
///
/// Uncontended admission is atomic. Parked work takes a FIFO admission turn,
/// so repeated fast acquisitions cannot starve an older waiter. Source/age
/// priority still belongs to the caller's scheduler, before joining this queue.
pub struct AdaptiveAdmission {
// One CAS observes both capacity and active count, so a concurrent shrink
// cannot admit a new unit using an already superseded capacity.
state: AtomicU64,
origin: Instant,
valid_until_millis: AtomicU64,
// Only the FIFO turn holder subscribes. A stored notification is harmless:
// capacity and freshness are always checked again after waking.
changed: Notify,
queued: AtomicUsize,
// FIFO admission turn, not a mutable-state lock. No storage lock or
// admitted permit may be held while joining. This private semaphore is
// never closed; release and resize do not acquire it.
turn: Semaphore,
maximum: u32,
slots: Box<[PermitSlots]>,
next_chunk: AtomicUsize,
}
struct PermitSlots {
free: AtomicU64,
references: [AtomicUsize; 64],
}
struct QueuedAdmission<'a>(&'a AdaptiveAdmission);
impl Drop for QueuedAdmission<'_> {
fn drop(&mut self) {
self.0.queued.fetch_sub(1, Ordering::AcqRel);
}
}
/// One active unit. Clones share that unit without allocating; the final drop
/// returns it to the same owner, including after resize or waiter cancellation.
#[must_use = "hold the permit for the admitted unit's complete lifetime"]
pub struct AdaptivePermit {
owner: Arc<AdaptiveAdmission>,
slot: usize,
}
impl Clone for AdaptivePermit {
fn clone(&self) -> Self {
// Every slot reference owns a strong owner reference. Clone the Arc
// first, so its overflow protection also bounds the slot refcount.
let owner = Arc::clone(&self.owner);
owner.slots[self.slot / 64].references[self.slot % 64].fetch_add(1, Ordering::Relaxed);
Self { owner, slot: self.slot }
}
}
impl std::fmt::Debug for AdaptivePermit {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AdaptivePermit")
.field("slot", &self.slot)
.field("owner", &self.owner.snapshot())
.finish()
}
}
/// One coherent view of configured capacity and actual active units.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AdaptiveAdmissionSnapshot {
/// Current cap on new admissions. Zero pauses all new units.
pub limit: u32,
/// Existing units, which may temporarily exceed a reduced limit.
pub active: u32,
}
/// Node scheduling capacity, separate from physical I/O and buffer ownership.
/// Its non-cloneable units end with the async operation; a native tail must
/// retain its own physical-resource unit, never this scheduling unit.
pub struct NodeAdmission {
capacity: Arc<AdaptiveAdmission>,
completed: AtomicU64,
}
/// One async operation's scheduling unit. Dropping a cancelled or failed
/// operation releases capacity without claiming completed work.
#[must_use = "retain the unit until the async operation ends"]
pub struct NodePermit {
_capacity: AdaptivePermit,
owner: Arc<NodeAdmission>,
}
impl NodeAdmission {
/// Construct one node owner with no capacity until a resource sample arrives.
pub fn new(maximum: NonZeroU16) -> Arc<Self> {
Arc::new(Self {
capacity: AdaptiveAdmission::new(maximum, 0),
completed: AtomicU64::new(0),
})
}
/// Publish feedback with this owner's actual completed-work counter.
pub fn update(&self, controller: &mut FeedbackController, observation: Option<FeedbackObservation>) -> FeedbackDecision {
let observation = observation.map(|mut observation| {
observation.completed_units = self.completed();
observation
});
controller.update(&self.capacity, observation)
}
/// Current leased scheduling cap and held async units.
pub fn snapshot(&self) -> AdaptiveAdmissionSnapshot {
self.capacity.snapshot()
}
/// Completed operations, or unknown after the counter exhausts its range.
pub fn completed(&self) -> Option<u64> {
let value = self.completed.load(Ordering::Acquire);
(value != u64::MAX).then_some(value)
}
/// Try without waiting, including when the caller already owns a lock.
pub fn try_acquire(self: &Arc<Self>) -> Option<NodePermit> {
self.capacity.try_acquire().map(|capacity| NodePermit {
_capacity: capacity,
owner: self.clone(),
})
}
/// Wait in FIFO order. Callers must provide a deadline/cancellation and
/// acquire before storage locks, not while retaining another node unit.
pub async fn acquire(self: &Arc<Self>) -> NodePermit {
NodePermit {
_capacity: self.capacity.acquire().await,
owner: self.clone(),
}
}
}
impl NodePermit {
/// Count a completed operation exactly once, then release its node unit.
/// This counter is scheduling progress, not an authoritative heal receipt.
pub fn complete(self) {
let _ = self
.owner
.completed
.try_update(Ordering::AcqRel, Ordering::Acquire, |value| value.checked_add(1));
}
}
/// Confidence-qualified pressure for one complete sampling window.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ResourcePressure {
/// Missing, stale, reset, or insufficient observations; never evidence of idle.
Unknown,
/// A resource or foreground-protection threshold was reached.
Busy,
/// All required observations were fresh and below their low watermarks.
Stable,
}
/// Resource-owner input to the controller, after applying operator and kernel
/// caps. This describes one owner; callers must not duplicate a device's budget
/// for aliases, pools, or remote requesters.
#[derive(Debug, Clone, Copy)]
pub struct FeedbackObservation {
/// Identity of the resource/telemetry generation. Replacement resets probes.
pub generation: u64,
/// Upper bound imposed by resources and explicit configuration; zero blocks.
pub max_concurrency: u32,
/// Complete-window pressure, including the confidence of its sources.
pub pressure: ResourcePressure,
/// Cumulative completed work in this generation, not just scheduled work.
pub completed_units: Option<u64>,
}
/// Why the latest concurrency limit was selected.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FeedbackReason {
/// The resource cap is unavailable; no new unit can be bounded safely.
Unavailable,
/// A changed owner or reset work counter requires a new baseline.
NewBaseline,
/// The resource or operator cap reduced the permitted concurrency.
CapReduced,
/// Pressure caused an immediate multiplicative reduction.
Pressure,
/// Incomplete telemetry retains at most one maintenance unit.
LowConfidence,
/// Waiting for consecutive stable windows, work progress and cooldown.
Recovering,
/// One additional unit is being probed after a stable recovery period.
Probe,
}
/// One controller decision, suitable for metrics without dynamic labels.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct FeedbackDecision {
/// New admissions use this cap; existing permits remain valid.
pub limit: u32,
/// Decision cause, distinct from a proof of work completion or storage health.
pub reason: FeedbackReason,
}
/// Single-owner additive probe/multiplicative decrease controller. It adjusts
/// only concurrency. Every source must be fresh before declaring `Stable`.
///
/// The sampler runs every second. A gap over two seconds resets recovery; eight
/// consecutive stable seconds and observed completed work allow one extra unit.
/// Pressure or lower caps apply immediately. This policy is not a performance
/// claim; its parameters require the same workload/foreground-SLO acceptance.
pub struct FeedbackController {
previous: Option<(Instant, FeedbackObservation)>,
stable_since: Option<Instant>,
last_change: Instant,
limit: u32,
}
impl Default for FeedbackController {
fn default() -> Self {
Self::new()
}
}
impl FeedbackController {
/// Start without a resource observation or an admission grant.
pub fn new() -> Self {
Self {
previous: None,
stable_since: None,
last_change: Instant::now(),
limit: 0,
}
}
/// Consume one sample and publish a three-second lease to the same gate.
/// If this function stops being called, admission expires independently.
pub fn update(&mut self, gate: &AdaptiveAdmission, observation: Option<FeedbackObservation>) -> FeedbackDecision {
let observation = observation.map(|mut observation| {
observation.max_concurrency = observation.max_concurrency.min(gate.maximum);
observation
});
let decision = self.decide(Instant::now(), observation);
gate.set_sampled_limit(decision.limit, Duration::from_secs(3));
decision
}
fn decide(&mut self, now: Instant, observation: Option<FeedbackObservation>) -> FeedbackDecision {
let Some(observation) = observation else {
self.previous = None;
self.stable_since = None;
self.last_change = now;
self.limit = 0;
return FeedbackDecision {
limit: 0,
reason: FeedbackReason::Unavailable,
};
};
let previous = self.previous.replace((now, observation));
let contiguous = previous.is_some_and(|(at, old)| {
now.saturating_duration_since(at) <= Duration::from_secs(2)
&& old.generation == observation.generation
&& matches!((old.completed_units, observation.completed_units), (Some(a), Some(b)) if b >= a)
});
let advanced = previous
.is_some_and(|(_, old)| matches!((old.completed_units, observation.completed_units), (Some(a), Some(b)) if b > a));
let old_limit = self.limit;
let reason = if observation.max_concurrency == 0 {
self.limit = 0;
self.stable_since = None;
FeedbackReason::CapReduced
} else if observation.pressure == ResourcePressure::Busy {
self.limit = (self.limit / 2).max(1).min(observation.max_concurrency);
self.stable_since = None;
// Repeated busy windows restart cooldown, even at the minimum cap.
self.last_change = now;
FeedbackReason::Pressure
} else if observation.max_concurrency < self.limit {
self.limit = observation.max_concurrency;
self.stable_since = None;
FeedbackReason::CapReduced
} else if observation.pressure == ResourcePressure::Unknown || observation.completed_units.is_none() {
self.limit = 1.min(observation.max_concurrency);
self.stable_since = None;
FeedbackReason::LowConfidence
} else if !contiguous || self.limit == 0 {
self.limit = 1.min(observation.max_concurrency);
self.stable_since = None;
self.last_change = now;
FeedbackReason::NewBaseline
} else {
let since = *self.stable_since.get_or_insert(now);
if advanced
&& now.saturating_duration_since(since) >= Duration::from_secs(8)
&& now.saturating_duration_since(self.last_change) >= Duration::from_secs(8)
&& self.limit < observation.max_concurrency
{
self.limit += 1;
FeedbackReason::Probe
} else {
FeedbackReason::Recovering
}
};
if old_limit != self.limit {
self.last_change = now;
}
FeedbackDecision {
limit: self.limit,
reason,
}
}
}
impl AdaptiveAdmission {
/// Construct a bounded owner, with zero initial capacity allowed for unknown
/// input. The immutable maximum reserves at most 65,535 lease slots once;
/// acquiring or cloning a permit does not allocate a per-unit Arc.
pub fn new(maximum: NonZeroU16, limit: u32) -> Arc<Self> {
let maximum_u32 = u32::from(maximum.get());
let maximum = usize::from(maximum.get());
let slots = (0..maximum.div_ceil(64))
.map(|chunk| {
let count = (maximum - chunk * 64).min(64);
PermitSlots {
free: AtomicU64::new(if count == 64 { u64::MAX } else { (1_u64 << count) - 1 }),
references: std::array::from_fn(|_| AtomicUsize::new(0)),
}
})
.collect::<Vec<_>>()
.into_boxed_slice();
Arc::new(Self {
state: AtomicU64::new(u64::from(limit.min(maximum_u32)) << 32),
origin: Instant::now(),
valid_until_millis: AtomicU64::new(u64::MAX),
changed: Notify::new(),
queued: AtomicUsize::new(0),
turn: Semaphore::new(1),
maximum: maximum_u32,
slots,
next_chunk: AtomicUsize::new(0),
})
}
/// Publish a cap bounded by the immutable maximum, without losing held work
/// or changing owner identity.
pub fn set_limit(&self, limit: u32) {
let limit = limit.min(self.maximum);
let _ = self.state.try_update(Ordering::AcqRel, Ordering::Acquire, |state| {
Some((u64::from(limit) << 32) | (state & u64::from(u32::MAX)))
});
self.changed.notify_one();
}
/// Publish a cap with a finite observation lifetime. Expired observations
/// block new admissions even if the sampling worker has stopped. Existing
/// permits are retained. A single resource owner must serialize publishers.
///
/// Capacity is published before freshness, so a concurrent reader can
/// temporarily reject a fresh update but cannot use an increased lifetime
/// with the previous, potentially larger capacity.
pub fn set_sampled_limit(&self, limit: u32, valid_for: Duration) {
self.set_limit(limit);
let deadline = self
.elapsed_millis()
.saturating_add(u64::try_from(valid_for.as_millis()).unwrap_or(u64::MAX));
self.valid_until_millis.store(deadline, Ordering::Release);
self.changed.notify_one();
}
fn elapsed_millis(&self) -> u64 {
u64::try_from(self.origin.elapsed().as_millis()).unwrap_or(u64::MAX)
}
fn fresh(&self) -> bool {
self.elapsed_millis() < self.valid_until_millis.load(Ordering::Acquire)
}
/// Read the cap and active count from the same atomic observation.
pub fn snapshot(&self) -> AdaptiveAdmissionSnapshot {
// Acquire freshness first: a newly published lease must not be paired
// with capacity loaded before its preceding reduction.
let fresh = self.fresh();
let state = self.state.load(Ordering::Acquire);
AdaptiveAdmissionSnapshot {
limit: if fresh {
u32::try_from(state >> 32).unwrap_or(u32::MAX)
} else {
0
},
active: u32::try_from(state & u64::from(u32::MAX)).unwrap_or(u32::MAX),
}
}
/// Admit immediately if capacity remains, without a per-unit allocation.
/// The uncontended path uses atomics; refunding a racing acquisition may
/// wake the FIFO head through Tokio's notification queue.
pub fn try_acquire(self: &Arc<Self>) -> Option<AdaptivePermit> {
if self.queued.load(Ordering::Acquire) != 0 {
return None;
}
let permit = self.acquire_capacity()?;
// A waiter can register between the first queue check and the capacity
// CAS. Refund before exposing the permit rather than stealing its turn.
(self.queued.load(Ordering::Acquire) == 0).then_some(permit)
}
fn acquire_capacity(self: &Arc<Self>) -> Option<AdaptivePermit> {
if !self.fresh() {
return None;
}
let state = self.state.load(Ordering::Acquire);
if state & u64::from(u32::MAX) >= state >> 32 {
return None;
}
let slot = self.reserve_slot()?;
if self
.state
.try_update(Ordering::AcqRel, Ordering::Acquire, |state| {
let active = state & u64::from(u32::MAX);
(active < state >> 32).then(|| state + 1)
})
.is_err()
{
self.release_slot(slot);
self.changed.notify_one();
return None;
}
self.slots[slot / 64].references[slot % 64].store(1, Ordering::Release);
let permit = AdaptivePermit {
owner: self.clone(),
slot,
};
// The capacity CAS can retry across an observation expiry. Recheck
// before giving ownership to the caller; dropping refunds this unit.
self.fresh().then_some(permit)
}
fn reserve_slot(&self) -> Option<usize> {
let first = self.next_chunk.fetch_add(1, Ordering::Relaxed) % self.slots.len();
for offset in 0..self.slots.len() {
let chunk_index = (first + offset) % self.slots.len();
let free = &self.slots[chunk_index].free;
if let Ok(bits) = free.try_update(Ordering::AcqRel, Ordering::Acquire, |bits| (bits != 0).then(|| bits & (bits - 1)))
{
// trailing_zeros is in 0..64 because the zero word is rejected.
return Some(chunk_index * 64 + bits.trailing_zeros() as usize);
}
}
None
}
fn release_slot(&self, slot: usize) {
self.slots[slot / 64].free.fetch_or(1_u64 << (slot % 64), Ordering::Release);
}
/// Wait for capacity. The future owns no permit until it returns; dropping
/// a pending future leaves capacity untouched. Wrap in the caller's existing
/// cancellation/deadline select before acquiring any storage commit locks.
pub async fn acquire(self: &Arc<Self>) -> AdaptivePermit {
if let Some(permit) = self.try_acquire() {
return permit;
}
self.queued.fetch_add(1, Ordering::AcqRel);
let _queued = QueuedAdmission(self);
// Retain the successful permit inside its Result. No code can close
// this private semaphore, so acquisition cannot return an error.
let _turn = self.turn.acquire().await;
loop {
let notified = self.changed.notified();
tokio::pin!(notified);
// Register before checking capacity to avoid a lost release/resize.
notified.as_mut().enable();
if let Some(permit) = self.acquire_capacity() {
return permit;
}
notified.await;
}
}
}
impl Drop for AdaptivePermit {
fn drop(&mut self) {
if self.owner.slots[self.slot / 64].references[self.slot % 64].fetch_sub(1, Ordering::AcqRel) != 1 {
return;
}
// Publish the free slot before exposing another admission capacity.
self.owner.release_slot(self.slot);
self.owner.state.fetch_sub(1, Ordering::AcqRel);
self.owner.changed.notify_one();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn node_progress_requires_explicit_completion_and_never_wraps() {
let node = NodeAdmission::new(NonZeroU16::new(2).expect("two slots"));
node.capacity.set_limit(2);
drop(node.acquire().await);
assert_eq!(node.completed(), Some(0), "cancellation is not progress");
node.acquire().await.complete();
assert_eq!(node.completed(), Some(1));
node.completed.store(u64::MAX - 1, Ordering::Release);
node.acquire().await.complete();
node.acquire().await.complete();
assert_eq!(node.completed(), None, "exhausted progress stays unknown");
assert_eq!(node.snapshot().active, 0);
}
#[tokio::test]
async fn cancelled_node_work_does_not_reserve_capacity_for_unrelated_native_tail() {
let node = NodeAdmission::new(NonZeroU16::new(1).expect("one slot"));
node.capacity.set_limit(1);
let physical = AdaptiveAdmission::new(NonZeroU16::new(1).expect("one disk unit"), 1);
let scheduling = node.acquire().await;
let native_tail = physical.acquire().await;
drop(scheduling);
assert_eq!(node.completed(), Some(0));
assert!(node.try_acquire().is_some(), "healthy resources can still be scheduled");
assert!(physical.try_acquire().is_none(), "the stalled disk remains charged");
drop(native_tail);
assert!(physical.try_acquire().is_some());
}
#[tokio::test(start_paused = true)]
async fn node_feedback_expires_without_revoking_held_work_or_fabricating_progress() {
let node = NodeAdmission::new(NonZeroU16::new(2).expect("two slots"));
let mut controller = FeedbackController::new();
let input = FeedbackObservation {
generation: 1,
max_concurrency: 2,
pressure: ResourcePressure::Stable,
completed_units: Some(99),
};
node.update(&mut controller, Some(input));
let held = node.acquire().await;
assert_eq!(node.completed(), Some(0));
tokio::time::advance(Duration::from_secs(4)).await;
assert_eq!(node.snapshot(), AdaptiveAdmissionSnapshot { limit: 0, active: 1 });
assert!(node.try_acquire().is_none());
held.complete();
assert_eq!(node.completed(), Some(1));
assert_eq!(node.snapshot().active, 0);
}
#[tokio::test]
async fn shared_unit_stays_admitted_until_native_worker_releases_its_last_clone() {
let gate = AdaptiveAdmission::new(NonZeroU16::new(1).expect("one unit"), 1);
let outer = gate.acquire().await;
let native = outer.clone();
let (started_tx, started_rx) = tokio::sync::oneshot::channel();
let (release_tx, release_rx) = tokio::sync::oneshot::channel();
let worker = tokio::task::spawn_blocking(move || {
started_tx.send(()).expect("worker started");
release_rx.blocking_recv().expect("release native worker");
drop(native);
});
started_rx.await.expect("native worker is live");
drop(outer);
assert_eq!(gate.snapshot().active, 1);
assert!(gate.try_acquire().is_none());
gate.set_limit(0);
assert_eq!(gate.snapshot().active, 1);
release_tx.send(()).expect("release tail");
worker.await.expect("native tail completed");
assert_eq!(gate.snapshot().active, 0);
gate.set_limit(1);
assert!(gate.try_acquire().is_some());
}
#[tokio::test]
async fn bounded_slots_cross_word_boundaries_and_recycle_only_after_final_drop() {
let maximum = NonZeroU16::new(65).expect("partial second chunk");
let gate = AdaptiveAdmission::new(maximum, u32::MAX);
assert_eq!(gate.snapshot().limit, 65);
for _ in 0..4 {
let units: Vec<_> = (0..65).map(|_| gate.try_acquire().expect("free slot")).collect();
let tails = units.clone();
assert_eq!(gate.snapshot().active, 65);
assert!(gate.try_acquire().is_none());
drop(units);
assert_eq!(gate.snapshot().active, 65);
gate.set_limit(0);
for (remaining, tail) in (0..65).rev().zip(tails) {
drop(tail);
assert_eq!(gate.snapshot().active, remaining);
}
gate.set_limit(u32::MAX);
assert_eq!(gate.snapshot().limit, 65);
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn shared_units_survive_concurrent_clone_drop_reuse_and_resize() {
let gate = AdaptiveAdmission::new(NonZeroU16::new(8).expect("bounded owner"), 8);
let mut workers = Vec::new();
for _ in 0..16 {
let gate = gate.clone();
workers.push(tokio::spawn(async move {
for _ in 0..64 {
let root = gate.acquire().await;
let one = root.clone();
let two = one.clone();
drop(root);
tokio::task::yield_now().await;
drop(two);
tokio::task::yield_now().await;
drop(one);
}
}));
}
for limit in [0, 1, 8, 2, 0, 8] {
gate.set_limit(limit);
tokio::task::yield_now().await;
}
tokio::time::timeout(Duration::from_secs(5), async {
for worker in workers {
worker.await.expect("lease worker");
}
})
.await
.expect("all lease workers complete");
assert_eq!(gate.snapshot(), AdaptiveAdmissionSnapshot { limit: 8, active: 0 });
let full: Vec<_> = (0..8).map(|_| gate.try_acquire().expect("all slots returned")).collect();
assert!(gate.try_acquire().is_none());
drop(full);
}
#[tokio::test]
async fn queued_work_cannot_be_starved_by_new_fast_acquisitions() {
use std::task::{Context, Poll, Waker};
let gate = AdaptiveAdmission::new(NonZeroU16::new(8).expect("bounded test owner"), 1);
let held = gate.acquire().await;
let mut first = Box::pin(gate.acquire());
let mut second = Box::pin(gate.acquire());
let mut context = Context::from_waker(Waker::noop());
assert!(first.as_mut().poll(&mut context).is_pending());
assert!(second.as_mut().poll(&mut context).is_pending());
drop(held);
assert!(gate.try_acquire().is_none(), "new work must not steal a parked worker's turn");
assert!(second.as_mut().poll(&mut context).is_pending(), "later work cannot pass the queue head");
let Poll::Ready(first_permit) = first.as_mut().poll(&mut context) else {
panic!("queue head must run")
};
drop(first_permit);
let Poll::Ready(second_permit) = second.as_mut().poll(&mut context) else {
panic!("second waiter must run")
};
drop(second_permit);
assert!(gate.try_acquire().is_some(), "drained queue restores the atomic fast path");
}
#[tokio::test]
async fn cancelling_queued_work_preserves_fifo_capacity_and_fast_path() {
use std::task::{Context, Poll, Waker};
let gate = AdaptiveAdmission::new(NonZeroU16::new(8).expect("bounded test owner"), 0);
let mut head = Box::pin(gate.acquire());
let mut middle = Box::pin(gate.acquire());
let mut tail = Box::pin(gate.acquire());
let mut context = Context::from_waker(Waker::noop());
assert!(head.as_mut().poll(&mut context).is_pending());
assert!(middle.as_mut().poll(&mut context).is_pending());
assert!(tail.as_mut().poll(&mut context).is_pending());
drop(middle);
drop(head);
gate.set_limit(1);
assert!(
gate.try_acquire().is_none(),
"cancelled head must not expose the surviving waiter's capacity"
);
let Poll::Ready(permit) = tail.as_mut().poll(&mut context) else {
panic!("surviving waiter must run")
};
drop(permit);
assert!(gate.try_acquire().is_some());
gate.set_limit(0);
let mut cancelled = Box::pin(gate.acquire());
assert!(cancelled.as_mut().poll(&mut context).is_pending());
drop(cancelled);
gate.set_limit(1);
assert!(
gate.try_acquire().is_some(),
"cancelling the final waiter must not strand an admission turn"
);
}
#[tokio::test]
async fn cancelling_a_notified_head_hands_capacity_to_the_next_waiter() {
use std::task::{Context, Poll, Waker};
let gate = AdaptiveAdmission::new(NonZeroU16::new(2).expect("two units"), 0);
let mut head = Box::pin(gate.acquire());
let mut next = Box::pin(gate.acquire());
let mut context = Context::from_waker(Waker::noop());
assert!(head.as_mut().poll(&mut context).is_pending());
assert!(next.as_mut().poll(&mut context).is_pending());
gate.set_limit(1);
// Cancel after notification, before the head consumes its capacity.
drop(head);
let Poll::Ready(permit) = next.as_mut().poll(&mut context) else {
panic!("successor must observe capacity without another notification")
};
assert_eq!(gate.snapshot(), AdaptiveAdmissionSnapshot { limit: 1, active: 1 });
drop(permit);
}
#[tokio::test(start_paused = true)]
async fn one_resize_serves_multiple_fifo_turns_but_stored_wakes_cannot_bypass_expiry() {
use std::task::{Context, Poll, Waker};
let gate = AdaptiveAdmission::new(NonZeroU16::new(2).expect("two units"), 0);
let mut first = Box::pin(gate.acquire());
let mut second = Box::pin(gate.acquire());
let mut context = Context::from_waker(Waker::noop());
assert!(first.as_mut().poll(&mut context).is_pending());
assert!(second.as_mut().poll(&mut context).is_pending());
gate.set_sampled_limit(2, Duration::from_secs(1));
let Poll::Ready(one) = first.as_mut().poll(&mut context) else {
panic!("first turn must observe resize")
};
let Poll::Ready(two) = second.as_mut().poll(&mut context) else {
panic!("second turn must observe resize without releasing the first unit")
};
tokio::time::advance(Duration::from_secs(1)).await;
drop(one);
drop(two);
let mut expired = Box::pin(gate.acquire());
assert!(expired.as_mut().poll(&mut context).is_pending(), "stored wake is not a fresh lease");
gate.set_sampled_limit(1, Duration::from_secs(1));
let Poll::Ready(renewed) = expired.as_mut().poll(&mut context) else {
panic!("fresh observation must wake expired admission")
};
drop(renewed);
assert_eq!(gate.snapshot().active, 0);
}
use std::future::Future;
use std::task::Poll;
#[tokio::test]
async fn shrink_preserves_all_held_units_and_blocks_until_below_new_limit() {
let owner = AdaptiveAdmission::new(NonZeroU16::new(8).expect("bounded test owner"), 3);
let first = owner.acquire().await;
let second = owner.acquire().await;
let third = owner.acquire().await;
owner.set_limit(1);
assert_eq!(owner.snapshot(), AdaptiveAdmissionSnapshot { limit: 1, active: 3 });
drop(first);
assert!(owner.try_acquire().is_none());
drop(second);
assert!(owner.try_acquire().is_none());
drop(third);
let fourth = owner.try_acquire().expect("last old permit returned to original owner");
assert_eq!(owner.snapshot(), AdaptiveAdmissionSnapshot { limit: 1, active: 1 });
drop(fourth);
assert_eq!(owner.snapshot().active, 0);
}
#[tokio::test]
async fn zero_capacity_and_cancelled_waiter_do_not_leak_or_bypass() {
let owner = AdaptiveAdmission::new(NonZeroU16::new(8).expect("bounded test owner"), 0);
let mut waiting = Box::pin(owner.acquire());
std::future::poll_fn(|cx| {
assert!(waiting.as_mut().poll(cx).is_pending());
Poll::Ready(())
})
.await;
drop(waiting);
assert_eq!(owner.snapshot(), AdaptiveAdmissionSnapshot { limit: 0, active: 0 });
assert!(owner.try_acquire().is_none());
owner.set_limit(1);
let permit = owner.acquire().await;
assert!(owner.try_acquire().is_none());
drop(permit);
assert_eq!(owner.snapshot().active, 0);
}
#[tokio::test]
async fn resize_wakes_a_registered_waiter_without_an_unrelated_release() {
let owner = AdaptiveAdmission::new(NonZeroU16::new(8).expect("bounded test owner"), 0);
let mut waiting = Box::pin(owner.acquire());
std::future::poll_fn(|cx| {
assert!(waiting.as_mut().poll(cx).is_pending());
Poll::Ready(())
})
.await;
owner.set_limit(1);
let permit = tokio::time::timeout(std::time::Duration::from_secs(1), waiting)
.await
.expect("resize notification");
assert_eq!(owner.snapshot().active, 1);
drop(permit);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn concurrent_resize_release_and_acquire_keep_the_owner_count_exact() {
let owner = AdaptiveAdmission::new(NonZeroU16::new(8).expect("bounded test owner"), 8);
let mut tasks = Vec::new();
for _ in 0..16 {
let owner = owner.clone();
tasks.push(tokio::spawn(async move {
for _ in 0..100 {
let permit = owner.acquire().await;
tokio::task::yield_now().await;
drop(permit);
}
}));
}
for limit in [1, 0, 4, 2, 8, 0, u32::MAX, 1] {
owner.set_limit(limit);
tokio::task::yield_now().await;
}
owner.set_limit(8);
tokio::time::timeout(std::time::Duration::from_secs(5), async {
for task in tasks {
task.await.expect("admission task");
}
})
.await
.expect("all waiters make progress");
assert_eq!(owner.snapshot(), AdaptiveAdmissionSnapshot { limit: 8, active: 0 });
}
#[tokio::test(start_paused = true)]
async fn expired_observation_blocks_new_work_without_revoking_held_permits() {
let owner = AdaptiveAdmission::new(NonZeroU16::new(8).expect("bounded test owner"), 0);
owner.set_sampled_limit(2, Duration::from_secs(2));
let held = owner.acquire().await;
tokio::time::advance(Duration::from_secs(2)).await;
assert!(owner.try_acquire().is_none());
assert_eq!(owner.snapshot(), AdaptiveAdmissionSnapshot { limit: 0, active: 1 });
drop(held);
assert_eq!(owner.snapshot().active, 0);
owner.set_sampled_limit(1, Duration::from_secs(2));
let fresh = owner.try_acquire().expect("fresh observation reopens same owner");
owner.set_sampled_limit(8, Duration::ZERO);
assert!(owner.try_acquire().is_none());
drop(fresh);
assert_eq!(owner.snapshot().active, 0);
}
fn healthy(completed: u64) -> FeedbackObservation {
FeedbackObservation {
generation: 1,
max_concurrency: 8,
pressure: ResourcePressure::Stable,
completed_units: Some(completed),
}
}
#[tokio::test(start_paused = true)]
async fn controller_grows_one_unit_per_cooldown_and_cuts_pressure_immediately() {
let gate = AdaptiveAdmission::new(NonZeroU16::new(8).expect("bounded test owner"), 0);
let mut controller = FeedbackController::new();
assert_eq!(controller.update(&gate, Some(healthy(0))).limit, 1);
for completed in 1..=25 {
tokio::time::advance(Duration::from_secs(1)).await;
let limit = controller.update(&gate, Some(healthy(completed))).limit;
assert_eq!(
limit,
if completed < 9 {
1
} else if completed < 17 {
2
} else if completed < 25 {
3
} else {
4
}
);
}
let held = gate.try_acquire().expect("existing work");
let mut pressure = healthy(26);
pressure.pressure = ResourcePressure::Busy;
assert_eq!(
controller.update(&gate, Some(pressure)),
FeedbackDecision {
limit: 2,
reason: FeedbackReason::Pressure
}
);
pressure.max_concurrency = 0;
assert_eq!(controller.update(&gate, Some(pressure)).limit, 0);
assert_eq!(gate.snapshot().active, 1);
assert!(gate.try_acquire().is_none());
drop(held);
}
#[tokio::test(start_paused = true)]
async fn controller_missing_low_sample_stalled_or_changed_sources_never_expand() {
for kind in 0..5 {
let gate = AdaptiveAdmission::new(NonZeroU16::new(8).expect("bounded test owner"), 0);
let mut controller = FeedbackController::new();
for tick in 0..32 {
let mut observation = healthy(tick);
match kind {
0 => observation.pressure = ResourcePressure::Unknown,
1 => observation.completed_units = None,
2 => observation.completed_units = Some(0),
3 => observation.generation = tick,
_ => {}
}
tokio::time::advance(Duration::from_secs(if kind == 4 { 3 } else { 1 })).await;
assert!(controller.update(&gate, Some(observation)).limit <= 1, "kind {kind}");
}
assert_eq!(
controller.update(&gate, None),
FeedbackDecision {
limit: 0,
reason: FeedbackReason::Unavailable
}
);
assert!(gate.try_acquire().is_none());
}
}
#[tokio::test(start_paused = true)]
async fn controller_resumes_after_zero_cap_without_requiring_impossible_progress() {
let gate = AdaptiveAdmission::new(NonZeroU16::new(8).expect("bounded test owner"), 0);
let mut controller = FeedbackController::new();
let mut sample = healthy(0);
sample.max_concurrency = 0;
assert_eq!(controller.update(&gate, Some(sample)).limit, 0);
tokio::time::advance(Duration::from_secs(1)).await;
sample.max_concurrency = 8;
assert_eq!(controller.update(&gate, Some(sample)).limit, 1);
assert!(gate.try_acquire().is_some(), "minimum progress restarts inside the restored cap");
}
#[tokio::test(start_paused = true)]
async fn controller_applies_pressure_reduction_and_lower_cap_in_the_same_window() {
let gate = AdaptiveAdmission::new(NonZeroU16::new(8).expect("bounded test owner"), 0);
let mut controller = FeedbackController::new();
for completed in 0..=25 {
controller.update(&gate, Some(healthy(completed)));
tokio::time::advance(Duration::from_secs(1)).await;
}
assert_eq!(gate.snapshot().limit, 4);
let mut sample = healthy(26);
sample.max_concurrency = 3;
sample.pressure = ResourcePressure::Busy;
assert_eq!(controller.update(&gate, Some(sample)).limit, 2);
}
}
+568
View File
@@ -0,0 +1,568 @@
// Copyright 2026 RustFS Team
// Licensed under the Apache License, Version 2.0.
//! Bounded foreground completion windows for background resource feedback.
//! Recording uses fixed atomic storage, without an allocation, mutex or task
//! per request. A single reader rotates banks and waits for the retired bank's
//! writers before reading it. Delayed writers delay publication, not the
//! recorded window boundary. Percentiles are intervals, never exact values.
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
use std::time::{Duration, Instant};
const SUB_BUCKETS: usize = 64;
const FINITE_BUCKETS: usize = 27 * SUB_BUCKETS;
const BUCKETS: usize = FINITE_BUCKETS + 1;
const MAX_MICROS: u64 = u32::MAX as u64;
/// Bounded foreground categories; no bucket, object or request identifiers.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ForegroundClass {
/// Object body reads.
Read,
/// Object writes, including multipart and copy operations.
Write,
/// Metadata and listing operations.
Metadata,
/// Other operations, including rejection before typed dispatch.
Other,
}
impl ForegroundClass {
/// Stable order shared by window summaries.
pub const ALL: [Self; 4] = [Self::Read, Self::Write, Self::Metadata, Self::Other];
/// Fixed metric label.
pub const fn as_str(self) -> &'static str {
match self {
Self::Read => "read",
Self::Write => "write",
Self::Metadata => "metadata",
Self::Other => "other",
}
}
const fn index(self) -> usize {
match self {
Self::Read => 0,
Self::Write => 1,
Self::Metadata => 2,
Self::Other => 3,
}
}
}
/// Outcome at the observed response-body boundary, not a client ACK.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CompletionOutcome {
/// Successful response body completed.
Success,
/// Error response, service error, or body-stream failure.
Failed,
/// The request/body was dropped before completion.
Cancelled,
}
/// Inclusive bounds containing the observed percentile.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct LatencyBounds {
/// Inclusive lower bound in nanoseconds.
pub lower_nanos: u64,
/// Inclusive upper bound; absent for the overflow bucket.
pub upper_nanos: Option<u64>,
}
/// One class in a complete observation window.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct ClassCompletions {
/// All finishes, including errors and cancellation.
pub finished: u64,
/// Error responses, service errors and body-stream failures.
pub failed: u64,
/// Censored observations; these must not be treated as successful latency.
pub cancelled: u64,
/// P99 of non-cancelled finishes, when at least 100 samples exist.
/// This count permits computing the rank; it is not statistical confidence.
pub p99: Option<LatencyBounds>,
}
/// Clock readings bracketing an atomic event-window cut. A scheduler pause
/// between these readings is visible, rather than disguised as precise timing.
#[derive(Clone, Copy, Debug)]
pub struct WindowBoundary {
/// Read immediately before the event cut.
pub earliest: Instant,
/// Read immediately after the event cut.
pub latest: Instant,
}
/// Complete windows retain their actual cut brackets, even if publication waits.
#[derive(Clone, Copy, Debug)]
pub struct ForegroundWindow {
/// Start of this event window.
pub start: WindowBoundary,
/// Cut of the retired bank, possibly older than publication time.
pub end: WindowBoundary,
/// Request starts in this window, including pre-dispatch rejection.
pub started: Option<u64>,
/// Requests still alive at the cut; absent after accounting overflow.
pub in_flight: Option<u64>,
/// Per-class completions in ForegroundClass::ALL order.
pub classes: [Option<ClassCompletions>; 4],
/// False after a counter overflow or an inconsistent event count.
pub valid: bool,
}
struct ClassBank {
latencies: [AtomicU64; BUCKETS],
failed: AtomicU64,
cancelled: AtomicU64,
}
impl ClassBank {
fn new() -> Self {
Self {
latencies: std::array::from_fn(|_| AtomicU64::new(0)),
failed: AtomicU64::new(0),
cancelled: AtomicU64::new(0),
}
}
}
struct Bank {
writers: AtomicUsize,
started: AtomicU64,
overflowed: AtomicBool,
classes: [ClassBank; 4],
}
impl Bank {
fn new() -> Self {
Self {
writers: AtomicUsize::new(0),
started: AtomicU64::new(0),
overflowed: AtomicBool::new(false),
classes: std::array::from_fn(|_| ClassBank::new()),
}
}
fn increment(&self, counter: &AtomicU64) {
if counter.fetch_add(1, Ordering::Relaxed) == u64::MAX {
self.overflowed.store(true, Ordering::Relaxed);
}
}
}
struct Writer<'a>(&'a Bank);
impl Drop for Writer<'_> {
fn drop(&mut self) {
self.0.writers.fetch_sub(1, Ordering::SeqCst);
}
}
/// One process-wide recorder. Its fixed storage is allocated with the owner.
/// A request holds only a reference, timestamps and bounded enum state.
pub struct ForegroundRecorder {
created_at: Instant,
epoch: AtomicU64,
reader_taken: AtomicBool,
banks: [Bank; 2],
}
impl Default for ForegroundRecorder {
fn default() -> Self {
Self::new()
}
}
impl ForegroundRecorder {
/// Construct empty accounting for one lifetime of the owning process.
pub fn new() -> Self {
Self {
created_at: Instant::now(),
epoch: AtomicU64::new(0),
reader_taken: AtomicBool::new(false),
banks: std::array::from_fn(|_| Bank::new()),
}
}
fn writer(&self) -> Writer<'_> {
loop {
let epoch = self.epoch.load(Ordering::SeqCst);
let bank = &self.banks[usize::from(epoch & 1 != 0)];
bank.writers.fetch_add(1, Ordering::SeqCst);
let writer = Writer(bank);
// Sequential ordering joins registration, the reader's cut, and
// its zero-writer check. A late old-epoch registrant cannot write
// into a bank that the reader has begun draining or reusing.
if self.epoch.load(Ordering::SeqCst) == epoch {
return writer;
}
}
}
/// Begin at the external service-call boundary. Drop records cancellation
/// unless finish has already recorded a response-body outcome.
pub fn start(&self) -> ForegroundObservation<'_> {
let started_at = Instant::now();
let writer = self.writer();
writer.0.increment(&writer.0.started);
ForegroundObservation {
recorder: self,
started_at: Some(started_at),
class: ForegroundClass::Other,
}
}
fn finish(&self, class: ForegroundClass, elapsed: Duration, outcome: CompletionOutcome) {
let writer = self.writer();
let bank = writer.0;
let class = &bank.classes[class.index()];
if outcome == CompletionOutcome::Cancelled {
bank.increment(&class.cancelled);
return;
}
let bucket = latency_bucket(elapsed);
bank.increment(&class.latencies[bucket]);
if outcome == CompletionOutcome::Failed {
bank.increment(&class.failed);
}
}
/// Claim the sole reader. It cannot be replaced after being dropped:
/// resetting its carried in-flight count would falsify later windows.
pub fn take_reader(&self) -> Option<ForegroundReader<'_>> {
self.reader_taken
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
.ok()?;
Some(ForegroundReader {
recorder: self,
start: WindowBoundary {
earliest: self.created_at,
latest: self.created_at,
},
retired: None,
in_flight: Some(0),
})
}
}
/// Non-cloneable request ownership, moved from the service future to its body.
pub struct ForegroundObservation<'a> {
recorder: &'a ForegroundRecorder,
started_at: Option<Instant>,
class: ForegroundClass,
}
impl ForegroundObservation<'_> {
/// Set the first dispatched operation's bounded category.
pub fn set_class(&mut self, class: ForegroundClass) {
self.class = class;
}
/// Finish once. Later calls or Drop do not produce another observation.
pub fn finish(&mut self, outcome: CompletionOutcome) {
if let Some(started_at) = self.started_at.take() {
self.recorder.finish(self.class, started_at.elapsed(), outcome);
}
}
}
impl Drop for ForegroundObservation<'_> {
fn drop(&mut self) {
self.finish(CompletionOutcome::Cancelled);
}
}
/// Single-reader state; never acquired by a request path.
pub struct ForegroundReader<'a> {
recorder: &'a ForegroundRecorder,
start: WindowBoundary,
retired: Option<(usize, WindowBoundary)>,
in_flight: Option<u64>,
}
impl ForegroundReader<'_> {
/// Cut a new window, or finish publishing an earlier cut. A live retired
/// writer returns None without blocking, discarding data or moving its cut.
pub fn sample(&mut self) -> Option<ForegroundWindow> {
if self.retired.is_none() {
let epoch = self.recorder.epoch.load(Ordering::SeqCst);
let next = epoch.checked_add(1)?;
let earliest = Instant::now();
self.recorder.epoch.store(next, Ordering::SeqCst);
let cut = WindowBoundary {
earliest,
latest: Instant::now(),
};
self.retired = Some((usize::from(epoch & 1 != 0), cut));
}
let (index, end) = self.retired?;
let bank = &self.recorder.banks[index];
if bank.writers.load(Ordering::SeqCst) != 0 {
return None;
}
let started = bank.started.swap(0, Ordering::Relaxed);
let overflowed = bank.overflowed.swap(false, Ordering::Relaxed);
let classes = std::array::from_fn(|index| {
let class = &bank.classes[index];
let failed = class.failed.swap(0, Ordering::Relaxed);
let cancelled = class.cancelled.swap(0, Ordering::Relaxed);
let buckets = std::array::from_fn::<_, BUCKETS, _>(|i| class.latencies[i].swap(0, Ordering::Relaxed));
let non_cancelled = buckets.iter().try_fold(0_u64, |sum, count| sum.checked_add(*count));
let finished = non_cancelled.and_then(|total| total.checked_add(cancelled));
let finished = finished.filter(|_| !overflowed && non_cancelled.is_some_and(|total| failed <= total))?;
Some(ClassCompletions {
finished,
failed,
cancelled,
p99: non_cancelled
.filter(|total| *total >= 100)
.and_then(|total| percentile_99(&buckets, total)),
})
});
let started = (!overflowed).then_some(started);
let finished = classes
.iter()
.try_fold(0_u64, |sum, class| sum.checked_add(class.as_ref()?.finished));
self.in_flight = self
.in_flight
.zip(finished)
.and_then(|(active, finished)| active.checked_add(started?)?.checked_sub(finished));
let window = ForegroundWindow {
start: self.start,
end,
started,
in_flight: self.in_flight,
classes,
valid: self.in_flight.is_some(),
};
self.start = end;
self.retired = None;
Some(window)
}
}
fn latency_bucket(elapsed: Duration) -> usize {
let micros = elapsed.as_nanos().div_ceil(1_000);
let Ok(micros) = u64::try_from(micros) else {
return FINITE_BUCKETS;
};
if micros > MAX_MICROS {
return FINITE_BUCKETS;
}
if micros < SUB_BUCKETS as u64 {
return usize::try_from(micros).unwrap_or(FINITE_BUCKETS);
}
let shift = 63 - micros.leading_zeros() - 6;
usize::try_from((u64::from(shift) + 1) * SUB_BUCKETS as u64 + (micros >> shift) - SUB_BUCKETS as u64)
.unwrap_or(FINITE_BUCKETS)
}
fn bucket_bounds(index: usize) -> LatencyBounds {
if index == FINITE_BUCKETS {
return LatencyBounds {
lower_nanos: MAX_MICROS * 1_000 + 1,
upper_nanos: None,
};
}
let (lower, upper) = if index < SUB_BUCKETS {
(index as u64, index as u64)
} else {
let shift = index / SUB_BUCKETS - 1;
let lower = (SUB_BUCKETS + index % SUB_BUCKETS) as u64 * (1_u64 << shift);
(lower, lower + (1_u64 << shift) - 1)
};
LatencyBounds {
lower_nanos: lower.saturating_sub(1) * 1_000 + u64::from(lower != 0),
upper_nanos: Some(upper * 1_000),
}
}
fn percentile_99(buckets: &[u64; BUCKETS], total: u64) -> Option<LatencyBounds> {
let rank = (u128::from(total) * 99).div_ceil(100);
let mut cumulative = 0_u128;
for (index, count) in buckets.iter().enumerate() {
cumulative += u128::from(*count);
if cumulative >= rank {
return Some(bucket_bounds(index));
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
#[test]
fn percentile_intervals_contain_each_bucket_edge_and_neighbour() {
for index in 0..BUCKETS {
let bounds = bucket_bounds(index);
assert_eq!(latency_bucket(Duration::from_nanos(bounds.lower_nanos)), index);
if let Some(upper) = bounds.upper_nanos {
assert!(upper >= bounds.lower_nanos);
assert_eq!(latency_bucket(Duration::from_nanos(upper)), index);
assert_eq!(latency_bucket(Duration::from_nanos(upper + 1)), index + 1);
}
}
assert_eq!(latency_bucket(Duration::MAX), FINITE_BUCKETS);
}
#[test]
fn body_ownership_carries_in_flight_counts_across_windows() {
let recorder = ForegroundRecorder::new();
let mut reader = recorder.take_reader().expect("first reader");
let mut request = recorder.start();
request.set_class(ForegroundClass::Read);
let first = reader.sample().expect("cut with a live request");
assert!(first.valid);
assert_eq!(first.started, Some(1));
assert_eq!(first.in_flight, Some(1));
assert_eq!(first.classes[0].expect("valid read count").finished, 0);
request.finish(CompletionOutcome::Success);
request.finish(CompletionOutcome::Failed);
drop(request);
let second = reader.sample().expect("body finished");
assert!(second.valid);
assert_eq!(second.started, Some(0));
assert_eq!(second.in_flight, Some(0));
let read = second.classes[0].expect("valid read summary");
assert_eq!((read.finished, read.failed, read.cancelled, read.p99), (1, 0, 0, None));
assert_eq!(first.end.earliest, second.start.earliest);
assert_eq!(first.end.latest, second.start.latest);
}
#[test]
fn cancellations_are_counted_without_fabricating_success_latencies() {
let recorder = ForegroundRecorder::new();
let mut reader = recorder.take_reader().expect("first reader");
for _ in 0..150 {
let mut request = recorder.start();
request.set_class(ForegroundClass::Write);
}
let window = reader.sample().expect("cancelled requests");
let write = window.classes[1].expect("known write count");
assert!(window.valid);
assert_eq!(window.in_flight, Some(0));
assert_eq!((write.finished, write.cancelled, write.failed, write.p99), (150, 150, 0, None));
}
#[test]
fn delayed_retired_writer_preserves_the_original_cut_and_next_bank() {
let recorder = ForegroundRecorder::new();
let mut reader = recorder.take_reader().expect("first reader");
let writer = recorder.writer();
assert!(reader.sample().is_none(), "never read a live writer's bank");
let original_cut = reader.retired.expect("pending cut").1;
let request = recorder.start(); // a start after the cut belongs to the new bank
writer.0.increment(&writer.0.started);
drop(writer);
let previous = reader.sample().expect("retired writer drained");
assert_eq!(previous.end.earliest, original_cut.earliest);
assert_eq!(previous.end.latest, original_cut.latest);
assert_eq!(previous.started, Some(1));
assert_eq!(previous.in_flight, Some(1));
drop(request);
let current = reader.sample().expect("next bank");
assert_eq!(current.started, Some(1));
assert_eq!(current.in_flight, Some(1));
assert_eq!(current.classes[3].expect("unclassified cancellation").cancelled, 1);
}
#[test]
fn completed_percentile_uses_rank_and_never_turns_maximum_into_p99() {
let recorder = ForegroundRecorder::new();
let mut reader = recorder.take_reader().expect("first reader");
for i in 0..100 {
let writer = recorder.writer();
writer.0.increment(&writer.0.started);
drop(writer);
recorder.finish(
ForegroundClass::Read,
if i == 99 {
Duration::from_secs(60)
} else {
Duration::from_millis(2)
},
if i == 99 {
CompletionOutcome::Failed
} else {
CompletionOutcome::Success
},
);
}
let window = reader.sample().expect("complete window");
let read = window.classes[0].expect("read summary");
assert_eq!((read.finished, read.failed, read.cancelled), (100, 1, 0));
let p99 = read.p99.expect("100 non-cancelled samples");
assert!(p99.lower_nanos <= 2_000_000);
assert!(p99.upper_nanos.is_some_and(|upper| (2_000_000..3_000_000).contains(&upper)));
}
#[test]
fn overflow_stays_unknown_and_reader_cannot_reset_event_accounting() {
let recorder = ForegroundRecorder::new();
{
let mut reader = recorder.take_reader().expect("first reader");
recorder.banks[0].started.store(u64::MAX, Ordering::Relaxed);
drop(recorder.start());
let invalid = reader.sample().expect("overflow window");
assert!(!invalid.valid);
assert_eq!(invalid.started, None);
assert_eq!(invalid.in_flight, None);
assert_eq!(invalid.classes, [None; 4]);
let later = reader.sample().expect("later empty window");
assert!(!later.valid, "a lost carried count cannot recover by inventing zero active work");
}
assert!(recorder.take_reader().is_none(), "reader replacement would lose live request ownership");
}
#[test]
fn concurrent_rotation_preserves_every_start_finish_and_cancellation() {
let recorder = Arc::new(ForegroundRecorder::new());
let mut reader = recorder.take_reader().expect("first reader");
let workers: Vec<_> = (0..4)
.map(|_| {
let recorder = Arc::clone(&recorder);
std::thread::spawn(move || {
for index in 0..10_000 {
let mut request = recorder.start();
request.set_class(ForegroundClass::Read);
if index % 10 != 0 {
request.finish(CompletionOutcome::Success);
}
}
})
})
.collect();
let (mut started, mut finished, mut cancelled) = (0, 0, 0);
let mut add_window = |window: ForegroundWindow| {
assert!(window.valid, "concurrent event cuts must stay balanced");
started += window.started.expect("valid start count");
let reads = window.classes[0].expect("valid read count");
finished += reads.finished;
cancelled += reads.cancelled;
};
while workers.iter().any(|worker| !worker.is_finished()) {
if let Some(window) = reader.sample() {
add_window(window);
}
std::thread::yield_now();
}
for worker in workers {
worker.join().expect("writer must finish");
}
add_window(reader.sample().expect("drain after writers join"));
// A previously retired window may have been published by the last
// sample; cut once more to include the current, still open bank.
let final_window = reader.sample().expect("final open bank");
assert_eq!(final_window.in_flight, Some(0));
add_window(final_window);
assert_eq!((started, finished, cancelled), (40_000, 40_000, 4_000));
}
}
+2
View File
@@ -36,8 +36,10 @@
// Re-exported io-core type used by downstream timeout implementations.
pub use rustfs_io_core::OperationProgress;
pub mod adaptive;
mod backpressure;
mod deadlock;
pub mod foreground;
mod queue;
pub mod workers;
pub mod workload;
+55 -2
View File
@@ -49,12 +49,28 @@ impl Workers {
/// Acquire a worker slot, waiting until one becomes available.
pub async fn take(&self) {
struct AcquireCancelGuard<'a> {
workers: &'a Workers,
completed: bool,
}
impl Drop for AcquireCancelGuard<'_> {
fn drop(&mut self) {
if !self.completed {
self.workers.in_use.send_modify(|_| {});
}
}
}
let mut guard = AcquireCancelGuard {
workers: self,
completed: false,
};
let permit = match self.semaphore.acquire().await {
Ok(permit) => permit,
// The semaphore is owned by `self` and never closed.
Err(_) => unreachable!("worker semaphore is never closed"),
};
permit.forget(); // returned manually via give()
guard.completed = true;
self.in_use.send_modify(|held| *held += 1);
trace!(
event = "worker_slot.acquire",
@@ -82,6 +98,7 @@ impl Workers {
});
if released {
self.semaphore.add_permits(1);
self.in_use.send_modify(|_| {});
}
trace!(
event = "worker_slot.release",
@@ -99,7 +116,9 @@ impl Workers {
pub async fn wait(&self) {
let mut rx = self.in_use.subscribe();
// The sender is owned by `self`, so it outlives this borrow.
let _ = rx.wait_for(|&held| held == 0).await;
let _ = rx
.wait_for(|&held| held == 0 && self.semaphore.available_permits() == self.limit)
.await;
debug!(
event = "worker_slot.wait",
component = "concurrency",
@@ -114,7 +133,9 @@ impl Workers {
/// Return the current number of available worker slots.
pub async fn available(&self) -> usize {
self.limit.saturating_sub(*self.in_use.borrow())
self.limit
.saturating_sub(*self.in_use.borrow())
.min(self.semaphore.available_permits())
}
}
@@ -217,4 +238,36 @@ mod tests {
}
assert_eq!(workers.available().await, 0);
}
#[tokio::test]
async fn test_wait_does_not_complete_while_permit_is_in_transit_to_queued_taker() {
use std::future::Future;
use std::pin::pin;
use std::task::{Context, Poll, Waker};
let workers = Workers::new(1).unwrap();
workers.take().await;
let mut taker = pin!(workers.take());
let mut cx = Context::from_waker(Waker::noop());
assert!(matches!(taker.as_mut().poll(&mut cx), Poll::Pending));
let mut waiter = pin!(workers.wait());
assert!(matches!(waiter.as_mut().poll(&mut cx), Poll::Pending));
// Hand off the slot to the queued taker before `taker` is polled again.
workers.give().await;
assert_eq!(workers.available().await, 0);
assert!(
matches!(waiter.as_mut().poll(&mut cx), Poll::Pending),
"wait() must not complete while a permit is in transit to a woken taker"
);
assert!(matches!(taker.as_mut().poll(&mut cx), Poll::Ready(())));
assert!(matches!(waiter.as_mut().poll(&mut cx), Poll::Pending));
workers.give().await;
assert!(matches!(waiter.as_mut().poll(&mut cx), Poll::Ready(())));
assert_eq!(workers.available().await, 1);
}
}
+7
View File
@@ -116,6 +116,7 @@ when restoring `/rustfs/console` for a custom OEM build.
- `RUSTFS_DATA_SCANNER_START_DELAY_SECS` (deprecated alias for compatibility)
- `RUSTFS_SCANNER_IDLE_MODE` (canonical)
- `RUSTFS_SCANNER_CACHE_SAVE_TIMEOUT_SECS` (canonical)
- `RUSTFS_SCANNER_RECOVERY_BACKOFF_CAP_SECS` (canonical)
- `RUSTFS_SCANNER_CYCLE_MAX_DURATION_SECS` (canonical)
- `RUSTFS_SCANNER_CYCLE_MAX_OBJECTS` (canonical)
- `RUSTFS_SCANNER_CYCLE_MAX_DIRECTORIES` (canonical)
@@ -127,6 +128,12 @@ Scanner cycle budget controls:
- When `RUSTFS_SCANNER_CYCLE_MAX_DURATION_SECS` is unset, the finite default is 1800 seconds (30 minutes), matching the scanner benchmark guidance.
- An explicit `0` preserves the compatibility behavior of an unbounded runtime budget. Object and directory budgets likewise remain unbounded when explicitly set to `0`.
Scanner recovery retry controls:
- `RUSTFS_SCANNER_RECOVERY_BACKOFF_CAP_SECS` caps the wait between automatic startup recovery retries. The persistent admin key is `scanner.recovery_backoff_cap`; the environment takes precedence over the persistent value, and the unset default remains 600 seconds (10 minutes). Values must be positive integer seconds within the platform timer range; `0`, malformed values, and overflow are rejected. A valid environment override does not hide an invalid persistent value from `admin config set` validation.
- The cap bounds the existing exponential retry schedule. Updating the persistent value applies to the next computed retry; it does not reschedule a wait already in progress. Retryable observations continue retrying after the short retry budget. Admin scanner status reports the effective value and source at `runtime_config.recovery_backoff_cap_seconds`.
Scanner walker controls:
- `RUSTFS_SCANNER_WALKERS_PER_DISK` sets how many bucket walkers each local disk of an erasure set runs; the default `1` walks one bucket per disk at a time, which is the right value for rotational media. On flash, `2` to `4` lets one disk walk several buckets concurrently, which matters most for single-disk deployments with many buckets, where the walk is otherwise fully serial. Values outside `1` through `16` are rejected by `admin config set`; an out-of-range or non-numeric environment value is logged as a scanner runtime config error (`state = "startup_apply_failed"`) and the scanner keeps the default. The walkers of one disk share that disk's scanner admission, so peers still see one scan session per physical disk, and `RUSTFS_SCANNER_MAX_CONCURRENT_DISK_SCANS` still caps the total.
+6 -2
View File
@@ -276,6 +276,9 @@ pub const DEFAULT_INTERNODE_BULK_CHANNELS: usize = 2;
pub const ENV_INTERNODE_HTTP_TUNING_PROFILE: &str = "RUSTFS_INTERNODE_HTTP_TUNING_PROFILE";
pub const DEFAULT_INTERNODE_HTTP_TUNING_PROFILE: &str = "legacy";
/// Internode HTTP data-plane protocol: `http1` (default) or `auto` (TLS ALPN).
pub const ENV_INTERNODE_HTTP_PROTOCOL: &str = "RUSTFS_INTERNODE_HTTP_PROTOCOL";
/// Internode HTTP connection pool maximum idle connections per host.
pub const ENV_INTERNODE_HTTP_POOL_MAX_IDLE_PER_HOST: &str = "RUSTFS_INTERNODE_HTTP_POOL_MAX_IDLE_PER_HOST";
@@ -287,8 +290,9 @@ pub const ENV_INTERNODE_HTTP_POOL_IDLE_TIMEOUT_SECS: &str = "RUSTFS_INTERNODE_HT
/// NOTE (backlog#805-C3): all `RUSTFS_INTERNODE_HTTP2_*` tuning (window sizes,
/// adaptive window, keepalive timeout) only takes effect when the internode
/// connection actually negotiates HTTP/2, which happens via TLS-ALPN. Over
/// plaintext internode transport the connection is HTTP/1.1 and these settings
/// are silently inert — enable internode TLS to use them.
/// plaintext internode transport or with the default `http1` protocol policy,
/// these settings are inert. Select `RUSTFS_INTERNODE_HTTP_PROTOCOL=auto` and
/// enable internode TLS to use them.
pub const ENV_INTERNODE_HTTP2_INITIAL_STREAM_WINDOW_SIZE: &str = "RUSTFS_INTERNODE_HTTP2_INITIAL_STREAM_WINDOW_SIZE";
/// Internode HTTP/2 initial connection window size in bytes.
+12
View File
@@ -53,6 +53,9 @@ pub const SCANNER_IDLE_MODE: &str = "idle_mode";
/// Scanner config key controlling scanner cache save timeout in seconds.
pub const SCANNER_CACHE_SAVE_TIMEOUT: &str = "cache_save_timeout";
/// Scanner config key capping automatic recovery retry waits in seconds.
pub const SCANNER_RECOVERY_BACKOFF_CAP: &str = "recovery_backoff_cap";
/// Scanner config key capping concurrent scanner set tasks.
pub const SCANNER_MAX_CONCURRENT_SET_SCANS: &str = "max_concurrent_set_scans";
@@ -93,6 +96,7 @@ pub const SCANNER_KEYS: &[&str] = &[
SCANNER_BITROT_CYCLE,
SCANNER_IDLE_MODE,
SCANNER_CACHE_SAVE_TIMEOUT,
SCANNER_RECOVERY_BACKOFF_CAP,
SCANNER_MAX_CONCURRENT_SET_SCANS,
SCANNER_MAX_CONCURRENT_DISK_SCANS,
SCANNER_WALKERS_PER_DISK,
@@ -216,6 +220,14 @@ pub const ENV_SCANNER_CACHE_SAVE_TIMEOUT_SECS: &str = "RUSTFS_SCANNER_CACHE_SAVE
/// Default scanner cache save timeout in seconds.
pub const DEFAULT_SCANNER_CACHE_SAVE_TIMEOUT_SECS: u64 = 14;
/// Environment variable that caps automatic scanner recovery retry waits.
/// The value must be a positive integer number of seconds within the timer range.
/// - Example: `export RUSTFS_SCANNER_RECOVERY_BACKOFF_CAP_SECS=600`
pub const ENV_SCANNER_RECOVERY_BACKOFF_CAP_SECS: &str = "RUSTFS_SCANNER_RECOVERY_BACKOFF_CAP_SECS";
/// Default maximum wait between automatic scanner recovery retries.
pub const DEFAULT_SCANNER_RECOVERY_BACKOFF_CAP_SECS: u64 = 10 * 60;
/// Environment variable that caps concurrent scanner set tasks.
/// A value of `0` keeps the existing topology-based concurrency.
/// - Example: `export RUSTFS_SCANNER_MAX_CONCURRENT_SET_SCANS=2`
+2 -1
View File
@@ -107,7 +107,7 @@ flate2.workspace = true
http.workspace = true
http-body-util.workspace = true
hyper = { workspace = true, features = ["http2", "http1", "server"] }
hyper-util = { workspace = true, features = ["tokio", "server-auto", "server-graceful", "tracing"] }
hyper-util = { workspace = true, features = ["tokio", "server-auto", "server-graceful", "client-legacy", "tracing"] }
reqwest = { workspace = true, features = ["json", "multipart", "stream"] }
rustfs-signer.workspace = true
@@ -147,5 +147,6 @@ clap = { workspace = true, features = ["derive", "env"] }
[dev-dependencies]
aws-smithy-http-client = { workspace = true, default-features = false, features = ["test-util"] }
rustfs-heal.workspace = true
tempfile.workspace = true
tokio = { workspace = true, features = ["test-util"] }
+4 -3
View File
@@ -200,10 +200,11 @@ python3 scripts/e2e_binary.py run --features e2e-test-hooks -- cargo nextest run
python3 scripts/e2e_binary.py run --binary "$RUSTFS_E2E_STARTUP_CAS_BINARY" --features e2e-test-hooks -- cargo nextest run --profile e2e-full -p e2e_test
python3 scripts/e2e_binary.py run --features e2e-test-hooks -- cargo nextest run --profile e2e-nightly -p e2e_test
# Distributed 4-node 4-disk lane uses the default server.
# Distributed 4-node 4-disk lane includes physical-worker fault injection and
# scanner_heal_topology_ cases; both require the fault-test hooks server.
# Upgrade cases require RUSTFS_UPGRADE_SOURCE_BINARY and fail closed without it.
python3 scripts/e2e_binary.py build
python3 scripts/e2e_binary.py run -- cargo nextest run --profile e2e-distributed -p e2e_test
python3 scripts/e2e_binary.py build --bins --features e2e-test-hooks
python3 scripts/e2e_binary.py run --features e2e-test-hooks -- cargo nextest run --profile e2e-distributed -p e2e_test
# Replication nightly uses the default server; awscurl is required for STS.
python3 scripts/e2e_binary.py build
+152 -14
View File
@@ -34,7 +34,7 @@ use rustfs_signer::sign_v4;
use s3s::Body;
use serde_json;
use std::fs as stdfs;
use std::io::ErrorKind;
use std::io::{self, ErrorKind};
use std::net::{Ipv6Addr, SocketAddr};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
@@ -1272,6 +1272,7 @@ pub struct RustFSTestClusterEnvironment {
/// Optional socket proxies used for the corresponding node's volume
/// endpoints. Proxies must be installed before [`Self::start`].
volume_proxy_addresses: Vec<Option<SocketAddr>>,
tls_ca_pem: Option<Vec<u8>>,
}
impl RustFSTestClusterEnvironment {
@@ -1312,7 +1313,10 @@ impl RustFSTestClusterEnvironment {
pub async fn with_topology(topology: ClusterTopology) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
topology.validate()?;
let temp_dir = format!("/tmp/rustfs_cluster_test_{}", Uuid::new_v4());
let temp_dir = std::env::temp_dir()
.join(format!("rustfs_cluster_test_{}", Uuid::new_v4()))
.to_string_lossy()
.into_owned();
fs::create_dir_all(&temp_dir).await?;
// Map every node to its owning pool index.
@@ -1374,9 +1378,35 @@ impl RustFSTestClusterEnvironment {
node_capture_log_paths: vec![None; topology.node_count],
topology,
volume_proxy_addresses: vec![None; node_count],
tls_ca_pem: None,
})
}
/// Configure verified HTTPS for both volume endpoints and S3 clients.
/// The bundle must be generated before any node starts.
pub async fn enable_tls(&mut self, directory: &Path) -> std::io::Result<()> {
if self.nodes.iter().any(|node| node.process.is_some()) {
return Err(std::io::Error::new(
ErrorKind::InvalidInput,
"configure cluster TLS before starting nodes",
));
}
let directory = fs::canonicalize(directory).await?;
let ca_pem = fs::read(directory.join("ca.crt")).await?;
reqwest::Certificate::from_pem(&ca_pem).map_err(|error| std::io::Error::new(ErrorKind::InvalidData, error))?;
self.set_env(
"RUSTFS_TLS_PATH",
directory
.to_str()
.ok_or_else(|| std::io::Error::new(ErrorKind::InvalidInput, "TLS directory is not UTF-8"))?,
);
for node in &mut self.nodes {
node.url = format!("https://{}", node.address);
}
self.tls_ca_pem = Some(ca_pem);
Ok(())
}
/// Add an extra environment variable applied to every cluster node process.
pub fn set_env<K, V>(&mut self, key: K, value: V)
where
@@ -1467,6 +1497,29 @@ impl RustFSTestClusterEnvironment {
Ok(proxy)
}
/// Route internode traffic through a method-aware HTTP proxy.
pub(crate) async fn start_rpc_proxy_for_node(
&mut self,
node_idx: usize,
) -> io::Result<crate::rpc_fault_proxy::RpcFaultProxy> {
self.ensure_node_index(node_idx)
.map_err(|error| io::Error::new(ErrorKind::InvalidInput, error))?;
if self.volume_proxy_addresses[node_idx].is_some() {
return Err(io::Error::new(
ErrorKind::AlreadyExists,
format!("a volume proxy is already configured for node {node_idx}"),
));
}
let target = self.nodes[node_idx]
.address
.parse::<SocketAddr>()
.map_err(|error| io::Error::new(ErrorKind::InvalidInput, error))?;
let listen = SocketAddr::from((Ipv6Addr::LOCALHOST, target.port()));
let proxy = crate::rpc_fault_proxy::RpcFaultProxy::start_on(listen, target).await?;
self.volume_proxy_addresses[node_idx] = Some(proxy.local_addr());
Ok(proxy)
}
fn volume_address(&self, node_idx: usize) -> String {
self.volume_proxy_addresses[node_idx]
.map(|address| address.to_string())
@@ -1475,6 +1528,7 @@ impl RustFSTestClusterEnvironment {
fn build_volumes_arg(&self) -> String {
let pools = self.topology.normalized_pools();
let scheme = if self.tls_ca_pem.is_some() { "https" } else { "http" };
if pools.len() <= 1 {
// Single pool: explicit enumeration of every drive on every node.
@@ -1484,7 +1538,7 @@ impl RustFSTestClusterEnvironment {
.enumerate()
.flat_map(|(node_idx, n)| {
let address = self.volume_address(node_idx);
n.data_dirs.iter().map(move |dir| format!("http://{}{}", address, dir))
n.data_dirs.iter().map(move |dir| format!("{scheme}://{address}{dir}"))
})
.collect::<Vec<_>>()
.join(" ");
@@ -1504,7 +1558,7 @@ impl RustFSTestClusterEnvironment {
.and_then(|d| d.rsplit_once('/').map(|(parent, _)| parent))
.unwrap_or(&node.data_dir);
format!(
"http://{}{}/drive{{0...{}}}",
"{scheme}://{}{}/drive{{0...{}}}",
self.volume_address(node_idx),
base,
self.topology.drives_per_node - 1
@@ -1693,13 +1747,20 @@ impl RustFSTestClusterEnvironment {
if node_idx >= self.nodes.len() {
return Err("node_idx is invalid".into());
}
Ok(Client::from_conf(build_test_s3_config(
&self.nodes[node_idx].url,
&self.access_key,
&self.secret_key,
None,
"cluster-test",
)))
let mut config =
build_test_s3_config(&self.nodes[node_idx].url, &self.access_key, &self.secret_key, None, "cluster-test");
if let Some(ca_pem) = &self.tls_ca_pem {
use aws_smithy_http_client::tls::{Provider, TlsContext, TrustStore, rustls_provider::CryptoMode};
let tls = TlsContext::builder()
.with_trust_store(TrustStore::empty().with_pem_certificate(ca_pem.clone()))
.build()?;
let client = SmithyHttpClientBuilder::new()
.tls_provider(Provider::Rustls(CryptoMode::AwsLc))
.tls_context(tls)
.build_https();
config = config.to_builder().http_client(client).build();
}
Ok(Client::from_conf(config))
}
/// Create S3 clients for all nodes in the RustFS cluster and collect them into a vector.
@@ -1824,7 +1885,7 @@ impl RustFSTestClusterEnvironment {
}
self.nodes.push(ClusterNode {
url: format!("http://{address}"),
url: format!("{}://{address}", if self.tls_ca_pem.is_some() { "https" } else { "http" }),
address,
data_dir: data_dirs[0].clone(),
data_dirs,
@@ -1846,6 +1907,47 @@ impl RustFSTestClusterEnvironment {
Ok(new_idx)
}
/// Remove retired single-node pools from a stopped multi-pool cluster.
///
/// A decommissioned pool must leave the server command line before the
/// next start; the server refuses to start while it is still listed. The
/// remaining pools keep their node order and are renumbered from zero.
/// Data directories are left on disk for the owning harness to clean up.
pub fn remove_single_node_pools(&mut self, retired: &[usize]) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
if self.nodes.iter().any(|node| node.process.is_some()) {
return Err("stop the cluster before removing a pool".into());
}
let pools = self.topology.normalized_pools();
if pools.iter().any(|nodes| nodes.len() != 1) {
return Err("remove_single_node_pools requires one node per pool".into());
}
if retired.iter().any(|pool| *pool >= pools.len()) || retired.len() >= pools.len() {
return Err(format!("cannot remove pools {retired:?} from {} pools", pools.len()).into());
}
let keep: Vec<usize> = (0..self.nodes.len())
.filter(|node_idx| !retired.contains(&self.nodes[*node_idx].pool_idx))
.collect();
fn retain_indices<T>(values: &mut Vec<T>, keep: &[usize]) {
let old = std::mem::take(values);
*values = old
.into_iter()
.enumerate()
.filter(|(idx, _)| keep.contains(idx))
.map(|(_, value)| value)
.collect();
}
retain_indices(&mut self.nodes, &keep);
retain_indices(&mut self.node_extra_env, &keep);
retain_indices(&mut self.node_capture_log_paths, &keep);
retain_indices(&mut self.volume_proxy_addresses, &keep);
for (pool_idx, node) in self.nodes.iter_mut().enumerate() {
node.pool_idx = pool_idx;
}
self.topology.node_count = self.nodes.len();
self.topology.pools = (0..self.nodes.len()).map(|node_idx| vec![node_idx]).collect();
Ok(())
}
/// Gracefully stop one cluster node and wait for its process to exit.
///
/// This is intentionally separate from [`Self::stop_node`]: the latter is
@@ -2397,6 +2499,7 @@ mod tests {
node_capture_log_paths: vec![None; topology.node_count],
topology,
volume_proxy_addresses: vec![None; node_count],
tls_ca_pem: None,
}
}
@@ -2414,8 +2517,8 @@ mod tests {
);
}
#[test]
fn volumes_single_pool_multidrive_enumerates_every_drive() {
#[tokio::test]
async fn volumes_single_pool_multidrive_enumerates_every_drive() {
// 4 nodes x 2 drives -> 8 explicit endpoints, one legacy pool. No
// ellipses, so the server parser keeps this as a single DistErasure pool.
let env = fake_cluster(ClusterTopology::single_pool_multidrive(4, 2));
@@ -2427,6 +2530,25 @@ mod tests {
.join(" ");
assert_eq!(env.build_volumes_arg(), expected);
assert_eq!(env.build_volumes_arg().split(' ').count(), 8);
let directory = tempfile::tempdir().expect("TLS fixture directory");
crate::tls_gen::run(crate::tls_gen::Args {
out_dir: directory.path().to_owned(),
days: 1,
force: false,
})
.expect("generate TLS bundle");
for topology in [
ClusterTopology::single_pool_multidrive(4, 4),
ClusterTopology::per_node_pools(4, vec![vec![0], vec![1]]),
] {
let mut cluster = fake_cluster(topology);
let plaintext_volumes = cluster.build_volumes_arg();
cluster.enable_tls(directory.path()).await.expect("configure TLS");
assert_eq!(cluster.build_volumes_arg(), plaintext_volumes.replace("http://", "https://"));
assert!(cluster.nodes.iter().all(|node| node.url.starts_with("https://")));
assert!(cluster.create_s3_client(0).is_ok(), "trusted SDK client should build");
}
}
#[test]
@@ -2514,6 +2636,22 @@ mod tests {
proxy.shutdown().await;
}
#[tokio::test]
async fn cluster_data_dirs_use_os_temporary_directory() {
let cluster = RustFSTestClusterEnvironment::new(1).await.expect("allocate isolated cluster");
let root = PathBuf::from(&cluster.temp_dir);
assert!(
root.starts_with(std::env::temp_dir()),
"cluster must honor the selected temporary filesystem"
);
for node in &cluster.nodes {
assert!(Path::new(&node.data_dir).starts_with(&root));
assert!(node.data_dirs.iter().all(|drive| Path::new(drive).starts_with(&root)));
}
drop(cluster);
assert!(!root.exists(), "dropping the fixture must remove its owned directories");
}
#[test]
fn cluster_node_env_supports_per_node_overrides() {
let mut env = fake_cluster(ClusterTopology::single_pool(4));
@@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
//! Regression test for rustfs/backlog#629(b): region-aware `CreateBucket`.
//! Region-aware bucket creation and location regressions: backlog#629(b), rustfs#8729.
//!
//! `minio-go`'s `MakeBucket(bucket, "us-east-1")` was reported to fail SigV4
//! validation. The SigV4 signature is verified by `s3s` over the raw request
@@ -23,7 +23,13 @@
#[cfg(test)]
mod tests {
use crate::common::{RustFSTestEnvironment, init_logging};
use crate::common::{
AdminTransport, RustFSTestEnvironment, admin_add_canned_policy_via, admin_attach_user_policy_via, admin_create_user,
build_test_s3_config, build_test_sts_client, init_logging, local_http_client, signed_s3_request_with_headers,
};
use aws_sdk_s3::Client;
use aws_sdk_s3::config::Region;
use aws_sdk_s3::error::ProvideErrorMetadata;
use aws_sdk_s3::types::{BucketLocationConstraint, CreateBucketConfiguration};
use std::error::Error;
@@ -33,7 +39,7 @@ mod tests {
async fn test_create_bucket_with_us_east_1_location_constraint() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
env.start_rustfs_server(vec!["--region", "us-east-1"]).await?;
let client = env.create_s3_client();
client
@@ -48,6 +54,13 @@ mod tests {
.await
.expect("region-aware CreateBucket must pass SigV4 validation and succeed");
let location = client.get_bucket_location().bucket("region-aware-bucket").send().await?;
assert_eq!(
location.location_constraint().map(|value| value.as_str()),
Some(""),
"us-east-1 must serialize an empty LocationConstraint element"
);
// The bucket exists and is listed.
let listed = client.list_buckets().send().await?;
let names: Vec<&str> = listed.buckets().iter().filter_map(|b| b.name()).collect();
@@ -63,7 +76,7 @@ mod tests {
async fn test_create_bucket_without_location_constraint() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
env.start_rustfs_server(vec!["--region", "us-east-1"]).await?;
let client = env.create_s3_client();
client
@@ -76,4 +89,423 @@ mod tests {
env.stop_server();
Ok(())
}
#[tokio::test]
async fn test_create_bucket_rejects_unserved_location_constraints() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec!["--region", "us-east-1"]).await?;
let client = env.create_s3_client();
for (index, region) in ["eu-1", "not-a-real-region-42", "us-west-2", "EU"].iter().enumerate() {
let bucket = format!("rejected-region-{index}");
let error = client
.create_bucket()
.bucket(&bucket)
.create_bucket_configuration(
CreateBucketConfiguration::builder()
.location_constraint(BucketLocationConstraint::from(*region))
.build(),
)
.send()
.await
.expect_err("a region not served by this instance must be rejected");
assert_eq!(error.raw_response().map(|response| response.status().as_u16()), Some(400));
assert_eq!(
error.as_service_error().and_then(ProvideErrorMetadata::code),
Some("InvalidLocationConstraint")
);
let missing = client
.head_bucket()
.bucket(&bucket)
.send()
.await
.expect_err("rejected bucket must not exist");
assert_eq!(missing.raw_response().map(|response| response.status().as_u16()), Some(404));
}
let missing = client
.get_bucket_location()
.bucket("missing-region-bucket")
.send()
.await
.expect_err("missing bucket must remain an error");
assert_eq!(missing.as_service_error().and_then(ProvideErrorMetadata::code), Some("NoSuchBucket"));
Ok(())
}
#[tokio::test]
async fn test_create_bucket_uses_configured_region() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
for region in ["eu-1", "eu-west-1"] {
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec!["--region", region]).await?;
let client = Client::from_conf(
env.create_s3_client()
.config()
.to_builder()
.region(Region::new(region))
.build(),
);
let mut configurations = vec![None, Some(CreateBucketConfiguration::builder().build())];
for constraint in ["", region].into_iter().chain((region == "eu-west-1").then_some("EU")) {
configurations.push(Some(
CreateBucketConfiguration::builder()
.location_constraint(BucketLocationConstraint::from(constraint))
.build(),
));
}
// Omitted and empty constraints retain the S3-compatible server's site default.
for (index, configuration) in configurations.into_iter().enumerate() {
let bucket = format!("configured-region-{index}");
client
.create_bucket()
.bucket(&bucket)
.set_create_bucket_configuration(configuration)
.send()
.await?;
let location = client.get_bucket_location().bucket(&bucket).send().await?;
assert_eq!(location.location_constraint().map(|value| value.as_str()), Some(region));
}
// A correctly signed request still cannot choose another bucket region.
let error = client
.create_bucket()
.bucket("wrong-default-region")
.create_bucket_configuration(
CreateBucketConfiguration::builder()
.location_constraint(BucketLocationConstraint::from("us-east-1"))
.build(),
)
.send()
.await
.expect_err("the default region must not bypass a custom site region");
assert_eq!(error.raw_response().map(|response| response.status().as_u16()), Some(400));
assert_eq!(
error.as_service_error().and_then(ProvideErrorMetadata::code),
Some("InvalidLocationConstraint")
);
let missing = client
.head_bucket()
.bucket("wrong-default-region")
.send()
.await
.expect_err("rejected bucket must not exist");
assert_eq!(missing.raw_response().map(|response| response.status().as_u16()), Some(404));
}
Ok(())
}
async fn location_policy_client(
env: &RustFSTestEnvironment,
user: &str,
statements: serde_json::Value,
) -> Result<Client, Box<dyn Error + Send + Sync>> {
let secret = "location-policy-test-secret";
admin_create_user(env, user, secret).await?;
let policy = serde_json::json!({"Version":"2012-10-17", "Statement":statements});
admin_add_canned_policy_via(
AdminTransport::Signed,
&env.url,
&env.access_key,
&env.secret_key,
user,
&policy.to_string(),
)
.await?;
admin_attach_user_policy_via(AdminTransport::Signed, &env.url, &env.access_key, &env.secret_key, user, user).await?;
Ok(Client::from_conf(
build_test_s3_config(&env.url, user, secret, None, "location-policy")
.to_builder()
.region(Region::new("eu-west-1"))
.build(),
))
}
#[tokio::test]
async fn test_create_bucket_location_constraint_policy_allow() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec!["--region", "eu-west-1"]).await?;
let root = Client::from_conf(
env.create_s3_client()
.config()
.to_builder()
.region(Region::new("eu-west-1"))
.build(),
);
let configuration = |region: &str| {
CreateBucketConfiguration::builder()
.location_constraint(BucketLocationConstraint::from(region))
.build()
};
for (user, condition, accepts_region, accepts_alias, accepts_missing) in [
(
"location-allow",
serde_json::json!({"StringEquals":{"s3:LocationConstraint":"eu-west-1"}}),
true,
false,
false,
),
(
"location-alias",
serde_json::json!({"StringEquals":{"s3:LocationConstraint":"EU"}}),
false,
true,
false,
),
(
"location-null",
serde_json::json!({"Null":{"s3:LocationConstraint":"true"}}),
false,
false,
true,
),
] {
let client = location_policy_client(
&env,
user,
serde_json::json!([{
"Effect":"Allow", "Action":"s3:CreateBucket", "Resource":"arn:aws:s3:::location-*", "Condition":condition,
}]),
)
.await?;
for (index, (config, allowed)) in [
(Some(configuration("eu-west-1")), accepts_region),
(Some(configuration("EU")), accepts_alias),
(None, accepts_missing),
(Some(CreateBucketConfiguration::builder().build()), accepts_missing),
(Some(configuration("")), accepts_missing),
]
.into_iter()
.enumerate()
{
let bucket = format!("{user}-{index}");
let result = client
.create_bucket()
.bucket(&bucket)
.set_create_bucket_configuration(config)
.send()
.await;
if allowed {
result.unwrap_or_else(|error| panic!("{bucket} should be allowed: {error:?}"));
root.head_bucket().bucket(&bucket).send().await?;
} else {
let denied = result.expect_err("the requested XML constraint must satisfy the conditional Allow");
assert_eq!(denied.raw_response().map(|response| response.status().as_u16()), Some(403), "{bucket}");
assert_eq!(
denied.as_service_error().and_then(ProvideErrorMetadata::code),
Some("AccessDenied"),
"{bucket}"
);
let absent = root
.head_bucket()
.bucket(&bucket)
.send()
.await
.expect_err("denied bucket must not be created");
assert_eq!(absent.raw_response().map(|response| response.status().as_u16()), Some(404));
}
}
}
let object_lock_client = location_policy_client(
&env,
"location-lock",
serde_json::json!([
{"Effect":"Allow", "Action":"s3:CreateBucket", "Resource":"arn:aws:s3:::location-*",
"Condition":{"StringEquals":{"s3:LocationConstraint":"eu-west-1"}}},
{"Effect":"Allow", "Action":["s3:PutBucketObjectLockConfiguration", "s3:PutBucketVersioning"],
"Resource":"arn:aws:s3:::location-*", "Condition":{"Null":{"s3:LocationConstraint":"true"}}}
]),
)
.await?;
object_lock_client
.create_bucket()
.bucket("location-with-lock")
.create_bucket_configuration(configuration("eu-west-1"))
.object_lock_enabled_for_bucket(true)
.send()
.await
.expect("only CreateBucket receives the XML condition; dependent actions see it absent");
let object_lock = root
.get_object_lock_configuration()
.bucket("location-with-lock")
.send()
.await?;
assert_eq!(
object_lock
.object_lock_configuration()
.and_then(|configuration| configuration.object_lock_enabled())
.map(|value| value.as_str()),
Some("Enabled")
);
let versioning = root.get_bucket_versioning().bucket("location-with-lock").send().await?;
assert_eq!(versioning.status().map(|value| value.as_str()), Some("Enabled"));
let user = "location-session";
location_policy_client(
&env,
user,
serde_json::json!([{
"Effect":"Allow", "Action":"s3:CreateBucket", "Resource":"arn:aws:s3:::location-*"
}]),
)
.await?;
let session_policy = serde_json::json!({"Version":"2012-10-17", "Statement":[
{"Effect":"Allow", "Action":"s3:CreateBucket", "Resource":"arn:aws:s3:::location-*"},
{"Effect":"Deny", "Action":"s3:CreateBucket", "Resource":"arn:aws:s3:::location-*",
"Condition":{"StringEquals":{"s3:LocationConstraint":"eu-west-1"}}}
]});
let assumed = build_test_sts_client(&env.url, user, "location-policy-test-secret", None, "location-policy-sts")
.assume_role()
.role_arn("arn:aws:iam::123456789012:role/location-policy")
.role_session_name("location-policy-session")
.policy(session_policy.to_string())
.send()
.await?;
let credentials = assumed.credentials().expect("AssumeRole returns temporary credentials");
let session = Client::from_conf(
build_test_s3_config(
&env.url,
credentials.access_key_id(),
credentials.secret_access_key(),
Some(credentials.session_token()),
"location-policy-session",
)
.to_builder()
.region(Region::new("eu-west-1"))
.build(),
);
let denied = session
.create_bucket()
.bucket("location-session-denied")
.create_bucket_configuration(configuration("eu-west-1"))
.send()
.await
.expect_err("the STS session's conditional Deny must be enforced");
assert_eq!(denied.as_service_error().and_then(ProvideErrorMetadata::code), Some("AccessDenied"));
let absent = root
.head_bucket()
.bucket("location-session-denied")
.send()
.await
.expect_err("session denial must not create a bucket");
assert_eq!(absent.raw_response().map(|response| response.status().as_u16()), Some(404));
session.create_bucket().bucket("location-session-allowed").send().await?;
Ok(())
}
#[tokio::test]
async fn test_create_bucket_location_constraint_policy_deny() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec!["--region", "eu-west-1"]).await?;
let root = Client::from_conf(
env.create_s3_client()
.config()
.to_builder()
.region(Region::new("eu-west-1"))
.build(),
);
let user = "location-deny";
let client = location_policy_client(
&env,
user,
serde_json::json!([
{"Effect":"Allow", "Action":"s3:CreateBucket", "Resource":"arn:aws:s3:::location-*"},
{"Effect":"Deny", "Action":"s3:CreateBucket", "Resource":"arn:aws:s3:::location-*",
"Condition":{"StringEquals":{"s3:LocationConstraint":"eu-west-1"}}},
]),
)
.await?;
let denied = client
.create_bucket()
.bucket("location-deny-matching")
.create_bucket_configuration(
CreateBucketConfiguration::builder()
.location_constraint(BucketLocationConstraint::from("eu-west-1"))
.build(),
)
.send()
.await
.expect_err("a matching explicit Deny must override the unconditional Allow");
assert_eq!(denied.as_service_error().and_then(ProvideErrorMetadata::code), Some("AccessDenied"));
assert_eq!(denied.raw_response().map(|response| response.status().as_u16()), Some(403));
let absent = root
.head_bucket()
.bucket("location-deny-matching")
.send()
.await
.expect_err("denied bucket must not exist");
assert_eq!(absent.raw_response().map(|response| response.status().as_u16()), Some(404));
client.create_bucket().bucket("location-deny-missing").send().await?;
let xml = r#"<CreateBucketConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><LocationConstraint>eu-west-1</LocationConstraint></CreateBucketConfiguration>"#;
let mut headers = http::HeaderMap::new();
headers.insert("locationconstraint", http::HeaderValue::from_static("us-east-1"));
for (index, body) in [xml.to_owned(), xml.replace("eu-west-1", "eu-west-&#49;")]
.into_iter()
.enumerate()
{
let bucket = format!("location-deny-spoofed-{index}");
let spoofed = signed_s3_request_with_headers(
http::Method::PUT,
&format!("{}/{bucket}?LocationConstraint=us-east-1", env.url),
Some(body),
Some("application/xml"),
user,
"location-policy-test-secret",
&headers,
)
.await?;
assert_eq!(spoofed.status(), reqwest::StatusCode::FORBIDDEN);
assert!(spoofed.text().await?.contains("<Code>AccessDenied</Code>"));
let absent = root
.head_bucket()
.bucket(&bucket)
.send()
.await
.expect_err("spoofed conditions must not create a bucket");
assert_eq!(absent.raw_response().map(|response| response.status().as_u16()), Some(404));
}
let bucket = "location-anonymous";
root.create_bucket().bucket(bucket).send().await?;
for (effect, match_status, missing_status) in [
("Allow", reqwest::StatusCode::CONFLICT, reqwest::StatusCode::FORBIDDEN),
("Deny", reqwest::StatusCode::FORBIDDEN, reqwest::StatusCode::CONFLICT),
] {
let mut statements = vec![];
if effect == "Deny" {
statements.push(serde_json::json!({"Effect":"Allow", "Principal":"*", "Action":"s3:CreateBucket", "Resource":format!("arn:aws:s3:::{bucket}")}));
}
statements.push(serde_json::json!({"Effect":effect, "Principal":"*", "Action":"s3:CreateBucket", "Resource":format!("arn:aws:s3:::{bucket}"),
"Condition":{"StringEquals":{"s3:LocationConstraint":"eu-west-1"}}}));
root.put_bucket_policy()
.bucket(bucket)
.policy(serde_json::json!({"Version":"2012-10-17", "Statement":statements}).to_string())
.send()
.await?;
let unsigned = local_http_client();
for (body, expected) in [(xml, match_status), ("", missing_status)] {
let response = unsigned
.put(format!("{}/{bucket}", env.url))
.header("content-type", "application/xml")
.body(body)
.send()
.await?;
assert_eq!(response.status(), expected, "anonymous {effect}, XML present={}", !body.is_empty());
let response = response.text().await?;
assert!(response.contains(if expected == reqwest::StatusCode::FORBIDDEN {
"<Code>AccessDenied</Code>"
} else {
"<Code>BucketAlreadyExists</Code>"
}));
}
}
Ok(())
}
}
+18 -10
View File
@@ -14,6 +14,7 @@
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::{BucketVersioningStatus, VersioningConfiguration};
use futures::{StreamExt, TryStreamExt, stream};
use rustfs_data_usage::DataUsageInfo;
use tokio::time::{Duration, sleep};
@@ -74,16 +75,23 @@ async fn data_usage_reports_all_objects() -> Result<(), Box<dyn std::error::Erro
// Create bucket and upload objects
client.create_bucket().bucket(TEST_BUCKET).send().await?;
for i in 0..1000 {
let key = format!("obj-{i:04}");
client
.put_object()
.bucket(TEST_BUCKET)
.key(key)
.body(ByteStream::from_static(b"hello-world"))
.send()
.await?;
}
// Keep the full fixture and await every durable PUT before checking publication.
stream::iter(0..1000)
.map(Ok)
.try_for_each_concurrent(16, |i| {
let client = &client;
async move {
client
.put_object()
.bucket(TEST_BUCKET)
.key(format!("obj-{i:04}"))
.body(ByteStream::from_static(b"hello-world"))
.send()
.await
.map(|_| ())
}
})
.await?;
let usage = wait_for_bucket_usage(&env, TEST_BUCKET, |usage| {
usage
@@ -24,6 +24,163 @@
//! `xl.meta` immediately visible, and the old file-cache shortcut has been
//! removed from the read path.
use aws_sdk_s3::Client;
use aws_sdk_s3::config::retry::RetryConfig;
use aws_sdk_s3::types::{BucketVersioningStatus, Delete, ObjectIdentifier, VersioningConfiguration};
use std::collections::BTreeMap;
async fn delete_exact_marker_versions(
client: &Client,
bucket: &str,
key: &str,
versions: &[(String, bool)],
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let objects = versions
.iter()
.map(|(version, _)| ObjectIdentifier::builder().key(key).version_id(version).build())
.collect::<Result<Vec<_>, _>>()?;
let response = client
.delete_objects()
.bucket(bucket)
.delete(Delete::builder().set_objects(Some(objects)).build()?)
.send()
.await?;
assert!(response.errors().is_empty(), "explicit batch returned errors: {response:?}");
let mut actual = BTreeMap::new();
for deleted in response.deleted() {
assert_eq!(deleted.key(), Some(key));
*actual
.entry((
deleted.version_id().ok_or("Deleted entry omitted version ID")?.to_string(),
deleted.delete_marker().unwrap_or(false),
deleted.delete_marker_version_id().map(str::to_string),
))
.or_insert(0_usize) += 1;
}
let mut expected = BTreeMap::new();
for (version, marker) in versions {
*expected
.entry((version.clone(), *marker, marker.then(|| version.clone())))
.or_insert(0_usize) += 1;
}
assert_eq!(actual, expected, "every requested occurrence must report its own version identity");
Ok(())
}
pub(crate) async fn assert_explicit_marker_batch_cleanup(
clients: &[Client],
bucket: &str,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
assert!(!clients.is_empty());
let clients: Vec<_> = clients
.iter()
.map(|client| {
Client::from_conf(
client
.config()
.to_builder()
.retry_config(RetryConfig::standard().with_max_attempts(1))
.build(),
)
})
.collect();
let writer = &clients[0];
let marker_writer = &clients[1 % clients.len()];
let deleter = &clients[2 % clients.len()];
let key = "same-key-history";
writer.create_bucket().bucket(bucket).send().await?;
writer
.put_bucket_versioning()
.bucket(bucket)
.versioning_configuration(
VersioningConfiguration::builder()
.status(BucketVersioningStatus::Enabled)
.build(),
)
.send()
.await?;
let mut data = Vec::new();
for byte in [41_u8, 73_u8] {
let body = vec![byte; 1024 * 1024];
let uploaded = writer
.put_object()
.bucket(bucket)
.key(key)
.body(body.clone().into())
.send()
.await?;
data.push((uploaded.version_id().ok_or("versioned PUT omitted ID")?.to_string(), body));
}
let mut markers = Vec::with_capacity(16);
for _ in 0..16 {
let deleted = marker_writer.delete_object().bucket(bucket).key(key).send().await?;
assert_eq!(deleted.delete_marker(), Some(true));
markers.push(deleted.version_id().ok_or("marker creation omitted ID")?.to_string());
}
let mut requested: Vec<_> = markers.iter().map(|id| (id.clone(), true)).collect();
requested.push((markers[0].clone(), true));
requested.push((markers[15].clone(), true));
delete_exact_marker_versions(deleter, bucket, key, &requested).await?;
for client in &clients {
let versions = client.list_object_versions().bucket(bucket).send().await?;
assert!(versions.delete_markers().is_empty(), "marker survived successful batch: {versions:?}");
let mut actual: Vec<_> = versions
.versions()
.iter()
.filter_map(|version| version.version_id())
.collect();
actual.sort_unstable();
let mut expected: Vec<_> = data.iter().map(|(id, _)| id.as_str()).collect();
expected.sort_unstable();
assert_eq!(actual, expected);
let current = client.get_object().bucket(bucket).key(key).send().await?;
assert_eq!(current.body.collect().await?.into_bytes().as_ref(), data[1].1.as_slice());
let historical = client
.get_object()
.bucket(bucket)
.key(key)
.version_id(&data[0].0)
.send()
.await?;
assert_eq!(historical.body.collect().await?.into_bytes().as_ref(), data[0].1.as_slice());
}
let uuid_marker = marker_writer.delete_object().bucket(bucket).key(key).send().await?;
let uuid_marker = uuid_marker.version_id().ok_or("marker creation omitted UUID")?.to_string();
writer
.put_bucket_versioning()
.bucket(bucket)
.versioning_configuration(
VersioningConfiguration::builder()
.status(BucketVersioningStatus::Suspended)
.build(),
)
.send()
.await?;
let null_marker = marker_writer.delete_object().bucket(bucket).key(key).send().await?;
assert_eq!(null_marker.delete_marker(), Some(true));
assert_eq!(null_marker.version_id(), Some("null"));
let requested = [
(uuid_marker.clone(), true),
(data[1].0.clone(), false),
("null".to_string(), true),
(data[0].0.clone(), false),
(uuid_marker, true),
];
delete_exact_marker_versions(deleter, bucket, key, &requested).await?;
for client in &clients {
let versions = client.list_object_versions().bucket(bucket).send().await?;
assert!(versions.versions().is_empty() && versions.delete_markers().is_empty(), "{versions:?}");
}
clients
.last()
.expect("nonempty clients")
.delete_bucket()
.bucket(bucket)
.send()
.await?;
Ok(())
}
#[cfg(test)]
mod tests {
use crate::common::{RustFSTestEnvironment, init_logging};
@@ -35,6 +192,16 @@ mod tests {
env.create_s3_client()
}
#[tokio::test]
async fn test_explicit_delete_marker_batch_removes_versions_without_client_retries()
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server_with_env(vec![], &[("RUSTFS_CONSOLE_ENABLE", "false")])
.await?;
super::assert_explicit_marker_batch_cleanup(&[env.create_s3_client()], "explicit-marker-batch").await
}
/// Regression test for Issue #1878.
///
/// Verifies that after calling `delete_objects` (the batch plural API) on
@@ -0,0 +1,378 @@
// Copyright 2026 RustFS Team
// SPDX-License-Identifier: Apache-2.0
#![cfg(unix)]
use super::artifact_seal_test::{assert_exact_version, confirm_seal, create_artifact_attempt};
use super::harness::{
DistCluster, DistLayout, TestResult, enable_versioning, payload_for, s3_probe_client, unique_bucket, wait_for_ready,
};
use crate::chaos::{VersionShardCensus, census_object_version_on_disk};
use crate::common::init_logging;
use aws_sdk_s3::error::ProvideErrorMetadata;
use aws_sdk_s3::types::{Delete, ObjectIdentifier};
use serde_json::json;
use std::collections::{BTreeMap, BTreeSet};
use std::os::unix::fs::MetadataExt;
use std::path::{Path, PathBuf};
use std::time::Duration;
use tokio::time::timeout;
const PUBLIC_INTERNAL_ERROR: &str = "We encountered an internal error, please try again.";
#[derive(Debug, PartialEq, Eq)]
pub(super) struct PhysicalEntry {
identity: (u64, u64),
changed: (i64, i64),
modified: (i64, i64),
bytes: Option<Vec<u8>>,
}
// Include directory identity/ctime as well as file bytes. Creating and removing
// a temporary marker/rollback file cannot hide behind a restored final xl.meta.
// Reads change atime, which is deliberately excluded.
pub(super) fn physical_snapshot(dist: &DistCluster, bucket: &str) -> TestResult<BTreeMap<(usize, PathBuf), PhysicalEntry>> {
let mut snapshot = BTreeMap::new();
for (node, instance) in dist.cluster.nodes.iter().enumerate() {
let root = Path::new(&instance.data_dirs[0]).join(bucket);
for entry in walkdir::WalkDir::new(&root) {
let entry = entry?;
let metadata = std::fs::symlink_metadata(entry.path())?;
assert!(!metadata.file_type().is_symlink(), "unexpected fixture symlink");
snapshot.insert(
(node, entry.path().strip_prefix(&root)?.to_path_buf()),
PhysicalEntry {
identity: (metadata.dev(), metadata.ino()),
changed: (metadata.ctime(), metadata.ctime_nsec()),
modified: (metadata.mtime(), metadata.mtime_nsec()),
bytes: if metadata.is_dir() {
None
} else {
Some(std::fs::read(entry.path())?)
},
},
);
}
}
Ok(snapshot)
}
struct Version {
key: String,
id: String,
body: Vec<u8>,
manifests: Vec<VersionShardCensus>,
}
impl Version {
async fn put(dist: &DistCluster, bucket: &str, key: &str, seed: &str) -> TestResult<Self> {
let body = payload_for(seed, 2 * 1024 * 1024);
let output = timeout(
Duration::from_secs(30),
dist.client(1)?
.put_object()
.bucket(bucket)
.key(key)
.body(body.clone().into())
.send(),
)
.await??;
let id = output.version_id().ok_or("fixture PUT omitted version ID")?.to_owned();
let manifests = dist
.cluster
.nodes
.iter()
.map(|node| census_object_version_on_disk(Path::new(&node.data_dirs[0]), bucket, key, Some(&id)))
.collect::<TestResult<Vec<_>>>()?;
assert!(
manifests
.iter()
.all(|manifest| manifest.is_complete() && !manifest.expected_part_numbers.is_empty())
);
Ok(Self {
key: key.to_owned(),
id,
body,
manifests,
})
}
}
fn target(key: &str, version: Option<&str>) -> TestResult<ObjectIdentifier> {
Ok(ObjectIdentifier::builder()
.key(key)
.set_version_id(version.map(str::to_owned))
.build()?)
}
async fn assert_fixture(
dist: &DistCluster,
bucket: &str,
prefix: &str,
versions: &[Version],
marker: (&str, &str),
) -> TestResult {
let expected: BTreeSet<_> = versions
.iter()
.map(|version| (version.key.clone(), version.id.clone()))
.collect();
for node in 0..dist.cluster.nodes.len() {
let client = dist.client(node)?;
let listed = timeout(
Duration::from_secs(30),
client.list_object_versions().bucket(bucket).prefix(prefix).send(),
)
.await??;
let actual: BTreeSet<_> = listed
.versions()
.iter()
.map(|version| {
(
version.key().unwrap_or_default().to_owned(),
version.version_id().unwrap_or_default().to_owned(),
)
})
.collect();
assert_eq!(listed.versions().len(), versions.len());
assert_eq!(actual, expected, "delete changed exact data versions on node {node}");
assert_eq!(listed.delete_markers().len(), 1);
assert_eq!(listed.delete_markers()[0].key(), Some(marker.0));
assert_eq!(listed.delete_markers()[0].version_id(), Some(marker.1));
assert_eq!(listed.delete_markers()[0].is_latest(), Some(true));
for version in versions {
assert_exact_version(&client, bucket, &version.key, &version.id, &version.body).await?;
let disk = Path::new(&dist.cluster.nodes[node].data_dirs[0]);
assert!(
census_object_version_on_disk(disk, bucket, &version.key, Some(&version.id))?
.matches_manifest(&version.manifests[node])
);
}
}
Ok(())
}
async fn assert_single_denied(dist: &DistCluster, node: usize, bucket: &str, key: &str, version: Option<&str>) -> TestResult {
let before = physical_snapshot(dist, bucket)?;
// One attempt: a later retry must not conceal a first local mutation.
let client = s3_probe_client(&dist.client(node)?);
let error = timeout(
Duration::from_secs(30),
client
.delete_object()
.bucket(bucket)
.key(key)
.set_version_id(version.map(str::to_owned))
.send(),
)
.await?
.expect_err("ordinary deletion succeeded in a managed bucket");
let service = error
.as_service_error()
.ok_or("delete failed without an S3 service rejection")?;
assert_eq!(error.raw_response().map(|response| response.status().as_u16()), Some(500));
assert_eq!(service.code(), Some("InternalError"));
assert_eq!(service.message(), Some(PUBLIC_INTERNAL_ERROR));
assert!(
physical_snapshot(dist, bucket)? == before,
"first delete through node {node} wrote or rolled back managed data"
);
Ok(())
}
async fn assert_batch_denied(dist: &DistCluster, node: usize, bucket: &str, objects: &[ObjectIdentifier]) -> TestResult {
let before = physical_snapshot(dist, bucket)?;
let client = s3_probe_client(&dist.client(node)?);
let response = timeout(
Duration::from_secs(30),
client
.delete_objects()
.bucket(bucket)
.delete(Delete::builder().set_objects(Some(objects.to_vec())).quiet(false).build()?)
.send(),
)
.await??;
assert!(response.deleted().is_empty(), "managed batch reported a successful delete");
assert_eq!(response.errors().len(), objects.len(), "batch must reject every requested occurrence");
for (error, requested) in response.errors().iter().zip(objects) {
assert_eq!(error.key(), Some(requested.key()));
assert_eq!(error.version_id(), requested.version_id());
assert_eq!(error.code(), Some("InternalError"));
assert_eq!(error.message(), Some(PUBLIC_INTERNAL_ERROR));
}
assert!(
physical_snapshot(dist, bucket)? == before,
"first batch through node {node} wrote or rolled back managed data"
);
Ok(())
}
fn copy_existing_object(source: &Path, destination: &Path) -> TestResult {
assert!(!destination.exists());
for entry in walkdir::WalkDir::new(source) {
let entry = entry?;
let target = destination.join(entry.path().strip_prefix(source)?);
if entry.file_type().is_dir() {
std::fs::create_dir_all(&target)?;
} else {
assert!(entry.file_type().is_file());
std::fs::copy(entry.path(), target)?;
}
}
Ok(())
}
async fn ordinary_delete_control(dist: &DistCluster, bucket: &str) -> TestResult {
let client = s3_probe_client(&dist.client(0)?);
let key = "ordinary-observer-control.bin";
let version = Version::put(dist, bucket, key, key).await?;
let before = physical_snapshot(dist, bucket)?;
let deleted = client.delete_object().bucket(bucket).key(key).send().await?;
assert_eq!(deleted.delete_marker(), Some(true));
let marker = deleted.version_id().ok_or("ordinary delete omitted marker ID")?;
assert!(
physical_snapshot(dist, bucket)? != before,
"physical observer missed real marker creation"
);
client
.delete_object()
.bucket(bucket)
.key(key)
.version_id(marker)
.send()
.await?;
// The logical version inventory is restored, but ctime/identity still
// expose the two writes. This checks the observer used by managed negatives.
let listed = client.list_object_versions().bucket(bucket).prefix(key).send().await?;
assert_eq!(listed.versions().len(), 1);
assert_eq!(listed.versions()[0].version_id(), Some(version.id.as_str()));
assert!(listed.delete_markers().is_empty());
assert!(
physical_snapshot(dist, bucket)? != before,
"marker round-trip hid transient physical writes"
);
assert_exact_version(&client, bucket, key, &version.id, &version.body).await?;
client
.delete_object()
.bucket(bucket)
.key(key)
.version_id(&version.id)
.send()
.await?;
assert!(
client
.list_object_versions()
.bucket(bucket)
.prefix(key)
.send()
.await?
.versions()
.is_empty()
);
let current = Version::put(dist, bucket, "ordinary-batch-current.bin", "current").await?;
let exact = Version::put(dist, bucket, "ordinary-batch-exact.bin", "exact").await?;
let response = client
.delete_objects()
.bucket(bucket)
.delete(
Delete::builder()
.objects(target(&current.key, None)?)
.objects(target(&exact.key, Some(&exact.id))?)
.objects(target(&exact.key, Some(&exact.id))?)
.build()?,
)
.send()
.await?;
assert!(response.errors().is_empty());
assert_eq!(response.deleted().len(), 3);
let listed = client
.list_object_versions()
.bucket(bucket)
.prefix(&current.key)
.send()
.await?;
assert_eq!(listed.versions().len(), 1);
assert_eq!(listed.delete_markers().len(), 1);
assert!(
client
.list_object_versions()
.bucket(bucket)
.prefix(&exact.key)
.send()
.await?
.versions()
.is_empty()
);
Ok(())
}
#[tokio::test]
async fn four_node_artifact_delete_entrypoints_preserve_versions_and_physical_state() -> TestResult {
init_logging();
let mut dist = DistCluster::start(DistLayout::FourNodeFourDisk).await?;
let (identity, admin) = create_artifact_attempt(&dist).await?;
let bucket = identity["bucket"]
.as_str()
.ok_or("managed receipt omitted bucket")?
.to_owned();
let prefix = identity["attempt_prefix"]
.as_str()
.ok_or("managed receipt omitted prefix")?
.to_owned();
let key = format!("{prefix}native.bin");
let mut versions = vec![
Version::put(&dist, &bucket, &key, "native-old").await?,
Version::put(&dist, &bucket, &key, "native-new").await?,
];
let ordinary = unique_bucket("delete-control");
dist.create_bucket(&ordinary).await?;
enable_versioning(&dist.client(0)?, &ordinary).await?;
// A new managed bucket cannot legitimately create a marker. Model existing
// persisted state from before adoption/upgrade using a real S3-generated
// ordinary object+marker, copied disk-for-disk in the same erasure set.
// Only this object's files move; bucket authority/configuration is untouched.
let marker_key = format!("{prefix}existing-marker.bin");
versions.push(Version::put(&dist, &ordinary, &marker_key, "existing-data").await?);
let output = dist
.client(2)?
.delete_object()
.bucket(&ordinary)
.key(&marker_key)
.send()
.await?;
assert_eq!(output.delete_marker(), Some(true));
let marker = output.version_id().ok_or("ordinary fixture omitted marker ID")?.to_owned();
dist.cluster.stop();
for node in &dist.cluster.nodes {
let disk = Path::new(&node.data_dirs[0]);
copy_existing_object(&disk.join(&ordinary).join(&marker_key), &disk.join(&bucket).join(&marker_key))?;
}
dist.cluster.start().await?;
wait_for_ready(&dist.cluster).await?;
assert_fixture(&dist, &bucket, &prefix, &versions, (&marker_key, &marker)).await?;
ordinary_delete_control(&dist, &ordinary).await?;
let batch = vec![
target(&key, None)?,
target(&key, Some(&versions[0].id))?,
target(&key, Some(&versions[1].id))?,
target(&marker_key, Some(&marker))?,
target(&key, Some(&versions[0].id))?,
target(&marker_key, Some(&versions[2].id))?,
];
for phase in ["open", "sealed", "restarted"] {
if phase == "sealed" {
confirm_seal(&dist, &admin, &json!({"version": 1, "target": identity}).to_string(), &identity).await?;
} else if phase == "restarted" {
dist.restart_with_current_binary().await?;
}
for node in 0..dist.cluster.nodes.len() {
assert_single_denied(&dist, node, &bucket, &key, None).await?;
assert_single_denied(&dist, node, &bucket, &key, Some(&versions[0].id)).await?;
assert_single_denied(&dist, node, &bucket, &marker_key, Some(&marker)).await?;
assert_batch_denied(&dist, node, &bucket, &batch).await?;
}
assert_fixture(&dist, &bucket, &prefix, &versions, (&marker_key, &marker)).await?;
}
Ok(())
}
@@ -0,0 +1,434 @@
// Copyright 2026 RustFS Team
// SPDX-License-Identifier: Apache-2.0
#![cfg(unix)]
use super::artifact_delete_test::physical_snapshot;
use super::artifact_seal_test::{ArtifactAdmin, create_artifact_attempt};
use super::harness::{DistCluster, DistLayout, TestResult, wait_for_ready};
use crate::common::{
AdminTransport, admin_add_canned_policy_via, admin_attach_user_policy_via, admin_create_user_via, init_logging,
signed_s3_request,
};
use futures::{StreamExt as _, TryStreamExt as _};
use http::{Method, StatusCode};
use serde::Deserialize;
use serde_json::{Value, json};
use std::{collections::BTreeSet, time::Duration};
use tokio::time::timeout;
use uuid::Uuid;
const ROUTE: &str = "/rustfs/admin/v3/plugin-artifacts/inventory";
const BUDGET: Duration = Duration::from_secs(45);
struct Reader {
access: String,
secret: String,
}
impl Reader {
async fn create(dist: &DistCluster, target: &Value, action: &str) -> TestResult<Self> {
let reader = Self {
access: format!("inventory{}", &Uuid::new_v4().simple().to_string()[..12]),
secret: Uuid::new_v4().simple().to_string(),
};
let policy_name = format!("inventory{}", &Uuid::new_v4().simple().to_string()[..12]);
let bucket = target["bucket"].as_str().ok_or("target bucket")?;
let prefix = target["attempt_prefix"]
.as_str()
.ok_or("target prefix")?
.trim_end_matches('/');
let resource = if action == "s3:ListBucket" {
format!("arn:aws:s3:::{bucket}")
} else {
format!("arn:aws:s3:::{bucket}/{prefix}")
};
let policy =
json!({"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":action,"Resource":resource}]}).to_string();
let url = &dist.cluster.nodes[0].url;
let access = &dist.cluster.access_key;
let secret = &dist.cluster.secret_key;
admin_create_user_via(AdminTransport::Signed, url, access, secret, &reader.access, &reader.secret).await?;
admin_add_canned_policy_via(AdminTransport::Signed, url, access, secret, &policy_name, &policy).await?;
admin_attach_user_policy_via(AdminTransport::Signed, url, access, secret, &policy_name, &reader.access).await?;
Ok(reader)
}
async fn request(&self, dist: &DistCluster, target: &Value, invocation: Uuid) -> TestResult<reqwest::Response> {
timeout(
BUDGET,
signed_s3_request(
Method::POST,
&format!("{}{ROUTE}", dist.cluster.nodes[0].url),
Some(json!({"version":1,"invocation_id":invocation,"target":target}).to_string()),
Some("application/json"),
&self.access,
&self.secret,
),
)
.await?
}
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct Frame {
version: u32,
invocation_id: Uuid,
target: Value,
sequence: u64,
payload: Payload,
}
#[derive(Debug, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
enum Payload {
Observation {
disk_id: Uuid,
key: String,
version_id: Uuid,
delete_marker: bool,
},
Complete {
members: usize,
observations: u64,
},
}
async fn read_frames(mut response: reqwest::Response) -> TestResult<(Vec<Frame>, bool)> {
let mut pending = Vec::new();
let mut frames = Vec::new();
let mut clean = true;
loop {
match timeout(BUDGET, response.chunk()).await? {
Ok(Some(chunk)) => {
pending.extend_from_slice(&chunk);
while let Some(end) = pending.iter().position(|byte| *byte == b'\n') {
assert!(end < 1024 * 1024, "HTTP inventory line exceeds encoded bound");
let line: Vec<_> = pending.drain(..=end).collect();
frames.push(serde_json::from_slice(&line[..end])?);
}
assert!(pending.len() < 1024 * 1024, "unbounded incomplete HTTP line");
}
Ok(None) => break,
Err(_) => {
clean = false;
break;
}
}
}
Ok((frames, clean && pending.is_empty()))
}
async fn seal(dist: &DistCluster, user: &ArtifactAdmin, target: &Value) -> TestResult {
let (status, body) = user
.request(
dist,
0,
"/rustfs/admin/v3/plugin-artifacts/seal-writes",
json!({"version":1,"target":target}).to_string(),
)
.await?;
assert_eq!(status, StatusCode::OK, "actual all-member closure: {body}");
assert_eq!(serde_json::from_str::<Value>(&body)?["all_writes_sealed"], true);
Ok(())
}
fn exact_inventory(frames: &[Frame], target: &Value, invocation: Uuid, versions: &BTreeSet<(String, Uuid, bool)>) {
let mut observed = BTreeSet::new();
let mut disks = BTreeSet::new();
let mut count = 0u64;
for (sequence, frame) in frames.iter().enumerate() {
assert_eq!(frame.version, 1);
assert_eq!(frame.invocation_id, invocation);
assert_eq!(&frame.target, target);
assert_eq!(frame.sequence, u64::try_from(sequence).expect("bounded fixture sequence"));
match &frame.payload {
Payload::Observation {
disk_id,
key,
version_id,
delete_marker,
} => {
assert!(!disk_id.is_nil() && !version_id.is_nil());
assert!(
observed.insert((*disk_id, key.clone(), *version_id, *delete_marker)),
"duplicate physical observation"
);
disks.insert(*disk_id);
count += 1;
}
Payload::Complete { members, observations } => {
assert_eq!(sequence + 1, frames.len(), "Complete must be the last HTTP frame");
assert_eq!(*members, 4);
assert_eq!(*observations, count);
}
}
}
assert!(
matches!(frames.last().map(|frame| &frame.payload), Some(Payload::Complete { .. })),
"A03I3A_HTTP_COMPLETE"
);
if !versions.is_empty() {
assert_eq!(disks.len(), 4);
}
for disk in disks {
let actual: BTreeSet<_> = observed
.iter()
.filter(|(id, ..)| *id == disk)
.map(|(_, key, version, marker)| (key.clone(), *version, *marker))
.collect();
assert_eq!(&actual, versions, "A03I3A_EXACT_VERSIONS");
}
assert_eq!(observed.len(), versions.len() * 4);
}
#[tokio::test]
async fn artifact_inventory_http_exact_versions() -> TestResult {
crate::common::rustfs_binary_path_with_features(Some("e2e-test-hooks"));
init_logging();
let dist = DistCluster::start(DistLayout::FourNodeFourDisk).await?;
let (target, admin) = create_artifact_attempt(&dist).await?;
let bucket = target["bucket"].as_str().ok_or("bucket")?;
let prefix = target["attempt_prefix"].as_str().ok_or("prefix")?;
let mut versions = BTreeSet::new();
for (name, body) in [
("same", b"first".to_vec()),
("same", b"second".to_vec()),
("zero", Vec::new()),
] {
let key = format!("{prefix}{name}");
let output = timeout(BUDGET, dist.client(1)?.put_object().bucket(bucket).key(&key).body(body.into()).send()).await??;
let version = Uuid::parse_str(output.version_id().ok_or("actual PUT version")?)?;
versions.insert((key, version, false));
}
seal(&dist, &admin, &target).await?;
// A wildcard action is still restricted to this exact attempt resource.
// The separate authority case exercises the dedicated action alone.
let reader = Reader::create(&dist, &target, "admin:*").await?;
let before = physical_snapshot(&dist, bucket)?;
let invocation = Uuid::new_v4();
let response = reader.request(&dist, &target, invocation).await?;
assert_eq!(response.status(), StatusCode::OK, "A03I3A_HTTP_ROUTE");
assert_eq!(
response.headers().get("content-type").and_then(|value| value.to_str().ok()),
Some("application/x-ndjson")
);
let (frames, clean) = read_frames(response).await?;
assert!(clean, "Complete requires clean HTTP EOF");
assert!(frames.len() > 2, "real multi-frame HTTP body");
exact_inventory(&frames, &target, invocation, &versions);
assert!(
before == physical_snapshot(&dist, bucket)?,
"inventory mutated physical artifact namespace"
);
let (empty, empty_admin) = create_artifact_attempt(&dist).await?;
seal(&dist, &empty_admin, &empty).await?;
let empty_reader = Reader::create(&dist, &empty, "admin:*").await?;
let invocation = Uuid::new_v4();
let response = empty_reader.request(&dist, &empty, invocation).await?;
assert_eq!(response.status(), StatusCode::OK);
let (frames, clean) = read_frames(response).await?;
assert!(clean);
exact_inventory(&frames, &empty, invocation, &BTreeSet::new());
Ok(())
}
#[tokio::test]
async fn artifact_inventory_http_scoped_authority() -> TestResult {
crate::common::rustfs_binary_path_with_features(Some("e2e-test-hooks"));
init_logging();
let dist = DistCluster::start(DistLayout::FourNodeFourDisk).await?;
let (target, admin) = create_artifact_attempt(&dist).await?;
seal(&dist, &admin, &target).await?;
let reader = Reader::create(&dist, &target, "admin:InventoryPluginArtifactAttempt").await?;
let invocation = Uuid::new_v4();
let response = reader.request(&dist, &target, invocation).await?;
assert_eq!(response.status(), StatusCode::OK, "dedicated inventory authority");
let (frames, clean) = read_frames(response).await?;
assert!(clean);
exact_inventory(&frames, &target, invocation, &BTreeSet::new());
let mut other_prefix = target.clone();
other_prefix["attempt_prefix"] = json!(format!("outputs/6169/{}/{}/", Uuid::new_v4(), Uuid::new_v4()));
let mut other_bucket = target.clone();
other_bucket["bucket"] = json!("another-artifact-bucket");
for denied in [&other_prefix, &other_bucket] {
assert_eq!(
reader.request(&dist, denied, Uuid::new_v4()).await?.status(),
StatusCode::FORBIDDEN,
"A03I3A_SCOPED_AUTHORITY"
);
}
let (status, _) = admin
.request(
&dist,
0,
ROUTE,
json!({"version":1,"invocation_id":Uuid::new_v4(),"target":target}).to_string(),
)
.await?;
assert_eq!(status, StatusCode::FORBIDDEN, "A03I3A_SCOPED_AUTHORITY seal-only user");
let listed = Reader::create(&dist, &target, "s3:ListBucket").await?;
dist.client_with_credentials(0, &listed.access, &listed.secret)?
.list_objects_v2()
.bucket(target["bucket"].as_str().ok_or("bucket")?)
.send()
.await?;
assert_eq!(
listed.request(&dist, &target, Uuid::new_v4()).await?.status(),
StatusCode::FORBIDDEN,
"A03I3A_SCOPED_AUTHORITY S3 list-only user"
);
let root_response = signed_s3_request(
Method::POST,
&format!("{}{ROUTE}", dist.cluster.nodes[0].url),
Some(json!({"version":1,"invocation_id":Uuid::new_v4(),"target":target}).to_string()),
Some("application/json"),
&dist.cluster.access_key,
&dist.cluster.secret_key,
)
.await?;
assert_eq!(root_response.status(), StatusCode::FORBIDDEN, "original scoped user required");
for body in [
json!({"version":1,"invocation_id":Uuid::nil(),"target":target}),
json!({"version":1,"invocation_id":Uuid::new_v4(),"target":target,"extra":true}),
] {
let response = signed_s3_request(
Method::POST,
&format!("{}{ROUTE}", dist.cluster.nodes[0].url),
Some(body.to_string()),
Some("application/json"),
&reader.access,
&reader.secret,
)
.await?;
assert_eq!(response.status(), StatusCode::BAD_REQUEST, "strict inventory request");
}
Ok(())
}
async fn assert_incomplete(response: reqwest::Response) -> TestResult<Vec<Frame>> {
assert_eq!(
response.status(),
StatusCode::OK,
"valid request must exercise the streaming failure boundary"
);
let (frames, _) = read_frames(response).await?;
assert!(
!frames.iter().any(|frame| matches!(frame.payload, Payload::Complete { .. })),
"A03I3A_NO_FALSE_COMPLETE"
);
Ok(frames)
}
#[tokio::test]
async fn artifact_inventory_http_incomplete_has_no_terminal() -> TestResult {
crate::common::rustfs_binary_path_with_features(Some("e2e-test-hooks"));
init_logging();
let mut dist = DistCluster::start(DistLayout::FourNodeFourDisk).await?;
let (target, admin) = create_artifact_attempt(&dist).await?;
let bucket = target["bucket"].as_str().ok_or("bucket")?;
let prefix = target["attempt_prefix"].as_str().ok_or("prefix")?;
// Real PUTs produce more data than a stalled HTTP/2 flow-control window.
futures::stream::iter(0..256)
.map(|index| {
let client = dist.client(1);
let key = format!("{prefix}slow/{index:04}/{}/{}", "a".repeat(190), "b".repeat(190));
async move {
let output = client?
.put_object()
.bucket(bucket)
.key(key)
.body(vec![1].into())
.send()
.await?;
assert!(!Uuid::parse_str(output.version_id().ok_or("actual PUT version")?)?.is_nil());
Ok::<_, Box<dyn std::error::Error + Send + Sync>>(())
}
})
.buffer_unordered(8)
.try_collect::<Vec<_>>()
.await?;
seal(&dist, &admin, &target).await?;
let reader = Reader::create(&dist, &target, "admin:InventoryPluginArtifactAttempt").await?;
dist.cluster.stop_node(3)?;
assert_incomplete(reader.request(&dist, &target, Uuid::new_v4()).await?).await?;
dist.cluster.start_node(3).await?;
wait_for_ready(&dist.cluster).await?;
let mut wrong = target.clone();
wrong["bucket_incarnation"] = json!(Uuid::new_v4());
assert_incomplete(reader.request(&dist, &wrong, Uuid::new_v4()).await?).await?;
// The configured endpoint map is ordered. Damage its final receiver, so
// earlier real observations are emitted before the physical scan fails.
let node = dist
.cluster
.nodes
.iter()
.max_by_key(|node| format!("{}{}", node.url, node.data_dirs[0]))
.ok_or("configured members")?;
let metadata = std::path::Path::new(&node.data_dirs[0])
.join(bucket)
.join(format!("{prefix}slow/0000/{}/{}", "a".repeat(190), "b".repeat(190)))
.join("xl.meta");
let original = std::fs::read(&metadata)?;
std::fs::write(&metadata, b"corrupt inventory metadata")?;
let before = physical_snapshot(&dist, bucket)?;
let outcome = assert_incomplete(reader.request(&dist, &target, Uuid::new_v4()).await?).await;
let after = physical_snapshot(&dist, bucket)?;
std::fs::write(&metadata, original)?;
let frames = outcome?;
assert!(before == after, "failed inventory repaired or mutated a physical object");
assert!(
frames
.iter()
.any(|frame| matches!(frame.payload, Payload::Observation { .. })),
"late physical failure must follow real HTTP observations"
);
let client = reqwest::Client::builder()
.no_proxy()
.http2_prior_knowledge()
.http2_initial_stream_window_size(1)
.http2_initial_connection_window_size(1)
.http2_adaptive_window(false)
.build()?;
let url = format!("{}{ROUTE}", dist.cluster.nodes[0].url);
let request = |invocation| {
json!({"version":1,"invocation_id":invocation,"target":target})
.to_string()
.into_bytes()
};
let response = crate::common::signed_request_with_client(
&client,
Method::POST,
&url,
&reader.access,
&reader.secret,
Some(request(Uuid::new_v4())),
Some("application/json"),
)
.await?;
assert_eq!(response.status(), StatusCode::OK);
// Do not poll the body: its one-byte HTTP/2 receive window stalls transport.
// This waits beyond the fixed30s service budget, not for readiness.
tokio::time::sleep(Duration::from_secs(35)).await;
assert_incomplete(response).await?;
let response = crate::common::signed_request_with_client(
&client,
Method::POST,
&url,
&reader.access,
&reader.secret,
Some(request(Uuid::new_v4())),
Some("application/json"),
)
.await?;
assert_eq!(response.status(), StatusCode::OK);
drop(response);
let response = timeout(Duration::from_secs(10), reader.request(&dist, &target, Uuid::new_v4())).await??;
let (frames, clean) = timeout(Duration::from_secs(10), read_frames(response)).await??;
assert!(
clean && matches!(frames.last().map(|frame| &frame.payload), Some(Payload::Complete { .. })),
"fresh scan must succeed promptly after an actual disconnected HTTP body"
);
Ok(())
}
@@ -0,0 +1,269 @@
// Copyright 2026 RustFS Team
// SPDX-License-Identifier: Apache-2.0
use super::artifact_repair_tail_test::PhysicalBarrier;
use super::artifact_seal_test::{assert_exact_version, confirm_seal, create_artifact_attempt};
use super::harness::{DistCluster, DistLayout, TestResult, payload_for, wait_until};
use crate::chaos::census_object_version_on_disk;
use crate::common::init_logging;
use crate::storage_api::artifact_rpc::{
DiskError, TONIC_RPC_PREFIX, gen_tonic_signature_headers, set_tonic_canonical_body_digest,
};
use http::StatusCode;
use rustfs_filemeta::FileMeta;
use rustfs_protos::proto_gen::node_service::{RenameDataRequest, node_service_client::NodeServiceClient};
use serde_json::{Value, json};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::time::Duration;
use tokio::time::timeout;
use tonic::Request;
use uuid::Uuid;
const PUT_SEAL_ROUTE: &str = "/rustfs/admin/v3/plugin-artifacts/seal-attempt";
fn signed_put_request(body: RenameDataRequest, audience: &str) -> TestResult<Request<RenameDataRequest>> {
let canonical = rustfs_protos::canonical_artifact_rename_data_request_body(&body)?;
let mut request = Request::new(body);
request.set_timeout(Duration::from_secs(15));
set_tonic_canonical_body_digest(&mut request, &canonical)?;
let digest = request
.metadata()
.get("x-rustfs-content-sha256")
.ok_or("missing canonical digest")?
.to_str()?
.to_owned();
request.metadata_mut().as_mut().extend(gen_tonic_signature_headers(
audience,
TONIC_RPC_PREFIX.trim_start_matches('/'),
"ArtifactRenameData",
Some(&digest),
)?);
Ok(request)
}
fn publication_snapshot(paths: &[&Path]) -> TestResult<BTreeMap<PathBuf, Option<Vec<u8>>>> {
let mut snapshot = BTreeMap::new();
for root in paths {
for entry in walkdir::WalkDir::new(root) {
let entry = entry?;
let contents = if entry.file_type().is_dir() {
None
} else {
Some(std::fs::read(entry.path())?)
};
snapshot.insert(entry.path().to_path_buf(), contents);
}
}
Ok(snapshot)
}
async fn assert_raw_put_closed(body: &RenameDataRequest, url: &str, audience: &str, paths: &[&Path]) -> TestResult {
let before = publication_snapshot(paths)?;
// Re-sign the exact original request; replay protection is not the rejection.
let request = signed_put_request(body.clone(), audience)?;
let response = timeout(Duration::from_secs(20), async {
let mut client = NodeServiceClient::connect(url.to_owned()).await?;
Ok::<_, Box<dyn std::error::Error + Send + Sync>>(client.artifact_rename_data(request).await?.into_inner())
})
.await??;
assert!(!response.success, "sealed attempt admitted another ordinary PUT");
let error = response.error.ok_or("rejected raw PUT omitted error")?;
assert_eq!(error.code, DiskError::Io(std::io::ErrorKind::PermissionDenied.into()).to_u32());
assert_eq!(error.error_info, "io error artifact attempt mutation domain is closed");
assert_eq!(publication_snapshot(paths)?, before, "rejected raw PUT mutated staged or published files");
Ok(())
}
#[tokio::test]
async fn four_node_artifact_put_seal_waits_for_disconnected_physical_tail() -> TestResult {
crate::common::rustfs_binary_path_with_features(Some("e2e-test-hooks"));
init_logging();
let _ = rustfs_credentials::set_global_rpc_secret("rustfs-internode-signature-e2e-secret".to_owned());
let rpc_secret = rustfs_credentials::try_get_rpc_token()?;
let dist = DistCluster::start_with_env(DistLayout::FourNodeFourDisk, &[("RUSTFS_RPC_SECRET", &rpc_secret)]).await?;
let (target, user) = create_artifact_attempt(&dist).await?;
let bucket = target["bucket"].as_str().ok_or("target omitted bucket")?;
let prefix = target["attempt_prefix"].as_str().ok_or("target omitted prefix")?;
let key = format!("{prefix}detached-put.bin");
let payload = payload_for(&key, 2 * 1024 * 1024);
let output = timeout(
Duration::from_secs(30),
dist.client(1)?
.put_object()
.bucket(bucket)
.key(&key)
.body(payload.clone().into())
.send(),
)
.await??;
let version = output.version_id().ok_or("managed PUT omitted version ID")?;
let request_body = json!({"version": 1, "target": target}).to_string();
let baselines = dist
.cluster
.nodes
.iter()
.map(|node| census_object_version_on_disk(Path::new(&node.data_dirs[0]), bucket, &key, Some(version)))
.collect::<TestResult<Vec<_>>>()?;
assert!(
baselines
.iter()
.all(|baseline| baseline.is_complete() && !baseline.expected_part_numbers.is_empty())
);
let node = &dist.cluster.nodes[3];
let disk = PathBuf::from(&node.data_dirs[0]).canonicalize()?;
let object = disk.join(bucket).join(&key);
let metadata_bytes = std::fs::read(object.join("xl.meta"))?;
let fi = FileMeta::load(&metadata_bytes)?.into_fileinfo(bucket, &key, version, true, false, true)?;
let admitted_file_info = serde_json::to_value(&fi)?;
assert!(!fi.is_healing(), "ordinary publication must not carry the repair marker");
let data_dir = fi
.data_dir
.ok_or("physical-tail fixture requires external shards")?
.to_string();
let source = Uuid::new_v4().to_string();
let staging = disk.join(".rustfs.sys/tmp").join(&source);
std::fs::create_dir_all(&staging)?;
let destination = object.join(&data_dir);
// Replay the receiver publication of a real ordinary PUT while its attempt
// remains open. Preserve its referenced version metadata. The admitted
// owned operation may still finish metadata publication after RPC detach.
std::fs::rename(&destination, staging.join(&data_dir))?;
let barrier = PhysicalBarrier::arm(&disk, &destination)?;
let format: Value = serde_json::from_slice(&std::fs::read(disk.join(".rustfs.sys/format.json"))?)?;
let disk_id = Uuid::parse_str(format["xl"]["this"].as_str().ok_or("disk format omitted xl.this")?)?;
let incarnation = Uuid::parse_str(target["bucket_incarnation"].as_str().ok_or("target omitted incarnation")?)?;
let put = RenameDataRequest {
disk: disk_id.to_string(),
src_volume: ".rustfs.sys/tmp".to_owned(),
src_path: source,
dst_volume: bucket.to_owned(),
dst_path: key.clone(),
file_info: serde_json::to_string(&fi)?,
file_info_bin: Default::default(),
scanner_publication_lease_token: Default::default(),
bucket_incarnation_id: incarnation.as_bytes().to_vec().into(),
};
let request = signed_put_request(put.clone(), &node.address)?;
// Direct RPC takes no coordinator attempt lock and owns its own connection.
let url = node.url.clone();
let mut rpc = tokio::spawn(async move {
timeout(Duration::from_secs(30), async move {
let mut client = NodeServiceClient::connect(url).await?;
Ok::<_, Box<dyn std::error::Error + Send + Sync>>(client.artifact_rename_data(request).await?)
})
.await
});
tokio::select! {
result = &mut rpc => {
let response = result???.into_inner();
panic!(
"ordinary PUT receiver completed before the physical barrier: success={}, error={:?}",
response.success, response.error
);
}
arrived = barrier.observed("arrived") => arrived?,
}
assert!(!destination.exists(), "physical PUT escaped its barrier");
rpc.abort();
assert!(rpc.await.expect_err("disconnect the ordinary PUT waiter").is_cancelled());
// The ordinary PUT wrapper retains an owned task. Observe its RPC-facing
// waiter dropping, rather than cancellation of the continuing disk task.
barrier.observed("rpc-waiter-dropped").await?;
let seal = user.request(&dist, 0, PUT_SEAL_ROUTE, request_body.clone());
tokio::pin!(seal);
let closed = wait_until(
Duration::from_secs(15),
|| async {
Ok(walkdir::WalkDir::new(disk.join(".rustfs-artifact-put-seals"))
.min_depth(2)
.max_depth(2)
.into_iter()
.filter_map(Result::ok)
.any(|entry| entry.file_name() == "closed"))
},
"PUT receiver persisted closing before physical drain",
);
tokio::pin!(closed);
tokio::select! {
result = &mut seal => {
let (status, response) = result?;
panic!("PUT seal returned before its blocked receiver closed admission: {status} {response}");
}
result = &mut closed => result?,
}
let (status, response) = timeout(Duration::from_secs(40), &mut seal).await??;
assert_eq!(
status,
StatusCode::SERVICE_UNAVAILABLE,
"PUT seal confirmed an unfinished physical tail: {response}"
);
assert!(response.contains("artifact operation unconfirmed"));
assert!(!destination.exists());
assert_raw_put_closed(&put, &node.url, &node.address, &[&staging, &object]).await?;
assert!(!destination.exists(), "a second admission published while the worker was paused");
barrier.release()?;
wait_until(
Duration::from_secs(15),
|| async { Ok(destination.join("part.1").is_file()) },
"detached ordinary PUT worker finished",
)
.await?;
confirm_seal(&dist, &user, &request_body, &target).await?;
// Seal confirmation is the boundary: already admitted work may re-encode
// metadata before this point, but the complete FileInfo must be unchanged.
let published_objects: Vec<_> = dist
.cluster
.nodes
.iter()
.map(|node| PathBuf::from(&node.data_dirs[0]).join(bucket).join(&key))
.collect();
let published_paths: Vec<_> = published_objects.iter().map(PathBuf::as_path).collect();
let sealed_snapshot = publication_snapshot(&published_paths)?;
let sealed_file_info =
FileMeta::load(&std::fs::read(object.join("xl.meta"))?)?.into_fileinfo(bucket, &key, version, true, false, true)?;
assert_eq!(
serde_json::to_value(sealed_file_info)?,
admitted_file_info,
"admitted PUT changed FileInfo semantics"
);
for (index, baseline) in baselines.iter().enumerate() {
assert!(
census_object_version_on_disk(Path::new(&dist.cluster.nodes[index].data_dirs[0]), bucket, &key, Some(version))?
.matches_manifest(baseline)
);
assert_exact_version(&dist.client(index)?, bucket, &key, version, &payload).await?;
}
// Stage the exact request again. A source metadata sentinel makes a missing
// admission check observable even if the existing destination later causes
// ENOTEMPTY: ordinary rename rewrites source metadata before that syscall.
let staged_data = staging.join(&data_dir);
std::fs::create_dir_all(&staged_data)?;
for entry in std::fs::read_dir(&destination)? {
let entry = entry?;
assert!(entry.file_type()?.is_file(), "unexpected nested shard fixture");
std::fs::copy(entry.path(), staged_data.join(entry.file_name()))?;
}
std::fs::write(staging.join("xl.meta"), b"closed-admission-must-preserve-this-staged-metadata")?;
assert_raw_put_closed(&put, &node.url, &node.address, &[&staging, &object]).await?;
let listed = dist
.client(0)?
.list_object_versions()
.bucket(bucket)
.prefix(&key)
.send()
.await?;
assert_eq!(listed.versions().len(), 1, "raw PUT retries created another version");
assert_eq!(listed.versions()[0].version_id(), Some(version));
assert!(listed.delete_markers().is_empty());
confirm_seal(&dist, &user, &request_body, &target).await?;
assert!(
publication_snapshot(&published_paths)? == sealed_snapshot,
"published files changed after the first successful physical-drain confirmation"
);
Ok(())
}
@@ -0,0 +1,218 @@
// Copyright 2026 RustFS Team
// SPDX-License-Identifier: Apache-2.0
use super::artifact_seal_test::{assert_exact_version, confirm_seal, create_artifact_attempt};
use super::harness::{DistCluster, DistLayout, TestResult, payload_for, wait_until};
use crate::chaos::census_object_version_on_disk;
use crate::common::init_logging;
use crate::storage_api::artifact_rpc::{TONIC_RPC_PREFIX, gen_tonic_signature_headers, set_tonic_canonical_body_digest};
use http::StatusCode;
use rustfs_filemeta::FileMeta;
use rustfs_protos::proto_gen::node_service::{
ArtifactRepairRenameDataRequest, RenameDataRequest, node_service_client::NodeServiceClient,
};
use serde_json::{Value, json};
use std::path::{Path, PathBuf};
use std::time::Duration;
use tokio::time::timeout;
use tonic::Request;
use uuid::Uuid;
const REPAIR_SEAL_ROUTE: &str = "/rustfs/admin/v3/plugin-artifacts/seal-repairs";
pub(super) struct PhysicalBarrier(PathBuf);
impl PhysicalBarrier {
pub(super) fn arm(disk: &Path, destination: &Path) -> TestResult<Self> {
let path = disk.join(".rustfs.sys/e2e-physical-rename-barrier");
std::fs::write(&path, destination.to_str().ok_or("non-UTF8 fixture destination")?)?;
Ok(Self(path))
}
pub(super) async fn observed(&self, stage: &str) -> TestResult {
wait_until(Duration::from_secs(15), || async { Ok(self.0.with_extension(stage).is_file()) }, stage).await
}
pub(super) fn release(&self) -> TestResult {
std::fs::write(self.0.with_extension("release"), b"release physical worker")?;
Ok(())
}
}
impl Drop for PhysicalBarrier {
fn drop(&mut self) {
// Release even on assertion failure; the worker also has its own deadline.
let _ = self.release();
let _ = std::fs::remove_file(&self.0);
}
}
#[tokio::test]
async fn four_node_artifact_repair_seal_waits_for_disconnected_physical_tail() -> TestResult {
crate::common::rustfs_binary_path_with_features(Some("e2e-test-hooks"));
init_logging();
// Match the other raw internode signature tests sharing the OnceLock.
let _ = rustfs_credentials::set_global_rpc_secret("rustfs-internode-signature-e2e-secret".to_owned());
let rpc_secret = rustfs_credentials::try_get_rpc_token()?;
let dist = DistCluster::start_with_env(DistLayout::FourNodeFourDisk, &[("RUSTFS_RPC_SECRET", &rpc_secret)]).await?;
let (target, user) = create_artifact_attempt(&dist).await?;
let bucket = target["bucket"].as_str().ok_or("target omitted bucket")?;
let prefix = target["attempt_prefix"].as_str().ok_or("target omitted prefix")?;
let key = format!("{prefix}detached-rename.bin");
let body = payload_for(&key, 2 * 1024 * 1024);
let output = timeout(
Duration::from_secs(30),
dist.client(1)?
.put_object()
.bucket(bucket)
.key(&key)
.body(body.clone().into())
.send(),
)
.await??;
let version = output.version_id().ok_or("managed PUT omitted version ID")?;
let request_body = json!({"version": 1, "target": target}).to_string();
confirm_seal(&dist, &user, &request_body, &target).await?;
let node = &dist.cluster.nodes[3];
let disk = PathBuf::from(&node.data_dirs[0]).canonicalize()?;
let object = disk.join(bucket).join(&key);
let metadata = FileMeta::load(&std::fs::read(object.join("xl.meta"))?)?;
let mut fi = metadata.into_fileinfo(bucket, &key, version, true, false, true)?;
fi.set_healing();
let data_dir = fi
.data_dir
.ok_or("physical-tail fixture requires external shard files")?
.to_string();
let baseline = census_object_version_on_disk(&disk, bucket, &key, Some(version))?;
assert!(baseline.is_complete() && !baseline.expected_part_numbers.is_empty());
let source = Uuid::new_v4().to_string();
let staging = disk.join(".rustfs.sys/tmp").join(&source);
std::fs::create_dir_all(&staging)?;
let destination = object.join(&data_dir);
std::fs::rename(&destination, staging.join(&data_dir))?;
let barrier = PhysicalBarrier::arm(&disk, &destination)?;
let format: Value = serde_json::from_slice(&std::fs::read(disk.join(".rustfs.sys/format.json"))?)?;
let disk_id = Uuid::parse_str(format["xl"]["this"].as_str().ok_or("disk format omitted xl.this")?)?;
let incarnation = Uuid::parse_str(target["bucket_incarnation"].as_str().ok_or("target omitted incarnation")?)?;
let repair = ArtifactRepairRenameDataRequest {
rename: Some(RenameDataRequest {
disk: disk_id.to_string(),
src_volume: ".rustfs.sys/tmp".to_owned(),
src_path: source,
dst_volume: bucket.to_owned(),
dst_path: key.clone(),
file_info: serde_json::to_string(&fi)?,
file_info_bin: Default::default(),
scanner_publication_lease_token: Default::default(),
bucket_incarnation_id: incarnation.as_bytes().to_vec().into(),
}),
expected_disk_id: disk_id.as_bytes().to_vec().into(),
};
let canonical = rustfs_protos::canonical_artifact_repair_rename_data_request_body(&repair)?;
let mut request = Request::new(repair);
request.set_timeout(Duration::from_secs(15));
set_tonic_canonical_body_digest(&mut request, &canonical)?;
let digest = request
.metadata()
.get("x-rustfs-content-sha256")
.ok_or("missing canonical digest")?
.to_str()?
.to_owned();
let headers = gen_tonic_signature_headers(
&node.address,
TONIC_RPC_PREFIX.trim_start_matches('/'),
"ArtifactRepairRenameData",
Some(&digest),
)?;
request.metadata_mut().as_mut().extend(headers);
// An independent channel and direct receiver RPC take no coordinator attempt
// lock. Dropping this task closes the client stream while rename is paused.
let url = node.url.clone();
let mut rpc = tokio::spawn(async move {
timeout(Duration::from_secs(30), async move {
let mut client = NodeServiceClient::connect(url).await?;
client
.artifact_repair_rename_data(request)
.await
.map_err(|error| -> Box<dyn std::error::Error + Send + Sync> { Box::new(error) })
})
.await
});
tokio::select! {
result = &mut rpc => {
let response = result???.into_inner();
panic!(
"repair receiver completed before the physical barrier: success={}, error={:?}",
response.success, response.error
);
}
arrived = barrier.observed("arrived") => arrived?,
}
assert!(!destination.exists(), "physical publication ran before its barrier");
rpc.abort();
assert!(rpc.await.expect_err("cancel the disconnected RPC waiter").is_cancelled());
// This acknowledgement is emitted by the receiver's dropped async waiter,
// not by the client and not by a delayed response.
barrier.observed("waiter-dropped").await?;
let seal = user.request(&dist, 0, REPAIR_SEAL_ROUTE, request_body.clone());
tokio::pin!(seal);
let closed = wait_until(
Duration::from_secs(15),
|| async {
Ok(walkdir::WalkDir::new(disk.join(".rustfs-artifact-repair-seals"))
.min_depth(2)
.max_depth(2)
.into_iter()
.filter_map(Result::ok)
.any(|entry| entry.file_name() == "closed"))
},
"receiver persisted repair closure while its physical worker remains paused",
);
tokio::pin!(closed);
tokio::select! {
result = &mut seal => {
let (status, response) = result?;
panic!("repair seal returned before the blocked receiver closed admission: {status} {response}");
}
result = &mut closed => result?,
}
// Cover a complete receiver drain timeout, so early physical-owner release
// causes a successful response here and fails this runtime assertion.
let (status, response) = timeout(Duration::from_secs(40), &mut seal).await??;
assert_eq!(
status,
StatusCode::SERVICE_UNAVAILABLE,
"seal confirmed an unfinished physical tail: {response}"
);
assert!(response.contains("artifact operation unconfirmed"));
assert!(!destination.exists(), "worker escaped the physical barrier");
barrier.release()?;
wait_until(
Duration::from_secs(15),
|| async { Ok(destination.join("part.1").is_file()) },
"detached physical rename completes after release",
)
.await?;
wait_until(
Duration::from_secs(45),
|| async {
let (status, response) = user.request(&dist, 0, REPAIR_SEAL_ROUTE, request_body.clone()).await?;
if status == StatusCode::SERVICE_UNAVAILABLE && response.contains("artifact operation unconfirmed") {
return Ok(false);
}
assert_eq!(status, StatusCode::OK, "repair seal did not confirm after physical drain: {response}");
assert_eq!(
serde_json::from_str::<Value>(&response)?,
json!({"version": 1, "target": target, "attempt_repairs_sealed": true})
);
Ok(true)
},
"exact retry confirms all four receivers after physical drain",
)
.await?;
assert!(census_object_version_on_disk(&disk, bucket, &key, Some(version))?.matches_manifest(&baseline));
assert_exact_version(&dist.client(0)?, bucket, &key, version, &body).await?;
Ok(())
}
@@ -0,0 +1,327 @@
// Copyright 2026 RustFS Team
// SPDX-License-Identifier: Apache-2.0
use super::artifact_seal_test::{ArtifactAdmin, assert_exact_version, confirm_seal, create_artifact_attempt};
use super::harness::{
DistCluster, DistLayout, TestResult, enable_versioning, payload_for, unique_bucket, wait_for_ready, wait_until,
};
use crate::chaos::{VersionShardCensus, census_object_version_on_disk, signed_admin_post, start_root_heal_when_control_ready};
use crate::common::init_logging;
use http::StatusCode;
use serde_json::{Value, json};
use std::path::{Path, PathBuf};
use std::time::Duration;
use tokio::time::timeout;
const REPAIR_SEAL_ROUTE: &str = "/rustfs/admin/v3/plugin-artifacts/seal-repairs";
const HEAL_BODY: &str =
r#"{"recursive":true,"dryRun":false,"remove":false,"recreate":true,"scanMode":2,"updateParity":false,"nolock":false}"#;
const HEAL_BUDGET: Duration = Duration::from_secs(120);
pub(super) struct RepairObject {
pub(super) key: String,
pub(super) version: String,
pub(super) body: Vec<u8>,
manifests: Vec<VersionShardCensus>,
}
fn roots(dist: &DistCluster) -> Vec<PathBuf> {
dist.cluster
.nodes
.iter()
.map(|node| PathBuf::from(&node.data_dirs[0]))
.collect()
}
impl RepairObject {
pub(super) async fn put(dist: &DistCluster, bucket: &str, key: String) -> TestResult<Self> {
let body = payload_for(&key, 2 * 1024 * 1024);
let output = timeout(
Duration::from_secs(30),
dist.client(1)?
.put_object()
.bucket(bucket)
.key(&key)
.body(body.clone().into())
.send(),
)
.await??;
let version = output.version_id().ok_or("managed PUT omitted version ID")?.to_owned();
let manifests = roots(dist)
.iter()
.map(|disk| census_object_version_on_disk(disk, bucket, &key, Some(&version)))
.collect::<TestResult<Vec<_>>>()?;
for manifest in &manifests {
assert!(manifest.is_complete(), "repair needs a complete baseline: {manifest:?}");
assert!(!manifest.expected_part_numbers.is_empty(), "repair must use actual shard files");
}
Ok(Self {
key,
version,
body,
manifests,
})
}
pub(super) fn part(&self, disk: &Path, bucket: &str, node: usize) -> TestResult<PathBuf> {
let data_dir = self.manifests[node]
.data_dir
.as_ref()
.ok_or("external shard omitted data directory")?;
Ok(disk.join(bucket).join(&self.key).join(data_dir).join("part.1"))
}
pub(super) async fn assert_repaired(&self, dist: &DistCluster, bucket: &str) -> TestResult {
wait_until(
HEAL_BUDGET,
|| async {
for (disk, baseline) in roots(dist).iter().zip(&self.manifests) {
if !census_object_version_on_disk(disk, bucket, &self.key, Some(&self.version))?.matches_manifest(baseline) {
return Ok(false);
}
}
Ok(true)
},
"every configured receiver restored the exact version and original shard fingerprints",
)
.await?;
for node in 0..dist.cluster.nodes.len() {
assert_exact_version(&dist.client(node)?, bucket, &self.key, &self.version, &self.body).await?;
}
Ok(())
}
}
pub(super) async fn start_heal(dist: &DistCluster, bucket: &str, prefix: &str, remove: bool) -> TestResult<String> {
let url = format!("{}/rustfs/admin/v3/heal/{bucket}/{prefix}?forceStart=true", dist.cluster.nodes[0].url);
let body = if remove {
HEAL_BODY.replace("\"remove\":false", "\"remove\":true")
} else {
HEAL_BODY.to_owned()
};
let response = timeout(
HEAL_BUDGET,
start_root_heal_when_control_ready(&url, &body, &dist.cluster.access_key, &dist.cluster.secret_key),
)
.await??;
let response: Value = serde_json::from_str(&response)?;
Ok(response["clientToken"]
.as_str()
.ok_or("heal omitted its task token")?
.to_owned())
}
pub(super) async fn finish_heal(dist: &DistCluster, token: &str, rejected_key: Option<&str>) -> TestResult {
let url = format!("{}/rustfs/admin/v3/heal/?clientToken={token}", dist.cluster.nodes[0].url);
wait_until(
HEAL_BUDGET,
|| async {
let response = signed_admin_post(&url, None, &dist.cluster.access_key, &dist.cluster.secret_key).await?;
let status: Value = serde_json::from_str(&response)?;
if status["summary"] == "running" {
return Ok(false);
}
if let Some(key) = rejected_key {
assert_eq!(status["summary"], "stopped", "closed repair must report failure: {status}");
assert!(
status["outcome"]["counters"]["failed"]
.as_u64()
.is_some_and(|count| count > 0)
);
assert!(
status["outcome"]["objects"].as_array().is_some_and(|objects| objects
.iter()
.any(|object| { object["identity"]["object"] == key && object["disposition"]["state"] == "failed" })),
"heal must actually attempt the rejected object: {status}"
);
} else {
assert_eq!(status["summary"], "finished", "heal did not complete: {status}");
}
Ok(true)
},
"exact admin heal task reached its expected terminal outcome",
)
.await
}
async fn repair_seal(dist: &DistCluster, user: &ArtifactAdmin, target: &Value, confirmed: bool) -> TestResult {
wait_until(
Duration::from_secs(45),
|| async {
let request = json!({"version": 1, "target": target}).to_string();
let (status, body) = user.request(dist, 0, REPAIR_SEAL_ROUTE, request).await?;
if !confirmed {
assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE, "offline receiver repair seal: {body}");
assert!(body.contains("<Code>ServiceUnavailable</Code>"));
assert!(body.contains("artifact operation unconfirmed"));
return Ok(true);
}
if status == StatusCode::SERVICE_UNAVAILABLE
&& body.contains("<Code>ServiceUnavailable</Code>")
&& body.contains("<Message>artifact operation unconfirmed; retry the same identity</Message>")
{
return Ok(false);
}
assert_eq!(status, StatusCode::OK, "repair seal failed: {body}");
assert_eq!(
serde_json::from_str::<Value>(&body)?,
json!({"version": 1, "target": target, "attempt_repairs_sealed": true})
);
Ok(true)
},
"repair seal confirms every configured receiver",
)
.await
}
#[tokio::test]
async fn four_node_artifact_shard_and_minority_metadata_repair_after_put_seal() -> TestResult {
init_logging();
// Sub-quorum metadata recovery supports genuine legacy shards only.
let mut dist =
DistCluster::start_with_env(DistLayout::FourNodeFourDisk, &[("RUSTFS_SHARD_INTEGRITY_WRITE", "false")]).await?;
let (target, user) = create_artifact_attempt(&dist).await?;
let bucket = target["bucket"].as_str().ok_or("target omitted bucket")?;
let prefix = target["attempt_prefix"].as_str().ok_or("target omitted prefix")?;
let shard = RepairObject::put(&dist, bucket, format!("{prefix}shard.bin")).await?;
let metadata = RepairObject::put(&dist, bucket, format!("{prefix}metadata.bin")).await?;
let later = dist
.client(2)?
.put_object()
.bucket(bucket)
.key(&shard.key)
.body(b"newer-version".to_vec().into())
.send()
.await?;
let later_version = later.version_id().ok_or("second PUT omitted version ID")?.to_owned();
assert_ne!(shard.version, later_version);
confirm_seal(&dist, &user, &json!({"version": 1, "target": target}).to_string(), &target).await?;
dist.cluster.stop();
let disks = roots(&dist);
std::fs::remove_file(shard.part(&disks[3], bucket, 3)?)?;
for disk in &disks[1..] {
std::fs::remove_file(disk.join(bucket).join(&metadata.key).join("xl.meta"))?;
}
assert!(!census_object_version_on_disk(&disks[3], bucket, &shard.key, Some(&shard.version))?.is_complete());
assert!(disks[0].join(bucket).join(&metadata.key).join("xl.meta").is_file());
assert!(
disks[1..]
.iter()
.all(|disk| !disk.join(bucket).join(&metadata.key).join("xl.meta").exists())
);
dist.cluster.start().await?;
wait_for_ready(&dist.cluster).await?;
let token = start_heal(&dist, bucket, prefix, false).await?;
shard.assert_repaired(&dist, bucket).await?;
metadata.assert_repaired(&dist, bucket).await?;
finish_heal(&dist, &token, None).await?;
assert_exact_version(&dist.client(3)?, bucket, &shard.key, &later_version, b"newer-version").await?;
let listed = dist
.client(0)?
.list_object_versions()
.bucket(bucket)
.prefix(prefix)
.send()
.await?;
assert_eq!(listed.versions().len(), 3, "repair must not publish replacement versions");
assert!(listed.delete_markers().is_empty());
Ok(())
}
#[tokio::test]
async fn four_node_artifact_repair_seal_denies_shard_and_metadata_repair_after_restart() -> TestResult {
init_logging();
let mut dist =
DistCluster::start_with_env(DistLayout::FourNodeFourDisk, &[("RUSTFS_SHARD_INTEGRITY_WRITE", "false")]).await?;
let (target, user) = create_artifact_attempt(&dist).await?;
let bucket = target["bucket"].as_str().ok_or("target omitted bucket")?;
let prefix = target["attempt_prefix"].as_str().ok_or("target omitted prefix")?;
let shard = RepairObject::put(&dist, bucket, format!("{prefix}shard.bin")).await?;
let metadata = RepairObject::put(&dist, bucket, format!("{prefix}metadata.bin")).await?;
confirm_seal(&dist, &user, &json!({"version": 1, "target": target}).to_string(), &target).await?;
repair_seal(&dist, &user, &target, true).await?;
let ordinary_bucket = unique_bucket("repair-control");
dist.create_bucket(&ordinary_bucket).await?;
enable_versioning(&dist.client(0)?, &ordinary_bucket).await?;
let ordinary = RepairObject::put(&dist, &ordinary_bucket, "ordinary.bin".to_owned()).await?;
let disks = roots(&dist);
let part = shard.part(&disks[3], bucket, 3)?;
std::fs::remove_file(&part)?;
for disk in &disks[1..] {
std::fs::remove_file(disk.join(bucket).join(&metadata.key).join("xl.meta"))?;
}
for restarted in [false, true] {
if restarted {
dist.restart_with_current_binary().await?;
}
// The same receivers must still execute an ordinary bucket repair.
std::fs::remove_file(ordinary.part(&disks[3], &ordinary_bucket, 3)?)?;
let control = start_heal(&dist, &ordinary_bucket, &ordinary.key, false).await?;
ordinary.assert_repaired(&dist, &ordinary_bucket).await?;
finish_heal(&dist, &control, None).await?;
for object in [&shard, &metadata] {
let token = start_heal(&dist, bucket, &object.key, false).await?;
finish_heal(&dist, &token, Some(&object.key)).await?;
}
assert!(!part.exists(), "sealed repair recreated the missing shard");
assert!(
disks[1..]
.iter()
.all(|disk| !disk.join(bucket).join(&metadata.key).join("xl.meta").exists())
);
assert_exact_version(&dist.client(2)?, bucket, &shard.key, &shard.version, &shard.body).await?;
}
Ok(())
}
#[tokio::test]
async fn four_node_artifact_repair_seal_requires_every_receiver_on_exact_retry() -> TestResult {
init_logging();
let mut dist = DistCluster::start(DistLayout::FourNodeFourDisk).await?;
let (target, user) = create_artifact_attempt(&dist).await?;
let request = json!({"version": 1, "target": target}).to_string();
confirm_seal(&dist, &user, &request, &target).await?;
// Repeat after global confirmation: its persisted receipt cannot replace a
// fresh acknowledgement from an offline configured receiver.
for _ in 0..2 {
dist.cluster.stop_node(3)?;
repair_seal(&dist, &user, &target, false).await?;
dist.cluster.start_node(3).await?;
wait_for_ready(&dist.cluster).await?;
repair_seal(&dist, &user, &target, true).await?;
}
Ok(())
}
#[tokio::test]
async fn four_node_artifact_dangling_repair_preserves_surviving_metadata() -> TestResult {
init_logging();
let mut dist =
DistCluster::start_with_env(DistLayout::FourNodeFourDisk, &[("RUSTFS_SHARD_INTEGRITY_WRITE", "false")]).await?;
let (target, user) = create_artifact_attempt(&dist).await?;
let bucket = target["bucket"].as_str().ok_or("target omitted bucket")?;
let prefix = target["attempt_prefix"].as_str().ok_or("target omitted prefix")?;
let object = RepairObject::put(&dist, bucket, format!("{prefix}dangling.bin")).await?;
confirm_seal(&dist, &user, &json!({"version": 1, "target": target}).to_string(), &target).await?;
dist.cluster.stop();
let disks = roots(&dist);
let survivor = disks[0].join(bucket).join(&object.key).join("xl.meta");
let before = std::fs::read(&survivor)?;
for (node, disk) in disks.iter().enumerate() {
std::fs::remove_file(object.part(disk, bucket, node)?)?;
if node != 0 {
std::fs::remove_file(disk.join(bucket).join(&object.key).join("xl.meta"))?;
}
}
dist.cluster.start().await?;
wait_for_ready(&dist.cluster).await?;
let token = start_heal(&dist, bucket, &object.key, true).await?;
finish_heal(&dist, &token, Some(&object.key)).await?;
assert_eq!(
std::fs::read(survivor)?,
before,
"distributed repair must not authorize managed dangling deletion"
);
Ok(())
}
@@ -0,0 +1,347 @@
// Copyright 2026 RustFS Team
// SPDX-License-Identifier: Apache-2.0
use super::harness::{
DistCluster, DistLayout, TestResult, assert_object_bytes, put_object, s3_probe_client, unique_bucket, wait_for_ready,
wait_until,
};
use crate::common::{
AdminTransport, admin_add_canned_policy_via, admin_attach_user_policy_via, admin_create_user_via, admin_request, init_logging,
};
use aws_sdk_s3::Client;
use aws_sdk_s3::error::ProvideErrorMetadata;
use http::{Method, StatusCode};
use serde_json::{Value, json};
use std::time::Duration;
use tokio::time::timeout;
use uuid::Uuid;
const BUCKET_ROUTE: &str = "/rustfs/admin/v3/plugin-artifacts/buckets";
const INSPECT_ROUTE: &str = "/rustfs/admin/v3/plugin-artifacts/buckets/inspect";
const SEAL_ROUTE: &str = "/rustfs/admin/v3/plugin-artifacts/seal-attempt";
const ADMIN_BUDGET: Duration = Duration::from_secs(45);
pub(super) struct ArtifactAdmin {
access_key: String,
secret_key: String,
}
impl ArtifactAdmin {
pub(super) async fn request(
&self,
dist: &DistCluster,
node: usize,
route: &str,
body: String,
) -> TestResult<(StatusCode, String)> {
timeout(
ADMIN_BUDGET,
admin_request(
&dist.cluster.nodes[node].url,
Method::POST,
route,
Some(body),
&self.access_key,
&self.secret_key,
),
)
.await?
}
}
async fn create_artifact_admin(dist: &DistCluster, bucket: &str, prefix: &str) -> TestResult<ArtifactAdmin> {
let user = ArtifactAdmin {
access_key: format!("artifact{}", &Uuid::new_v4().simple().to_string()[..12]),
secret_key: Uuid::new_v4().simple().to_string(),
};
let policy_name = format!("artifact{}", &Uuid::new_v4().simple().to_string()[..12]);
let policy = json!({
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["admin:CreatePluginArtifactBucket", "admin:GetPluginArtifactBucket"],
"Resource": format!("arn:aws:s3:::{bucket}"),
},
{
"Effect": "Allow",
"Action": ["admin:SealPluginArtifactAttempt", "admin:SealPluginArtifactRepairs", "admin:SealPluginArtifactWrites"],
"Resource": format!("arn:aws:s3:::{bucket}/{}", prefix.trim_end_matches('/')),
},
],
})
.to_string();
let url = &dist.cluster.nodes[0].url;
let access = &dist.cluster.access_key;
let secret = &dist.cluster.secret_key;
admin_create_user_via(AdminTransport::Signed, url, access, secret, &user.access_key, &user.secret_key).await?;
admin_add_canned_policy_via(AdminTransport::Signed, url, access, secret, &policy_name, &policy).await?;
admin_attach_user_policy_via(AdminTransport::Signed, url, access, secret, &policy_name, &user.access_key).await?;
Ok(user)
}
pub(super) async fn create_artifact_attempt(dist: &DistCluster) -> TestResult<(Value, ArtifactAdmin)> {
let bucket = unique_bucket("artifact");
let prefix = format!("outputs/6169/{}/{}/", Uuid::new_v4(), Uuid::new_v4());
let user = create_artifact_admin(dist, &bucket, &prefix).await?;
let body = json!({"version": 1, "bucket": bucket, "base_prefix": "outputs/"}).to_string();
let (status, response) = user.request(dist, 0, BUCKET_ROUTE, body).await?;
assert_eq!(status, StatusCode::OK, "artifact bucket creation failed: {response}");
let receipt: Value = serde_json::from_str(&response)?;
assert_eq!(receipt["version"], 1);
assert_eq!(receipt["bucket"], bucket);
assert_eq!(receipt["base_prefix"], "outputs/");
assert_eq!(receipt["versioning_enabled"], true);
let incarnation = receipt["bucket_incarnation"]
.as_str()
.ok_or("artifact receipt omitted incarnation")?;
assert!(!Uuid::parse_str(incarnation)?.is_nil());
let target = json!({
"bucket": bucket,
"bucket_incarnation": incarnation,
"attempt_prefix": prefix,
});
assert_bucket_identity(dist, &user, 1, &target).await?;
Ok((target, user))
}
async fn assert_bucket_identity(dist: &DistCluster, user: &ArtifactAdmin, node: usize, target: &Value) -> TestResult {
let body = json!({"version": 1, "bucket": target["bucket"], "base_prefix": "outputs/"}).to_string();
wait_until(
ADMIN_BUDGET,
|| async {
let (status, response) = user.request(dist, node, INSPECT_ROUTE, body.clone()).await?;
// IAM peers may not have received the new user and policy yet.
// Storage failures must fail immediately, including the old gate's 503.
if matches!(status, StatusCode::FORBIDDEN | StatusCode::UNAUTHORIZED)
&& (response.contains("<Code>AccessDenied</Code>") || response.contains("<Code>InvalidAccessKeyId</Code>"))
{
return Ok(false);
}
assert_eq!(status, StatusCode::OK, "cross-node artifact inspect failed: {response}");
assert_eq!(
serde_json::from_str::<Value>(&response)?,
json!({
"version": 1,
"bucket": target["bucket"],
"base_prefix": "outputs/",
"bucket_incarnation": target["bucket_incarnation"],
"versioning_enabled": true,
}),
"inspect must return the created managed bucket identity"
);
Ok(true)
},
"artifact IAM user and policy propagation",
)
.await
}
pub(super) async fn confirm_seal(dist: &DistCluster, user: &ArtifactAdmin, request: &str, target: &Value) -> TestResult {
wait_until(
ADMIN_BUDGET,
|| async {
let (status, response) = user.request(dist, 0, SEAL_ROUTE, request.to_owned()).await?;
// HTTP readiness does not confirm that remote disk handles have
// recovered. Retry only the API's explicit same-identity response.
if status == StatusCode::SERVICE_UNAVAILABLE
&& response.contains("<Code>ServiceUnavailable</Code>")
&& response.contains("<Message>artifact operation unconfirmed; retry the same identity</Message>")
{
return Ok(false);
}
assert_eq!(status, StatusCode::OK, "artifact seal failed: {response}");
let receipt: Value = serde_json::from_str(&response)?;
assert_eq!(receipt, json!({"version": 1, "target": target, "ordinary_put_sealed": true}));
Ok(true)
},
"exact artifact seal confirmation after receiver recovery",
)
.await
}
async fn assert_seal_unconfirmed(dist: &DistCluster, user: &ArtifactAdmin, request: &str) -> TestResult {
let (status, body) = user.request(dist, 0, SEAL_ROUTE, request.to_owned()).await?;
assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE, "offline receiver seal response: {body}");
assert!(
body.contains("<Code>ServiceUnavailable</Code>"),
"seal failure lost its service code: {body}"
);
assert!(
body.contains("artifact operation unconfirmed"),
"seal failure lost its retry contract: {body}"
);
Ok(())
}
pub(super) async fn assert_exact_version(client: &Client, bucket: &str, key: &str, version: &str, body: &[u8]) -> TestResult {
let output = timeout(
Duration::from_secs(30),
client.get_object().bucket(bucket).key(key).version_id(version).send(),
)
.await??;
assert_eq!(output.version_id(), Some(version));
let actual = timeout(Duration::from_secs(30), output.body.collect()).await??.into_bytes();
assert_eq!(actual.as_ref(), body);
Ok(())
}
async fn assert_put_denied(client: &Client, ordinary_bucket: &str, bucket: &str, key: &str) -> TestResult {
// The same receiver must still accept a normal write before its sealed probe.
put_object(client, ordinary_bucket, key, b"ordinary-control".to_vec()).await?;
assert_object_bytes(client, ordinary_bucket, key, b"ordinary-control").await?;
let client = s3_probe_client(client);
let result = timeout(
Duration::from_secs(30),
client
.put_object()
.bucket(bucket)
.key(key)
.body(b"must-not-publish".to_vec().into())
.send(),
)
.await?;
let error = result.expect_err("sealed artifact attempt accepted a new PUT");
let service = error
.as_service_error()
.ok_or("sealed PUT failed without a service rejection")?;
assert_eq!(
error.raw_response().map(|response| response.status().as_u16()),
Some(500),
"sealed PUT must return its current HTTP status"
);
assert_eq!(service.code(), Some("InternalError"), "sealed PUT must return its current service code");
// StorageError::Io is redacted at the API boundary; the sealed reason is
// not exposed. Success controls and exact inventory checks prove the effect.
assert_eq!(
service.message(),
Some("We encountered an internal error, please try again."),
"sealed PUT must retain the public redacted message"
);
Ok(())
}
#[tokio::test]
async fn four_node_artifact_put_seal_survives_full_restart() -> TestResult {
init_logging();
let mut dist = DistCluster::start(DistLayout::FourNodeFourDisk).await?;
let (target, user) = create_artifact_attempt(&dist).await?;
let bucket = target["bucket"].as_str().ok_or("target omitted bucket")?;
let prefix = target["attempt_prefix"].as_str().ok_or("target omitted attempt prefix")?;
let key = format!("{prefix}result.bin");
let mut versions = Vec::new();
for (node, body) in [(1, b"first-version".as_slice()), (2, b"second-version".as_slice())] {
let client = dist.client(node)?;
let output = timeout(
Duration::from_secs(30),
client.put_object().bucket(bucket).key(&key).body(body.to_vec().into()).send(),
)
.await??;
let version = output.version_id().ok_or("artifact PUT omitted version ID")?.to_owned();
assert!(!Uuid::parse_str(&version)?.is_nil());
assert_exact_version(&dist.client(3)?, bucket, &key, &version, body).await?;
versions.push((version, body.to_vec()));
}
assert_ne!(versions[0].0, versions[1].0);
let ordinary = unique_bucket("ordinary");
dist.create_bucket(&ordinary).await?;
put_object(&dist.client(2)?, &ordinary, "normal.bin", b"before-seal".to_vec()).await?;
assert_object_bytes(&dist.client(3)?, &ordinary, "normal.bin", b"before-seal").await?;
let request = json!({"version": 1, "target": target}).to_string();
confirm_seal(&dist, &user, &request, &target).await?;
let other_prefix = format!("outputs/6169/{}/{}/", Uuid::new_v4(), Uuid::new_v4());
for phase in ["sealed", "restarted"] {
if phase == "restarted" {
dist.cluster.stop();
dist.cluster.start().await?;
wait_for_ready(&dist.cluster).await?;
}
assert_bucket_identity(&dist, &user, 2, &target).await?;
for node in 0..dist.cluster.nodes.len() {
// A second attempt in the managed bucket must remain writable;
// a broken bucket-wide write path cannot stand in for sealing.
let client = dist.client(node)?;
let other_key = format!("{other_prefix}{phase}-{node}.bin");
let output = timeout(
Duration::from_secs(30),
client
.put_object()
.bucket(bucket)
.key(&other_key)
.body(b"other-attempt".to_vec().into())
.send(),
)
.await??;
let version = output.version_id().ok_or("other attempt PUT omitted version ID")?;
assert!(!Uuid::parse_str(version)?.is_nil());
assert_exact_version(
&dist.client((node + 1) % dist.cluster.nodes.len())?,
bucket,
&other_key,
version,
b"other-attempt",
)
.await?;
assert_put_denied(&dist.client(node)?, &ordinary, bucket, &format!("{prefix}{phase}-{node}.bin")).await?;
for (version, body) in &versions {
assert_exact_version(&dist.client(node)?, bucket, &key, version, body).await?;
}
}
let listed = dist
.client(0)?
.list_object_versions()
.bucket(bucket)
.prefix(prefix)
.send()
.await?;
assert_eq!(listed.versions().len(), 2, "denied PUT left an artifact version: {listed:?}");
assert!(listed.delete_markers().is_empty(), "artifact seal created a delete marker");
put_object(&dist.client(1)?, &ordinary, "normal.bin", phase.as_bytes().to_vec()).await?;
assert_object_bytes(&dist.client(2)?, &ordinary, "normal.bin", phase.as_bytes()).await?;
}
Ok(())
}
#[tokio::test]
async fn four_node_artifact_seal_requires_every_configured_receiver_on_exact_retry() -> TestResult {
init_logging();
let mut dist = DistCluster::start(DistLayout::FourNodeFourDisk).await?;
let (target, user) = create_artifact_attempt(&dist).await?;
let bucket = target["bucket"].as_str().ok_or("target omitted bucket")?;
let prefix = target["attempt_prefix"].as_str().ok_or("target omitted attempt prefix")?;
let key = format!("{prefix}existing.bin");
let output = dist
.client(1)?
.put_object()
.bucket(bucket)
.key(&key)
.body(b"existing".to_vec().into())
.send()
.await?;
let version = output.version_id().ok_or("artifact PUT omitted version ID")?.to_owned();
assert_exact_version(&dist.client(3)?, bucket, &key, &version, b"existing").await?;
let ordinary = unique_bucket("ordinary");
dist.create_bucket(&ordinary).await?;
let request = json!({"version": 1, "target": target}).to_string();
// The second iteration starts from a fully acknowledged global seal. Its
// persisted record cannot replace a fresh acknowledgement from every disk.
for _ in 0..2 {
dist.cluster.stop_node(3)?;
assert_seal_unconfirmed(&dist, &user, &request).await?;
dist.cluster.start_node(3).await?;
wait_for_ready(&dist.cluster).await?;
confirm_seal(&dist, &user, &request, &target).await?;
}
assert_put_denied(&dist.client(3)?, &ordinary, bucket, &format!("{prefix}late.bin")).await?;
assert_exact_version(&dist.client(2)?, bucket, &key, &version, b"existing").await?;
let listed = timeout(
Duration::from_secs(30),
dist.client(0)?.list_object_versions().bucket(bucket).prefix(prefix).send(),
)
.await??;
assert_eq!(listed.versions().len(), 1, "rejected PUT added a version");
assert!(listed.delete_markers().is_empty(), "seal added a delete marker");
Ok(())
}
@@ -0,0 +1,235 @@
// Copyright 2026 RustFS Team
// SPDX-License-Identifier: Apache-2.0
#![cfg(unix)]
use super::artifact_delete_test::physical_snapshot;
use super::artifact_repair_test::{RepairObject, finish_heal, start_heal};
use super::artifact_seal_test::{ArtifactAdmin, assert_exact_version, confirm_seal, create_artifact_attempt};
use super::harness::{
DistCluster, DistLayout, TestResult, enable_versioning, s3_probe_client, unique_bucket, wait_for_ready, wait_until,
};
use crate::common::init_logging;
use aws_sdk_s3::error::ProvideErrorMetadata;
use aws_sdk_s3::types::{
ObjectLockConfiguration, ObjectLockEnabled, ObjectLockLegalHold, ObjectLockLegalHoldStatus, ObjectLockRetention,
ObjectLockRetentionMode,
};
use http::StatusCode;
use serde_json::{Value, json};
use std::path::Path;
use std::time::Duration;
const WRITES_ROUTE: &str = "/rustfs/admin/v3/plugin-artifacts/seal-writes";
const INTERNAL_MESSAGE: &str = "We encountered an internal error, please try again.";
async fn seal_writes(dist: &DistCluster, user: &ArtifactAdmin, target: &Value, confirmed: bool) -> TestResult {
wait_until(Duration::from_secs(45), || async {
let (status, response) = user.request(dist, 0, WRITES_ROUTE, json!({"version":1,"target":target}).to_string()).await?;
if status == StatusCode::SERVICE_UNAVAILABLE && response.contains("artifact operation unconfirmed; retry the same identity") {
return Ok(!confirmed);
}
assert!(confirmed, "offline configured receiver produced a complete receipt: {status} {response}");
assert_eq!(status, StatusCode::OK, "combined writes seal: {response}");
assert_eq!(serde_json::from_str::<Value>(&response)?, json!({"version":1,"target":target,"ordinary_put_sealed":true,"attempt_repairs_sealed":true,"all_writes_sealed":true}));
Ok(true)
}, "combined seal acknowledges both domains on every receiver").await
}
async fn assert_put_closed(dist: &DistCluster, bucket: &str, key: &str) -> TestResult {
let before = physical_snapshot(dist, bucket)?;
let client = s3_probe_client(&dist.client(2)?);
let error = client
.put_object()
.bucket(bucket)
.key(key)
.body(b"must-not-publish".to_vec().into())
.send()
.await
.expect_err("combined seal admitted a new ordinary PUT");
let service = error.as_service_error().ok_or("PUT was not a service rejection")?;
assert_eq!(service.code(), Some("InternalError"));
assert_eq!(service.message(), Some(INTERNAL_MESSAGE));
assert!(
physical_snapshot(dist, bucket)? == before,
"rejected PUT changed the managed physical namespace"
);
Ok(())
}
async fn lock_metadata_entrypoints(dist: &DistCluster, managed: &Value, object: &RepairObject, ordinary: &str) -> TestResult {
let bucket = managed["bucket"].as_str().ok_or("bucket identity")?;
let client = s3_probe_client(&dist.client(0)?);
for bucket in [bucket, ordinary] {
client
.put_object_lock_configuration()
.bucket(bucket)
.object_lock_configuration(
ObjectLockConfiguration::builder()
.object_lock_enabled(ObjectLockEnabled::Enabled)
.build(),
)
.send()
.await?;
wait_until(
Duration::from_secs(30),
|| async {
for node in 0..4 {
let output = match dist.client(node)?.get_object_lock_configuration().bucket(bucket).send().await {
Ok(output) => output,
Err(error)
if error.as_service_error().and_then(ProvideErrorMetadata::code)
== Some("ObjectLockConfigurationNotFoundError") =>
{
return Ok(false);
}
Err(error) => return Err(error.into()),
};
if output
.object_lock_configuration()
.and_then(|configuration| configuration.object_lock_enabled())
!= Some(&ObjectLockEnabled::Enabled)
{
return Ok(false);
}
}
Ok(true)
},
"actual Object Lock configuration is enabled on all request nodes",
)
.await?;
}
let control = RepairObject::put(dist, ordinary, "lock-control.bin".to_owned()).await?;
let retention = ObjectLockRetention::builder()
.mode(ObjectLockRetentionMode::Governance)
.retain_until_date(aws_sdk_s3::primitives::DateTime::from_secs(chrono::Utc::now().timestamp() + 86400))
.build();
let hold = ObjectLockLegalHold::builder().status(ObjectLockLegalHoldStatus::On).build();
client
.put_object_retention()
.bucket(ordinary)
.key(&control.key)
.version_id(&control.version)
.retention(retention.clone())
.send()
.await?;
assert_eq!(
client
.get_object_retention()
.bucket(ordinary)
.key(&control.key)
.version_id(&control.version)
.send()
.await?
.retention()
.and_then(|value| value.mode()),
Some(&ObjectLockRetentionMode::Governance)
);
client
.put_object_legal_hold()
.bucket(ordinary)
.key(&control.key)
.version_id(&control.version)
.legal_hold(hold.clone())
.send()
.await?;
assert_eq!(
client
.get_object_legal_hold()
.bucket(ordinary)
.key(&control.key)
.version_id(&control.version)
.send()
.await?
.legal_hold()
.and_then(|value| value.status()),
Some(&ObjectLockLegalHoldStatus::On)
);
let before = physical_snapshot(dist, bucket)?;
let error = client
.put_object_retention()
.bucket(bucket)
.key(&object.key)
.version_id(&object.version)
.retention(retention)
.send()
.await
.expect_err("managed Retention must reach and fail its unsupported metadata write");
let service = error.as_service_error().ok_or("retention was not a service rejection")?;
assert_eq!(service.code(), Some("InternalError"));
assert_eq!(service.message(), Some(INTERNAL_MESSAGE));
assert!(physical_snapshot(dist, bucket)? == before, "retention changed managed metadata");
let error = client
.put_object_legal_hold()
.bucket(bucket)
.key(&object.key)
.version_id(&object.version)
.legal_hold(hold)
.send()
.await
.expect_err("managed LegalHold must reach and fail its unsupported metadata write");
let service = error.as_service_error().ok_or("legal hold was not a service rejection")?;
assert_eq!(service.code(), Some("InternalError"));
assert_eq!(service.message(), Some("Io error: artifact bucket does not support this write operation"));
assert!(physical_snapshot(dist, bucket)? == before, "legal hold changed managed metadata");
Ok(())
}
#[tokio::test]
async fn four_node_artifact_writes_seal_first_partial_retry_offline_and_restart() -> TestResult {
init_logging();
let mut dist = DistCluster::start(DistLayout::FourNodeFourDisk).await?;
let ordinary = unique_bucket("writes-control");
dist.create_bucket(&ordinary).await?;
enable_versioning(&dist.client(0)?, &ordinary).await?;
let (fresh, fresh_user) = create_artifact_attempt(&dist).await?;
let fresh_bucket = fresh["bucket"].as_str().ok_or("fresh bucket")?;
let fresh_prefix = fresh["attempt_prefix"].as_str().ok_or("fresh prefix")?;
let first = RepairObject::put(&dist, fresh_bucket, format!("{fresh_prefix}existing.bin")).await?;
lock_metadata_entrypoints(&dist, &fresh, &first, &ordinary).await?;
seal_writes(&dist, &fresh_user, &fresh, true).await?;
seal_writes(&dist, &fresh_user, &fresh, true).await?;
assert_put_closed(&dist, fresh_bucket, &format!("{fresh_prefix}closed.bin")).await?;
let (partial, partial_user) = create_artifact_attempt(&dist).await?;
let bucket = partial["bucket"].as_str().ok_or("partial bucket")?;
let prefix = partial["attempt_prefix"].as_str().ok_or("partial prefix")?;
let repaired = RepairObject::put(&dist, bucket, format!("{prefix}repair.bin")).await?;
// Enter the combined operation with only its ordinary domain closed.
confirm_seal(&dist, &partial_user, &json!({"version":1,"target":partial}).to_string(), &partial).await?;
let part = repaired.part(Path::new(&dist.cluster.nodes[3].data_dirs[0]), bucket, 3)?;
std::fs::remove_file(&part)?;
let token = start_heal(&dist, bucket, &repaired.key, false).await?;
repaired.assert_repaired(&dist, bucket).await?;
finish_heal(&dist, &token, None).await?;
for _ in 0..2 {
dist.cluster.stop_node(3)?;
seal_writes(&dist, &partial_user, &partial, false).await?;
dist.cluster.start_node(3).await?;
wait_for_ready(&dist.cluster).await?;
seal_writes(&dist, &partial_user, &partial, true).await?;
}
let ordinary_object = RepairObject::put(&dist, &ordinary, "repair-control.bin".to_owned()).await?;
for restarted in [false, true] {
if restarted {
dist.restart_with_current_binary().await?;
}
seal_writes(&dist, &partial_user, &partial, true).await?;
assert_put_closed(&dist, bucket, &format!("{prefix}closed-{restarted}.bin")).await?;
if part.exists() {
std::fs::remove_file(&part)?;
}
let before = physical_snapshot(&dist, bucket)?;
let token = start_heal(&dist, bucket, &repaired.key, false).await?;
finish_heal(&dist, &token, Some(&repaired.key)).await?;
assert!(!part.exists(), "combined seal admitted a remote repair publication");
assert!(physical_snapshot(&dist, bucket)? == before, "rejected repair changed metadata or shards");
assert_exact_version(&dist.client(2)?, bucket, &repaired.key, &repaired.version, &repaired.body).await?;
let ordinary_part = ordinary_object.part(Path::new(&dist.cluster.nodes[3].data_dirs[0]), &ordinary, 3)?;
std::fs::remove_file(ordinary_part)?;
let token = start_heal(&dist, &ordinary, &ordinary_object.key, false).await?;
ordinary_object.assert_repaired(&dist, &ordinary).await?;
finish_heal(&dist, &token, None).await?;
}
Ok(())
}
+172 -24
View File
@@ -34,11 +34,12 @@ use crate::common::{
};
use crate::replication_extension_test::LOOPBACK_REPLICATION_TARGET_ENV;
use aws_sdk_s3::Client;
use aws_sdk_s3::config::retry::RetryConfig;
use aws_sdk_s3::config::{Credentials, retry::RetryConfig};
use aws_sdk_s3::error::{ProvideErrorMetadata, SdkError};
use aws_sdk_s3::operation::{get_object::GetObjectError, put_object::PutObjectError};
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::{BucketVersioningStatus, VersioningConfiguration};
use futures::future::try_join_all;
use http::{Method, StatusCode};
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
@@ -77,6 +78,7 @@ pub(crate) struct DistCluster {
pub cluster: RustFSTestClusterEnvironment,
pool_storage_roots: Option<Vec<PathBuf>>,
owned_pool_dirs: Vec<PathBuf>,
capture_log_dir: Option<PathBuf>,
}
impl DistCluster {
@@ -128,11 +130,12 @@ impl DistCluster {
for &(key, value) in extra_env {
cluster.set_env(key, value);
}
configure_node_logs(&mut cluster)?;
configure_node_logs(&mut cluster, None)?;
Ok(Self {
cluster,
pool_storage_roots,
owned_pool_dirs,
capture_log_dir: None,
})
}
@@ -170,17 +173,34 @@ impl DistCluster {
Ok(())
}
/// Append every node's stdout/stderr to `directory/node-<idx>.log`, including
/// nodes added by later pool expansion. Call before the first start so startup
/// events such as the resolved durability mode are captured.
pub fn capture_logs_under(&mut self, directory: &Path) -> TestResult {
self.capture_log_dir = Some(directory.to_path_buf());
configure_node_logs(&mut self.cluster, Some(directory))
}
pub fn client_with_credentials(&self, node_idx: usize, access_key: &str, secret_key: &str) -> TestResult<Client> {
if node_idx >= self.cluster.nodes.len() {
return Err("node_idx is invalid".into());
}
Ok(Client::from_conf(build_test_s3_config(
&self.cluster.nodes[node_idx].url,
access_key,
secret_key,
None,
"cluster-iam-test",
)))
let config = self.cluster.create_s3_client(node_idx)?.config().to_builder();
Ok(Client::from_conf(
config
.credentials_provider(Credentials::new(access_key, secret_key, None, None, "cluster-iam-test"))
.build(),
))
}
/// Stop the fleet, drop retired pools from the command line, and restart
/// the remaining pools, which are renumbered from zero.
pub async fn remove_retired_pools_and_restart(&mut self, retired: &[usize]) -> TestResult {
self.stop_all_gracefully().await?;
self.cluster.remove_single_node_pools(retired)?;
self.cluster.start().await?;
wait_for_ready(&self.cluster).await?;
Ok(())
}
pub async fn append_pool_and_restart(&mut self) -> TestResult<usize> {
@@ -192,7 +212,7 @@ impl DistCluster {
.ok_or("pool expansion requires configured isolated pool filesystems")?;
self.owned_pool_dirs
.push(relocate_pool_storage(&mut self.cluster, node_idx, roots)?);
configure_node_logs(&mut self.cluster)?;
configure_node_logs(&mut self.cluster, self.capture_log_dir.as_deref())?;
self.cluster.start().await?;
wait_for_ready(&self.cluster).await?;
Ok(node_idx)
@@ -326,7 +346,19 @@ fn relocate_pool_storage(cluster: &mut RustFSTestClusterEnvironment, node_idx: u
Ok(pool_run_root)
}
fn configure_node_logs(cluster: &mut RustFSTestClusterEnvironment) -> TestResult {
pub(super) fn configure_node_logs(cluster: &mut RustFSTestClusterEnvironment, capture_dir: Option<&Path>) -> TestResult {
if let Some(directory) = capture_dir {
std::fs::create_dir_all(directory)?;
for node_idx in 0..cluster.nodes.len() {
// Overrides a path assigned from RUSTFS_E2E_LOG_DIR so every node of
// the run, including the seed node, logs into the same directory.
let path = directory.join(format!("node-{node_idx}.log"));
if cluster.node_capture_log_paths[node_idx].as_deref() != Some(path.to_string_lossy().as_ref()) {
cluster.set_node_capture_log_path(node_idx, path.to_string_lossy().into_owned())?;
}
}
return Ok(());
}
let Some(log_dir) = std::env::var_os("RUSTFS_E2E_LOG_DIR") else {
return Ok(());
};
@@ -656,13 +688,50 @@ pub(crate) async fn wait_for_replicated_bytes(
.await
}
/// Quota stats for `bucket` from every node at once. Each node answers from
/// its own cached copy of the persisted usage snapshot, and only the scanner
/// leader's save invalidates that copy. Leadership goes to whichever node
/// starts its scanner first, so a probe pinned to one node can wait out the
/// whole cache TTL on a node that never saves.
pub(crate) async fn quota_stats_on_every_node(
cluster: &RustFSTestClusterEnvironment,
bucket: &str,
) -> TestResult<Vec<(StatusCode, String)>> {
let path = format!("/rustfs/admin/v3/quota-stats/{bucket}");
try_join_all(
cluster
.nodes
.iter()
.map(|node| admin_request(&node.url, Method::GET, &path, None, &cluster.access_key, &cluster.secret_key)),
)
.await
}
/// `current_usage` from a quota-stats body. `None` means the node has no
/// scanner usage for the bucket yet: a quota admitted by the reservation
/// ledger reports `null` rather than a guess.
pub(crate) fn quota_stats_current_usage(body: &str) -> TestResult<Option<u64>> {
let stats: serde_json::Value =
serde_json::from_str(body).map_err(|error| format!("quota stats returned invalid JSON: {error}: {body}"))?;
match stats.get("current_usage") {
Some(serde_json::Value::Null) => Ok(None),
Some(usage) => Ok(Some(
usage
.as_u64()
.ok_or_else(|| format!("quota stats current_usage is not a byte count: {stats}"))?,
)),
None => Err(format!("quota stats omitted current_usage: {stats}").into()),
}
}
pub(crate) async fn set_bucket_quota(cluster: &RustFSTestClusterEnvironment, bucket: &str, quota_bytes: u64) -> TestResult {
wait_until(
Duration::from_secs(30),
|| async {
let (status, _) =
cluster_admin(cluster, Method::GET, &format!("/rustfs/admin/v3/quota-stats/{bucket}"), None).await?;
Ok(status.is_success() || status == StatusCode::NOT_FOUND)
let stats = quota_stats_on_every_node(cluster, bucket).await?;
Ok(stats
.iter()
.any(|(status, _)| status.is_success() || *status == StatusCode::NOT_FOUND))
},
"quota stats ready",
)
@@ -686,23 +755,44 @@ pub(crate) async fn set_bucket_quota(cluster: &RustFSTestClusterEnvironment, buc
.await
}
/// The existing decommission cases wait up to 180 s for movement to finish; a
/// start refused because a movement gate is busy is retried within the same budget.
const DECOMMISSION_START_BUSY_BUDGET: Duration = Duration::from_secs(180);
const DECOMMISSION_START_PROOF_BUDGET: Duration = Duration::from_secs(45);
/// Start decommission and fail closed unless the admin API acknowledges it.
pub(crate) async fn start_decommission(cluster: &RustFSTestClusterEnvironment, pool_id: usize) -> TestResult {
let path = format!("/rustfs/admin/v3/pools/decommission?pool={pool_id}&by-id=true");
let deadline = Instant::now() + Duration::from_secs(45);
post_decommission_start(cluster, &path, DECOMMISSION_START_BUSY_BUDGET).await?;
Ok(())
}
/// POST a decommission start and return the response body. A start that meets
/// a busy movement gate answers a retryable 503 and is retried within
/// `busy_budget`; the init stage's missing fleet capability proof (500) is
/// retried for 45 s. Every other status is fatal.
pub(crate) async fn post_decommission_start(
cluster: &RustFSTestClusterEnvironment,
path: &str,
busy_budget: Duration,
) -> TestResult<String> {
let started = Instant::now();
let mut delay = Duration::from_millis(250);
loop {
let (status, response) = cluster_admin(cluster, Method::POST, &path, None).await?;
let (status, response) = cluster_admin(cluster, Method::POST, path, None).await?;
if status.is_success() {
return Ok(());
return Ok(response);
}
if status == StatusCode::INTERNAL_SERVER_ERROR
let elapsed = started.elapsed();
let busy = status == StatusCode::SERVICE_UNAVAILABLE && elapsed < busy_budget;
let awaiting_proof = status == StatusCode::INTERNAL_SERVER_ERROR
&& response.contains("requires a live fleet capability proof")
&& Instant::now() < deadline
{
sleep(Duration::from_secs(1)).await;
continue;
&& elapsed < DECOMMISSION_START_PROOF_BUDGET;
if !busy && !awaiting_proof {
return Err(format!("POST {path} did not start decommission: {status} {response}").into());
}
return Err(format!("POST {path} did not start decommission: {status} {response}").into());
sleep(delay).await;
delay = (delay * 2).min(Duration::from_secs(2));
}
}
@@ -885,6 +975,21 @@ pub(crate) async fn rebalance_status_json(cluster: &RustFSTestClusterEnvironment
Ok(serde_json::from_str(&body)?)
}
/// Mirrors `REBALANCE_DEFERRED_ENTRY_ERROR_PREFIX` in `crates/ecstore/src/services/rebalance`.
const REBALANCE_DEFERRED_ENTRY_ERROR_PREFIX: &str = "deferred transient rebalance entry failure:";
/// A transient entry failure (for example a write quorum miss on a busy
/// target) is recorded as `lastError` and retried at the end of the run; the
/// worker clears it when the pool completes. It is only acceptable while the
/// pool is still started: a terminal pool keeping it is a real failure.
fn is_retrying_deferred_rebalance_error(pool: &serde_json::Value, error: &str) -> bool {
error.starts_with(REBALANCE_DEFERRED_ENTRY_ERROR_PREFIX)
&& pool
.get("status")
.and_then(serde_json::Value::as_str)
.is_some_and(|status| status.eq_ignore_ascii_case("started"))
}
fn validate_rebalance_status<'a>(status: &'a serde_json::Value, expected_id: &str) -> TestResult<&'a [serde_json::Value]> {
let id = status
.get("id")
@@ -912,7 +1017,7 @@ fn validate_rebalance_status<'a>(status: &'a serde_json::Value, expected_id: &st
if pool
.get("lastError")
.and_then(serde_json::Value::as_str)
.is_some_and(|error| !error.is_empty())
.is_some_and(|error| !error.is_empty() && !is_retrying_deferred_rebalance_error(pool, error))
{
return Err(format!("rebalance reported lastError: {status}").into());
}
@@ -1259,6 +1364,29 @@ async fn append_single_node_pool_extends_ellipses_volumes() {
assert!(volumes.contains("/drive{0...1}"), "expanded layout must keep drive ellipses: {volumes}");
}
#[tokio::test]
async fn remove_single_node_pools_drops_retired_pools_and_renumbers() {
let mut env =
RustFSTestClusterEnvironment::with_topology(ClusterTopology::per_node_pools(2, vec![vec![0], vec![1], vec![2], vec![3]]))
.await
.expect("four-pool topology");
let kept: Vec<String> = env.nodes[2..].iter().map(|node| node.address.clone()).collect();
env.remove_single_node_pools(&[0, 1]).expect("remove two retired pools");
assert_eq!(env.nodes.len(), 2);
assert_eq!(env.nodes.iter().map(|node| node.address.clone()).collect::<Vec<_>>(), kept);
assert_eq!(env.nodes.iter().map(|node| node.pool_idx).collect::<Vec<_>>(), vec![0, 1]);
let volumes = env.rustfs_volumes_arg();
assert_eq!(volumes.split(' ').count(), 2, "expected two pool arguments, got: {volumes}");
assert!(
volumes.contains(&kept[0]) && volumes.contains(&kept[1]),
"retired pools must leave the command line: {volumes}"
);
env.remove_single_node_pools(&[0, 1])
.expect_err("the last remaining pool cannot be removed");
}
#[tokio::test]
async fn append_single_node_pool_rejects_striped_single_pool() {
let mut env = RustFSTestClusterEnvironment::new(4).await.expect("four-node single pool");
@@ -1412,3 +1540,23 @@ fn rebalance_complete_accepts_non_participating_pools_but_requires_progress() {
});
assert!(!rebalance_complete(&no_movement, "run-1").unwrap());
}
#[test]
fn rebalance_status_tolerates_deferred_entry_error_only_while_started() {
let deferred = "deferred transient rebalance entry failure: Storage resources are insufficient for the write operation: b/k";
let mut status = serde_json::json!({
"id": "run-1",
"pools": [{ "id": 0, "status": "Started", "stopping": false, "lastError": deferred, "progress": { "objects": 1, "bytes": 1 } }]
});
assert!(rebalance_active(&status, "run-1").expect("a retried deferral keeps the run active"));
status["pools"][0]["status"] = serde_json::json!("Completed");
assert!(
rebalance_complete(&status, "run-1").is_err(),
"a completed pool must have cleared its deferred entry error"
);
status["pools"][0]["status"] = serde_json::json!("Started");
status["pools"][0]["lastError"] = serde_json::json!("rebalance entry failed: Storage resources are insufficient");
assert!(rebalance_active(&status, "run-1").is_err(), "a non-deferred lastError stays fatal");
}
@@ -0,0 +1,286 @@
// Copyright 2026 RustFS Team
// Licensed under the Apache License, Version 2.0.
use super::harness::{DistCluster, DistLayout, TestResult, unique_bucket};
use crate::tls_gen;
use aws_sdk_s3::{Client, config::retry::RetryConfig, primitives::ByteStream};
use rand::{Rng, SeedableRng, rngs::StdRng};
use sha2::{Digest, Sha256};
use std::io::{Read, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::task::JoinSet;
const MIB: u64 = 1024 * 1024;
const READ_TIMEOUT: Duration = Duration::from_secs(180);
struct ExpectedRead {
range: Option<(u64, u64)>,
length: u64,
sha256: String,
}
struct ObjectFixture {
key: String,
path: PathBuf,
length: u64,
reads: Vec<ExpectedRead>,
}
fn fixture(directory: &Path, length: u64) -> TestResult<ObjectFixture> {
let key = format!("object-{length}");
let path = directory.join(&key);
let mut file = std::fs::File::create(&path)?;
let mut rng = StdRng::seed_from_u64(length);
let mut block = vec![0; usize::try_from(MIB)?];
let mut remaining = length;
let mut digest = Sha256::new();
while remaining != 0 {
let count = usize::try_from(remaining.min(MIB))?;
rng.fill_bytes(&mut block[..count]);
file.write_all(&block[..count])?;
digest.update(&block[..count]);
remaining -= u64::try_from(count)?;
}
drop(file);
let mut reads = vec![ExpectedRead {
range: None,
length,
sha256: hex_simd::encode_to_string(digest.finalize(), hex_simd::AsciiCase::Lower),
}];
// Unaligned ranges cross TLS frames and EC blocks; the tail detects
// truncation and incorrect range offsets independently of a full GET.
for (start, end) in [(17, (MIB + 31).min(length - 1)), (length.saturating_sub(65537), length - 1)] {
let mut input = std::fs::File::open(&path)?;
input.seek(SeekFrom::Start(start))?;
let range_length = end - start + 1;
let mut hasher = Sha256::new();
let mut input = input.take(range_length);
loop {
let count = input.read(&mut block)?;
if count == 0 {
break;
}
hasher.update(&block[..count]);
}
reads.push(ExpectedRead {
range: Some((start, end)),
length: range_length,
sha256: hex_simd::encode_to_string(hasher.finalize(), hex_simd::AsciiCase::Lower),
});
}
Ok(ObjectFixture {
key,
path,
length,
reads,
})
}
async fn verified_get(
client: &Client,
node: usize,
bucket: &str,
object: &ObjectFixture,
expected: &ExpectedRead,
concurrent: bool,
) -> TestResult<serde_json::Value> {
let started = Instant::now();
let mut received = 0u64;
let mut stage = "response headers";
tokio::time::timeout(READ_TIMEOUT, async {
let mut request = client.get_object().bucket(bucket).key(&object.key);
if let Some((start, end)) = expected.range {
request = request.range(format!("bytes={start}-{end}"));
}
let mut output = request.send().await?;
assert_eq!(output.content_length(), Some(i64::try_from(expected.length)?));
if let Some((start, end)) = expected.range {
assert_eq!(output.content_range(), Some(format!("bytes {start}-{end}/{}", object.length).as_str()));
} else {
assert_eq!(output.content_range(), None);
}
stage = "response body";
let mut digest = Sha256::new();
while let Some(chunk) = output.body.next().await {
let chunk = chunk?;
received += u64::try_from(chunk.len())?;
assert!(received <= expected.length, "node {node}: GET exceeded the requested length");
digest.update(&chunk);
}
let actual_sha256 = hex_simd::encode_to_string(digest.finalize(), hex_simd::AsciiCase::Lower);
assert_eq!(received, expected.length, "node {node}: truncated GET {}", object.key);
assert_eq!(actual_sha256, expected.sha256, "node {node}: corrupt GET {}", object.key);
eprintln!(
"TLS acceptance: GET node={node}, key={}, range={:?}, concurrent={concurrent}, bytes={received}, elapsed={:?}",
object.key, expected.range, started.elapsed()
);
Ok(serde_json::json!({
"operation": "get", "node": node, "key": object.key,
"range": expected.range, "bytes": received, "sha256": actual_sha256,
"concurrent": concurrent, "elapsed_ms": started.elapsed().as_secs_f64() * 1000.0,
}))
})
.await
.map_err(|error| {
std::io::Error::new(
std::io::ErrorKind::TimedOut,
format!(
"TLS GET timed out: node={node}, key={}, range={:?}, concurrent={concurrent}, stage={stage}, received={received}/{}, elapsed={:?}: {error}",
object.key, expected.range, expected.length, started.elapsed()
),
)
})?
}
/// This case always exercises the full acceptance sizes; do not substitute
/// tiny objects and count that as a large distributed-read pass.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn four_node_tls_large_and_range_gets_preserve_bytes() -> TestResult {
let mode = std::env::var("RUSTFS_E2E_INTERNODE_HTTP_MODE").unwrap_or_else(|_| "http1".into());
let protocol = match mode.as_str() {
"http1" => "http1",
"auto" | "unpooled" => "auto",
_ => return Err("RUSTFS_E2E_INTERNODE_HTTP_MODE must be http1, auto or unpooled".into()),
};
let mut dist = DistCluster::new_stopped_with_env(
DistLayout::FourByFour,
&[
("RUSTFS_INTERNODE_HTTP_PROTOCOL", protocol),
("RUSTFS_SCANNER_ENABLED", "true"),
("RUSTFS_SCANNER_START_DELAY_SECS", "0"),
("RUSTFS_SCANNER_CYCLE", "1"),
("RUSTFS_HEAL_ENABLED", "true"),
("RUST_LOG", "warn"),
],
)
.await?;
if mode == "unpooled" {
dist.cluster.set_env("RUSTFS_INTERNODE_HTTP_POOL_MAX_IDLE_PER_HOST", "0");
}
let directory = PathBuf::from(&dist.cluster.temp_dir);
let tls_directory = directory.join("tls");
tls_gen::run(tls_gen::Args {
out_dir: tls_directory.clone(),
days: 1,
force: false,
})?;
dist.cluster.enable_tls(&tls_directory).await?;
let report_path = std::env::var_os("RUSTFS_INTERNODE_ACCEPTANCE_REPORT").map(PathBuf::from);
if let Some(path) = &report_path
&& (path.exists() || path.with_extension("pids.json").exists())
{
return Err("acceptance report paths must be unused".into());
}
let log_directory = report_path.as_ref().map_or_else(
|| {
std::env::var_os("RUSTFS_E2E_LOG_DIR")
.map_or_else(|| directory.join("logs"), |root| PathBuf::from(root).join(unique_bucket("internode-tls")))
},
|path| path.with_extension("logs"),
);
std::fs::create_dir(&log_directory)?;
dist.capture_logs_under(&log_directory)?;
dist.cluster.start().await?;
let clients = dist
.clients()?
.into_iter()
.map(|client| Client::from_conf(client.config().to_builder().retry_config(RetryConfig::disabled()).build()))
.collect::<Vec<_>>();
let bucket = unique_bucket("internode-tls");
clients[0].create_bucket().bucket(&bucket).send().await?;
let pids = dist
.cluster
.nodes
.iter()
.map(|node| node.process.as_ref().map(std::process::Child::id))
.collect::<Vec<_>>();
if let Some(path) = &report_path {
std::fs::write(path.with_extension("pids.json"), serde_json::to_vec(&pids)?)?;
}
let mut results = Vec::new();
for length in [32 * 1024, 100 * MIB, 1024 * MIB] {
let fixture_directory = directory.clone();
let object = Arc::new(tokio::task::spawn_blocking(move || fixture(&fixture_directory, length)).await??);
eprintln!("TLS acceptance: fixture key={}, bytes={length}", object.key);
let started = Instant::now();
tokio::time::timeout(
READ_TIMEOUT,
clients[0]
.put_object()
.bucket(&bucket)
.key(&object.key)
.body(ByteStream::from_path(&object.path).await?)
.send(),
)
.await
.map_err(|error| {
std::io::Error::new(
std::io::ErrorKind::TimedOut,
format!(
"TLS PUT timed out: key={}, bytes={length}, elapsed={:?}: {error}",
object.key,
started.elapsed()
),
)
})??;
eprintln!("TLS acceptance: PUT key={}, bytes={length}, elapsed={:?}", object.key, started.elapsed());
results.push(
serde_json::json!({"operation": "put", "bytes": length, "elapsed_ms": started.elapsed().as_secs_f64() * 1000.0}),
);
if length == 32 * 1024 {
let alternate = dist.client_with_credentials(0, &dist.cluster.access_key, &dist.cluster.secret_key)?;
let alternate = Client::from_conf(alternate.config().to_builder().retry_config(RetryConfig::disabled()).build());
results.push(verified_get(&alternate, 0, &bucket, &object, &object.reads[0], false).await?);
let invalid = dist.client_with_credentials(0, "invalid-tls-user", "invalid-tls-secret-password")?;
let invalid = Client::from_conf(invalid.config().to_builder().retry_config(RetryConfig::disabled()).build());
let denied = invalid
.list_buckets()
.send()
.await
.expect_err("alternate credentials must not inherit the root identity");
assert_eq!(denied.raw_response().map(|response| response.status().as_u16()), Some(403));
}
for expected in &object.reads {
for (node, client) in clients.iter().enumerate() {
results.push(verified_get(client, node, &bucket, &object, expected, false).await?);
}
}
// Each reader owns a Tokio task and its cooperative budget. Polling
// every body and synchronous digest in one parent task can let an
// always-ready reader starve its siblings and their deadlines.
let mut readers = JoinSet::new();
for (node, client) in clients.iter().enumerate() {
let client = client.clone();
let bucket = bucket.clone();
let object = Arc::clone(&object);
readers.spawn(async move { verified_get(&client, node, &bucket, &object, &object.reads[0], true).await });
}
while let Some(result) = readers.join_next().await {
results.push(result??);
}
tokio::fs::remove_file(&object.path).await?;
}
for node in 0..clients.len() {
let log = tokio::fs::read_to_string(log_directory.join(format!("node-{node}.log"))).await?;
assert!(
!log.contains("internode body stalled"),
"node {node}: internode body stall during verified reads"
);
}
let report = serde_json::json!({
"schema": 1, "mode": mode, "tls_verified": true, "nodes": 4, "drives_per_node": 4,
"source_revision": env!("RUSTFS_E2E_BUILD_COMMIT"),
"source_dirty": env!("RUSTFS_E2E_BUILD_DIRTY"),
"scanner_enabled": true, "heal_enabled": true,
"scanner_progress_verified": false, "heal_progress_verified": false,
"results": results,
});
if let Some(path) = report_path {
std::fs::write(path, serde_json::to_vec_pretty(&report)?)?;
}
eprintln!("internode_tls_acceptance={report}");
Ok(())
}
+11
View File
@@ -18,6 +18,13 @@
//! `.github/workflows/e2e-distributed.yml`. Excluded from `e2e-full` because
//! each case starts four real `rustfs` processes.
mod artifact_delete_test;
mod artifact_inventory_test;
mod artifact_put_tail_test;
mod artifact_repair_tail_test;
mod artifact_repair_test;
mod artifact_seal_test;
mod artifact_writes_test;
mod chaos_test;
mod concurrency_stability_test;
mod concurrent_data_movement_test;
@@ -26,12 +33,16 @@ mod expand_decommission_rebalance_test;
mod extra_test;
mod harness;
mod heal_test;
mod internode_tls_test;
mod multipart_fence_acceptance_test;
mod object_lock_test;
mod observability_test;
mod replication_delete_marker_test;
mod replication_quota_test;
mod s3_basic_test;
mod s3_during_data_movement_test;
mod scanner_heal_topology_fixture;
mod scanner_heal_topology_test;
mod site_replication_test;
mod upgrade_test;
mod versioning_test;
@@ -0,0 +1,825 @@
// 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::{
TestResult, assert_object_bytes, cluster_admin_ok, configure_node_logs, payload_for, retrying_put, sha256_hex, unique_bucket,
wait_until,
};
use crate::common::{ClusterTopology, RustFSTestClusterEnvironment};
use crate::rpc_fault_proxy::{RpcFaultProxy, RpcPause};
use aws_sdk_s3::{
Client,
config::retry::RetryConfig,
error::{ProvideErrorMetadata, SdkError},
operation::create_multipart_upload::{CreateMultipartUploadError, CreateMultipartUploadOutput},
primitives::ByteStream,
types::{CompletedMultipartUpload, CompletedPart},
};
use futures::{StreamExt as _, TryStreamExt as _, stream};
use std::time::{Duration, Instant};
use tokio::task::JoinHandle;
const DEADLINE: Duration = Duration::from_secs(45);
async fn fixture(
drives_per_node: usize,
storage_class: &str,
) -> TestResult<(RustFSTestClusterEnvironment, Vec<RpcFaultProxy>, Vec<Client>, String)> {
let mut cluster =
RustFSTestClusterEnvironment::with_topology(ClusterTopology::single_pool_multidrive(4, drives_per_node)).await?;
for (name, value) in [
("RUSTFS_STORAGE_CLASS_STANDARD", storage_class),
("RUSTFS_OBJECT_LOCK_ACQUIRE_TIMEOUT", "5"),
("RUSTFS_OBJECT_LOCK_RPC_TIMEOUT_MS", "3000"),
("RUSTFS_DRIVE_METADATA_TIMEOUT_SECS", "2"),
("RUSTFS_DRIVE_MAX_TIMEOUT_DURATION", "5"),
// Keep disk-health ejection out of a method-specific quorum test.
("RUSTFS_DRIVE_SUSPECT_FAILURE_THRESHOLD", "1000"),
("RUSTFS_OBS_METRICS_EXPORT_ENABLED", "false"),
("NO_PROXY", "127.0.0.1,::1,localhost"),
("HTTP_PROXY", ""),
("HTTPS_PROXY", ""),
("RUST_LOG", "warn"),
] {
cluster.set_env(name, value);
}
let mut proxies = Vec::new();
for node in 0..4 {
proxies.push(cluster.start_rpc_proxy_for_node(node).await?);
}
configure_node_logs(&mut cluster, None)?;
cluster.start().await?;
let clients = cluster
.create_all_clients()?
.into_iter()
.map(|client| {
Client::from_conf(
client
.config()
.to_builder()
.retry_config(RetryConfig::standard().with_max_attempts(1))
.build(),
)
})
.collect();
let bucket = unique_bucket("mpu-fence");
cluster.create_test_bucket(&bucket).await?;
Ok((cluster, proxies, clients, bucket))
}
fn create(
client: &Client,
bucket: &str,
key: &str,
) -> JoinHandle<Result<CreateMultipartUploadOutput, SdkError<CreateMultipartUploadError>>> {
let request = client.create_multipart_upload().bucket(bucket).key(key);
tokio::spawn(async move { request.send().await })
}
async fn wait_for_faults(pauses: &[RpcPause], methods: &[&str]) -> TestResult {
if methods.iter().any(|method| matches!(*method, "Lock" | "LockBatch")) {
// Adaptive lock fan-out may not contact an unnecessary voter. All
// selected peers remain gated; require an actual scoped request hit.
let (result, _, _) = futures::future::select_all(pauses.iter().map(|pause| Box::pin(pause.wait_for_hits(1)))).await;
result?;
} else {
// Storage mutations fan out eagerly. Every selected peer must really
// receive this upload's RPC, so an unrelated failure cannot fake quorum.
for pause in pauses {
pause.wait_for_hits(1).await?;
}
}
Ok(())
}
fn assert_unavailable<E: std::fmt::Debug + ProvideErrorMetadata>(error: &SdkError<E>, cause: &str, retry: Option<&str>) {
let response = error.raw_response().expect("an S3 service response");
assert_eq!(response.status().as_u16(), 503, "{error:?}");
assert_eq!(error.as_service_error().and_then(|e| e.meta().code()), Some("ServiceUnavailable"));
assert_eq!(response.headers().get("x-rustfs-error-cause"), Some(cause));
assert_eq!(response.headers().get("retry-after"), retry);
assert!(
response
.headers()
.get("content-type")
.is_some_and(|v| v.starts_with("application/xml"))
);
let request_id = response.headers().get("x-amz-request-id").expect("request correlation ID");
assert!(!request_id.is_empty());
eprintln!("MPU acceptance: cause={cause}, retry={retry:?}, request_id={request_id}");
}
async fn abort(client: &Client, bucket: &str, key: &str, upload: &str) -> TestResult {
client
.abort_multipart_upload()
.bucket(bucket)
.key(key)
.upload_id(upload)
.send()
.await?;
let missing = client
.list_parts()
.bucket(bucket)
.key(key)
.upload_id(upload)
.send()
.await
.expect_err("an aborted upload must no longer admit ListParts");
assert_eq!(missing.as_service_error().and_then(|e| e.meta().code()), Some("NoSuchUpload"));
Ok(())
}
// Fault removal does not synchronously clear existing server leases or drive
// health state. Keep the injected operation single-attempt, then require
// bounded recovery of the exact cleanup API instead of assuming instant heal.
async fn recover_after_fault<T, E, F, Fut>(operation: &str, mut call: F) -> TestResult<Result<T, SdkError<E>>>
where
E: std::fmt::Debug + ProvideErrorMetadata,
F: FnMut() -> Fut,
Fut: std::future::Future<Output = Result<T, SdkError<E>>>,
{
let started = Instant::now();
let mut attempts = 0;
let mut last_failure = String::new();
let outcome = tokio::time::timeout(DEADLINE, async {
loop {
attempts += 1;
let result = call().await;
let retry = result.as_ref().err().and_then(|error| {
let response = error.raw_response()?;
if response.status().as_u16() != 503
|| error.as_service_error()?.meta().code() != Some("ServiceUnavailable")
|| !matches!(
response.headers().get("x-rustfs-error-cause"),
Some(
"namespace_lock_timeout"
| "namespace_lock_unavailable"
| "storage_write_quorum"
| "storage_read_quorum"
)
)
{
return None;
}
let delay = match response.headers().get("retry-after") {
Some("1") => Duration::from_secs(1),
None => Duration::from_millis(100),
Some(_) => return None,
};
last_failure = format!(
"cause={:?}, request_id={:?}",
response.headers().get("x-rustfs-error-cause"),
response.headers().get("x-amz-request-id")
);
Some(delay)
});
match retry {
Some(delay) => tokio::time::sleep(delay).await,
None => return result,
}
}
})
.await;
eprintln!(
"MPU recovery: operation={operation}, attempts={attempts}, elapsed={:?}, last={last_failure}",
started.elapsed()
);
outcome.map_err(|_| format!("{operation} did not recover within {DEADLINE:?}: {last_failure}").into())
}
async fn abort_after_fault(client: &Client, bucket: &str, key: &str, upload: &str, allow_missing: bool) -> TestResult {
match recover_after_fault(&format!("Abort {key}"), || {
client
.abort_multipart_upload()
.bucket(bucket)
.key(key)
.upload_id(upload)
.send()
})
.await?
{
Ok(_) => {}
Err(error) if allow_missing && error.as_service_error().and_then(|e| e.meta().code()) == Some("NoSuchUpload") => {}
Err(error) => return Err(error.into()),
}
let missing = recover_after_fault(&format!("ListParts {key}"), || {
client.list_parts().bucket(bucket).key(key).upload_id(upload).send()
})
.await?
.expect_err("a recovered Abort must remove the upload");
assert_eq!(missing.as_service_error().and_then(|e| e.meta().code()), Some("NoSuchUpload"));
Ok(())
}
async fn round_trip(clients: &[Client], bucket: &str, key: &str, size: usize) -> TestResult {
let upload = clients[0].create_multipart_upload().bucket(bucket).key(key).send().await?;
let upload_id = upload.upload_id().ok_or("missing upload ID")?;
let body = payload_for(key, size);
let mut parts: Vec<_> = stream::iter(body.chunks(8 * 1024 * 1024).enumerate())
.map(|(part, bytes)| async move {
let number = i32::try_from(part + 1)?;
let result = clients[part % clients.len()]
.upload_part()
.bucket(bucket)
.key(key)
.upload_id(upload_id)
.part_number(number)
.body(ByteStream::from(bytes.to_vec()))
.send()
.await?;
Ok::<_, Box<dyn std::error::Error + Send + Sync>>(
CompletedPart::builder()
.part_number(number)
.e_tag(result.e_tag().ok_or("missing part ETag")?)
.build(),
)
})
.buffer_unordered(4)
.try_collect()
.await?;
parts.sort_by_key(|part| part.part_number());
clients[1]
.complete_multipart_upload()
.bucket(bucket)
.key(key)
.upload_id(upload_id)
.multipart_upload(CompletedMultipartUpload::builder().set_parts(Some(parts)).build())
.send()
.await?;
for client in clients {
assert_object_bytes(client, bucket, key, &body).await?;
}
clients[0].delete_object().bucket(bucket).key(key).send().await?;
Ok(())
}
async fn assert_no_uploads(clients: &[Client], bucket: &str) -> TestResult {
for client in clients {
let uploads = client.list_multipart_uploads().bucket(bucket).send().await?;
assert!(uploads.uploads().is_empty(), "unexpected residual uploads: {:?}", uploads.uploads());
}
Ok(())
}
async fn assert_no_uploads_after_fault(clients: &[Client], bucket: &str) -> TestResult {
for client in clients {
let uploads = recover_after_fault("ListMultipartUploads after fault", || {
client.list_multipart_uploads().bucket(bucket).send()
})
.await??;
assert!(uploads.uploads().is_empty(), "unexpected residual uploads: {:?}", uploads.uploads());
}
Ok(())
}
async fn assert_object_bytes_after_fault(client: &Client, bucket: &str, key: &str, expected: &[u8]) -> TestResult {
let output = recover_after_fault(&format!("GetObject {key} after fault"), || {
client.get_object().bucket(bucket).key(key).send()
})
.await??;
// A successful response must have a complete, correct body. Streaming
// failures are not retried or converted into a successful comparison.
let bytes = output.body.collect().await?.into_bytes();
if bytes.as_ref() != expected {
return Err(format!("object {key} bytes differ: expected {} bytes, got {}", expected.len(), bytes.len()).into());
}
Ok(())
}
// This fixture inspects RustFS RPCs, so use the server-generated upload's
// canonical suffix and fail explicitly if its encoding changes.
fn upload_scope(upload_id: &str) -> TestResult<String> {
let decoded = String::from_utf8(base64_simd::URL_SAFE_NO_PAD.decode_to_vec(upload_id.as_bytes())?)?;
let (_, suffix) = decoded.split_once('.').ok_or("server upload ID lacks deployment delimiter")?;
if suffix.is_empty() {
return Err("server upload ID has an empty suffix".into());
}
Ok(suffix.to_owned())
}
fn upload_path_scope(bucket: &str, key: &str, upload_id: &str) -> TestResult<String> {
Ok(format!(
"{}/{}",
sha256_hex(format!("{bucket}/{key}").as_bytes()),
upload_scope(upload_id)?
))
}
async fn with_paused_peers<T: Send + 'static>(
proxies: &[RpcFaultProxy],
methods: &[&'static str],
limits: &[usize],
scope: &str,
request: impl std::future::Future<Output = T> + Send + 'static,
) -> TestResult<T> {
let pauses: Vec<_> = proxies[1..=limits.len()]
.iter()
.zip(limits)
.map(|(proxy, limit)| proxy.pause(methods, *limit, Some(scope)))
.collect::<Result<_, _>>()?;
let task = tokio::spawn(request);
wait_for_faults(&pauses, methods).await?;
let result = tokio::time::timeout(DEADLINE, task).await??;
for pause in &pauses {
pause.discard_pending();
}
drop(pauses);
Ok(result)
}
fn quorum_outcome<T: std::fmt::Debug, E: std::fmt::Debug + ProvideErrorMetadata>(
result: Result<T, SdkError<E>>,
at_quorum: bool,
locks: bool,
) -> TestResult<Option<T>> {
if at_quorum {
Ok(Some(result.map_err(|error| format!("exactly quorum must succeed: {error:?}"))?))
} else {
assert_unavailable(
&result.expect_err("quorum-minus-one must fail"),
if locks {
"namespace_lock_timeout"
} else {
"storage_write_quorum"
},
if locks { Some("1") } else { None },
);
Ok(None)
}
}
async fn wait_for_lock_peer_recovery(proxies: &[RpcFaultProxy], client: &Client, bucket: &str) -> TestResult {
let key = unique_bucket("peer-recovery");
let observers = proxies[1..]
.iter()
.map(|proxy| proxy.pause(&["Lock", "LockBatch"], usize::MAX, Some(&key)))
.collect::<Result<Vec<_>, _>>()?;
for observer in &observers {
observer.release();
}
wait_until(
DEADLINE,
|| async {
retrying_put(client, bucket, &key, vec![0x42; 1024], DEADLINE).await?;
// Quorum success alone can leave one peer's request breaker open.
// Two replies require a half-open peer to finish its first probe
// before admitting another RPC; every peer must rejoin the matrix.
Ok(observers.iter().all(|observer| observer.forwarded() >= 2))
},
"all lock peers recover after fault retirement",
)
.await
}
async fn wait_for_storage_peer_recovery(
proxies: &[RpcFaultProxy],
client: &Client,
bucket: &str,
upload: &str,
drives_per_node: usize,
) -> TestResult {
let scope = upload_scope(upload)?;
tokio::time::timeout(
DEADLINE,
wait_until(
DEADLINE,
|| async {
let observers = proxies[1..]
.iter()
.map(|proxy| proxy.pause(&["ReadVersion"], usize::MAX, Some(&scope)))
.collect::<Result<Vec<_>, _>>()?;
for observer in &observers {
observer.release();
}
// A fresh absent key forces a full metadata read without mutating
// this upload. Count distinct configured disk routes, not retries
// from the same healthy disk or just lock-RPC reachability.
let key = format!("storage-ready-{}", uuid::Uuid::new_v4());
let _ = client.list_parts().bucket(bucket).key(key).upload_id(upload).send().await;
Ok(observers
.iter()
.all(|observer| observer.forwarded_routes() >= drives_per_node))
},
"all storage disk routes recover after fault retirement",
),
)
.await
.map_err(|_| "storage route recovery deadline exceeded".to_string())?
}
async fn phase_quorum_matrix(
proxies: &[RpcFaultProxy],
clients: &[Client],
bucket: &str,
locks: bool,
drives_per_node: usize,
) -> TestResult {
for phase in ["create", "part", "complete", "abort"] {
// Ordinary Create holds only the shared bucket lifecycle fence.
// Four-node read quorum is two; commit/write quorum is three.
let unavailable_at_quorum = if locks && phase == "create" { 2 } else { 1 };
for unavailable in [unavailable_at_quorum, unavailable_at_quorum + 1] {
let key = format!("{locks}-{phase}-{unavailable}");
let mut limits = vec![usize::MAX; unavailable];
// EC12+4: one peer contributes four shards; one packet on the
// second peer moves exactly twelve available shards to eleven.
if !locks && drives_per_node == 4 && unavailable > 1 {
limits[unavailable - 1] = 1;
}
let available_votes = if locks {
4 - unavailable
} else {
4 * drives_per_node - limits.iter().map(|limit| drives_per_node.min(*limit)).sum::<usize>()
};
let methods: &[&'static str] = if locks {
&["Lock", "LockBatch"]
} else {
match phase {
"create" => &["WriteMetadata"],
"part" => &["PreparePartTransaction"],
"complete" => &["RenameData", "RenameDataAtIncarnation", "RenameDataWithQuotaFence"],
"abort" => &["Delete"],
_ => unreachable!(),
}
};
eprintln!("MPU quorum acceptance: phase={phase}, lock_fault={locks}, available_votes={available_votes}");
if phase == "create" {
let request = clients[0].create_multipart_upload().bucket(bucket).key(&key);
let result = with_paused_peers(proxies, methods, &limits, bucket, async move { request.send().await }).await?;
if let Some(upload) = quorum_outcome(result, unavailable == unavailable_at_quorum, locks)? {
let upload_id = upload.upload_id().ok_or("missing upload ID")?;
wait_for_storage_peer_recovery(proxies, &clients[0], bucket, upload_id, drives_per_node).await?;
abort_after_fault(&clients[0], bucket, &key, upload_id, false).await?;
}
} else {
let upload = clients[0]
.create_multipart_upload()
.bucket(bucket)
.key(&key)
.send()
.await
.map_err(|error| format!("setup Create failed for {key}: {error:?}"))?;
let upload_id = upload.upload_id().ok_or("missing upload ID")?;
let scope = if !locks && phase == "abort" {
upload_path_scope(bucket, &key, upload_id)?
} else {
upload_scope(upload_id)?
};
let body = payload_for(&key, if drives_per_node == 4 { 9 * 1024 * 1024 } else { 1024 * 1024 });
let initial = if phase != "part" {
Some(
clients[0]
.upload_part()
.bucket(bucket)
.key(&key)
.upload_id(upload_id)
.part_number(1)
.body(ByteStream::from(body.clone()))
.send()
.await?,
)
} else {
None
};
match phase {
"part" => {
let request = clients[0]
.upload_part()
.bucket(bucket)
.key(&key)
.upload_id(upload_id)
.part_number(1)
.body(ByteStream::from(body.clone()));
let result =
with_paused_peers(proxies, methods, &limits, &scope, async move { request.send().await }).await?;
if let Some(part) = quorum_outcome(result, unavailable == unavailable_at_quorum, locks)? {
let completion = clients[0]
.complete_multipart_upload()
.bucket(bucket)
.key(&key)
.upload_id(upload_id)
.multipart_upload(
CompletedMultipartUpload::builder()
.parts(
CompletedPart::builder()
.part_number(1)
.e_tag(part.e_tag().ok_or("missing ETag")?)
.build(),
)
.build(),
);
recover_after_fault(&format!("Complete {key} after part fault"), || completion.clone().send())
.await??;
for reader in clients {
assert_object_bytes_after_fault(reader, bucket, &key, &body).await?;
}
}
}
"complete" => {
let part = initial.as_ref().expect("complete fixture has one part");
let request = clients[0]
.complete_multipart_upload()
.bucket(bucket)
.key(&key)
.upload_id(upload_id)
.multipart_upload(
CompletedMultipartUpload::builder()
.parts(
CompletedPart::builder()
.part_number(1)
.e_tag(part.e_tag().ok_or("missing ETag")?)
.build(),
)
.build(),
);
let result =
with_paused_peers(proxies, methods, &limits, &scope, async move { request.send().await }).await?;
if quorum_outcome(result, unavailable == unavailable_at_quorum, locks)?.is_some() {
for reader in clients {
assert_object_bytes_after_fault(reader, bucket, &key, &body).await?;
}
}
}
"abort" => {
let request = clients[0]
.abort_multipart_upload()
.bucket(bucket)
.key(&key)
.upload_id(upload_id);
let result =
with_paused_peers(proxies, methods, &limits, &scope, async move { request.send().await }).await?;
quorum_outcome(result, unavailable == unavailable_at_quorum, locks)?;
}
_ => unreachable!(),
}
wait_for_storage_peer_recovery(proxies, &clients[0], bucket, upload_id, drives_per_node).await?;
// A rejected Complete must retain authority for ordinary
// Abort. NoSuchUpload must not hide partial staging loss.
let allow_missing = phase != "complete" || unavailable == unavailable_at_quorum;
abort_after_fault(&clients[0], bucket, &key, upload_id, allow_missing).await?;
recover_after_fault(&format!("DeleteObject {key} after {phase}"), || {
clients[0].delete_object().bucket(bucket).key(&key).send()
})
.await??;
}
wait_for_lock_peer_recovery(proxies, &clients[0], bucket).await?;
round_trip(clients, bucket, "recovered", 9 * 1024 * 1024).await?;
assert_no_uploads_after_fault(clients, bucket).await?;
}
}
Ok(())
}
#[tokio::test]
async fn metadata_delay_preserves_write_quorum_and_rejects_quorum_minus_one() -> TestResult {
let (_cluster, proxies, clients, bucket) = fixture(1, "EC:2").await?;
phase_quorum_matrix(&proxies, &clients, &bucket, false, 1).await
}
#[tokio::test]
async fn lock_delay_preserves_admission_quorum_and_reports_timeout() -> TestResult {
let (_cluster, proxies, clients, bucket) = fixture(1, "EC:2").await?;
phase_quorum_matrix(&proxies, &clients, &bucket, true, 1).await
}
#[tokio::test]
async fn sixteen_shard_multipart_metadata_requires_twelve_votes() -> TestResult {
let (cluster, proxies, clients, bucket) = fixture(4, "EC:4").await?;
let storage = cluster_admin_ok(&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));
phase_quorum_matrix(&proxies, &clients, &bucket, false, 4).await
}
#[tokio::test]
async fn cancelled_metadata_wait_releases_fence_for_bucket_recreation() -> TestResult {
let (_cluster, proxies, clients, bucket) = fixture(1, "EC:2").await?;
let pauses: Vec<_> = proxies[1..]
.iter()
.map(|proxy| proxy.pause(&["WriteMetadata"], usize::MAX, Some(&bucket)))
.collect::<Result<_, _>>()?;
let task = create(&clients[0], &bucket, "cancelled");
wait_for_faults(&pauses, &["WriteMetadata"]).await?;
task.abort();
assert!(task.await.expect_err("cancelled S3 future").is_cancelled());
drop(pauses);
// Deletion must wait for admitted server work to finish, then erase its
// generation. Client cancellation alone does not prove server cancellation.
tokio::time::timeout(DEADLINE, clients[1].delete_bucket().bucket(&bucket).send()).await??;
clients[2].create_bucket().bucket(&bucket).send().await?;
assert_no_uploads(&clients, &bucket).await?;
round_trip(&clients, &bucket, "new-generation", 9 * 1024 * 1024).await?;
assert_no_uploads(&clients, &bucket).await
}
/// Same logical geometry as the incident: one pool, four 16-shard sets,
/// EC12+4, 45 MiB files, 8 MiB parts, two files and four parts in flight.
#[tokio::test]
async fn eight_node_eight_drive_multipart_load_leaves_no_residual_uploads() -> TestResult {
let rounds = std::env::var("RUSTFS_E2E_MPU_LOAD_ROUNDS")
.unwrap_or_else(|_| "3".into())
.parse::<usize>()?;
if !(1..=256).contains(&rounds) {
return Err("RUSTFS_E2E_MPU_LOAD_ROUNDS must be in 1..=256".into());
}
let mut cluster = RustFSTestClusterEnvironment::with_topology(ClusterTopology::single_pool_multidrive(8, 8)).await?;
for (name, value) in [
("RUSTFS_STORAGE_CLASS_STANDARD", "EC:4"),
("TOKIO_WORKER_THREADS", "2"),
("RAYON_NUM_THREADS", "2"),
("RUSTFS_OBS_METRICS_EXPORT_ENABLED", "false"),
("NO_PROXY", "127.0.0.1,::1,localhost"),
("HTTP_PROXY", ""),
("HTTPS_PROXY", ""),
("RUST_LOG", "warn"),
] {
cluster.set_env(name, value);
}
configure_node_logs(&mut cluster, None)?;
cluster.start().await?;
let clients: Vec<_> = cluster
.create_all_clients()?
.into_iter()
.map(|client| {
Client::from_conf(
client
.config()
.to_builder()
.retry_config(RetryConfig::standard().with_max_attempts(1))
.build(),
)
})
.collect();
let storage = cluster_admin_ok(&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(), 64);
assert_eq!(storage.backend.total_sets, vec![4]);
assert_eq!(storage.backend.drives_per_set, vec![16]);
assert_eq!(storage.backend.standard_sc_parity, Some(4));
let bucket = unique_bucket("mpu-8x8");
cluster.create_test_bucket(&bucket).await?;
let started = Instant::now();
for round in 0..rounds {
let first = format!("round-{round}-first");
let second = format!("round-{round}-second");
tokio::try_join!(
round_trip(&clients, &bucket, &first, 45 * 1024 * 1024),
round_trip(&clients, &bucket, &second, 45 * 1024 * 1024),
)?;
let aborted = clients[round % clients.len()]
.create_multipart_upload()
.bucket(&bucket)
.key("aborted")
.send()
.await?;
let upload_id = aborted.upload_id().ok_or("missing upload ID")?;
clients[(round + 1) % clients.len()]
.upload_part()
.bucket(&bucket)
.key("aborted")
.upload_id(upload_id)
.part_number(1)
.body(ByteStream::from(vec![0xa5; 8 * 1024 * 1024]))
.send()
.await?;
abort(&clients[(round + 2) % clients.len()], &bucket, "aborted", upload_id).await?;
assert_no_uploads(&clients, &bucket).await?;
eprintln!(
"MPU 8x8 acceptance: round={}/{rounds}, completed={}, aborted={}, elapsed={:?}",
round + 1,
2 * (round + 1),
round + 1,
started.elapsed()
);
}
Ok(())
}
async fn resume_at_rpc<T: Send + 'static>(
proxies: &[RpcFaultProxy],
methods: &[&'static str],
scope: &str,
request: impl std::future::Future<Output = TestResult<T>> + Send + 'static,
) -> TestResult<T> {
let pauses: Vec<_> = proxies[1..]
.iter()
.map(|proxy| proxy.pause(methods, usize::MAX, Some(scope)))
.collect::<Result<_, _>>()?;
eprintln!("MPU delay acceptance: methods={methods:?}, scope={scope}");
let task = tokio::spawn(request);
wait_for_faults(&pauses, methods).await?;
assert!(
!task.is_finished(),
"the selected commit must wait while only one shard/voter is available"
);
drop(pauses);
tokio::time::timeout(DEADLINE, task).await??
}
#[tokio::test]
async fn each_multipart_phase_resumes_after_metadata_and_lock_delays() -> TestResult {
let (_cluster, proxies, clients, bucket) = fixture(1, "EC:2").await?;
for locks in [false, true] {
let key = format!("phase-{locks}");
let client = clients[0].clone();
let create = client.create_multipart_upload().bucket(&bucket).key(&key);
let upload = resume_at_rpc(
&proxies,
if locks { &["Lock", "LockBatch"] } else { &["WriteMetadata"] },
bucket.as_str(),
async move { Ok(create.send().await?) },
)
.await?;
let upload_id = upload.upload_id().ok_or("missing upload ID")?;
let scope = upload_scope(upload_id)?;
let body = payload_for(&key, 1024 * 1024);
let part = client
.upload_part()
.bucket(&bucket)
.key(&key)
.upload_id(upload_id)
.part_number(1)
.body(ByteStream::from(body.clone()));
let part = resume_at_rpc(
&proxies,
if locks {
&["Lock", "LockBatch"]
} else {
&["PreparePartTransaction"]
},
&scope,
async move { Ok(part.send().await?) },
)
.await?;
let complete = client
.complete_multipart_upload()
.bucket(&bucket)
.key(&key)
.upload_id(upload_id)
.multipart_upload(
CompletedMultipartUpload::builder()
.parts(
CompletedPart::builder()
.part_number(1)
.e_tag(part.e_tag().ok_or("missing ETag")?)
.build(),
)
.build(),
);
resume_at_rpc(
&proxies,
if locks {
&["Lock", "LockBatch"]
} else {
&["RenameData", "RenameDataAtIncarnation", "RenameDataWithQuotaFence"]
},
&scope,
async move { Ok(complete.send().await?) },
)
.await?;
for reader in &clients {
assert_object_bytes(reader, &bucket, &key, &body).await?;
}
client.delete_object().bucket(&bucket).key(&key).send().await?;
let upload = client.create_multipart_upload().bucket(&bucket).key(&key).send().await?;
let upload_id = upload.upload_id().ok_or("missing upload ID")?;
let scope = upload_scope(upload_id)?;
let scope = if locks {
scope
} else {
upload_path_scope(&bucket, &key, upload_id)?
};
let request = client.abort_multipart_upload().bucket(&bucket).key(&key).upload_id(upload_id);
resume_at_rpc(&proxies, if locks { &["Lock", "LockBatch"] } else { &["Delete"] }, &scope, async move {
Ok(request.send().await?)
})
.await?;
let missing = client
.list_parts()
.bucket(&bucket)
.key(&key)
.upload_id(upload_id)
.send()
.await
.expect_err("delayed Abort must remove the upload");
assert_eq!(missing.as_service_error().and_then(|e| e.meta().code()), Some("NoSuchUpload"));
assert_no_uploads(&clients, &bucket).await?;
}
Ok(())
}
@@ -13,13 +13,14 @@
// limitations under the License.
use super::harness::{
DistCluster, DistLayout, TestResult, enable_versioning, put_bucket_replication, put_object, retrying_put, set_bucket_quota,
set_remote_target, unique_bucket, wait_for_ready, wait_for_replicated_bytes, wait_until,
DistCluster, DistLayout, TestResult, cluster_admin_ok, enable_versioning, put_bucket_replication, put_object,
quota_stats_current_usage, quota_stats_on_every_node, retrying_put, set_bucket_quota, set_remote_target, unique_bucket,
wait_for_ready, wait_for_replicated_bytes, wait_until,
};
use crate::common::{FAST_DATA_USAGE_SCANNER_ENV, init_logging};
use crate::common::{FAST_DATA_USAGE_SCANNER_ENV, admin_request, init_logging};
use aws_sdk_s3::error::ProvideErrorMetadata;
use aws_sdk_s3::primitives::ByteStream;
use http::Method;
use http::{Method, StatusCode};
use std::time::Duration;
async fn wait_for_replication_status(
@@ -109,12 +110,165 @@ async fn four_node_bucket_replication_converges_to_peer_cluster() -> TestResult
Ok(())
}
#[tokio::test]
async fn four_node_quota_changes_refresh_peers_before_response() -> TestResult {
init_logging();
let mut dist = DistCluster::start(DistLayout::FourByFour).await?;
let bucket = unique_bucket("quota-refresh");
dist.create_bucket(&bucket).await?;
let native_path = format!("/rustfs/admin/v3/quota/{bucket}");
let compat_set = format!("/rustfs/admin/v3/set-bucket-quota?bucket={bucket}");
let compat_get = format!("/rustfs/admin/v3/get-bucket-quota?bucket={bucket}");
// Warm every node's no-quota cache. The compatibility GET does not
// depend on a scanner usage snapshot for this newly created bucket.
for node in &dist.cluster.nodes {
let (status, body) = admin_request(
&node.url,
Method::GET,
&compat_get,
None,
&dist.cluster.access_key,
&dist.cluster.secret_key,
)
.await?;
assert_eq!(status, StatusCode::OK, "{body}");
assert_eq!(serde_json::from_str::<serde_json::Value>(&body)?["quota"], 0);
}
wait_until(
Duration::from_secs(30),
|| async {
let (status, body) = admin_request(
&dist.cluster.nodes[0].url,
Method::PUT,
&native_path,
Some(serde_json::json!({"quota": 8192, "quota_type": "HARD"}).to_string()),
&dist.cluster.access_key,
&dist.cluster.secret_key,
)
.await?;
if status == StatusCode::SERVICE_UNAVAILABLE {
return Ok(false);
}
assert_eq!(status, StatusCode::OK, "{body}");
Ok(true)
},
"quota fleet capability ready",
)
.await?;
for (method, path, payload, expected) in [
(Method::GET, native_path.as_str(), None, Some(8192_u64)),
(
Method::PUT,
native_path.as_str(),
Some(serde_json::json!({"quota": 4096, "quota_type": "HARD"})),
Some(4096),
),
(Method::DELETE, native_path.as_str(), None, None),
(
Method::PUT,
compat_set.as_str(),
Some(serde_json::json!({"quota": 16384, "quotatype": "hard", "reservation_protocol": 2})),
Some(16384),
),
(
Method::PUT,
compat_set.as_str(),
Some(serde_json::json!({"quota": 0, "quotatype": "hard"})),
None,
),
] {
if method != Method::GET {
cluster_admin_ok(&dist.cluster, method, path, payload.map(|value| value.to_string())).await?;
}
// No polling: a successful mutation waits for healthy peers to reload.
for (index, node) in dist.cluster.nodes.iter().enumerate() {
let (status, body) = admin_request(
&node.url,
Method::GET,
&compat_get,
None,
&dist.cluster.access_key,
&dist.cluster.secret_key,
)
.await?;
assert_eq!(status, StatusCode::OK, "node {index}: {body}");
assert_eq!(
serde_json::from_str::<serde_json::Value>(&body)?["quota"],
expected.unwrap_or(0),
"node {index} kept stale quota metadata",
);
if expected.is_some() {
let (status, body) = admin_request(
&node.url,
Method::GET,
&native_path,
None,
&dist.cluster.access_key,
&dist.cluster.secret_key,
)
.await?;
assert_eq!(status, StatusCode::OK, "node {index}: {body}");
assert_eq!(serde_json::from_str::<serde_json::Value>(&body)?["quota"].as_u64(), expected);
}
}
}
cluster_admin_ok(
&dist.cluster,
Method::PUT,
&native_path,
Some(serde_json::json!({"quota": 8192, "quota_type": "HARD"}).to_string()),
)
.await?;
dist.cluster.stop_node(3)?;
// An unreachable peer cannot turn an already committed clear into a failure.
cluster_admin_ok(&dist.cluster, Method::DELETE, &native_path, None).await?;
for node in dist.cluster.nodes.iter().take(3) {
let (status, body) = admin_request(
&node.url,
Method::GET,
&compat_get,
None,
&dist.cluster.access_key,
&dist.cluster.secret_key,
)
.await?;
assert_eq!(status, StatusCode::OK, "{body}");
assert_eq!(serde_json::from_str::<serde_json::Value>(&body)?["quota"], 0);
}
Ok(())
}
#[tokio::test]
async fn four_node_four_drive_hard_quota_rejects_over_limit_put() -> TestResult {
init_logging();
let dist = DistCluster::start_with_env(DistLayout::FourByFour, FAST_DATA_USAGE_SCANNER_ENV).await?;
let bucket = unique_bucket("quota");
dist.create_bucket(&bucket).await?;
// A bucket without a quota reports usage only once a snapshot covers it.
// The scanner leader sees its own save at once; every other node holds a
// snapshot cached before the bucket existed and must reload it early
// rather than answer 503 for the rest of the 30 s cache TTL.
wait_until(
Duration::from_secs(30),
|| async {
let stats = quota_stats_on_every_node(&dist.cluster, &bucket).await?;
Ok(stats.iter().any(|(status, _)| status.is_success()))
},
"first node reports quota stats for the new bucket",
)
.await?;
wait_until(
Duration::from_secs(15),
|| async {
let stats = quota_stats_on_every_node(&dist.cluster, &bucket).await?;
Ok(stats.iter().all(|(status, _)| status.is_success()))
},
"every node reports quota stats for the new bucket",
)
.await?;
set_bucket_quota(&dist.cluster, &bucket, 8 * 1024).await?;
let client = dist.client(1)?;
@@ -122,23 +276,12 @@ async fn four_node_four_drive_hard_quota_rejects_over_limit_put() -> TestResult
wait_until(
Duration::from_secs(30),
|| async {
let (status, body) = super::harness::cluster_admin(
&dist.cluster,
Method::GET,
&format!("/rustfs/admin/v3/quota-stats/{bucket}"),
None,
)
.await?;
if !status.is_success() {
return Ok(false);
for (status, body) in quota_stats_on_every_node(&dist.cluster, &bucket).await? {
if status.is_success() && quota_stats_current_usage(&body)?.is_some_and(|usage| usage >= 1024) {
return Ok(true);
}
}
let stats: serde_json::Value =
serde_json::from_str(&body).map_err(|error| format!("quota stats returned invalid JSON: {error}: {body}"))?;
let usage = stats
.get("current_usage")
.and_then(serde_json::Value::as_u64)
.ok_or_else(|| format!("quota stats omitted current_usage: {stats}"))?;
Ok(usage >= 1024)
Ok(false)
},
"quota stats observe small object",
)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,761 @@
// 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.
//! `scanner_heal_topology_` protection matrix (backlog#2873, S00).
//!
//! Every case seeds the deterministic inventory from the fixture into one
//! pool, then drives expansion, rebalance or decommission while re-verifying
//! the recorded versions through every node and the physical pool placement
//! from `xl.meta`. Fault cases hold the mover at a proven window through the
//! `e2e-test-hooks` barriers before injecting a kill or a commit failure. The
//! gate mapping is documented in `docs/testing/scanner-heal-topology-gates.md`.
use super::harness::unique_bucket;
use super::harness::{
DECOMMISSION_POOL_ID, TestResult, decommission_status_json, rebalance_complete, rebalance_status_json, start_decommission,
start_rebalance, wait_for_decommission_active, wait_for_decommission_complete, wait_for_decommission_running_with_progress,
wait_for_rebalance_active, wait_for_rebalance_complete,
};
use super::scanner_heal_topology_fixture::{
LiveWorkload, MOVEMENT_ACTIVE_TIMEOUT, MOVEMENT_COMPLETE_TIMEOUT, MovementOperation, SEED_POOL,
STAGE_AFTER_RESPONSIBILITY_PERSISTED, STAGE_BEFORE_SOURCE_CLEANUP, STAGE_BEFORE_TARGET_COMMIT, TopologyManifest, TopologyRun,
assert_bucket_durability_strict, assert_every_node_serves, assert_probe_objects, assert_process_durability_strict,
assert_seed_pool_census_unchanged, assert_single_pool_placement, cancel_decommission, capture_seed_pool_census,
clear_decommission, expect_decommission_rejected, expected_live_state, incarnation_sidecar_census, pool_count, pools_holding,
put_probe_objects, rebalance_stopped, require_test_hooks, run_live_workload, seed_manifest, start_decommission_targets,
stop_rebalance, verify_from_every_node, verify_live_state, verify_manifest, wait_for_decommission_flag,
};
use crate::common::{admin_request, init_logging};
use http::Method;
use serde_json::{Value, json};
use std::collections::BTreeSet;
use std::time::Duration;
use tokio::time::{Instant, sleep};
const ALL_POOLS: [usize; 4] = [0, 1, 2, 3];
const TARGET_POOLS: [usize; 3] = [1, 2, 3];
const LIVE_WRITERS: usize = 6;
const LIVE_ROUNDS: usize = 6;
/// Locally the decommission start reaches its durable save in about a second
/// (POST to response ~0.8 s); 60 s separates a stalled start path from slow I/O.
const PRE_ACK_REACH_TIMEOUT: Duration = Duration::from_secs(60);
/// Cancel and stop are acknowledged before every in-flight entry finishes its
/// cleanup; terminal state and placement are polled within this budget.
const TERMINAL_TRANSITION_TIMEOUT: Duration = Duration::from_secs(90);
/// Recovery includes the fleet-proof retry before migration can resume. A
/// healthy CI restart completed after 253 s while still making progress; keep
/// this recovery budget below the distributed profile's 720 s process limit.
const DECOMMISSION_RESUME_COMPLETE_TIMEOUT: Duration = Duration::from_secs(360);
fn pools(ids: &[usize]) -> BTreeSet<usize> {
ids.iter().copied().collect()
}
fn seed_pool_sidecars(
census: &std::collections::BTreeMap<String, crate::chaos::VersionShardCensus>,
) -> Vec<(&String, &crate::chaos::VersionShardCensus)> {
census
.iter()
.filter(|(location, _)| location.starts_with(&format!("{SEED_POOL}/")))
.collect()
}
/// Expansion and restart must leave the seed pool's sidecar census unchanged.
fn assert_seed_sidecars_unchanged(run: &TopologyRun, manifest: &TopologyManifest, label: &str) -> TestResult {
for bucket in manifest.buckets() {
let now = incarnation_sidecar_census(&run.dist, bucket)?;
let before = &manifest.incarnation_sidecars[bucket];
if seed_pool_sidecars(&now) != seed_pool_sidecars(before) {
return Err(format!(
"{label}: bucket incarnation sidecar of {bucket} changed on the seed pool: before {before:?} now {now:?}"
)
.into());
}
}
Ok(())
}
/// After the seed pool is retired every bucket must keep its incarnation
/// sidecar on at least one target pool.
fn assert_sidecars_survive_on_targets(
run: &TopologyRun,
manifest: &TopologyManifest,
targets: &BTreeSet<usize>,
label: &str,
) -> TestResult<Value> {
let mut summary = serde_json::Map::new();
for bucket in manifest.buckets() {
let now = incarnation_sidecar_census(&run.dist, bucket)?;
let on_targets: Vec<&String> = now
.keys()
.filter(|location| {
location
.split('/')
.next()
.and_then(|pool| pool.parse::<usize>().ok())
.is_some_and(|pool| targets.contains(&pool))
})
.collect();
if on_targets.is_empty() {
return Err(
format!("{label}: bucket {bucket} has no incarnation sidecar on target pools {targets:?}: {now:?}").into(),
);
}
summary.insert(bucket.to_string(), json!(on_targets));
}
Ok(Value::Object(summary))
}
fn manifest_summary(manifest: &TopologyManifest) -> Value {
let mut shapes = std::collections::BTreeMap::new();
for entry in &manifest.entries {
*shapes.entry(entry.shape).or_insert(0usize) += 1;
}
json!({
"buckets": manifest.buckets(),
"entries": manifest.entries.len(),
"keys": manifest.entries.iter().map(|entry| format!("{}/{}", entry.bucket, entry.key)).collect::<BTreeSet<_>>().len(),
"shapes": shapes,
"bytes": manifest.entries.iter().map(|entry| entry.length).sum::<u64>(),
})
}
fn timeouts() -> Value {
json!({
"movement_active_secs": MOVEMENT_ACTIVE_TIMEOUT.as_secs(),
"movement_complete_secs": MOVEMENT_COMPLETE_TIMEOUT.as_secs(),
"decommission_resume_complete_secs": DECOMMISSION_RESUME_COMPLETE_TIMEOUT.as_secs(),
"barrier_reach_secs": MOVEMENT_COMPLETE_TIMEOUT.as_secs(),
"terminal_transition_secs": TERMINAL_TRANSITION_TIMEOUT.as_secs(),
})
}
/// Cancel and stop acknowledge before every in-flight entry has finished its
/// cleanup, so placement is polled until it settles within the terminal budget.
async fn wait_for_single_pool_placement(
run: &TopologyRun,
manifest: &TopologyManifest,
allowed: &BTreeSet<usize>,
label: &str,
) -> TestResult<std::collections::BTreeMap<String, usize>> {
let deadline = Instant::now() + TERMINAL_TRANSITION_TIMEOUT;
loop {
match assert_single_pool_placement(&run.dist, manifest, allowed, label) {
Ok(placement) => return Ok(placement),
Err(error) if Instant::now() < deadline => {
tracing::info!("{label}: placement not settled yet: {error}");
sleep(Duration::from_secs(1)).await;
}
Err(error) => return Err(error),
}
}
}
/// A stopped run may leave a committed target beside its retained source.
/// Every version must still be held by at least one pool; returns the
/// versions held by more than one.
fn assert_held_by_some_pool(run: &TopologyRun, manifest: &TopologyManifest, label: &str) -> TestResult<Vec<String>> {
let mut duplicated = Vec::new();
for entry in &manifest.entries {
let held = pools_holding(&run.dist, &entry.bucket, &entry.key, entry.version_id.as_deref())?;
if held.is_empty() {
return Err(format!(
"{label}: {}/{} version {:?} is held by no pool",
entry.bucket, entry.key, entry.version_id
)
.into());
}
if held.len() > 1 {
duplicated.push(format!("{}/{}@{} {held:?}", entry.bucket, entry.key, entry.listed_version_id()));
}
}
Ok(duplicated)
}
async fn expand_and_verify(run: &mut TopologyRun, manifest: &TopologyManifest) -> TestResult {
run.dist.expand_to_four_pools().await?;
assert_eq!(pool_count(&run.dist.cluster).await?, 4, "expected four pools after expansion");
verify_from_every_node(&run.dist, manifest, "after expansion to four pools").await?;
assert_single_pool_placement(&run.dist, manifest, &pools(&[SEED_POOL]), "before data movement")?;
Ok(())
}
/// G-EXP: 1→2→4 pools with scanner and heal enabled; old versions intact on
/// every node, seed-pool shards and set placement untouched, new writes land
/// in exactly one pool, and the pool map survives a full restart.
#[tokio::test]
async fn scanner_heal_topology_expand_one_to_four_pools_preserves_versions_and_placement() -> TestResult {
init_logging();
let mut run = TopologyRun::start("expand").await?;
let manifest = seed_manifest(&run).await?;
let bucket_durability = assert_bucket_durability_strict(&run.dist.cluster, &manifest.buckets()).await?;
verify_from_every_node(&run.dist, &manifest, "seeded").await?;
let seed_census = capture_seed_pool_census(&run.dist, &manifest)?;
let scratch = unique_bucket("sht-scratch");
run.dist.create_bucket(&scratch).await?;
let mut steps = Vec::new();
for expected_pools in 2..=4 {
let new_node = run.dist.append_pool_and_restart().await?;
if new_node + 1 != expected_pools {
return Err(format!("appended node index {new_node} does not match pool count {expected_pools}").into());
}
let label = format!("after expanding to {expected_pools} pools");
assert_eq!(pool_count(&run.dist.cluster).await?, expected_pools, "{label}: pool list");
verify_from_every_node(&run.dist, &manifest, &label).await?;
assert_seed_pool_census_unchanged(&run.dist, &manifest, &seed_census, &label)?;
assert_seed_sidecars_unchanged(&run, &manifest, &label)?;
steps.push(json!({ "pools": expected_pools, "verified_nodes": run.dist.cluster.nodes.len() }));
}
let probes = put_probe_objects(&run.dist, &scratch, "post-expand", 8).await?;
let probe_placement = assert_probe_objects(&run.dist, &scratch, &probes, &pools(&ALL_POOLS), "post-expand probes").await?;
run.dist.restart_current_binary_gracefully().await?;
assert_eq!(pool_count(&run.dist.cluster).await?, 4, "pool map after restart");
verify_from_every_node(&run.dist, &manifest, "after full restart").await?;
assert_seed_pool_census_unchanged(&run.dist, &manifest, &seed_census, "after full restart")?;
assert_seed_sidecars_unchanged(&run, &manifest, "after full restart")?;
assert_probe_objects(&run.dist, &scratch, &probes, &pools(&ALL_POOLS), "post-expand probes after restart").await?;
assert_bucket_durability_strict(&run.dist.cluster, &manifest.buckets()).await?;
let strict_nodes = assert_process_durability_strict(&run.node_log_paths())?;
run.write_evidence(
"g-exp-expand",
&json!({
"gate": "G-EXP",
"manifest": manifest_summary(&manifest),
"bucket_durability": bucket_durability,
"process_durability_strict_nodes": strict_nodes,
"expansion_steps": steps,
"probe_placement": probe_placement,
"timeouts": timeouts(),
}),
)?;
Ok(())
}
/// Wait for the first rebalance run to report either `stopped` (the expected
/// outcome) or `completed` (the inventory moved before the stop arrived).
async fn wait_for_rebalance_stopped_or_completed(run: &TopologyRun, rebalance_id: &str) -> TestResult<(&'static str, Value)> {
let deadline = Instant::now() + TERMINAL_TRANSITION_TIMEOUT;
loop {
let status = rebalance_status_json(&run.dist.cluster).await?;
if rebalance_stopped(&status, rebalance_id)? {
return Ok(("stopped", status));
}
if rebalance_complete(&status, rebalance_id).unwrap_or(false) {
return Ok(("completed_before_stop", status));
}
if Instant::now() >= deadline {
return Err(format!(
"rebalance {rebalance_id} reached neither stopped nor completed within {TERMINAL_TRANSITION_TIMEOUT:?}: {status}"
)
.into());
}
sleep(Duration::from_millis(500)).await;
}
}
/// G-REB: rebalance with concurrent PUT/GET/DELETE and the scanner running;
/// stop mid-run, verify no content error and no lost acknowledged version,
/// restart the cluster, then rebalance to completion.
#[tokio::test]
async fn scanner_heal_topology_rebalance_survives_live_writes_stop_and_restart() -> TestResult {
init_logging();
let mut run = TopologyRun::start("rebalance").await?;
let manifest = seed_manifest(&run).await?;
assert_bucket_durability_strict(&run.dist.cluster, &manifest.buckets()).await?;
verify_from_every_node(&run.dist, &manifest, "seeded").await?;
let scratch = unique_bucket("sht-scratch");
run.dist.create_bucket(&scratch).await?;
expand_and_verify(&mut run, &manifest).await?;
let baseline: Vec<(String, String)> = manifest
.plain_entries()
.filter(|entry| matches!(entry.shape, "inline" | "external" | "special"))
.map(|entry| (entry.key.clone(), entry.sha256.clone()))
.collect();
let first_id = start_rebalance(&run.dist.cluster).await?;
wait_for_rebalance_active(&run.dist.cluster, &first_id, MOVEMENT_ACTIVE_TIMEOUT).await?;
let during_rebalance = put_probe_objects(&run.dist, &scratch, "during-rebalance", 4).await?;
let workload = tokio::spawn(run_live_workload(LiveWorkload {
clients: run.dist.clients()?,
bucket: scratch.clone(),
prefix: "live".to_string(),
writers: LIVE_WRITERS,
rounds: LIVE_ROUNDS,
baseline_bucket: manifest.plain_bucket.clone(),
baseline: baseline.clone(),
pause: Duration::from_millis(100),
}));
sleep(Duration::from_secs(2)).await;
stop_rebalance(&run.dist.cluster).await?;
let (first_outcome, first_status) = wait_for_rebalance_stopped_or_completed(&run, &first_id).await?;
let ops = workload.await??;
let live_state = expected_live_state(&ops);
for node_idx in 0..run.dist.cluster.nodes.len() {
let client = run.dist.client(node_idx)?;
verify_manifest(&client, &manifest, &format!("after rebalance {first_outcome} via node {node_idx}")).await?;
verify_live_state(
&client,
&scratch,
&live_state,
&format!("after rebalance {first_outcome} via node {node_idx}"),
)
.await?;
}
let retained_after_stop = assert_held_by_some_pool(&run, &manifest, &format!("after rebalance {first_outcome}"))?;
let during_placement = assert_probe_objects(
&run.dist,
&scratch,
&during_rebalance,
&pools(&TARGET_POOLS),
"writes issued while the seed pool was rebalancing must avoid it",
)
.await?;
run.dist.restart_current_binary_gracefully().await?;
assert_every_node_serves(&run.dist, &scratch, "after-stop-restart").await?;
verify_from_every_node(&run.dist, &manifest, "after restart following the stop").await?;
verify_live_state(&run.dist.client(0)?, &scratch, &live_state, "after restart following the stop").await?;
let second_id = start_rebalance(&run.dist.cluster).await?;
wait_for_rebalance_active(&run.dist.cluster, &second_id, MOVEMENT_ACTIVE_TIMEOUT).await?;
wait_for_rebalance_complete(&run.dist.cluster, &second_id, MOVEMENT_COMPLETE_TIMEOUT).await?;
verify_from_every_node(&run.dist, &manifest, "after the second rebalance completed").await?;
verify_live_state(&run.dist.client(3)?, &scratch, &live_state, "after the second rebalance completed").await?;
let final_placement =
assert_single_pool_placement(&run.dist, &manifest, &pools(&ALL_POOLS), "after the second rebalance completed")?;
let moved = final_placement.values().filter(|pool| **pool != SEED_POOL).count();
if moved == 0 {
return Err("rebalance completed without moving any manifest version off the seed pool".into());
}
let strict_nodes = assert_process_durability_strict(&run.node_log_paths())?;
run.write_evidence(
"g-reb-rebalance",
&json!({
"gate": "G-REB",
"manifest": manifest_summary(&manifest),
"first_run": { "id": first_id, "outcome": first_outcome, "status": first_status, "versions_held_by_two_pools_after_stop": retained_after_stop },
"second_run": { "id": second_id, "versions_off_seed_pool": moved },
"live_ops": ops,
"probe_placement_during_rebalance": during_placement,
"process_durability_strict_nodes": strict_nodes,
"timeouts": timeouts(),
}),
)?;
Ok(())
}
/// G-DEC: cancel an active decommission and keep every version; queue two
/// pools, retire them in order, reject retiring the last active pools, and
/// keep listings, history, markers and locks correct across a restart.
#[tokio::test]
async fn scanner_heal_topology_decommission_queue_cancel_resume_and_last_pool_guard() -> TestResult {
init_logging();
let mut run = TopologyRun::start("decommission").await?;
let manifest = seed_manifest(&run).await?;
assert_bucket_durability_strict(&run.dist.cluster, &manifest.buckets()).await?;
verify_from_every_node(&run.dist, &manifest, "seeded").await?;
let scratch = unique_bucket("sht-scratch");
run.dist.create_bucket(&scratch).await?;
expand_and_verify(&mut run, &manifest).await?;
start_decommission(&run.dist.cluster, DECOMMISSION_POOL_ID).await?;
wait_for_decommission_running_with_progress(&run.dist.cluster, DECOMMISSION_POOL_ID, MOVEMENT_ACTIVE_TIMEOUT).await?;
cancel_decommission(&run.dist.cluster, DECOMMISSION_POOL_ID).await?;
let canceled =
wait_for_decommission_flag(&run.dist.cluster, DECOMMISSION_POOL_ID, "canceled", TERMINAL_TRANSITION_TIMEOUT).await?;
let after_cancel = wait_for_single_pool_placement(&run, &manifest, &pools(&ALL_POOLS), "after cancel").await?;
verify_from_every_node(&run.dist, &manifest, "after cancel").await?;
clear_decommission(&run.dist.cluster, DECOMMISSION_POOL_ID).await?;
start_decommission_targets(&run.dist.cluster, "0,1").await?;
let queued = wait_for_decommission_flag(&run.dist.cluster, 1, "queued", Duration::from_secs(5)).await?;
wait_for_decommission_active(&run.dist.cluster, 0, MOVEMENT_ACTIVE_TIMEOUT).await?;
let during = put_probe_objects(&run.dist, &scratch, "during-decommission", 4).await?;
wait_for_decommission_complete(&run.dist.cluster, 0, MOVEMENT_COMPLETE_TIMEOUT).await?;
wait_for_decommission_complete(&run.dist.cluster, 1, MOVEMENT_COMPLETE_TIMEOUT).await?;
let (rejected_status, rejected_body) = expect_decommission_rejected(&run.dist.cluster, "2,3").await?;
verify_from_every_node(&run.dist, &manifest, "after retiring pools 0 and 1").await?;
let final_placement = assert_single_pool_placement(&run.dist, &manifest, &pools(&[2, 3]), "after retiring pools 0 and 1")?;
assert_probe_objects(&run.dist, &scratch, &during, &pools(&[2, 3]), "writes issued during decommission").await?;
let sidecars = assert_sidecars_survive_on_targets(&run, &manifest, &pools(&[2, 3]), "after retiring pools 0 and 1")?;
// A decommissioned pool must leave the command line; the server refuses to
// start while it is still listed. Pools 2 and 3 are renumbered to 0 and 1.
run.dist.remove_retired_pools_and_restart(&[0, 1]).await?;
let pools_after_restart = pool_count(&run.dist.cluster).await?;
if pools_after_restart != 2 {
return Err(format!("expected two pools after removing the retired pools, found {pools_after_restart}").into());
}
verify_from_every_node(&run.dist, &manifest, "after restart without the retired pools").await?;
assert_single_pool_placement(&run.dist, &manifest, &pools(&[0, 1]), "after restart without the retired pools")?;
let status_after_restart = decommission_status_json(&run.dist.cluster).await?;
let strict_nodes = assert_process_durability_strict(&run.node_log_paths())?;
run.write_evidence(
"g-dec-decommission",
&json!({
"gate": "G-DEC",
"manifest": manifest_summary(&manifest),
"cancel": canceled,
"placement_after_cancel_pools": after_cancel.values().collect::<BTreeSet<_>>(),
"queued_pool_1": queued,
"final_placement_pools": final_placement.values().collect::<BTreeSet<_>>(),
"sidecars_on_targets": sidecars,
"last_pool_rejection": { "status": rejected_status.as_u16(), "body": rejected_body },
"status_after_restart": status_after_restart,
"process_durability_strict_nodes": strict_nodes,
"timeouts": timeouts(),
}),
)?;
Ok(())
}
/// Outcome of a fault case: the worker result and the data verdict are
/// reported separately so a fail-closed worker and a lost version never hide
/// each other.
async fn settle_decommission(
run: &TopologyRun,
manifest: &TopologyManifest,
label: &str,
completion_timeout: Duration,
) -> TestResult<Value> {
let completion = wait_for_decommission_complete(&run.dist.cluster, DECOMMISSION_POOL_ID, completion_timeout).await;
let allowed = if completion.is_ok() {
pools(&TARGET_POOLS)
} else {
pools(&ALL_POOLS)
};
let integrity = async {
verify_from_every_node(&run.dist, manifest, label).await?;
wait_for_single_pool_placement(run, manifest, &allowed, label).await
}
.await;
let status = decommission_status_json(&run.dist.cluster).await.unwrap_or(Value::Null);
settle(label, completion, integrity, status)
}
async fn settle_rebalance(run: &TopologyRun, manifest: &TopologyManifest, rebalance_id: &str, label: &str) -> TestResult<Value> {
let completion = wait_for_rebalance_complete(&run.dist.cluster, rebalance_id, MOVEMENT_COMPLETE_TIMEOUT).await;
let integrity = async {
verify_from_every_node(&run.dist, manifest, label).await?;
wait_for_single_pool_placement(run, manifest, &pools(&ALL_POOLS), label).await
}
.await;
let status = rebalance_status_json(&run.dist.cluster).await.unwrap_or(Value::Null);
settle(label, completion, integrity, status)
}
fn settle(
label: &str,
completion: TestResult,
integrity: TestResult<std::collections::BTreeMap<String, usize>>,
status: Value,
) -> TestResult<Value> {
match (completion, integrity) {
(Ok(()), Ok(placement)) => {
Ok(json!({ "worker": "completed", "placement_pools": placement.values().collect::<BTreeSet<_>>(), "status": status }))
}
(Err(worker), Ok(_)) => {
Err(format!("{label}: data stayed intact but the worker did not complete: {worker}; status {status}").into())
}
(Ok(()), Err(data)) => Err(format!("{label}: worker completed but data diverged: {data}").into()),
(Err(worker), Err(data)) => {
Err(format!("{label}: worker did not complete: {worker}; and data diverged: {data}; status {status}").into())
}
}
}
fn record(run: &TopologyRun, case: &str, outcome: &TestResult<Value>, mut evidence: Value) -> TestResult {
evidence["outcome"] = match outcome {
Ok(detail) => detail.clone(),
Err(error) => json!({ "error": error.to_string() }),
};
run.write_evidence(case, &evidence)?;
Ok(())
}
/// G-DEC fault: SIGKILL the decommission leader while an entry is held after
/// its target commit and before source cleanup; the resumed worker must
/// finish without losing or duplicating the version.
#[tokio::test]
async fn scanner_heal_topology_decommission_kill_before_source_cleanup_resumes_without_loss() -> TestResult {
init_logging();
require_test_hooks();
let mut run = TopologyRun::start("decom-kill").await?;
let manifest = seed_manifest(&run).await?;
verify_from_every_node(&run.dist, &manifest, "seeded").await?;
expand_and_verify(&mut run, &manifest).await?;
let key = manifest.plain_key("external", 3)?;
run.movement_barrier
.arm_movement("decommission", &manifest.plain_bucket, &key, STAGE_BEFORE_SOURCE_CLEANUP)?;
start_decommission(&run.dist.cluster, DECOMMISSION_POOL_ID).await?;
let reached = run
.movement_barrier
.wait_reached_during(
&run.dist.cluster,
MovementOperation::Decommission(DECOMMISSION_POOL_ID),
MOVEMENT_COMPLETE_TIMEOUT,
)
.await?;
let window = pools_holding(&run.dist, &manifest.plain_bucket, &key, None)?;
if !window.contains(&SEED_POOL) || window.len() < 2 {
return Err(format!(
"barrier window did not show a committed target beside the retained source for {key}: pools {window:?}"
)
.into());
}
let killed_pid = run.kill_node(SEED_POOL)?;
run.movement_barrier.disarm()?;
let restarted_pid = run.restart_killed_node(SEED_POOL).await?;
let label = "after the killed leader resumed decommission";
let outcome = settle_decommission(&run, &manifest, label, DECOMMISSION_RESUME_COMPLETE_TIMEOUT).await;
let strict_nodes = assert_process_durability_strict(&run.node_log_paths());
record(
&run,
"g-dec-kill-before-source-cleanup",
&outcome,
json!({
"gate": "G-DEC",
"window": STAGE_BEFORE_SOURCE_CLEANUP,
"nonce": run.movement_barrier.nonce(),
"object": key,
"reached": reached,
"pools_holding_at_window": window,
"killed_pid": killed_pid,
"restarted_pid": restarted_pid,
"process_durability_strict_nodes": strict_nodes.as_ref().ok(),
"timeouts": timeouts(),
}),
)?;
outcome?;
strict_nodes?;
Ok(())
}
/// G-DEC fault: inject a target commit failure after the multipart copy is
/// staged; the source must stay authoritative and a later attempt completes.
#[tokio::test]
async fn scanner_heal_topology_decommission_target_commit_failure_retains_source() -> TestResult {
init_logging();
require_test_hooks();
let mut run = TopologyRun::start("decom-target-fail").await?;
let manifest = seed_manifest(&run).await?;
verify_from_every_node(&run.dist, &manifest, "seeded").await?;
expand_and_verify(&mut run, &manifest).await?;
let key = manifest.plain_key("multipart", 0)?;
run.movement_barrier
.arm_movement("decommission", &manifest.plain_bucket, &key, STAGE_BEFORE_TARGET_COMMIT)?;
start_decommission(&run.dist.cluster, DECOMMISSION_POOL_ID).await?;
let reached = run
.movement_barrier
.wait_reached_during(
&run.dist.cluster,
MovementOperation::Decommission(DECOMMISSION_POOL_ID),
MOVEMENT_COMPLETE_TIMEOUT,
)
.await?;
let window = pools_holding(&run.dist, &manifest.plain_bucket, &key, None)?;
if window != pools(&[SEED_POOL]) {
return Err(format!("the target must not be published before its commit; {key} is held by {window:?}").into());
}
run.movement_barrier.disarm()?;
run.movement_barrier.release_movement_with_io_error()?;
let label = "after the failed target commit was retried";
let outcome = settle_decommission(&run, &manifest, label, MOVEMENT_COMPLETE_TIMEOUT).await;
let strict_nodes = assert_process_durability_strict(&run.node_log_paths());
record(
&run,
"g-dec-target-commit-failure",
&outcome,
json!({
"gate": "G-DEC",
"window": STAGE_BEFORE_TARGET_COMMIT,
"nonce": run.movement_barrier.nonce(),
"object": key,
"reached": reached,
"injection": "io_error",
"process_durability_strict_nodes": strict_nodes.as_ref().ok(),
"timeouts": timeouts(),
}),
)?;
outcome?;
strict_nodes?;
Ok(())
}
/// G-DEC fault: kill the leader after the decommission start transaction is
/// durable in `pool.bin` but before the admin request is acknowledged and
/// before any worker starts; the persisted responsibility must resume on restart.
#[tokio::test]
async fn scanner_heal_topology_decommission_kill_after_responsibility_persisted_before_ack_resumes() -> TestResult {
init_logging();
require_test_hooks();
let mut run = TopologyRun::start("decom-ack").await?;
let manifest = seed_manifest(&run).await?;
verify_from_every_node(&run.dist, &manifest, "seeded").await?;
expand_and_verify(&mut run, &manifest).await?;
run.movement_barrier
.arm_movement("decommission_start", ".rustfs.sys", "pool.bin", STAGE_AFTER_RESPONSIBILITY_PERSISTED)?;
let leader_url = run.dist.cluster.nodes[SEED_POOL].url.clone();
let access_key = run.dist.cluster.access_key.clone();
let secret_key = run.dist.cluster.secret_key.clone();
let start_request = tokio::spawn(async move {
// Mirror start_decommission: the init stage answers 500 with the fleet
// proof marker until the fleet has proven its capability.
let path = format!("/rustfs/admin/v3/pools/decommission?pool={DECOMMISSION_POOL_ID}&by-id=true");
// A busy movement gate answers a retryable 503; keep retrying within
// the window budget so a refused start is never read as a reached window.
let deadline = Instant::now() + PRE_ACK_REACH_TIMEOUT;
loop {
match admin_request(&leader_url, Method::POST, &path, None, &access_key, &secret_key).await {
Ok((status, body))
if (status.as_u16() == 503
|| (status.as_u16() == 500 && body.contains("requires a live fleet capability proof")))
&& Instant::now() < deadline =>
{
sleep(Duration::from_secs(1)).await;
}
Ok((status, body)) => return Ok(format!("{status} {body}")),
Err(error) => return Err(error.to_string()),
}
}
});
// The start request normally reaches the durable save within about a
// second. If it returns first the window was never entered; if it stays in
// flight past the budget, the start path itself stalled before the save.
let requested_at = Instant::now();
let mut start_request = start_request;
let reached = tokio::select! {
reached = run.movement_barrier.wait_reached(PRE_ACK_REACH_TIMEOUT) => match reached {
Ok(reached) => reached,
Err(error) => {
return Err(format!(
"the decommission start stayed in flight for {PRE_ACK_REACH_TIMEOUT:?} without reaching the durable start save: {error}"
)
.into());
}
},
finished = &mut start_request => {
return Err(format!("the decommission start returned before the pre-ACK window was reached: {:?}", finished?).into());
}
};
let reach_elapsed = requested_at.elapsed();
let killed_pid = run.kill_node(SEED_POOL)?;
let start_outcome = start_request.await?;
if let Ok(response) = &start_outcome {
return Err(format!(
"the decommission start was acknowledged ({response}) although the leader was killed in the pre-ACK window"
)
.into());
}
run.movement_barrier.disarm()?;
let restarted_pid = run.restart_killed_node(SEED_POOL).await?;
let label = "after the persisted decommission resumed";
let resumed = wait_for_decommission_active(&run.dist.cluster, DECOMMISSION_POOL_ID, Duration::from_secs(60)).await;
let outcome = match resumed {
Ok(()) => settle_decommission(&run, &manifest, label, DECOMMISSION_RESUME_COMPLETE_TIMEOUT).await,
Err(error) => Err(format!("{label}: the persisted decommission did not become active after the restart: {error}").into()),
};
let strict_nodes = assert_process_durability_strict(&run.node_log_paths());
record(
&run,
"g-dec-kill-before-ack",
&outcome,
json!({
"gate": "G-DEC",
"window": STAGE_AFTER_RESPONSIBILITY_PERSISTED,
"nonce": run.movement_barrier.nonce(),
"reached": reached,
"reach_elapsed_ms": reach_elapsed.as_millis() as u64,
"start_request": { "outcome": "uncertain", "error": start_outcome.err() },
"killed_pid": killed_pid,
"restarted_pid": restarted_pid,
"process_durability_strict_nodes": strict_nodes.as_ref().ok(),
"timeouts": timeouts(),
}),
)?;
outcome?;
strict_nodes?;
Ok(())
}
/// G-REB fault: SIGKILL the rebalancing node while an entry is held after its
/// target commit and before source cleanup; the resumed run must complete
/// without losing or duplicating the version.
#[tokio::test]
async fn scanner_heal_topology_rebalance_kill_before_source_cleanup_resumes_without_loss() -> TestResult {
init_logging();
require_test_hooks();
let mut run = TopologyRun::start("rebal-kill").await?;
let manifest = seed_manifest(&run).await?;
verify_from_every_node(&run.dist, &manifest, "seeded").await?;
expand_and_verify(&mut run, &manifest).await?;
let key = manifest.plain_key("empty", 0)?;
run.movement_barrier
.arm_movement("rebalance", &manifest.plain_bucket, &key, STAGE_BEFORE_SOURCE_CLEANUP)?;
let rebalance_id = start_rebalance(&run.dist.cluster).await?;
let reached = run
.movement_barrier
.wait_reached_during(&run.dist.cluster, MovementOperation::Rebalance(&rebalance_id), MOVEMENT_COMPLETE_TIMEOUT)
.await?;
let window = pools_holding(&run.dist, &manifest.plain_bucket, &key, None)?;
if !window.contains(&SEED_POOL) || window.len() < 2 {
return Err(format!(
"barrier window did not show a committed target beside the retained source for {key}: pools {window:?}"
)
.into());
}
let killed_pid = run.kill_node(SEED_POOL)?;
run.movement_barrier.disarm()?;
let restarted_pid = run.restart_killed_node(SEED_POOL).await?;
let label = "after the killed node resumed rebalance";
let outcome = settle_rebalance(&run, &manifest, &rebalance_id, label).await;
let strict_nodes = assert_process_durability_strict(&run.node_log_paths());
record(
&run,
"g-reb-kill-before-source-cleanup",
&outcome,
json!({
"gate": "G-REB",
"window": STAGE_BEFORE_SOURCE_CLEANUP,
"nonce": run.movement_barrier.nonce(),
"object": key,
"reached": reached,
"pools_holding_at_window": window,
"rebalance_id": rebalance_id,
"killed_pid": killed_pid,
"restarted_pid": restarted_pid,
"process_durability_strict_nodes": strict_nodes.as_ref().ok(),
"timeouts": timeouts(),
}),
)?;
outcome?;
strict_nodes?;
Ok(())
}
@@ -17,6 +17,14 @@ use crate::common::init_logging;
use aws_sdk_s3::error::ProvideErrorMetadata;
use aws_sdk_s3::types::{BucketVersioningStatus, VersioningConfiguration};
#[tokio::test]
async fn four_node_explicit_delete_marker_batch_is_immediately_visible() -> TestResult {
init_logging();
let dist = DistCluster::start(DistLayout::FourByFour).await?;
let bucket = unique_bucket("explicit-marker-batch");
crate::delete_objects_versioning_test::assert_explicit_marker_batch_cleanup(&dist.clients()?, &bucket).await
}
#[tokio::test]
async fn four_node_four_drive_versioning_put_list_get_delete_marker() -> TestResult {
init_logging();
@@ -1206,7 +1206,9 @@ mod tests {
let interval = interval
.filter(|interval| *interval != 0)
.ok_or("scanner schedule has no positive interval")?;
let next_cycle = cycle_end
// A new leader may already be sleeping before completing its first cycle.
let schedule_start = if cycle_end == 0 { now } else { cycle_end };
let next_cycle = schedule_start
.checked_add(interval)
.ok_or("scanner schedule timestamp overflows")?;
let budget = next_cycle
@@ -1223,6 +1225,11 @@ mod tests {
Ok(Duration::from_secs(259)),
);
assert_eq!(scanner_cycle_completion_budget(100, Some(1), 110, false), Ok(Duration::from_secs(60)));
// A newly elected leader can advertise a sleep before its first completed cycle.
assert_eq!(
scanner_cycle_completion_budget(0, Some(289), 1_792_913_211, false),
Ok(Duration::from_secs(349)),
);
}
#[test]
@@ -1243,7 +1250,7 @@ mod tests {
previous_cycle_end: u64,
) -> Result<u64, Box<dyn Error + Send + Sync>> {
let mut deadline = Instant::now() + Duration::from_secs(60);
let mut schedule_accounted = false;
let mut accounted_schedules = HashSet::new();
loop {
let mut latest_cycle_end = 0;
let mut versions_observed = false;
@@ -1290,15 +1297,27 @@ mod tests {
return Err("scanner cycle timestamp is in the future".into());
}
let schedule = &status["cycle_schedule"];
if !schedule_accounted && schedule["execution_role"].as_str() == Some("leader") {
if schedule["execution_role"].as_str() == Some("leader") {
let active = metrics["current_cycle_active"]
.as_bool()
.ok_or("scanner status is missing its active-cycle flag")?;
if active || (cycle_end != 0 && schedule["effective_interval_available"].as_bool() == Some(true)) {
let interval = schedule["effective_interval_seconds"].as_u64();
let budget = scanner_cycle_completion_budget(cycle_end, interval, now, active)?;
deadline = Instant::now().checked_add(budget).ok_or("scanner deadline overflows")?;
schedule_accounted = true;
if active || schedule["effective_interval_available"].as_bool() == Some(true) {
let interval = if active {
None
} else {
Some(
schedule["effective_interval_seconds"]
.as_u64()
.ok_or("scanner schedule is missing its effective interval")?,
)
};
// Reconsider a changed leader or retry plan, but never renew
// the deadline on repeated observations of the same sleep.
if accounted_schedules.insert((node_index, cycle_end, interval)) {
let budget = scanner_cycle_completion_budget(cycle_end, interval, now, active)?;
let scheduled_deadline = Instant::now().checked_add(budget).ok_or("scanner deadline overflows")?;
deadline = deadline.max(scheduled_deadline);
}
}
}
// Superseded walks complete all sets while withholding quota authority.
+6
View File
@@ -45,6 +45,9 @@ pub mod on_demand_migration;
#[cfg(test)]
pub mod fault_proxy;
#[cfg(test)]
mod rpc_fault_proxy;
// Reliability tests built on the fault-injection harness
#[cfg(test)]
mod reliability_disk_fault_test;
@@ -189,6 +192,9 @@ mod conditional_delete_objects_test;
#[cfg(test)]
mod delete_authorization_test;
#[cfg(test)]
mod table_location_authorization_test;
// Regression test for signed DELETE Object?versionId requests without Content-Length.
#[cfg(test)]
mod delete_object_no_content_length_test;
@@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
//! Harbor / Docker Distribution multipart staging to CopyObject regressions.
//! Multipart completion validation, staging preservation, and CopyObject readiness.
use crate::common::{RustFSTestEnvironment, init_logging};
use aws_sdk_s3::error::ProvideErrorMetadata;
@@ -39,6 +39,132 @@ fn list_contains_key(output: &aws_sdk_s3::operation::list_objects_v2::ListObject
.any(|object| object.key().is_some_and(|candidate| candidate == key))
}
#[tokio::test]
async fn multipart_complete_invalid_etag_returns_400_and_preserves_retry() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let client = env.create_s3_client();
let bucket = "multipart-invalid-etag";
let key = "object";
env.create_test_bucket(bucket).await?;
let original = b"original object";
client
.put_object()
.bucket(bucket)
.key(key)
.body(ByteStream::from_static(original))
.send()
.await?;
let upload = client.create_multipart_upload().bucket(bucket).key(key).send().await?;
let upload_id = upload.upload_id().ok_or("missing upload id")?;
let payload = b"completed multipart payload";
let mut uploaded_etag = None;
// The first case reproduces #8771's Create -> Complete sequence without UploadPart.
for (upload_first, part_number, etag) in [
(false, 1, Some("unknown-etag")),
(true, 1, Some("unknown-etag")),
(true, 1, Some("\"unknown-etag\"")),
(true, 1, Some("00000000000000000000000000000000")),
(true, 1, None),
(true, 2, Some("unknown-etag")),
] {
if upload_first && uploaded_etag.is_none() {
let part = client
.upload_part()
.bucket(bucket)
.key(key)
.upload_id(upload_id)
.part_number(1)
.body(ByteStream::from_static(payload))
.send()
.await?;
uploaded_etag = Some(part.e_tag().ok_or("missing part ETag")?.to_owned());
}
let error = client
.complete_multipart_upload()
.bucket(bucket)
.key(key)
.upload_id(upload_id)
.multipart_upload(
CompletedMultipartUpload::builder()
.parts(
CompletedPart::builder()
.part_number(part_number)
.set_e_tag(etag.map(str::to_owned))
.build(),
)
.build(),
)
.send()
.await
.expect_err("missing or mismatched parts must not complete the upload");
assert_eq!(error.raw_response().map(|response| response.status().as_u16()), Some(400), "{error:?}");
assert_eq!(
error.as_service_error().and_then(ProvideErrorMetadata::code),
Some("InvalidPart"),
"{error:?}"
);
let staged = client
.list_parts()
.bucket(bucket)
.key(key)
.upload_id(upload_id)
.send()
.await?;
assert_eq!(staged.parts().len(), usize::from(uploaded_etag.is_some()));
if let Some(etag) = uploaded_etag.as_deref() {
assert_eq!(staged.parts()[0].part_number(), Some(1));
assert_eq!(staged.parts()[0].e_tag(), Some(etag));
}
let current = client
.get_object()
.bucket(bucket)
.key(key)
.send()
.await?
.body
.collect()
.await?
.into_bytes();
assert_eq!(current.as_ref(), original, "rejected completion must preserve the original object");
}
let etag = uploaded_etag.ok_or("part was not uploaded")?;
// Accept the unquoted value, then replay with the quoted UploadPart response.
for etag in [etag.trim_matches('"'), etag.as_str()] {
client
.complete_multipart_upload()
.bucket(bucket)
.key(key)
.upload_id(upload_id)
.multipart_upload(
CompletedMultipartUpload::builder()
.parts(CompletedPart::builder().part_number(1).e_tag(etag).build())
.build(),
)
.send()
.await?;
}
let current = client
.get_object()
.bucket(bucket)
.key(key)
.send()
.await?
.body
.collect()
.await?
.into_bytes();
assert_eq!(current.as_ref(), payload, "a valid retry must publish the exact uploaded bytes");
client.delete_object().bucket(bucket).key(key).send().await?;
env.delete_test_bucket(bucket).await?;
env.stop_server();
Ok(())
}
async fn upload_one_part_mpu(
client: &aws_sdk_s3::Client,
bucket: &str,
+340 -37
View File
@@ -230,6 +230,302 @@ fn encode_unsigned_aws_chunked_with_sha256_trailer(decoded: &[u8]) -> Vec<u8> {
encoded
}
/// Sign the data and terminating chunks independently of the server decoder.
fn encode_signed_aws_chunked(signer: &SigV4, headers: &SignedHeaders, decoded: &[u8], trailer: bool) -> Vec<u8> {
let mut previous = headers
.authorization
.rsplit_once("Signature=")
.expect("seed signature")
.1
.to_string();
let mut encoded = Vec::new();
for chunk in [decoded, b"".as_slice()] {
let string_to_sign = format!(
"AWS4-HMAC-SHA256-PAYLOAD\n{}\n{}\n{previous}\n{}\n{}",
headers.amz_date,
get_scope(REGION, signer.time, "s3"),
sha256_hex(b""),
sha256_hex(chunk),
);
previous = get_signature(get_signing_key(&signer.secret_key, REGION, signer.time, "s3"), &string_to_sign);
encoded.extend_from_slice(format!("{:x};chunk-signature={previous}\r\n", chunk.len()).as_bytes());
encoded.extend_from_slice(chunk);
encoded.extend_from_slice(b"\r\n");
}
if trailer {
let checksum = sha256_base64(decoded);
let canonical = format!("x-amz-checksum-sha256:{checksum}\n");
let string_to_sign = format!(
"AWS4-HMAC-SHA256-TRAILER\n{}\n{}\n{previous}\n{}",
headers.amz_date,
get_scope(REGION, signer.time, "s3"),
sha256_hex(canonical.as_bytes()),
);
let signature = get_signature(get_signing_key(&signer.secret_key, REGION, signer.time, "s3"), &string_to_sign);
encoded.extend_from_slice(
format!("x-amz-checksum-sha256:{checksum}\r\nx-amz-trailer-signature:{signature}\r\n\r\n").as_bytes(),
);
}
encoded
}
/// Exercise decoder failures through both storage publication boundaries.
#[tokio::test]
async fn aws_chunked_named_errors_reject_put_and_upload_part() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let mut env = RustFSTestEnvironment::new().await?;
setup(&mut env).await?;
let client = env.create_s3_client();
let decoded = b"private-chunked-fixture-body";
let mut failures = Vec::new();
for multipart in [false, true] {
for (case, expected_status, expected_code) in [
("signature", 403, "SignatureDoesNotMatch"),
("final-signature", 403, "SignatureDoesNotMatch"),
("format", 400, "IncompleteBody"),
("metadata-at-limit", 400, "IncompleteBody"),
("metadata-over-limit", 400, "EntityTooLarge"),
("chunk-over-limit", 400, "EntityTooLarge"),
("trailers-over-limit", 400, "IncompleteBody"),
("too-many-trailers", 400, "IncompleteBody"),
] {
let key = format!("chunked-{case}-{multipart}.bin");
let upload_id = if multipart {
Some(
client
.create_multipart_upload()
.bucket(BUCKET)
.key(&key)
.send()
.await?
.upload_id()
.ok_or("missing upload ID")?
.to_owned(),
)
} else {
None
};
let query = upload_id
.as_ref()
.map(|id| format!("partNumber=1&uploadId={}", urlencoding::encode(id)))
.unwrap_or_default();
let path = format!("/{BUCKET}/{key}");
let target = if query.is_empty() {
path.clone()
} else {
format!("{path}?{query}")
};
let signer = SigV4::new(&env);
let unsigned = matches!(case, "trailers-over-limit" | "too-many-trailers");
let mode = if unsigned {
UNSIGNED_PAYLOAD_TRAILER
} else {
"STREAMING-AWS4-HMAC-SHA256-PAYLOAD"
};
let length = decoded.len().to_string();
let mut extra = vec![
("content-encoding", "aws-chunked"),
("x-amz-decoded-content-length", length.as_str()),
];
if unsigned {
extra.push(("x-amz-trailer", "x-amz-checksum-sha256"));
}
let headers = signer.sign_with_extra_headers("PUT", &path, &query, mode, &extra);
let encoded = match case {
"signature" | "final-signature" => {
let mut body = encode_signed_aws_chunked(&signer, &headers, decoded, false);
let offset = if case == "signature" {
body.windows(2).position(|w| w == b"\r\n").expect("chunk header") + 2
} else {
body.len() - 5
};
body[offset] = if body[offset] == b'a' { b'b' } else { b'a' };
body
}
"format" => b"invalid-chunk-size\r\n".to_vec(),
"metadata-at-limit" | "metadata-over-limit" => {
let size = if case == "metadata-at-limit" { 1024 } else { 1025 };
let mut body = vec![b'z'; size - 2];
body.extend_from_slice(b"\r\n");
body
}
"chunk-over-limit" => format!("10000001;chunk-signature={}\r\n", "0".repeat(64)).into_bytes(),
"trailers-over-limit" | "too-many-trailers" => {
let mut body = format!("{:x}\r\n", decoded.len()).into_bytes();
body.extend_from_slice(decoded);
body.extend_from_slice(b"\r\n0\r\n");
if case == "trailers-over-limit" {
body.extend_from_slice(format!("x-private:{}\r\n\r\n", "v".repeat(16385 - 14)).as_bytes());
} else {
body.extend_from_slice("x-private:v\r\n".repeat(101).as_bytes());
body.extend_from_slice(b"\r\n");
}
body
}
_ => unreachable!("all fixtures are enumerated"),
};
let mut request = local_http_client()
.put(format!("{}{target}", env.url))
.header("authorization", &headers.authorization)
.header("x-amz-content-sha256", &headers.content_sha256)
.header("x-amz-date", &headers.amz_date);
for (name, value) in extra {
request = request.header(name, value);
}
let response = request
.body(encoded)
.timeout(std::time::Duration::from_secs(20))
.send()
.await?;
let status = response.status().as_u16();
let body = response.text().await?;
eprintln!("aws-chunked {case} multipart={multipart}: HTTP {status}, {body}");
if status != expected_status || !body.contains(&format!("<Code>{expected_code}</Code>")) {
failures.push(format!(
"{case} multipart={multipart}: expected {expected_status} {expected_code}, got {status} {body}"
));
}
for private in [
"private-chunked-fixture-body",
"chunk-signature=",
"AwsChunkedStreamError",
"x-private",
] {
if body.contains(private) {
failures.push(format!("{case}: response exposed {private}"));
}
}
if let Some(id) = upload_id {
let parts = client.list_parts().bucket(BUCKET).key(&key).upload_id(&id).send().await?;
assert!(parts.parts().is_empty(), "{case}: rejected UploadPart published a part");
client
.abort_multipart_upload()
.bucket(BUCKET)
.key(&key)
.upload_id(id)
.send()
.await?;
}
let absent = client
.get_object()
.bucket(BUCKET)
.key(&key)
.send()
.await
.expect_err("a rejected body must not publish an object");
assert_eq!(absent.as_service_error().and_then(ProvideErrorMetadata::code), Some("NoSuchKey"));
}
}
assert!(failures.is_empty(), "{}", failures.join("\n"));
Ok(())
}
#[tokio::test]
async fn aws_chunked_valid_signed_and_trailer_boundaries_succeed() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let mut env = RustFSTestEnvironment::new().await?;
setup(&mut env).await?;
let client = env.create_s3_client();
let decoded = b"valid-chunked-control";
for (case, multipart) in ["signed", "signed-trailer", "trailer-size-limit", "trailer-count-limit"]
.into_iter()
.flat_map(|case| [false, true].map(|multipart| (case, multipart)))
{
let key = format!("{case}-{multipart}");
let upload_id = if multipart {
Some(
client
.create_multipart_upload()
.bucket(BUCKET)
.key(&key)
.send()
.await?
.upload_id()
.ok_or("missing upload ID")?
.to_owned(),
)
} else {
None
};
let query = upload_id
.as_ref()
.map(|id| format!("partNumber=1&uploadId={}", urlencoding::encode(id)))
.unwrap_or_default();
let signer = SigV4::new(&env);
let path = format!("/{BUCKET}/{key}");
let target = if query.is_empty() {
path.clone()
} else {
format!("{path}?{query}")
};
let length = decoded.len().to_string();
let mode = match case {
"signed" => "STREAMING-AWS4-HMAC-SHA256-PAYLOAD",
"signed-trailer" => "STREAMING-AWS4-HMAC-SHA256-PAYLOAD-TRAILER",
_ => UNSIGNED_PAYLOAD_TRAILER,
};
let mut extra = vec![
("content-encoding", "aws-chunked"),
("x-amz-decoded-content-length", length.as_str()),
];
if case != "signed" {
extra.push(("x-amz-trailer", "x-amz-checksum-sha256"));
}
let headers = signer.sign_with_extra_headers("PUT", &path, &query, mode, &extra);
let mut encoded = if case.starts_with("signed") {
encode_signed_aws_chunked(&signer, &headers, decoded, case == "signed-trailer")
} else {
encode_unsigned_aws_chunked_with_sha256_trailer(decoded)
};
if !case.starts_with("signed") {
encoded.truncate(encoded.len() - 2);
if case == "trailer-size-limit" {
let checksum_line_size = format!("x-amz-checksum-sha256:{}\r\n", sha256_base64(decoded)).len();
encoded.extend_from_slice(format!("x-padding:{}\r\n", "v".repeat(16384 - checksum_line_size - 14)).as_bytes());
} else {
encoded.extend_from_slice("x-padding:v\r\n".repeat(99).as_bytes());
}
encoded.extend_from_slice(b"\r\n");
}
let mut request = local_http_client()
.put(format!("{}{target}", env.url))
.header("authorization", &headers.authorization)
.header("x-amz-content-sha256", &headers.content_sha256)
.header("x-amz-date", &headers.amz_date);
for (name, value) in extra {
request = request.header(name, value);
}
let response = request
.body(encoded)
.timeout(std::time::Duration::from_secs(20))
.send()
.await?;
let status = response.status();
let etag = response
.headers()
.get("etag")
.and_then(|value| value.to_str().ok())
.map(str::to_owned);
let body = response.text().await?;
assert_eq!(status.as_u16(), 200, "{case}: {body}");
if let Some(id) = upload_id {
let part = aws_sdk_s3::types::CompletedPart::builder()
.part_number(1)
.e_tag(etag.ok_or("successful UploadPart omitted ETag")?)
.build();
client
.complete_multipart_upload()
.bucket(BUCKET)
.key(&key)
.upload_id(id)
.multipart_upload(aws_sdk_s3::types::CompletedMultipartUpload::builder().parts(part).build())
.send()
.await?;
}
let object = client.get_object().bucket(BUCKET).key(&key).send().await?;
assert_eq!(object.body.collect().await?.into_bytes().as_ref(), decoded, "{case}");
}
Ok(())
}
/// Positive control: a correctly hand-signed request must succeed. Without
/// this, every negative assertion below could pass for the wrong reason (a
/// broken signer that never produces a valid signature).
@@ -566,47 +862,54 @@ async fn tampered_payload_is_rejected() -> Result<(), Box<dyn std::error::Error
let mut env = RustFSTestEnvironment::new().await?;
setup(&mut env).await?;
let key = "tampered-payload.txt";
let path = format!("/{BUCKET}/{key}");
let claimed_body = b"the-body-i-claim-to-send";
let actual_body = b"the-body-i-really-send!!";
assert_eq!(claimed_body.len(), actual_body.len(), "keep content-length stable for the mismatch");
use md5::{Digest, Md5};
let signer = SigV4::new(&env);
// Sign over the hash of the CLAIMED body (single-chunk payload hash), then
// send a different body of equal length.
let headers = signer.sign("PUT", &path, "", &sha256_hex(claimed_body));
let result = send_signed(&env, reqwest::Method::PUT, &path, &headers, Some(actual_body.to_vec())).await;
match result {
Ok(resp) => {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
let client = env.create_s3_client();
// Empty, small-eager, zero-copy, and streaming PUTs must share the error contract.
for size in [0, 24, 2 * 1024 * 1024, 17 * 1024 * 1024] {
let actual_body = vec![b'a'; size];
let wrong_hash = sha256_hex(b"a different payload");
let content_md5 = base64_simd::STANDARD.encode_to_string(Md5::digest(&actual_body));
for with_md5 in [false, true] {
let key = format!("tampered-payload-{size}-{with_md5}.txt");
let path = format!("/{BUCKET}/{key}");
let extra = if with_md5 {
vec![("content-md5", content_md5.as_str())]
} else {
vec![]
};
let headers = signer.sign_with_extra_headers("PUT", &path, "", &wrong_hash, &extra);
let mut request = local_http_client()
.put(format!("{}{}", env.url, path))
.header("x-amz-date", &headers.amz_date)
.header("x-amz-content-sha256", &headers.content_sha256)
.header("authorization", &headers.authorization);
for (name, value) in extra {
request = request.header(name, value);
}
let response = request.body(actual_body.clone()).send().await?;
let status = response.status();
let body = response.text().await?;
assert_eq!(status, reqwest::StatusCode::BAD_REQUEST, "payload mismatch: {body}");
assert_error_code(&body, "XAmzContentSHA256Mismatch");
assert!(
status.is_client_error(),
"payload mismatch must be rejected with a client error, got {status}, body:\n{body}"
body.replace("&apos;", "'")
.contains("<Message>The provided 'x-amz-content-sha256' header does not match what was computed.</Message>"),
"wrong message: {body}"
);
info!(%status, "tampered payload rejected with error status");
}
// A mid-stream hash-mismatch abort surfacing as a transport error is
// also a valid rejection (definitely not a 200 success).
Err(err) => {
assert!(!err.is_connect(), "connection failure is not proof of payload rejection: {err}");
assert!(!err.is_timeout(), "request timeout is not proof of payload rejection: {err}");
info!(%err, "tampered payload rejected via mid-stream transport error");
let absent = client
.get_object()
.bucket(BUCKET)
.key(&key)
.send()
.await
.expect_err("a tampered payload must not publish an object");
assert_eq!(absent.raw_response().map(|response| response.status().as_u16()), Some(404));
assert_eq!(absent.as_service_error().and_then(ProvideErrorMetadata::code), Some("NoSuchKey"));
}
}
let absent = env
.create_s3_client()
.get_object()
.bucket(BUCKET)
.key(key)
.send()
.await
.expect_err("a tampered payload must not publish an object");
assert_eq!(absent.raw_response().map(|response| response.status().as_u16()), Some(404));
assert_eq!(absent.as_service_error().and_then(ProvideErrorMetadata::code), Some("NoSuchKey"));
Ok(())
}
@@ -638,9 +941,9 @@ async fn tampered_upload_part_payload_is_rejected() -> Result<(), Box<dyn std::e
assert_eq!(
status,
reqwest::StatusCode::BAD_REQUEST,
"multipart payload mismatch must be rejected as BadDigest, body:\n{body}"
"multipart payload mismatch must be rejected as XAmzContentSHA256Mismatch, body:\n{body}"
);
assert_error_code(&body, "BadDigest");
assert_error_code(&body, "XAmzContentSHA256Mismatch");
let parts = client
.list_parts()
@@ -541,6 +541,50 @@ impl NodeService for MinimalLockNodeService {
Err(Status::unimplemented("lock-only test server"))
}
async fn artifact_rename_data(
&self,
_request: Request<rustfs_protos::proto_gen::node_service::RenameDataRequest>,
) -> Result<Response<rustfs_protos::proto_gen::node_service::RenameDataResponse>, Status> {
Err(Status::unimplemented("lock-only test server"))
}
async fn artifact_repair_rename_data(
&self,
_request: Request<rustfs_protos::proto_gen::node_service::ArtifactRepairRenameDataRequest>,
) -> Result<Response<rustfs_protos::proto_gen::node_service::RenameDataResponse>, Status> {
Err(Status::unimplemented("lock-only test server"))
}
async fn artifact_repair_write_metadata(
&self,
_request: Request<rustfs_protos::proto_gen::node_service::ArtifactRepairWriteMetadataRequest>,
) -> Result<Response<rustfs_protos::proto_gen::node_service::WriteMetadataResponse>, Status> {
Err(Status::unimplemented("lock-only test server"))
}
type InventoryArtifactAttemptStream =
Pin<Box<dyn Stream<Item = Result<rustfs_protos::proto_gen::node_service::ArtifactInventoryFrame, Status>> + Send>>;
async fn inventory_artifact_attempt(
&self,
_request: Request<rustfs_protos::proto_gen::node_service::ArtifactInventoryRequest>,
) -> Result<Response<Self::InventoryArtifactAttemptStream>, Status> {
Err(Status::unimplemented("lock-only test server"))
}
async fn seal_artifact_attempt_repairs(
&self,
_request: Request<rustfs_protos::proto_gen::node_service::SealArtifactAttemptPutsRequest>,
) -> Result<Response<rustfs_protos::proto_gen::node_service::SealArtifactAttemptPutsResponse>, Status> {
Err(Status::unimplemented("lock-only test server"))
}
async fn seal_artifact_attempt_puts(
&self,
_request: Request<rustfs_protos::proto_gen::node_service::SealArtifactAttemptPutsRequest>,
) -> Result<Response<rustfs_protos::proto_gen::node_service::SealArtifactAttemptPutsResponse>, Status> {
Err(Status::unimplemented("lock-only test server"))
}
async fn rename_data(
&self,
_request: Request<rustfs_protos::proto_gen::node_service::RenameDataRequest>,
@@ -10085,10 +10085,9 @@ async fn build_scanner_compensation_pair(
/// P1-20: objects that already exist when a rule with
/// ExistingObjectReplication=Enabled arrives are compensated by the scanner's
/// existing-object resync pass, whatever wrote them — plain PUT, CopyObject,
/// or Snowball auto-extract. The pinned exception is a null-version object
/// (written before the bucket became versioned): the scanner heal gate skips
/// nil-version objects entirely (`scanner_folder.rs` heal_replication), so it
/// must NEVER be compensated.
/// or Snowball auto-extract. That includes a null-version object written
/// before the bucket became versioned (rustfs/rustfs#8747): it is compensated
/// like any other existing object and lands as the target's null version.
#[tokio::test]
async fn test_scanner_compensates_existing_objects_across_write_paths() -> TestResult {
init_logging();
@@ -10098,14 +10097,54 @@ async fn test_scanner_compensates_existing_objects_across_write_paths() -> TestR
let source_client = source_env.create_s3_client();
let target_client = target_env.create_s3_client();
// Null-version cell: PUT before versioning; the object keeps the nil
// version id forever.
// Null-version cell: PUT before versioning; the object keeps the null
// version forever.
let null_key = "pre-versioning-null.txt";
let null_payload = "null version payload";
source_client
.put_object()
.bucket(source_bucket)
.key(null_key)
.body(ByteStream::from_static(b"null version payload"))
.body(ByteStream::from_static(null_payload.as_bytes()))
.send()
.await?;
// Multipart null-version cell: a one-part upload already carries a
// multipart ETag, so its replica travels the target's multipart path,
// whose completion must keep the null version too.
let multipart_null_key = "pre-versioning-null-multipart.txt";
let multipart_null_payload = "multipart null version payload";
let upload = source_client
.create_multipart_upload()
.bucket(source_bucket)
.key(multipart_null_key)
.send()
.await?;
let upload_id = upload.upload_id().ok_or("multipart upload id missing")?;
let part = source_client
.upload_part()
.bucket(source_bucket)
.key(multipart_null_key)
.upload_id(upload_id)
.part_number(1)
.body(ByteStream::from_static(multipart_null_payload.as_bytes()))
.send()
.await?;
source_client
.complete_multipart_upload()
.bucket(source_bucket)
.key(multipart_null_key)
.upload_id(upload_id)
.multipart_upload(
CompletedMultipartUpload::builder()
.parts(
CompletedPart::builder()
.part_number(1)
.set_e_tag(part.e_tag().map(str::to_string))
.build(),
)
.build(),
)
.send()
.await?;
@@ -10193,9 +10232,22 @@ async fn test_scanner_compensates_existing_objects_across_write_paths() -> TestR
wait_for_replicated_object(&target_client, target_bucket, copy_key, plain_payload).await?;
wait_for_replicated_object(&target_client, target_bucket, member_key, std::str::from_utf8(member_payload)?).await?;
// Null-version contract: with every sibling compensated (scanner proven
// live), the nil-version object must stay absent across further cycles.
assert_replication_key_absent(&target_client, target_bucket, null_key, Duration::from_secs(6)).await?;
// The null versions converge too, and keep their identity whichever path
// carried them: each replica is the target's null version, not a freshly
// minted one.
for (key, payload) in [(null_key, null_payload), (multipart_null_key, multipart_null_payload)] {
wait_for_replicated_object(&target_client, target_bucket, key, payload).await?;
let null_replica = target_client
.get_object()
.bucket(target_bucket)
.key(key)
.version_id("null")
.send()
.await?;
assert_eq!(null_replica.version_id(), Some("null"), "{key} must replicate as the null version");
let null_replica_body = null_replica.body.collect().await?.into_bytes();
assert_eq!(null_replica_body.as_ref(), payload.as_bytes());
}
Ok(())
}
+614
View File
@@ -0,0 +1,614 @@
// 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.
//! Method-selective faults without changing the server or gRPC body/trailers.
use bytes::Bytes;
use http_body_util::{BodyExt as _, Full, Limited, combinators::BoxBody};
use hyper::{Request, body::Incoming, service::service_fn};
use hyper_util::{
client::legacy::{Client, connect::HttpConnector},
rt::{TokioExecutor, TokioIo},
server::conn::auto,
};
use prost::Message as _;
use rustfs_protos::proto_gen::node_service::{DeleteRequest, ReadVersionRequest};
use std::{
collections::HashSet,
io,
net::SocketAddr,
sync::{
Arc, Mutex,
atomic::{AtomicUsize, Ordering},
},
time::Duration,
};
use tokio::{
net::TcpListener,
sync::{Notify, watch},
task::{JoinHandle, JoinSet},
};
type ProxyBody = BoxBody<Bytes, io::Error>;
type ProxyClient = Client<HttpConnector, ProxyBody>;
#[derive(Clone, Copy, PartialEq, Eq)]
enum Disposition {
Hold,
Forward,
Discard,
}
struct PauseState {
methods: Vec<&'static str>,
scope: Option<Vec<u8>>,
limit: usize,
matched: AtomicUsize,
blocked: AtomicUsize,
forwarded: AtomicUsize,
forwarded_routes: Mutex<HashSet<String>>,
reached: Notify,
release: watch::Sender<Disposition>,
}
type PauseSlot = Arc<Mutex<Option<Arc<PauseState>>>>;
pub(crate) struct RpcPause {
state: Arc<PauseState>,
slot: PauseSlot,
}
impl RpcPause {
pub(crate) fn hits(&self) -> usize {
self.state.blocked.load(Ordering::SeqCst)
}
/// Selected RPCs whose backend returned a response, excluding discarded packets.
pub(crate) fn forwarded(&self) -> usize {
self.state.forwarded.load(Ordering::SeqCst)
}
pub(crate) fn forwarded_routes(&self) -> usize {
self.state.forwarded_routes.lock().expect("RPC route observations").len()
}
pub(crate) async fn wait_for_hits(&self, count: usize) -> io::Result<()> {
tokio::time::timeout(Duration::from_secs(20), async {
loop {
let notified = self.state.reached.notified();
if self.hits() >= count {
return;
}
notified.await;
}
})
.await
.map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "the selected RPC fault was not reached"))
}
pub(crate) fn release(&self) {
self.settle(Disposition::Forward);
}
/// Retire packets that outlived the caller's operation budget. A later
/// healthy request must not replay an expired lock grant or mutation.
pub(crate) fn discard_pending(&self) {
self.settle(Disposition::Discard);
}
fn settle(&self, disposition: Disposition) {
self.state.release.send_if_modified(|current| {
if *current == Disposition::Hold {
*current = disposition;
true
} else {
false
}
});
}
}
impl Drop for RpcPause {
fn drop(&mut self) {
self.release();
let mut slot = self.slot.lock().expect("RPC pause slot");
if slot.as_ref().is_some_and(|current| Arc::ptr_eq(current, &self.state)) {
*slot = None;
}
}
}
pub(crate) struct RpcFaultProxy {
address: SocketAddr,
slot: PauseSlot,
task: JoinHandle<()>,
}
impl RpcFaultProxy {
pub(crate) async fn start_on(address: SocketAddr, target: SocketAddr) -> io::Result<Self> {
let listener = TcpListener::bind(address).await?;
let address = listener.local_addr()?;
let slot: PauseSlot = Arc::new(Mutex::new(None));
let active = slot.clone();
let task = tokio::spawn(async move {
let h1: ProxyClient = Client::builder(TokioExecutor::new()).build_http();
let h2: ProxyClient = Client::builder(TokioExecutor::new()).http2_only(true).build_http();
let mut connections = JoinSet::new();
loop {
tokio::select! {
accepted = listener.accept() => {
let Ok((socket, _)) = accepted else { break };
let h1 = h1.clone();
let h2 = h2.clone();
let active = active.clone();
connections.spawn(async move {
let service = service_fn(move |request: Request<Incoming>| {
let client = if request.version() == http::Version::HTTP_2 { h2.clone() } else { h1.clone() };
let pause = active.lock().expect("RPC pause slot").clone();
forward_request(request, client, target, pause)
});
let _ = auto::Builder::new(TokioExecutor::new())
.serve_connection(TokioIo::new(socket), service).await;
});
}
_ = connections.join_next(), if !connections.is_empty() => {}
}
}
});
Ok(Self { address, slot, task })
}
pub(crate) fn local_addr(&self) -> SocketAddr {
self.address
}
/// Pause at most limit requests to the named unary methods; other streams
/// on the same HTTP/2 connection continue forwarding.
pub(crate) fn pause(&self, methods: &[&'static str], limit: usize, scope: Option<&str>) -> io::Result<RpcPause> {
let mut slot = self.slot.lock().expect("RPC pause slot");
if slot.is_some() || methods.is_empty() || limit == 0 || scope.is_some_and(str::is_empty) {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"RPC pause needs an empty slot, methods and a positive limit",
));
}
let (release, _) = watch::channel(Disposition::Hold);
let state = Arc::new(PauseState {
methods: methods.to_vec(),
scope: scope.map(|scope| scope.as_bytes().to_vec()),
limit,
matched: AtomicUsize::new(0),
blocked: AtomicUsize::new(0),
forwarded: AtomicUsize::new(0),
forwarded_routes: Mutex::new(HashSet::new()),
reached: Notify::new(),
release,
});
*slot = Some(state.clone());
Ok(RpcPause {
state,
slot: self.slot.clone(),
})
}
}
fn unary_payload(body: &[u8]) -> io::Result<&[u8]> {
if body.len() < 5 || body[0] != 0 {
return Err(io::Error::new(io::ErrorKind::InvalidData, "scoped RPC needs an uncompressed unary frame"));
}
let mut encoded_length = [0; 4];
encoded_length.copy_from_slice(&body[1..5]);
if u32::try_from(body.len() - 5).ok() != Some(u32::from_be_bytes(encoded_length)) {
return Err(io::Error::new(io::ErrorKind::InvalidData, "scoped RPC frame length mismatch"));
}
Ok(&body[5..])
}
fn scope_matches(method: &str, body: &[u8], scope: &[u8]) -> io::Result<bool> {
if method != "Delete" {
return Ok(body.windows(scope.len()).any(|window| window == scope));
}
let request = DeleteRequest::decode(unary_payload(body)?).map_err(io::Error::other)?;
let options: serde_json::Value = serde_json::from_str(&request.options).map_err(io::Error::other)?;
Ok(request.volume == ".rustfs.sys/multipart"
&& request.path.trim_end_matches('/').as_bytes() == scope
&& options.get("recursive").and_then(serde_json::Value::as_bool) == Some(true))
}
async fn forward_request(
request: Request<Incoming>,
client: ProxyClient,
target: SocketAddr,
pause: Option<Arc<PauseState>>,
) -> io::Result<hyper::Response<Incoming>> {
let (mut parts, body) = request.into_parts();
let mut body = body.map_err(io::Error::other).boxed();
let mut observed = None;
if let Some(pause) = pause {
let method = parts.uri.path().strip_prefix("/node_service.NodeService/");
if let Some(method) = method.filter(|name| pause.methods.contains(name)) {
let mut route = 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.
let collected = Limited::new(body, 1024 * 1024).collect().await.map_err(io::Error::other)?;
let trailers = collected.trailers().cloned();
let bytes = collected.to_bytes();
let matches = scope_matches(method, &bytes, scope)?;
if matches && method == "ReadVersion" {
let disk = ReadVersionRequest::decode(unary_payload(&bytes)?)
.map_err(io::Error::other)?
.disk;
if !disk.is_empty() {
route = Some(disk);
}
}
body = Full::new(bytes)
.map_err(|never| match never {})
.with_trailers(async move { trailers.map(Ok::<_, io::Error>) })
.boxed();
matches
} else {
true
};
if matches && pause.matched.fetch_add(1, Ordering::SeqCst) < pause.limit {
let mut released = pause.release.subscribe();
pause.blocked.fetch_add(1, Ordering::SeqCst);
pause.reached.notify_one();
let disposition = loop {
let disposition = *released.borrow_and_update();
if disposition != Disposition::Hold {
break disposition;
}
if released.changed().await.is_err() {
break Disposition::Discard;
}
};
if disposition == Disposition::Discard {
return Err(io::Error::new(io::ErrorKind::ConnectionAborted, "injected RPC packet loss"));
}
observed = Some((pause, route));
}
}
}
if !parts.headers.contains_key(http::header::HOST)
&& let Some(authority) = parts.uri.authority()
{
parts.headers.insert(
http::header::HOST,
http::HeaderValue::from_str(authority.as_str()).map_err(io::Error::other)?,
);
}
parts.uri = http::Uri::builder()
.scheme("http")
.authority(target.to_string())
.path_and_query(parts.uri.path_and_query().map_or("/", |value| value.as_str()))
.build()
.map_err(io::Error::other)?;
let response = client
.request(Request::from_parts(parts, body))
.await
.map_err(io::Error::other)?;
if let Some((pause, route)) = observed
&& response.status().is_success()
&& response.headers().get("grpc-status").is_none_or(|status| status == "0")
{
pause.forwarded.fetch_add(1, Ordering::SeqCst);
if let Some(route) = route {
pause.forwarded_routes.lock().expect("RPC route observations").insert(route);
}
}
Ok(response)
}
impl Drop for RpcFaultProxy {
fn drop(&mut self) {
self.task.abort();
}
}
#[cfg(test)]
mod tests {
use super::*;
use http_body_util::{Full, StreamBody};
use hyper::{Response, body::Frame};
use std::convert::Infallible;
struct Backend {
received: Arc<AtomicUsize>,
address: SocketAddr,
task: JoinHandle<()>,
}
impl Drop for Backend {
fn drop(&mut self) {
self.task.abort();
}
}
async fn backend() -> 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));
let received_task = received.clone();
let task = tokio::spawn(async move {
let mut children = JoinSet::new();
loop {
tokio::select! {
accepted = listener.accept() => {
let Ok((socket, _)) = accepted else { break };
let received = received_task.clone();
children.spawn(async move {
let service = service_fn(move |request: Request<Incoming>| {
let received = received.clone();
async move {
received.fetch_add(1, Ordering::SeqCst);
let authority = request.headers().get(http::header::HOST).cloned();
let body = request.into_body().collect().await?.to_bytes();
let mut trailers = http::HeaderMap::new();
trailers.insert("grpc-status", http::HeaderValue::from_static("0"));
trailers.insert("x-proof", http::HeaderValue::from_static("unchanged-proof"));
let stream = futures::stream::iter([
Ok::<_, Infallible>(Frame::data(body)),
Ok(Frame::trailers(trailers)),
]);
let mut response = Response::new(StreamBody::new(stream));
response.headers_mut().insert(http::header::TRAILER, http::HeaderValue::from_static("grpc-status, x-proof"));
if let Some(authority) = authority { response.headers_mut().insert("x-original-authority", authority); }
Ok::<_, hyper::Error>(response)
}});
let _ = auto::Builder::new(TokioExecutor::new()).serve_connection(TokioIo::new(socket), service).await;
});
}
_ = children.join_next(), if !children.is_empty() => {}
}
}
});
Ok(Backend { received, address, task })
}
async fn call(client: &ProxyClient, address: SocketAddr, method: &str) -> io::Result<hyper::Response<Incoming>> {
call_body(client, address, method, Bytes::from_static(b"opaque signed protobuf")).await
}
async fn call_body(
client: &ProxyClient,
address: SocketAddr,
method: &str,
bytes: Bytes,
) -> io::Result<hyper::Response<Incoming>> {
let body: ProxyBody = Full::new(bytes).map_err(|never| match never {}).boxed();
let request = Request::builder()
.method("POST")
.uri(format!("http://{address}/node_service.NodeService/{method}"))
.header(http::header::TE, "trailers")
.body(body)
.map_err(io::Error::other)?;
client.request(request).await.map_err(io::Error::other)
}
#[tokio::test]
async fn preserves_protocol_authority_payload_and_grpc_trailers() {
tokio::time::timeout(Duration::from_secs(10), async {
let backend = backend().await.expect("backend listener");
let proxy = RpcFaultProxy::start_on("127.0.0.1:0".parse().expect("loopback address"), backend.address)
.await
.expect("proxy");
for h2 in [false, true] {
let client: ProxyClient = Client::builder(TokioExecutor::new()).http2_only(h2).build_http();
let response = call(&client, proxy.local_addr(), "ReadVersion")
.await
.expect("transparent request");
assert_eq!(response.version(), if h2 { http::Version::HTTP_2 } else { http::Version::HTTP_11 });
assert_eq!(response.headers()["x-original-authority"], proxy.local_addr().to_string());
let collected = response.into_body().collect().await.expect("complete body and trailers");
let trailers = collected.trailers().expect("gRPC trailers must survive both proxy hops");
assert_eq!(trailers["grpc-status"], "0");
assert_eq!(trailers["x-proof"], "unchanged-proof");
assert_eq!(collected.to_bytes(), Bytes::from_static(b"opaque signed protobuf"));
}
})
.await
.expect("transparent proxy deadline");
}
#[tokio::test]
async fn isolates_rpc_methods_limits_injection_and_releases_on_drop() {
tokio::time::timeout(Duration::from_secs(10), async {
let backend = backend().await.expect("backend listener");
let proxy = RpcFaultProxy::start_on("127.0.0.1:0".parse().expect("loopback address"), backend.address)
.await
.expect("proxy");
let client: ProxyClient = Client::builder(TokioExecutor::new()).http2_only(true).build_http();
let pause = proxy.pause(&["WriteMetadata"], 1, None).expect("arm one metadata request");
let task_client = client.clone();
let address = proxy.local_addr();
let blocked = tokio::spawn(async move { call(&task_client, address, "WriteMetadata").await });
pause.wait_for_hits(1).await.expect("metadata barrier reached");
for method in ["Lock", "WriteMetadata"] {
let response = call(&client, address, method)
.await
.expect("unselected or over-limit request must progress");
assert_eq!(
response.into_body().collect().await.expect("forwarded body").to_bytes(),
Bytes::from_static(b"opaque signed protobuf")
);
}
assert_eq!(pause.hits(), 1);
assert!(!blocked.is_finished(), "the selected request must still be held");
drop(pause);
assert_eq!(
blocked
.await
.expect("blocked request task")
.expect("released request")
.into_body()
.collect()
.await
.expect("released body")
.to_bytes(),
Bytes::from_static(b"opaque signed protobuf")
);
})
.await
.expect("selective fault deadline");
}
#[tokio::test]
async fn released_observer_requires_a_selected_backend_response() {
tokio::time::timeout(Duration::from_secs(10), async {
let backend = backend().await.expect("backend listener");
let proxy = RpcFaultProxy::start_on("127.0.0.1:0".parse().expect("loopback"), backend.address)
.await
.expect("proxy listener");
let client: ProxyClient = Client::builder(TokioExecutor::new()).http2_only(true).build_http();
let observer = proxy
.pause(&["Lock"], usize::MAX, Some("recovery-bucket"))
.expect("RPC observer");
let unrelated = call_body(&client, proxy.local_addr(), "Lock", Bytes::from_static(b"other-bucket"))
.await
.expect("unrelated request");
unrelated.into_body().collect().await.expect("unrelated body");
assert_eq!(observer.forwarded(), 0, "unrelated traffic cannot prove recovery");
let task_client = client.clone();
let address = proxy.local_addr();
let request =
tokio::spawn(
async move { call_body(&task_client, address, "Lock", Bytes::from_static(b"recovery-bucket")).await },
);
observer.wait_for_hits(1).await.expect("selected request arrived");
assert_eq!(observer.forwarded(), 0, "an intercepted request has not reached its backend");
observer.release();
let body = request
.await
.expect("request task")
.expect("forwarded request")
.into_body()
.collect()
.await
.expect("forwarded body");
assert_eq!(body.trailers().expect("preserved trailers")["x-proof"], "unchanged-proof");
assert_eq!(body.to_bytes(), Bytes::from_static(b"recovery-bucket"));
assert_eq!(observer.forwarded(), 1);
let another = call_body(&client, address, "Lock", Bytes::from_static(b"recovery-bucket"))
.await
.expect("observer forwards without pausing");
another.into_body().collect().await.expect("second body");
assert_eq!(observer.forwarded(), 2);
drop(observer);
let routes = proxy
.pause(&["ReadVersion"], usize::MAX, Some("recovery-route"))
.expect("disk-route observer");
routes.release();
for (disk, expected) in [("drive-a", 1), ("drive-a", 1), ("drive-b", 2)] {
let request = ReadVersionRequest {
disk: disk.to_owned(),
path: "recovery-route".to_owned(),
..Default::default()
}
.encode_to_vec();
let mut frame = vec![0];
frame.extend_from_slice(&u32::try_from(request.len()).expect("small frame").to_be_bytes());
frame.extend_from_slice(&request);
call_body(&client, address, "ReadVersion", Bytes::from(frame))
.await
.expect("routed read")
.into_body()
.collect()
.await
.expect("routed response");
assert_eq!(routes.forwarded_routes(), expected, "repeated traffic from one disk is only one voter");
}
})
.await
.expect("passive observer deadline");
}
#[tokio::test]
async fn scoped_fault_discards_expired_packets_without_replaying_them() {
tokio::time::timeout(Duration::from_secs(10), async {
let backend = backend().await.expect("backend listener");
let proxy = RpcFaultProxy::start_on("127.0.0.1:0".parse().expect("loopback"), backend.address)
.await
.expect("proxy listener");
let client: ProxyClient = Client::builder(TokioExecutor::new()).http2_only(true).build_http();
assert!(proxy.pause(&["Lock"], 1, Some("")).is_err(), "empty scope must not match every body");
let pause = proxy
.pause(&["Lock"], usize::MAX, Some("specific-bucket"))
.expect("bucket-scoped lock fault");
let unrelated = call_body(&client, proxy.local_addr(), "Lock", Bytes::from_static(b"other-bucket"))
.await
.expect("unrelated bucket must progress");
let unrelated = unrelated.into_body().collect().await.expect("unrelated response");
assert_eq!(unrelated.trailers().expect("unchanged trailers")["x-proof"], "unchanged-proof");
assert_eq!(unrelated.to_bytes(), Bytes::from_static(b"other-bucket"));
assert_eq!(pause.hits(), 0);
let task_client = client.clone();
let address = proxy.local_addr();
let blocked =
tokio::spawn(
async move { call_body(&task_client, address, "Lock", Bytes::from_static(b"specific-bucket")).await },
);
pause.wait_for_hits(1).await.expect("selected bucket reached");
assert!(!blocked.is_finished());
pause.discard_pending();
drop(pause); // Drop must not replace Discard with Forward.
assert!(blocked.await.expect("request task").is_err());
assert_eq!(backend.received.load(Ordering::SeqCst), 1, "discard must never execute at the peer");
let recovered = call(&client, address, "Lock").await.expect("future requests recover");
recovered.into_body().collect().await.expect("recovered response");
assert_eq!(backend.received.load(Ordering::SeqCst), 2);
})
.await
.expect("scoped discard deadline");
}
#[test]
fn delete_scope_matches_only_the_upload_root() {
let frame = |volume: &str, path: &str| {
let message = DeleteRequest {
volume: volume.to_owned(),
path: path.to_owned(),
options: r#"{"recursive":true}"#.to_owned(),
..Default::default()
};
let payload = message.encode_to_vec();
let length = u32::try_from(payload.len()).expect("small control packet");
let mut bytes = vec![0];
bytes.extend_from_slice(&length.to_be_bytes());
bytes.extend_from_slice(&payload);
bytes
};
let root = b"hash/upload";
assert!(scope_matches("Delete", &frame(".rustfs.sys/multipart", "hash/upload"), root).expect("root frame"));
assert!(scope_matches("Delete", &frame(".rustfs.sys/multipart", "hash/upload/"), root).expect("directory frame"));
for path in ["hash/upload/part.1", "hash/upload/.abort-authority", "hash/upload-other"] {
assert!(!scope_matches("Delete", &frame(".rustfs.sys/multipart", path), root).expect("other path"));
}
assert!(!scope_matches("Delete", &frame(".rustfs.sys/tmp", "hash/upload"), root).expect("other volume"));
let non_recursive = DeleteRequest {
volume: ".rustfs.sys/multipart".to_owned(),
path: "hash/upload".to_owned(),
options: r#"{"recursive":false}"#.to_owned(),
..Default::default()
}
.encode_to_vec();
let mut non_recursive_frame = vec![0];
non_recursive_frame.extend_from_slice(&u32::try_from(non_recursive.len()).expect("small frame").to_be_bytes());
non_recursive_frame.extend_from_slice(&non_recursive);
assert!(!scope_matches("Delete", &non_recursive_frame, root).expect("non-recursive frame"));
assert!(
scope_matches("Delete", &[0, 0, 0, 0, 1], root).is_err(),
"truncation must not become a match"
);
}
}
@@ -245,11 +245,11 @@ async fn test_concurrent_object_operations() -> Result<(), Box<dyn Error + Send
Ok(())
}
/// Internal/private endpoints must be rejected as remote tier backends (SSRF)
/// Loopback and metadata endpoints must be rejected as remote tier backends (SSRF)
/// before RustFS opens a connection to them.
///
/// This issues a real admin AddTier call (`PUT /rustfs/admin/v3/tier`) for each
/// internal/private endpoint and requires the exact validation response. A
/// restricted endpoint and requires the exact validation response. A
/// loopback listener additionally proves the literal loopback case is rejected
/// without an outbound connection; a generic connectivity failure is not
/// sufficient evidence of SSRF protection.
@@ -267,6 +267,9 @@ async fn test_tiering_url_validation() -> Result<(), Box<dyn Error + Send + Sync
("http://localhost:8080", false),
("http://169.254.169.254", false), // cloud instance metadata endpoint
("http://[::1]:8080", false),
("http://[fd00:ec2::254]", false),
("http://[fd00:ec2::23]", false),
("http://[fd20:ce::254]", false),
];
for (endpoint, checks_connection_attempt) in internal_endpoints {
+14 -2
View File
@@ -15,12 +15,14 @@
#[cfg(test)]
pub(crate) use rustfs_ecstore::api::bucket::bucket_target_sys::BucketTargetSys;
#[cfg(test)]
pub(crate) use rustfs_ecstore::api::disk::{Endpoint, RUSTFS_META_BUCKET, VolumeInfo, WalkDirOptions};
pub(crate) use rustfs_ecstore::api::disk::{
DiskError, DiskOption, Endpoint, RUSTFS_META_BUCKET, VolumeInfo, WalkDirOptions, new_disk,
};
pub(crate) use rustfs_ecstore::api::rpc::{AuthenticatedChannel, TonicInterceptor, node_service_time_out_client_no_auth};
#[cfg(test)]
pub(crate) use rustfs_ecstore::api::rpc::{
TONIC_RPC_PREFIX, gen_signature_headers, gen_tonic_replay_scope_headers, gen_tonic_signature_headers,
verify_tonic_boot_epoch_response,
set_tonic_canonical_body_digest, verify_tonic_boot_epoch_response,
};
#[cfg(test)]
pub(crate) use rustfs_ecstore::api::rpc::{gen_tonic_signature_interceptor, node_service_time_out_client};
@@ -36,6 +38,11 @@ pub(crate) mod grpc_lock {
pub(crate) use super::{AuthenticatedChannel, TonicInterceptor, node_service_time_out_client_no_auth};
}
#[cfg(test)]
pub(crate) mod artifact_rpc {
pub(crate) use super::{DiskError, TONIC_RPC_PREFIX, gen_tonic_signature_headers, set_tonic_canonical_body_digest};
}
/// Signing/transport surface used by the cross-process internode RPC signature
/// acceptance tests (backlog#1327). The signing helpers are what let a test mint
/// the one legitimately signed request an on-path attacker is assumed to have
@@ -52,3 +59,8 @@ pub(crate) mod internode_rpc_signature {
pub(crate) mod replication_extension {
pub(crate) use super::BucketTargetSys;
}
#[cfg(test)]
pub(crate) mod upgrade_compatibility {
pub(crate) use super::{DiskOption, Endpoint, new_disk};
}
@@ -0,0 +1,417 @@
// 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 crate::common::{
AdminTransport, RustFSTestEnvironment, admin_add_canned_policy_via, admin_attach_user_policy_via, admin_create_user,
init_logging, signed_request,
};
use aws_sdk_s3::primitives::ByteStream;
use http::{Method, StatusCode};
use serde_json::{Value, json};
use uuid::Uuid;
type TestResult<T = ()> = Result<T, Box<dyn std::error::Error + Send + Sync>>;
async fn set_policy(env: &RustFSTestEnvironment, user: &str, policy: Value) -> TestResult {
admin_add_canned_policy_via(
AdminTransport::Signed,
&env.url,
&env.access_key,
&env.secret_key,
user,
&policy.to_string(),
)
.await?;
admin_attach_user_policy_via(AdminTransport::Signed, &env.url, &env.access_key, &env.secret_key, user, user).await
}
async fn catalog_request(
env: &RustFSTestEnvironment,
credentials: &(String, String),
method: Method,
path: &str,
body: Option<Value>,
expected: StatusCode,
) -> TestResult<Value> {
let response = signed_request(
method.clone(),
&format!("{}{path}", env.url),
&credentials.0,
&credentials.1,
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, "{method} {path}: {text}");
if text.is_empty() || !status.is_success() {
Ok(Value::Null)
} else {
Ok(serde_json::from_str(&text)?)
}
}
async fn get_object(
env: &RustFSTestEnvironment,
credentials: &(String, String),
bucket: &str,
key: &str,
expected: StatusCode,
) -> TestResult {
let response = signed_request(
Method::GET,
&format!("{}/{bucket}/{key}", env.url),
&credentials.0,
&credentials.1,
None,
None,
)
.await?;
assert_eq!(response.status(), expected, "GET {bucket}/{key}");
if expected == StatusCode::OK {
assert_eq!(response.bytes().await?.as_ref(), b"original object");
}
Ok(())
}
fn create_table(name: &str) -> Value {
json!({
"name": name,
"schema": {
"type": "struct",
"fields": [{"id": 1, "name": "id", "required": false, "type": "long"}]
},
"stage-create": false
})
}
fn staged_commit(metadata: &Value, location: &str) -> Value {
json!({
"requirements": [{"type": "assert-create"}],
"updates": [
{"action": "assign-uuid", "uuid": metadata["table-uuid"]},
{"action": "upgrade-format-version", "format-version": metadata["format-version"]},
{"action": "add-schema", "schema": metadata["schemas"][0]},
{"action": "set-current-schema", "schema-id": -1},
{"action": "add-spec", "spec": metadata["partition-specs"][0]},
{"action": "set-default-spec", "spec-id": -1},
{"action": "add-sort-order", "sort-order": metadata["sort-orders"][0]},
{"action": "set-default-sort-order", "sort-order-id": -1},
{"action": "set-location", "location": location}
]
})
}
async fn location_claim_preserves_other_users_objects_after_drop_and_restart(backing: &str) -> TestResult {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
let server_env = [("RUSTFS_TABLE_CATALOG_BACKING", backing)];
env.start_rustfs_server_without_cleanup_with_env(&[("RUSTFS_TABLE_CATALOG_BACKING", "object")])
.await?;
env.wait_for_server_ready().await?;
let root = (env.access_key.clone(), env.secret_key.clone());
let creator = (format!("creator-{}", Uuid::new_v4().simple()), Uuid::new_v4().simple().to_string());
let reader = (format!("reader-{}", Uuid::new_v4().simple()), Uuid::new_v4().simple().to_string());
let bucket = "table-location-authorization";
let root_s3 = env.create_s3_client();
root_s3.create_bucket().bucket(bucket).send().await?;
for key in ["shared/known.txt", "shared2/known.txt", "authorized/known.txt"] {
root_s3
.put_object()
.bucket(bucket)
.key(key)
.body(ByteStream::from_static(b"original object"))
.send()
.await?;
}
let catalog = format!("/iceberg/v1/{bucket}/namespaces/lab/tables");
catalog_request(&env, &root, Method::PUT, &format!("/iceberg/v1/buckets/{bucket}"), None, StatusCode::OK).await?;
catalog_request(
&env,
&root,
Method::POST,
&format!("/iceberg/v1/{bucket}/namespaces"),
Some(json!({"namespace": ["lab"]})),
StatusCode::OK,
)
.await?;
if backing == "durable-strong" {
catalog_request(
&env,
&root,
Method::POST,
&format!("/iceberg/v1/{bucket}/catalog/warehouse-index/backfill"),
None,
StatusCode::NO_CONTENT,
)
.await?;
let preflight = catalog_request(
&env,
&root,
Method::GET,
&format!("/iceberg/v1/{bucket}/catalog/migration"),
None,
StatusCode::OK,
)
.await?;
assert_eq!(preflight["blockers"], json!([]), "{preflight}");
let migration = catalog_request(
&env,
&root,
Method::POST,
&format!("/iceberg/v1/{bucket}/catalog/migration"),
None,
StatusCode::OK,
)
.await?;
assert_eq!(migration["ready_to_enable_durable_strong"], json!(true), "{migration}");
env.restart_server_preserving_data(vec![], &server_env).await?;
env.wait_for_server_ready().await?;
}
admin_create_user(&env, &creator.0, &creator.1).await?;
admin_create_user(&env, &reader.0, &reader.1).await?;
let create_statement = json!({
"Effect": "Allow",
"Action": "admin:CreateTable",
"Resource": format!("arn:aws:s3:::{bucket}/namespaces/lab")
});
set_policy(
&env,
&creator.0,
json!({"Version": "2012-10-17", "Statement": [create_statement.clone()]}),
)
.await?;
set_policy(
&env,
&reader.0,
json!({"Version": "2012-10-17", "Statement": [{
"Effect": "Allow", "Action": "s3:GetObject", "Resource": format!("arn:aws:s3:::{bucket}/*")
}]}),
)
.await?;
for key in ["shared/known.txt", "shared2/known.txt"] {
get_object(&env, &reader, bucket, key, StatusCode::OK).await?;
get_object(&env, &creator, bucket, key, StatusCode::FORBIDDEN).await?;
}
for (method, path) in [
(Method::PUT, format!("/{bucket}/shared/new.txt")),
(Method::GET, format!("/{bucket}?list-type=2")),
] {
let response = signed_request(method, &format!("{}{path}", env.url), &creator.0, &creator.1, None, None).await?;
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
// Both direct and staged requests must reject a caller-selected foreign prefix.
for staged in [false, true] {
for location in [format!("s3://{bucket}/shared"), format!("s3://{bucket}/shared/")] {
let mut request = create_table("claim");
request["location"] = json!(location);
request["stage-create"] = json!(staged);
catalog_request(&env, &creator, Method::POST, &catalog, Some(request), StatusCode::FORBIDDEN).await?;
}
}
catalog_request(&env, &root, Method::GET, &format!("{catalog}/claim"), None, StatusCode::NOT_FOUND).await?;
// Server-assigned locations still need only CreateTable, and retain protection after drop.
let created = catalog_request(&env, &creator, Method::POST, &catalog, Some(create_table("managed")), StatusCode::OK).await?;
let location = created["metadata"]["location"].as_str().ok_or("missing table location")?;
let managed_key = format!(
"{}/known.txt",
location
.strip_prefix(&format!("s3://{bucket}/"))
.ok_or("table location has wrong bucket")?
);
root_s3
.put_object()
.bucket(bucket)
.key(&managed_key)
.body(ByteStream::from_static(b"original object"))
.send()
.await?;
get_object(&env, &reader, bucket, &managed_key, StatusCode::FORBIDDEN).await?;
catalog_request(&env, &root, Method::DELETE, &format!("{catalog}/managed"), None, StatusCode::NO_CONTENT).await?;
env.restart_server_preserving_data(vec![], &server_env).await?;
env.wait_for_server_ready().await?;
for table in ["claim", "managed"] {
catalog_request(&env, &root, Method::GET, &format!("{catalog}/{table}"), None, StatusCode::NOT_FOUND).await?;
}
for key in ["shared/known.txt", "shared2/known.txt", "authorized/known.txt"] {
get_object(&env, &reader, bucket, key, StatusCode::OK).await?;
}
get_object(&env, &reader, bucket, &managed_key, StatusCode::FORBIDDEN).await?;
get_object(&env, &root, bucket, &managed_key, StatusCode::OK).await?;
// Explicit adoption is scoped to a physical prefix and does not grant S3 access.
set_policy(
&env,
&creator.0,
json!({"Version": "2012-10-17", "Statement": [
create_statement.clone(),
{"Effect": "Allow", "Action": "admin:ClaimTableLocation", "Resource": format!("arn:aws:s3:::{bucket}/authorized")}
]}),
)
.await?;
let mut request = create_table("authorized");
request["location"] = json!(format!("s3://{bucket}/shared"));
catalog_request(&env, &creator, Method::POST, &catalog, Some(request.clone()), StatusCode::FORBIDDEN).await?;
request["location"] = json!(format!("s3://{bucket}/authorized"));
let adopted = catalog_request(&env, &creator, Method::POST, &catalog, Some(request), StatusCode::OK).await?;
get_object(&env, &creator, bucket, "authorized/known.txt", StatusCode::FORBIDDEN).await?;
get_object(&env, &reader, bucket, "authorized/known.txt", StatusCode::FORBIDDEN).await?;
// Existing updates retain their permissions; relocation and re-registration
// require a prefix grant even when the caller can access every S3 object.
set_policy(
&env,
&creator.0,
json!({"Version": "2012-10-17", "Statement": [
create_statement,
{"Effect": "Allow", "Action": "admin:RegisterTable", "Resource": [
format!("arn:aws:s3:::{bucket}/namespaces/lab"),
format!("arn:aws:s3:::{bucket}/namespaces/lab/tables/*")
]},
{"Effect": "Allow", "Action": "admin:CommitTable", "Resource": format!("arn:aws:s3:::{bucket}/namespaces/lab/tables/*")},
{"Effect": "Allow", "Action": "admin:SetTableMetadataLocation", "Resource": format!("arn:aws:s3:::{bucket}/namespaces/lab/tables/authorized")},
{"Effect": "Allow", "Action": ["s3:GetObject", "s3:PutObject"], "Resource": format!("arn:aws:s3:::{bucket}/*")}
]}),
)
.await?;
let requirements = json!([{"type": "assert-table-uuid", "uuid": adopted["metadata"]["table-uuid"]}]);
let committed = catalog_request(
&env,
&creator,
Method::POST,
&format!("{catalog}/authorized"),
Some(json!({
"requirements": requirements,
"updates": [{"action": "set-properties", "updates": {"test.location-authorization": "retained"}}]
})),
StatusCode::OK,
)
.await?;
catalog_request(
&env,
&creator,
Method::POST,
&format!("{catalog}/authorized"),
Some(json!({
"requirements": requirements,
"updates": [{"action": "set-location", "location": format!("s3://{bucket}/shared")}]
})),
StatusCode::FORBIDDEN,
)
.await?;
let current = catalog_request(&env, &root, Method::GET, &format!("{catalog}/authorized"), None, StatusCode::OK).await?;
assert_eq!(current["metadata"]["location"], json!(format!("s3://{bucket}/authorized")));
assert_eq!(current["metadata-location"], committed["metadata-location"]);
catalog_request(
&env,
&root,
Method::DELETE,
&format!("{catalog}/authorized"),
None,
StatusCode::NO_CONTENT,
)
.await?;
catalog_request(
&env,
&creator,
Method::POST,
&format!("/iceberg/v1/{bucket}/namespaces/lab/register"),
Some(json!({"name": "authorized", "metadata-location": committed["metadata-location"]})),
StatusCode::FORBIDDEN,
)
.await?;
catalog_request(
&env,
&creator,
Method::POST,
&format!("{catalog}/authorized/catalog/import"),
Some(json!({"metadata-location": committed["metadata-location"]})),
StatusCode::FORBIDDEN,
)
.await?;
if backing == "object" {
catalog_request(
&env,
&creator,
Method::POST,
&format!("{catalog}/authorized/catalog/external/sync"),
Some(json!({
"catalog": "polaris",
"external-namespace": "lab",
"external-table": "authorized",
"metadata-location": committed["metadata-location"]
})),
StatusCode::FORBIDDEN,
)
.await?;
}
// Staged metadata is client-controlled and must be authorized at publication.
let mut request = create_table("staged");
request["stage-create"] = json!(true);
let staged = catalog_request(&env, &creator, Method::POST, &catalog, Some(request), StatusCode::OK).await?;
catalog_request(
&env,
&creator,
Method::POST,
&format!("{catalog}/staged"),
Some(staged_commit(&staged["metadata"], &format!("s3://{bucket}/shared"))),
StatusCode::FORBIDDEN,
)
.await?;
catalog_request(&env, &root, Method::GET, &format!("{catalog}/staged"), None, StatusCode::NOT_FOUND).await?;
set_policy(
&env,
&creator.0,
json!({"Version": "2012-10-17", "Statement": [
{"Effect": "Allow", "Action": "admin:CreateTable", "Resource": format!("arn:aws:s3:::{bucket}/namespaces/lab")},
{"Effect": "Allow", "Action": "admin:CommitTable", "Resource": format!("arn:aws:s3:::{bucket}/namespaces/lab/tables/staged")},
{"Effect": "Allow", "Action": "admin:ClaimTableLocation", "Resource": format!("arn:aws:s3:::{bucket}/stage-owned")},
{"Effect": "Allow", "Action": ["s3:GetObject", "s3:PutObject"], "Resource": format!("arn:aws:s3:::{bucket}/*")}
]}),
)
.await?;
catalog_request(
&env,
&creator,
Method::POST,
&format!("{catalog}/staged"),
Some(staged_commit(&staged["metadata"], &format!("s3://{bucket}/stage-owned"))),
StatusCode::OK,
)
.await?;
catalog_request(&env, &root, Method::DELETE, &format!("{catalog}/staged"), None, StatusCode::NO_CONTENT).await?;
env.restart_server_preserving_data(vec![], &server_env).await?;
env.wait_for_server_ready().await?;
for table in ["authorized", "staged"] {
catalog_request(&env, &root, Method::GET, &format!("{catalog}/{table}"), None, StatusCode::NOT_FOUND).await?;
}
get_object(&env, &reader, bucket, "authorized/known.txt", StatusCode::FORBIDDEN).await?;
get_object(&env, &root, bucket, "authorized/known.txt", StatusCode::OK).await?;
for key in ["shared/known.txt", "shared2/known.txt"] {
get_object(&env, &reader, bucket, key, StatusCode::OK).await?;
}
Ok(())
}
#[tokio::test]
async fn object_catalog_location_claim_preserves_objects_after_drop_and_restart() -> TestResult {
location_claim_preserves_other_users_objects_after_drop_and_restart("object").await
}
#[tokio::test]
async fn durable_strong_catalog_location_claim_preserves_objects_after_drop_and_restart() -> TestResult {
location_claim_preserves_other_users_objects_after_drop_and_restart("durable-strong").await
}
@@ -14,7 +14,7 @@
use crate::common::{
RustFSTestClusterEnvironment, RustFSTestEnvironment, admin_request, init_logging, replication_fast_env, rustfs_binary_path,
signed_request,
signal_process, signed_request,
};
use crate::fake_s3_target::{BucketMode, FAKE_ACCESS_KEY, FAKE_SECRET_KEY, FakeS3Target, Operation as FakeTargetOperation};
use crate::on_demand_migration::common::{ODM_SERVER_ENV, OdmTestEnv, SeedObject, fake_source_client};
@@ -1372,6 +1372,353 @@ async fn rc5_rollback_requires_restoring_odm_configuration() -> TestResult {
Ok(())
}
struct Rc5CheckpointCapture {
disk: PathBuf,
path: PathBuf,
task_id: String,
bytes: Vec<u8>,
}
async fn capture_rc5_checkpoint(drives: &[PathBuf], writer_pid: u32) -> Result<Rc5CheckpointCapture, BoxError> {
let has_signed_progress = |value: &Value| {
value["schema_version"].as_u64() == Some(6)
&& value["successful_objects"].as_u64().is_some_and(|count| count > 0)
&& value["processed_objects"]
.as_array()
.is_some_and(|objects| !objects.is_empty())
&& value["integrity_digest"].as_str().is_some_and(|digest| !digest.is_empty())
};
tokio::time::timeout(Duration::from_secs(60), async {
loop {
for drive in drives {
let mut entries = match tokio::fs::read_dir(drive.join(".rustfs.sys/buckets")).await {
Ok(entries) => entries,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
Err(error) => return Err(error.into()),
};
while let Some(entry) = entries.next_entry().await? {
if !entry.file_name().to_string_lossy().ends_with("_ahm_checkpoint.json") {
continue;
}
let metadata = entry.metadata().await?;
if !metadata.is_file() || metadata.len() > 1024 * 1024 {
return Err("the old checkpoint is not a bounded regular file".into());
}
let bytes = match tokio::fs::read(entry.path()).await {
Ok(bytes) => bytes,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
Err(error) => return Err(error.into()),
};
let Ok(value) = serde_json::from_slice::<Value>(&bytes) else {
continue;
};
if !has_signed_progress(&value) {
continue;
}
// Freeze only this fixture's writer before copying its actual
// bytes. The environment's normal stop path kills a stopped child.
signal_process(writer_pid, "STOP")?;
let bytes = tokio::fs::read(entry.path()).await?;
let value: Value = serde_json::from_slice(&bytes)?;
if !has_signed_progress(&value) {
return Err("the stopped rc.5 writer did not retain signed checkpoint progress".into());
}
let task_id = value["task_id"].as_str().ok_or("the old checkpoint has no task id")?;
uuid::Uuid::parse_str(task_id)?;
if entry.file_name() != std::ffi::OsStr::new(&format!("{task_id}_ahm_checkpoint.json")) {
return Err("the old checkpoint task id does not match its filename".into());
}
return Ok(Rc5CheckpointCapture {
disk: drive.clone(),
path: entry.path(),
task_id: task_id.to_owned(),
bytes,
});
}
}
sleep(Duration::from_millis(5)).await;
}
})
.await
.map_err(|_| "rc.5 did not produce a signed checkpoint with progress within 60 seconds")?
}
fn rc5_checkpoint_object(index: usize) -> (String, Vec<u8>) {
(
format!("object-{index:05}.bin"),
format!("rc.5 checkpoint upgrade object {index:05}\n")
.repeat(256)
.into_bytes(),
)
}
fn checkpoint_binary_sha256(path: &Path) -> Result<String, BoxError> {
use sha2::Digest;
use std::io::Read;
let mut input = std::fs::File::open(path)?;
let mut digest = sha2::Sha256::new();
let mut buffer = [0_u8; 64 * 1024];
loop {
let count = input.read(&mut buffer)?;
if count == 0 {
break;
}
digest.update(&buffer[..count]);
}
Ok(hex_simd::encode_to_string(digest.finalize(), hex_simd::AsciiCase::Lower))
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "requires the pinned 1.0.0-rc.5 release binary"]
async fn rc5_checkpoint_upgrade_rewinds_signed_progress_without_losing_objects() -> TestResult {
use crate::storage_api::upgrade_compatibility::{DiskOption, Endpoint, new_disk};
use futures::{StreamExt, TryStreamExt, stream};
use rustfs_heal::heal::CheckpointManager;
const BUCKET: &str = "upgrade-rc5-checkpoint";
const OBJECTS: usize = 1600;
init_logging();
let previous_binary = source_binary()?;
assert_eq!(binary_source_revision(&previous_binary).await?, RC5_COMMIT);
let current_binary = rustfs_binary_path();
let evidence_dir = std::env::var_os(G09_EVIDENCE_DIR_ENV)
.map(|root| PathBuf::from(root).join(format!("rc5-checkpoint-upgrade-{}", uuid::Uuid::new_v4())));
if let Some(directory) = &evidence_dir {
std::fs::create_dir_all(directory)?;
}
// Declare the server after its extra drives so error unwinding stops it
// before the fixture directories are removed.
let fixture = tempfile::TempDir::new()?;
let extra_drives = (0..3)
.map(|index| {
let path = fixture.path().join(format!("drive-{index}"));
std::fs::create_dir(&path)?;
Ok(path.to_str().ok_or("fixture drive path is not UTF-8")?.to_owned())
})
.collect::<Result<Vec<_>, BoxError>>()?;
let mut env = RustFSTestEnvironment::new().await?;
let log_path = evidence_dir.as_deref().unwrap_or(fixture.path()).join("server.log");
env.capture_log_path = Some(log_path.to_str().ok_or("fixture log path is not UTF-8")?.to_owned());
let server_env = [
("RUSTFS_UNSAFE_BYPASS_DISK_CHECK", "true"),
("RUSTFS_DURABILITY_MODE", "strict"),
("RUSTFS_NEW_BUCKET_DURABILITY_MODE", "strict"),
("RUST_LOG", "warn,rustfs::heal=debug"),
];
env.start_rustfs_server_from_binary(&previous_binary, extra_drives.iter().map(String::as_str).collect(), &server_env)
.await?;
env.create_test_bucket(BUCKET).await?;
let (status, durability) = admin_request(
&env.url,
Method::GET,
&format!("/rustfs/admin/v3/bucket-durability/{BUCKET}"),
None,
&env.access_key,
&env.secret_key,
)
.await?;
assert_eq!(status, StatusCode::OK, "old bucket durability: {durability}");
assert_eq!(serde_json::from_str::<Value>(&durability)?["mode"], "strict");
let old_client = env.create_s3_client();
let manifest = stream::iter(0..OBJECTS)
.map(|index| {
let client = old_client.clone();
async move {
let (key, body) = rc5_checkpoint_object(index);
let entry = serde_json::json!({
"bucket": BUCKET,
"key": key,
"length": body.len(),
"sha256": rustfs_utils::hex_sha256(&body, str::to_owned),
});
client
.put_object()
.bucket(BUCKET)
.key(&key)
.body(ByteStream::from(body))
.send()
.await?;
Ok::<_, BoxError>(entry)
}
})
.buffered(4)
.try_collect::<Vec<_>>()
.await?;
let drives = extra_drives
.iter()
.map(PathBuf::from)
.chain(std::iter::once(PathBuf::from(&env.temp_dir)))
.collect::<Vec<_>>();
let writer_pid = env.process.as_ref().ok_or("rc.5 writer is not running")?.id();
let start_heal = async {
let (status, body) = tokio::time::timeout(
Duration::from_secs(15),
admin_request(
&env.url,
Method::POST,
"/rustfs/admin/v3/heal/",
Some(
serde_json::json!({
"recursive": true, "dryRun": false, "remove": false, "recreate": false,
"scanMode": 2, "updateParity": false, "nolock": false, "pool": 0, "set": 0,
})
.to_string(),
),
&env.access_key,
&env.secret_key,
),
)
.await??;
if status != StatusCode::OK {
return Err(format!("rc.5 root heal was not accepted: {status} {body}").into());
}
Ok::<_, BoxError>(body)
};
let (capture, heal_start) = tokio::try_join!(capture_rc5_checkpoint(&drives, writer_pid), start_heal)?;
env.stop_server();
assert_eq!(
std::fs::read(&capture.path)?,
capture.bytes,
"stopping the old writer must preserve captured bytes"
);
let checkpoint_dir = capture.disk.join(".rustfs.sys/buckets");
let resume_path = checkpoint_dir.join(format!("{}_ahm_resume_state.json", capture.task_id));
let resume_bytes = std::fs::read(&resume_path)?;
let old_checkpoint: Value = serde_json::from_slice(&capture.bytes)?;
if let Some(directory) = &evidence_dir {
std::fs::write(directory.join("checkpoint.rc5.json"), &capture.bytes)?;
std::fs::write(directory.join("resume.rc5.json"), &resume_bytes)?;
std::fs::write(directory.join("manifest.json"), serde_json::to_vec_pretty(&manifest)?)?;
std::fs::write(
directory.join("source.json"),
serde_json::to_vec_pretty(&serde_json::json!({
"kind": "old-release-checkpoint-reader-upgrade",
"scope": "schema-6 reader authentication, safe rewind and object reads; not full G09 qualification",
"writer_revision": RC5_COMMIT,
"writer_binary_sha256": checkpoint_binary_sha256(&previous_binary)?,
"candidate_revision": current_source_revision()?,
"candidate_binary_sha256": checkpoint_binary_sha256(&current_binary)?,
"layout": "one process and four logical drives; not physical fault qualification",
"task_id": capture.task_id,
"checkpoint_sha256": rustfs_utils::hex_sha256(&capture.bytes, str::to_owned),
"resume_sha256": rustfs_utils::hex_sha256(&resume_bytes, str::to_owned),
"acknowledged_objects": manifest.len(),
"bucket_durability": serde_json::from_str::<Value>(&durability)?,
"heal_start": serde_json::from_str::<Value>(&heal_start)?,
}))?,
)?;
}
let blocked_path = checkpoint_dir.join(format!("{}_ahm_checkpoint.blocked", capture.task_id));
assert!(!blocked_path.try_exists()?, "the original checkpoint must not already be blocked");
let disk_options = DiskOption {
cleanup: false,
health_check: false,
};
// Reopen with the same member order passed to the old writer.
let disk_index = drives
.iter()
.position(|drive| drive == &capture.disk)
.ok_or("captured disk is not a fixture member")?;
let mut endpoint = Endpoint::try_from(capture.disk.to_str().ok_or("captured drive path is not UTF-8")?)?;
endpoint.set_pool_index(0);
endpoint.set_set_index(0);
endpoint.set_disk_index(disk_index);
let disk = new_disk(&endpoint, &disk_options).await?;
let manager = CheckpointManager::load_from_disk(disk.clone(), &capture.task_id).await?;
let rewound = manager.get_checkpoint().await;
assert_eq!(rewound.schema_version, 7);
assert_eq!(rewound.task_id, capture.task_id);
assert_eq!((rewound.current_bucket_index, rewound.current_object_index), (0, 0));
assert!(rewound.processed_objects.is_empty() && rewound.failed_objects.is_empty() && rewound.skipped_objects.is_empty());
assert_eq!(rewound.successful_objects, 0);
assert_eq!(rewound.failed_object_count, 0);
assert_eq!(rewound.skipped_object_count, 0);
assert_eq!(rewound.processed_bytes, 0);
assert_eq!((rewound.total_objects, rewound.total_bytes), (0, 0));
assert!(rewound.baseline_generation.is_none() && !rewound.baseline_known && !rewound.counter_unknown);
assert!(!blocked_path.try_exists()?, "an authentic old checkpoint must not be quarantined");
assert_eq!(
std::fs::read(&capture.path)?,
capture.bytes,
"the reader must not rewrite the old checkpoint"
);
assert_eq!(std::fs::read(&resume_path)?, resume_bytes, "the reader must not take over the old task");
drop(manager);
drop(disk);
// A separate copy with one digest byte changed proves that successful
// rewind above did not bypass authentication of the original wire bytes.
let corrupt_root = fixture.path().join("corrupt-reader-control");
let corrupt_metadata = corrupt_root.join(".rustfs.sys/buckets");
std::fs::create_dir_all(&corrupt_metadata)?;
let mut corrupted = capture.bytes.clone();
let digest = old_checkpoint["integrity_digest"]
.as_str()
.ok_or("old checkpoint digest is not a string")?;
let offset = corrupted
.windows(digest.len())
.position(|bytes| bytes == digest.as_bytes())
.ok_or("the digest is absent from the captured bytes")?;
corrupted[offset] = if corrupted[offset] == b'A' { b'B' } else { b'A' };
let corrupt_path = corrupt_metadata.join(format!("{}_ahm_checkpoint.json", capture.task_id));
std::fs::write(&corrupt_path, &corrupted)?;
let endpoint = Endpoint::try_from(corrupt_root.to_str().ok_or("control disk path is not UTF-8")?)?;
let corrupt_disk = new_disk(&endpoint, &disk_options).await?;
let Err(error) = CheckpointManager::load_from_disk(corrupt_disk.clone(), &capture.task_id).await else {
return Err("the current checkpoint reader accepted a corrupted signature".into());
};
assert!(matches!(error, rustfs_heal::Error::InvalidCheckpoint(ref reason) if reason.contains("digest")));
let corrupt_blocked = corrupt_metadata.join(format!("{}_ahm_checkpoint.blocked", capture.task_id));
assert!(corrupt_blocked.try_exists()?, "the corrupted copy must retain a blocked marker");
assert_eq!(std::fs::read(&corrupt_path)?, corrupted, "quarantine must preserve the invalid bytes");
if let Some(directory) = &evidence_dir {
std::fs::write(directory.join("checkpoint.corrupted.json"), &corrupted)?;
std::fs::copy(&corrupt_blocked, directory.join("checkpoint.corrupted.blocked"))?;
}
drop(corrupt_disk);
env.restart_server_preserving_data(extra_drives.iter().map(String::as_str).collect(), &server_env)
.await?;
let current_client = env.create_s3_client();
stream::iter(0..OBJECTS)
.map(|index| {
let client = current_client.clone();
async move {
let (key, expected) = rc5_checkpoint_object(index);
let (_, actual) = read_object(&client, BUCKET, &key, None).await?;
assert_eq!(actual, expected, "old object {key} changed during the upgrade");
Ok::<_, BoxError>(())
}
})
.buffer_unordered(4)
.try_collect::<Vec<_>>()
.await?;
env.stop_server();
if let Some(directory) = &evidence_dir {
std::fs::write(
directory.join("result.json"),
serde_json::to_vec_pretty(&serde_json::json!({
"status": "passed",
"scope": "old-release checkpoint reader and object-data upgrade only",
"authenticated_original_bytes": true,
"original_checkpoint_unchanged_after_read": true,
"old_successful_objects": old_checkpoint["successful_objects"],
"rewound_schema": rewound.schema_version,
"rewound_successful_objects": rewound.successful_objects,
"corrupt_digest_rejected_and_retained": true,
"verified_objects": OBJECTS,
}))?,
)?;
}
Ok(())
}
#[tokio::test]
#[ignore = "requires a pinned previous RustFS release binary"]
async fn direct_upgrade_from_previous_release_preserves_object_contracts() -> TestResult {
+4 -4
View File
@@ -278,10 +278,6 @@ harness = false
name = "comparison_benchmark"
harness = false
[[bench]]
name = "rename_data_meta_benchmark"
harness = false
[[bench]]
name = "single_block_non_inline_benchmark"
harness = false
@@ -290,5 +286,9 @@ harness = false
name = "multipart_read_parts_benchmark"
harness = false
[[bench]]
name = "local_walk_dir_benchmark"
harness = false
[lib]
doctest = false
@@ -0,0 +1,85 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use criterion::{Criterion, Throughput, criterion_group, criterion_main};
use rustfs_filemeta::{MetaCacheEntry, MetacacheReader, test_data::create_real_xlmeta};
use std::hint::black_box;
mod storage_api;
use storage_api::local_walk_dir::{Disk, DiskAPI, DiskOption, Endpoint, WalkDirOptions, new_disk};
const OBJECTS: usize = 1000;
async fn walk_page(disk: &Disk, recursive: bool) -> Vec<MetaCacheEntry> {
let opts = WalkDirOptions {
bucket: "bench-bucket".to_string(),
base_dir: "prefix".to_string(),
recursive,
limit: i32::try_from(OBJECTS).expect("page limit fits"),
..Default::default()
};
let (reader, mut writer) = tokio::io::duplex(4096);
let produce = async move { disk.walk_dir(opts, &mut writer).await };
let consume = async { MetacacheReader::new(reader).read_all().await.expect("decode listing stream") };
let (result, entries) = tokio::join!(produce, consume);
result.expect("production walk_dir must succeed");
entries
}
fn bench_local_walk_dir(c: &mut Criterion) {
let root = tempfile::tempdir().expect("create benchmark disk");
let metadata = create_real_xlmeta().expect("create object metadata");
let decoded = rustfs_filemeta::FileMeta::load(&metadata).expect("decode fixture");
let data_dirs = decoded.get_data_dirs().expect("fixture data directories");
for i in 0..OBJECTS {
let object_dir = root.path().join("bench-bucket/prefix").join(format!("object-{i:04}"));
std::fs::create_dir_all(&object_dir).expect("create object directory");
for data_dir in data_dirs.iter().flatten() {
std::fs::create_dir_all(object_dir.join(data_dir.to_string())).expect("create data directory");
}
std::fs::write(object_dir.join("xl.meta"), &metadata).expect("write object metadata");
}
let runtime = tokio::runtime::Builder::new_multi_thread()
.worker_threads(4)
.enable_all()
.build()
.expect("build benchmark runtime");
let endpoint = Endpoint::try_from(root.path().to_str().expect("UTF-8 disk path")).expect("disk endpoint");
let disk = runtime
.block_on(new_disk(
&endpoint,
&DiskOption {
cleanup: false,
health_check: false,
},
))
.expect("open local disk");
let mut group = c.benchmark_group("local_walk_dir");
group.throughput(Throughput::Elements(u64::try_from(OBJECTS).expect("object count fits")));
for (name, recursive) in [("recursive_1000_objects", true), ("non_recursive_1000_objects", false)] {
let entries = runtime.block_on(walk_page(&disk, recursive));
let objects: Vec<_> = entries.iter().filter(|entry| !entry.metadata.is_empty()).collect();
assert_eq!(objects.len(), OBJECTS);
for (i, entry) in objects.iter().enumerate() {
assert_eq!(entry.name, format!("prefix/object-{i:04}"));
}
group.bench_function(name, |b| {
b.iter(|| black_box(runtime.block_on(walk_page(&disk, recursive))));
});
}
group.finish();
}
criterion_group!(benches, bench_local_walk_dir);
criterion_main!(benches);
@@ -33,3 +33,7 @@ pub(crate) mod single_block_non_inline {
pub(crate) mod multipart {
pub(crate) use rustfs_ecstore::api::disk::{DiskAPI, DiskOption, Endpoint, new_disk};
}
pub(crate) mod local_walk_dir {
pub(crate) use rustfs_ecstore::api::disk::{Disk, DiskAPI, DiskOption, Endpoint, WalkDirOptions, new_disk};
}
+60 -14
View File
@@ -17,8 +17,8 @@
#[cfg(feature = "diagnostic-read-version-caller")]
pub mod diagnostic {
pub use crate::cluster::rpc::runtime_sources::{
current_read_version_entry_source, observe_read_version_context_boundary, with_read_version_entry,
with_read_version_spawn_context,
current_read_version_entry_source, observe_read_version_context_boundary, with_captured_read_version_context,
with_read_version_entry, with_read_version_spawn_context,
};
pub use rustfs_io_metrics::internode_metrics::{ReadVersionContextBoundary, ReadVersionEntrySource};
}
@@ -341,8 +341,9 @@ pub mod cache {
pub mod capacity {
pub use crate::core::pools::{
DecommissionUnresolvedEntry, PoolDecommissionInfo, 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, 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,
};
pub use crate::store::utils::is_reserved_or_invalid_bucket;
}
@@ -381,6 +382,43 @@ pub mod config {
with_config_object_read_lock, with_config_object_write_lock, with_server_config_read_lock,
with_server_config_write_lock,
};
/// Observe persisted config bytes and their reader-associated metadata within a byte limit.
pub async fn read_config_observation(
api: std::sync::Arc<crate::store::ECStore>,
file: &str,
max_bytes: usize,
) -> crate::error::Result<(Vec<u8>, crate::object_api::ObjectInfo)> {
crate::config::com::read_config_observation(api, file, max_bytes).await
}
/// Observe bounded config bytes while the caller retains the object's write owner.
pub async fn read_config_observation_no_lock(
api: std::sync::Arc<crate::store::ECStore>,
file: &str,
max_bytes: usize,
guard: &crate::object_api::WriteCommitGuard,
) -> crate::error::Result<(Vec<u8>, crate::object_api::ObjectInfo)> {
crate::config::com::read_config_observation_no_lock(api, file, max_bytes, guard).await
}
/// Read config bytes and metadata without treating an empty payload as absent.
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 async fn read_config_limited_preserve_empty_with_metadata_opts(
api: std::sync::Arc<crate::store::ECStore>,
file: &str,
opts: &crate::object_api::ObjectOptions,
max_bytes: usize,
) -> crate::error::Result<(Vec<u8>, crate::object_api::ObjectInfo)> {
crate::config::com::read_config_limited_preserve_empty_with_metadata_opts(api, file, opts, max_bytes).await
}
}
pub mod storageclass {
@@ -430,16 +468,19 @@ pub mod disk {
pub use crate::disk::local::{ReplacementExecutionLease, ScanGuard};
#[cfg(all(feature = "test-util", not(windows)))]
pub use crate::disk::os::{LocalPublicationPause, LocalPublicationStage};
pub use crate::disk::{ArtifactInventoryFrame, ArtifactInventoryVersion};
pub use crate::disk::{
BATCH_READ_VERSION_MAX_ITEMS, BUCKET_META_PREFIX, BatchReadVersionItem, BatchReadVersionReq, BatchReadVersionResp,
CheckPartsResp, ConditionalFileUpdate, CurrentDeleteGeneration, CurrentDeleteTransition,
DELETE_RECOVERY_PROTOCOL_VERSION, DeleteOptions, DeleteRecoveryAction, DeleteRecoveryCursor, DeleteRecoveryDigest,
DeleteRecoveryInspection, DeleteRecoveryPage, DeleteRecoveryRpcOperation, DeleteRecoveryRpcReply, DeleteRecoveryState,
Disk, DiskAPI, DiskInfo, DiskInfoOptions, DiskLocation, DiskOption, DiskStore, FileInfoVersions, FileReader, FileWriter,
GuardedDeleteVersionsPayload, GuardedFileInfoVersions, HEALING_MARKER_PATH, NsScannerOpenRequest, OldCurrentSize,
PartTransactionAction, QUOTA_MUTATION_FENCE_METADATA_SUFFIX, RUSTFS_META_BUCKET, ReadMultipleReq, ReadMultipleResp,
ReadOptions, RenameDataResp, STORAGE_FORMAT_FILE, SnapshotLeaseToken, UpdateMetadataOpts, VolumeInfo, WalkDirOptions,
is_quota_mutation_fence_path, new_disk, validate_batch_read_version_item_count,
GuardedDeleteVersionsPayload, GuardedFileInfoVersions, HEALING_MARKER_PATH, LegacyFileStream, LegacyFileWrite,
LegacyMetadataWrite, LegacyPartMutation, LegacyPartOperation, LegacyPathDelete, LegacyPathDeleteOperation,
LegacyRenameData, LegacyRenameFile, NsScannerOpenRequest, OldCurrentSize, PartTransactionAction,
QUOTA_MUTATION_FENCE_METADATA_SUFFIX, RUSTFS_META_BUCKET, ReadMultipleReq, ReadMultipleResp, ReadOptions, RenameDataResp,
STORAGE_FORMAT_FILE, SnapshotLeaseToken, UpdateMetadataOpts, VolumeInfo, WalkDirOptions, is_quota_mutation_fence_path,
new_disk, validate_batch_read_version_item_count,
};
pub use bytes::Bytes;
pub use endpoint::Endpoint;
@@ -490,6 +531,8 @@ pub mod global {
}
pub mod runtime {
#[cfg(any(test, feature = "test-util"))]
pub use crate::runtime::global::set_object_store_resolver;
pub use crate::runtime::instance::{InstanceContext, bootstrap_ctx};
pub use crate::runtime::sources::{
boot_time, bucket_monitor, deployment_id, endpoint_pools, expiry_state_handle, first_cluster_node_is_local,
@@ -586,12 +629,14 @@ pub mod rpc {
decode_heal_bucket_rpc_options, encode_heal_bucket_rpc_options, gen_signature_headers, gen_tonic_replay_scope_headers,
gen_tonic_signature_headers, gen_tonic_signature_interceptor, node_service_time_out_client,
node_service_time_out_client_no_auth, normalize_tonic_rpc_audience, set_tonic_canonical_body_digest,
sign_ns_scanner_capability, sign_ns_scanner_capability_with_tier_registry_generation, sign_put_file_capability,
sign_tonic_rpc_response_proof, tonic_boot_epoch_challenge, tonic_boot_epoch_response_headers,
tonic_rpc_auth_failure_reason, verify_ns_scanner_capability, verify_ns_scanner_capability_with_tier_registry_generation,
verify_put_file_auth_trailer, verify_put_file_capability, verify_rpc_signature, verify_tonic_boot_epoch_response,
verify_tonic_canonical_body_digest, verify_tonic_mutation_body_digest, verify_tonic_mutation_body_digest_reject_unsigned,
verify_tonic_rpc_response_proof, verify_tonic_rpc_signature, verify_tonic_rpc_signature_with_bootstrap,
sign_ns_scanner_capability, sign_ns_scanner_capability_with_checkpoint_coverage,
sign_ns_scanner_capability_with_tier_registry_generation, sign_put_file_capability, sign_tonic_rpc_response_proof,
tonic_boot_epoch_challenge, tonic_boot_epoch_response_headers, tonic_rpc_auth_failure_reason,
verify_ns_scanner_capability, verify_ns_scanner_capability_with_checkpoint_coverage,
verify_ns_scanner_capability_with_tier_registry_generation, verify_put_file_auth_trailer, verify_put_file_capability,
verify_rpc_signature, verify_tonic_boot_epoch_response, verify_tonic_canonical_body_digest,
verify_tonic_mutation_body_digest, verify_tonic_mutation_body_digest_reject_unsigned, verify_tonic_rpc_response_proof,
verify_tonic_rpc_signature, verify_tonic_rpc_signature_with_bootstrap,
};
}
@@ -630,6 +675,7 @@ pub mod store_list {
}
pub mod storage {
pub use crate::bucket::artifact::inventory::ArtifactAttemptInventoryFrame;
pub use crate::core::pools::{HealLifecycleExpiryContext, POOL_META_NAME};
pub use crate::store::HealWalkVersion;
pub use crate::store::{
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,120 @@
// Copyright 2026 RustFS Team
// SPDX-License-Identifier: Apache-2.0
use super::*;
use crate::bucket::metadata::BUCKET_INCARNATION_FILE;
use crate::config::com::read_config_observation;
use crate::object_api::WriteCompletion;
impl ECStore {
/// Permanently retain this managed bucket's versions for historical restores.
/// Lifecycle WRITE drains earlier exact deletes. The detached owner keeps
/// lifecycle -> metadata transaction locks until every metadata write drains.
pub async fn retain_artifact_backups(self: Arc<Self>, bucket: &str, incarnation: Uuid, base_prefix: &str) -> Result<()> {
let bucket = bucket.to_owned();
let base_prefix = base_prefix.to_owned();
let ctx = Arc::clone(&self.ctx);
ctx.run_detached_mutation(async move {
require_put_seal_topology(&self).await?;
if self.ctx.lock_manager().is_disabled() {
return Err(Error::other("artifact backup retention requires namespace locking"));
}
let lifecycle = self.acquire_bucket_lifecycle_write_lock(&bucket).await?;
let transaction = metadata_sys::acquire_bucket_metadata_transaction_lock_in(&self.ctx, &bucket).await?;
let mut metadata = self.backup_retention_metadata(&bucket, incarnation, &base_prefix).await?;
let check = || {
if lifecycle.is_lock_lost() || transaction.is_lock_lost() {
Err(Error::other("artifact backup retention fence was lost"))
} else {
Ok(())
}
};
check()?;
// Even an idempotent retry rewrites with TailDrained: an earlier
// interrupted call may have reached only some metadata replicas.
metadata.artifact_backup_retained = true;
let mut opts = ObjectOptions {
max_parity: true,
write_completion: WriteCompletion::TailDrained,
..Default::default()
};
opts.add_bucket_lifecycle_lock_guard(&lifecycle);
opts.add_namespace_lock_guard(&transaction);
metadata.save_with_store_options(self.clone(), &opts).await?;
check()?;
let sys = metadata_sys::require_bucket_metadata_sys_in(&self.ctx)?;
let sys = sys.read().await.clone();
sys.set_validated(metadata).await?;
check()
})
.await
.map_err(|_| Error::other("artifact backup retention owner failed"))?
}
/// Observe the immutable bucket identity and retention in one locked read.
pub async fn inspect_artifact_backup_retention(&self, bucket: &str, incarnation: Uuid, base_prefix: &str) -> Result<bool> {
require_put_seal_topology(self).await?;
if self.ctx.lock_manager().is_disabled() {
return Err(Error::other("artifact backup retention requires namespace locking"));
}
let lifecycle = self.acquire_bucket_lifecycle_read_lock(bucket).await?;
let metadata = self.backup_retention_metadata(bucket, incarnation, base_prefix).await?;
if lifecycle.is_lock_lost() {
return Err(Error::other("artifact backup retention observation fence was lost"));
}
Ok(metadata.artifact_backup_retained)
}
async fn backup_retention_metadata(&self, bucket: &str, incarnation: Uuid, base_prefix: &str) -> Result<BucketMetadata> {
if incarnation.is_nil() || !crate::storage_api_contracts::bucket::valid_artifact_base_prefix(base_prefix) {
return Err(Error::other("artifact backup retention identity is invalid"));
}
let mut metadata = self.read_backup_retention_metadata(bucket).await?;
let store = metadata_sys::object_store_in(&self.ctx).await?;
let (sidecar, _) =
read_config_observation(store, &format!("{BUCKET_META_PREFIX}/{bucket}/{BUCKET_INCARNATION_FILE}"), 16).await?;
metadata.parse_all_configs()?;
if metadata.bucket_incarnation_id != incarnation
|| Uuid::from_slice(&sidecar).ok() != Some(incarnation)
|| metadata.artifact_base_prefix.as_deref() != Some(base_prefix)
|| !metadata.bucket_creation_committed
|| !metadata.versioning_config.as_ref().is_some_and(|versioning| {
versioning.enabled()
&& !versioning.exclude_folders.unwrap_or(false)
&& versioning.excluded_prefixes.as_ref().is_none_or(Vec::is_empty)
})
{
return Err(Error::other("artifact backup retention identity mismatch"));
}
metadata.bucket_incarnation_sidecar = true;
Ok(metadata)
}
/// Caller must hold the bucket lifecycle fence. An unreadable or missing
/// record cannot prove that historical backup protection was never enabled.
pub(crate) async fn require_no_artifact_backup_retention(&self, bucket: &str) -> Result<()> {
if self.read_backup_retention_metadata(bucket).await?.artifact_backup_retained {
return Err(Error::other("artifact versions are retained for backups"));
}
Ok(())
}
async fn read_backup_retention_metadata(&self, bucket: &str) -> Result<BucketMetadata> {
if bucket.is_empty()
|| super::super::utils::has_bad_path_component(bucket)
|| super::super::utils::is_meta_bucketname(bucket)
{
return Err(Error::other("artifact backup retention bucket is invalid"));
}
let store = metadata_sys::object_store_in(&self.ctx).await?;
if store.id != self.id {
return Err(Error::other("artifact backup retention belongs to another store"));
}
let (bytes, _) = read_config_observation(store, &BucketMetadata::new(bucket).save_file_path(), 64 * 1024 * 1024).await?;
let metadata = BucketMetadata::decode_file(&bytes)?;
if metadata.name != bucket {
return Err(Error::other("artifact backup retention bucket mismatch"));
}
Ok(metadata)
}
}
@@ -0,0 +1,154 @@
// Copyright 2026 RustFS Team
// SPDX-License-Identifier: Apache-2.0
use super::*;
use crate::storage_api_contracts::object::ObjectOperations as _;
/// In-process authority for exactly one drained managed version. No caller can
/// construct this capability or serialize it into a disk/RPC request.
#[doc(hidden)]
pub struct ArtifactDeleteTarget {
store: Uuid,
record: ArtifactVersionRetirementRecord,
lifecycle: Arc<rustfs_lock::NamespaceLockGuard>,
attempt: WriteCommitGuard,
repair: WriteCommitGuard,
}
impl ArtifactDeleteTarget {
fn check_live(&self) -> Result<()> {
if self.lifecycle.is_lock_lost() || self.attempt.is_lock_lost() || self.repair.is_lock_lost() {
return Err(Error::other("artifact exact delete owned fence was lost"));
}
Ok(())
}
pub(crate) fn lifecycle(&self) -> Arc<rustfs_lock::NamespaceLockGuard> {
Arc::clone(&self.lifecycle)
}
pub(crate) fn validate(&self, store: &ECStore, bucket: &str, object: &str, opts: &ObjectOptions) -> Result<()> {
if self.store != store.id
|| bucket != self.record.bucket
|| rustfs_utils::path::encode_dir_object(object) != self.record.object
|| opts.expected_bucket_incarnation_id != Some(self.record.incarnation_id)
|| opts.version_id.as_deref() != Some(self.record.version_id.to_string().as_str())
|| !opts.versioned
|| opts.version_suspended
|| opts.delete_prefix
|| opts.delete_marker
|| opts.data_movement
|| opts.lifecycle_delete_all.is_some()
|| self.lifecycle.is_lock_lost()
|| self.attempt.is_lock_lost()
|| self.repair.is_lock_lost()
{
return Err(Error::other("artifact exact delete target or owned fence mismatch"));
}
Ok(())
}
}
impl ECStore {
/// Read-only capability observation; each mutation revalidates the topology.
pub async fn supports_artifact_exact_delete(&self) -> bool {
require_local_put_seal_topology(self).await.is_ok() && !self.ctx.lock_manager().is_disabled()
}
/// Delete an exact version in a fully sealed attempt. This authenticates the
/// storage identity and drains writers; the caller still owns GC eligibility
/// and cleanup IAM. Distributed receiver deletion is not yet supported.
pub async fn delete_artifact_object_version(
self: Arc<Self>,
bucket: &str,
expected_incarnation: Uuid,
attempt_prefix: &str,
object: &str,
version: Uuid,
) -> Result<()> {
require_local_put_seal_topology(&self).await?;
if self.ctx.lock_manager().is_disabled() {
return Err(Error::other("artifact exact deletion requires namespace locking"));
}
let lifecycle = Arc::new(self.acquire_bucket_lifecycle_read_lock(bucket).await?);
self.require_no_artifact_backup_retention(bucket).await?;
let put = self
.inventory_artifact_record(bucket, expected_incarnation, attempt_prefix)
.await?;
let repair = ArtifactRepairSealRecord::new(&put);
let record = ArtifactVersionRetirementRecord::new(&put, object, version)?;
let object = object.to_owned();
let store = Arc::clone(&self);
// Lifecycle -> attempt -> repair seal -> metadata transaction -> object.
// The spawned owner, and the disk deletion tail's typed capability, keep
// these locks after cancellation of the HTTP caller.
with_config_object_write_lock(self, put.path(), move |attempt| async move {
if !put.exists(Arc::clone(&store), &attempt).await? {
return Err(Error::other("artifact exact deletion requires sealed PUTs"));
}
let lock = store.new_ns_lock(RUSTFS_META_BUCKET, &repair.path()).await?;
let repair_owner = WriteCommitGuard::acquire(&lock, get_lock_acquire_timeout()).await?;
if !repair.exists(Arc::clone(&store), &repair_owner).await? {
return Err(Error::other("artifact exact deletion requires sealed repairs"));
}
let receivers = store.capture_artifact_receivers().await?;
store
.close_artifact_receiver_snapshot(&put, crate::disk::local::ArtifactLeaseDomain::Put, &receivers)
.await?;
store
.close_artifact_receiver_snapshot(&put, crate::disk::local::ArtifactLeaseDomain::Repair, &receivers)
.await?;
store.verify_artifact_receiver_snapshot(&put, &receivers).await?;
let target = Arc::new(ArtifactDeleteTarget {
store: store.id,
record,
lifecycle,
attempt,
repair: repair_owner,
});
let mut opts = ObjectOptions {
versioned: true,
version_id: Some(version.to_string()),
expected_bucket_incarnation_id: Some(expected_incarnation),
artifact_delete_target: Some(Arc::clone(&target)),
delete_mutation_owner: Some(target.clone()),
..Default::default()
};
opts.add_write_commit_guard(&target.attempt);
opts.add_write_commit_guard(&target.repair);
// These owners fence seal records, not the object. Ordinary DELETE
// must still acquire and retain its exact object WRITE owner.
opts.no_lock = false;
target.check_live()?;
// Preserve the normal exact DELETE's Object Lock, quorum, journal,
// cache and capacity behavior. Its success is logical deletion, not
// a claim that every offline shard has been physically erased.
match store.delete_object(&put.bucket, &object, opts).await {
Ok(info) => {
// Match the S3 request layer's committed-generation quota
// delta. A strict absence retry must not subtract twice.
if !info.name.is_empty()
&& !info.delete_marker
&& let Ok(size) = crate::data_usage::quota_object_size(&info)
{
crate::data_usage::record_bucket_object_delete_memory(&put.bucket, size, false).await;
}
}
Err(Error::VersionNotFound(bucket, object, version))
if bucket == target.record.bucket
&& rustfs_utils::path::encode_dir_object(&object) == target.record.object
&& version == target.record.version_id.to_string() => {}
Err(Error::ObjectNotFound(bucket, object))
if bucket == target.record.bucket
&& rustfs_utils::path::encode_dir_object(&object) == target.record.object => {}
Err(error) => return Err(error),
}
store.verify_artifact_receiver_snapshot(&put, &receivers).await?;
store
.inventory_artifact_record(&put.bucket, expected_incarnation, &put.attempt_prefix)
.await?;
target.check_live()
})
.await?
}
}
@@ -0,0 +1,207 @@
// Copyright 2026 RustFS Team
// SPDX-License-Identifier: Apache-2.0
use super::*;
use crate::disk::{ArtifactInventoryFrame, ArtifactInventoryVersion, Disk};
use rustfs_protos::proto_gen::node_service::ArtifactInventoryPhase;
use tokio::{sync::mpsc, time::Instant};
/// Physical observations retain their member. Replicas are not extra business
/// versions and this stream makes no quorum-readability or GC claim.
#[derive(Debug)]
pub enum ArtifactAttemptInventoryFrame {
Observations {
endpoint: String,
disk_id: Uuid,
versions: Vec<ArtifactInventoryVersion>,
},
Complete {
members: usize,
observations: u64,
},
}
impl ECStore {
/// Inventory one fully sealed attempt across every configured receiver.
/// Earlier output is staging only; only the final frame plus Ok is complete.
pub async fn inventory_artifact_attempt(
self: Arc<Self>,
bucket: &str,
incarnation: Uuid,
prefix: &str,
deadline: Instant,
output: mpsc::Sender<ArtifactAttemptInventoryFrame>,
) -> Result<()> {
tokio::time::timeout_at(deadline, Box::pin(async move {
require_put_seal_topology(&self).await?;
if self.ctx.lock_manager().is_disabled() { return Err(Error::other("artifact inventory requires namespace locking")); }
let lifecycle = self.acquire_bucket_lifecycle_read_lock(bucket).await?;
let put = self.inventory_artifact_record(bucket, incarnation, prefix).await?;
let repair = ArtifactRepairSealRecord::new(&put);
// Lifecycle -> attempt -> repair record. Receivers do not reenter
// distributed locks; actual two-domain disk owners guard their reads.
let store = self.clone();
let bucket = bucket.to_owned();
let prefix = prefix.to_owned();
with_config_object_write_lock(self, put.path(), move |attempt| async move {
tokio::time::timeout_at(deadline, async {
if !put.exists(store.clone(), &attempt).await? { return Err(Error::other("artifact inventory requires PUT seal record")); }
let lock = store.new_ns_lock(RUSTFS_META_BUCKET, &repair.path()).await?;
let repair_owner = WriteCommitGuard::acquire(&lock, get_lock_acquire_timeout()).await?;
if !repair.exists(store.clone(), &repair_owner).await? { return Err(Error::other("artifact inventory requires repair seal record")); }
let receivers = store.capture_artifact_receivers().await?;
let mut observations = 0u64;
for (endpoint, (disk, expected)) in &receivers {
if lifecycle.is_lock_lost() || attempt.is_lock_lost() || repair_owner.is_lock_lost() {
return Err(Error::other("artifact inventory fence was lost"));
}
let (sender, mut frames) = mpsc::channel(2);
let scan = async {
match disk.as_ref() {
Disk::Local(local) => local.get_disk().inventory_artifact_attempt(*expected, &bucket, &prefix, put.identity(), deadline, sender).await,
Disk::Remote(remote) => remote.inventory_artifact_attempt(*expected, (&bucket, incarnation), &prefix, ArtifactInventoryPhase::ArtifactInventoryScan, deadline, sender).await,
}
};
tokio::pin!(scan);
let mut sequence = 0;
let mut count = 0u64;
let mut terminal = false;
let mut ended = false;
let mut done = false;
while !ended || !done {
tokio::select! {
result = &mut scan, if !done => { result.map_err(Error::other)?; done = true; },
frame = frames.recv(), if !ended => match frame {
Some(ArtifactInventoryFrame::Entries { sequence: actual, versions }) if !terminal && actual == sequence => {
count = count.checked_add(u64::try_from(versions.len()).map_err(Error::other)?).ok_or_else(|| Error::other("artifact inventory count overflow"))?;
output.send(ArtifactAttemptInventoryFrame::Observations { endpoint: endpoint.clone(), disk_id: *expected, versions }).await
.map_err(|_| Error::other("artifact inventory consumer closed"))?;
sequence += 1;
}
Some(ArtifactInventoryFrame::Complete { disk_id, incarnation: actual, attempt_prefix, entries })
if !terminal && disk_id == *expected && actual == incarnation && attempt_prefix == prefix && entries == count => terminal = true,
Some(_) => return Err(Error::other("artifact inventory member stream is invalid")),
None => ended = true,
},
}
}
if !terminal { return Err(Error::other("artifact inventory member is incomplete")); }
observations = observations.checked_add(count).ok_or_else(|| Error::other("artifact inventory count overflow"))?;
}
store.verify_inventory_members(&put, &receivers, deadline).await?;
let current = store.artifact_inventory_receiver_identity(&bucket, incarnation, &prefix).await?;
if current.receiver_key != put.identity().receiver_key || lifecycle.is_lock_lost() || attempt.is_lock_lost() || repair_owner.is_lock_lost() {
return Err(Error::other("artifact inventory authority changed"));
}
output.send(ArtifactAttemptInventoryFrame::Complete { members: receivers.len(), observations }).await
.map_err(|_| Error::other("artifact inventory consumer closed"))?;
Ok(())
}).await.map_err(|_| Error::other("artifact inventory deadline exceeded"))?
}).await?
})).await.map_err(|_| Error::other("artifact inventory deadline exceeded"))?
}
pub(super) async fn inventory_artifact_record(&self, bucket: &str, expected: Uuid, prefix: &str) -> Result<PutSealRecord> {
use crate::bucket::metadata::BUCKET_INCARNATION_FILE;
use crate::config::com::read_config_observation;
if expected.is_nil()
|| crate::bucket::utils::has_bad_path_component(bucket)
|| bucket.is_empty()
|| crate::bucket::utils::is_meta_bucketname(bucket)
{
return Err(Error::other("artifact inventory authority is invalid"));
}
let store = metadata_sys::object_store_in(&self.ctx).await?;
if store.id != self.id {
return Err(Error::other("artifact inventory authority belongs to another store"));
}
// Do not enter metadata_sys's lazy bucket READ/migration path: the
// coordinator already owns lifecycle READ and a writer may be queued.
// Observation reads bypass cache and suppress physical read repair.
let path = BucketMetadata::new(bucket).save_file_path();
let (bytes, _) = read_config_observation(store.clone(), &path, 64 * 1024 * 1024).await?;
let mut metadata = BucketMetadata::decode_file(&bytes)?;
let (sidecar, _) =
read_config_observation(store, &format!("{BUCKET_META_PREFIX}/{bucket}/{BUCKET_INCARNATION_FILE}"), 16).await?;
if metadata.name != bucket
|| !metadata.bucket_creation_committed
|| metadata.bucket_incarnation_id != expected
|| Uuid::from_slice(&sidecar).ok() != Some(expected)
{
return Err(Error::other("artifact inventory incarnation mismatch"));
}
metadata.parse_all_configs()?;
if !metadata.versioning_config.as_ref().is_some_and(|config| {
config.enabled()
&& !config.exclude_folders.unwrap_or(false)
&& config.excluded_prefixes.as_ref().is_none_or(Vec::is_empty)
}) {
return Err(Error::other("artifact inventory requires managed versioning"));
}
PutSealRecord::new(&metadata, prefix)
}
pub(crate) async fn artifact_inventory_receiver_identity(
&self,
bucket: &str,
expected: Uuid,
prefix: &str,
) -> Result<ArtifactPutIdentity> {
Ok(self.inventory_artifact_record(bucket, expected, prefix).await?.identity())
}
async fn verify_inventory_members(
&self,
record: &PutSealRecord,
captured: &ArtifactReceiverSnapshot,
deadline: Instant,
) -> Result<()> {
for final_pass in [false, true] {
let current = self.artifact_receiver_inventory().await?;
if current.len() != captured.len()
|| current
.iter()
.any(|(endpoint, disk)| !captured.get(endpoint).is_some_and(|(old, _)| Arc::ptr_eq(old, disk)))
{
return Err(Error::other("artifact inventory membership changed"));
}
if final_pass {
for (disk, expected) in captured.values() {
if disk.cached_disk_id().await != Some(*expected) {
return Err(Error::other("artifact inventory expected identity changed"));
}
}
break;
}
for (disk, expected) in captured.values() {
if disk.cached_disk_id().await != Some(*expected) {
return Err(Error::other("artifact inventory expected identity changed"));
}
match disk.as_ref() {
Disk::Local(local) => {
let _verification = local
.get_disk()
.verify_artifact_inventory_closed(*expected, record.identity(), deadline)
.await
.map_err(Error::other)?;
}
Disk::Remote(remote) => {
let (output, _unused) = mpsc::channel(1);
remote
.inventory_artifact_attempt(
*expected,
(&record.bucket, record.incarnation_id),
&record.attempt_prefix,
ArtifactInventoryPhase::ArtifactInventoryVerify,
deadline,
output,
)
.await
.map_err(Error::other)?;
}
}
}
}
Ok(())
}
}
@@ -0,0 +1,287 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use std::collections::HashMap;
use std::future::Future;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex, Weak};
/// Shares only reads started after a caller arrives; retains no idle results.
#[derive(Default)]
pub(crate) struct OrdinaryClassificationRegistry {
gates: Mutex<HashMap<String, Weak<Gate>>>,
#[cfg(test)]
after_unregister: Option<std::sync::Barrier>,
}
#[derive(Default)]
struct Gate {
started_generation: AtomicU64,
completed: tokio::sync::Mutex<Option<CompletedRead>>,
}
struct CompletedRead {
generation: u64,
allowed: bool,
}
struct Registration<'a> {
registry: &'a OrdinaryClassificationRegistry,
bucket: &'a str,
gate: Option<Arc<Gate>>,
observed_generation: u64,
}
impl OrdinaryClassificationRegistry {
pub(crate) async fn classify(&self, bucket: &str, read: impl Future<Output = bool>) -> bool {
self.register(bucket).classify(read).await
}
fn register<'a>(&'a self, bucket: &'a str) -> Registration<'a> {
// Callers requiring physical admission must register after acquiring it.
// Release the registry lock before awaiting the gate, then acquire the
// existing metadata locks.
let mut gates = self.gates.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let gate = gates.get(bucket).and_then(Weak::upgrade).unwrap_or_else(|| {
let gate = Arc::new(Gate::default());
gates.insert(bucket.to_owned(), Arc::downgrade(&gate));
gate
});
let observed_generation = gate.started_generation.load(Ordering::Acquire);
Registration {
registry: self,
bucket,
gate: Some(gate),
observed_generation,
}
}
}
impl Registration<'_> {
async fn classify(self, read: impl Future<Output = bool>) -> bool {
let Some(gate) = self.gate.as_ref() else {
return false;
};
let mut completed = gate.completed.lock().await;
// Only a read started after this caller registered can authorize it.
// Arrivals during a read therefore wait for the next fresh read.
if let Some(result) = completed
.as_ref()
.filter(|result| result.generation > self.observed_generation)
{
return result.allowed;
}
let Ok(previous) = gate
.started_generation
.try_update(Ordering::AcqRel, Ordering::Acquire, |generation| generation.checked_add(1))
else {
return false;
};
// Keep the gate through the original bounded metadata read. Cancellation
// releases it without publishing a result; another caller can read again.
let allowed = read.await;
*completed = Some(CompletedRead {
generation: previous + 1,
allowed,
});
allowed
}
}
impl Drop for Registration<'_> {
fn drop(&mut self) {
let Some(gate) = self.gate.take() else {
return;
};
let mut gates = self.registry.gates.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
// Strong references are acquired and released under this registry lock,
// so concurrent final callers cannot both miss the last reference.
if Arc::strong_count(&gate) == 1
&& gates
.get(self.bucket)
.is_some_and(|registered| registered.ptr_eq(&Arc::downgrade(&gate)))
{
gates.remove(self.bucket);
}
drop(gate);
drop(gates);
#[cfg(test)]
if let Some(barrier) = &self.registry.after_unregister {
barrier.wait();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use futures::poll;
use std::task::Poll;
use tokio::sync::oneshot;
fn assert_empty(registry: &OrdinaryClassificationRegistry) {
assert!(registry.gates.lock().expect("classification registry").is_empty());
}
#[tokio::test]
async fn late_arrivals_share_only_the_next_fail_closed_read() {
let registry = OrdinaryClassificationRegistry::default();
let reads = AtomicU64::new(0);
let (release, waiting) = oneshot::channel();
let mut first = Box::pin(registry.classify("bucket", async {
reads.fetch_add(1, Ordering::Relaxed);
waiting.await.expect("release first read");
true
}));
assert!(poll!(first.as_mut()).is_pending());
let mut followers = (0..16)
.map(|_| {
Box::pin(registry.classify("bucket", async {
reads.fetch_add(1, Ordering::Relaxed);
false
}))
})
.collect::<Vec<_>>();
for follower in &mut followers {
assert!(poll!(follower.as_mut()).is_pending());
}
release.send(()).expect("first reader still alive");
assert_eq!(poll!(first.as_mut()), Poll::Ready(true));
for follower in &mut followers {
assert_eq!(poll!(follower.as_mut()), Poll::Ready(false));
}
assert_eq!(reads.load(Ordering::Relaxed), 2);
assert_empty(&registry);
}
#[tokio::test]
async fn successive_waves_always_read_again() {
let registry = OrdinaryClassificationRegistry::default();
let reads = AtomicU64::new(0);
for expected in [true, false, true] {
assert_eq!(
registry
.classify("bucket", async {
reads.fetch_add(1, Ordering::Relaxed);
expected
})
.await,
expected
);
assert_empty(&registry);
}
assert_eq!(reads.load(Ordering::Relaxed), 3);
}
#[tokio::test]
async fn canceled_leader_does_not_publish_or_strand_waiters() {
let registry = OrdinaryClassificationRegistry::default();
let reads = AtomicU64::new(0);
let old_registration = registry.register("bucket");
assert!(
registry
.classify("bucket", async {
reads.fetch_add(1, Ordering::Relaxed);
true
})
.await
);
let mut leader = Box::pin(registry.classify("bucket", async {
reads.fetch_add(1, Ordering::Relaxed);
std::future::pending::<bool>().await
}));
assert!(poll!(leader.as_mut()).is_pending());
let mut old_waiter =
Box::pin(old_registration.classify(async { panic!("old waiter must reuse the completed post-registration read") }));
assert!(poll!(old_waiter.as_mut()).is_pending());
let mut late_waiter = Box::pin(registry.classify("bucket", async {
reads.fetch_add(1, Ordering::Relaxed);
false
}));
assert!(poll!(late_waiter.as_mut()).is_pending());
drop(leader);
assert_eq!(poll!(old_waiter.as_mut()), Poll::Ready(true));
assert_eq!(poll!(late_waiter.as_mut()), Poll::Ready(false));
assert_eq!(reads.load(Ordering::Relaxed), 3);
assert_empty(&registry);
}
#[tokio::test]
async fn canceled_follower_does_not_retain_the_gate() {
let registry = OrdinaryClassificationRegistry::default();
let (release, waiting) = oneshot::channel();
let mut leader = Box::pin(registry.classify("bucket", async {
waiting.await.expect("release leader");
true
}));
assert!(poll!(leader.as_mut()).is_pending());
let mut follower = Box::pin(registry.classify("bucket", async { panic!("canceled follower must not read") }));
assert!(poll!(follower.as_mut()).is_pending());
drop(follower);
assert_eq!(registry.gates.lock().expect("classification registry").len(), 1);
release.send(()).expect("leader still alive");
assert_eq!(poll!(leader.as_mut()), Poll::Ready(true));
assert_empty(&registry);
}
#[test]
fn concurrent_final_registrations_remove_the_gate() {
let registry = OrdinaryClassificationRegistry {
after_unregister: Some(std::sync::Barrier::new(2)),
..Default::default()
};
let first = registry.register("bucket");
let second = registry.register("bucket");
// Both destructors reach the unlocked barrier before either returns.
// Moving the Arc release after this barrier leaves a stale registry key.
std::thread::scope(|scope| {
scope.spawn(move || drop(first));
scope.spawn(move || drop(second));
});
assert_empty(&registry);
}
#[tokio::test]
async fn buckets_and_stores_do_not_share_gates() {
let first_store = OrdinaryClassificationRegistry::default();
let second_store = OrdinaryClassificationRegistry::default();
let mut blocked = Box::pin(first_store.classify("first", std::future::pending()));
assert!(poll!(blocked.as_mut()).is_pending());
assert_eq!(poll!(Box::pin(first_store.classify("second", async { true }))), Poll::Ready(true));
assert_eq!(poll!(Box::pin(second_store.classify("first", async { false }))), Poll::Ready(false));
assert_empty(&second_store);
drop(blocked);
assert_empty(&first_store);
}
#[tokio::test]
async fn generation_exhaustion_fails_closed_without_reading() {
let registry = OrdinaryClassificationRegistry::default();
let registration = registry.register("bucket");
let gate = registration.gate.as_ref().expect("registered gate");
gate.started_generation.store(u64::MAX, Ordering::Release);
*gate.completed.lock().await = Some(CompletedRead {
generation: u64::MAX,
allowed: true,
});
assert!(
!registry
.classify("bucket", async { panic!("exhausted generation must deny without reading") })
.await
);
assert_eq!(gate.started_generation.load(Ordering::Acquire), u64::MAX);
drop(registration);
assert_empty(&registry);
}
}
+29 -1
View File
@@ -2012,7 +2012,7 @@ impl TargetClient {
.head_object()
.bucket(bucket)
.key(object)
.set_version_id(version_id)
.set_version_id(resolve_read_api_version_id(version_id))
.customize()
.map_request(move |mut req| {
for (k, v) in headers.clone().into_iter() {
@@ -4170,6 +4170,34 @@ mod tests {
);
}
/// The worker's convergence HEAD addresses a source null version (nil
/// UUID) as `versionId=null`, the spelling every S3 target understands,
/// like the proxy and audit reads do.
#[tokio::test]
async fn head_object_addresses_nil_source_version_as_null() {
let (client, request_uris) = recording_target_client();
let versioned = Uuid::new_v4().to_string();
// Only the request URI matters here; the canned empty response may or
// may not parse as a HEAD result.
let _ = client
.head_object("target-bucket", "object", Some(Uuid::nil().to_string()))
.await;
let _ = client.head_object("target-bucket", "object", Some(versioned.clone())).await;
let request_uris = request_uris.lock().expect("recorded request lock should not be poisoned");
assert_eq!(request_uris.len(), 2);
assert!(
request_uris[0].contains("versionId=null") && !request_uris[0].contains(&Uuid::nil().to_string()),
"a nil source version must be addressed as the null version: {}",
request_uris[0]
);
assert!(
request_uris[1].contains(&format!("versionId={versioned}")),
"a concrete source version must pass through unchanged: {}",
request_uris[1]
);
}
#[tokio::test]
async fn put_object_sends_source_version_id_query_to_target() {
// MinIO reads the replicated version only from the `versionId` query
@@ -879,13 +879,18 @@ fn free_version_cleanup_fences_current(
&& free_version_physical_topology_generation(api) == topology_generation
}
async fn cleanup_free_version_exact(api: Arc<ECStore>, oi: &ObjectInfo, cancel: &CancellationToken) -> std::io::Result<bool> {
pub(crate) async fn cleanup_free_version_exact(
api: Arc<ECStore>,
oi: &ObjectInfo,
cancel: &CancellationToken,
) -> std::io::Result<bool> {
const FREE_VERSION_REMOTE_DEADLINE: StdDuration = StdDuration::from_secs(30);
let topology_generation = free_version_physical_topology_generation(&api);
let bucket_guard = api
.acquire_bucket_lifecycle_read_lock(&oi.bucket)
.await
.map(Arc::new)
.map_err(std::io::Error::other)?;
let (lease, delete_plan) = acquire_free_version_tier_lease(oi, &api.tier_config_mgr()).await?;
let local_object = encode_dir_object(&oi.name);
@@ -954,7 +959,7 @@ async fn cleanup_free_version_exact(api: Arc<ECStore>, oi: &ObjectInfo, cancel:
};
delete_request.set_tier_free_version();
if let Err(err) = set
.delete_object_version(&oi.bucket, &local_object, &delete_request, false)
.delete_object_version_with_lifecycle_owner(&oi.bucket, &local_object, &delete_request, Arc::clone(&bucket_guard))
.await
&& first_error.is_none()
{
@@ -1038,6 +1043,32 @@ pub struct ExpiryState {
recovery_notify: Arc<Notify>,
}
/// Keeps every lifecycle expiry task the process enqueues in its queue until
/// released, as if the expiry workers were busy.
#[cfg(all(test, feature = "test-util"))]
pub(crate) struct HeldExpiryQueue {
state: Arc<RwLock<ExpiryState>>,
previous: ExpiryState,
}
#[cfg(all(test, feature = "test-util"))]
impl HeldExpiryQueue {
pub(crate) async fn hold() -> Self {
let held = Arc::try_unwrap(ExpiryState::new_with_unconsumed_worker_channel(16))
.ok()
.expect("a new expiry state has a single owner")
.into_inner();
let state = runtime_sources::expiry_state_handle();
let previous = std::mem::replace(&mut *state.write().await, held);
Self { state, previous }
}
/// Drops the held tasks unrun and restores the previous expiry state.
pub(crate) async fn release(self) {
*self.state.write().await = self.previous;
}
}
impl ExpiryState {
#[allow(clippy::new_ret_no_self)]
pub fn new() -> Arc<RwLock<Self>> {
@@ -3012,14 +3043,7 @@ fn spawn_tier_free_version_recovery_once_with_options(
move |bucket_marker, object_marker, recovery_cancel| {
let api = Arc::clone(&api);
async move {
recover_tier_free_versions_with_cancel(
api,
DEFAULT_FREE_VERSION_RECOVERY_LIMIT,
bucket_marker,
object_marker,
recovery_cancel,
)
.await
recover_configured_tier_free_versions(api, bucket_marker, object_marker, recovery_cancel).await
}
},
)
@@ -3031,6 +3055,39 @@ fn spawn_tier_free_version_recovery_once_with_options(
}))
}
async fn recover_configured_tier_free_versions(
api: Arc<ECStore>,
bucket_marker: Option<String>,
object_marker: Option<String>,
cancel_token: CancellationToken,
) -> crate::error::Result<FreeVersionRecoveryStats> {
let tier_config_mgr = api.tier_config_mgr();
let has_tiers = select! {
biased;
_ = cancel_token.cancelled() => {
return Err(std::io::Error::new(std::io::ErrorKind::Interrupted, "tier free-version recovery cancelled").into());
}
manager = tier_config_mgr.read() => !manager.empty(),
};
// Startup can launch recovery before tier configuration is loaded. Keep
// polling, but release the manager lock before any namespace I/O.
if !has_tiers {
return Ok(FreeVersionRecoveryStats {
scanned: 0,
enqueued: 0,
failed: 0,
next_bucket_marker: None,
next_object_marker: None,
scanned_entries: 0,
buckets_scanned: 0,
truncated: false,
});
}
recover_tier_free_versions_with_cancel(api, DEFAULT_FREE_VERSION_RECOVERY_LIMIT, bucket_marker, object_marker, cancel_token)
.await
}
fn tier_free_version_recovery_leader_retry_delay() -> StdDuration {
StdDuration::from_secs(rand::rng().random_range(15..=30))
}
@@ -8440,6 +8497,104 @@ mod tests {
}
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial]
async fn artifact_delete_entrypoints_free_version_cleaner_completes_with_queued_lifecycle_writer() {
let (disk_paths, ecstore) = setup_test_env().await;
let bucket = format!("free-version-queued-writer-{}", Uuid::new_v4());
let object = "free-version";
create_test_bucket(&ecstore, &bucket).await;
let (backend, identity) = register_recovery_mock_tier(&ecstore).await;
seed_recoverable_free_version(&disk_paths, &bucket, object, None, Some(identity)).await;
let oi = list_tier_free_versions(Arc::clone(&ecstore), 100, None, None, CancellationToken::new())
.await
.expect("list real persisted free version")
.items
.into_iter()
.find(|oi| oi.bucket == bucket)
.expect("free-version task must address this fixture");
assert!(oi.transitioned_object.free_version);
backend
.set_put_remote_version(Some(oi.transitioned_object.version_id.clone()))
.await;
let remote = TierConfigMgr::acquire_operation_lease(&ecstore.tier_config_mgr(), "WARM")
.await
.expect("remote fixture lease");
remote
.put(&oi.transitioned_object.name, ReaderImpl::Body(Bytes::from_static(b"body")), 4)
.await
.expect("seed exact remote object");
drop(remote);
let barrier = Arc::new(super::FreeVersionPostRemoteDeleteTestBarrier::default());
let cleanup_barrier = Arc::clone(&barrier);
let cleanup_store = Arc::clone(&ecstore);
let cleanup_oi = oi.clone();
let mut cleanup = tokio::spawn(async move {
super::FREE_VERSION_POST_REMOTE_DELETE_TEST_BARRIER
.scope(cleanup_barrier, async move {
super::cleanup_free_version_exact(cleanup_store, &cleanup_oi, &CancellationToken::new()).await
})
.await
});
tokio::time::timeout(StdDuration::from_secs(10), barrier.arrived.notified())
.await
.expect("cleaner owns lifecycle READ and has completed remote DELETE");
assert!(!backend.contains(&oi.transitioned_object.name).await);
for disk in &disk_paths {
assert!(
disk.join(&bucket).join(object).join(STORAGE_FORMAT_FILE).is_file(),
"local exact cleanup must still be pending at the boundary"
);
}
// Resolve first so the pending poll is the actual local lock wait.
// A later READ would normally be admitted alongside the cleaner's READ;
// its pending state independently proves this WRITE is now queued.
let lifecycle = ecstore
.new_ns_lock(&bucket, crate::storage_api_contracts::bucket::BUCKET_LIFECYCLE_LOCK_OBJECT)
.await
.expect("lifecycle namespace");
let mut writer = Box::pin(lifecycle.get_write_lock(StdDuration::from_secs(30)));
let writer_queued = futures::poll!(&mut writer).is_pending();
let mut later_reader = Box::pin(lifecycle.get_read_lock(StdDuration::from_secs(30)));
let later_reader_blocked = futures::poll!(&mut later_reader).is_pending();
drop(later_reader);
barrier.release.notify_one();
assert!(writer_queued, "cleaner must retain the real lifecycle READ owner");
assert!(later_reader_blocked, "queued lifecycle WRITE must block a later READ");
// Keep the same WRITE future queued, without cancelling/retrying it.
// Re-entering READ here would deadlock behind this waiting writer.
let finished = tokio::time::timeout(StdDuration::from_secs(10), &mut cleanup).await;
if finished.is_err() {
cleanup.abort();
drop(writer);
let _ = tokio::time::timeout(StdDuration::from_secs(5), &mut cleanup).await;
panic!("exact local free-version cleanup re-entered lifecycle READ behind the queued writer");
}
assert!(
finished
.expect("bounded cleaner completion")
.expect("cleaner task")
.expect("exact local cleanup")
);
let writer = tokio::time::timeout(StdDuration::from_secs(10), writer)
.await
.expect("queued lifecycle WRITE proceeds after real cleanup drains")
.expect("lifecycle WRITE acquired");
for disk in &disk_paths {
assert!(!disk.join(&bucket).join(object).exists(), "free version remains on a real local disk");
}
assert_eq!(backend.remove_count().await, 1, "cleaner must delete the exact remote tuple once");
drop(writer);
ecstore
.delete_bucket(&bucket, &DeleteBucketOptions::default())
.await
.expect("remove empty fixture bucket");
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial]
@@ -14220,9 +14375,14 @@ mod tests {
crate::store::init_local_disks(endpoint_pools.clone()).await.unwrap();
let ecstore = ECStore::new("127.0.0.1:0".parse().unwrap(), endpoint_pools, CancellationToken::new())
.await
.unwrap();
// Tests start the recovery loop they observe. An automatic startup loop
// would race that loop for the same unconsumed worker queue.
let ecstore = temp_env::async_with_vars(
[(super::ENV_TIER_FREE_VERSION_RECOVERY_ENABLED, Some("false"))],
ECStore::new("127.0.0.1:0".parse().unwrap(), endpoint_pools, CancellationToken::new()),
)
.await
.unwrap();
let buckets = ecstore
.list_bucket(&BucketOptions {
@@ -14698,10 +14858,232 @@ mod tests {
StdDuration::from_millis(10)
}
#[tokio::test]
#[serial]
async fn tier_free_version_recovery_without_tiers_skips_listing_and_resets_cursor() {
let (disk_paths, ecstore) = setup_test_env().await;
let manager = ecstore.tier_config_mgr();
let saved_tiers = std::mem::take(&mut manager.write().await.tiers);
let runtime_state = install_unconsumed_runtime_expiry_worker(&ecstore, 1).await;
let recovery_rx = Arc::clone(&runtime_state.read().await.tasks_rx[0]);
let bucket = format!("recovery-no-tiers-{}", Uuid::new_v4());
let object = "free-version";
create_test_bucket(&ecstore, &bucket).await;
seed_recoverable_free_version(&disk_paths, &bucket, object, None, None).await;
let metadata_path = disk_paths[0].join(&bucket).join(object).join(STORAGE_FORMAT_FILE);
let metadata_before = fs::read(&metadata_path).await.expect("seeded metadata should be readable");
let list_started = Arc::new(tokio::sync::Notify::new());
let list_wait = set_recovery_bucket_list_wait_hook(Arc::clone(&list_started));
let walk_calls = Arc::new(AtomicUsize::new(0));
let counted_walks = Arc::clone(&walk_calls);
let walk_hook = set_recovery_walk_test_hook(move |_| {
counted_walks.fetch_add(1, Ordering::SeqCst);
None
});
let mut schedule = super::TierFreeVersionRecoverySchedule::default();
schedule.record_success(&free_version_recovery_stats(1, 0, true));
let stats = tokio::select! {
biased;
_ = list_started.notified() => panic!("empty tier configuration must not list buckets"),
result = super::recover_configured_tier_free_versions(
Arc::clone(&ecstore),
schedule.bucket_marker.clone(),
schedule.object_marker.clone(),
CancellationToken::new(),
) => result.expect("empty tier configuration should be an idle recovery result"),
};
assert_eq!(
stats,
FreeVersionRecoveryStats {
scanned: 0,
enqueued: 0,
failed: 0,
next_bucket_marker: None,
next_object_marker: None,
scanned_entries: 0,
buckets_scanned: 0,
truncated: false,
}
);
schedule.record_success(&stats);
assert!(schedule.bucket_marker.is_none());
assert!(schedule.object_marker.is_none());
assert!(!schedule.follow_up_sweep);
assert_eq!(walk_calls.load(Ordering::SeqCst), 0);
assert!(matches!(
recovery_rx.lock().await.try_recv(),
Err(tokio::sync::mpsc::error::TryRecvError::Empty)
));
assert_eq!(
fs::read(&metadata_path).await.expect("paused recovery must retain metadata"),
metadata_before
);
drop(list_wait);
drop(walk_hook);
remove_seeded_free_version(&disk_paths, &bucket, object).await;
ecstore
.delete_bucket(&bucket, &DeleteBucketOptions::default())
.await
.expect("test bucket should be removed");
manager.write().await.tiers = saved_tiers;
reset_runtime_expiry_state(&ecstore).await;
}
#[tokio::test]
#[serial]
async fn tier_free_version_recovery_tier_snapshot_wait_is_cancellable() {
let (_disk_paths, ecstore) = setup_test_env().await;
let manager = ecstore.tier_config_mgr();
let _write_guard = manager.write().await;
let cancel = CancellationToken::new();
let recovery = super::recover_configured_tier_free_versions(ecstore, None, None, cancel.clone());
tokio::pin!(recovery);
assert!(
futures::poll!(&mut recovery).is_pending(),
"recovery should wait for the tier manager lock"
);
cancel.cancel();
let err = recovery
.await
.expect_err("cancellation must not wait for the tier manager writer");
assert!(matches!(err, crate::error::Error::Io(ref err) if err.kind() == std::io::ErrorKind::Interrupted));
}
#[tokio::test]
#[serial]
async fn tier_free_version_recovery_loop_observes_tier_configuration_changes() {
let automatic_recovery_started = super::TIER_FREE_VERSION_RECOVERY_STARTED.get().copied();
let (disk_paths, ecstore) = setup_test_env().await;
assert_eq!(
super::TIER_FREE_VERSION_RECOVERY_STARTED.get().copied(),
automatic_recovery_started,
"fixture must not start an unowned automatic recovery loop"
);
let manager = ecstore.tier_config_mgr();
let saved_tiers = std::mem::take(&mut manager.write().await.tiers);
let runtime_state = install_unconsumed_runtime_expiry_worker(&ecstore, 1).await;
let recovery_rx = Arc::clone(&runtime_state.read().await.tasks_rx[0]);
let bucket = format!("recovery-dynamic-tier-{}", Uuid::new_v4());
let object = "free-version";
create_test_bucket(&ecstore, &bucket).await;
seed_recoverable_free_version(&disk_paths, &bucket, object, None, None).await;
let page_permits = Arc::new(tokio::sync::Semaphore::new(1));
let worker_permits = Arc::clone(&page_permits);
let (page_tx, mut page_rx) = tokio::sync::mpsc::unbounded_channel();
let cancel = CancellationToken::new();
let loop_cancel = cancel.clone();
let recovery_store = Arc::clone(&ecstore);
let worker = tokio::spawn(async move {
run_tier_free_version_recovery_loop(
loop_cancel,
runtime_state,
std::convert::identity,
move |bucket, object, cancel| {
let api = Arc::clone(&recovery_store);
let page_tx = page_tx.clone();
let page_permits = Arc::clone(&worker_permits);
async move {
// Advance the scheduler clock only while real disk I/O is
// parked. Otherwise its timeout can win while the blocking
// filesystem operation is still running on wall-clock time.
let permit = tokio::select! {
_ = cancel.cancelled() => return Err(crate::error::Error::other("test recovery canceled")),
permit = page_permits.acquire() => permit.expect("test controls page admission"),
};
permit.forget();
let stats =
super::recover_configured_tier_free_versions(api, bucket.clone(), object.clone(), cancel).await?;
page_tx
.send((bucket, object, stats.clone()))
.expect("test should observe each recovery result");
Ok(stats)
}
},
)
.await;
});
let (_, _, idle) = page_rx
.recv()
.await
.expect("worker should check the initially empty configuration");
assert_eq!(idle.buckets_scanned, 0);
assert!(matches!(
recovery_rx.lock().await.try_recv(),
Err(tokio::sync::mpsc::error::TryRecvError::Empty)
));
let tier = format!("RECOVERY{}", Uuid::new_v4().simple()).to_uppercase();
crate::services::tier::test_util::register_mock_tier(&manager, &tier).await;
tokio::time::pause();
tokio::task::yield_now().await;
tokio::time::advance(TIER_FREE_VERSION_RECOVERY_BASE_INTERVAL).await;
tokio::time::resume();
page_permits.add_permits(1);
let (bucket_marker, object_marker, active) = tokio::time::timeout(StdDuration::from_secs(30), page_rx.recv())
.await
.expect("new tier should enable the next recovery page")
.expect("worker should remain running");
assert!(bucket_marker.is_none());
assert!(object_marker.is_none());
assert_eq!(active.enqueued, 1, "recovery page: {active:?}");
let task = recovery_rx
.lock()
.await
.try_recv()
.expect("new tier should enable discovery")
.expect("queue item should exist");
let recovered = task
.as_any()
.downcast_ref::<FreeVersionTask>()
.expect("seeded free version should be queued");
assert_eq!(recovered.0.bucket, bucket);
assert_eq!(recovered.0.name, object);
// Simulate a historical or temporarily missing configuration without
// bypassing the admin removal path's persisted-reference proof.
let configured_tiers = std::mem::take(&mut manager.write().await.tiers);
tokio::time::pause();
tokio::task::yield_now().await;
tokio::time::advance(TIER_FREE_VERSION_RECOVERY_BASE_INTERVAL).await;
tokio::time::resume();
page_permits.add_permits(1);
let (_, _, idle_again) = tokio::time::timeout(StdDuration::from_secs(30), page_rx.recv())
.await
.expect("empty configuration should still produce an idle check")
.expect("worker should remain running");
assert_eq!(idle_again.buckets_scanned, 0);
assert_eq!(idle_again.enqueued, 0);
assert!(matches!(
recovery_rx.lock().await.try_recv(),
Err(tokio::sync::mpsc::error::TryRecvError::Empty)
));
cancel.cancel();
worker.await.expect("worker should exit on cancellation");
manager.write().await.tiers = configured_tiers;
manager
.write()
.await
.remove(&tier, true)
.await
.expect("mock tier should be removed");
manager.write().await.tiers = saved_tiers;
remove_seeded_free_version(&disk_paths, &bucket, object).await;
ecstore
.delete_bucket(&bucket, &DeleteBucketOptions::default())
.await
.expect("test bucket should be removed");
reset_runtime_expiry_state(&ecstore).await;
}
#[tokio::test]
#[serial]
async fn tier_free_version_recovery_production_entrypoint_enqueues_seeded_item() {
let (disk_paths, ecstore) = setup_test_env().await;
let tier = format!("RECOVERY{}", Uuid::new_v4().simple()).to_uppercase();
crate::services::tier::test_util::register_mock_tier(&ecstore.tier_config_mgr(), &tier).await;
let runtime_state = install_unconsumed_runtime_expiry_worker(&ecstore, 1).await;
let recovery_rx = {
let state = runtime_state.read().await;
@@ -14737,6 +15119,13 @@ mod tests {
.await
.expect_err("aborted production recovery task should report cancellation");
assert!(join_error.is_cancelled());
ecstore
.tier_config_mgr()
.write()
.await
.remove(&tier, true)
.await
.expect("mock tier should be removed");
remove_seeded_free_version(&disk_paths, &bucket, object).await;
ecstore
.delete_bucket(&bucket, &DeleteBucketOptions::default())
@@ -63,7 +63,14 @@ pub(crate) async fn get_expiry_configs_for_options(
bucket: &str,
opts: &crate::object_api::ObjectOptions,
) -> Result<LifecycleExpiryConfigs> {
let bucket_incarnation_id = metadata_sys::get_bucket_incarnation_id_for_options_in(&api.ctx, bucket, opts).await?;
let bucket_incarnation_id = match opts
.object_lock_config_snapshot
.as_ref()
.and_then(|snapshot| snapshot.validated_bucket_incarnation_for(api.id, bucket, opts.expected_bucket_incarnation_id))
{
Some(incarnation_id) => incarnation_id,
None => metadata_sys::get_bucket_incarnation_id_for_options_in(&api.ctx, bucket, opts).await?,
};
let metadata = get_authoritative_metadata(api, bucket, bucket_incarnation_id).await?;
let table_bucket_enabled = metadata.table_bucket_enabled();
@@ -174,5 +181,36 @@ mod tests {
.unwrap()
);
assert!(get_expiry_configs(&store_b, bucket).await.unwrap().lifecycle.is_none());
let snapshot = store_a.object_lock_config_snapshot(bucket).await.unwrap();
assert_eq!(
snapshot.validated_bucket_incarnation_for(store_a.id, bucket, Some(configs.bucket_incarnation_id)),
Some(configs.bucket_incarnation_id)
);
assert_eq!(
snapshot.validated_bucket_incarnation_for(store_a.id, bucket, Some(uuid::Uuid::new_v4())),
None
);
assert_eq!(snapshot.validated_bucket_incarnation_for(store_b.id, bucket, None), None);
let opts = crate::object_api::ObjectOptions {
object_lock_config_snapshot: Some(snapshot.clone()),
expected_bucket_incarnation_id: Some(configs.bucket_incarnation_id),
..Default::default()
};
assert_eq!(
get_expiry_configs_for_options(&store_a, bucket, &opts)
.await
.unwrap()
.bucket_incarnation_id,
configs.bucket_incarnation_id
);
let unguarded = crate::object_api::ObjectLockConfigSnapshot::for_store_bucket(
store_a.id,
bucket,
configs.bucket_incarnation_id,
time::OffsetDateTime::UNIX_EPOCH,
metadata_sys::ObjectLockConfigState::ConfirmedAbsent,
);
assert_eq!(unguarded.validated_bucket_incarnation_for(store_a.id, bucket, None), None);
}
}
+212 -18
View File
@@ -437,6 +437,8 @@ pub struct BucketMetadata {
pub created: OffsetDateTime,
pub lock_enabled: bool, // While marked as unused, it may need to be retained
pub bucket_incarnation_id: Uuid,
pub artifact_base_prefix: Option<String>,
pub artifact_backup_retained: bool,
pub(crate) bucket_incarnation_sidecar: bool,
pub(crate) bucket_creation_committed: bool,
pub policy_config_json: Vec<u8>,
@@ -512,6 +514,8 @@ impl Default for BucketMetadata {
created: OffsetDateTime::UNIX_EPOCH,
lock_enabled: Default::default(),
bucket_incarnation_id: Uuid::nil(),
artifact_base_prefix: None,
artifact_backup_retained: false,
bucket_incarnation_sidecar: false,
bucket_creation_committed: false,
policy_config_json: Default::default(),
@@ -607,7 +611,7 @@ impl BucketMetadata {
/// legacy migration path, which mints the sidecar first; it must not be
/// rewritten by this migration.
pub(crate) fn needs_bucket_creation_commit(&self) -> bool {
self.lock_enabled
(self.lock_enabled || self.artifact_base_prefix.is_some())
&& !self.bucket_creation_committed
&& self.bucket_incarnation_sidecar
&& !self.bucket_incarnation_id.is_nil()
@@ -704,6 +708,22 @@ impl BucketMetadata {
self.bucket_incarnation_id =
Uuid::from_slice(&bytes).map_err(|err| Error::other(format!("invalid BucketIncarnationID: {err}")))?;
}
"ArtifactBackupRetained" => {
if self.artifact_backup_retained || !rmp::decode::read_bool(rd)? {
return Err(Error::other("invalid artifact backup retention marker"));
}
self.artifact_backup_retained = true;
}
"ArtifactBasePrefix" => {
if self.artifact_base_prefix.is_some() {
return Err(Error::other("duplicate artifact namespace"));
}
let prefix = read_msgp_str(rd)?;
if !crate::storage_api_contracts::bucket::valid_artifact_base_prefix(&prefix) {
return Err(Error::other("invalid artifact namespace"));
}
self.artifact_base_prefix = Some(prefix);
}
"PolicyConfigJSON" | "PolicyConfigJson" => self.policy_config_json = read_msgp_bin(rd)?,
"NotificationConfigXML" | "NotificationConfigXml" => self.notification_config_xml = read_msgp_bin(rd)?,
"LifecycleConfigXML" | "LifecycleConfigXml" => self.lifecycle_config_xml = read_msgp_bin(rd)?,
@@ -766,7 +786,7 @@ impl BucketMetadata {
/// Encode to msgp bytes. Field order follows MinIO BucketMetadata for compatibility.
pub fn encode_to<W: Write>(&self, wr: &mut W) -> Result<()> {
// Map size: MinIO fields (25) + RustFS extensions (22)
let map_len: u32 = 47;
let map_len: u32 = 47 + u32::from(self.artifact_base_prefix.is_some()) + u32::from(self.artifact_backup_retained);
rmp::encode::write_map_len(wr, map_len)?;
// MinIO field order (same as Go struct)
@@ -780,6 +800,15 @@ impl BucketMetadata {
rmp::encode::write_bool(wr, self.lock_enabled)?;
write_bin_field(wr, "BucketIncarnationID", self.bucket_incarnation_id.as_bytes())?;
if let Some(prefix) = &self.artifact_base_prefix {
rmp::encode::write_str(wr, "ArtifactBasePrefix")?;
rmp::encode::write_str(wr, prefix)?;
}
if self.artifact_backup_retained {
rmp::encode::write_str(wr, "ArtifactBackupRetained")?;
rmp::encode::write_bool(wr, true)?;
}
write_bin_field(wr, "PolicyConfigJSON", &self.policy_config_json)?;
write_bin_field(wr, "NotificationConfigXML", &self.notification_config_xml)?;
@@ -880,13 +909,28 @@ impl BucketMetadata {
}
match version {
BUCKET_METADATA_VERSION => {}
BUCKET_METADATA_VERSION | 2 => {}
_ => return Err(Error::other("read_bucket_metadata: version invalid")),
}
Ok(())
}
/// Version two is reserved for permanent artifact backup retention. Older
/// readers reject its header instead of skipping the protection field.
pub(crate) fn decode_file(bytes: &[u8]) -> Result<Self> {
Self::check_header(bytes)?;
let metadata = Self::unmarshal(&bytes[4..])?;
let retained_header = LittleEndian::read_u16(&bytes[2..4]) == 2;
if retained_header != metadata.artifact_backup_retained {
return Err(Error::other("artifact backup retention header mismatch"));
}
if metadata.artifact_backup_retained {
metadata.validate_artifact_configuration()?;
}
Ok(metadata)
}
pub(crate) fn default_timestamps(&mut self) {
if self.policy_config_updated_at == OffsetDateTime::UNIX_EPOCH {
self.policy_config_updated_at = self.created
@@ -1025,6 +1069,9 @@ impl BucketMetadata {
}
Some(config)
};
if self.artifact_base_prefix.is_some() && !config.as_ref().is_some_and(artifact_versioning_enabled) {
return Err(Error::other("artifact bucket versioning must remain enabled"));
}
self.versioning_config_xml = data;
self.versioning_config = config;
self.versioning_config_updated_at = updated;
@@ -1127,13 +1174,34 @@ impl BucketMetadata {
&mut self,
store: std::sync::Arc<crate::store::ECStore>,
write_completion: WriteCompletion,
) -> Result<()> {
let opts = crate::object_api::ObjectOptions {
max_parity: true,
write_completion,
..Default::default()
};
self.save_with_store_options(store, &opts).await
}
pub(crate) async fn save_with_store_options(
&mut self,
store: Arc<ECStore>,
opts: &crate::object_api::ObjectOptions,
) -> Result<()> {
self.parse_all_configs()?;
self.validate_artifact_update(store.clone()).await?;
let mut buf: Vec<u8> = vec![0; 4];
LittleEndian::write_u16(&mut buf[0..2], BUCKET_METADATA_FORMAT);
LittleEndian::write_u16(&mut buf[2..4], BUCKET_METADATA_VERSION);
LittleEndian::write_u16(
&mut buf[2..4],
if self.artifact_backup_retained {
2
} else {
BUCKET_METADATA_VERSION
},
);
let data = self
.marshal_msg()
@@ -1141,21 +1209,52 @@ impl BucketMetadata {
buf.extend_from_slice(&data);
save_config_with_opts(
store,
self.save_file_path().as_str(),
buf,
&crate::object_api::ObjectOptions {
max_parity: true,
write_completion,
..Default::default()
},
)
.await?;
save_config_with_opts(store, self.save_file_path().as_str(), buf, opts).await?;
Ok(())
}
fn validate_artifact_configuration(&self) -> Result<()> {
if self.artifact_backup_retained && (self.artifact_base_prefix.is_none() || !self.bucket_creation_committed) {
return Err(Error::other("artifact backup retention requires a committed managed bucket"));
}
if let Some(prefix) = &self.artifact_base_prefix {
if !crate::storage_api_contracts::bucket::valid_artifact_base_prefix(prefix) || self.bucket_incarnation_id.is_nil() {
return Err(Error::other("invalid artifact bucket identity"));
}
let versioning = deserialize::<VersioningConfiguration>(&self.versioning_config_xml)?;
if !artifact_versioning_enabled(&versioning) {
return Err(Error::other("artifact bucket versioning must remain enabled"));
}
}
Ok(())
}
/// Full metadata replacement must not change a generation's creation identity.
pub(crate) async fn validate_artifact_update(&self, store: Arc<ECStore>) -> Result<()> {
self.validate_artifact_configuration()?;
// An existing empty/corrupt metadata object is not permission to create an identity.
match read_config_preserve_empty(store, &self.save_file_path()).await {
Ok(data) => {
let previous = Self::decode_file(&data)?;
previous.validate_artifact_configuration()?;
if previous.artifact_backup_retained && !self.artifact_backup_retained {
return Err(Error::other("artifact backup retention cannot be removed"));
}
// Deletion removes this record before recreation. A caller-supplied
// incarnation cannot turn an existing bucket into a new generation.
if previous.artifact_base_prefix != self.artifact_base_prefix
|| (previous.artifact_base_prefix.is_some() && previous.bucket_incarnation_id != self.bucket_incarnation_id)
{
return Err(Error::other("artifact bucket identity is immutable"));
}
}
Err(Error::ConfigNotFound) => {}
Err(error) => return Err(error),
}
Ok(())
}
fn parse_policy_config(&mut self) -> Result<()> {
if !self.policy_config_json.is_empty() {
self.policy_config = Some(serde_json::from_slice(&self.policy_config_json)?);
@@ -1199,6 +1298,7 @@ impl BucketMetadata {
/// `rustfs_bucket_metadata_parse_failed_total`, reflected in
/// `rustfs_bucket_metadata_unparsable_current`, and logged at error level.
pub(super) fn parse_all_configs(&mut self) -> Result<()> {
self.validate_artifact_configuration()?;
if let Err(e) = self.parse_policy_config() {
tracing::warn!(
event = "bucket_metadata_parse_failed",
@@ -1464,6 +1564,10 @@ impl BucketMetadata {
}
}
fn artifact_versioning_enabled(config: &VersioningConfiguration) -> bool {
config.enabled() && !config.exclude_folders.unwrap_or(false) && config.excluded_prefixes.as_ref().is_none_or(Vec::is_empty)
}
pub(crate) async fn load_bucket_incarnation(api: Arc<ECStore>, bucket: &str) -> Result<Option<Uuid>> {
let path = format!("{BUCKET_META_PREFIX}/{bucket}/{BUCKET_INCARNATION_FILE}");
let data = match read_config_preserve_empty(api, &path).await {
@@ -1560,9 +1664,7 @@ async fn read_bucket_metadata(api: Arc<ECStore>, bucket: &str) -> Result<BucketM
let data = read_config(api, &file_path).await?;
BucketMetadata::check_header(&data)?;
let bm = BucketMetadata::unmarshal(&data[4..])?;
let bm = BucketMetadata::decode_file(&data)?;
Ok(bm)
}
@@ -1586,6 +1688,98 @@ where
mod test {
use super::*;
#[test]
fn artifact_metadata_roundtrip_and_corrupt_prefix_rejection() {
let mut metadata = BucketMetadata::new("artifacts");
metadata.artifact_base_prefix = Some("images/".to_owned());
let encoded = metadata.marshal_msg().expect("encode artifact metadata");
let decoded = BucketMetadata::unmarshal(&encoded).expect("decode artifact metadata");
assert_eq!(decoded.artifact_base_prefix, metadata.artifact_base_prefix, "ARTIFACT_METADATA_ROUNDTRIP");
assert_eq!(decoded.bucket_incarnation_id, metadata.bucket_incarnation_id);
let legacy = BucketMetadata::new("ordinary")
.marshal_msg()
.expect("encode ordinary metadata");
assert!(
BucketMetadata::unmarshal(&legacy)
.expect("decode ordinary metadata")
.artifact_base_prefix
.is_none()
);
for prefix in ["", "../", "outputs", "a b/"] {
let mut invalid = Vec::new();
rmp::encode::write_map_len(&mut invalid, 1).expect("encode map");
rmp::encode::write_str(&mut invalid, "ArtifactBasePrefix").expect("encode field");
rmp::encode::write_str(&mut invalid, prefix).expect("encode invalid prefix");
assert!(BucketMetadata::unmarshal(&invalid).is_err(), "ARTIFACT_CORRUPT_PREFIX");
}
}
#[test]
fn artifact_backup_retention_marker_rejects_false_and_duplicates() {
for count in [1, 2] {
let mut bytes = Vec::new();
rmp::encode::write_map_len(&mut bytes, count).unwrap();
for _ in 0..count {
rmp::encode::write_str(&mut bytes, "ArtifactBackupRetained").unwrap();
rmp::encode::write_bool(&mut bytes, count == 2).unwrap();
}
assert!(BucketMetadata::unmarshal(&bytes).is_err(), "O02P3_INVALID_RETENTION_MARKER");
}
}
#[test]
fn artifact_backup_retention_requires_matching_header_and_identity() {
let mut metadata = BucketMetadata::new("retained");
metadata.bucket_incarnation_id = Uuid::new_v4();
metadata.bucket_creation_committed = true;
metadata.artifact_base_prefix = Some("outputs/".to_owned());
metadata.versioning_config_xml = b"<VersioningConfiguration><Status>Enabled</Status></VersioningConfiguration>".to_vec();
let file = |metadata: &BucketMetadata, version: u16| {
let mut bytes = Vec::new();
bytes.extend_from_slice(&BUCKET_METADATA_FORMAT.to_le_bytes());
bytes.extend_from_slice(&version.to_le_bytes());
bytes.extend_from_slice(&metadata.marshal_msg().unwrap());
bytes
};
assert!(BucketMetadata::decode_file(&file(&metadata, 1)).is_ok(), "O02P3_ORDINARY_V1_COMPATIBLE");
assert!(BucketMetadata::decode_file(&file(&metadata, 2)).is_err(), "O02P3_MARKER_REQUIRED");
metadata.artifact_backup_retained = true;
assert!(
BucketMetadata::decode_file(&file(&metadata, 1)).is_err(),
"O02P3_PROTECTION_CANNOT_DOWNGRADE_HEADER"
);
assert!(
BucketMetadata::decode_file(&file(&metadata, 2))
.unwrap()
.artifact_backup_retained,
"O02P3_PROTECTED_V2_ROUNDTRIP"
);
metadata.artifact_base_prefix = None;
assert!(
BucketMetadata::decode_file(&file(&metadata, 2)).is_err(),
"O02P3_PROTECTION_REQUIRES_MANAGED_BUCKET"
);
}
#[test]
fn artifact_versioning_cannot_be_disabled_or_excluded() {
let mut metadata = BucketMetadata::new("artifacts");
metadata.artifact_base_prefix = Some("images/".to_owned());
for xml in [
"",
"<VersioningConfiguration><Status>Suspended</Status></VersioningConfiguration>",
"<VersioningConfiguration><Status>Enabled</Status><ExcludeFolders>true</ExcludeFolders></VersioningConfiguration>",
"<VersioningConfiguration><Status>Enabled</Status><ExcludedPrefixes><Prefix>images/</Prefix></ExcludedPrefixes></VersioningConfiguration>",
] {
assert!(
metadata
.update_config(BUCKET_VERSIONING_CONFIG, xml.as_bytes().to_vec())
.is_err(),
"ARTIFACT_VERSIONING_REQUIRED"
);
}
}
/// rustfs/backlog#1734: `StorageError::clone` rebuilds I/O errors from
/// their text. The unreadable-config refusal must stay typed across that
/// clone, or a cloned error stops mapping to its retryable S3 response.
+40 -18
View File
@@ -293,8 +293,10 @@ pub(super) fn get_bucket_metadata_sys() -> Result<Arc<RwLock<BucketMetadataSys>>
pub async fn set_bucket_metadata(bucket: String, bm: BucketMetadata) -> Result<()> {
let sys = get_bucket_metadata_sys()?;
let lock = sys.write().await;
lock.set(bucket, Arc::new(bm)).await;
Ok(())
if bucket != bm.name {
return Err(Error::other("bucket metadata identity mismatch"));
}
lock.set_validated(bm).await
}
pub async fn reload_bucket_metadata(api: Arc<ECStore>, bucket: &str) -> Result<()> {
@@ -585,6 +587,15 @@ pub(crate) async fn get_cached_bucket_incarnation_id_in(
sys.get_bucket_incarnation_id(bucket).await
}
pub(crate) async fn get_bucket_incarnation_metadata_in(
ctx: &crate::runtime::instance::InstanceContext,
bucket: &str,
) -> Result<Arc<BucketMetadata>> {
let sys = bucket_metadata_sys_of(ctx)?;
let sys = sys.read().await.clone();
sys.get_bucket_incarnation_metadata(bucket).await
}
pub(crate) async fn set_bucket_metadata_in(ctx: &crate::runtime::instance::InstanceContext, bm: BucketMetadata) -> Result<()> {
let sys = bucket_metadata_sys_of(ctx)?;
let lock = sys.read().await;
@@ -2132,7 +2143,16 @@ impl BucketMetadataSys {
}
}
pub async fn set(&self, bucket: String, bm: Arc<BucketMetadata>) {
pub(crate) async fn set_validated(&self, mut bm: BucketMetadata) -> Result<()> {
if bm.artifact_base_prefix.is_some() {
bm.parse_all_configs()?;
}
bm.validate_artifact_update(self.object_store()).await?;
self.set(bm.name.clone(), Arc::new(bm)).await;
Ok(())
}
async fn set(&self, bucket: String, bm: Arc<BucketMetadata>) {
if !is_meta_bucketname(&bucket) {
let publish_lock = self.metadata_publish_lock(&bucket);
let _publish_guard = publish_lock.lock().await;
@@ -2147,6 +2167,11 @@ impl BucketMetadataSys {
}
}
#[cfg(any(test, feature = "test-util"))]
pub async fn set_for_test(&self, bucket: String, bm: Arc<BucketMetadata>) {
self.set(bucket, bm).await;
}
/// Remove a bucket's cached metadata from the in-memory map.
///
/// Returns `true` if an entry was present. Reserved meta buckets are ignored.
@@ -2651,12 +2676,16 @@ impl BucketMetadataSys {
}
async fn get_bucket_incarnation_id(&self, bucket: &str) -> Result<Uuid> {
Ok(self.get_bucket_incarnation_metadata(bucket).await?.bucket_incarnation_id)
}
async fn get_bucket_incarnation_metadata(&self, bucket: &str) -> Result<Arc<BucketMetadata>> {
let authority = self.get_metadata_authority(bucket).await?;
if let BucketMetadataAuthority::Authoritative(metadata) = &authority
&& metadata.bucket_incarnation_sidecar
&& !metadata.bucket_incarnation_id.is_nil()
{
return Ok(metadata.bucket_incarnation_id);
return Ok(Arc::clone(metadata));
}
match authority {
@@ -2665,7 +2694,7 @@ impl BucketMetadataSys {
BucketMetadataAuthority::Authoritative(metadata)
if metadata.bucket_incarnation_sidecar && !metadata.bucket_incarnation_id.is_nil() =>
{
Ok(metadata.bucket_incarnation_id)
Ok(metadata)
}
BucketMetadataAuthority::Authoritative(_) | BucketMetadataAuthority::Fabricated => {
Err(Error::other(format!("bucket incarnation metadata is not authoritative: {bucket}")))
@@ -2993,7 +3022,8 @@ impl BucketMetadataSys {
load_bucket_metadata_parse_with_presence(self.object_store(), bucket, true),
)
.await?;
if persisted && metadata.lock_enabled && !metadata.bucket_creation_committed {
if persisted && (metadata.lock_enabled || metadata.artifact_base_prefix.is_some()) && !metadata.bucket_creation_committed
{
return Err(Error::ErasureWriteQuorum);
}
if persisted {
@@ -3186,17 +3216,9 @@ pub mod test_support {
use crate::store::init_local_disks_with_instance_ctx;
pub async fn isolated_store_over_temp_disks() -> (Vec<tempfile::TempDir>, Arc<ECStore>) {
isolated_store_over_temp_disks_with_count(4).await
}
pub async fn isolated_store_over_temp_disks_with_count(disk_count: usize) -> (Vec<tempfile::TempDir>, Arc<ECStore>) {
assert!(
matches!(disk_count, 4 | 16),
"isolated store fixture requires a registered logical set geometry"
);
let mut dirs = Vec::with_capacity(disk_count);
let mut endpoints = Vec::with_capacity(disk_count);
for disk_idx in 0..disk_count {
let mut dirs = Vec::with_capacity(4);
let mut endpoints = Vec::with_capacity(4);
for disk_idx in 0..4 {
let dir = tempfile::tempdir().expect("tempdir should be created");
let mut endpoint =
Endpoint::try_from(dir.path().to_str().expect("tempdir path should be utf8")).expect("endpoint should parse");
@@ -3209,7 +3231,7 @@ pub mod test_support {
let endpoint_pools = EndpointServerPools(vec![PoolEndpoints {
legacy: false,
set_count: 1,
drives_per_set: disk_count,
drives_per_set: 4,
endpoints: Endpoints::from(endpoints),
cmd_line: "metadata-sys-cache-test".to_string(),
platform: "test".to_string(),
+1
View File
@@ -14,6 +14,7 @@
// #730: bucket subsystems still contain staged ECStore migration code.
pub mod artifact;
pub mod bandwidth;
pub mod bucket_target_sys;
pub mod config_parse_mode;
+58 -27
View File
@@ -19,7 +19,7 @@ use super::{
use crate::bucket::metadata_sys::{
BucketMetadataSys, update, update_if_incarnation, update_quota_with_sys_if_incarnation_and_revision,
};
use crate::data_usage::get_bucket_usage_memory;
use crate::data_usage::{get_bucket_usage_memory, reload_unknown_bucket_usage};
use crate::error::StorageError;
use rustfs_config::QUOTA_CONFIG_FILE;
use rustfs_scanner_metrics::metrics::Metric;
@@ -235,6 +235,8 @@ impl QuotaChecker {
Ok(updated_at)
}
/// Return quota configuration with optional scanner usage, independently
/// of the usage requirements enforced by write admission.
pub async fn get_quota_stats(&self, bucket: &str) -> Result<(BucketQuota, Option<u64>), QuotaError> {
// If bucket doesn't exist, return ConfigNotFound error
if !self.bucket_exists(bucket).await {
@@ -244,35 +246,28 @@ impl QuotaChecker {
}
let quota = self.get_quota_config(bucket).await?;
let current_usage = if quota.uses_durable_reservations() {
self.report_only_usage(bucket).await
} else {
Some(self.get_real_time_usage(bucket).await?)
};
let current_usage = self.report_only_usage(bucket).await;
Ok((quota, current_usage))
}
/// Scanner usage for a bucket the reservation ledger admits is reporting
/// input only, so a failed lookup degrades to "unknown" and is counted
/// instead of becoming an error the write path would fail on.
/// Best-effort usage for statistics and durable admission reports.
/// Missing scanner coverage is unknown, not an error or a zero-sized bucket.
async fn report_only_usage(&self, bucket: &str) -> Option<u64> {
match self.get_real_time_usage(bucket).await {
Ok(usage) => Some(usage),
Err(err) => {
metrics::counter!(METRIC_QUOTA_USAGE_REPORT_UNAVAILABLE_TOTAL).increment(1);
debug!(
event = EVENT_QUOTA_USAGE_REPORT,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_QUOTA,
state = "usage_unknown",
bucket = %bucket,
error = %err,
"durable quota usage report degraded"
);
None
}
// The plain lookup: a report must not wait for an early snapshot reload.
let usage = get_bucket_usage_memory(bucket).await;
if usage.is_none() {
metrics::counter!(METRIC_QUOTA_USAGE_REPORT_UNAVAILABLE_TOTAL).increment(1);
debug!(
event = EVENT_QUOTA_USAGE_REPORT,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_QUOTA,
state = "usage_unknown",
bucket = %bucket,
"quota usage report degraded"
);
}
usage
}
/// Re-save `bucket`'s quota under the sharded durable reservation
@@ -390,8 +385,22 @@ impl QuotaChecker {
self.metadata_sys.read().await.get(bucket).await.is_ok()
}
/// Usage for a caller that fails closed without it. In a distributed
/// deployment a bucket the cached usage snapshot does not cover yet may
/// reload that snapshot before the degraded fallback is consulted.
pub async fn get_real_time_usage(&self, bucket: &str) -> Result<u64, QuotaError> {
if let Some(usage) = get_bucket_usage_memory(bucket).await {
let usage = match get_bucket_usage_memory(bucket).await {
Some(usage) => Some(usage),
// Boxed like the degraded fallback below: the snapshot reload
// would otherwise grow every write's state machine, and it only
// runs for a bucket the cached snapshot does not cover.
None => Box::pin(reload_unknown_bucket_usage(bucket)).await,
};
self.usage_or_degraded_baseline(bucket, usage).await
}
async fn usage_or_degraded_baseline(&self, bucket: &str, usage: Option<u64>) -> Result<u64, QuotaError> {
if let Some(usage) = usage {
return Ok(usage);
}
@@ -528,6 +537,8 @@ mod tests {
use serial_test::serial;
use uuid::Uuid;
mod reporting_retirement;
/// Regression (PR #5307 / s3-tests `test_100_continue_error_retry`): a
/// bucket with no persisted metadata has no quota, so the admission check
/// must pass and let the request reach its NoSuchBucket answer. With the
@@ -695,6 +706,11 @@ mod tests {
.await
.expect("stats for a ledger-admitted bucket degrade to an absent usage");
assert_eq!(usage, None);
assert_eq!(
crate::data_usage::reload_unknown_bucket_usage_calls_for_test(&bucket),
0,
"reporting a ledger-admitted bucket's usage must not wait for an early snapshot reload"
);
// Negative control: the ledger, not the scanner, still refuses an
// over-limit write with the same error.
@@ -718,7 +734,22 @@ mod tests {
checker.check_quota(&legacy, QuotaOperation::PutObject, 512).await,
Err(QuotaError::UsageUnavailable { .. })
));
assert!(matches!(checker.get_quota_stats(&legacy).await, Err(QuotaError::UsageUnavailable { .. })));
assert_eq!(
crate::data_usage::reload_unknown_bucket_usage_calls_for_test(&legacy),
1,
"fail-closed admission takes the early-reload lookup"
);
let (_, usage) = checker
.get_quota_stats(&legacy)
.await
.expect("legacy quota statistics are reporting only");
assert_eq!(usage, None);
assert_eq!(crate::data_usage::reload_unknown_bucket_usage_calls_for_test(&legacy), 1);
assert_eq!(
crate::data_usage::reload_unknown_bucket_usage_calls_for_test(&bucket),
0,
"ledger-admitted writes never take the early-reload lookup"
);
crate::data_usage::prepare_bucket_usage_for_namespace_change(&bucket, None)
.await
@@ -864,7 +895,7 @@ mod tests {
.update_bucket_metadata_config(&bucket, QUOTA_CONFIG_FILE, legacy_quota_json(8192))
.await
.expect("newer quota save");
sys.read().await.set(bucket.clone(), stale).await;
sys.read().await.set_for_test(bucket.clone(), stale).await;
assert_eq!(
checker
.migrate_reservation_protocol(&bucket)
@@ -0,0 +1,330 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use super::*;
use crate::disk::DiskAPI as _;
use crate::object_api::{ObjectOptions, PutObjReader};
use crate::storage_api_contracts::object::ObjectIO as _;
use crate::test_metrics::CapturingRecorder;
use rustfs_data_usage::{BucketUsageInfo, DataUsageInfo};
use std::time::SystemTime;
async fn stop_reporting_fixture(store: &crate::store::ECStore) {
if let Some(cancel) = store.ctx.background_cancel_token() {
cancel.cancel();
}
for pool in &store.pools {
for set in &pool.disk_set {
let disks = set.disks.read().await.clone();
for disk in disks.iter().flatten() {
disk.close().await.expect("stop fixture disk monitoring");
}
}
}
}
async fn install_reporting_snapshot(
store: Arc<crate::store::ECStore>,
bucket: &str,
source: &str,
size: u64,
) -> Option<(String, Vec<u8>)> {
if source == "missing" {
return None;
}
let mut info = DataUsageInfo {
last_update: Some(SystemTime::now()),
scanner_epoch: Some(1),
scanner_cycle: Some(1),
buckets_count: 1,
..Default::default()
};
info.buckets_usage.insert(
bucket.to_owned(),
BucketUsageInfo {
size,
objects_count: 1,
versions_count: 1,
..Default::default()
},
);
info.bucket_sizes.insert(bucket.to_owned(), size);
info.calculate_totals();
let object = match source {
"legacy" => rustfs_data_usage::LEGACY_DATA_USAGE_OBJECT_NAME,
"incomplete" => rustfs_data_usage::DATA_USAGE_OBJECT_NAME,
"complete" => {
info.usage_snapshot_complete = true;
rustfs_data_usage::DATA_USAGE_OBJECT_NAME
}
"observed" => {
info.usage_snapshot_complete = true;
info.usage_snapshot_converged = Some(false);
info.usage_snapshot_authoritative_baseline = Some(DataUsageInfo::default().snapshot_identity());
rustfs_data_usage::DATA_USAGE_OBSERVED_OBJECT_NAME
}
_ => panic!("unknown scanner fixture source"),
};
let path = format!("{}/{object}", crate::disk::BUCKET_META_PREFIX);
let bytes = serde_json::to_vec(&info).expect("scanner fixture encoding");
crate::config::com::save_config(store, &path, bytes.clone())
.await
.expect("persist scanner fixture");
if source == "complete" {
crate::data_usage::replace_bucket_usage_memory_from_info(&info).await;
}
Some((path, bytes))
}
#[tokio::test(flavor = "current_thread")]
#[serial]
async fn durable_quota_reporting_retires_degraded_floors_without_changing_ledger_admission() {
let recorder = CapturingRecorder::default();
let _recorder = metrics::set_default_local_recorder(&recorder);
for protocol in [1, 2] {
for source in ["missing", "legacy", "incomplete", "observed", "complete"] {
let (_dirs, store, sys) = isolated_durable_quota_store().await;
let checker = QuotaChecker::new(sys);
let bucket = format!("quota-report-{protocol}-{}", Uuid::new_v4().simple());
let quota = if protocol == 1 {
BucketQuota::new(Some(4096))
} else {
BucketQuota::new_sharded(4096)
};
create_bucket_with_quota_json(&store, &bucket, serde_json::to_vec(&quota).expect("quota fixture encoding")).await;
let snapshot = install_reporting_snapshot(store.clone(), &bucket, source, 1_000_000).await;
crate::data_usage::invalidate_data_usage_snapshot_cache().await;
let unavailable_before = recorder.counter_value(METRIC_QUOTA_USAGE_REPORT_UNAVAILABLE_TOTAL, &[]);
let check = checker
.check_quota(&bucket, QuotaOperation::PutObject, 512)
.await
.expect("reporting cannot fail ledger admission");
let (_, usage) = checker.get_quota_stats(&bucket).await.expect("best-effort quota statistics");
let preview = checker
.check_quota_with_usage_reporting(&bucket, QuotaOperation::PutObject, 512, true)
.await
.expect("admin preview uses the same optional report");
// Exercise the real storage entry point before testing the new
// reporting semantics: the same ledger must admit and refuse
// these writes even when a scanner floor claims a million bytes.
let mut too_large = PutObjReader::from_vec(vec![0x5a; 8192]);
assert!(
matches!(
store
.put_object(&bucket, "too-large", &mut too_large, &ObjectOptions::default())
.await,
Err(StorageError::QuotaExceeded { .. })
),
"protocol={protocol}, source={source}"
);
let mut fits = PutObjReader::from_vec(vec![0x5a; 512]);
store
.put_object(&bucket, "fits", &mut fits, &ObjectOptions::default())
.await
.expect("ledger admits 512 bytes");
let mut fills = PutObjReader::from_vec(vec![0x5a; 3584]);
store
.put_object(&bucket, "fills", &mut fills, &ObjectOptions::default())
.await
.expect("ledger admits exactly the remaining quota");
let mut exceeds = PutObjReader::from_vec(vec![0x5a; 1]);
assert!(
matches!(
store
.put_object(&bucket, "exceeds", &mut exceeds, &ObjectOptions::default())
.await,
Err(StorageError::QuotaExceeded { .. })
),
"ledger cannot undercount protocol={protocol}, source={source}"
);
assert!(check.allowed && check.uses_durable_reservations);
let expected_usage = (source == "complete").then_some(1_000_000);
assert_eq!(
check.current_usage, expected_usage,
"protocol={protocol}, source={source}: a floor is not a current report"
);
assert_eq!(check.remaining, expected_usage.map(|_| 0));
assert_eq!(usage, expected_usage, "statistics use the same best-effort reporting contract");
assert_eq!(preview.current_usage, expected_usage);
assert_eq!(preview.remaining, expected_usage.map(|_| 0));
assert_eq!(preview.allowed, source != "complete", "unknown preview is advisory, not a reservation");
assert_eq!(
recorder.counter_value(METRIC_QUOTA_USAGE_REPORT_UNAVAILABLE_TOTAL, &[]) - unavailable_before,
if source == "complete" { 0 } else { 3 },
"each unknown check/statistics/preview report is counted"
);
assert_eq!(crate::data_usage::reload_unknown_bucket_usage_calls_for_test(&bucket), 0);
assert_eq!(persisted_quota(&store, &bucket).await.reservation_protocol, Some(protocol));
if let Some((path, original)) = snapshot {
assert_eq!(
crate::config::com::read_config(store.clone(), &path)
.await
.expect("retained scanner evidence"),
original
);
}
crate::data_usage::prepare_bucket_usage_for_namespace_change(&bucket, None)
.await
.expect("fixture cache cleanup");
crate::data_usage::invalidate_data_usage_snapshot_cache().await;
stop_reporting_fixture(&store).await;
}
}
}
#[tokio::test]
#[serial]
async fn snapshot_quota_admission_retains_degraded_floors_until_migration() {
for source in ["missing", "legacy", "incomplete", "observed"] {
let (_dirs, store, _) = isolated_durable_quota_store().await;
let bucket = format!("quota-compat-{}", Uuid::new_v4().simple());
create_bucket_with_quota_json(&store, &bucket, legacy_quota_json(4096)).await;
// An unregistered checker models a snapshot-protocol reader without
// starting the optional background migration during this assertion.
let checker = QuotaChecker::new(Arc::new(RwLock::new(BucketMetadataSys::new(store.clone()))));
install_reporting_snapshot(store.clone(), &bucket, source, 1000).await;
crate::data_usage::invalidate_data_usage_snapshot_cache().await;
let check = checker.check_quota(&bucket, QuotaOperation::PutObject, 512).await;
if source == "missing" {
assert!(matches!(check, Err(QuotaError::UsageUnavailable { .. })));
} else {
let check = check.expect("legacy admission retains its grounded compatibility fallback");
assert_eq!(check.current_usage, Some(1000));
assert!(check.allowed && !check.uses_durable_reservations);
assert!(
!checker
.check_quota(&bucket, QuotaOperation::PutObject, 4096)
.await
.expect("legacy quota decision")
.allowed
);
}
assert_eq!(persisted_quota(&store, &bucket).await.reservation_protocol, None);
crate::data_usage::prepare_bucket_usage_for_namespace_change(&bucket, None)
.await
.expect("fixture cache cleanup");
crate::data_usage::invalidate_data_usage_snapshot_cache().await;
stop_reporting_fixture(&store).await;
}
}
#[tokio::test(flavor = "current_thread")]
#[serial]
async fn quota_without_limit_keeps_the_zero_usage_io_fast_path() {
let (_dirs, store, sys) = isolated_durable_quota_store().await;
let bucket = format!("quota-unlimited-{}", Uuid::new_v4().simple());
create_bucket_with_quota_json(
&store,
&bucket,
serde_json::to_vec(&BucketQuota::new(None)).expect("unlimited quota encoding"),
)
.await;
let recorder = CapturingRecorder::default();
let _recorder = metrics::set_default_local_recorder(&recorder);
let check = QuotaChecker::new(sys)
.check_quota(&bucket, QuotaOperation::PutObject, u64::MAX)
.await
.expect("unlimited quota fast path");
assert!(check.allowed);
assert_eq!(check.current_usage, None);
assert_eq!(check.quota_limit, None);
assert_eq!(recorder.counter_value(METRIC_QUOTA_USAGE_REPORT_UNAVAILABLE_TOTAL, &[]), 0);
assert_eq!(crate::data_usage::reload_unknown_bucket_usage_calls_for_test(&bucket), 0);
stop_reporting_fixture(&store).await;
}
#[tokio::test]
#[serial]
async fn quota_statistics_remain_available_without_a_complete_scanner_snapshot() {
use crate::storage_api_contracts::bucket::{BucketOperations as _, MakeBucketOptions};
for configuration in ["unconfigured", "unlimited", "snapshot"] {
for source in ["missing", "legacy", "incomplete", "observed", "complete", "empty"] {
let (_dirs, store, _) = isolated_durable_quota_store().await;
let bucket = format!("quota-statistics-{}", Uuid::new_v4().simple());
match configuration {
"unconfigured" => store
.make_bucket(&bucket, &MakeBucketOptions::default())
.await
.expect("create bucket without a quota configuration"),
"unlimited" => {
create_bucket_with_quota_json(
&store,
&bucket,
serde_json::to_vec(&BucketQuota::new(None)).expect("unlimited quota encoding"),
)
.await;
}
"snapshot" => create_bucket_with_quota_json(&store, &bucket, legacy_quota_json(4096)).await,
_ => unreachable!(),
}
// An unregistered checker keeps legacy migration out of the
// assertion: reporting must work before a protocol upgrade.
let metadata_sys = BucketMetadataSys::new(store.clone());
let (metadata, _) = metadata_sys.get_config(&bucket).await.expect("load existing bucket metadata");
metadata_sys.set_for_test(bucket.clone(), metadata).await;
let checker = QuotaChecker::new(Arc::new(RwLock::new(metadata_sys)));
let (snapshot_source, size) = if source == "empty" { ("complete", 0) } else { (source, 1000) };
install_reporting_snapshot(store.clone(), &bucket, snapshot_source, size).await;
crate::data_usage::invalidate_data_usage_snapshot_cache().await;
let statistics = checker.get_quota_stats(&bucket).await;
let early_reloads = crate::data_usage::reload_unknown_bucket_usage_calls_for_test(&bucket);
let missing_bucket = format!("missing-{}", Uuid::new_v4().simple());
let missing_statistics = checker.get_quota_stats(&missing_bucket).await;
crate::data_usage::prepare_bucket_usage_for_namespace_change(&bucket, None)
.await
.expect("fixture cache cleanup");
crate::data_usage::invalidate_data_usage_snapshot_cache().await;
stop_reporting_fixture(&store).await;
let (quota, usage) = statistics.unwrap_or_else(|error| {
panic!("configuration={configuration}, source={source}: quota statistics must remain available: {error}")
});
assert_eq!(quota.quota, (configuration == "snapshot").then_some(4096));
assert_eq!(
usage,
(snapshot_source == "complete").then_some(size),
"configuration={configuration}, source={source}: only known usage is numeric"
);
assert_eq!(early_reloads, 0, "read-only statistics must not force an early snapshot reload");
assert!(
matches!(missing_statistics, Err(QuotaError::ConfigNotFound { bucket }) if bucket == missing_bucket),
"reporting must still reject a missing bucket"
);
}
}
}
#[tokio::test]
async fn quota_statistics_preserve_configuration_errors() {
let (_dirs, store, _) = isolated_durable_quota_store().await;
let bucket = format!("quota-invalid-{}", Uuid::new_v4().simple());
create_bucket_with_quota_json(&store, &bucket, legacy_quota_json(4096)).await;
let metadata_sys = BucketMetadataSys::new(store.clone());
let (mut metadata, _) = metadata_sys.get_config(&bucket).await.expect("load existing bucket metadata");
Arc::make_mut(&mut metadata).quota_config_json = b"{not-json".to_vec();
metadata_sys.set_for_test(bucket.clone(), metadata).await;
let checker = QuotaChecker::new(Arc::new(RwLock::new(metadata_sys)));
let statistics = checker.get_quota_stats(&bucket).await;
stop_reporting_fixture(&store).await;
assert!(
matches!(statistics, Err(QuotaError::InvalidConfig { .. })),
"unavailable reporting must not hide malformed quota configuration"
);
}
+80 -11
View File
@@ -595,7 +595,7 @@ async fn reserve_sharded(context: QuotaContext, old_size: u64, new_size: u64) ->
let allow_create = shard_may_be_created(&allocator, data.shard_index, bootstrap);
let shard_lock = store.new_ns_lock(RUSTFS_META_BUCKET, &data.shard_object).await?;
let shard_guard = Arc::new(shard_lock.get_write_lock(get_lock_acquire_timeout()).await?);
let mut shard = load_current_shard_locked(
let (mut shard, shard_created) = load_current_shard_locked(
Arc::clone(&store),
&data.shard_object,
bucket_incarnation,
@@ -640,7 +640,7 @@ async fn reserve_sharded(context: QuotaContext, old_size: u64, new_size: u64) ->
});
}
let shard_was_empty = shard.grants.is_empty() && shard.reservations.is_empty() && shard.accounted_usage == 0;
if shard_was_empty {
if shard_created || shard_was_empty {
fence_namespace_mutations(&store, RUSTFS_META_BUCKET, &data.shard_object, None).await?;
save_shard_locked(Arc::clone(&store), &data.shard_object, &shard, &shard_guard).await?;
}
@@ -1510,18 +1510,18 @@ async fn load_current_shard_locked(
shard_index: u16,
allow_create: bool,
allocator: &QuotaAllocatorLedger,
) -> Result<QuotaShardLedger> {
let mut shard = match load_shard_locked(Arc::clone(&store), object).await {
Ok(shard) if shard.matches(bucket_incarnation, quota_revision, shard_index) => shard,
) -> Result<(QuotaShardLedger, bool)> {
let (mut shard, created) = match load_shard_locked(Arc::clone(&store), object).await {
Ok(shard) if shard.matches(bucket_incarnation, quota_revision, shard_index) => (shard, false),
Ok(_) => return Err(StorageError::PartMissingOrCorrupt),
Err(StorageError::ConfigNotFound) if allow_create => {
QuotaShardLedger::new(bucket_incarnation, quota_revision, shard_index)
(QuotaShardLedger::new(bucket_incarnation, quota_revision, shard_index), true)
}
Err(StorageError::ConfigNotFound) => return Err(StorageError::PartMissingOrCorrupt),
Err(err) => return Err(err),
};
shard.adopt_grants(allocator)?;
Ok(shard)
Ok((shard, created))
}
async fn save_allocator_locked(
@@ -1646,7 +1646,7 @@ async fn reap_sharded_reservations(
.await?;
let shard_lock = store.new_ns_lock(RUSTFS_META_BUCKET, &data.shard_object).await?;
let shard_guard = shard_lock.get_write_lock(get_lock_acquire_timeout()).await?;
let shard = load_current_shard_locked(
let (shard, _) = load_current_shard_locked(
Arc::clone(&store),
&data.shard_object,
bucket_incarnation,
@@ -1709,7 +1709,7 @@ async fn reap_sharded_reservations(
.await?;
let shard_lock = store.new_ns_lock(RUSTFS_META_BUCKET, &data.shard_object).await?;
let shard_guard = Arc::new(shard_lock.get_write_lock(get_lock_acquire_timeout()).await?);
let mut shard = load_current_shard_locked(
let (mut shard, _) = load_current_shard_locked(
Arc::clone(&store),
&data.shard_object,
bucket_incarnation,
@@ -1774,7 +1774,7 @@ async fn mark_commit_started_sharded(data: &ShardedReservationData) -> Result<()
let allocator = load_allocator_locked(Arc::clone(&store), &data.allocator_object).await?;
let shard_lock = store.new_ns_lock(RUSTFS_META_BUCKET, &data.shard_object).await?;
let shard_guard = Arc::new(shard_lock.get_write_lock(get_lock_acquire_timeout()).await?);
let mut shard = load_current_shard_locked(
let (mut shard, _) = load_current_shard_locked(
Arc::clone(&store),
&data.shard_object,
data.bucket_incarnation,
@@ -1819,7 +1819,7 @@ async fn settle_sharded(data: &ShardedReservationData, committed: bool) -> Resul
let allocator = load_allocator_locked(Arc::clone(&store), &data.allocator_object).await?;
let shard_lock = store.new_ns_lock(RUSTFS_META_BUCKET, &data.shard_object).await?;
let shard_guard = Arc::new(shard_lock.get_write_lock(get_lock_acquire_timeout()).await?);
let mut shard = load_current_shard_locked(
let (mut shard, _) = load_current_shard_locked(
Arc::clone(&store),
&data.shard_object,
data.bucket_incarnation,
@@ -2459,7 +2459,10 @@ mod tests {
}
}
// Its writes read the process-wide usage snapshot cache through the
// degraded-baseline fallback, which the data-usage cache tests rewrite.
#[tokio::test]
#[serial]
async fn sharded_reservation_reloads_allocator_after_concurrent_refill() {
use crate::object_api::PutObjReader;
use crate::storage_api_contracts::bucket::{BucketOperations, MakeBucketOptions};
@@ -2532,4 +2535,70 @@ mod tests {
1
);
}
#[tokio::test]
#[serial]
async fn sharded_reservation_persists_a_populated_lazy_shard_before_refill() {
use crate::object_api::PutObjReader;
use crate::storage_api_contracts::object::ObjectIO;
let (_directories, store) = metadata_sys::test_support::isolated_store_over_temp_disks().await;
metadata_sys::init_bucket_metadata_sys(Arc::clone(&store), Vec::new()).await;
crate::services::notification_sys::install_cross_pool_fence_fleet_proof_for_test();
let bucket = format!("quota-populated-bootstrap-{}", Uuid::new_v4().simple());
store
.make_bucket(&bucket, &MakeBucketOptions::default())
.await
.expect("create quota bucket");
let mut seed_shard = None;
let mut seed_name = None;
let mut candidate_name = None;
let mut bootstrap_name = None;
for index in 0..10_000 {
let object = format!("quota-populated-object-{index}");
let object_shard = shard_index(&object);
if seed_shard.is_none() {
seed_shard = Some(object_shard);
seed_name = Some(object);
} else if Some(object_shard) == seed_shard && candidate_name.is_none() {
candidate_name = Some(object);
} else if Some(object_shard) != seed_shard && bootstrap_name.is_none() {
bootstrap_name = Some(object);
}
if candidate_name.is_some() && bootstrap_name.is_some() {
break;
}
}
let seed_name = seed_name.expect("find a seeded shard");
let candidate_name = candidate_name.expect("find a second object in the seeded shard");
let bootstrap_name = bootstrap_name.expect("find an empty bootstrap shard");
let mut seed = PutObjReader::from_vec(vec![1]);
store
.put_object(&bucket, &seed_name, &mut seed, &ObjectOptions::default())
.await
.expect("seed existing usage before enabling v2");
store
.update_bucket_metadata_config(
&bucket,
"quota.json",
serde_json::to_vec(&super::super::BucketQuota::new_sharded(100_000)).expect("serialize v2 quota"),
)
.await
.expect("enable v2 quota");
let mut bootstrap = PutObjReader::from_vec(vec![2]);
store
.put_object(&bucket, &bootstrap_name, &mut bootstrap, &ObjectOptions::default())
.await
.expect("bootstrap an empty shard");
let mut candidate = PutObjReader::from_vec(vec![3]);
let result = store
.put_object(&bucket, &candidate_name, &mut candidate, &ObjectOptions::default())
.await
.expect("a populated shard must persist before refill and accept its first growth");
assert_eq!(result.size, 1);
}
}
@@ -84,6 +84,8 @@ pub use replication_queue_boundary::{
};
pub use replication_resync_boundary::ResyncStatusType;
pub use replication_resync_boundary::{BucketReplicationResyncStatus, ResyncOpts, TargetReplicationResyncStatus};
#[cfg(test)]
pub(crate) use replication_resyncer::read_source_delete_marker;
pub use replication_scanner_bridge::ReplicationScannerBridge;
pub use replication_state::{ReplicationStats, RuntimeReplicationTargetBacklog};
pub use replication_stats_boundary::{BucketReplicationStat, BucketReplicationStats, BucketStats, InQueueMetric, XferStats};
@@ -2475,19 +2475,8 @@ pub(crate) async fn replicate_delete_with_outcome<S: ReplicationStorage>(
if dobj.delete_object.delete_marker
&& let Some(delete_marker_version_id) = dobj.delete_object.delete_marker_version_id
{
let source_marker_state = storage
.get_object_info(
&bucket,
&dobj.delete_object.object_name,
&ObjectOptions {
version_id: Some(delete_marker_version_id.to_string()),
versioned: ReplicationVersioningStore::prefix_enabled(&bucket, &dobj.delete_object.object_name).await,
version_suspended: ReplicationVersioningStore::prefix_suspended(&bucket, &dobj.delete_object.object_name)
.await,
..Default::default()
},
)
.await;
let source_marker_state =
read_source_delete_marker(&*storage, &bucket, &dobj.delete_object.object_name, delete_marker_version_id).await;
match source_marker_state {
Ok(info) if info.delete_marker && info.version_id == Some(delete_marker_version_id) => {}
@@ -2858,14 +2847,18 @@ pub(crate) async fn replicate_delete_with_outcome<S: ReplicationStorage>(
)
}
async fn source_delete_marker_missing<S: EcstoreObjectOperations>(
/// Reads the source version addressed by `delete_marker_version_id` so a
/// delete-marker replication can confirm the marker still exists. Plain
/// `get_object_info` rejects explicit marker version reads on multi-pool
/// stores with `MethodNotAllowed`, which would fail every marker (rustfs#8746).
pub(crate) async fn read_source_delete_marker<S: EcstoreObjectOperations>(
storage: &S,
bucket: &str,
object_name: &str,
delete_marker_version_id: Uuid,
) -> bool {
match storage
.get_object_info(
) -> Result<ObjectInfo> {
storage
.get_object_info_for_delete(
bucket,
object_name,
&ObjectOptions {
@@ -2876,7 +2869,15 @@ async fn source_delete_marker_missing<S: EcstoreObjectOperations>(
},
)
.await
{
}
async fn source_delete_marker_missing<S: EcstoreObjectOperations>(
storage: &S,
bucket: &str,
object_name: &str,
delete_marker_version_id: Uuid,
) -> bool {
match read_source_delete_marker(storage, bucket, object_name, delete_marker_version_id).await {
Ok(info) => !info.delete_marker || info.version_id != Some(delete_marker_version_id),
Err(err) => is_err_object_not_found(&err) || is_err_version_not_found(&err),
}
+447 -66
View File
@@ -25,7 +25,7 @@ use std::{
future::Future,
io::ErrorKind,
pin::Pin,
sync::{Arc, OnceLock, atomic::AtomicBool},
sync::{Arc, OnceLock},
task::{Context, Poll},
time::Duration,
};
@@ -40,7 +40,7 @@ const LOG_COMPONENT_ECSTORE: &str = "ecstore";
const LOG_SUBSYSTEM_METACACHE: &str = "metacache";
const EVENT_METACACHE_LISTING: &str = "metacache_listing";
pub type AgreedFn = Box<dyn Fn(MetaCacheEntry) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + 'static>;
pub type AgreedFn = Box<dyn Fn(MetaCacheEntry) -> Pin<Box<dyn Future<Output = Result<(), DiskError>> + Send>> + Send + 'static>;
pub type PartialFn =
Box<dyn Fn(MetaCacheEntries, &[Option<DiskError>]) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + 'static>;
pub(crate) type PartialResultFn = Box<
@@ -198,6 +198,8 @@ pub(crate) enum TestReaderBehavior {
stopped: Arc<tokio::sync::Notify>,
},
IgnoreCancel,
/// Entries followed by a terminator trailer saying the producer stopped at its limit.
LimitReached(Vec<MetaCacheEntry>),
ProducerError(DiskError),
PrimaryErrorThenFallback(DiskError),
PartialThenTimeout(Vec<MetaCacheEntry>),
@@ -236,8 +238,6 @@ pub struct ListPathRawOptions {
pub skip_walkdir_total_timeout: bool,
pub walkdir_timeout: Option<Duration>,
pub walkdir_stall_timeout: Option<Duration>,
/// Shared terminal state for bounded producer walks.
pub producer_limit_reached: Option<Arc<AtomicBool>>,
pub agreed: Option<AgreedFn>,
pub partial: Option<PartialFn>,
pub finished: Option<FinishedFn>,
@@ -248,7 +248,7 @@ pub struct ListPathRawOptions {
#[cfg(test)]
pub(crate) peek_timeout: Option<Duration>,
#[cfg(test)]
pub(crate) test_disk_io_context_observed: Option<Arc<AtomicBool>>,
pub(crate) test_disk_io_context_observed: Option<Arc<std::sync::atomic::AtomicBool>>,
// pub agreed: Option<Arc<dyn Fn(MetaCacheEntry) + Send + Sync>>,
// pub partial: Option<Arc<dyn Fn(MetaCacheEntries, &[Option<Error>]) + Send + Sync>>,
// pub finished: Option<Arc<dyn Fn(&[Option<Error>]) + Send + Sync>>,
@@ -273,7 +273,6 @@ impl Clone for ListPathRawOptions {
skip_walkdir_total_timeout: self.skip_walkdir_total_timeout,
walkdir_timeout: self.walkdir_timeout,
walkdir_stall_timeout: self.walkdir_stall_timeout,
producer_limit_reached: self.producer_limit_reached.clone(),
#[cfg(test)]
test_reader_behaviors: self.test_reader_behaviors.clone(),
#[cfg(test)]
@@ -301,22 +300,31 @@ fn walk_dir_options(opts: &ListPathRawOptions) -> WalkDirOptions {
skip_total_timeout: opts.skip_walkdir_total_timeout,
timeout_ms: opts.walkdir_timeout.map(duration_millis),
stall_timeout_ms: opts.walkdir_stall_timeout.map(duration_millis),
producer_limit_reached: opts.producer_limit_reached.clone(),
..Default::default()
}
}
/// How a merged raw listing stream ended.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct ListPathRawOutcome {
/// At least one producer stopped at its per-disk limit instead of at the
/// end of its namespace, so the closed stream is not proof of exhaustion.
/// Carried in-band by the metacache stream trailer, so remote producers
/// report it as well.
pub limit_reached: bool,
}
pub async fn list_path_raw(rx: CancellationToken, opts: ListPathRawOptions) -> disk::error::Result<()> {
let rx = rx.child_token();
let _cancel_guard = rx.clone().drop_guard();
list_path_raw_inner(rx, opts, None, None).await
list_path_raw_inner(rx, opts, None, None).await.map(|_| ())
}
pub(crate) async fn list_path_raw_with_claim_tracker(
rx: CancellationToken,
opts: ListPathRawOptions,
claim_tracker: FallbackClaimTracker,
) -> disk::error::Result<()> {
) -> disk::error::Result<ListPathRawOutcome> {
let rx = rx.child_token();
let _cancel_guard = rx.clone().drop_guard();
list_path_raw_inner(rx, opts, Some(claim_tracker), None).await
@@ -327,7 +335,7 @@ pub(crate) async fn list_path_raw_with_partial_result(
opts: ListPathRawOptions,
claim_tracker: FallbackClaimTracker,
partial_result: PartialResultFn,
) -> disk::error::Result<()> {
) -> disk::error::Result<ListPathRawOutcome> {
let rx = rx.child_token();
let _cancel_guard = rx.clone().drop_guard();
list_path_raw_inner(rx, opts, Some(claim_tracker), Some(partial_result)).await
@@ -338,7 +346,7 @@ async fn list_path_raw_inner(
opts: ListPathRawOptions,
fallback_claim_tracker: Option<FallbackClaimTracker>,
partial_result: Option<PartialResultFn>,
) -> disk::error::Result<()> {
) -> disk::error::Result<ListPathRawOutcome> {
if opts.disks.is_empty() {
return Err(DiskError::ErasureReadQuorum);
}
@@ -372,7 +380,7 @@ async fn list_path_raw_inner(
let cancel_rx_clone = cancel_rx.clone();
let producer_errs_clone = producer_errs.clone();
let disk_io_context_clone = disk_io_context.clone();
let (rd, wr) = tokio::io::duplex(64);
let (rd, wr) = tokio::io::duplex(64 * 1024);
readers.push(MetacacheReader::new(rd));
jobs.spawn(with_optional_disk_io_context(disk_io_context_clone, async move {
let mut wr = PublishedBytesWriter::new(wr);
@@ -393,6 +401,14 @@ async fn list_path_raw_inner(
out.close().await.expect("test entries should close");
return Ok(());
}
TestReaderBehavior::LimitReached(entries) => {
let mut out = rustfs_filemeta::MetacacheWriter::new(&mut wr);
out.write(&entries).await.expect("test entries should be written");
out.close_with_limit_reached(true)
.await
.expect("test entries should close at the limit");
return Ok(());
}
TestReaderBehavior::Stall => {
let _held_writer = wr;
cancel_rx_clone.cancelled().await;
@@ -531,6 +547,16 @@ async fn list_path_raw_inner(
last_err = None;
continue;
}
TestReaderBehavior::LimitReached(entries) => {
let mut out = rustfs_filemeta::MetacacheWriter::new(&mut wr);
out.write(&entries).await.expect("test fallback entries should be written");
out.close_with_limit_reached(true)
.await
.expect("test fallback entries should close at the limit");
need_fallback = false;
last_err = None;
continue;
}
TestReaderBehavior::ProducerError(err) | TestReaderBehavior::PrimaryErrorThenFallback(err) => {
last_err = Some(err);
continue;
@@ -699,9 +725,13 @@ async fn list_path_raw_inner(
}
let mut pending_entries: Vec<Option<MetaCacheEntry>> = vec![None; readers.len()];
let mut peek_outcomes: Vec<Option<PeekOutcome>> = std::iter::repeat_with(|| None).take(readers.len()).collect();
// Sticky across rounds: once any reader's trailer says its producer
// stopped at the per-disk limit, the merged stream may hide more keys.
let mut limit_reached = false;
loop {
let mut current = MetaCacheEntry::default();
let mut current_idx: Option<usize> = None;
// warn!(
// "list_path_raw: loop start, bucket: {}, path: {}, current: {:?}",
@@ -709,7 +739,7 @@ async fn list_path_raw_inner(
// );
if revjob_rx.is_cancelled() {
return Ok(());
return Ok(ListPathRawOutcome { limit_reached });
}
let mut top_entries: Vec<Option<MetaCacheEntry>> = vec![None; readers.len()];
@@ -759,6 +789,7 @@ async fn list_path_raw_inner(
continue;
}
// eof
limit_reached |= r.limit_reached() == Some(true);
at_eof += 1;
// warn!("list_path_raw: peek eof, disk: {}", i);
continue;
@@ -832,7 +863,7 @@ async fn list_path_raw_inner(
*r = MetacacheReader::new(detached_rd);
continue;
}
PeekOutcome::Cancelled => return Ok(()),
PeekOutcome::Cancelled => return Ok(ListPathRawOutcome { limit_reached }),
}
};
@@ -840,30 +871,32 @@ async fn list_path_raw_inner(
// If no current, add it.
if current.name.is_empty() {
top_entries[i] = Some(entry.clone());
current_idx = Some(i);
current = entry;
agree += 1;
continue;
}
// If exact match, we agree.
if let (_, true) = current.matches(Some(&entry), true) {
top_entries[i] = Some(entry);
agree += 1;
continue;
}
// If only the name matches we didn't agree, but add it for resolution.
if entry.name == current.name {
top_entries[i] = Some(entry);
continue;
}
// We got different entries
if entry.name > current.name {
pending_entries[i] = Some(entry);
continue;
}
if entry.name == current.name {
// If exact match, we agree.
if current.matches_strict(&entry) {
top_entries[i] = Some(entry);
agree += 1;
} else {
// If only the name matches we didn't agree, but add it for resolution.
top_entries[i] = Some(entry);
}
continue;
}
if let Some(idx) = current_idx.take() {
pending_entries[idx] = Some(std::mem::take(&mut current));
}
for (idx, item) in top_entries.iter_mut().enumerate().take(i) {
if let Some(entry) = item.take() {
pending_entries[idx] = Some(entry);
@@ -871,7 +904,7 @@ async fn list_path_raw_inner(
}
agree = 1;
top_entries[i] = Some(entry.clone());
current_idx = Some(i);
current = entry;
}
@@ -889,7 +922,7 @@ async fn list_path_raw_inner(
if let Some(finished_fn) = opts.finished.as_ref() {
tokio::select! {
biased;
_ = revjob_rx.cancelled() => return Ok(()),
_ = revjob_rx.cancelled() => return Ok(ListPathRawOutcome { limit_reached }),
_ = finished_fn(&errs) => {}
}
}
@@ -946,7 +979,7 @@ async fn list_path_raw_inner(
{
tokio::select! {
biased;
_ = revjob_rx.cancelled() => return Ok(()),
_ = revjob_rx.cancelled() => return Ok(ListPathRawOutcome { limit_reached }),
_ = finished_fn(&errs) => {}
}
}
@@ -966,8 +999,8 @@ async fn list_path_raw_inner(
// warn!("list_path_raw: agreed_fn start, current: {:?}", &current.name);
tokio::select! {
biased;
_ = revjob_rx.cancelled() => return Ok(()),
_ = agreed_fn(current) => {}
_ = revjob_rx.cancelled() => return Ok(ListPathRawOutcome { limit_reached }),
result = agreed_fn(current) => result?
}
// warn!("list_path_raw: agreed_fn done");
}
@@ -977,6 +1010,10 @@ async fn list_path_raw_inner(
// warn!("list_path_raw: skip start, current: {:?}", &current.name);
if let Some(idx) = current_idx.take() {
top_entries[idx] = Some(current);
}
for (i, r) in readers.iter_mut().enumerate() {
if top_entries[i].is_some() {
let _ = r.skip(1).await;
@@ -986,58 +1023,61 @@ async fn list_path_raw_inner(
if let Some(partial_fn) = partial_result.as_ref() {
tokio::select! {
biased;
_ = revjob_rx.cancelled() => return Ok(()),
_ = revjob_rx.cancelled() => return Ok(ListPathRawOutcome { limit_reached }),
result = partial_fn(MetaCacheEntries(top_entries), &errs) => result?,
}
} else if let Some(partial_fn) = opts.partial.as_ref() {
tokio::select! {
biased;
_ = revjob_rx.cancelled() => return Ok(()),
_ = revjob_rx.cancelled() => return Ok(ListPathRawOutcome { limit_reached }),
_ = partial_fn(MetaCacheEntries(top_entries), &errs) => {}
}
}
}
Ok(())
Ok(ListPathRawOutcome { limit_reached })
});
let merge_started = std::time::Instant::now();
let Some(revjob) = revjobs.join_next().await else {
return Err(DiskError::Unexpected);
};
if let Err(err) = revjob.map_err(std::io::Error::other)? {
rustfs_io_metrics::record_stage_duration("metacache_merge_failed", merge_started.elapsed().as_secs_f64() * 1000.0);
if is_missing_path_error(&err) {
debug!(
event = EVENT_METACACHE_LISTING,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_METACACHE,
bucket = %log_bucket,
path = %log_path,
state = "merge_job_missing_path",
error = ?err,
"Metacache merge job missing path skipped"
);
} else {
error!(
event = EVENT_METACACHE_LISTING,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_METACACHE,
bucket = %log_bucket,
path = %log_path,
state = "merge_job_failed",
error = ?err,
"Metacache merge job failed"
);
}
cancel_rx.cancel();
jobs.abort_all();
let outcome = match revjob.map_err(std::io::Error::other)? {
Ok(outcome) => outcome,
Err(err) => {
rustfs_io_metrics::record_stage_duration("metacache_merge_failed", merge_started.elapsed().as_secs_f64() * 1000.0);
if is_missing_path_error(&err) {
debug!(
event = EVENT_METACACHE_LISTING,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_METACACHE,
bucket = %log_bucket,
path = %log_path,
state = "merge_job_missing_path",
error = ?err,
"Metacache merge job missing path skipped"
);
} else {
error!(
event = EVENT_METACACHE_LISTING,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_METACACHE,
bucket = %log_bucket,
path = %log_path,
state = "merge_job_failed",
error = ?err,
"Metacache merge job failed"
);
}
cancel_rx.cancel();
jobs.abort_all();
return Err(err);
}
return Err(err);
}
};
if rx.is_cancelled() {
cancel_rx.cancel();
jobs.abort_all();
return Ok(());
return Ok(outcome);
}
rustfs_io_metrics::record_stage_duration("metacache_merge", merge_started.elapsed().as_secs_f64() * 1000.0);
@@ -1106,7 +1146,7 @@ async fn list_path_raw_inner(
}
// warn!("list_path_raw: done");
Ok(())
Ok(outcome)
}
#[inline]
@@ -1125,6 +1165,7 @@ mod tests {
use rustfs_filemeta::MetacacheWriter;
use rustfs_filemeta::{FileInfo, FileMeta, MetadataResolutionParams};
use std::sync::Mutex;
use std::sync::atomic::AtomicBool;
use time::OffsetDateTime;
use uuid::Uuid;
@@ -1245,6 +1286,61 @@ mod tests {
);
}
#[tokio::test]
async fn list_path_raw_reports_limit_reached_from_any_reader_trailer() {
let first = fallback_test_entry();
let second = MetaCacheEntry {
name: "bucket/object-2".to_string(),
..fallback_test_entry()
};
// One bounded producer, one that ends naturally, one legacy empty
// stream without any trailer.
let outcome = list_path_raw_inner(
CancellationToken::new(),
ListPathRawOptions {
disks: vec![None, None, None],
min_disks: 1,
test_reader_behaviors: vec![
TestReaderBehavior::LimitReached(vec![first.clone()]),
TestReaderBehavior::Entries(vec![first, second]),
TestReaderBehavior::Eof,
],
..Default::default()
},
None,
None,
)
.await
.expect("listing should complete");
assert!(
outcome.limit_reached,
"a reader whose trailer reports a limit stop must mark the merged stream as possibly incomplete"
);
}
#[tokio::test]
async fn list_path_raw_reports_natural_end_when_no_reader_trailer_says_limit() {
let outcome = list_path_raw_inner(
CancellationToken::new(),
ListPathRawOptions {
disks: vec![None, None],
min_disks: 1,
test_reader_behaviors: vec![
TestReaderBehavior::Entries(vec![fallback_test_entry()]),
TestReaderBehavior::Eof,
],
..Default::default()
},
None,
None,
)
.await
.expect("listing should complete");
assert!(!outcome.limit_reached, "natural and legacy stream ends must not claim a limit stop");
}
#[tokio::test]
async fn list_path_raw_returns_typed_quorum_error_for_multiple_drive_failures() {
let err = list_path_raw(
@@ -1703,6 +1799,34 @@ mod tests {
.expect("aborting the listing should drop the blocked producer");
}
#[tokio::test]
async fn list_path_raw_propagates_agreed_callback_failure() {
let entry = MetaCacheEntry {
name: "bucket/object".to_owned(),
metadata: vec![1],
..Default::default()
};
let result = timeout(
Duration::from_secs(1),
list_path_raw(
CancellationToken::new(),
ListPathRawOptions {
disks: vec![None],
min_disks: 1,
test_reader_behaviors: vec![TestReaderBehavior::Entries(vec![entry])],
agreed: Some(Box::new(|_| Box::pin(async { Err(DiskError::ErasureReadQuorum) }))),
..Default::default()
},
),
)
.await
.expect("callback failure must terminate the walk");
assert_eq!(
result.expect_err("agreed resolution must not silently omit the key"),
DiskError::ErasureReadQuorum
);
}
#[tokio::test]
async fn list_path_raw_propagates_partial_callback_failure() {
let first = MetaCacheEntry {
@@ -1804,6 +1928,7 @@ mod tests {
Box::pin(async move {
callback_started.notify_one();
std::future::pending::<()>().await;
Ok(())
})
})),
..Default::default()
@@ -1955,6 +2080,7 @@ mod tests {
let seen = seen_clone.clone();
Box::pin(async move {
seen.lock().expect("seen mutex poisoned").push(entry.name);
Ok(())
})
})),
..Default::default()
@@ -1997,6 +2123,7 @@ mod tests {
let seen = seen_clone.clone();
Box::pin(async move {
seen.lock().expect("seen mutex poisoned").push(entry.name);
Ok(())
})
})),
..Default::default()
@@ -2069,6 +2196,7 @@ mod tests {
let seen = agreed_seen.clone();
Box::pin(async move {
seen.lock().expect("seen mutex poisoned").push(entry.name);
Ok(())
})
})),
partial: Some(Box::new(move |entries, _| {
@@ -2180,4 +2308,257 @@ mod tests {
assert_eq!(err, DiskError::FileAccessDenied);
}
#[tokio::test]
async fn list_path_raw_demotes_current_and_agreed_peers_across_cascading_smaller_keys() {
fn make_entry(name: &str, vid: u128, etag: &str) -> MetaCacheEntry {
let mut meta = FileMeta::default();
let mut fi = FileInfo::new(name, 2, 2);
fi.version_id = Some(Uuid::from_u128(vid));
fi.mod_time = Some(OffsetDateTime::from_unix_timestamp(1_705_312_300).expect("valid timestamp"));
fi.metadata.insert("etag".to_string(), etag.to_string());
meta.add_version(fi).expect("add version");
MetaCacheEntry {
name: name.to_string(),
metadata: meta.marshal_msg().expect("marshal metadata"),
cached: None,
reusable: false,
}
}
// Round 1: Disks 0 and 1 BOTH start at "key-d" and strictly agree, populating `current_idx = Some(0)`
// and `top_entries[1] = Some(key-d)`. Disk 2 then yields "key-b" (< "key-d"), forcing demotion of
// BOTH `current_idx` (`pending_entries[0]`) AND `top_entries[1]` (`pending_entries[1]`).
// Disk 3 then yields "key-a" (< "key-b"), forcing a second cascading demotion of Disk 2 to `pending_entries[2]`.
// Later at "key-e", all 4 disks present the same name, version_id, and mod_time, but Disk 2 has a different
// ETag ("etag-e2" vs "etag-e1"), proving `matches_strict` routes all 4 replicas to `partial` instead of `agreed`.
let k_a = make_entry("key-a", 1, "etag-a");
let k_b = make_entry("key-b", 2, "etag-b");
let k_d = make_entry("key-d", 4, "etag-d");
let k_e_v1 = make_entry("key-e", 5, "etag-e1");
let k_e_v2 = make_entry("key-e", 5, "etag-e2");
let disk0 = vec![k_d.clone(), k_e_v1.clone()];
let disk1 = vec![k_d.clone(), k_e_v1.clone()];
let disk2 = vec![k_b.clone(), k_d.clone(), k_e_v2];
let disk3 = vec![k_a, k_b, k_d, k_e_v1];
let events = Arc::new(Mutex::new(Vec::new()));
let agreed_events = events.clone();
let partial_events = events.clone();
list_path_raw(
CancellationToken::new(),
ListPathRawOptions {
disks: vec![None, None, None, None],
min_disks: 1,
test_reader_behaviors: vec![
TestReaderBehavior::Entries(disk0),
TestReaderBehavior::Entries(disk1),
TestReaderBehavior::Entries(disk2),
TestReaderBehavior::Entries(disk3),
],
agreed: Some(Box::new(move |entry| {
let agreed_events = agreed_events.clone();
Box::pin(async move {
agreed_events.lock().expect("events lock").push((entry.name, 4usize, true));
Ok(())
})
})),
partial: Some(Box::new(move |entries, _| {
let partial_events = partial_events.clone();
Box::pin(async move {
let present = entries.0.iter().flatten().count();
let name = entries
.0
.iter()
.flatten()
.next()
.expect("at least one entry in partial")
.name
.clone();
partial_events.lock().expect("events lock").push((name, present, false));
})
})),
..Default::default()
},
)
.await
.expect("cascading demotion listing should succeed");
assert_eq!(
events.lock().expect("events lock").as_slice(),
&[
("key-a".to_string(), 1, false),
("key-b".to_string(), 2, false),
("key-d".to_string(), 4, true),
("key-e".to_string(), 4, false),
],
"cascading demotions must preserve pending_entries from both current_idx and top_entries, and 4-disk ETag divergence must take partial"
);
}
#[tokio::test]
async fn list_path_raw_seeded_multi_disk_streams_match_reference_oracle() {
type Step = (String, Vec<Option<String>>, bool);
fn make_entry(name: &str, variant: u64) -> MetaCacheEntry {
if name.ends_with('/') {
return MetaCacheEntry {
name: name.to_string(),
metadata: Vec::new(),
cached: None,
reusable: false,
};
}
let mut meta = FileMeta::default();
let mut fi = FileInfo::new(name, 2, 2);
fi.version_id = Some(Uuid::from_u128(1));
fi.mod_time = Some(OffsetDateTime::from_unix_timestamp(1_705_312_300).expect("valid timestamp"));
fi.metadata.insert("etag".to_string(), format!("etag-{variant}"));
meta.add_version(fi).expect("add version");
MetaCacheEntry {
name: name.to_string(),
metadata: meta.marshal_msg().expect("marshal metadata"),
cached: None,
reusable: false,
}
}
fn describe_entry(entry: &MetaCacheEntry) -> String {
if entry.is_dir() {
return format!("{}:0", entry.name);
}
let fi = entry.to_fileinfo("bucket").expect("decode test entry");
let etag = fi.metadata.get("etag").expect("etag present");
let v = etag.strip_prefix("etag-").expect("etag prefix");
format!("{}:{v}", entry.name)
}
let universe = ["a", "a/", "a/b", "a/b/", "a/c", "a0", "b", "b/", "b/1", "c", "d/", "e"];
// Deterministic LCG seed using high bits across 24 random 4-disk stream schedules.
let mut seed = 0x5eed_cafe_1234_5678_u64;
let mut next_rand = || {
seed = seed.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
seed >> 33
};
let mut total_agreed = 0usize;
let mut total_partial = 0usize;
let mut total_preserved_all_four = 0usize;
for round in 0..24 {
let preserve_replica_metadata = round % 3 == 0;
let mut disk_streams: Vec<Vec<MetaCacheEntry>> = vec![Vec::new(); 4];
let mut expected_steps: Vec<Step> = Vec::new();
for &key in &universe {
// High-bit mask in 1..=15; force mask 15 on every 4th key so 4-disk agreement/divergence is heavily exercised.
let mask = if next_rand() % 4 == 0 {
0b1111_u64
} else {
(next_rand() % 15) + 1
};
let mut slot_variants: Vec<Option<u64>> = Vec::with_capacity(4);
let mut first_variant = None;
let mut all_present_and_equal = true;
for (disk_idx, stream) in disk_streams.iter_mut().enumerate() {
if (mask & (1_u64 << disk_idx)) != 0 {
// Directory entries carry no metadata, so their variant is always 0.
let v = if key.ends_with('/') {
first_variant = Some(0);
0
} else if first_variant.is_none() || (next_rand() % 4 != 0) {
let v = first_variant.unwrap_or_else(|| next_rand() % 3);
first_variant = Some(v);
v
} else {
all_present_and_equal = false;
(first_variant.unwrap_or(0) + 1) % 3
};
stream.push(make_entry(key, v));
slot_variants.push(Some(v));
} else {
all_present_and_equal = false;
slot_variants.push(None);
}
}
let expect_agreed = all_present_and_equal && !preserve_replica_metadata;
if expect_agreed {
total_agreed += 1;
} else {
total_partial += 1;
if all_present_and_equal && preserve_replica_metadata {
total_preserved_all_four += 1;
}
}
let expected_slots = slot_variants
.into_iter()
.map(|opt| opt.map(|v| format!("{key}:{v}")))
.collect();
expected_steps.push((key.to_string(), expected_slots, expect_agreed));
}
let observed: Arc<Mutex<Vec<Step>>> = Arc::new(Mutex::new(Vec::new()));
let agreed_obs = observed.clone();
let partial_obs = observed.clone();
list_path_raw(
CancellationToken::new(),
ListPathRawOptions {
disks: vec![None; 4],
min_disks: 1,
preserve_replica_metadata,
test_reader_behaviors: disk_streams.into_iter().map(TestReaderBehavior::Entries).collect(),
agreed: Some(Box::new(move |entry| {
let agreed_obs = agreed_obs.clone();
let desc = describe_entry(&entry);
let name = entry.name;
Box::pin(async move {
agreed_obs.lock().expect("obs lock").push((name, vec![Some(desc); 4], true));
Ok(())
})
})),
partial: Some(Box::new(move |entries, _| {
let partial_obs = partial_obs.clone();
let name = entries
.0
.iter()
.flatten()
.next()
.expect("partial must contain at least one entry")
.name
.clone();
let slots = entries
.0
.iter()
.map(|opt| opt.as_ref().map(describe_entry))
.collect::<Vec<_>>();
Box::pin(async move {
partial_obs.lock().expect("obs lock").push((name, slots, false));
})
})),
..Default::default()
},
)
.await
.expect("seeded list_path_raw must succeed");
assert_eq!(
*observed.lock().expect("obs lock"),
expected_steps,
"round={round}, preserve_replica_metadata={preserve_replica_metadata}"
);
}
assert!(total_agreed > 0, "seeded schedule must exercise agreed fast-path");
assert!(total_partial > 0, "seeded schedule must exercise partial path");
assert!(
total_preserved_all_four > 0,
"seeded schedule must exercise preserve_replica_metadata on 4-disk agreement"
);
}
}
+88 -14
View File
@@ -94,6 +94,7 @@ const REPLAY_CACHE_AUTO_MEMORY_PERCENT: u64 = 13;
const REPLAY_CACHE_AUTO_RPC_RPS_PER_CPU: usize = 4096;
const REPLAY_CACHE_AUTO_MAX_CAPACITY: usize = 33_554_432;
const NS_SCANNER_CAPABILITY_AUTH_DOMAIN: &[u8] = b"rustfs-ns-scanner-capability-v3";
const NS_SCANNER_CHECKPOINT_COVERAGE_AUTH_DOMAIN: &[u8] = b"rustfs-ns-scanner-checkpoint-coverage-v1";
const NS_SCANNER_TIER_REGISTRY_GENERATION_AUTH_DOMAIN: &[u8] = b"rustfs-ns-scanner-tier-registry-generation-v1";
pub const TONIC_RPC_PREFIX: &str = "/node_service.NodeService";
static INTERNODE_RPC_SIGNATURE_STRICT: LazyLock<bool> = LazyLock::new(|| {
@@ -642,6 +643,7 @@ fn update_ns_scanner_capability_mac(
challenge: Uuid,
server_epoch: Uuid,
supports_tier_registry_generation: bool,
supports_checkpoint_coverage: bool,
) {
mac.update(NS_SCANNER_CAPABILITY_AUTH_DOMAIN);
mac.update(&NS_SCANNER_PROTOCOL_VERSION.to_be_bytes());
@@ -653,6 +655,9 @@ fn update_ns_scanner_capability_mac(
// that receives generation-fenced scanner work.
mac.update(NS_SCANNER_TIER_REGISTRY_GENERATION_AUTH_DOMAIN);
}
if supports_checkpoint_coverage {
mac.update(NS_SCANNER_CHECKPOINT_COVERAGE_AUTH_DOMAIN);
}
}
fn generate_ns_scanner_capability_proof(
@@ -660,13 +665,20 @@ fn generate_ns_scanner_capability_proof(
challenge: Uuid,
server_epoch: Uuid,
supports_tier_registry_generation: bool,
supports_checkpoint_coverage: bool,
) -> std::io::Result<Vec<u8>> {
if challenge.is_nil() || server_epoch.is_nil() {
return Err(std::io::Error::other("Invalid namespace scanner capability scope"));
}
let mut mac =
<HmacSha256 as KeyInit>::new_from_slice(secret.as_bytes()).map_err(|_| std::io::Error::other("Invalid RPC HMAC key"))?;
update_ns_scanner_capability_mac(&mut mac, challenge, server_epoch, supports_tier_registry_generation);
update_ns_scanner_capability_mac(
&mut mac,
challenge,
server_epoch,
supports_tier_registry_generation,
supports_checkpoint_coverage,
);
Ok(mac.finalize().into_bytes().to_vec())
}
@@ -676,13 +688,20 @@ fn verify_ns_scanner_capability_proof(
server_epoch: Uuid,
proof: &[u8],
supports_tier_registry_generation: bool,
supports_checkpoint_coverage: bool,
) -> std::io::Result<()> {
if challenge.is_nil() || server_epoch.is_nil() {
return Err(std::io::Error::other("Invalid namespace scanner capability scope"));
}
let mut mac =
<HmacSha256 as KeyInit>::new_from_slice(secret.as_bytes()).map_err(|_| std::io::Error::other("Invalid RPC HMAC key"))?;
update_ns_scanner_capability_mac(&mut mac, challenge, server_epoch, supports_tier_registry_generation);
update_ns_scanner_capability_mac(
&mut mac,
challenge,
server_epoch,
supports_tier_registry_generation,
supports_checkpoint_coverage,
);
mac.verify_slice(proof)
.map_err(|_| std::io::Error::new(std::io::ErrorKind::PermissionDenied, "Invalid namespace scanner capability proof"))
}
@@ -700,7 +719,22 @@ pub fn sign_ns_scanner_capability_with_tier_registry_generation(
server_epoch: Uuid,
supports_tier_registry_generation: bool,
) -> std::io::Result<Vec<u8>> {
generate_ns_scanner_capability_proof(&get_shared_secret()?, challenge, server_epoch, supports_tier_registry_generation)
sign_ns_scanner_capability_with_checkpoint_coverage(challenge, server_epoch, supports_tier_registry_generation, false)
}
pub fn sign_ns_scanner_capability_with_checkpoint_coverage(
challenge: Uuid,
server_epoch: Uuid,
supports_tier_registry_generation: bool,
supports_checkpoint_coverage: bool,
) -> std::io::Result<Vec<u8>> {
generate_ns_scanner_capability_proof(
&get_shared_secret()?,
challenge,
server_epoch,
supports_tier_registry_generation,
supports_checkpoint_coverage,
)
}
pub fn verify_ns_scanner_capability_with_tier_registry_generation(
@@ -709,7 +743,30 @@ pub fn verify_ns_scanner_capability_with_tier_registry_generation(
proof: &[u8],
supports_tier_registry_generation: bool,
) -> std::io::Result<()> {
verify_ns_scanner_capability_proof(&get_shared_secret()?, challenge, server_epoch, proof, supports_tier_registry_generation)
verify_ns_scanner_capability_with_checkpoint_coverage(
challenge,
server_epoch,
proof,
supports_tier_registry_generation,
false,
)
}
pub fn verify_ns_scanner_capability_with_checkpoint_coverage(
challenge: Uuid,
server_epoch: Uuid,
proof: &[u8],
supports_tier_registry_generation: bool,
supports_checkpoint_coverage: bool,
) -> std::io::Result<()> {
verify_ns_scanner_capability_proof(
&get_shared_secret()?,
challenge,
server_epoch,
proof,
supports_tier_registry_generation,
supports_checkpoint_coverage,
)
}
#[derive(Clone, Copy)]
@@ -1776,13 +1833,30 @@ mod tests {
let secret = "test-scanner-capability-secret";
let challenge = Uuid::new_v4();
let server_epoch = Uuid::new_v4();
let proof = generate_ns_scanner_capability_proof(secret, challenge, server_epoch, false)
let proof = generate_ns_scanner_capability_proof(secret, challenge, server_epoch, false, false)
.expect("capability proof should be generated");
assert!(verify_ns_scanner_capability_proof(secret, challenge, server_epoch, &proof, false).is_ok());
assert!(verify_ns_scanner_capability_proof(secret, Uuid::new_v4(), server_epoch, &proof, false).is_err());
assert!(verify_ns_scanner_capability_proof(secret, challenge, Uuid::new_v4(), &proof, false).is_err());
assert!(verify_ns_scanner_capability_proof("different-secret", challenge, server_epoch, &proof, false).is_err());
assert!(verify_ns_scanner_capability_proof(secret, challenge, server_epoch, &proof, false, false).is_ok());
assert!(verify_ns_scanner_capability_proof(secret, Uuid::new_v4(), server_epoch, &proof, false, false).is_err());
assert!(verify_ns_scanner_capability_proof(secret, challenge, Uuid::new_v4(), &proof, false, false).is_err());
assert!(verify_ns_scanner_capability_proof("different-secret", challenge, server_epoch, &proof, false, false).is_err());
}
#[test]
fn namespace_scanner_capability_proof_binds_checkpoint_coverage_support() {
let secret = "checkpoint-capability-test-secret";
let challenge = Uuid::new_v4();
let server_epoch = Uuid::new_v4();
let proof = generate_ns_scanner_capability_proof(secret, challenge, server_epoch, true, true)
.expect("checkpoint capability proof");
assert!(verify_ns_scanner_capability_proof(secret, challenge, server_epoch, &proof, true, true).is_ok());
assert!(verify_ns_scanner_capability_proof(secret, challenge, server_epoch, &proof, true, false).is_err());
assert!(verify_ns_scanner_capability_proof(secret, challenge, server_epoch, &proof, false, true).is_err());
let legacy = generate_ns_scanner_capability_proof(secret, challenge, server_epoch, true, false)
.expect("legacy generation capability proof");
assert!(verify_ns_scanner_capability_proof(secret, challenge, server_epoch, &legacy, true, true).is_err());
assert!(verify_ns_scanner_capability_proof(secret, challenge, server_epoch, &[], true, true).is_err());
assert!(verify_ns_scanner_capability_proof(secret, Uuid::new_v4(), server_epoch, &proof, true, true).is_err());
}
#[test]
@@ -1790,14 +1864,14 @@ mod tests {
let secret = "test-scanner-capability-secret";
let challenge = Uuid::new_v4();
let server_epoch = Uuid::new_v4();
let proof = generate_ns_scanner_capability_proof(secret, challenge, server_epoch, true)
let proof = generate_ns_scanner_capability_proof(secret, challenge, server_epoch, true, false)
.expect("generation capability proof should be generated");
assert!(verify_ns_scanner_capability_proof(secret, challenge, server_epoch, &proof, true).is_ok());
assert!(verify_ns_scanner_capability_proof(secret, challenge, server_epoch, &proof, false).is_err());
let legacy = generate_ns_scanner_capability_proof(secret, challenge, server_epoch, false)
assert!(verify_ns_scanner_capability_proof(secret, challenge, server_epoch, &proof, true, false).is_ok());
assert!(verify_ns_scanner_capability_proof(secret, challenge, server_epoch, &proof, false, false).is_err());
let legacy = generate_ns_scanner_capability_proof(secret, challenge, server_epoch, false, false)
.expect("legacy capability proof should be generated");
assert!(verify_ns_scanner_capability_proof(secret, challenge, server_epoch, &legacy, true).is_err());
assert!(verify_ns_scanner_capability_proof(secret, challenge, server_epoch, &legacy, true, false).is_err());
}
/// Security regression for GHSA-r5qv-rc46-hv8q (internode RPC fail-closed,
@@ -13,18 +13,19 @@
// limitations under the License.
use crate::cluster::rpc::{
build_auth_headers, build_put_file_auth_trailer, verify_ns_scanner_capability_with_tier_registry_generation,
verify_put_file_capability,
build_auth_headers, build_put_file_auth_trailer, verify_ns_scanner_capability_with_checkpoint_coverage,
verify_ns_scanner_capability_with_tier_registry_generation, verify_put_file_capability,
};
use crate::disk::error::{Error, Result};
use crate::disk::{FileReader, FileWriter};
use crate::storage_api_contracts::internode::{
NS_SCANNER_BODY_SHA256_QUERY, NS_SCANNER_CAPABILITY_CHALLENGE_QUERY, NS_SCANNER_CYCLE_QUERY, NS_SCANNER_LEADER_EPOCH_QUERY,
NS_SCANNER_PROTOCOL_VERSION, NS_SCANNER_PROTOCOL_VERSION_QUERY, NS_SCANNER_REQUEST_ID_QUERY, NS_SCANNER_SERVER_EPOCH_QUERY,
NS_SCANNER_SESSION_ID_QUERY, NS_SCANNER_SESSION_SEQUENCE_QUERY, NS_SCANNER_TIER_REGISTRY_GENERATION_QUERY,
NsScannerCapabilityResponse, PUT_FILE_AUTH_QUERY, PUT_FILE_AUTH_V1, PUT_FILE_CAPABILITY_CHALLENGE_QUERY,
PUT_FILE_CAPABILITY_QUERY, PUT_FILE_CAPABILITY_VERSION, PUT_FILE_NONCE_QUERY, PUT_FILE_SERVER_EPOCH_QUERY,
PutFileCapabilityResponse, WALK_DIR_BODY_SHA256_QUERY, WALK_DIR_STREAM_COMPLETION_QUERY, WALK_DIR_STREAM_COMPLETION_V1,
NS_SCANNER_BODY_SHA256_QUERY, NS_SCANNER_CAPABILITY_CHALLENGE_QUERY, NS_SCANNER_CHECKPOINT_COVERAGE_QUERY,
NS_SCANNER_CYCLE_QUERY, NS_SCANNER_LEADER_EPOCH_QUERY, NS_SCANNER_PROTOCOL_VERSION, NS_SCANNER_PROTOCOL_VERSION_QUERY,
NS_SCANNER_REQUEST_ID_QUERY, NS_SCANNER_SERVER_EPOCH_QUERY, NS_SCANNER_SESSION_ID_QUERY, NS_SCANNER_SESSION_SEQUENCE_QUERY,
NS_SCANNER_TIER_REGISTRY_GENERATION_QUERY, NsScannerCapabilityResponse, PUT_FILE_AUTH_QUERY, PUT_FILE_AUTH_V1,
PUT_FILE_CAPABILITY_CHALLENGE_QUERY, PUT_FILE_CAPABILITY_QUERY, PUT_FILE_CAPABILITY_VERSION, PUT_FILE_NONCE_QUERY,
PUT_FILE_SERVER_EPOCH_QUERY, PutFileCapabilityResponse, WALK_DIR_BODY_SHA256_QUERY, WALK_DIR_STREAM_COMPLETION_QUERY,
WALK_DIR_STREAM_COMPLETION_V1,
};
use async_trait::async_trait;
use http::{HeaderMap, HeaderValue, Method, header::CONTENT_TYPE};
@@ -250,6 +251,7 @@ pub struct NsScannerStreamRequest {
pub struct NsScannerCapabilityRequest {
pub endpoint: String,
pub supports_tier_registry_generation: bool,
pub supports_checkpoint_coverage: bool,
}
/// Data-plane stream opener used by `RemoteDisk`.
@@ -289,6 +291,7 @@ pub trait InternodeDataTransport: Send + Sync + std::fmt::Debug {
server_epoch,
proof: Vec::new(),
supports_tier_registry_generation: None,
supports_checkpoint_coverage: None,
})
}
// Interface facet nobody calls yet: every transport implements both, but no
@@ -379,6 +382,18 @@ impl InternodeDataTransport for TcpHttpInternodeDataTransport {
}
async fn probe_ns_scanner_capability(&self, request: NsScannerCapabilityRequest) -> Result<NsScannerCapabilityResponse> {
let request = if request.supports_checkpoint_coverage {
match self.probe_ns_scanner_capability_once(&request).await {
Ok(response) => return Ok(response),
Err(error) if ns_scanner_capability_error_allows_legacy(&error) => NsScannerCapabilityRequest {
supports_checkpoint_coverage: false,
..request
},
Err(error) => return Err(error),
}
} else {
request
};
if request.supports_tier_registry_generation {
return match self.probe_ns_scanner_capability_once(&request).await {
Ok(response) => Ok(response),
@@ -390,6 +405,7 @@ impl InternodeDataTransport for TcpHttpInternodeDataTransport {
let legacy_request = NsScannerCapabilityRequest {
endpoint: request.endpoint.clone(),
supports_tier_registry_generation: false,
supports_checkpoint_coverage: false,
};
match self.probe_ns_scanner_capability_once(&legacy_request).await {
Ok(mut response) => {
@@ -447,18 +463,32 @@ impl TcpHttpInternodeDataTransport {
if response.version != NS_SCANNER_PROTOCOL_VERSION || response.server_epoch.is_nil() {
return Err(Error::other("incompatible remote namespace scanner capability response"));
}
if let Err(err) = verify_ns_scanner_capability_with_tier_registry_generation(
if let Err(err) = verify_ns_scanner_capability_with_checkpoint_coverage(
challenge,
response.server_epoch,
&response.proof,
request.supports_tier_registry_generation,
request.supports_checkpoint_coverage,
) {
if request.supports_checkpoint_coverage
&& verify_ns_scanner_capability_with_tier_registry_generation(
challenge,
response.server_epoch,
&response.proof,
request.supports_tier_registry_generation,
)
.is_ok()
{
normalize_ns_scanner_capability_response(&mut response, request.supports_tier_registry_generation, false);
return Ok(response);
}
// A permissive older peer can ignore the additive marker and
// return a valid legacy-scope proof with HTTP 200. Accept that
// response only after independently authenticating the legacy
// scope; all other verification failures remain fail-closed.
if request.supports_tier_registry_generation && ns_scanner_capability_legacy_proof_is_valid(challenge, &response) {
response.supports_tier_registry_generation = None;
response.supports_checkpoint_coverage = None;
return Ok(response);
}
return Err(Error::other(format!("remote namespace scanner capability authentication failed: {err}")));
@@ -468,7 +498,11 @@ impl TcpHttpInternodeDataTransport {
// verified scope so an intermediary cannot strip or rewrite the field
// and force a silent downgrade after a successful generation-bound
// handshake.
normalize_ns_scanner_capability_response(&mut response, request.supports_tier_registry_generation);
normalize_ns_scanner_capability_response(
&mut response,
request.supports_tier_registry_generation,
request.supports_checkpoint_coverage,
);
Ok(response)
}
@@ -798,8 +832,13 @@ fn build_walk_dir_url(request: &WalkDirStreamRequest) -> String {
)
}
fn normalize_ns_scanner_capability_response(response: &mut NsScannerCapabilityResponse, requested_generation_support: bool) {
fn normalize_ns_scanner_capability_response(
response: &mut NsScannerCapabilityResponse,
requested_generation_support: bool,
requested_checkpoint_coverage: bool,
) {
response.supports_tier_registry_generation = requested_generation_support.then_some(true);
response.supports_checkpoint_coverage = requested_checkpoint_coverage.then_some(true);
}
fn ns_scanner_capability_legacy_proof_is_valid(challenge: Uuid, response: &NsScannerCapabilityResponse) -> bool {
@@ -832,7 +871,7 @@ fn build_ns_scanner_url(request: &NsScannerStreamRequest) -> String {
fn build_ns_scanner_capability_url(request: &NsScannerCapabilityRequest, challenge: Uuid) -> String {
format!(
"{}{}?{}={}&{}={}{}",
"{}{}?{}={}&{}={}{}{}",
request.endpoint,
NS_SCANNER_PATH,
NS_SCANNER_PROTOCOL_VERSION_QUERY,
@@ -843,6 +882,11 @@ fn build_ns_scanner_capability_url(request: &NsScannerCapabilityRequest, challen
format!("&{}=true", NS_SCANNER_TIER_REGISTRY_GENERATION_QUERY)
} else {
String::new()
},
if request.supports_checkpoint_coverage {
format!("&{}=true", NS_SCANNER_CHECKPOINT_COVERAGE_QUERY)
} else {
String::new()
}
)
}
@@ -1001,6 +1045,7 @@ mod tests {
.probe_ns_scanner(NsScannerCapabilityRequest {
endpoint: "http://node1:9000".to_string(),
supports_tier_registry_generation: false,
supports_checkpoint_coverage: false,
})
.await
.expect_err("legacy transport should report namespace scanner as unsupported");
@@ -1027,6 +1072,97 @@ mod tests {
assert!(matches!(open_err, Error::MethodNotAllowed));
}
#[tokio::test]
async fn namespace_scanner_checkpoint_capability_authenticates_new_and_legacy_peers() {
crate::runtime::sources::ensure_test_rpc_secret();
for peer in ["current", "strict-old", "permissive-old", "invalid-proof", "missing-proof"] {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("capability listener");
let address = listener.local_addr().expect("capability listener address");
let server_epoch = Uuid::new_v4();
let server = tokio::spawn(async move {
let requests = if peer == "strict-old" { 2 } else { 1 };
for index in 0..requests {
let (mut socket, _) = listener.accept().await.expect("capability request");
let mut bytes = Vec::new();
while !bytes.windows(4).any(|window| window == b"\r\n\r\n") {
let mut chunk = [0; 1024];
let read = socket.read(&mut chunk).await.expect("capability headers");
assert!(read > 0 && bytes.len() < 16 * 1024, "bounded complete capability headers");
bytes.extend_from_slice(&chunk[..read]);
}
let request = std::str::from_utf8(&bytes).expect("HTTP headers");
let target = request.split_whitespace().nth(1).expect("HTTP request target");
let url = reqwest::Url::parse(&format!("http://{address}{target}")).expect("capability URL");
let query: HashMap<_, _> = url.query_pairs().into_owned().collect();
assert_eq!(query.get(NS_SCANNER_TIER_REGISTRY_GENERATION_QUERY).map(String::as_str), Some("true"));
assert_eq!(query.contains_key(NS_SCANNER_CHECKPOINT_COVERAGE_QUERY), index == 0);
if peer == "strict-old" && index == 0 {
socket
.write_all(b"HTTP/1.1 400 Bad Request\r\ncontent-length: 0\r\nconnection: close\r\n\r\n")
.await
.expect("legacy parser rejection");
continue;
}
let challenge = query[NS_SCANNER_CAPABILITY_CHALLENGE_QUERY].parse().expect("challenge UUID");
let proof = match peer {
"invalid-proof" => vec![0; 32],
"missing-proof" => Vec::new(),
_ => crate::cluster::rpc::sign_ns_scanner_capability_with_checkpoint_coverage(
challenge,
server_epoch,
true,
peer == "current",
)
.expect("server capability proof"),
};
let body = rmp_serde::to_vec_named(&NsScannerCapabilityResponse {
version: NS_SCANNER_PROTOCOL_VERSION,
server_epoch,
proof,
supports_tier_registry_generation: Some(true),
// Strip a supported bit, or forge it for an older peer.
supports_checkpoint_coverage: (peer != "current").then_some(true),
})
.expect("capability response");
socket
.write_all(
format!("HTTP/1.1 200 OK\r\ncontent-length: {}\r\nconnection: close\r\n\r\n", body.len()).as_bytes(),
)
.await
.expect("capability response headers");
socket.write_all(&body).await.expect("capability response body");
}
requests
});
let response = tokio::time::timeout(
Duration::from_secs(10),
TcpHttpInternodeDataTransport.probe_ns_scanner_capability(NsScannerCapabilityRequest {
endpoint: format!("http://{address}"),
supports_tier_registry_generation: true,
supports_checkpoint_coverage: true,
}),
)
.await
.expect("bounded capability probe");
if peer.ends_with("proof") {
assert!(
response
.expect_err("unverified capability must fail closed")
.to_string()
.contains("authentication failed")
);
} else {
let response = response.expect("authenticated capability");
assert_eq!(response.server_epoch, server_epoch);
assert_eq!(response.supports_tier_registry_generation, Some(true));
assert_eq!(response.supports_checkpoint_coverage, (peer == "current").then_some(true));
}
assert_eq!(server.await.expect("capability server"), if peer == "strict-old" { 2 } else { 1 });
}
}
#[tokio::test]
async fn walk_dir_restores_explicit_remote_missing_error() {
let _ = rustfs_credentials::set_global_rpc_secret("walk-dir-error-test-secret".to_string());
@@ -1769,6 +1905,7 @@ mod tests {
&NsScannerCapabilityRequest {
endpoint: "http://node1:9000".to_string(),
supports_tier_registry_generation: false,
supports_checkpoint_coverage: false,
},
challenge,
);
@@ -1788,6 +1925,7 @@ mod tests {
&NsScannerCapabilityRequest {
endpoint: "http://node1:9000".to_string(),
supports_tier_registry_generation: true,
supports_checkpoint_coverage: false,
},
challenge,
);
@@ -1826,13 +1964,14 @@ mod tests {
server_epoch: Uuid::new_v4(),
proof: Vec::new(),
supports_tier_registry_generation: None,
supports_checkpoint_coverage: None,
};
normalize_ns_scanner_capability_response(&mut response, true);
normalize_ns_scanner_capability_response(&mut response, true, true);
assert_eq!(response.supports_tier_registry_generation, Some(true));
response.supports_tier_registry_generation = Some(false);
normalize_ns_scanner_capability_response(&mut response, false);
normalize_ns_scanner_capability_response(&mut response, false, false);
assert_eq!(response.supports_tier_registry_generation, None);
}
@@ -1846,6 +1985,7 @@ mod tests {
proof: crate::cluster::rpc::sign_ns_scanner_capability(challenge, Uuid::new_v4())
.expect("placeholder proof should be generated"),
supports_tier_registry_generation: None,
supports_checkpoint_coverage: None,
};
// A proof bound to a different challenge cannot authorize the legacy
// fallback, even though the response has the expected shape.
+7 -6
View File
@@ -36,12 +36,13 @@ pub use http_auth::{
TONIC_RPC_PREFIX, build_auth_headers, build_put_file_auth_trailer, check_and_record_signed_rpc_nonce, gen_signature_headers,
gen_tonic_replay_scope_headers, gen_tonic_signature_headers, normalize_tonic_rpc_audience, set_tonic_canonical_body_digest,
set_tonic_mutation_body_digest, set_tonic_rolling_canonical_body_digest, set_tonic_rolling_mutation_body_digest,
sign_ns_scanner_capability, sign_ns_scanner_capability_with_tier_registry_generation, sign_put_file_capability,
sign_tonic_rpc_response_proof, tonic_boot_epoch_challenge, tonic_boot_epoch_response_headers, tonic_rpc_auth_failure_reason,
verify_ns_scanner_capability, verify_ns_scanner_capability_with_tier_registry_generation, verify_put_file_auth_trailer,
verify_put_file_capability, verify_rpc_signature, verify_tonic_boot_epoch_response, verify_tonic_canonical_body_digest,
verify_tonic_mutation_body_digest, verify_tonic_mutation_body_digest_reject_unsigned, verify_tonic_rpc_response_proof,
verify_tonic_rpc_signature, verify_tonic_rpc_signature_with_bootstrap,
sign_ns_scanner_capability, sign_ns_scanner_capability_with_checkpoint_coverage,
sign_ns_scanner_capability_with_tier_registry_generation, sign_put_file_capability, sign_tonic_rpc_response_proof,
tonic_boot_epoch_challenge, tonic_boot_epoch_response_headers, tonic_rpc_auth_failure_reason, verify_ns_scanner_capability,
verify_ns_scanner_capability_with_checkpoint_coverage, verify_ns_scanner_capability_with_tier_registry_generation,
verify_put_file_auth_trailer, verify_put_file_capability, verify_rpc_signature, verify_tonic_boot_epoch_response,
verify_tonic_canonical_body_digest, verify_tonic_mutation_body_digest, verify_tonic_mutation_body_digest_reject_unsigned,
verify_tonic_rpc_response_proof, verify_tonic_rpc_signature, verify_tonic_rpc_signature_with_bootstrap,
};
#[cfg(test)]
pub(crate) use internode_data_transport::TcpHttpInternodeDataTransport;
@@ -631,6 +631,17 @@ fn decode_cross_pool_fence_capability(expected_member: &str, result: &[u8]) -> R
Ok((version, epoch))
}
fn decode_mrf_lifecycle_reader_capability(expected_member: &str, result: &[u8]) -> Result<(u8, Uuid)> {
let (&maximum_format_version, identity) = result
.split_first()
.ok_or_else(|| Error::other("peer returned a truncated MRF lifecycle reader capability"))?;
if maximum_format_version == 0 || expected_member.trim().is_empty() {
return Err(Error::other("peer returned an invalid MRF lifecycle reader capability"));
}
let epoch = decode_remote_version_state_capability(expected_member, identity)?;
Ok((maximum_format_version, epoch))
}
#[derive(Clone, Debug)]
pub struct PeerLiveEventsBatch {
pub events: Vec<u8>,
@@ -1964,6 +1975,16 @@ impl PeerRestClient {
Ok((self.topology_member.clone(), supported_version, epoch))
}
/// Reports one peer's reader support without authorizing a lifecycle writer or rollback.
pub async fn probe_mrf_lifecycle_reader(&self, topology_fingerprint: String) -> Result<(String, u8, Uuid)> {
let probe = rustfs_protos::mrf_lifecycle_reader_capability_probe(Uuid::new_v4().as_bytes());
let result = self
.heal_control(rustfs_protos::HEAL_CONTROL_PROTOCOL_VERSION, topology_fingerprint, probe)
.await?;
let (maximum_format_version, epoch) = decode_mrf_lifecycle_reader_capability(&self.topology_member, &result)?;
Ok((self.topology_member.clone(), maximum_format_version, epoch))
}
pub async fn probe_ilm_recovery_export(&self, topology_fingerprint: String) -> Result<(String, Uuid)> {
let probe = rustfs_protos::ilm_recovery_export_capability_probe(Uuid::new_v4().as_bytes());
let result = self
@@ -4333,6 +4354,49 @@ mod tests {
}
}
#[test]
fn mrf_lifecycle_reader_capability_decoder_preserves_the_advertised_version() {
let epoch = Uuid::new_v4();
for version in [1, 2, u8::MAX] {
let result = rustfs_protos::encode_mrf_lifecycle_reader_capability(version, "node-a:9000", epoch.as_bytes())
.expect("capability frame");
assert_eq!(
decode_mrf_lifecycle_reader_capability("node-a:9000", &result).expect("reported capability"),
(version, epoch)
);
}
}
#[test]
fn mrf_lifecycle_reader_capability_decoder_rejects_unbound_or_malformed_identity() {
let epoch = Uuid::new_v4();
let valid =
rustfs_protos::encode_mrf_lifecycle_reader_capability(2, "node-a:9000", epoch.as_bytes()).expect("capability frame");
let mut invalid = vec![Vec::new(), vec![2], valid[..valid.len() - 1].to_vec()];
let mut zero = valid.clone();
zero[0] = 0;
invalid.push(zero);
let mut trailing = valid.clone();
trailing.push(0);
invalid.push(trailing);
let mut length_overflow = valid.clone();
length_overflow[1..9].copy_from_slice(&u64::MAX.to_be_bytes());
invalid.push(length_overflow);
let mut invalid_utf8 = valid.clone();
invalid_utf8[9] = 0xff;
invalid.push(invalid_utf8);
invalid.push(
rustfs_protos::encode_mrf_lifecycle_reader_capability(2, "node-a:9000", Uuid::nil().as_bytes())
.expect("nil epoch fixture"),
);
for result in invalid {
assert!(decode_mrf_lifecycle_reader_capability("node-a:9000", &result).is_err());
}
assert!(decode_mrf_lifecycle_reader_capability("node-b:9000", &valid).is_err());
let empty = rustfs_protos::encode_mrf_lifecycle_reader_capability(2, "", epoch.as_bytes()).expect("empty member fixture");
assert!(decode_mrf_lifecycle_reader_capability("", &empty).is_err());
}
#[test]
fn remote_version_state_capability_decoder_fails_closed() {
let epoch = Uuid::new_v4();
@@ -417,8 +417,10 @@ impl S3PeerSys {
let errs = join_all(futures).await;
let mut pool_errs = Vec::new();
let mut all_pools_have_participants = true;
for pool_idx in 0..self.pools_count {
let per_pool_errs = pool_participant_errors(&self.clients, &errs, pool_idx);
all_pools_have_participants &= !per_pool_errs.is_empty();
pool_errs.push(reduce_pool_write_quorum_errs(&per_pool_errs));
}
@@ -451,6 +453,17 @@ impl S3PeerSys {
for pool_idx in 0..self.pools_count {
let per_pool_errs = pool_participant_errors(&self.clients, &errs, pool_idx);
if let Some(pool_err) = reduce_pool_write_quorum_errs(&per_pool_errs) {
// A success/busy split can miss quorum without a majority error.
// Keep that refusal retryable without masking other peer failures.
let pool_err = if pool_err == Error::ErasureWriteQuorum
&& all_pools_have_participants
&& per_pool_errs.iter().any(|err| err.as_ref() == Some(&Error::TooManyOpenFiles))
&& errs.iter().flatten().all(|err| *err == Error::TooManyOpenFiles)
{
Error::TooManyOpenFiles
} else {
pool_err
};
tracing::error!("heal_bucket per_pool_errs: {per_pool_errs:?}");
tracing::error!("heal_bucket reduce_write_quorum_errs: {pool_err}");
return Err(pool_err);
@@ -1810,6 +1823,7 @@ mod tests {
#[derive(Debug)]
struct TestPeerS3Client {
pools: Option<Vec<usize>>,
heal_bucket_result: Result<HealResultItem>,
make_bucket_result: Result<()>,
list_bucket_result: Result<Vec<BucketInfo>>,
delete_bucket_result: Result<()>,
@@ -1819,7 +1833,7 @@ mod tests {
#[async_trait]
impl PeerS3Client for TestPeerS3Client {
async fn heal_bucket(&self, _bucket: &str, _opts: &HealOpts) -> Result<HealResultItem> {
unreachable!("not used by quorum tests")
self.heal_bucket_result.clone()
}
async fn make_bucket(&self, _bucket: &str, _opts: &MakeBucketOptions) -> Result<()> {
@@ -1835,8 +1849,11 @@ mod tests {
self.delete_bucket_result.clone()
}
async fn get_bucket_info(&self, _bucket: &str, _opts: &BucketOptions) -> Result<BucketInfo> {
unreachable!("not used by quorum tests")
async fn get_bucket_info(&self, bucket: &str, _opts: &BucketOptions) -> Result<BucketInfo> {
Ok(BucketInfo {
name: bucket.to_string(),
..Default::default()
})
}
fn get_pools(&self) -> Option<Vec<usize>> {
@@ -1848,6 +1865,17 @@ mod tests {
test_peer_with_make_bucket(pools, Ok(()))
}
fn test_peer_with_heal_bucket(pools: &[usize], heal_bucket_result: Result<HealResultItem>) -> Client {
Arc::new(Box::new(TestPeerS3Client {
pools: Some(pools.to_vec()),
heal_bucket_result,
make_bucket_result: Ok(()),
list_bucket_result: Ok(Vec::new()),
delete_bucket_result: Ok(()),
make_bucket_calls: Arc::new(AtomicUsize::new(0)),
}))
}
fn test_peer_with_make_bucket(pools: &[usize], make_bucket_result: Result<()>) -> Client {
test_peer_with_results(pools, make_bucket_result, Ok(Vec::new()))
}
@@ -1885,6 +1913,7 @@ mod tests {
) -> Client {
Arc::new(Box::new(TestPeerS3Client {
pools: Some(pools.to_vec()),
heal_bucket_result: Ok(HealResultItem::default()),
make_bucket_result,
list_bucket_result,
delete_bucket_result,
@@ -2634,6 +2663,119 @@ mod tests {
assert_eq!(err, Error::VolumeExists);
}
#[tokio::test]
async fn test_heal_bucket_preserves_busy_refusal_below_error_quorum() {
for busy_count in 2..=4 {
let peer_sys = S3PeerSys {
clients: (0..4)
.map(|peer| {
test_peer_with_heal_bucket(
&[0],
if peer < busy_count {
Err(Error::TooManyOpenFiles)
} else {
Ok(HealResultItem::default())
},
)
})
.collect(),
pools_count: 1,
};
let error = peer_sys
.heal_bucket("bucket", &HealOpts::default())
.await
.expect_err("busy peers must not turn a failed heal into success");
assert_eq!(error, Error::TooManyOpenFiles, "busy peers: {busy_count}");
}
}
#[tokio::test]
async fn test_heal_bucket_busy_peer_preserves_success_quorum() {
let peer_sys = S3PeerSys {
clients: vec![
test_peer_with_heal_bucket(&[0], Err(Error::TooManyOpenFiles)),
test_peer(&[0]),
test_peer(&[0]),
test_peer(&[0]),
],
pools_count: 1,
};
peer_sys
.heal_bucket("bucket", &HealOpts::default())
.await
.expect("three successful peers still satisfy the four-peer write quorum");
}
#[tokio::test]
async fn test_heal_bucket_busy_peers_do_not_hide_other_failures() {
for failure in [Error::FaultyDisk, Error::VolumeNotFound, Error::ErasureWriteQuorum] {
let peer_sys = S3PeerSys {
clients: vec![
test_peer_with_heal_bucket(&[0], Err(Error::TooManyOpenFiles)),
test_peer_with_heal_bucket(&[0], Err(Error::TooManyOpenFiles)),
test_peer_with_heal_bucket(&[0], Err(failure.clone())),
test_peer(&[0]),
],
pools_count: 1,
};
let error = peer_sys
.heal_bucket("bucket", &HealOpts::default())
.await
.expect_err("a mixed failure must still fail write quorum");
assert_eq!(error, Error::ErasureWriteQuorum, "non-busy failure: {failure}");
}
}
#[tokio::test]
async fn test_heal_bucket_busy_pool_does_not_hide_another_pool_failure() {
let peer_sys = S3PeerSys {
clients: vec![
test_peer_with_heal_bucket(&[0], Err(Error::TooManyOpenFiles)),
test_peer_with_heal_bucket(&[0], Err(Error::TooManyOpenFiles)),
test_peer(&[0]),
test_peer(&[0]),
test_peer_with_heal_bucket(&[1], Err(Error::FaultyDisk)),
],
pools_count: 2,
};
let error = peer_sys
.heal_bucket("bucket", &HealOpts::default())
.await
.expect_err("the busy split must not hide a non-busy failure from another pool");
assert_eq!(error, Error::ErasureWriteQuorum);
}
#[tokio::test]
async fn test_heal_bucket_missing_pool_participants_remains_quorum_failure() {
for occupied_pool in 0..2 {
let peer_sys = S3PeerSys {
clients: vec![
test_peer_with_heal_bucket(&[occupied_pool], Err(Error::TooManyOpenFiles)),
test_peer_with_heal_bucket(&[occupied_pool], Err(Error::TooManyOpenFiles)),
test_peer(&[occupied_pool]),
test_peer(&[occupied_pool]),
],
pools_count: 2,
};
let error = peer_sys
.heal_bucket(
"bucket",
&HealOpts {
recreate: true,
..Default::default()
},
)
.await
.expect_err("a pool without participants cannot be treated as a busy gate");
assert_eq!(error, Error::ErasureWriteQuorum, "pool with participants: {occupied_pool}");
}
}
#[test]
fn heal_bucket_mode_fails_closed_on_incomplete_topology() {
let mut opts = HealOpts::default();
File diff suppressed because it is too large Load Diff
@@ -322,6 +322,19 @@ where
with_read_version_diagnostic_context(context, boundary, future)
}
/// Capture before spawning without inventing an entry for a generic task.
#[cfg(feature = "diagnostic-read-version-caller")]
pub fn with_captured_read_version_context<F>(
boundary: ReadVersionContextBoundary,
future: F,
) -> impl std::future::Future<Output = F::Output>
where
F: std::future::Future,
{
let context = ReadVersionDiagnosticContext::capture();
with_read_version_diagnostic_context(context, boundary, Box::pin(future))
}
/// Observe a named entry even when its scope retains an earlier source.
#[cfg(feature = "diagnostic-read-version-caller")]
pub fn observe_read_version_context_boundary(boundary: ReadVersionContextBoundary) {
@@ -1337,4 +1350,65 @@ mod tests {
assert_eq!(caller, Some(ReadVersionRequestCaller::Peer));
assert_eq!(current_read_version_request_caller(), None);
}
#[cfg(feature = "diagnostic-read-version-caller")]
#[tokio::test]
async fn captured_context_keeps_existing_values_without_creating_an_entry() {
use rustfs_io_metrics::s3_http_metrics::{
S3HttpRequestGuard, current_s3_http_operation_name, observe_s3_http_operation, with_s3_http_operation_context,
};
let mut request = S3HttpRequestGuard::new("PUT");
let operation = request.in_scope(|| {
observe_s3_http_operation(rustfs_s3_ops::S3Operation::PutObject);
current_s3_http_operation_context().expect("captured PUT operation")
});
for inherited in [false, true] {
let make_future = || {
with_captured_read_version_context(ReadVersionContextBoundary::BackgroundTask, async {
tokio::task::yield_now().await;
(
current_read_version_request_caller(),
current_read_version_entry_source(),
current_s3_http_operation_name(),
)
})
};
let captured = if inherited {
with_s3_http_operation_context(Some(operation), async {
with_read_version_request_caller(ReadVersionRequestCaller::Object, async {
with_read_version_entry(ReadVersionEntrySource::TierConfigReload, async { make_future() }).await
})
.await
})
.await
} else {
make_future()
};
assert_eq!(current_read_version_request_caller(), None);
assert_eq!(current_read_version_entry_source(), None);
let observed = tokio::spawn(captured).await.expect("captured child joins");
let expected = if inherited {
(
Some(ReadVersionRequestCaller::Object),
Some(ReadVersionEntrySource::TierConfigReload),
Some("s3:PutObject"),
)
} else {
(None, None, None)
};
assert_eq!(observed, expected);
assert_eq!(current_s3_http_operation_name(), None);
}
let polled = std::sync::atomic::AtomicBool::new(false);
with_read_version_entry(ReadVersionEntrySource::TierConfigReload, async {
let unpolled = with_captured_read_version_context(ReadVersionContextBoundary::BackgroundTask, async {
polled.store(true, std::sync::atomic::Ordering::Relaxed);
});
drop(unpolled);
})
.await;
assert!(!polled.load(std::sync::atomic::Ordering::Relaxed));
assert_eq!(current_read_version_entry_source(), None);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,327 @@
// 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::*;
const MINIO_CONFIG: &[u8] = include_bytes!("../../../tests/fixtures/server-config/minio-2025-09-07.json");
fn roundtrip(cfg: &Config, seed: &[u8]) -> (Config, Value) {
let encoded = encode_server_config_blob(cfg, Some(seed)).expect("encode config with seed");
let decoded = decode_server_config_blob(&encoded).expect("reload saved config");
let repeated = encode_server_config_blob(&decoded, Some(&encoded)).expect("encode reloaded config");
let encoded_value: Value = serde_json::from_slice(&encoded).unwrap();
let repeated_value: Value = serde_json::from_slice(&repeated).unwrap();
assert_eq!(encoded_value, repeated_value, "a second save must preserve JSON values");
(decoded, encoded_value)
}
#[test]
fn pinned_minio_config_preserves_unsupported_heal_settings_without_applying_them() {
assert!(
Config::unmarshal(MINIO_CONFIG).is_err(),
"the complete fixture exercises the external fallback"
);
let cfg = decode_server_config_blob(MINIO_CONFIG).expect("decode real MinIO config");
let heal = cfg.get_value(HEAL_SUB_SYS, DEFAULT_DELIMITER).unwrap_or_default();
for key in ["bitrotscan", "drive_workers", "max_io", "max_sleep"] {
assert!(heal.lookup(key).is_none(), "MinIO heal controls must not be mapped by name");
}
assert_eq!(
cfg.get_value(STORAGE_CLASS_SUB_SYS, DEFAULT_DELIMITER)
.unwrap()
.get("optimize"),
"availability"
);
let (_, saved) = roundtrip(&cfg, MINIO_CONFIG);
let original: Value = serde_json::from_slice(MINIO_CONFIG).unwrap();
assert_eq!(saved["heal"], original["heal"], "unsupported KVS entries survive unchanged");
assert_eq!(saved["cache"], original["cache"]);
}
#[test]
fn scalar_default_alias_reset_cannot_expose_shadow_overrides() {
for (subsystem, key, old, shadow, defaults) in [
(SCANNER_SUB_SYS, rustfs_config::SCANNER_CYCLE, "61", "59", &*scanner::DEFAULT_KVS),
(HEAL_SUB_SYS, rustfs_config::HEAL_BITROT_CYCLE, "off", "48h", &*heal::DEFAULT_KVS),
] {
let seed = serde_json::to_vec(&serde_json::json!({"version":"33", subsystem: {
"default": {key: old, "future":"preferred"},
"_": [{"key":key,"value":shadow,"future_attribute":"keep"}, {"key":"future","value":"shadow"}]
}}))
.unwrap();
let mut cfg = decode_server_config_blob(&seed).unwrap();
cfg.0.remove(subsystem);
let (decoded, saved) = roundtrip(&cfg, &seed);
assert_eq!(
decoded
.get_value(subsystem, DEFAULT_DELIMITER)
.unwrap_or_default()
.lookup(key)
.unwrap_or_else(|| defaults.get(key)),
defaults.get(key)
);
assert_eq!(saved[subsystem]["default"]["future"], "preferred");
assert_eq!(saved[subsystem]["_"][0]["value"], defaults.get(key));
assert_eq!(saved[subsystem]["_"][0]["future_attribute"], "keep");
assert_eq!(saved[subsystem]["_"][1]["value"], "shadow");
}
}
#[test]
fn root_aliases_keep_unknown_data_and_sync_known_values() {
let seed = br#"{"version":"33",
"storageclass":{"standard":"EC:2","future":"canonical"},
"storage_class":{"_":[{"key":"standard","value":"EC:4","extra":"keep"},{"key":"future","value":"native"}]},
"openid":{"default":{"config_url":"https://id.example/new","client_id":"new","future":"canonical"}},
"identity_openid":{"_":{"config_url":"https://id.example/old","client_id":"old","future":"alias"}},
"audit":{"webhook":{"default":{"enable":true,"endpoint":"https://audit.example/new","future":"audit"}}},
"logger":{"audit_webhook":{"_":{"enable":true,"endpoint":"https://audit.example/old","future":"logger"}}}
}"#;
let cfg = decode_server_config_blob(seed).unwrap();
let (decoded, saved) = roundtrip(&cfg, seed);
assert!(configs_semantically_equal(&cfg, &decoded));
assert_eq!(saved["storageclass"]["future"], "canonical");
assert_eq!(saved["storage_class"]["_"][0]["value"], "EC:2");
assert_eq!(saved["storage_class"]["_"][0]["extra"], "keep");
assert_eq!(saved["storage_class"]["_"][1]["value"], "native");
assert_eq!(saved["openid"]["default"]["future"], "canonical");
assert_eq!(saved["identity_openid"]["_"]["future"], "alias");
assert_eq!(saved["identity_openid"]["_"]["client_id"], "new");
assert_eq!(saved["audit"]["webhook"]["default"]["future"], "audit");
assert_eq!(saved["logger"]["audit_webhook"]["_"]["future"], "logger");
assert_eq!(saved["logger"]["audit_webhook"]["_"]["endpoint"], "https://audit.example/new");
}
#[test]
fn storageclass_nested_alias_reset_clears_inline_override_and_retains_attributes() {
let seed = br#"{"version":"33","storageclass":{"standard":"EC:2","_": [
{"key":"inline_block","value":"64KiB","extra":{"owner":"future"}},
{"key":"future","value":"keep"}]},
"storage_class":{"default":{"standard":"EC:4","future":"alias"}}}"#;
let (decoded, saved) = roundtrip(&Config::new(), seed);
assert_eq!(
decoded
.get_value(STORAGE_CLASS_SUB_SYS, DEFAULT_DELIMITER)
.unwrap()
.get("inline_block"),
storageclass::DEFAULT_KVS.get("inline_block")
);
assert_eq!(saved["storageclass"]["_"][0]["extra"]["owner"], "future");
assert_eq!(saved["storageclass"]["_"][1]["value"], "keep");
assert_eq!(saved["storage_class"]["default"]["future"], "alias");
}
#[test]
fn duplicate_known_kvs_entries_are_updated_consistently() {
let seed = br#"{"version":"33","scanner":[
{"key":"cycle","value":"61","extra":"first"},
{"key":"cycle","value":"62","extra":"last"}]}"#;
let mut cfg = decode_server_config_blob(seed).unwrap();
cfg.0
.get_mut(SCANNER_SUB_SYS)
.unwrap()
.get_mut(DEFAULT_DELIMITER)
.unwrap()
.insert("cycle".to_string(), "73".to_string());
let (decoded, saved) = roundtrip(&cfg, seed);
assert_eq!(decoded.get_value(SCANNER_SUB_SYS, DEFAULT_DELIMITER).unwrap().get("cycle"), "73");
for entry in saved["scanner"].as_array().unwrap() {
assert_eq!(entry["value"], "73");
}
}
#[test]
fn removed_target_with_kvs_attributes_stays_disabled_under_enabled_default() {
let seed = br#"{"version":"33","notify":{"webhook":{
"default":{"enable":true,"endpoint":"https://notify.example/default"},
"removed":[{"key":"endpoint","value":"https://notify.example/old","extra":"keep"}]
}}}"#;
let mut cfg = decode_server_config_blob(seed).unwrap();
cfg.0
.get_mut(rustfs_config::notify::NOTIFY_WEBHOOK_SUB_SYS)
.unwrap()
.remove("removed");
let (decoded, saved) = roundtrip(&cfg, seed);
let removed = decoded
.get_value(rustfs_config::notify::NOTIFY_WEBHOOK_SUB_SYS, "removed")
.unwrap();
assert_eq!(removed.get(ENABLE_KEY), "off");
assert_eq!(saved["notify"]["webhook"]["removed"][0]["extra"], "keep");
assert_eq!(saved["notify"]["webhook"]["removed"][0]["value"], "");
}
#[test]
fn empty_preferred_aliases_keep_presence_precedence_but_accept_new_settings() {
let seed = br#"{"version":"33","openid":null,"identity_openid":{"default":{"config_url":"https://old.example"}},
"audit":null,"logger":{"webhook":{"default":{"enable":true,"endpoint":"https://old.example"}}},
"scanner":{"default":null,"_":{"cycle":"59","future":"keep"}}}"#;
let cfg = decode_server_config_blob(seed).unwrap();
assert!(build_oidc_object(&cfg).is_empty());
assert!(build_audit_object(&cfg).is_empty());
assert!(build_scalar_config_object(&cfg, scanner_config_descriptor()).is_empty());
let (_, reset) = roundtrip(&cfg, seed);
assert!(reset["openid"].is_null(), "the preferred null alias must keep shadow config masked");
assert!(reset["identity_openid"]["default"].get("config_url").is_none());
let reset_cfg = decode_server_config_blob(&serde_json::to_vec(&reset).unwrap()).unwrap();
assert!(
build_oidc_object(&reset_cfg).is_empty(),
"the old shadow provider must stay unapplied after save"
);
let updated = decode_server_config_blob(br#"{"version":"33","openid":{"default":{"config_url":"https://new.example"}},"audit":{"webhook":{"default":{"enable":true,"endpoint":"https://new.example"}}},"scanner":{"cycle":"73"}}"#).unwrap();
let (decoded, _) = roundtrip(&updated, seed);
assert!(configs_semantically_equal(&updated, &decoded));
}
#[test]
fn storageclass_null_precedence_is_materialized_before_alias_resave() {
let seed = br#"{"version":"33","storageclass":null,"storage_class":{"standard":"EC:4","rrs":"EC:2","optimize":"capacity"}}"#;
let cfg = decode_server_config_blob(seed).unwrap();
let (_, saved) = roundtrip(&cfg, seed);
assert_eq!(saved["storageclass"]["standard"], "");
assert_eq!(saved["storageclass"]["rrs"], "");
assert_eq!(saved["storage_class"]["standard"], "");
assert_eq!(saved["storage_class"]["rrs"], "");
assert_eq!(saved["storage_class"]["optimize"], "availability");
}
#[derive(Clone, Default)]
struct LogCapture(std::sync::Arc<std::sync::Mutex<Vec<u8>>>);
impl std::io::Write for LogCapture {
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 LogCapture {
type Writer = Self;
fn make_writer(&'a self) -> Self {
self.clone()
}
}
fn captured_logs(run: impl FnOnce(&tracing::Dispatch)) -> String {
let capture = LogCapture::default();
let dispatch = tracing::Dispatch::new(
tracing_subscriber::fmt()
.without_time()
.with_ansi(false)
.with_writer(capture.clone())
.finish(),
);
tracing::dispatcher::with_default(&dispatch, || run(&dispatch));
String::from_utf8(capture.0.lock().unwrap().clone()).unwrap()
}
#[test]
fn compatibility_reports_are_bounded_summaries_and_never_log_untrusted_payloads() {
let make_blob = |prefix: &str, keys: usize| {
let mut settings = Map::new();
for i in 0..keys {
settings.insert(format!("SECRET_KEY_{prefix}_{i}"), Value::String("SECRET_VALUE".to_string()));
}
serde_json::to_vec(&serde_json::json!({"version":"33", "heal":settings, "scanner":"SECRET_LEGACY_PAYLOAD"})).unwrap()
};
let a = make_blob("a", 1000);
let b = make_blob("b", 65);
let logs = captured_logs(|dispatch| {
for _ in 0..100 {
decode_server_config_blob(&a).unwrap();
}
decode_server_config_blob(&b).unwrap();
decode_server_config_blob(&a).unwrap();
let barrier = std::sync::Barrier::new(12);
let concurrent = make_blob("concurrent", 80);
std::thread::scope(|scope| {
for _ in 0..12 {
let (barrier, concurrent) = (&barrier, &concurrent);
scope.spawn(move || {
tracing::dispatcher::with_default(dispatch, || {
barrier.wait();
decode_server_config_blob(concurrent).unwrap();
})
});
}
});
});
assert_eq!(
logs.matches(EVENT_SERVER_CONFIG_COMPATIBILITY_IGNORED).count(),
3,
"one summary per diagnostic set, including concurrent readers: {logs}"
);
assert!(logs.contains("unknown_key_count=1000"));
assert!(!logs.contains("SECRET"));
assert!(logs.len() < 2000, "log volume must not grow with unknown-key count");
}
#[test]
fn malformed_known_values_still_fail_without_a_successful_compatibility_warning() {
let logs = captured_logs(|_| {
for seed in [
br#"{"version":"33","heal":{"future":"secret","bitrot_cycle":null}}"#.as_slice(),
br#"{"version":"33","scanner":[{"key":"cycle","value":[]}]}"#.as_slice(),
br#"{"version":"33","scanner":[false]}"#.as_slice(),
br#"{"scanner":null,"heal":"SECRET"}"#.as_slice(),
] {
assert!(decode_server_config_blob(seed).is_err());
}
});
assert!(!logs.contains(EVENT_SERVER_CONFIG_COMPATIBILITY_IGNORED));
assert!(!logs.contains("secret"));
}
#[test]
#[serial_test::serial]
fn final_decode_failures_are_deduplicated_without_changing_error_classification() {
let previous = replace_server_config_decrypt_fn_for_test(None);
let logs = captured_logs(|_| {
for _ in 0..100 {
let err = decode_persisted_server_config(br#"{SECRET_CORRUPT_A"#).unwrap_err();
assert!(is_server_config_corrupt_error(&err));
}
assert!(decode_persisted_server_config(br#"{SECRET_CORRUPT_B"#).is_err());
assert!(decode_persisted_server_config(br#"{SECRET_CORRUPT_A"#).is_err());
replace_server_config_decrypt_fn_for_test(Some(std::sync::Arc::new(|_| None)));
for _ in 0..100 {
let err = decode_persisted_server_config(b"SECRET_CIPHERTEXT").unwrap_err();
assert!(is_server_config_decrypt_error(&err));
}
});
replace_server_config_decrypt_fn_for_test(previous);
assert_eq!(logs.matches(EVENT_SERVER_CONFIG_DECODE_FAILED).count(), 2);
assert_eq!(logs.matches(EVENT_SERVER_CONFIG_DECRYPT_FAILED).count(), 1);
assert!(!logs.contains("SECRET"));
}
#[test]
#[serial_test::serial]
fn pinned_minio_plaintext_exercises_the_legacy_decrypt_entry_without_false_errors() {
// The fixture is plaintext from MinIO; this envelope is produced by RustFS.
let encrypted = rustfs_crypto::encrypt_data(b"fixture-test-key", MINIO_CONFIG).unwrap();
let previous = replace_server_config_decrypt_fn_for_test(Some(std::sync::Arc::new(|data| {
rustfs_crypto::decrypt_data(b"fixture-test-key", data).ok()
})));
let logs = captured_logs(|_| {
let (cfg, seed) = decode_persisted_server_config_with_seed(&encrypted).unwrap();
assert_eq!(seed, MINIO_CONFIG);
roundtrip(&cfg, &seed);
});
replace_server_config_decrypt_fn_for_test(previous);
assert!(!logs.contains(EVENT_SERVER_CONFIG_DECODE_FAILED));
assert!(!logs.contains(EVENT_SERVER_CONFIG_DECRYPT_FAILED));
}
@@ -0,0 +1,206 @@
// 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 std::collections::VecDeque;
use std::hash::{DefaultHasher, Hash, Hasher};
use std::sync::{LazyLock, Mutex};
use std::time::{Duration, Instant};
use serde_json::Value;
use tracing::{error, warn};
use super::{
EVENT_SERVER_CONFIG_COMPATIBILITY_IGNORED, EVENT_SERVER_CONFIG_DECODE_FAILED, EVENT_SERVER_CONFIG_DECRYPT_FAILED,
HEAL_SUB_SYS, LOG_COMPONENT_CONFIG, LOG_SUBSYSTEM_CONFIG,
};
const RECENT_REPORTS: usize = 64;
const FAILURE_REPORT_INTERVAL: Duration = Duration::from_secs(300);
#[derive(Default)]
struct Reports(VecDeque<Report>);
struct Report {
key: u64,
reported: Instant,
suppressed: u64,
}
impl Reports {
fn observe(&mut self, key: u64, now: Instant, repeat: Option<Duration>) -> Option<u64> {
if let Some(index) = self.0.iter().position(|report| report.key == key) {
let mut report = self.0.remove(index)?;
let emit = repeat.is_some_and(|interval| now.saturating_duration_since(report.reported) >= interval);
let suppressed = report.suppressed;
if emit {
report.reported = now;
report.suppressed = 0;
} else {
report.suppressed = report.suppressed.saturating_add(1);
}
self.0.push_back(report);
return emit.then_some(suppressed);
}
if self.0.len() == RECENT_REPORTS {
self.0.pop_front();
}
self.0.push_back(Report {
key,
reported: now,
suppressed: 0,
});
Some(0)
}
}
static COMPATIBILITY_REPORTS: LazyLock<Mutex<Reports>> = LazyLock::new(Mutex::default);
static FAILURE_REPORTS: LazyLock<Mutex<Reports>> = LazyLock::new(Mutex::default);
fn observe(reports: &Mutex<Reports>, key: u64, repeat: Option<Duration>) -> Option<u64> {
// Never hold this process-local diagnostics lock across tracing or I/O.
reports
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.observe(key, Instant::now(), repeat)
}
#[derive(Default)]
pub(super) struct CompatibilityDiagnostics {
digest: DefaultHasher,
unknown_keys: usize,
legacy_shapes: usize,
legacy_heal_keys: u8,
shape_kinds: u8,
}
const LEGACY_HEAL_KEYS: [&str; 4] = ["bitrotscan", "drive_workers", "max_io", "max_sleep"];
const SHAPE_KINDS: [&str; 6] = ["null", "boolean", "number", "string", "array", "object"];
fn shape(value: &Value) -> usize {
match value {
Value::Null => 0,
Value::Bool(_) => 1,
Value::Number(_) => 2,
Value::String(_) => 3,
Value::Array(_) => 4,
Value::Object(_) => 5,
}
}
impl CompatibilityDiagnostics {
pub(super) fn unknown_key(&mut self, subsystem: &str, key: &str) {
("unknown_key", subsystem, key).hash(&mut self.digest);
self.unknown_keys += 1;
if subsystem == HEAL_SUB_SYS
&& let Some(index) = LEGACY_HEAL_KEYS.iter().position(|known| *known == key)
{
self.legacy_heal_keys |= 1 << index;
}
}
pub(super) fn legacy_shape(&mut self, subsystem: &str, value: &Value) {
let kind = shape(value);
("legacy_shape", subsystem, kind).hash(&mut self.digest);
self.legacy_shapes += 1;
self.shape_kinds |= 1 << kind;
}
pub(super) fn report(&self) {
if self.unknown_keys == 0 && self.legacy_shapes == 0 {
return;
}
if observe(&COMPATIBILITY_REPORTS, self.digest.finish(), None).is_none() {
return;
}
let legacy_heal_keys = LEGACY_HEAL_KEYS
.iter()
.enumerate()
.filter_map(|(index, key)| (self.legacy_heal_keys & (1 << index) != 0).then_some(*key))
.collect::<Vec<_>>();
let legacy_shape_kinds = SHAPE_KINDS
.iter()
.enumerate()
.filter_map(|(index, kind)| (self.shape_kinds & (1 << index) != 0).then_some(*kind))
.collect::<Vec<_>>();
warn!(
event = EVENT_SERVER_CONFIG_COMPATIBILITY_IGNORED,
component = LOG_COMPONENT_CONFIG,
subsystem = LOG_SUBSYSTEM_CONFIG,
state = "ignored",
unknown_key_count = self.unknown_keys,
legacy_shape_count = self.legacy_shapes,
?legacy_heal_keys,
?legacy_shape_kinds,
"Persisted server config contains unsupported settings; unknown fields are retained but not applied"
);
}
}
#[derive(Hash)]
pub(super) enum DecodeFailure {
Corrupt,
Decrypt,
}
pub(super) fn report_decode_failure(data: &[u8], failure: DecodeFailure, encrypted_size: Option<usize>) {
let mut digest = DefaultHasher::new();
failure.hash(&mut digest);
data.hash(&mut digest);
let Some(suppressed_attempts) = observe(&FAILURE_REPORTS, digest.finish(), Some(FAILURE_REPORT_INTERVAL)) else {
return;
};
let event = match failure {
DecodeFailure::Corrupt => EVENT_SERVER_CONFIG_DECODE_FAILED,
DecodeFailure::Decrypt => EVENT_SERVER_CONFIG_DECRYPT_FAILED,
};
error!(
event,
component = LOG_COMPONENT_CONFIG,
subsystem = LOG_SUBSYSTEM_CONFIG,
state = "failed",
size = data.len(),
encrypted_size,
suppressed_attempts,
"Persisted server config could not be decoded; the read remains failed"
);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reports_keep_history_without_confusing_keys_with_versions() {
let mut reports = Reports::default();
let now = Instant::now();
assert_eq!(reports.observe(1, now, None), Some(0));
assert_eq!(reports.observe(2, now, None), Some(0));
assert_eq!(reports.observe(1, now, None), None);
for key in 3..=65 {
assert_eq!(reports.observe(key, now, None), Some(0));
}
assert_eq!(reports.0.len(), RECENT_REPORTS);
assert_eq!(reports.observe(2, now, None), Some(0), "only the least recently used entry is evicted");
}
#[test]
fn failures_report_at_the_interval_boundary_and_count_suppressed_reads() {
let mut reports = Reports::default();
let now = Instant::now();
assert_eq!(reports.observe(1, now, Some(FAILURE_REPORT_INTERVAL)), Some(0));
assert_eq!(reports.observe(1, now + Duration::from_secs(299), Some(FAILURE_REPORT_INTERVAL)), None);
assert_eq!(reports.observe(1, now + FAILURE_REPORT_INTERVAL, Some(FAILURE_REPORT_INTERVAL)), Some(1));
assert_eq!(reports.observe(2, now + FAILURE_REPORT_INTERVAL, Some(FAILURE_REPORT_INTERVAL)), Some(0));
}
}
+11 -5
View File
@@ -17,12 +17,13 @@ use rustfs_config::{
DEFAULT_SCANNER_BITROT_CYCLE_SECS, DEFAULT_SCANNER_CACHE_SAVE_TIMEOUT_SECS, DEFAULT_SCANNER_CYCLE_MAX_DIRECTORIES,
DEFAULT_SCANNER_CYCLE_MAX_DURATION_SECS, DEFAULT_SCANNER_CYCLE_MAX_OBJECTS, DEFAULT_SCANNER_DISK_IO_BUDGET,
DEFAULT_SCANNER_IDLE_MODE, DEFAULT_SCANNER_MAX_CONCURRENT_DISK_SCANS, DEFAULT_SCANNER_MAX_CONCURRENT_SET_SCANS,
DEFAULT_SCANNER_SPEED, DEFAULT_SCANNER_WALKERS_PER_DISK, DEFAULT_SCANNER_YIELD_EVERY_N_OBJECTS, SCANNER_ALERT_EXCESS_FOLDERS,
SCANNER_ALERT_EXCESS_VERSION_SIZE, SCANNER_ALERT_EXCESS_VERSIONS, SCANNER_BITROT_CYCLE, SCANNER_CACHE_SAVE_TIMEOUT,
SCANNER_CYCLE, SCANNER_CYCLE_MAX_DIRECTORIES, SCANNER_CYCLE_MAX_DURATION, SCANNER_CYCLE_MAX_OBJECTS, SCANNER_DELAY,
DEFAULT_SCANNER_RECOVERY_BACKOFF_CAP_SECS, DEFAULT_SCANNER_SPEED, DEFAULT_SCANNER_WALKERS_PER_DISK,
DEFAULT_SCANNER_YIELD_EVERY_N_OBJECTS, SCANNER_ALERT_EXCESS_FOLDERS, SCANNER_ALERT_EXCESS_VERSION_SIZE,
SCANNER_ALERT_EXCESS_VERSIONS, SCANNER_BITROT_CYCLE, SCANNER_CACHE_SAVE_TIMEOUT, SCANNER_CYCLE,
SCANNER_CYCLE_MAX_DIRECTORIES, SCANNER_CYCLE_MAX_DURATION, SCANNER_CYCLE_MAX_OBJECTS, SCANNER_DELAY,
SCANNER_DISK_IO_BUDGET_BYTES_PER_SEC, SCANNER_DISK_IO_BUDGET_IOPS, SCANNER_IDLE_MODE, SCANNER_MAX_CONCURRENT_DISK_SCANS,
SCANNER_MAX_CONCURRENT_SET_SCANS, SCANNER_MAX_WAIT, SCANNER_SPEED, SCANNER_START_DELAY, SCANNER_WALKERS_PER_DISK,
SCANNER_YIELD_EVERY_N_OBJECTS,
SCANNER_MAX_CONCURRENT_SET_SCANS, SCANNER_MAX_WAIT, SCANNER_RECOVERY_BACKOFF_CAP, SCANNER_SPEED, SCANNER_START_DELAY,
SCANNER_WALKERS_PER_DISK, SCANNER_YIELD_EVERY_N_OBJECTS,
};
use std::sync::LazyLock;
@@ -83,6 +84,11 @@ pub static DEFAULT_KVS: LazyLock<KVS> = LazyLock::new(|| {
value: DEFAULT_SCANNER_CACHE_SAVE_TIMEOUT_SECS.to_string(),
hidden_if_empty: false,
},
KV {
key: SCANNER_RECOVERY_BACKOFF_CAP.to_owned(),
value: DEFAULT_SCANNER_RECOVERY_BACKOFF_CAP_SECS.to_string(),
hidden_if_empty: false,
},
KV {
key: SCANNER_MAX_CONCURRENT_SET_SCANS.to_owned(),
value: DEFAULT_SCANNER_MAX_CONCURRENT_SET_SCANS.to_string(),
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+31
View File
@@ -99,6 +99,33 @@ pub struct Sets {
ctx: Arc<InstanceContext>,
}
#[cfg(test)]
impl Sets {
pub(crate) fn from_test_set(
set: Arc<SetDisks>,
endpoints: PoolEndpoints,
format: FormatV3,
pool_idx: usize,
parity_count: usize,
ctx: Arc<InstanceContext>,
) -> Arc<Self> {
Arc::new(Self {
id: format.id,
disk_set: vec![set],
pool_idx,
set_count: 1,
set_drive_count: endpoints.drives_per_set,
endpoints,
format,
parity_count,
default_parity_count: parity_count,
distribution_algo: DistributionAlgoVersion::V1,
exit_signal: None,
ctx,
})
}
}
impl Drop for Sets {
fn drop(&mut self) {
if let Some(exit_signal) = self.exit_signal.take() {
@@ -800,6 +827,10 @@ impl crate::storage_api_contracts::object::ObjectOperations for Sets {
mod_time: dst_opts.mod_time,
http_preconditions: dst_opts.http_preconditions.clone(),
quota_admission: dst_opts.quota_admission,
expected_bucket_incarnation_id: dst_opts.expected_bucket_incarnation_id,
bucket_lifecycle_lock_fence: dst_opts.bucket_lifecycle_lock_fence.clone(),
bucket_incarnation_fence: dst_opts.bucket_incarnation_fence.clone(),
object_lock_config_snapshot: dst_opts.object_lock_config_snapshot.clone(),
..Default::default()
};
@@ -0,0 +1,128 @@
// 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.
//! Test-only barriers inside the data-movement commit windows.
//!
//! Compiled only with the `e2e-test-hooks` feature and inert unless
//! `RUSTFS_E2E_DATA_MOVEMENT_BARRIER_DIR` names an absolute directory holding
//! an `arm.json`. A mover that matches the arm records one `reached.*.json`
//! observation and then blocks until `release.<nonce>` exists. A release body
//! of `io_error` fails the window instead of continuing, so a fixture can
//! inject a target-commit or source-cleanup failure at a proven point. The
//! observation file is the only evidence that an injection reached its window;
//! a fixture must not count an unreached barrier as a fault test.
use std::io::ErrorKind;
use std::path::{Path, PathBuf};
use std::time::Duration;
use tokio::fs;
use tokio::time::Instant;
pub(crate) const BARRIER_DIR_ENV: &str = "RUSTFS_E2E_DATA_MOVEMENT_BARRIER_DIR";
/// Every target part is staged and the target version is not yet published.
pub(crate) const STAGE_BEFORE_TARGET_COMMIT: &str = "before_target_commit";
/// The target commit is durable and the source preflight matched; the source
/// delete has not been issued.
pub(crate) const STAGE_BEFORE_SOURCE_CLEANUP: &str = "before_source_cleanup";
/// A decommission start is durable in `pool.bin`; no worker has started and
/// the admin request has not been acknowledged.
pub(crate) const STAGE_AFTER_RESPONSIBILITY_PERSISTED: &str = "after_responsibility_persisted";
const RELEASE_TIMEOUT: Duration = Duration::from_secs(120);
const RELEASE_POLL: Duration = Duration::from_millis(20);
const RELEASE_IO_ERROR: &[u8] = b"io_error";
#[derive(serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct BarrierArm {
nonce: String,
bucket: String,
object: String,
stage: String,
/// `decommission` or `rebalance`; absent matches either mover. Movers label
/// their stages with that word or a `<word>_` prefix (`decommission_object`).
#[serde(default)]
operation: Option<String>,
}
fn valid_nonce(nonce: &str) -> bool {
!nonce.is_empty()
&& nonce.len() <= 64
&& nonce
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_')
}
async fn read_arm(directory: &Path) -> std::io::Result<Option<BarrierArm>> {
match fs::read(directory.join("arm.json")).await {
Ok(bytes) => serde_json::from_slice(&bytes).map(Some).map_err(std::io::Error::other),
Err(err) if err.kind() == ErrorKind::NotFound => Ok(None),
Err(err) => Err(err),
}
}
/// Hold `operation` at `stage` for `bucket/object` while a matching arm exists.
pub(crate) async fn wait(operation: &str, bucket: &str, object: &str, stage: &str) -> std::io::Result<()> {
let Some(directory) = std::env::var_os(BARRIER_DIR_ENV).map(PathBuf::from) else {
return Ok(());
};
let Some(arm) = read_arm(&directory).await? else {
return Ok(());
};
if arm.bucket != bucket
|| arm.object != object
|| arm.stage != stage
|| arm
.operation
.as_deref()
.is_some_and(|armed| armed != operation && !operation.starts_with(&format!("{armed}_")))
{
return Ok(());
}
if !directory.is_absolute() || !valid_nonce(&arm.nonce) {
return Err(std::io::Error::new(
ErrorKind::InvalidInput,
"data movement test barrier requires an absolute directory and a safe nonce",
));
}
let pid = std::process::id();
let observation = serde_json::json!({
"operation": operation,
"bucket": bucket,
"object": object,
"stage": stage,
"pid": pid,
"nonce": arm.nonce,
"reached_at_unix_ms": time::OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000_000,
});
let reached = directory.join(format!("reached.{}.{pid}.{}.json", arm.nonce, uuid::Uuid::new_v4().simple()));
fs::write(&reached, serde_json::to_vec(&observation)?).await?;
let release = directory.join(format!("release.{}", arm.nonce));
let deadline = Instant::now() + RELEASE_TIMEOUT;
loop {
match fs::read(&release).await {
Ok(body) if body == RELEASE_IO_ERROR => {
// The reached observation already names the stage and object.
return Err(std::io::Error::other("injected data movement barrier failure"));
}
Ok(_) => return Ok(()),
Err(err) if err.kind() == ErrorKind::NotFound => {}
Err(err) => return Err(err),
}
if Instant::now() >= deadline {
return Err(std::io::Error::new(ErrorKind::TimedOut, "data movement test barrier release timed out"));
}
tokio::time::sleep(RELEASE_POLL).await;
}
}
+471 -60
View File
@@ -15,9 +15,12 @@
// #730: data-movement migration keeps staged cleanup helpers until copy paths converge.
pub(crate) mod backpressure;
#[cfg(feature = "e2e-test-hooks")]
pub(crate) mod e2e_barrier;
pub(crate) mod scanner_backlog;
use crate::core::pools::{DecommissionCapacityOwner, decommission_capacity_mutation_id};
use crate::disk::error::DiskError;
use crate::error::{
Error, Result, is_err_data_movement_overwrite, is_err_invalid_upload_id, is_err_object_not_found, is_err_version_not_found,
};
@@ -28,7 +31,7 @@ use crate::storage_api_contracts::{
namespace::NamespaceLocking as _,
object::{HTTPPreconditions, ObjectOperations as _},
};
use crate::store::{DecommissionFixedReadAnchor, ECStore, SourceCleanupMutationFence};
use crate::store::{BucketIncarnationFenceGuard, DecommissionFixedReadAnchor, ECStore, SourceCleanupMutationFence};
use bytes::Bytes;
use rustfs_filemeta::{FileInfo, FileInfoVersions, ObjectPartInfo};
use rustfs_rio::{EtagResolvable, HashReader, HashReaderDetector, Index, TryGetIndex};
@@ -71,7 +74,9 @@ impl<R> IndexedDataMovementReader<R> {
impl<R: AsyncRead + Unpin + Send + Sync> AsyncRead for IndexedDataMovementReader<R> {
fn poll_read(mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll<std::io::Result<()>> {
Pin::new(&mut self.inner).poll_read(cx, buf)
Pin::new(&mut self.inner)
.poll_read(cx, buf)
.map(|result| result.map_err(data_movement_source_stream_error))
}
}
@@ -291,7 +296,7 @@ fn data_movement_new_multipart_opts(object_info: &ObjectInfo, src_pool_idx: usiz
rustfs_utils::http::insert_str(&mut user_defined, SUFFIX_DATA_MOVEMENT_UPLOAD, upload_identity);
ObjectOptions {
versioned: object_info.version_id.is_some(),
version_id: object_info.version_id.as_ref().map(|v| v.to_string()),
version_id: Some(data_movement_exact_version_id(object_info.version_id)),
user_defined,
preserve_etag: object_info.etag.clone(),
src_pool_idx,
@@ -461,7 +466,7 @@ fn data_movement_complete_multipart_opts(
}
Ok(ObjectOptions {
versioned: object_info.version_id.is_some(),
version_id: object_info.version_id.as_ref().map(|v| v.to_string()),
version_id: Some(data_movement_exact_version_id(object_info.version_id)),
http_preconditions: Some(data_movement_target_precondition()),
data_movement: true,
mod_time: object_info.mod_time,
@@ -478,7 +483,7 @@ fn data_movement_put_object_opts(object_info: &ObjectInfo, src_pool_idx: usize)
src_pool_idx,
data_movement: true,
shard_integrity_write_mode: Some(object_info.shard_integrity_write_mode()),
version_id: object_info.version_id.as_ref().map(|v| v.to_string()),
version_id: Some(data_movement_exact_version_id(object_info.version_id)),
http_preconditions: Some(data_movement_target_precondition()),
mod_time: object_info.mod_time,
user_defined: data_movement_user_defined(object_info),
@@ -487,6 +492,15 @@ fn data_movement_put_object_opts(object_info: &ObjectInfo, src_pool_idx: usize)
}
}
/// Exact `ObjectOptions::version_id` selector for one migrated version.
///
/// `None` selects the latest version, so the null version (stored with an
/// absent or nil UUID) is addressed by the nil UUID. Otherwise a source read or
/// target lookup resolves to a newer version or a delete marker instead.
pub(crate) fn data_movement_exact_version_id(version_id: Option<uuid::Uuid>) -> String {
version_id.unwrap_or_else(uuid::Uuid::nil).to_string()
}
fn is_unversioned_data_movement_object(object_info: &ObjectInfo) -> bool {
object_info.version_id.is_none_or(|version_id| version_id.is_nil())
}
@@ -532,11 +546,12 @@ pub(crate) fn can_replace_stale_data_movement_target(target: &ObjectInfo, opts:
return false;
}
let version_matches = match (opts.version_id.as_deref(), target.version_id) {
(None, None) => true,
(Some(expected), Some(actual)) => uuid::Uuid::parse_str(expected).ok() == Some(actual),
_ => false,
// The null version is addressed by the nil UUID but surfaces without an id.
let expected_version = match opts.version_id.as_deref() {
Some(expected) => uuid::Uuid::parse_str(expected).ok(),
None => Some(uuid::Uuid::nil()),
};
let version_matches = expected_version == Some(target.version_id.unwrap_or_else(uuid::Uuid::nil));
version_matches
&& target
@@ -635,11 +650,16 @@ pub(crate) fn data_movement_stage_source(err: &Error) -> Option<&Error> {
let Error::Io(io_err) = err else {
return None;
};
io_err
.get_ref()?
.downcast_ref::<DataMovementStageError>()?
.source
.downcast_ref::<Error>()
let source = io_err.get_ref()?;
if let Some(failure) = source.downcast_ref::<DataMovementSourceReadFailure>() {
return Some(&failure.0);
}
let source = &source.downcast_ref::<DataMovementStageError>()?.source;
source.downcast_ref::<Error>().or_else(|| {
source
.downcast_ref::<DataMovementSourceReadFailure>()
.map(|failure| &failure.0)
})
}
/// Recover a concrete typed error wrapped by [`data_movement_stage_error`].
@@ -657,6 +677,59 @@ where
.downcast_ref::<T>()
}
#[derive(Debug, thiserror::Error)]
#[error("{0}")]
struct DataMovementSourceReadFailure(#[source] Error);
pub(crate) fn is_data_movement_source_read_error(err: &Error) -> bool {
if data_movement_stage_source_as::<DataMovementSourceReadFailure>(err).is_some()
|| matches!(err, Error::Io(io_err) if io_err.get_ref().is_some_and(|source| source.is::<DataMovementSourceReadFailure>()))
{
return true;
}
data_movement_stage_source(err).is_some_and(is_data_movement_source_read_error)
}
fn data_movement_source_stream_error(err: std::io::Error) -> std::io::Error {
let Some(source) = err.get_ref() else {
return err;
};
let is_quorum = matches!(
source.downcast_ref::<Error>(),
Some(
Error::ErasureReadQuorum
| Error::ErasureWriteQuorum
| Error::InsufficientReadQuorum(_, _)
| Error::InsufficientWriteQuorum(_, _)
)
) || matches!(
source.downcast_ref::<DiskError>(),
Some(DiskError::ErasureReadQuorum | DiskError::ErasureWriteQuorum)
);
if !is_quorum {
return err;
}
std::io::Error::new(err.kind(), DataMovementSourceReadFailure(Error::from(err)))
}
/// Keep source metadata quorum failures distinct from target writes so
/// decommission retains its per-object failure policy.
pub(crate) fn data_movement_source_read_error(bucket: &str, object: &str, err: Error) -> Error {
if matches!(
err,
Error::ErasureReadQuorum
| Error::ErasureWriteQuorum
| Error::InsufficientReadQuorum(_, _)
| Error::InsufficientWriteQuorum(_, _)
) {
return data_movement_context_error(
format!("data movement source read failed for {bucket}/{object}: {err}"),
DataMovementSourceReadFailure(err),
);
}
err
}
fn schedule_data_movement_multipart_abort_cleanup(
store: Arc<ECStore>,
target_pool_idx: usize,
@@ -1118,6 +1191,43 @@ pub(crate) fn source_cleanup_version_identity(version: &FileInfo) -> SourceClean
}
}
/// Source versions whose transitioned remote object a committed data-movement
/// copy now references from the target pool.
///
/// Removing a transitioned version from xl.meta normally appends a tier free
/// version that owns the remote delete. After a move the target copy is the
/// live reference to that remote object, so the source must not get an owner
/// of its own: it would fail the decommission post-check and could only be
/// consumed once the target copy is gone. A version that did not move keeps
/// its free version: one lifecycle expired during the move, or one a newer
/// unversioned target object superseded ([`DataMovementCopy::Superseded`]).
#[doc(hidden)]
#[derive(Clone)]
pub struct TransferredTierVersions(Arc<[SourceCleanupVersionIdentity]>);
impl TransferredTierVersions {
fn new(versions: &[SourceCleanupVersionIdentity]) -> Option<Self> {
(!versions.is_empty()).then(|| Self(versions.iter().cloned().map(Self::without_name).collect()))
}
// Every version of one cleanup belongs to the same key, but the mover and
// the delete name a directory object differently.
fn without_name(mut identity: SourceCleanupVersionIdentity) -> SourceCleanupVersionIdentity {
identity.name.clear();
identity
}
pub(crate) fn contains(&self, version: &FileInfo) -> bool {
self.0.contains(&Self::without_name(source_cleanup_version_identity(version)))
}
}
impl std::fmt::Debug for TransferredTierVersions {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_tuple("TransferredTierVersions").field(&self.0.len()).finish()
}
}
fn source_cleanup_versions_match_with_allowed_missing(
expected: &FileInfoVersions,
current: &FileInfoVersions,
@@ -1168,11 +1278,40 @@ pub(crate) enum SourceCleanupError {
#[derive(Clone, Default)]
pub(crate) struct SourceCleanupBucketFence<'a> {
pub(crate) expected_incarnation_id: Option<uuid::Uuid>,
pub(crate) lifecycle_owner: Option<&'a Arc<BucketIncarnationFenceGuard>>,
pub(crate) lifecycle_guard: Option<&'a rustfs_lock::NamespaceLockGuard>,
pub(crate) namespace_lock_lost_signal: Option<Arc<rustfs_lock::distributed_lock::LockLostSignal>>,
pub(crate) object_mutation_fence: Option<&'a SourceCleanupMutationFence>,
}
/// Removes versions a mover skipped as not found from the cleanup snapshot.
///
/// "Not found" only makes a version safe to clean up if it is really gone.
/// Without it in the snapshot, the preflight reports `SourceChanged` while
/// that exact version still exists instead of deleting it with the entry.
pub(crate) fn source_cleanup_expected_without_vanished<'a>(
expected: &'a FileInfoVersions,
vanished: &[SourceCleanupVersionIdentity],
) -> std::borrow::Cow<'a, FileInfoVersions> {
if vanished.is_empty() {
return std::borrow::Cow::Borrowed(expected);
}
let mut remaining = vanished.to_vec();
let mut narrowed = expected.clone();
narrowed.versions.retain(|version| {
let identity = source_cleanup_version_identity(version);
match remaining.iter().position(|vanished| *vanished == identity) {
Some(index) => {
remaining.swap_remove(index);
false
}
None => true,
}
});
std::borrow::Cow::Owned(narrowed)
}
fn ensure_source_cleanup_versions_match(
expected: &FileInfoVersions,
current: &FileInfoVersions,
@@ -1319,12 +1458,16 @@ async fn pause_source_cleanup_before_delete(bucket: &str, object: &str) {
}
}
/// `transferred_tier_versions` lists the transitioned versions whose copy
/// settled as [`DataMovementCopy::Committed`]; see [`TransferredTierVersions`].
#[allow(clippy::too_many_arguments)]
pub(crate) async fn cleanup_source_entry_if_unchanged(
set: Arc<SetDisks>,
bucket: &str,
object: &str,
expected: &FileInfoVersions,
allowed_missing: &[SourceCleanupVersionIdentity],
transferred_tier_versions: &[SourceCleanupVersionIdentity],
bucket_fence: SourceCleanupBucketFence<'_>,
op_label: &str,
) -> std::result::Result<ObjectInfo, SourceCleanupError> {
@@ -1344,9 +1487,10 @@ pub(crate) async fn cleanup_source_entry_if_unchanged(
)
};
if bucket_fence
.lifecycle_guard
.is_some_and(rustfs_lock::NamespaceLockGuard::is_lock_lost)
if bucket_fence.lifecycle_owner.is_some_and(|owner| owner.is_lock_lost())
|| bucket_fence
.lifecycle_guard
.is_some_and(rustfs_lock::NamespaceLockGuard::is_lock_lost)
{
return Err(SourceCleanupError::Storage(Error::other(format!(
"{op_label}: bucket incarnation fence was lost before source cleanup"
@@ -1365,11 +1509,16 @@ pub(crate) async fn cleanup_source_entry_if_unchanged(
#[cfg(any(test, feature = "test-util"))]
pause_source_cleanup_before_delete(bucket, object).await;
#[cfg(feature = "e2e-test-hooks")]
e2e_barrier::wait(op_label, bucket, object, e2e_barrier::STAGE_BEFORE_SOURCE_CLEANUP)
.await
.map_err(|err| SourceCleanupError::Storage(Error::from(err)))?;
let mut opts = ObjectOptions {
delete_prefix: true,
delete_prefix_object: true,
data_movement: true,
data_movement_transferred_tier_versions: TransferredTierVersions::new(transferred_tier_versions),
no_lock: true,
expected_bucket_incarnation_id: bucket_fence.expected_incarnation_id,
..Default::default()
@@ -1380,6 +1529,9 @@ pub(crate) async fn cleanup_source_entry_if_unchanged(
if let Some(object_mutation_fence) = bucket_fence.object_mutation_fence {
object_mutation_fence.add_namespace_lock_fence(&mut opts);
}
if let Some(owner) = bucket_fence.lifecycle_owner {
owner.attach_owner(&mut opts);
}
if let Some(bucket_lifecycle_guard) = bucket_fence.lifecycle_guard {
opts.add_bucket_lifecycle_lock_guard(bucket_lifecycle_guard);
}
@@ -1405,7 +1557,7 @@ async fn find_data_movement_target_info(
) -> Result<Option<ObjectInfo>> {
let opts = ObjectOptions {
versioned: object_info.version_id.is_some(),
version_id: object_info.version_id.as_ref().map(|v| v.to_string()),
version_id: Some(data_movement_exact_version_id(object_info.version_id)),
no_lock: true,
include_part_checksums: true,
..Default::default()
@@ -1425,6 +1577,19 @@ async fn find_data_movement_target_info(
}
}
/// How a data-movement copy settled on its target pool.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum DataMovementCopy {
/// The target holds this exact source version, committed by this copy or
/// found equivalent from an earlier attempt, so it also holds any remote
/// tier tuple of the version.
Committed,
/// A newer unversioned object on the target replaced the source. The
/// target holds no copy of the source and no reference to its remote tier
/// object.
Superseded,
}
#[allow(dead_code, reason = "resume adjudication asserted by this file's tests (backlog#1823)")]
fn resolve_data_movement_overwrite_resume_result(
err: &Error,
@@ -1433,16 +1598,18 @@ fn resolve_data_movement_overwrite_resume_result(
src_pool_idx: usize,
target_pool_idx: usize,
) -> Result<bool> {
resolve_data_movement_overwrite_resume_result_for(
Ok(resolve_data_movement_overwrite_resume_copy(
err,
target_result,
source,
src_pool_idx,
target_pool_idx,
data_movement_part_checksum_writer_enabled(),
)
)?
.is_some())
}
#[cfg(test)]
fn resolve_data_movement_overwrite_resume_result_for(
err: &Error,
target_result: Result<Option<ObjectInfo>>,
@@ -1451,30 +1618,47 @@ fn resolve_data_movement_overwrite_resume_result_for(
target_pool_idx: usize,
compare_part_checksums: bool,
) -> Result<bool> {
Ok(resolve_data_movement_overwrite_resume_copy(
err,
target_result,
source,
src_pool_idx,
target_pool_idx,
compare_part_checksums,
)?
.is_some())
}
fn resolve_data_movement_overwrite_resume_copy(
err: &Error,
target_result: Result<Option<ObjectInfo>>,
source: &ObjectInfo,
src_pool_idx: usize,
target_pool_idx: usize,
compare_part_checksums: bool,
) -> Result<Option<DataMovementCopy>> {
if scanner_backlog::is_scanner_pause_backlog(&source.bucket, &source.name)
|| !should_check_data_movement_overwrite_resume(err)
|| !should_check_data_movement_resume_target(src_pool_idx, target_pool_idx)
{
return Ok(false);
return Ok(None);
}
let Some(target) = target_result? else {
return Ok(false);
return Ok(None);
};
if is_equivalent_data_movement_object_identity(source, &target, true, compare_part_checksums) {
return Ok(true);
if is_equivalent_data_movement_object_identity(source, &target, true, compare_part_checksums)
|| (compare_part_checksums && is_legacy_data_movement_checksum_target(source, &target))
|| is_data_movement_upload_takeover_target(source, &target, compare_part_checksums)
{
return Ok(Some(DataMovementCopy::Committed));
}
if compare_part_checksums && is_legacy_data_movement_checksum_target(source, &target) {
return Ok(true);
}
if is_data_movement_upload_takeover_target(source, &target, compare_part_checksums) {
return Ok(true);
}
Ok(matches!(err, Error::PreconditionFailed) && is_superseding_unversioned_data_movement_object(source, &target))
Ok(
(matches!(err, Error::PreconditionFailed) && is_superseding_unversioned_data_movement_object(source, &target))
.then_some(DataMovementCopy::Superseded),
)
}
#[derive(Clone, Copy)]
@@ -1491,13 +1675,13 @@ async fn should_treat_data_movement_overwrite_as_complete(
err: &Error,
compare_part_checksums: bool,
capacity: DataMovementOverwriteCapacity,
) -> Result<bool> {
) -> Result<Option<DataMovementCopy>> {
if !should_check_data_movement_overwrite_resume(err) {
return Ok(false);
return Ok(None);
}
let (src_pool_idx, target_pool_idx) = pool_indices;
let equivalent = resolve_data_movement_overwrite_resume_result_for(
let copy = resolve_data_movement_overwrite_resume_copy(
err,
find_data_movement_target_info(store, target_pool_idx, bucket, object_info).await,
object_info,
@@ -1505,7 +1689,9 @@ async fn should_treat_data_movement_overwrite_as_complete(
target_pool_idx,
compare_part_checksums,
)?;
if equivalent && let Some(owner) = capacity.owner {
if copy.is_some()
&& let Some(owner) = capacity.owner
{
let expected_data_bytes = capacity
.expected_data_bytes
.ok_or_else(|| Error::other("equivalent data-movement target cannot reconcile unknown committed data size"))?;
@@ -1513,7 +1699,7 @@ async fn should_treat_data_movement_overwrite_as_complete(
.reconcile_decommission_capacity_after_equivalent_target(owner, target_pool_idx, expected_data_bytes)
.await?;
}
Ok(equivalent)
Ok(copy)
}
fn data_movement_part_stage_error(
@@ -1525,7 +1711,16 @@ fn data_movement_part_stage_error(
err: Error,
) -> Error {
let rendered = format!("{op_label}: {stage} failed for {bucket}/{object} part {part_number}: {err}");
if matches!(&err, Error::DecommissionCapacityBlocked { .. }) {
if matches!(&err, Error::DecommissionCapacityBlocked { .. })
|| (stage == "put_object_part"
&& matches!(
&err,
Error::ErasureReadQuorum
| Error::ErasureWriteQuorum
| Error::InsufficientReadQuorum(_, _)
| Error::InsufficientWriteQuorum(_, _)
))
{
return data_movement_context_error(rendered, err);
}
// A missing target part is not evidence that the source can be deleted.
@@ -1554,7 +1749,7 @@ fn is_data_movement_part_read_error(err: &Error) -> bool {
.is_some_and(is_unexpected_eof)
}
matches!(err, Error::Io(io_err) if is_unexpected_eof(io_err))
is_data_movement_source_read_error(err) || matches!(err, Error::Io(io_err) if is_unexpected_eof(io_err))
}
fn data_movement_part_upload_failure_stage(err: &Error) -> &'static str {
@@ -1573,13 +1768,16 @@ pub(crate) async fn migrate_decommission_object(
source_bucket_incarnation_id: Option<uuid::Uuid>,
op_label: &str,
capacity_owner: Option<DecommissionCapacityOwner>,
) -> Result<()> {
) -> Result<DataMovementCopy> {
let source = rd.object_info.clone();
let mutation_fence = store
.acquire_decommission_object_mutation_fence(&bucket, &source.name)
.await?;
let current = find_data_movement_target_info(store.as_ref(), pool_idx, &bucket, &source)
.await?
let current = find_data_movement_target_info(store.as_ref(), pool_idx, &bucket, &source).await;
#[cfg(test)]
let current = crate::core::pools::decommission_test_wrap_result("source_recheck", &bucket, &source.name, 0, current);
let current = current
.map_err(|err| data_movement_source_read_error(&bucket, &source.name, err))?
.ok_or(Error::FileNotFound)?;
if !is_equivalent_data_movement_object_identity(&source, &current, true, false) {
return Err(Error::FileNotFound);
@@ -1607,7 +1805,7 @@ pub(crate) async fn migrate_object(
rd: GetObjectReader,
source_bucket_incarnation_id: Option<uuid::Uuid>,
op_label: &str,
) -> Result<()> {
) -> Result<DataMovementCopy> {
migrate_object_with_lock_lost_signal(store, pool_idx, bucket, rd, source_bucket_incarnation_id, op_label, None).await
}
@@ -1620,7 +1818,7 @@ pub(crate) async fn migrate_object_with_lock_lost_signal(
source_bucket_incarnation_id: Option<uuid::Uuid>,
op_label: &str,
lock_lost_signal: Option<Arc<rustfs_lock::distributed_lock::LockLostSignal>>,
) -> Result<()> {
) -> Result<DataMovementCopy> {
migrate_object_inner(
store,
pool_idx,
@@ -1646,7 +1844,7 @@ async fn migrate_object_inner(
lock_lost_signal: Option<Arc<rustfs_lock::distributed_lock::LockLostSignal>>,
capacity_owner: Option<DecommissionCapacityOwner>,
mutation_fence: Option<DecommissionFixedReadAnchor>,
) -> Result<()> {
) -> Result<DataMovementCopy> {
if scanner_backlog::is_scanner_pause_backlog(&bucket, &rd.object_info.name) {
return Err(Error::other("scanner pause backlog requires native retirement handoff"));
}
@@ -1720,7 +1918,7 @@ async fn migrate_object_inner(
bucket.as_str(),
object_info.name.as_str()
);
return Ok(());
return Ok(DataMovementCopy::Committed);
}
let mut cleanup_opts = data_movement_abort_opts(
&object_info,
@@ -1778,7 +1976,7 @@ async fn migrate_object_inner(
}
};
let abort_multipart_flag = new_multipart_abort_flag();
let multipart_result: Result<()> = async {
let multipart_result: Result<DataMovementCopy> = async {
let mut parts = vec![CompletePart::default(); object_info.parts.len()];
let reader = Arc::new(Mutex::new(rd.stream));
for (i, part) in object_info.parts.iter().enumerate() {
@@ -1855,6 +2053,23 @@ async fn migrate_object_inner(
};
}
#[cfg(feature = "e2e-test-hooks")]
e2e_barrier::wait(
op_label,
bucket.as_str(),
object_info.name.as_str(),
e2e_barrier::STAGE_BEFORE_TARGET_COMMIT,
)
.await
.map_err(|err| {
data_movement_stage_error(
op_label,
"complete_multipart_upload",
bucket.as_str(),
object_info.name.as_str(),
Error::from(err),
)
})?;
let mut complete_multipart_opts =
data_movement_complete_multipart_opts(&object_info, pool_idx, preserve_part_checksums).map_err(|err| {
data_movement_stage_error(
@@ -1892,7 +2107,7 @@ async fn migrate_object_inner(
)
.await
{
if should_treat_data_movement_overwrite_as_complete(
if let Some(copy) = should_treat_data_movement_overwrite_as_complete(
store.as_ref(),
(pool_idx, target_pool_idx),
bucket.as_str(),
@@ -1911,7 +2126,7 @@ async fn migrate_object_inner(
bucket.as_str(),
object_info.name.as_str()
);
return Ok(());
return Ok(copy);
}
error!("{op_label}: complete_multipart_upload err {:?}", &err);
@@ -1925,11 +2140,13 @@ async fn migrate_object_inner(
}
mark_multipart_upload_completed(&abort_multipart_flag);
Ok(())
Ok(DataMovementCopy::Committed)
}
.await;
if multipart_result.is_ok() && should_abort_multipart_upload(&abort_multipart_flag) {
if let Ok(copy) = multipart_result
&& should_abort_multipart_upload(&abort_multipart_flag)
{
let mut abort_opts = data_movement_abort_opts(
&object_info,
pool_idx,
@@ -1953,9 +2170,9 @@ async fn migrate_object_inner(
)
.await;
match abort_result {
Ok(()) => return Ok(()),
Ok(()) => return Ok(copy),
Err(abort_err) if is_err_invalid_upload_id(&abort_err) => {
if should_treat_data_movement_overwrite_as_complete(
if let Some(copy) = should_treat_data_movement_overwrite_as_complete(
store.as_ref(),
(pool_idx, target_pool_idx),
bucket.as_str(),
@@ -1969,7 +2186,7 @@ async fn migrate_object_inner(
)
.await?
{
return Ok(());
return Ok(copy);
}
return Err(data_movement_stage_error(
op_label,
@@ -2062,7 +2279,7 @@ async fn migrate_object_inner(
return Err(primary_err);
}
return Ok(());
return multipart_result;
}
let mut data = data_movement_put_object_reader(bucket.as_str(), &object_info, rd, op_label)?;
@@ -2090,7 +2307,7 @@ async fn migrate_object_inner(
.await
.map_err(|err| data_movement_stage_error(op_label, "prepare_put_object", &bucket, &object_info.name, err))?;
if let Err(err) = put_result {
if should_treat_data_movement_overwrite_as_complete(
if let Some(copy) = should_treat_data_movement_overwrite_as_complete(
store.as_ref(),
(pool_idx, target_pool_idx),
bucket.as_str(),
@@ -2109,7 +2326,7 @@ async fn migrate_object_inner(
bucket.as_str(),
object_info.name.as_str()
);
return Ok(());
return Ok(copy);
}
error!("{op_label}: put_object err {:?}", &err);
@@ -2122,7 +2339,7 @@ async fn migrate_object_inner(
));
}
Ok(())
Ok(DataMovementCopy::Committed)
}
#[cfg(test)]
@@ -2393,6 +2610,34 @@ mod tests {
assert!(matches!(err, SourceCleanupError::SourceChanged));
}
#[test]
fn source_cleanup_preflight_requires_vanished_versions_to_be_gone() {
let mut marker = cleanup_test_file_info("object.txt", Uuid::from_u128(1), "marker");
marker.deleted = true;
let mut null_version = cleanup_test_file_info("object.txt", Uuid::nil(), "null");
null_version.version_id = None;
let expected = cleanup_test_versions(vec![marker.clone(), null_version.clone()]);
let vanished = vec![source_cleanup_version_identity(&null_version)];
let narrowed = source_cleanup_expected_without_vanished(&expected, &vanished);
assert_eq!(narrowed.versions.len(), 1);
assert!(matches!(
source_cleanup_expected_without_vanished(&expected, &[]),
std::borrow::Cow::Borrowed(_)
));
// The unchanged source used to pass the preflight even though the mover
// never copied the version it reported as not found.
let unchanged = cleanup_test_versions(vec![marker.clone(), null_version]);
assert!(source_cleanup_versions_match_with_allowed_missing(&expected, &unchanged, &[]));
let err = ensure_source_cleanup_versions_match(&narrowed, &unchanged, &[])
.expect_err("a version skipped as not found must not be deleted while it still exists");
assert!(matches!(err, SourceCleanupError::SourceChanged));
let gone = cleanup_test_versions(vec![marker]);
ensure_source_cleanup_versions_match(&narrowed, &gone, &[])
.expect("a version that really vanished must not block cleanup");
}
#[test]
fn test_new_multipart_abort_flag_defaults_to_abort_enabled() {
let flag = new_multipart_abort_flag();
@@ -2466,6 +2711,54 @@ mod tests {
assert!(data_movement_stage_source(&Error::other("plain io error")).is_none());
}
#[test]
fn source_read_quorum_error_keeps_its_source_and_origin() {
let original = Error::InsufficientReadQuorum("bucket-a".to_owned(), "object-a".to_owned());
let wrapped = data_movement_source_read_error("bucket-a", "object-a", original.clone());
let failure = data_movement_stage_source_as::<DataMovementSourceReadFailure>(&wrapped)
.expect("the error must retain its source-read origin");
let source = std::error::Error::source(failure)
.and_then(|source| source.downcast_ref::<Error>())
.expect("the original storage error must remain in the error chain");
assert_eq!(source.to_string(), original.to_string());
assert!(is_data_movement_source_read_error(&wrapped));
assert!(matches!(data_movement_stage_source(&wrapped), Some(Error::InsufficientReadQuorum(_, _))));
assert!(matches!(
data_movement_source_read_error("bucket-a", "object-a", Error::FileNotFound),
Error::FileNotFound
));
assert!(matches!(
data_movement_source_read_error("bucket-a", "object-a", Error::DiskFull),
Error::DiskFull
));
}
#[test]
fn source_stream_quorum_keeps_its_kind_origin_and_original_error_through_put_stage() {
for source_error in [
std::io::Error::new(std::io::ErrorKind::InvalidData, Error::ErasureReadQuorum),
std::io::Error::new(std::io::ErrorKind::InvalidData, DiskError::ErasureReadQuorum),
] {
let marked = data_movement_source_stream_error(source_error);
assert_eq!(marked.kind(), std::io::ErrorKind::InvalidData);
let source = Error::from(marked);
assert!(is_data_movement_source_read_error(&source));
assert_eq!(data_movement_part_upload_failure_stage(&source), "read_part");
let wrapped = data_movement_stage_error("decommission_object", "put_object", "bucket-a", "object-a", source);
assert!(is_data_movement_source_read_error(&wrapped));
// Rebalance unwraps each stage before checking whether quorum is transient.
let mut original = &wrapped;
while let Some(source) = data_movement_stage_source(original) {
original = source;
}
assert!(matches!(original, Error::ErasureReadQuorum));
}
let unrelated = data_movement_source_stream_error(std::io::Error::new(std::io::ErrorKind::TimedOut, "source timeout"));
assert_eq!(unrelated.kind(), std::io::ErrorKind::TimedOut);
assert_eq!(unrelated.to_string(), "source timeout");
}
#[test]
fn test_data_movement_part_stage_error_includes_stage_object_and_part() {
let err =
@@ -2491,7 +2784,15 @@ mod tests {
#[test]
fn test_data_movement_part_upload_failure_stage_keeps_write_errors() {
assert_eq!(data_movement_part_upload_failure_stage(&Error::SlowDown), "put_object_part");
for target_error in [
Error::SlowDown,
Error::ErasureReadQuorum,
Error::ErasureWriteQuorum,
Error::InsufficientReadQuorum("bucket-a".to_owned(), "object-a".to_owned()),
Error::InsufficientWriteQuorum("bucket-a".to_owned(), "object-a".to_owned()),
] {
assert_eq!(data_movement_part_upload_failure_stage(&target_error), "put_object_part");
}
}
#[test]
@@ -3101,6 +3402,29 @@ mod tests {
);
}
#[test]
fn data_movement_target_opts_address_the_null_version_exactly() {
let nil = Uuid::nil().to_string();
assert_eq!(data_movement_exact_version_id(None), nil);
assert_eq!(data_movement_exact_version_id(Some(Uuid::nil())), nil);
assert_eq!(data_movement_exact_version_id(Some(Uuid::from_u128(9))), Uuid::from_u128(9).to_string());
// The null version keeps its unversioned write semantics, but the
// target precondition must not resolve to a newer version or marker.
let null_version = ObjectInfo {
mod_time: Some(OffsetDateTime::UNIX_EPOCH),
..Default::default()
};
let put = data_movement_put_object_opts(&null_version, 0);
let new_multipart = data_movement_new_multipart_opts(&null_version, 0);
let complete =
data_movement_complete_multipart_opts(&null_version, 0, false).expect("complete opts should encode metadata");
for opts in [&put, &new_multipart, &complete] {
assert!(!opts.versioned);
assert_eq!(opts.version_id.as_deref(), Some(nil.as_str()));
}
}
#[test]
fn test_data_movement_put_opts_do_not_persist_multipart_part_checksums() {
let object_info = ObjectInfo {
@@ -3215,6 +3539,16 @@ mod tests {
same_generation.mod_time = Some(source_time);
assert!(!can_replace_stale_data_movement_target(&same_generation, &opts));
let null_opts = ObjectOptions {
versioned: false,
version_id: Some(data_movement_exact_version_id(None)),
..opts.clone()
};
let mut null_target = target.clone();
null_target.version_id = None;
assert!(can_replace_stale_data_movement_target(&null_target, &null_opts));
assert!(!can_replace_stale_data_movement_target(&target, &null_opts));
let mut delete_marker = target;
delete_marker.delete_marker = true;
assert!(!can_replace_stale_data_movement_target(&delete_marker, &opts));
@@ -4258,6 +4592,83 @@ mod tests {
}
}
/// The resume adjudication tells a target that holds the source version
/// (equivalent, an old node's upload takeover, a legacy checksum target)
/// from a newer unversioned object that replaced it, and accepts neither
/// when the target's remote tier tuple differs (rustfs/backlog#2873).
#[test]
fn overwrite_resume_copy_separates_committed_from_superseded_targets() {
let classify = |err: &Error, target: &ObjectInfo, source: &ObjectInfo| {
resolve_data_movement_overwrite_resume_copy(err, Ok(Some(target.clone())), source, 0, 1, true)
.expect("the target should be evaluated")
};
let overwrite = Error::DataMovementOverwriteErr("bucket".to_string(), "object".to_string(), "version".to_string());
let mut source = overwrite_equivalence_source();
source.transitioned_object.status = rustfs_filemeta::TRANSITION_COMPLETE.to_string();
source.transitioned_object.tier = "WARM".to_string();
source.transitioned_object.name = "remote/object".to_string();
source.transitioned_object.version_id = "remote-version".to_string();
assert_eq!(classify(&overwrite, &source, &source), Some(DataMovementCopy::Committed));
let mut legacy = source.clone();
let mut legacy_parts = legacy.parts.as_ref().clone();
legacy_parts[0].checksums = None;
legacy.parts = Arc::new(legacy_parts);
let legacy_metadata = Arc::make_mut(&mut legacy.user_defined);
rustfs_utils::http::insert_str(legacy_metadata, SUFFIX_DATA_MOVED, "true".to_string());
rustfs_utils::http::insert_str(legacy_metadata, SUFFIX_DATA_MOVED_TAGS, "v1:tag=value".to_string());
assert_eq!(classify(&overwrite, &legacy, &source), Some(DataMovementCopy::Committed));
let mut takeover_source = source.clone();
let mut takeover_parts = takeover_source.parts.as_ref().clone();
takeover_parts[0].checksums = None;
takeover_source.parts = Arc::new(takeover_parts);
let mut takeover = ObjectInfo {
mod_time: OffsetDateTime::UNIX_EPOCH.checked_add(time::Duration::SECOND),
..takeover_source.clone()
};
rustfs_utils::http::insert_str(
Arc::make_mut(&mut takeover.user_defined),
SUFFIX_DATA_MOVEMENT_UPLOAD,
data_movement_upload_identity(&takeover_source),
);
let invalid_upload = Error::InvalidUploadID("bucket".to_string(), "object".to_string(), "upload-id".to_string());
assert_eq!(classify(&invalid_upload, &takeover, &takeover_source), Some(DataMovementCopy::Committed));
for (err, target, target_source) in [
(&overwrite, &source, &source),
(&overwrite, &legacy, &source),
(&invalid_upload, &takeover, &takeover_source),
] {
let mut other_remote = target.clone();
other_remote.transitioned_object.name = "remote/other".to_string();
assert_eq!(
classify(err, &other_remote, target_source),
None,
"a different remote tuple is not a copy"
);
}
let mut null_source = source;
null_source.version_id = None;
let newer_null = ObjectInfo {
etag: Some("etag-client-write".to_string()),
mod_time: OffsetDateTime::UNIX_EPOCH.checked_add(time::Duration::SECOND),
transitioned_object: Default::default(),
..null_source.clone()
};
assert_eq!(
classify(&Error::PreconditionFailed, &newer_null, &null_source),
Some(DataMovementCopy::Superseded)
);
assert_eq!(
classify(&overwrite, &newer_null, &null_source),
None,
"only a precondition conflict can be superseded"
);
}
#[test]
fn test_overwrite_error_rejects_newer_unversioned_target() {
let source = ObjectInfo {
@@ -50,11 +50,43 @@ impl ScannerPauseBacklogRetirementError {
}
}
/// The same native planner runs before repair and after each durable phase.
/// Keep this context bounded without exposing replica payloads or revisions.
#[derive(Clone, Copy, Debug)]
pub(crate) enum ScannerPauseBacklogRetirementPhase {
InitialRead,
SeedReadback,
CommitReadback,
StabilizeReadback,
FinalCheck,
}
impl ScannerPauseBacklogRetirementPhase {
fn as_str(self) -> &'static str {
match self {
Self::InitialRead => "initial_read",
Self::SeedReadback => "seed_readback",
Self::CommitReadback => "commit_readback",
Self::StabilizeReadback => "stabilize_readback",
Self::FinalCheck => "final_check",
}
}
pub(crate) fn planner_error(self, err: ScannerPauseBacklogRetirementError) -> Error {
data_movement_context_error(
format!("scanner pause backlog retirement planner failed during {}: {err}", self.as_str()),
err,
)
}
}
/// A bounded, storage-fenced native replica. Only a confirmed missing object
/// has no payload; read failures never enter the Scanner verifier.
pub struct ScannerPauseBacklogRetirementReplica {
pub pool_index: usize,
pub set_index: usize,
/// False for retiring members retained only to verify the old commit proof.
pub writable: bool,
pub data: Option<Vec<u8>>,
}
@@ -104,10 +136,11 @@ impl ScannerPauseBacklogRetirementRead {
}
}
async fn read_replica(set: Arc<SetDisks>) -> Result<ScannerPauseBacklogRetirementRead> {
async fn read_replica((set, writable): (Arc<SetDisks>, bool)) -> Result<ScannerPauseBacklogRetirementRead> {
let mut replica = ScannerPauseBacklogRetirementReplica {
pool_index: set.pool_index,
set_index: set.set_index,
writable,
data: None,
};
let opts = ObjectOptions {
@@ -156,7 +189,7 @@ async fn read_replica(set: Arc<SetDisks>) -> Result<ScannerPauseBacklogRetiremen
pub(crate) async fn read_scanner_pause_backlog_retirement_replicas(
source_pool_index: usize,
source_set_index: usize,
sets: Vec<Arc<SetDisks>>,
sets: Vec<(Arc<SetDisks>, bool)>,
) -> Result<Vec<ScannerPauseBacklogRetirementRead>> {
let replicas = join_all(sets.into_iter().map(read_replica))
.await
@@ -170,23 +203,30 @@ pub(crate) async fn read_scanner_pause_backlog_retirement_replicas(
Ok(replicas)
}
#[derive(Debug, thiserror::Error)]
#[error("scanner pause backlog native retirement planner is unavailable during {phase}")]
struct ScannerPauseBacklogPlannerUnavailable {
phase: &'static str,
}
pub(crate) fn plan_scanner_pause_backlog_retirement(
source_pool_index: usize,
replicas: &[ScannerPauseBacklogRetirementRead],
phase: ScannerPauseBacklogRetirementPhase,
) -> Result<Option<ScannerPauseBacklogRetirementPlan>> {
let planner = RETIREMENT_PLANNER
.get()
.ok_or_else(|| Error::other("scanner pause backlog native retirement planner is unavailable"))?;
.ok_or_else(|| Error::other(ScannerPauseBacklogPlannerUnavailable { phase: phase.as_str() }))?;
let snapshots = replicas
.iter()
.map(|read| ScannerPauseBacklogRetirementReplica {
pool_index: read.replica.pool_index,
set_index: read.replica.set_index,
writable: read.replica.writable,
data: read.replica.data.clone(),
})
.collect::<Vec<_>>();
planner(source_pool_index, &snapshots)
.map_err(|err| data_movement_context_error(format!("scanner pause backlog retirement planner failed: {err}"), err))
planner(source_pool_index, &snapshots).map_err(|err| phase.planner_error(err))
}
/// The native writer and retirement handoff use the same conditional, full-tail
@@ -315,3 +355,138 @@ pub mod test_util {
Ok(Some(fault))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::data_movement::data_movement_stage_source_as;
fn failing_planner(
source_pool_index: usize,
replicas: &[ScannerPauseBacklogRetirementReplica],
) -> std::result::Result<Option<ScannerPauseBacklogRetirementPlan>, ScannerPauseBacklogRetirementError> {
assert!(replicas.is_empty(), "the callback fixture needs no storage replicas");
Err(match source_pool_index {
0 => ScannerPauseBacklogRetirementError::HandoffConflict {
reason: "original reason".to_owned(),
},
1 => ScannerPauseBacklogRetirementError::AuthorityConflict {
reason: "original reason".to_owned(),
},
2 => ScannerPauseBacklogRetirementError::InvalidRecord {
reason: "original reason".to_owned(),
},
_ => panic!("unexpected planner fixture source {source_pool_index}"),
})
}
#[test]
fn retirement_planner_phase_context_preserves_every_typed_error() {
const TEST_NAME: &str =
"data_movement::scanner_backlog::tests::retirement_planner_phase_context_preserves_every_typed_error";
const CHILD: &str = "RUSTFS_TEST_RETIREMENT_PLANNER_PHASE_CHILD";
if std::env::var(CHILD).ok().as_deref() != Some(TEST_NAME) {
// Registration is process-global and must not replace another test's planner.
let output = std::process::Command::new(std::env::current_exe().expect("test executable"))
.args(["--exact", TEST_NAME, "--nocapture", "--color", "never"])
.env(CHILD, TEST_NAME)
.output()
.expect("start isolated retirement planner test");
let stdout = String::from_utf8_lossy(&output.stdout);
let one_passed = stdout
.lines()
.any(|line| line.starts_with("test result: ok. 1 passed; 0 failed;"));
assert!(
output.status.success() && one_passed,
"isolated planner regression must execute exactly one passing test: {stdout}\n{}",
String::from_utf8_lossy(&output.stderr),
);
return;
}
let phases = [
(ScannerPauseBacklogRetirementPhase::InitialRead, "initial_read"),
(ScannerPauseBacklogRetirementPhase::SeedReadback, "seed_readback"),
(ScannerPauseBacklogRetirementPhase::CommitReadback, "commit_readback"),
(ScannerPauseBacklogRetirementPhase::StabilizeReadback, "stabilize_readback"),
(ScannerPauseBacklogRetirementPhase::FinalCheck, "final_check"),
];
for (phase, label) in phases {
let unavailable = match plan_scanner_pause_backlog_retirement(0, &[], phase) {
Ok(_) => panic!("unregistered planner must fail during {label}"),
Err(error) => error,
};
assert_eq!(
unavailable.to_string(),
format!("Io error: scanner pause backlog native retirement planner is unavailable during {label}"),
);
let Error::Io(io_error) = &unavailable else {
panic!("unregistered planner must retain its IO error boundary during {label}");
};
let recovered = io_error
.get_ref()
.and_then(|cause| cause.downcast_ref::<ScannerPauseBacklogPlannerUnavailable>())
.expect("unregistered planner must retain its typed cause");
assert_eq!(recovered.phase, label);
assert!(data_movement_stage_source_as::<ScannerPauseBacklogRetirementError>(&unavailable).is_none());
let same_failure = plan_scanner_pause_backlog_retirement(0, &[], phase)
.err()
.expect("a second unregistered call must fail in the same phase");
let errors = [
Some(
unavailable
.narrow_to_disk()
.expect("planner IO error must cross the disk boundary"),
),
Some(
same_failure
.narrow_to_disk()
.expect("same-phase planner IO error must cross the disk boundary"),
),
];
let (count, _) = crate::disk::error_reduce::reduce_errs(&errors, &[]);
assert_eq!(count, 2, "same-phase planner failures must share one quorum bucket");
}
register_scanner_pause_backlog_retirement_planner(failing_planner);
for (phase, label) in phases {
for (source_pool_index, source) in [
ScannerPauseBacklogRetirementError::HandoffConflict {
reason: "original reason".to_owned(),
},
ScannerPauseBacklogRetirementError::AuthorityConflict {
reason: "original reason".to_owned(),
},
ScannerPauseBacklogRetirementError::InvalidRecord {
reason: "original reason".to_owned(),
},
]
.into_iter()
.enumerate()
{
let discriminant = std::mem::discriminant(&source);
let retryable = source.is_retryable_handoff_conflict();
let original = source.to_string();
let error = match plan_scanner_pause_backlog_retirement(source_pool_index, &[], phase) {
Ok(_) => panic!("the registered planner fixture must fail during {label}"),
Err(error) => error,
};
assert_eq!(
error.to_string(),
format!("Io error: scanner pause backlog retirement planner failed during {label}: {original}")
);
let recovered = data_movement_stage_source_as::<ScannerPauseBacklogRetirementError>(&error)
.expect("the worker classifier must still reach the original native error");
assert_eq!(std::mem::discriminant(recovered), discriminant);
assert_eq!(recovered.to_string(), original);
assert_eq!(recovered.is_retryable_handoff_conflict(), retryable);
let mut source = std::error::Error::source(&error);
let mut found_native = false;
while let Some(cause) = source {
found_native |= cause.downcast_ref::<ScannerPauseBacklogRetirementError>().is_some();
source = cause.source();
}
assert!(found_native, "the standard source chain must retain the native error during {label}");
}
}
}
}
+647 -31
View File
@@ -56,6 +56,13 @@ pub const DATA_USAGE_ROOT: &str = SLASH_SEPARATOR;
const DATA_COMPRESSION_TOTAL_NAME: &str = ".compression.json";
pub const DATA_USAGE_CACHE_NAME: &str = ".usage-cache.bin";
const DATA_USAGE_CACHE_TTL_SECS: u64 = 30;
// A scanner save invalidates only the saving process's snapshot cache; every
// other node sees it only after reloading. A fail-closed usage lookup for a
// bucket the cached snapshot does not mention at all may reload a copy this
// old, so a bucket created after the last load is not reported unknown for the
// rest of the TTL once the scanner leader has published it. Such early
// reloads are spaced by the same interval whatever their outcome.
const DATA_USAGE_UNKNOWN_BUCKET_REFRESH_SECS: u64 = 5;
const LIVE_BUCKET_USAGE_MAX_ENTRIES: u64 = 1024;
const DATA_USAGE_REMOVE_CAS_RETRIES: usize = 3;
@@ -169,6 +176,9 @@ static USAGE_CACHE_UPDATING: OnceLock<CacheUpdating> = OnceLock::new();
static LIVE_BUCKET_USAGE_CACHE: OnceLock<LiveBucketUsageCache> = OnceLock::new();
static USAGE_MEMORY_GENERATION: AtomicU64 = AtomicU64::new(0);
/// Compatibility input for snapshot-protocol quota admission and capacity
/// reporting. Durable quota checks and statistics do not consult this floor.
///
/// Best-available persisted usage for `bucket` when no authoritative source
/// exists yet (issue #5716): after an upgrade from a pre-v2 release the only
/// persisted usage data is the legacy `.usage.json`, which is demoted to
@@ -188,7 +198,7 @@ static USAGE_MEMORY_GENERATION: AtomicU64 = AtomicU64::new(0);
/// authoritative snapshot first, backfilled per bucket from the observed
/// (nonconverged) snapshot for buckets no authoritative cycle has covered
/// yet (issue #6852). Loads go through the TTL-bounded
/// snapshot cache, so the quota path adds at most one backend read per
/// snapshot cache, so this lookup adds at most one backend read per
/// [`DATA_USAGE_CACHE_TTL_SECS`] window. Returns `None` for buckets absent
/// from every persisted snapshot — those still fail closed.
pub async fn lookup_degraded_bucket_usage_baseline(store: Arc<ECStore>, bucket: &str) -> Option<u64> {
@@ -206,7 +216,7 @@ pub async fn lookup_degraded_bucket_usage_baseline(store: Arc<ECStore>, bucket:
// Stale or empty cache: refresh through the TTL-bounded loader. A failed
// refresh carries the previous baseline forward, so quota admission keeps
// its last grounded values through a backend read outage.
let _ = load_data_usage_from_backend_cached(store).await;
let _ = project_data_usage_from_backend_cached(store, |_| ()).await;
let cache = data_usage_snapshot_cache().read().await;
cache
.as_ref()
@@ -221,36 +231,40 @@ struct CachedDataUsageSnapshot {
loaded_at: tokio::time::Instant,
/// Pre-discard per-bucket sizes from the same load, retained even when the
/// snapshot is incomplete and its bucket data is discarded. Consumed only
/// by [`lookup_degraded_bucket_usage_baseline`] for quota admission.
/// by [`lookup_degraded_bucket_usage_baseline`] for snapshot-protocol
/// admission and capacity reporting until their compatibility windows end.
degraded_baseline: HashMap<String, u64>,
}
impl CachedDataUsageSnapshot {
fn result(&self) -> Result<DataUsageInfo, Error> {
fn result<T>(&self, project: impl FnOnce(&DataUsageInfo) -> T) -> Result<T, Error> {
self.info
.clone()
.as_ref()
.map(project)
.ok_or_else(|| Error::other("data usage snapshot load recently failed"))
}
}
fn fresh_cached_data_usage_snapshot(
fn fresh_cached_data_usage_snapshot<T>(
cache: &Option<CachedDataUsageSnapshot>,
now: tokio::time::Instant,
ttl: Duration,
) -> Option<Result<DataUsageInfo, Error>> {
project: impl FnOnce(&DataUsageInfo) -> T,
) -> Option<Result<T, Error>> {
cache
.as_ref()
.filter(|cached| now.duration_since(cached.loaded_at) < ttl)
.map(CachedDataUsageSnapshot::result)
.map(|cached| cached.result(project))
}
fn cache_data_usage_snapshot_result(
fn cache_data_usage_snapshot_result<T>(
cache: &mut Option<CachedDataUsageSnapshot>,
result: Result<LoadedUsageBaseline, Error>,
loaded_at: tokio::time::Instant,
refresh_generation: u64,
current_generation: u64,
) -> Option<Result<DataUsageInfo, Error>> {
project: impl FnOnce(&DataUsageInfo) -> T,
) -> Option<Result<T, Error>> {
if current_generation != refresh_generation {
return None;
}
@@ -269,12 +283,13 @@ fn cache_data_usage_snapshot_result(
degraded_baseline.entry(bucket.clone()).or_insert(*size);
}
}
let projected = project(&info);
*cache = Some(CachedDataUsageSnapshot {
info: Some(info.clone()),
info: Some(info),
loaded_at,
degraded_baseline,
});
Ok(info)
Ok(projected)
}
Err(e) => {
// Keep the previous baseline through a failed refresh: quota
@@ -1244,8 +1259,8 @@ async fn load_data_usage_from_backend_with_baseline(store: Arc<ECStore>) -> Resu
// gate ties the observation to this exact authoritative snapshot, so a
// stale observed object left behind by an earlier incarnation (e.g. a
// deleted and recreated bucket) cannot inject ghost usage. Loads sit
// behind the same TTL cache as the snapshot itself, so this adds at most
// one backend read per TTL window.
// behind the same cache as the snapshot itself, so this adds one backend
// read per snapshot refresh.
let mut observed_unavailable = false;
match load_observed_data_usage_snapshot(store).await {
Ok(Some(observed)) if observed_data_usage_is_newer(&observed, &authoritative_as_persisted) => {
@@ -1470,15 +1485,26 @@ async fn normalize_loaded_data_usage(
///
/// Admin read endpoints call this on every request; the cache bounds backend
/// reads (and the associated JSON parse and INFO log) to once per
/// `DATA_USAGE_CACHE_TTL_SECS` per process. `save_data_usage_in_backend`
/// invalidates the cache so a fresh scanner save is visible immediately.
/// `DATA_USAGE_CACHE_TTL_SECS` per process, apart from the spaced early
/// reloads of [`reload_unknown_bucket_usage`].
/// `save_data_usage_in_backend` invalidates the cache so a fresh scanner save
/// is visible immediately in the saving process.
pub async fn load_data_usage_from_backend_cached(store: Arc<ECStore>) -> Result<DataUsageInfo, Error> {
project_data_usage_from_backend_cached(store, DataUsageInfo::clone).await
}
// 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>(
store: Arc<ECStore>,
project: impl Fn(&DataUsageInfo) -> T,
) -> Result<T, Error> {
let ttl = Duration::from_secs(DATA_USAGE_CACHE_TTL_SECS);
loop {
{
let cache = data_usage_snapshot_cache().read().await;
if let Some(result) = fresh_cached_data_usage_snapshot(&cache, tokio::time::Instant::now(), ttl) {
if let Some(result) = fresh_cached_data_usage_snapshot(&cache, tokio::time::Instant::now(), ttl, &project) {
return result;
}
}
@@ -1490,7 +1516,7 @@ pub async fn load_data_usage_from_backend_cached(store: Arc<ECStore>) -> Result<
.await;
{
let cache = data_usage_snapshot_cache().read().await;
if let Some(result) = fresh_cached_data_usage_snapshot(&cache, tokio::time::Instant::now(), ttl) {
if let Some(result) = fresh_cached_data_usage_snapshot(&cache, tokio::time::Instant::now(), ttl, &project) {
return result;
}
}
@@ -1499,9 +1525,14 @@ pub async fn load_data_usage_from_backend_cached(store: Arc<ECStore>) -> Result<
let result = load_data_usage_from_backend_with_baseline(store.clone()).await;
let loaded_at = tokio::time::Instant::now();
let mut cache = data_usage_snapshot_cache().write().await;
if let Some(result) =
cache_data_usage_snapshot_result(&mut cache, result, loaded_at, refresh_generation, data_usage_snapshot_generation())
{
if let Some(result) = cache_data_usage_snapshot_result(
&mut cache,
result,
loaded_at,
refresh_generation,
data_usage_snapshot_generation(),
&project,
) {
return result;
}
drop(cache);
@@ -1519,7 +1550,8 @@ pub async fn load_admin_data_usage_from_backend_cached(store: Arc<ECStore>) -> R
loop {
{
let cache = admin_data_usage_snapshot_cache().read().await;
if let Some(result) = fresh_cached_data_usage_snapshot(&cache, tokio::time::Instant::now(), ttl) {
if let Some(result) = fresh_cached_data_usage_snapshot(&cache, tokio::time::Instant::now(), ttl, DataUsageInfo::clone)
{
return result;
}
}
@@ -1530,7 +1562,8 @@ pub async fn load_admin_data_usage_from_backend_cached(store: Arc<ECStore>) -> R
.await;
{
let cache = admin_data_usage_snapshot_cache().read().await;
if let Some(result) = fresh_cached_data_usage_snapshot(&cache, tokio::time::Instant::now(), ttl) {
if let Some(result) = fresh_cached_data_usage_snapshot(&cache, tokio::time::Instant::now(), ttl, DataUsageInfo::clone)
{
return result;
}
}
@@ -1551,6 +1584,7 @@ pub async fn load_admin_data_usage_from_backend_cached(store: Arc<ECStore>) -> R
loaded_at,
refresh_generation,
admin_data_usage_snapshot_generation(),
DataUsageInfo::clone,
) {
return result;
}
@@ -2260,8 +2294,150 @@ pub async fn record_bucket_delete_marker_memory(bucket: &str) {
/// Get bucket usage from the authoritative cache for this topology.
async fn get_persisted_bucket_usage(bucket: &str) -> Option<u64> {
let store = runtime_sources::object_store_handle()?;
let info = load_data_usage_from_backend_cached(store).await.ok()?;
info.buckets_usage.get(bucket).map(|usage| usage.size)
persisted_bucket_usage(store, bucket).await
}
async fn persisted_bucket_usage(store: Arc<ECStore>, bucket: &str) -> Option<u64> {
project_data_usage_from_backend_cached(store, |info| info.buckets_usage.get(bucket).map(|usage| usage.size))
.await
.ok()
.flatten()
}
async fn reload_persisted_bucket_usage(store: Arc<ECStore>, bucket: &str) -> Option<u64> {
let loader_store = Arc::clone(&store);
if !reload_snapshot_for_unknown_bucket(bucket, move || load_data_usage_from_backend_with_baseline(loader_store)).await {
return None;
}
persisted_bucket_usage(store, bucket).await
}
/// Last early snapshot reload attempted for a bucket the cached snapshot did
/// not cover. Attempts are spaced whatever their outcome, so a failing backend
/// or a bucket the scanner never reaches costs at most one load per interval.
static UNKNOWN_BUCKET_RELOAD_ATTEMPTED_AT: std::sync::Mutex<Option<tokio::time::Instant>> = std::sync::Mutex::new(None);
fn unknown_bucket_reload_due(cache: &Option<CachedDataUsageSnapshot>, bucket: &str, now: tokio::time::Instant) -> bool {
let refresh_age = Duration::from_secs(DATA_USAGE_UNKNOWN_BUCKET_REFRESH_SECS);
// A cached failure keeps its TTL backoff. A bucket the degraded baseline
// covers is served from it; reloading would only re-read a snapshot whose
// bucket data is still incomplete.
let Some(cached) = cache.as_ref() else {
return false;
};
let Some(info) = cached.info.as_ref() else {
return false;
};
if info.buckets_usage.contains_key(bucket)
|| cached.degraded_baseline.contains_key(bucket)
|| now.duration_since(cached.loaded_at) < refresh_age
{
return false;
}
UNKNOWN_BUCKET_RELOAD_ATTEMPTED_AT
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.is_none_or(|attempted_at| now.duration_since(attempted_at) >= refresh_age)
}
/// Whether a freshly loaded snapshot is older than the cached one, as when a
/// primary read fails and the loader falls back to the previous `.bkp` copy.
fn snapshot_predates(loaded: Option<SystemTime>, cached: Option<SystemTime>) -> bool {
match (loaded, cached) {
(Some(loaded), Some(cached)) => loaded < cached,
(None, Some(_)) => true,
_ => false,
}
}
/// Reload the cached persisted snapshot ahead of its TTL when it does not
/// cover `bucket` at all. Only the scanner leader's own save invalidates its
/// cache, so on every other node a bucket created after the last load would
/// otherwise stay unknown for the rest of the TTL. A failed load, or one older
/// than the cached snapshot, never replaces it. Returns whether the cache may
/// now cover `bucket`.
///
/// Lock order: `DATA_USAGE_SNAPSHOT_REFRESH`, then the snapshot cache, then the
/// attempt stamp, which is never held across an await.
async fn reload_snapshot_for_unknown_bucket<F, Fut>(bucket: &str, load: F) -> bool
where
F: FnOnce() -> Fut,
Fut: Future<Output = Result<LoadedUsageBaseline, Error>>,
{
if !unknown_bucket_reload_due(&*data_usage_snapshot_cache().read().await, bucket, tokio::time::Instant::now()) {
return false;
}
let _refresh_guard = DATA_USAGE_SNAPSHOT_REFRESH
.get_or_init(|| Arc::new(TokioMutex::new(())))
.lock()
.await;
let now = tokio::time::Instant::now();
if !unknown_bucket_reload_due(&*data_usage_snapshot_cache().read().await, bucket, now) {
// Another caller reloaded, or attempted to, while this one waited.
return true;
}
*UNKNOWN_BUCKET_RELOAD_ATTEMPTED_AT
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(now);
let refresh_generation = data_usage_snapshot_generation();
let Ok(loaded) = load().await else {
return false;
};
let mut cache = data_usage_snapshot_cache().write().await;
let cached_last_update = cache
.as_ref()
.and_then(|cached| cached.info.as_ref())
.and_then(|info| info.last_update);
if snapshot_predates(loaded.info.last_update, cached_last_update) {
return false;
}
cache_data_usage_snapshot_result(
&mut cache,
Ok(loaded),
tokio::time::Instant::now(),
refresh_generation,
data_usage_snapshot_generation(),
|_| (),
)
.is_some()
}
// Calls of `reload_unknown_bucket_usage` per bucket. Unit tests do not run in
// a distributed deployment, where the reload itself happens, so this is how
// they tell which lookup a quota caller took.
#[cfg(test)]
static RELOAD_UNKNOWN_BUCKET_USAGE_CALLS: LazyLock<std::sync::Mutex<HashMap<String, usize>>> = LazyLock::new(Default::default);
#[cfg(test)]
pub(crate) fn reload_unknown_bucket_usage_calls_for_test(bucket: &str) -> usize {
RELOAD_UNKNOWN_BUCKET_USAGE_CALLS
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.get(bucket)
.copied()
.unwrap_or(0)
}
/// Second lookup for a caller that fails closed after [`get_bucket_usage_memory`]
/// found no usage: in a distributed deployment, reload the cached persisted
/// snapshot early when it does not cover `bucket` at all, then look the bucket
/// up again. The reload is awaited and is a full snapshot load, so reporting
/// paths do not use this.
pub async fn reload_unknown_bucket_usage(bucket: &str) -> Option<u64> {
#[cfg(test)]
{
*RELOAD_UNKNOWN_BUCKET_USAGE_CALLS
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.entry(bucket.to_string())
.or_default() += 1;
}
if !runtime_sources::setup_is_dist_erasure().await {
return None;
}
let store = runtime_sources::object_store_handle()?;
reload_persisted_bucket_usage(store, bucket).await
}
pub async fn get_bucket_usage_memory(bucket: &str) -> Option<u64> {
@@ -2739,6 +2915,8 @@ pub async fn init_compression_total_memory_from_backend(store: Arc<ECStore>) {
#[cfg(test)]
mod tests {
mod snapshot_projection;
use super::*;
use crate::layout::endpoints::EndpointServerPools;
use crate::runtime::instance::InstanceContext;
@@ -3182,6 +3360,7 @@ mod tests {
pool_meta_save_gate: TokioMutex::default(),
ctx,
bucket_fence_registry: Arc::default(),
ordinary_classification: Default::default(),
})
}
@@ -3992,6 +4171,384 @@ mod tests {
invalidate_data_usage_snapshot_cache().await;
}
async fn set_data_usage_snapshot_cache_age_for_test(age: Duration) {
let mut cache = data_usage_snapshot_cache().write().await;
let cached = cache.as_mut().expect("the snapshot cache must hold a load");
cached.loaded_at = tokio::time::Instant::now()
.checked_sub(age)
.expect("the monotonic clock must reach back by the requested age");
}
async fn install_data_usage_snapshot_cache_for_test(info: DataUsageInfo, age: Duration) {
let degraded_baseline = info.bucket_sizes.clone();
*data_usage_snapshot_cache().write().await = Some(CachedDataUsageSnapshot {
info: Some(info),
loaded_at: tokio::time::Instant::now()
.checked_sub(age)
.expect("the monotonic clock must reach back by the requested age"),
degraded_baseline,
});
}
fn clear_unknown_bucket_reload_attempt_for_test() {
*UNKNOWN_BUCKET_RELOAD_ATTEMPTED_AT
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner()) = None;
}
fn usage_snapshot_with_new_bucket(last_update: SystemTime, existing_size: u64) -> DataUsageInfo {
let mut info = data_usage_info_for_test("existing", 2, existing_size, last_update);
info.buckets_usage.insert(
"created-later".to_string(),
BucketUsageInfo {
objects_count: 1,
versions_count: 1,
size: 1024,
..Default::default()
},
);
info.bucket_sizes.insert("created-later".to_string(), 1024);
info.buckets_count = info.buckets_usage.len() as u64;
info.calculate_totals();
info
}
fn loaded_usage_for_test(info: DataUsageInfo) -> LoadedUsageBaseline {
LoadedUsageBaseline {
degraded_baseline: info.bucket_sizes.clone(),
info,
observed_unavailable: false,
}
}
// The fail-closed composition the quota checker uses in a distributed deployment.
async fn fail_closed_lookup_for_test(store: &Arc<ECStore>, bucket: &str) -> Option<u64> {
match persisted_bucket_usage(Arc::clone(store), bucket).await {
Some(usage) => Some(usage),
None => reload_persisted_bucket_usage(Arc::clone(store), bucket).await,
}
}
async fn cached_bucket_size_for_test(bucket: &str) -> Option<u64> {
data_usage_snapshot_cache()
.read()
.await
.as_ref()
.and_then(|cached| cached.info.as_ref())
.and_then(|info| info.buckets_usage.get(bucket))
.map(|usage| usage.size)
}
#[tokio::test]
#[serial]
async fn unknown_bucket_lookup_reloads_a_snapshot_another_node_published() {
use crate::bucket::metadata_sys::test_support::isolated_store_over_temp_disks;
use crate::config::com::save_config;
let (_dirs, store) = isolated_store_over_temp_disks().await;
let before = data_usage_info_for_test("existing", 1, 10, SystemTime::UNIX_EPOCH + Duration::from_secs(1));
save_config(
store.clone(),
DATA_USAGE_OBJ_NAME_PATH.as_str(),
serde_json::to_vec(&before).expect("encode usage"),
)
.await
.expect("persist usage that predates the new bucket");
invalidate_data_usage_snapshot_cache().await;
clear_unknown_bucket_reload_attempt_for_test();
assert_eq!(fail_closed_lookup_for_test(&store, "existing").await, Some(10));
// The scanner leader on another node publishes a snapshot covering the
// new bucket. Its save does not invalidate this process's cache.
let after = usage_snapshot_with_new_bucket(SystemTime::UNIX_EPOCH + Duration::from_secs(2), 20);
save_config(
store.clone(),
DATA_USAGE_OBJ_NAME_PATH.as_str(),
serde_json::to_vec(&after).expect("encode usage"),
)
.await
.expect("persist the later snapshot");
// A fresh copy is reused even for an unknown bucket, which bounds reloads.
set_data_usage_snapshot_cache_age_for_test(Duration::ZERO).await;
assert_eq!(fail_closed_lookup_for_test(&store, "created-later").await, None);
set_data_usage_snapshot_cache_age_for_test(Duration::from_secs(DATA_USAGE_UNKNOWN_BUCKET_REFRESH_SECS)).await;
assert_eq!(
fail_closed_lookup_for_test(&store, "existing").await,
Some(10),
"a bucket the cached copy covers keeps the cached value for the full TTL"
);
assert_eq!(
persisted_bucket_usage(store.clone(), "created-later").await,
None,
"a reporting lookup never reloads early"
);
assert_eq!(
fail_closed_lookup_for_test(&store, "created-later").await,
Some(1024),
"an unknown bucket must reload an aged copy instead of waiting out the TTL"
);
assert_eq!(persisted_bucket_usage(store, "existing").await, Some(20));
clear_unknown_bucket_reload_attempt_for_test();
invalidate_data_usage_snapshot_cache().await;
}
#[tokio::test]
#[serial]
async fn degraded_baseline_bucket_lookup_does_not_reload_the_snapshot() {
use crate::bucket::metadata_sys::test_support::isolated_store_over_temp_disks;
use crate::config::com::save_config;
let (_dirs, store) = isolated_store_over_temp_disks().await;
let mut incomplete = data_usage_info_for_test("legacy", 1, 7, SystemTime::UNIX_EPOCH + Duration::from_secs(1));
incomplete.usage_snapshot_complete = false;
save_config(
store.clone(),
DATA_USAGE_OBJ_NAME_PATH.as_str(),
serde_json::to_vec(&incomplete).expect("encode usage"),
)
.await
.expect("persist an incomplete snapshot");
invalidate_data_usage_snapshot_cache().await;
clear_unknown_bucket_reload_attempt_for_test();
assert_eq!(fail_closed_lookup_for_test(&store, "legacy").await, None);
assert_eq!(lookup_degraded_bucket_usage_baseline(store.clone(), "legacy").await, Some(7));
save_config(
store.clone(),
DATA_USAGE_OBJ_NAME_PATH.as_str(),
serde_json::to_vec(&data_usage_info_for_test("legacy", 2, 9, SystemTime::UNIX_EPOCH + Duration::from_secs(2)))
.expect("encode usage"),
)
.await
.expect("persist the later complete snapshot");
set_data_usage_snapshot_cache_age_for_test(Duration::from_secs(DATA_USAGE_UNKNOWN_BUCKET_REFRESH_SECS)).await;
assert_eq!(fail_closed_lookup_for_test(&store, "legacy").await, None);
assert_eq!(
lookup_degraded_bucket_usage_baseline(store, "legacy").await,
Some(7),
"a bucket the degraded baseline covers must not reload the snapshot early"
);
clear_unknown_bucket_reload_attempt_for_test();
invalidate_data_usage_snapshot_cache().await;
}
#[tokio::test]
#[serial]
async fn early_reload_keeps_the_cached_snapshot_when_the_backend_read_fails() {
use crate::bucket::metadata_sys::test_support::isolated_store_over_temp_disks;
use crate::config::com::save_config;
let (_dirs, store) = isolated_store_over_temp_disks().await;
let current = data_usage_info_for_test("existing", 1, 10, SystemTime::UNIX_EPOCH + Duration::from_secs(2));
save_config(
store.clone(),
DATA_USAGE_OBJ_NAME_PATH.as_str(),
serde_json::to_vec(&current).expect("encode usage"),
)
.await
.expect("persist the current snapshot");
invalidate_data_usage_snapshot_cache().await;
clear_unknown_bucket_reload_attempt_for_test();
assert_eq!(fail_closed_lookup_for_test(&store, "existing").await, Some(10));
// The primary becomes unreadable and there is no backup.
save_config(store.clone(), DATA_USAGE_OBJ_NAME_PATH.as_str(), b"not a usage snapshot".to_vec())
.await
.expect("corrupt the primary snapshot");
set_data_usage_snapshot_cache_age_for_test(Duration::from_secs(DATA_USAGE_UNKNOWN_BUCKET_REFRESH_SECS)).await;
assert_eq!(fail_closed_lookup_for_test(&store, "unknown").await, None);
let cached = load_data_usage_from_backend_cached(store)
.await
.expect("a failed early reload must keep the cached snapshot");
assert_eq!(cached.buckets_usage.get("existing").map(|usage| usage.size), Some(10));
clear_unknown_bucket_reload_attempt_for_test();
invalidate_data_usage_snapshot_cache().await;
}
#[tokio::test]
#[serial]
async fn early_reload_keeps_the_cached_snapshot_when_the_backend_regresses_to_an_older_backup() {
use crate::bucket::metadata_sys::test_support::isolated_store_over_temp_disks;
use crate::config::com::save_config;
let (_dirs, store) = isolated_store_over_temp_disks().await;
let current = data_usage_info_for_test("existing", 1, 10, SystemTime::UNIX_EPOCH + Duration::from_secs(2));
save_config(
store.clone(),
DATA_USAGE_OBJ_NAME_PATH.as_str(),
serde_json::to_vec(&current).expect("encode usage"),
)
.await
.expect("persist the current snapshot");
invalidate_data_usage_snapshot_cache().await;
clear_unknown_bucket_reload_attempt_for_test();
assert_eq!(fail_closed_lookup_for_test(&store, "existing").await, Some(10));
// The primary becomes unreadable and the loader falls back to an older
// backup that would otherwise expose the unknown bucket.
save_config(store.clone(), DATA_USAGE_OBJ_NAME_PATH.as_str(), b"not a usage snapshot".to_vec())
.await
.expect("corrupt the primary snapshot");
let older = usage_snapshot_with_new_bucket(SystemTime::UNIX_EPOCH + Duration::from_secs(1), 5);
save_config(
store.clone(),
DATA_USAGE_OBJ_BACKUP_PATH.as_str(),
serde_json::to_vec(&older).expect("encode usage"),
)
.await
.expect("persist an older backup snapshot");
set_data_usage_snapshot_cache_age_for_test(Duration::from_secs(DATA_USAGE_UNKNOWN_BUCKET_REFRESH_SECS)).await;
assert_eq!(
fail_closed_lookup_for_test(&store, "created-later").await,
None,
"an older snapshot must not replace the cached one"
);
let cached = load_data_usage_from_backend_cached(store)
.await
.expect("an older early reload must keep the cached snapshot");
assert_eq!(cached.buckets_usage.get("existing").map(|usage| usage.size), Some(10));
clear_unknown_bucket_reload_attempt_for_test();
invalidate_data_usage_snapshot_cache().await;
}
#[tokio::test]
#[serial]
async fn early_reload_rejects_a_snapshot_without_last_update() {
// With both the primary and the `.bkp` copy missing, the loader returns
// an empty snapshot without `last_update`. Installing it over a good
// copy would make every bucket unknown and fail snapshot-protocol
// admission closed.
let cached = data_usage_info_for_test("existing", 1, 10, SystemTime::UNIX_EPOCH + Duration::from_secs(2));
install_data_usage_snapshot_cache_for_test(cached, Duration::from_secs(DATA_USAGE_UNKNOWN_BUCKET_REFRESH_SECS)).await;
clear_unknown_bucket_reload_attempt_for_test();
assert!(
!reload_snapshot_for_unknown_bucket("created-later", || async {
Ok(loaded_usage_for_test(DataUsageInfo::default()))
})
.await
);
assert_eq!(
cached_bucket_size_for_test("existing").await,
Some(10),
"a snapshot without last_update must not replace a dated one"
);
clear_unknown_bucket_reload_attempt_for_test();
invalidate_data_usage_snapshot_cache().await;
}
#[tokio::test]
#[serial]
async fn early_reload_accepts_a_snapshot_with_the_same_last_update() {
let last_update = SystemTime::UNIX_EPOCH + Duration::from_secs(2);
let cached = data_usage_info_for_test("existing", 1, 10, last_update);
install_data_usage_snapshot_cache_for_test(cached, Duration::from_secs(DATA_USAGE_UNKNOWN_BUCKET_REFRESH_SECS)).await;
clear_unknown_bucket_reload_attempt_for_test();
let same_age = usage_snapshot_with_new_bucket(last_update, 10);
assert!(reload_snapshot_for_unknown_bucket("created-later", || async { Ok(loaded_usage_for_test(same_age)) }).await);
assert_eq!(
cached_bucket_size_for_test("created-later").await,
Some(1024),
"a snapshot as recent as the cached one must be accepted"
);
clear_unknown_bucket_reload_attempt_for_test();
invalidate_data_usage_snapshot_cache().await;
}
#[tokio::test]
#[serial]
async fn early_reload_attempts_are_spaced_whatever_their_outcome() {
let refresh_age = Duration::from_secs(DATA_USAGE_UNKNOWN_BUCKET_REFRESH_SECS);
let cached = data_usage_info_for_test("existing", 1, 10, SystemTime::UNIX_EPOCH + Duration::from_secs(2));
install_data_usage_snapshot_cache_for_test(cached, refresh_age).await;
clear_unknown_bucket_reload_attempt_for_test();
let loads = AtomicUsize::new(0);
assert!(
!reload_snapshot_for_unknown_bucket("created-later", || async {
loads.fetch_add(1, Ordering::SeqCst);
Err(Error::ErasureReadQuorum)
})
.await
);
assert_eq!(cached_bucket_size_for_test("existing").await, Some(10));
let newer = usage_snapshot_with_new_bucket(SystemTime::UNIX_EPOCH + Duration::from_secs(3), 20);
assert!(
!reload_snapshot_for_unknown_bucket("created-later", || async {
loads.fetch_add(1, Ordering::SeqCst);
Ok(loaded_usage_for_test(newer.clone()))
})
.await
);
assert_eq!(loads.load(Ordering::SeqCst), 1, "a failed attempt must still space the next one");
*UNKNOWN_BUCKET_RELOAD_ATTEMPTED_AT
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner()) = tokio::time::Instant::now().checked_sub(refresh_age);
assert!(
reload_snapshot_for_unknown_bucket("created-later", || async {
loads.fetch_add(1, Ordering::SeqCst);
Ok(loaded_usage_for_test(newer.clone()))
})
.await
);
assert_eq!(loads.load(Ordering::SeqCst), 2);
assert_eq!(cached_bucket_size_for_test("created-later").await, Some(1024));
clear_unknown_bucket_reload_attempt_for_test();
invalidate_data_usage_snapshot_cache().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[serial]
async fn concurrent_unknown_and_known_lookups_load_the_snapshot_once() {
use crate::bucket::metadata_sys::test_support::isolated_store_over_temp_disks;
// Nothing is persisted, so any real backend load would replace the
// installed snapshot with an empty one and fail the final assertions.
let (_dirs, store) = isolated_store_over_temp_disks().await;
let cached = data_usage_info_for_test("existing", 1, 10, SystemTime::UNIX_EPOCH + Duration::from_secs(2));
install_data_usage_snapshot_cache_for_test(cached, Duration::from_secs(DATA_USAGE_UNKNOWN_BUCKET_REFRESH_SECS)).await;
clear_unknown_bucket_reload_attempt_for_test();
let loads = Arc::new(AtomicUsize::new(0));
let newer = usage_snapshot_with_new_bucket(SystemTime::UNIX_EPOCH + Duration::from_secs(3), 10);
let start = Arc::new(tokio::sync::Barrier::new(16));
let mut unknown_lookups = Vec::new();
let mut known_lookups = Vec::new();
for _ in 0..8 {
let (unknown_start, loads, newer) = (Arc::clone(&start), Arc::clone(&loads), newer.clone());
unknown_lookups.push(tokio::spawn(async move {
unknown_start.wait().await;
reload_snapshot_for_unknown_bucket("created-later", move || async move {
loads.fetch_add(1, Ordering::SeqCst);
// Hold the refresh lock long enough for the other lookups to queue.
tokio::time::sleep(Duration::from_millis(50)).await;
Ok(loaded_usage_for_test(newer))
})
.await
}));
let (known_start, store) = (Arc::clone(&start), Arc::clone(&store));
known_lookups.push(tokio::spawn(async move {
known_start.wait().await;
fail_closed_lookup_for_test(&store, "existing").await
}));
}
for lookup in unknown_lookups {
lookup.await.expect("unknown-bucket lookup task");
}
for lookup in known_lookups {
assert_eq!(lookup.await.expect("known-bucket lookup task"), Some(10));
}
assert_eq!(loads.load(Ordering::SeqCst), 1, "concurrent lookups must share one backend load");
assert_eq!(persisted_bucket_usage(store, "created-later").await, Some(1024));
clear_unknown_bucket_reload_attempt_for_test();
invalidate_data_usage_snapshot_cache().await;
}
#[tokio::test]
async fn authoritative_save_cleanup_removes_observed_snapshot_best_effort() {
let store = UsageCasStore::default();
@@ -4042,15 +4599,29 @@ mod tests {
loaded_at,
refresh_generation,
data_usage_snapshot_generation(),
DataUsageInfo::clone,
)
.expect("an uninterrupted refresh should populate the cache");
assert!(matches!(first, Err(Error::ErasureReadQuorum)));
let cached = fresh_cached_data_usage_snapshot(&cache, loaded_at + Duration::from_secs(1), Duration::from_secs(30))
.expect("the failed load must remain cached within the TTL");
let cached = fresh_cached_data_usage_snapshot(
&cache,
loaded_at + Duration::from_secs(1),
Duration::from_secs(30),
DataUsageInfo::clone,
)
.expect("the failed load must remain cached within the TTL");
assert!(cached.is_err());
assert!(fresh_cached_data_usage_snapshot(&cache, loaded_at + Duration::from_secs(30), Duration::from_secs(30)).is_none());
assert!(
fresh_cached_data_usage_snapshot(
&cache,
loaded_at + Duration::from_secs(30),
Duration::from_secs(30),
DataUsageInfo::clone
)
.is_none()
);
}
#[test]
@@ -4071,14 +4642,20 @@ mod tests {
loaded_at,
refresh_generation,
data_usage_snapshot_generation(),
DataUsageInfo::clone,
)
.expect("an uninterrupted refresh should populate the cache")
.expect("successful load must be returned");
assert_snapshot_bucket(&first, "bucket");
let cached = fresh_cached_data_usage_snapshot(&cache, loaded_at + Duration::from_secs(1), Duration::from_secs(30))
.expect("successful load must remain cached within the TTL")
.expect("cached success must remain successful");
let cached = fresh_cached_data_usage_snapshot(
&cache,
loaded_at + Duration::from_secs(1),
Duration::from_secs(30),
DataUsageInfo::clone,
)
.expect("successful load must remain cached within the TTL")
.expect("cached success must remain successful");
assert_snapshot_bucket(&cached, "bucket");
}
@@ -4103,6 +4680,7 @@ mod tests {
loaded_at,
refresh_generation,
data_usage_snapshot_generation(),
DataUsageInfo::clone,
)
.expect("an uninterrupted refresh should populate the cache")
.expect("successful load must be returned");
@@ -4136,6 +4714,7 @@ mod tests {
loaded_at,
refresh_generation,
data_usage_snapshot_generation(),
DataUsageInfo::clone,
);
assert!(stale_result.is_none());
@@ -5831,6 +6410,43 @@ mod tests {
assert_eq!(get_bucket_usage_memory("bucket-a").await, None);
}
#[tokio::test]
#[serial]
async fn complete_scanner_cycle_with_carried_bucket_preserves_legacy_quota_floor() {
clear_usage_memory_cache_for_test().await;
let bucket = "partial-carried-quota";
let mut baseline = data_usage_info_for_test(bucket, 2, 100, SystemTime::UNIX_EPOCH + Duration::from_secs(100));
baseline.scanner_epoch = Some(7);
baseline.scanner_cycle = Some(10);
baseline.usage_snapshot_converged = Some(true);
replace_bucket_usage_memory_from_info(&baseline).await;
record_bucket_object_write_memory(bucket, None, 25).await;
record_bucket_object_delete_memory(bucket, 20, true).await;
assert_eq!(get_bucket_usage_memory(bucket).await, Some(125));
let mutation_time = memory_cache().read().await[bucket].usage_updated_at;
let mut carried = baseline;
carried.last_update = Some(mutation_time + Duration::from_secs(1));
carried.scanner_cycle = Some(11);
carried.usage_snapshot_converged = Some(false);
carried.usage_snapshot_pending_buckets = vec![bucket.to_string()];
assert!(carried.usage_snapshot_complete);
replace_bucket_usage_memory_from_info(&carried).await;
assert_eq!(
get_bucket_usage_memory(bucket).await,
Some(125),
"Complete with a carried certified value must retain positive deltas and the delete hold"
);
let mut reconciled = data_usage_info_for_test(bucket, 2, 105, mutation_time + Duration::from_secs(2));
reconciled.scanner_epoch = Some(7);
reconciled.scanner_cycle = Some(12);
reconciled.usage_snapshot_converged = Some(true);
replace_bucket_usage_memory_from_info(&reconciled).await;
assert_eq!(get_bucket_usage_memory(bucket).await, Some(105));
clear_usage_memory_cache_for_test().await;
}
#[tokio::test]
#[serial]
async fn stale_quota_uses_complete_baseline_plus_positive_deltas() {
@@ -0,0 +1,191 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use super::*;
#[test]
fn scalar_projection_borrows_snapshot_and_preserves_unknown_and_zero() {
let loaded_at = tokio::time::Instant::now();
let mut info = data_usage_info_for_test("known", 1, 42, SystemTime::UNIX_EPOCH);
info.buckets_usage.insert("empty".to_string(), BucketUsageInfo::default());
let cache = Some(CachedDataUsageSnapshot {
info: Some(info),
loaded_at,
degraded_baseline: HashMap::new(),
});
let stored = cache
.as_ref()
.expect("cached snapshot")
.info
.as_ref()
.expect("known snapshot");
let ttl = Duration::from_secs(30);
for (bucket, expected) in [("known", Some(42)), ("empty", Some(0)), ("missing", None)] {
let value = fresh_cached_data_usage_snapshot(&cache, loaded_at, ttl, |info| {
assert!(std::ptr::eq(info, stored), "projection must borrow the cached snapshot");
info.buckets_usage.get(bucket).map(|usage| usage.size)
})
.expect("fresh snapshot")
.expect("successful snapshot");
let owned = fresh_cached_data_usage_snapshot(&cache, loaded_at, ttl, DataUsageInfo::clone)
.expect("fresh snapshot")
.expect("successful snapshot");
assert_eq!(value, expected);
assert_eq!(value, owned.buckets_usage.get(bucket).map(|usage| usage.size));
}
}
#[test]
fn projection_is_skipped_for_missing_failed_expired_or_invalidated_snapshots() {
let loaded_at = tokio::time::Instant::now();
let ttl = Duration::from_secs(30);
let never_project = |_: &DataUsageInfo| -> u64 { panic!("unusable snapshots must not be projected") };
assert!(fresh_cached_data_usage_snapshot(&None, loaded_at, ttl, never_project).is_none());
let mut cache = None;
let failure = cache_data_usage_snapshot_result(&mut cache, Err(Error::ErasureReadQuorum), loaded_at, 1, 1, never_project)
.expect("matching generation");
assert!(matches!(failure, Err(Error::ErasureReadQuorum)));
assert!(
fresh_cached_data_usage_snapshot(&cache, loaded_at, ttl, never_project)
.expect("negative cache remains fresh")
.is_err()
);
cache = Some(CachedDataUsageSnapshot {
info: Some(data_usage_info_for_test("known", 1, 42, SystemTime::UNIX_EPOCH)),
loaded_at,
degraded_baseline: HashMap::new(),
});
assert!(fresh_cached_data_usage_snapshot(&cache, loaded_at + ttl, ttl, never_project).is_none());
cache = None;
let rejected = cache_data_usage_snapshot_result(
&mut cache,
Ok(LoadedUsageBaseline {
info: data_usage_info_for_test("known", 1, 42, SystemTime::UNIX_EPOCH),
degraded_baseline: HashMap::new(),
observed_unavailable: false,
}),
loaded_at,
1,
2,
never_project,
);
assert!(rejected.is_none());
assert!(cache.is_none(), "rejected generation must not republish stale data");
}
#[test]
fn projected_publication_moves_bucket_maps_into_cache() {
let info = data_usage_info_for_test("known", 1, 42, SystemTime::UNIX_EPOCH);
let original_key = info.buckets_usage.get_key_value("known").expect("source bucket").0.as_ptr();
let mut cache = None;
let usage = cache_data_usage_snapshot_result(
&mut cache,
Ok(LoadedUsageBaseline {
info,
degraded_baseline: HashMap::new(),
observed_unavailable: false,
}),
tokio::time::Instant::now(),
1,
1,
|info| {
assert_eq!(
info.buckets_usage
.get_key_value("known")
.expect("projected bucket")
.0
.as_ptr(),
original_key
);
info.buckets_usage.get("known").map(|usage| usage.size)
},
)
.expect("matching generation")
.expect("successful publication");
assert_eq!(usage, Some(42));
let cached = cache
.as_ref()
.expect("published cache")
.info
.as_ref()
.expect("published snapshot");
assert_eq!(
cached.buckets_usage.get_key_value("known").expect("cached bucket").0.as_ptr(),
original_key
);
}
#[tokio::test]
#[serial]
async fn persisted_scalar_projection_shares_cache_refresh_and_invalidation() {
use crate::bucket::metadata_sys::test_support::isolated_store_over_temp_disks;
use crate::config::com::save_config;
let (_dirs, store) = isolated_store_over_temp_disks().await;
let mut info = data_usage_info_for_test("known", 1, 42, SystemTime::UNIX_EPOCH);
info.buckets_usage.insert("empty".to_string(), BucketUsageInfo::default());
info.bucket_sizes.insert("empty".to_string(), 0);
info.buckets_count = 2;
save_config(
store.clone(),
DATA_USAGE_OBJ_NAME_PATH.as_str(),
serde_json::to_vec(&info).expect("encode snapshot"),
)
.await
.expect("persist snapshot");
invalidate_data_usage_snapshot_cache().await;
for (bucket, expected) in [("known", Some(42)), ("empty", Some(0)), ("missing", None)] {
assert_eq!(persisted_bucket_usage(store.clone(), bucket).await, 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));
}
info.buckets_usage.get_mut("known").expect("known bucket").size = 84;
info.bucket_sizes.insert("known".to_string(), 84);
save_config(
store.clone(),
DATA_USAGE_OBJ_NAME_PATH.as_str(),
serde_json::to_vec(&info).expect("encode new snapshot"),
)
.await
.expect("persist new snapshot without invalidating cache");
assert_eq!(
persisted_bucket_usage(store.clone(), "known").await,
Some(42),
"warm reads reuse the TTL cache"
);
invalidate_data_usage_snapshot_cache().await;
assert_eq!(
persisted_bucket_usage(store.clone(), "known").await,
Some(84),
"invalidation exposes the new snapshot"
);
invalidate_data_usage_snapshot_cache().await;
if let Some(cancel) = store.ctx.background_cancel_token() {
cancel.cancel();
}
for pool in &store.pools {
for set in &pool.disk_set {
let disks = set.disks.read().await.clone();
for disk in disks.iter().flatten() {
disk.close().await.expect("stop fixture disk monitoring");
}
}
}
}

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