Compare commits

...

258 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
overtrue 9fbf5d0553 feat(storage): retain managed artifact versions for database backups 2026-10-20 23:51:32 +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
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
269 changed files with 74942 additions and 6992 deletions
+2 -2
View File
@@ -1,2 +1,2 @@
sha256-linux=a5f33e6b51d5e6f6be9cd2c7f3464e3d810500d0064ea2447eae58e60c584b66
sha256-darwin=a5f33e6b51d5e6f6be9cd2c7f3464e3d810500d0064ea2447eae58e60c584b66
sha256-linux=74a1d6975e2d6dfe01363585cb9bfa3232d79556a154d4f599653fe2ed1ce696
sha256-darwin=74a1d6975e2d6dfe01363585cb9bfa3232d79556a154d4f599653fe2ed1ce696
+2 -2
View File
@@ -1,2 +1,2 @@
sha256-darwin=c43e78171a8a39bf170473dfcc349c4cb8c1155cdc6a4a8b39398564f23fbcff
sha256-linux=fb5598b9e4cb48bd5c775c40b27d4708762d89573ba4bed231aa9f486e415991
sha256-darwin=b53fe6d46cdffedfad6a83da0dfffa9aec10cd7c8ace53469f129587bc9f4d50
sha256-linux=6be90f5c3886e9852ac27d51f2d47e6d2fa8bb424e0d03ee931268dcd0020db8
+1 -1
View File
@@ -1 +1 @@
sha256=2db635627cc625fc89af9e1f92a9aa814f9f438b89eaf7667be892ade61b5807
sha256=002ec5d973f82288e50a096a2c8b3b38749f62a7a3e169775566874d8ee98b4a
+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"
Generated
+55 -58
View File
@@ -255,7 +255,7 @@ version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -266,7 +266,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -1461,12 +1461,6 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6"
[[package]]
name = "base64"
version = "0.21.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
[[package]]
name = "base64"
version = "0.22.1"
@@ -1618,7 +1612,7 @@ version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array 0.14.9",
"generic-array 0.14.7",
]
[[package]]
@@ -1637,7 +1631,7 @@ version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"
dependencies = [
"generic-array 0.14.9",
"generic-array 0.14.7",
]
[[package]]
@@ -1869,7 +1863,7 @@ dependencies = [
"maybe-owned",
"rustix",
"rustix-linux-procfs",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
"winx",
]
@@ -2073,7 +2067,7 @@ version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common 0.1.6",
"crypto-common 0.1.7",
"inout 0.1.4",
]
@@ -2296,9 +2290,9 @@ dependencies = [
[[package]]
name = "const_format"
version = "0.2.36"
version = "0.2.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4481a617ad9a412be3b97c5d403fef8ed023103368908b9c50af598ff467cc1e"
checksum = "a3decb4d6965448659923544a634a0571ba44cede4e11ef15a5a317126840454"
dependencies = [
"const_format_proc_macros",
"konst",
@@ -2306,9 +2300,9 @@ dependencies = [
[[package]]
name = "const_format_proc_macros"
version = "0.2.34"
version = "0.2.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744"
checksum = "941b570269a642506f08cb5b688c04bfa6cfd41a26f8bcdc49db8fd00c0ab659"
dependencies = [
"proc-macro2",
"quote",
@@ -2548,9 +2542,9 @@ checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "crypto-bigint"
version = "0.7.6"
version = "0.7.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6bb08299a6046833a879ace115697f12fd2bd6c8d3e93c909ffc9e0738e7cae0"
checksum = "cc907c45bb7e9ef8fc3778defd0f3cd8c81288f1b4e748eba064948f20c47122"
dependencies = [
"cpubits",
"ctutils",
@@ -2565,11 +2559,11 @@ dependencies = [
[[package]]
name = "crypto-common"
version = "0.1.6"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array 0.14.9",
"generic-array 0.14.7",
"typenum",
]
@@ -3910,7 +3904,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer 0.10.4",
"crypto-common 0.1.6",
"crypto-common 0.1.7",
"subtle",
]
@@ -3945,7 +3939,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -4137,9 +4131,9 @@ checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be"
[[package]]
name = "elliptic-curve"
version = "0.14.1"
version = "0.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d65aa39b3a5c1c9c1b745c9a019234bb7a21b77abcb4f4d266d706e2d577d65"
checksum = "0ad4496221fcfc21015fd8c0e26fcfab00504832dc0604f52acdd7bad2a8dc81"
dependencies = [
"base16ct",
"crypto-bigint",
@@ -4258,7 +4252,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -4575,9 +4569,9 @@ dependencies = [
[[package]]
name = "generic-array"
version = "0.14.9"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
@@ -4589,7 +4583,7 @@ version = "1.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "337d46834ee672ab3e48caca2cb0c78cc174fb12b3a68d0d88f99a0519a5e36e"
dependencies = [
"generic-array 0.14.9",
"generic-array 0.14.7",
"rustversion",
"typenum",
]
@@ -5241,9 +5235,9 @@ dependencies = [
[[package]]
name = "hotpath"
version = "0.29.0"
version = "0.29.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73f137bde4c97275aef220f3a1566f39721fbd53086a444c8d841b5f345719dc"
checksum = "62f2351902fa259b758c7303505273393f6b08172ab5bdd5739a2d2bc6d554e8"
dependencies = [
"arc-swap",
"async-channel",
@@ -5276,15 +5270,15 @@ dependencies = [
[[package]]
name = "hotpath-drain"
version = "0.29.0"
version = "0.29.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87df2604170a3a0b512a837eba4d5d18671e54df8d450cc894af36d41291dba5"
checksum = "83dab2340134d9976d62f9f5172f9c6584c7f521b092f258e0e58e412e160b53"
[[package]]
name = "hotpath-macros"
version = "0.29.0"
version = "0.29.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25dcd4a80153dd66b34b8ae5d02572d71cc3ff55aff397578cd471d645a8a740"
checksum = "a04bbe5701b097f224a08187a78b75bb9d9b4bbaff6a279c2a50ba73edbb7aac"
dependencies = [
"proc-macro2",
"quote",
@@ -5613,7 +5607,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"block-padding 0.3.3",
"generic-array 0.14.9",
"generic-array 0.14.7",
]
[[package]]
@@ -6264,9 +6258,9 @@ dependencies = [
[[package]]
name = "lzma-rust2"
version = "0.22.0"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7277c9742229dc25243236d578b6471a798c554512c6f6a04365ec2e87c2a3bb"
checksum = "f1c46f3fcd2e888afdb01a07efc071cb21300e07c68d32c3e8b01b5c8cb1fcaa"
dependencies = [
"sha2 0.11.1",
]
@@ -6871,7 +6865,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -7004,7 +6998,7 @@ name = "oauth2"
version = "5.1.0"
source = "git+https://github.com/ramosbugs/oauth2-rs?rev=89585f4a280afd7b285eaf1c9be0068874e03d22#89585f4a280afd7b285eaf1c9be0068874e03d22"
dependencies = [
"base64 0.21.7",
"base64 0.22.1",
"chrono",
"getrandom 0.2.17",
"http 1.5.0",
@@ -8580,7 +8574,7 @@ dependencies = [
"once_cell",
"socket2",
"tracing",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -10125,18 +10119,18 @@ dependencies = [
[[package]]
name = "rustfs-mimalloc"
version = "0.7.1"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91c0ac4b4c6f8246bb843c985b47f7f8ea3e654e02d965a5d6c19fc006813584"
checksum = "8301a578772fc9823d68e083e42c3b414613d588a0eac594c1ca6bc0bbe0653f"
dependencies = [
"rustfs-mimalloc-sys",
]
[[package]]
name = "rustfs-mimalloc-sys"
version = "0.7.1"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "265332babe807f64b800b1a47d6d39e6897f249c153b962a6b1050f073816342"
checksum = "6eb4082853043cb29556b4b776b05f28c3146706f43c53869dbf1ef262e00e91"
dependencies = [
"cc",
]
@@ -10304,6 +10298,7 @@ dependencies = [
"tokio",
"tracing",
"tracing-subscriber",
"uuid",
]
[[package]]
@@ -10693,6 +10688,7 @@ dependencies = [
"hotpath",
"insta",
"rustfs-filemeta",
"rustfs-utils",
"serde",
"serde_json",
"time",
@@ -10960,7 +10956,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -11043,7 +11039,7 @@ dependencies = [
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -11804,9 +11800,9 @@ dependencies = [
[[package]]
name = "smallvec"
version = "1.16.3"
version = "1.16.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b"
checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8"
dependencies = [
"serde",
]
@@ -11845,7 +11841,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
dependencies = [
"libc",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -11996,7 +11992,7 @@ dependencies = [
"cfg-if",
"libc",
"psm",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -12302,10 +12298,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.3.4",
"getrandom 0.4.3",
"once_cell",
"rustix",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -12978,9 +12974,9 @@ checksum = "86a801b3cea342a06d468c8710662aa29e5e05e4f5c0d62f00bbb7f2ad7941c2"
[[package]]
name = "typed-path"
version = "0.12.4"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "54ec2755506471f54baf5e4bb5499d8e510cc1caf271270e778a736bd2d7336c"
checksum = "9ce898823d8cd0f028662d2f349694b1af0a5cc9be83f1d213a00d3147445e1e"
[[package]]
name = "typeid"
@@ -13399,7 +13395,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -13960,9 +13956,9 @@ dependencies = [
[[package]]
name = "zip"
version = "9.0.5"
version = "9.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e04976da629d3b04204ba8105b396a193af8ad2583b2c79bfb3ab8af580a11e"
checksum = "2886b00003a72c4ccee3f42cfc34908d33b2508a74d827dc2a4244706475c704"
dependencies = [
"aes 0.9.3",
"bzip2",
@@ -13981,6 +13977,7 @@ dependencies = [
"sha1 0.11.0",
"time",
"typed-path",
"windows-sys 0.61.2",
"zeroize",
"zopfli",
"zstd 0.14.3",
+4 -4
View File
@@ -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" }
@@ -354,7 +354,7 @@ winapi-util = "0.1.11"
windows = { version = "0.62.2" }
windows-sys = "0.61.2"
xxhash-rust = { version = "0.8.19" }
zip = "9.0.5"
zip = "9.1.0"
zstd = "0.14.3"
# Observability and Metrics
@@ -382,9 +382,9 @@ russh-sftp = "3.0.1"
dav-server = "0.11.0"
# Performance Analysis and Memory Profiling
rustfs-mimalloc = { version = "0.7.1" }
rustfs-mimalloc = { version = "0.7.2" }
# Preserve Unicode focus filters until rustfs/backlog#2302 is resolved.
hotpath = { version = "0.29.0", default-features = false }
hotpath = { version = "0.29.1", default-features = false }
# Snapshot testing for output format regression detection
insta = { version = "1.49" }
+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);
}
}
+1 -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
+2 -2
View File
@@ -200,8 +200,8 @@ 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 fault-test hooks server; the
# scanner_heal_topology_ fault cases fail closed without it.
# 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 --bins --features e2e-test-hooks
python3 scripts/e2e_binary.py run --features e2e-test-hooks -- cargo nextest run --profile e2e-distributed -p e2e_test
+111 -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,
@@ -2438,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,
}
}
@@ -2455,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));
@@ -2468,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]
@@ -2555,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));
@@ -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(())
}
+8 -9
View File
@@ -34,7 +34,7 @@ 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;
@@ -185,13 +185,12 @@ impl DistCluster {
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
@@ -347,7 +346,7 @@ fn relocate_pool_storage(cluster: &mut RustFSTestClusterEnvironment, node_idx: u
Ok(pool_run_root)
}
fn configure_node_logs(cluster: &mut RustFSTestClusterEnvironment, capture_dir: Option<&Path>) -> 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() {
@@ -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(())
}
+9
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,6 +33,8 @@ 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;
@@ -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(())
}
@@ -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.
+3
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;
+296
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).
@@ -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>,
+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"
);
}
}
+9 -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::{DiskOption, Endpoint, RUSTFS_META_BUCKET, VolumeInfo, WalkDirOptions, new_disk};
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
+45 -4
View File
@@ -382,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 {
@@ -431,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;
@@ -635,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);
}
}
@@ -890,6 +890,7 @@ pub(crate) async fn cleanup_free_version_exact(
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);
@@ -958,7 +959,7 @@ pub(crate) async fn cleanup_free_version_exact(
};
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()
{
@@ -8496,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]
+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.
+36 -6
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 {
+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;
+13 -12
View File
@@ -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,18 +246,13 @@ 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 unavailable memory usage is reported as "unknown" and 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> {
// The plain lookup: a report must not wait for an early snapshot reload.
let usage = get_bucket_usage_memory(bucket).await;
@@ -267,7 +264,7 @@ impl QuotaChecker {
subsystem = LOG_SUBSYSTEM_QUOTA,
state = "usage_unknown",
bucket = %bucket,
"durable quota usage report degraded"
"quota usage report degraded"
);
}
usage
@@ -742,8 +739,12 @@ mod tests {
1,
"fail-closed admission takes the early-reload lookup"
);
assert!(matches!(checker.get_quota_stats(&legacy).await, Err(QuotaError::UsageUnavailable { .. })));
assert_eq!(crate::data_usage::reload_unknown_bucket_usage_calls_for_test(&legacy), 2);
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,
@@ -894,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)
@@ -245,3 +245,86 @@ async fn quota_without_limit_keeps_the_zero_usage_io_fast_path() {
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"
);
}
+77 -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,
@@ -2535,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);
}
}
+174 -51
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);
}
@@ -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,6 +725,9 @@ 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();
@@ -710,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()];
@@ -760,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;
@@ -833,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 }),
}
};
@@ -892,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) => {}
}
}
@@ -949,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) => {}
}
}
@@ -969,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");
}
@@ -993,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);
@@ -1113,7 +1146,7 @@ async fn list_path_raw_inner(
}
// warn!("list_path_raw: done");
Ok(())
Ok(outcome)
}
#[inline]
@@ -1132,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;
@@ -1252,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(
@@ -1710,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 {
@@ -1811,6 +1928,7 @@ mod tests {
Box::pin(async move {
callback_started.notify_one();
std::future::pending::<()>().await;
Ok(())
})
})),
..Default::default()
@@ -1962,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()
@@ -2004,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()
@@ -2076,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, _| {
@@ -2241,6 +2362,7 @@ mod tests {
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, _| {
@@ -2397,6 +2519,7 @@ mod tests {
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, _| {
@@ -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();
+516 -9
View File
@@ -2375,7 +2375,300 @@ fn validate_decoded_file_info(file_info: &FileInfo) -> Result<()> {
file_info.validate_for_metadata_read().map_err(Into::into)
}
fn validate_artifact_repair_seal_response(
request: &rustfs_protos::proto_gen::node_service::SealArtifactAttemptPutsRequest,
response: &rustfs_protos::proto_gen::node_service::SealArtifactAttemptPutsResponse,
) -> Result<()> {
let canonical = rustfs_protos::canonical_seal_artifact_attempt_repairs_response_body(request, response)
.map_err(|_| Error::other("artifact repair seal response is too large"))?;
crate::cluster::rpc::verify_tonic_rpc_response_proof(&canonical, &response.response_proof)
.map_err(|_| Error::other("artifact repair seal response proof is invalid"))?;
if !response.success {
return Err(response.error.clone().unwrap_or_default().into());
}
let expected_disk = Uuid::from_slice(&request.expected_disk_id).ok().filter(|id| !id.is_nil());
if response.error.is_some() || expected_disk.is_none() || Uuid::from_slice(&response.disk_id).ok() != expected_disk {
return Err(Error::other("artifact repair seal response disk identity mismatch"));
}
Ok(())
}
impl RemoteDisk {
pub(crate) async fn rename_artifact_repair_data(
&self,
expected_disk: Uuid,
source: (&str, &str),
fi: &FileInfo,
destination: (&str, &str),
expected_incarnation: Uuid,
) -> Result<RenameDataResp> {
use rustfs_protos::proto_gen::node_service::ArtifactRepairRenameDataRequest;
self.execute_with_timeout_for_op(
"artifact_repair_rename_data",
|| async {
let body = ArtifactRepairRenameDataRequest {
rename: Some(RenameDataRequest {
disk: self.endpoint.to_string(),
src_volume: source.0.to_owned(),
src_path: source.1.to_owned(),
dst_volume: destination.0.to_owned(),
dst_path: destination.1.to_owned(),
file_info: compat_json(fi)?,
file_info_bin: encode_file_info_msgpack(fi)?.into(),
scanner_publication_lease_token: Bytes::new(),
bucket_incarnation_id: expected_incarnation.as_bytes().to_vec().into(),
}),
expected_disk_id: expected_disk.as_bytes().to_vec().into(),
};
let canonical = rustfs_protos::canonical_artifact_repair_rename_data_request_body(&body)
.map_err(|_| Error::other("artifact repair rename request is too large"))?;
let mut request = Request::new(body);
crate::cluster::rpc::set_tonic_canonical_body_digest(&mut request, &canonical).map_err(Error::other)?;
// Unimplemented is terminal: never downgrade repair publication to PUT or legacy RPCs.
let response = self
.get_client()
.await?
.artifact_repair_rename_data(request)
.await?
.into_inner();
if !response.success {
return Err(response.error.unwrap_or_default().into());
}
decode_msgpack_or_json::<RenameDataResp>(
&response.rename_data_resp_bin,
&response.rename_data_resp,
"RenameDataResp",
)
},
get_max_timeout_duration(),
)
.await
}
pub(crate) async fn write_artifact_repair_metadata(
&self,
expected_disk: Uuid,
target: (&str, &str),
fi: &FileInfo,
expected_incarnation: Uuid,
) -> Result<()> {
use rustfs_protos::proto_gen::node_service::ArtifactRepairWriteMetadataRequest;
self.execute_with_timeout_for_op(
"artifact_repair_write_metadata",
|| async {
let body = ArtifactRepairWriteMetadataRequest {
metadata: Some(WriteMetadataRequest {
disk: self.endpoint.to_string(),
volume: target.0.to_owned(),
path: target.1.to_owned(),
file_info: compat_json(fi)?,
file_info_bin: encode_file_info_msgpack(fi)?.into(),
bucket_incarnation_id: expected_incarnation.as_bytes().to_vec().into(),
}),
expected_disk_id: expected_disk.as_bytes().to_vec().into(),
};
let canonical = rustfs_protos::canonical_artifact_repair_write_metadata_request_body(&body)
.map_err(|_| Error::other("artifact repair metadata request is too large"))?;
let mut request = Request::new(body);
crate::cluster::rpc::set_tonic_canonical_body_digest(&mut request, &canonical).map_err(Error::other)?;
let response = self
.get_client()
.await?
.artifact_repair_write_metadata(request)
.await?
.into_inner();
if !response.success {
return Err(response.error.unwrap_or_default().into());
}
Ok(())
},
get_max_timeout_duration(),
)
.await
}
/// One authenticated scan (or explicitly distinct read-only identity probe).
/// Partial observations are never retried after output has begun.
pub async fn inventory_artifact_attempt(
&self,
expected_disk: Uuid,
target: (&str, Uuid),
prefix: &str,
phase: rustfs_protos::proto_gen::node_service::ArtifactInventoryPhase,
deadline: tokio::time::Instant,
output: tokio::sync::mpsc::Sender<crate::disk::ArtifactInventoryFrame>,
) -> Result<()> {
use crate::disk::{ArtifactInventoryFrame, ArtifactInventoryVersion};
use rustfs_protos::{
artifact_inventory as protocol,
proto_gen::node_service::{ArtifactInventoryRequest, artifact_inventory_frame::Payload},
};
let (bucket, incarnation) = target;
let duration = deadline
.checked_duration_since(tokio::time::Instant::now())
.ok_or(Error::Timeout)?;
let body = ArtifactInventoryRequest {
protocol_version: protocol::VERSION,
invocation_id: Uuid::new_v4().as_bytes().to_vec().into(),
disk: self.endpoint.to_string(),
expected_disk_id: expected_disk.as_bytes().to_vec().into(),
bucket: bucket.to_owned(),
bucket_incarnation_id: incarnation.as_bytes().to_vec().into(),
attempt_prefix: prefix.to_owned(),
timeout_millis: u64::try_from(duration.as_millis().min(u128::from(protocol::MAX_TIMEOUT_MILLIS)))
.map_err(Error::other)?,
phase: phase.into(),
};
protocol::validate_request(&body).map_err(Error::other)?;
tokio::time::timeout_at(deadline, async {
let canonical = protocol::canonical_request(&body).map_err(Error::other)?;
let mut request = Request::new(body.clone());
request.set_timeout(duration);
crate::cluster::rpc::set_tonic_canonical_body_digest(&mut request, &canonical).map_err(Error::other)?;
let mut stream = self
.get_client()
.await?
.max_decoding_message_size(protocol::MAX_FRAME_BYTES)
.inventory_artifact_attempt(request)
.await?
.into_inner();
let mut state = protocol::StreamState::new();
let mut terminal = None;
while let Some(frame) = stream.message().await? {
state
.accept(&body, &frame, |canonical, proof| {
crate::cluster::rpc::verify_tonic_rpc_response_proof(canonical, proof).is_ok()
})
.map_err(Error::other)?;
match frame.payload {
Some(Payload::Entries(entries)) => {
let versions = entries
.versions
.into_iter()
.map(|entry| {
Ok(ArtifactInventoryVersion {
key: entry.key,
version_id: Uuid::from_slice(&entry.version_id).map_err(Error::other)?,
delete_marker: entry.delete_marker,
})
})
.collect::<Result<Vec<_>>>()?;
output
.send(ArtifactInventoryFrame::Entries {
sequence: frame.sequence,
versions,
})
.await
.map_err(|_| Error::other("artifact inventory consumer closed"))?;
}
Some(Payload::Complete(complete)) => terminal = Some(complete.entries),
Some(Payload::Verified(_)) => {}
None => return Err(Error::other("artifact inventory payload is absent")),
}
}
// A valid Complete followed by a transport failure or extra frame is
// still incomplete. Publish the local terminal only after clean EOF.
state.finish().map_err(Error::other)?;
if let Some(entries) = terminal {
output
.send(ArtifactInventoryFrame::Complete {
disk_id: expected_disk,
incarnation,
attempt_prefix: prefix.to_owned(),
entries,
})
.await
.map_err(|_| Error::other("artifact inventory consumer closed"))?;
}
Ok(())
})
.await
.map_err(|_| Error::Timeout)?
}
pub(crate) async fn seal_artifact_puts(
&self,
expected_disk: Uuid,
bucket: &str,
incarnation: Uuid,
attempt_prefix: &str,
) -> Result<()> {
use rustfs_protos::proto_gen::node_service::SealArtifactAttemptPutsRequest;
self.execute_with_timeout_for_op(
"seal_artifact_puts",
|| async {
let body = SealArtifactAttemptPutsRequest {
disk: self.endpoint.to_string(),
bucket: bucket.to_owned(),
bucket_incarnation_id: incarnation.as_bytes().to_vec().into(),
attempt_prefix: attempt_prefix.to_owned(),
expected_disk_id: expected_disk.as_bytes().to_vec().into(),
};
let canonical = rustfs_protos::canonical_seal_artifact_attempt_puts_request_body(&body)
.map_err(|_| Error::other("artifact seal request is too large"))?;
let mut request = Request::new(body.clone());
crate::cluster::rpc::set_tonic_canonical_body_digest(&mut request, &canonical).map_err(Error::other)?;
let response = self
.get_client()
.await?
.seal_artifact_attempt_puts(request)
.await?
.into_inner();
let canonical = rustfs_protos::canonical_seal_artifact_attempt_puts_response_body(&body, &response)
.map_err(|_| Error::other("artifact seal response is too large"))?;
crate::cluster::rpc::verify_tonic_rpc_response_proof(&canonical, &response.response_proof)
.map_err(|_| Error::other("artifact seal response proof is invalid"))?;
if !response.success {
return Err(response.error.unwrap_or_default().into());
}
if response.error.is_some() || Uuid::from_slice(&response.disk_id).ok() != Some(expected_disk) {
return Err(Error::other("artifact seal response disk identity mismatch"));
}
Ok(())
},
get_max_timeout_duration(),
)
.await
}
pub(crate) async fn seal_artifact_repairs(
&self,
expected_disk: Uuid,
bucket: &str,
incarnation: Uuid,
attempt_prefix: &str,
) -> Result<()> {
use rustfs_protos::proto_gen::node_service::SealArtifactAttemptPutsRequest;
if expected_disk.is_nil() || incarnation.is_nil() {
return Err(Error::other("artifact repair seal requires non-nil disk and incarnation identities"));
}
self.execute_with_timeout_for_op(
"seal_artifact_repairs",
|| async {
let body = SealArtifactAttemptPutsRequest {
disk: self.endpoint.to_string(),
bucket: bucket.to_owned(),
bucket_incarnation_id: incarnation.as_bytes().to_vec().into(),
attempt_prefix: attempt_prefix.to_owned(),
expected_disk_id: expected_disk.as_bytes().to_vec().into(),
};
let canonical = rustfs_protos::canonical_seal_artifact_attempt_repairs_request_body(&body)
.map_err(|_| Error::other("artifact repair seal request is too large"))?;
let mut request = Request::new(body.clone());
crate::cluster::rpc::set_tonic_canonical_body_digest(&mut request, &canonical).map_err(Error::other)?;
let response = self
.get_client()
.await?
.seal_artifact_attempt_repairs(request)
.await?
.into_inner();
validate_artifact_repair_seal_response(&body, &response)
},
get_max_timeout_duration(),
)
.await
}
#[tracing::instrument(level = "trace", skip_all)]
pub(crate) async fn rename_data_borrowed(
&self,
@@ -2385,20 +2678,39 @@ impl RemoteDisk {
dst_volume: &str,
dst_path: &str,
) -> Result<RenameDataResp> {
self.rename_data_borrowed_with_fence(src_volume, src_path, fi, dst_volume, dst_path, None)
self.rename_data_borrowed_with_fence((src_volume, src_path), fi, (dst_volume, dst_path), None, None)
.await
}
#[tracing::instrument(level = "trace", skip_all)]
pub(crate) async fn rename_data_borrowed_with_fence(
&self,
src_volume: &str,
src_path: &str,
source: (&str, &str),
fi: &FileInfo,
dst_volume: &str,
dst_path: &str,
target: (&str, &str),
scanner_publication_lease_token: Option<Uuid>,
artifact_incarnation: Option<Uuid>,
) -> Result<RenameDataResp> {
let (src_volume, src_path) = source;
let (dst_volume, dst_path) = target;
if let Some(scope) = crate::bucket::artifact::artifact_repair_scope(dst_volume, dst_path)
&& let Some(identity) = scope.receiver_identity()
{
scope.check().map_err(Error::other)?;
if artifact_incarnation.is_some() || scanner_publication_lease_token.is_some() {
return Err(Error::other("artifact repair cannot use a publication lease"));
}
let expected = self
.get_disk_id()
.await?
.filter(|id| !id.is_nil())
.ok_or_else(|| Error::other("artifact repair disk identity is unavailable"))?;
let result = self
.rename_artifact_repair_data(expected, (src_volume, src_path), fi, (dst_volume, dst_path), identity.incarnation)
.await;
scope.check().map_err(Error::other)?;
return result;
}
trace!(
event = EVENT_REMOTE_DISK_RPC,
component = LOG_COMPONENT_ECSTORE,
@@ -2421,8 +2733,11 @@ impl RemoteDisk {
let file_info_bin = encode_file_info_msgpack(fi)?;
let mut client = self.get_client().await?;
let mut request = Request::new(RenameDataRequest {
bucket_incarnation_id: crate::store::bucket_heal_scope_for_object(dst_volume, dst_path)
.map(|scope| scope.incarnation.as_bytes().to_vec().into())
bucket_incarnation_id: artifact_incarnation
.or_else(|| {
crate::store::bucket_heal_scope_for_object(dst_volume, dst_path).map(|scope| scope.incarnation)
})
.map(|incarnation| incarnation.as_bytes().to_vec().into())
.unwrap_or_default(),
disk: self.endpoint.to_string(),
src_volume: src_volume.to_string(),
@@ -2435,7 +2750,11 @@ impl RemoteDisk {
.map(|token| token.as_bytes().to_vec().into())
.unwrap_or_default(),
});
let canonical_body = rustfs_protos::canonical_rename_data_request_body(request.get_ref());
let canonical_body = if artifact_incarnation.is_some() {
rustfs_protos::canonical_artifact_rename_data_request_body(request.get_ref())
} else {
rustfs_protos::canonical_rename_data_request_body(request.get_ref())
};
let incarnation_bound = !request.get_ref().bucket_incarnation_id.is_empty();
let quota_fenced = rustfs_utils::http::metadata_compat::contains_key_str(
&fi.metadata,
@@ -2458,7 +2777,9 @@ impl RemoteDisk {
let rpc_started = rustfs_io_metrics::put_stage_timer();
let response = rustfs_io_metrics::observe_put_stage_future(
async {
if quota_fenced {
if artifact_incarnation.is_some() {
client.artifact_rename_data(request).await
} else if quota_fenced {
// The method itself proves quota-token enforcement; an old peer must fail closed.
client.rename_data_with_quota_fence(request).await
} else if incarnation_bound {
@@ -3204,6 +3525,21 @@ impl DiskAPI for RemoteDisk {
#[tracing::instrument(level = "trace", skip_all)]
async fn write_metadata(&self, _org_volume: &str, volume: &str, path: &str, fi: FileInfo) -> Result<()> {
if let Some(scope) = crate::bucket::artifact::artifact_repair_scope(volume, path)
&& let Some(identity) = scope.receiver_identity()
{
scope.check().map_err(Error::other)?;
let expected = self
.get_disk_id()
.await?
.filter(|id| !id.is_nil())
.ok_or_else(|| Error::other("artifact repair disk identity is unavailable"))?;
let result = self
.write_artifact_repair_metadata(expected, (volume, path), &fi, identity.incarnation)
.await;
scope.check().map_err(Error::other)?;
return result;
}
trace!(
event = EVENT_REMOTE_DISK_RPC,
component = LOG_COMPONENT_ECSTORE,
@@ -4553,6 +4889,83 @@ mod tests {
static INIT: Once = Once::new();
#[tokio::test]
async fn artifact_repair_old_peer_unimplemented_never_downgrades_mutations() {
runtime_sources::ensure_test_rpc_secret();
let peer = TestGrpcPeer::spawn(Bytes::new(), Bytes::new())
.await
.expect("loopback test peer");
let endpoint = Endpoint {
url: url::Url::parse(&format!("{}/data/rustfs0", peer.addr)).expect("test disk endpoint"),
is_local: false,
pool_idx: 0,
set_idx: 0,
disk_idx: 0,
};
let disk = RemoteDisk::new(
&endpoint,
&DiskOption {
cleanup: false,
health_check: false,
},
Arc::new(TcpHttpInternodeDataTransport),
)
.await
.expect("remote test disk");
let expected_disk = Uuid::new_v4();
let incarnation = Uuid::new_v4();
let info = FileInfo::default();
let rename = disk
.rename_artifact_repair_data(
expected_disk,
("tmp", "staged"),
&info,
("artifacts", "outputs/tenant/run/attempt/image"),
incarnation,
)
.await
.expect_err("old peer cannot publish repair data");
let metadata = disk
.write_artifact_repair_metadata(expected_disk, ("artifacts", "outputs/tenant/run/attempt/image"), &info, incarnation)
.await
.expect_err("old peer cannot publish repair metadata");
let seal = disk
.seal_artifact_repairs(expected_disk, "artifacts", incarnation, "outputs/tenant/run/attempt")
.await
.expect_err("old peer cannot confirm repair closure");
for error in [rename, metadata, seal] {
let DiskError::Io(error) = error else {
panic!("expected gRPC status error");
};
let status = error
.get_ref()
.and_then(|error| error.downcast_ref::<crate::disk::error::RpcStatusError>())
.expect("preserved gRPC status");
assert_eq!(status.status().code(), Code::Unimplemented);
}
let paths = peer.peer.rpc_paths.lock().expect("RPC inventory").clone();
for method in [
"ArtifactRepairRenameData",
"ArtifactRepairWriteMetadata",
"SealArtifactAttemptRepairs",
] {
assert_eq!(
paths.iter().filter(|path| path.ends_with(&format!("/{method}"))).count(),
1,
"each repair method is sent once"
);
}
assert!(
paths.iter().all(|path| matches!(
path.rsplit('/').next(),
Some("ArtifactRepairRenameData" | "ArtifactRepairWriteMetadata" | "SealArtifactAttemptRepairs" | "Ping")
)),
"repair cannot downgrade to a legacy mutation: {paths:?}"
);
disk.cancel_token.cancel();
peer.stop().await;
}
#[tokio::test]
async fn quota_fence_old_peer_rejects_without_legacy_fallback() {
runtime_sources::ensure_test_rpc_secret();
@@ -4648,6 +5061,93 @@ mod tests {
peer.stop().await;
}
#[test]
fn artifact_repair_seal_response_requires_proof_full_request_and_physical_identity() {
use rustfs_protos::proto_gen::node_service::{
Error as WireError, SealArtifactAttemptPutsRequest, SealArtifactAttemptPutsResponse,
};
runtime_sources::ensure_test_rpc_secret();
let request = SealArtifactAttemptPutsRequest {
disk: "disk-a".into(),
bucket: "artifacts".into(),
bucket_incarnation_id: vec![1; 16].into(),
attempt_prefix: "outputs/tenant/run/attempt".into(),
expected_disk_id: vec![2; 16].into(),
};
let mut response = SealArtifactAttemptPutsResponse {
success: true,
disk_id: request.expected_disk_id.clone(),
..Default::default()
};
let sign = |request: &SealArtifactAttemptPutsRequest, response: &mut SealArtifactAttemptPutsResponse| {
let canonical = rustfs_protos::canonical_seal_artifact_attempt_repairs_response_body(request, response)
.expect("repair response canonical");
response.response_proof = crate::cluster::rpc::http_auth::sign_tonic_rpc_response_proof(&canonical)
.expect("repair response proof")
.into();
};
assert!(validate_artifact_repair_seal_response(&request, &response).is_err(), "proof is mandatory");
sign(&request, &mut response);
validate_artifact_repair_seal_response(&request, &response).expect("signed exact physical receiver");
for mutate in [
|r: &mut SealArtifactAttemptPutsRequest| r.disk = "other-disk".into(),
|r: &mut SealArtifactAttemptPutsRequest| r.bucket = "other-bucket".into(),
|r: &mut SealArtifactAttemptPutsRequest| r.bucket_incarnation_id = vec![3; 16].into(),
|r: &mut SealArtifactAttemptPutsRequest| r.attempt_prefix = "other-attempt".into(),
|r: &mut SealArtifactAttemptPutsRequest| r.expected_disk_id = vec![4; 16].into(),
] {
let mut changed = request.clone();
mutate(&mut changed);
assert!(
validate_artifact_repair_seal_response(&changed, &response).is_err(),
"another request cannot reuse the proof"
);
}
let legacy = rustfs_protos::canonical_seal_artifact_attempt_puts_response_body(&request, &response).expect("PUT domain");
let mut legacy_response = response.clone();
legacy_response.response_proof = crate::cluster::rpc::http_auth::sign_tonic_rpc_response_proof(&legacy)
.expect("PUT proof")
.into();
assert!(
validate_artifact_repair_seal_response(&request, &legacy_response).is_err(),
"PUT proof cannot acknowledge repair seal"
);
for disk in [vec![], vec![0; 16], vec![3; 16], vec![1; 15]] {
let mut changed = response.clone();
changed.disk_id = disk.into();
sign(&request, &mut changed);
assert!(
validate_artifact_repair_seal_response(&request, &changed).is_err(),
"signed wrong or absent physical identity is invalid"
);
}
for mutate in [
|r: &mut SealArtifactAttemptPutsResponse| r.success = false,
|r: &mut SealArtifactAttemptPutsResponse| r.error = Some(WireError::default()),
] {
let mut changed = response.clone();
mutate(&mut changed);
assert!(
validate_artifact_repair_seal_response(&request, &changed).is_err(),
"response fields are bound"
);
sign(&request, &mut changed);
assert!(
validate_artifact_repair_seal_response(&request, &changed).is_err(),
"signed failure or inconsistent success is invalid"
);
}
let mut nil_request = request;
nil_request.expected_disk_id = vec![0; 16].into();
let mut nil_response = response;
nil_response.disk_id = nil_request.expected_disk_id.clone();
sign(&nil_request, &mut nil_response);
assert!(
validate_artifact_repair_seal_response(&nil_request, &nil_response).is_err(),
"nil physical identity is never a receipt"
);
}
#[test]
fn recoverable_batch_delete_rejects_invalid_error_codes_and_length() {
let errors = decode_delete_recovery_batch_errors(vec![None, Some(DiskError::FileNotFound.to_u32())], 2)
@@ -5038,6 +5538,7 @@ mod tests {
format_data: Bytes,
read_all_data: Bytes,
next_format_read_gate: Arc<StdMutex<Option<Arc<TestFormatReadGate>>>>,
rpc_paths: Arc<StdMutex<Vec<String>>>,
unsupported_methods: Arc<StdMutex<Vec<String>>>,
}
@@ -5057,6 +5558,7 @@ mod tests {
format_data,
read_all_data,
next_format_read_gate: Arc::default(),
rpc_paths: Arc::default(),
unsupported_methods: Arc::default(),
}
}
@@ -5112,6 +5614,11 @@ mod tests {
}
fn call(&mut self, request: http::Request<B>) -> Self::Future {
self.peer
.rpc_paths
.lock()
.expect("RPC path inventory")
.push(request.uri().path().to_owned());
match request.uri().path() {
"/node_service.NodeService/DiskInfo" => {
#[derive(Clone)]
+452 -8
View File
@@ -438,6 +438,64 @@ where
Ok(data)
}
pub(crate) async fn read_config_observation<S>(api: Arc<S>, file: &str, max_bytes: usize) -> Result<(Vec<u8>, ObjectInfo)>
where
S: EcstoreObjectIO,
{
let opts = ObjectOptions {
suppress_read_repair: true,
no_lock: false,
skip_verify_bitrot: false,
..Default::default()
};
crate::object_api::without_get_object_body_cache_hook(read_config_limited_preserve_empty_with_metadata_opts(
api, file, &opts, max_bytes,
))
.await
}
pub(crate) async fn read_config_observation_no_lock<S>(
api: Arc<S>,
file: &str,
max_bytes: usize,
guard: &crate::object_api::WriteCommitGuard,
) -> Result<(Vec<u8>, ObjectInfo)>
where
S: EcstoreObjectIO,
{
if !guard.matches(RUSTFS_META_BUCKET, file) {
return Err(Error::InvalidArgument(
RUSTFS_META_BUCKET.to_owned(),
file.to_owned(),
"locked observation requires a matching write owner".to_owned(),
));
}
let lock_lost_error = || Error::NamespaceLockQuorumUnavailable {
mode: "locked_config_observation",
bucket: RUSTFS_META_BUCKET.to_owned(),
object: file.to_owned(),
required: 1,
achieved: 0,
};
if guard.is_lock_lost() {
return Err(lock_lost_error());
}
let opts = ObjectOptions {
suppress_read_repair: true,
no_lock: true,
skip_verify_bitrot: false,
..Default::default()
};
let result = crate::object_api::without_get_object_body_cache_hook(read_config_limited_preserve_empty_with_metadata_opts(
api, file, &opts, max_bytes,
))
.await;
if guard.is_lock_lost() {
return Err(lock_lost_error());
}
result
}
pub(crate) async fn read_config_limited_preserve_empty<S>(api: Arc<S>, file: &str, max_bytes: usize) -> Result<Vec<u8>>
where
S: EcstoreObjectIO,
@@ -484,10 +542,22 @@ pub(crate) async fn read_config_preserve_empty<S>(api: Arc<S>, file: &str) -> Re
where
S: EcstoreObjectIO,
{
let (data, _obj) = read_config_with_metadata_inner(api, file, &ObjectOptions::default(), true, None).await?;
let (data, _obj) = read_config_preserve_empty_with_metadata_opts(api, file, &ObjectOptions::default()).await?;
Ok(data)
}
/// Preserve an empty config payload, its metadata, and caller-supplied read options.
pub(crate) async fn read_config_preserve_empty_with_metadata_opts<S>(
api: Arc<S>,
file: &str,
opts: &ObjectOptions,
) -> Result<(Vec<u8>, ObjectInfo)>
where
S: EcstoreObjectIO,
{
read_config_with_metadata_inner(api, file, opts, true, None).await
}
pub async fn read_config_no_lock<S>(api: Arc<S>, file: &str) -> Result<Vec<u8>>
where
S: ObjectIO<
@@ -516,15 +586,13 @@ pub(crate) async fn read_config_no_lock_preserve_empty_with_metadata<S>(api: Arc
where
S: EcstoreObjectIO,
{
read_config_with_metadata_inner(
read_config_preserve_empty_with_metadata_opts(
api,
file,
&ObjectOptions {
no_lock: true,
..Default::default()
},
true,
None,
)
.await
}
@@ -2919,9 +2987,9 @@ mod tests {
config_task_join_error, configs_semantically_equal, decode_server_config_blob, encode_server_config_blob,
heal_config_descriptor, is_standard_object_server_config, lookup_configs, map_system_metadata_write_error,
new_and_save_server_config, read_config, read_config_no_lock_preserve_empty_with_metadata, read_config_preserve_empty,
read_config_with_metadata, read_config_without_migrate, read_server_config_snapshot, save_config_with_opts_inner,
save_server_config, save_server_config_snapshot, save_server_config_snapshot_with_generation,
server_config_transaction_lock_path, storage_class_kvs_mut,
read_config_preserve_empty_with_metadata_opts, read_config_with_metadata, read_config_without_migrate,
read_server_config_snapshot, save_config_with_opts_inner, save_server_config, save_server_config_snapshot,
save_server_config_snapshot_with_generation, server_config_transaction_lock_path, storage_class_kvs_mut,
};
use crate::config::{audit, heal, notify, oidc, scanner};
use crate::disk::{RUSTFS_META_BUCKET, endpoint::Endpoint};
@@ -5396,6 +5464,7 @@ mod tests {
heal_replacement: Option<Vec<u8>>,
heal_calls: AtomicUsize,
write_calls: AtomicUsize,
last_get_no_lock: AtomicBool,
last_put_no_lock: AtomicBool,
last_put_preconditions: Mutex<Option<HTTPPreconditions>>,
revision: AtomicUsize,
@@ -5411,6 +5480,7 @@ mod tests {
heal_replacement,
heal_calls: AtomicUsize::new(0),
write_calls: AtomicUsize::new(0),
last_get_no_lock: AtomicBool::new(false),
last_put_no_lock: AtomicBool::new(false),
last_put_preconditions: Mutex::new(None),
revision: AtomicUsize::new(1),
@@ -5466,8 +5536,9 @@ mod tests {
_object: &str,
_range: Option<HTTPRangeSpec>,
_h: HeaderMap,
_opts: &ObjectOptions,
opts: &ObjectOptions,
) -> Result<GetObjectReader> {
self.last_get_no_lock.store(opts.no_lock, Ordering::SeqCst);
let data = match &*self.state.lock().expect("state lock poisoned") {
RecoveryReadState::Missing => return Err(Error::ConfigNotFound),
RecoveryReadState::Blob(data) => data.clone(),
@@ -5779,6 +5850,325 @@ mod tests {
assert_eq!(store.write_calls.load(Ordering::SeqCst), 0);
}
type ObservationEofGate = (Option<tokio::sync::oneshot::Sender<()>>, tokio::sync::oneshot::Receiver<()>);
struct ObservationStream {
inner: Cursor<Vec<u8>>,
consumed: Arc<AtomicUsize>,
fail: bool,
eof_gate: Option<ObservationEofGate>,
}
impl AsyncRead for ObservationStream {
fn poll_read(mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll<std::io::Result<()>> {
if self.fail {
return Poll::Ready(Err(std::io::Error::other("observation stream failed")));
}
if self.inner.position() == u64::try_from(self.inner.get_ref().len()).expect("fixture length")
&& let Some((ready, release)) = self.eof_gate.as_mut()
{
if let Some(ready) = ready.take() {
ready.send(()).expect("EOF observer remains active");
}
match std::future::Future::poll(Pin::new(release), cx) {
Poll::Pending => return Poll::Pending,
Poll::Ready(result) => result.expect("EOF gate released"),
}
self.eof_gate = None;
}
let before = buf.filled().len();
let result = Pin::new(&mut self.inner).poll_read(cx, buf);
self.consumed.fetch_add(buf.filled().len() - before, Ordering::SeqCst);
result
}
}
struct ObservationStore {
body: Vec<u8>,
declared_size: i64,
error: Option<Error>,
stream_error: bool,
gets: AtomicUsize,
eof_gate: std::sync::Mutex<Option<ObservationEofGate>>,
consumed: Arc<AtomicUsize>,
options: std::sync::Mutex<Option<(bool, bool, bool)>>,
writes: AtomicUsize,
}
impl Debug for ObservationStore {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ObservationStore").finish()
}
}
impl ObservationStore {
fn new(body: Vec<u8>, declared_size: i64) -> Self {
Self {
body,
declared_size,
error: None,
stream_error: false,
gets: AtomicUsize::new(0),
eof_gate: std::sync::Mutex::new(None),
consumed: Arc::new(AtomicUsize::new(0)),
options: std::sync::Mutex::new(None),
writes: AtomicUsize::new(0),
}
}
}
#[async_trait::async_trait]
impl crate::storage_api_contracts::object::ObjectIO for ObservationStore {
type Error = Error;
type RangeSpec = HTTPRangeSpec;
type HeaderMap = HeaderMap;
type ObjectOptions = ObjectOptions;
type ObjectInfo = ObjectInfo;
type GetObjectReader = GetObjectReader;
type PutObjectReader = PutObjReader;
async fn get_object_reader(
&self,
bucket: &str,
object: &str,
range: Option<HTTPRangeSpec>,
_headers: HeaderMap,
opts: &ObjectOptions,
) -> Result<GetObjectReader> {
assert_eq!(bucket, RUSTFS_META_BUCKET);
assert_eq!(object, "config/observation.bin");
self.gets.fetch_add(1, Ordering::SeqCst);
assert!(range.is_none());
*self.options.lock().expect("observation options") =
Some((opts.suppress_read_repair, opts.no_lock, opts.skip_verify_bitrot));
if let Some(error) = &self.error {
return Err(error.clone());
}
Ok(GetObjectReader {
stream: Box::new(ObservationStream {
inner: Cursor::new(self.body.clone()),
consumed: self.consumed.clone(),
fail: self.stream_error,
eof_gate: self.eof_gate.lock().expect("EOF gate").take(),
}),
object_info: ObjectInfo {
size: self.declared_size,
etag: Some("observation-etag".to_string()),
..Default::default()
},
buffered_body: None,
body_source: Default::default(),
})
}
async fn put_object(
&self,
_bucket: &str,
_object: &str,
_data: &mut PutObjReader,
_opts: &ObjectOptions,
) -> Result<ObjectInfo> {
self.writes.fetch_add(1, Ordering::SeqCst);
Err(Error::other("observation must not write"))
}
}
#[tokio::test]
async fn locked_observation_preserves_options_and_typed_errors() {
let object = "config/observation.bin";
let manager = Arc::new(rustfs_lock::GlobalLockManager::new());
let wrapper = rustfs_lock::NamespaceLockWrapper::new(
rustfs_lock::NamespaceLock::with_local_manager("locked-observation-options".to_owned(), manager.clone()),
rustfs_lock::ObjectKey::new(RUSTFS_META_BUCKET, object),
"locked-observation-options".to_owned(),
);
let guard = crate::object_api::WriteCommitGuard::acquire(&wrapper, std::time::Duration::from_secs(5))
.await
.expect("matching real owner");
let store = Arc::new(ObservationStore::new(b"raw".to_vec(), 3));
let result = super::read_config_observation_no_lock(store.clone(), object, 3, &guard).await;
assert!(result.is_ok(), "I06L_READ_FLAGS: {result:?}");
assert!(*store.options.lock().expect("options") == Some((true, true, false)), "I06L_READ_FLAGS");
assert_eq!(result.expect("raw observation").0, b"raw");
assert_eq!(store.writes.load(Ordering::SeqCst), 0);
for (bucket, key) in [(RUSTFS_META_BUCKET, "config/other.bin"), ("wrong-bucket", object)] {
let wrapper = rustfs_lock::NamespaceLockWrapper::new(
rustfs_lock::NamespaceLock::with_local_manager("locked-observation-target".to_owned(), manager.clone()),
rustfs_lock::ObjectKey::new(bucket, key),
"locked-observation-target".to_owned(),
);
let wrong = crate::object_api::WriteCommitGuard::acquire(&wrapper, std::time::Duration::from_secs(5))
.await
.expect("wrong-target real owner");
let store = Arc::new(ObservationStore::new(Vec::new(), 0));
let result = super::read_config_observation_no_lock(store.clone(), object, 8, &wrong).await;
assert!(matches!(result, Err(Error::InvalidArgument(..))), "I06L_TARGET_REJECTED");
assert!(store.gets.load(Ordering::SeqCst) == 0, "I06L_TARGET_REJECTED");
}
for error in [
Error::FileNotFound,
Error::ObjectNotFound(RUSTFS_META_BUCKET.to_owned(), object.to_owned()),
Error::ErasureReadQuorum,
] {
let expected = if error == Error::FileNotFound || matches!(error, Error::ObjectNotFound(..)) {
Error::ConfigNotFound
} else {
error.clone()
};
let mut fixture = ObservationStore::new(Vec::new(), 0);
fixture.error = Some(error);
let store = Arc::new(fixture);
assert_eq!(
super::read_config_observation_no_lock(store.clone(), object, 8, &guard)
.await
.expect_err("typed error"),
expected
);
assert_eq!(store.writes.load(Ordering::SeqCst), 0);
}
let mut fixture = ObservationStore::new(Vec::new(), 0);
fixture.stream_error = true;
let store = Arc::new(fixture);
let err = super::read_config_observation_no_lock(store.clone(), object, 8, &guard)
.await
.expect_err("stream failure");
assert!(err.to_string().contains("observation stream failed"), "I06L_TYPED_STREAM_ERROR");
assert_eq!(store.writes.load(Ordering::SeqCst), 0);
let store = Arc::new(ObservationStore::new(vec![7; 64], 0));
let result = super::read_config_observation_no_lock(store.clone(), object, 8, &guard).await;
assert!(matches!(result, Err(Error::CorruptedFormat)), "I06L_STREAM_BOUND");
assert!(store.consumed.load(Ordering::SeqCst) == 9, "I06L_STREAM_BOUND");
assert_eq!(store.writes.load(Ordering::SeqCst), 0);
}
#[tokio::test]
async fn locked_observation_rejects_actual_owner_loss_before_and_after_read() {
let object = "config/observation.bin";
for after_read in [false, true] {
let manager = Arc::new(rustfs_lock::GlobalLockManager::new());
let client = Arc::new(RefreshControlledClient::new(manager));
let wrapper = rustfs_lock::NamespaceLockWrapper::new(
rustfs_lock::NamespaceLock::new("locked-observation-loss".to_owned(), client.clone()),
rustfs_lock::ObjectKey::new(RUSTFS_META_BUCKET, object),
"locked-observation-loss".to_owned(),
);
let guard = crate::object_api::WriteCommitGuard::acquire(&wrapper, std::time::Duration::from_secs(5))
.await
.expect("real distributed owner");
let signal = guard.lock_lost_signal().expect("acquired distributed owner signal");
let store = Arc::new(ObservationStore::new(b"raw".to_vec(), 3));
if after_read {
let (ready_tx, ready_rx) = tokio::sync::oneshot::channel();
let (release_tx, release_rx) = tokio::sync::oneshot::channel();
*store.eof_gate.lock().expect("EOF gate") = Some((Some(ready_tx), release_rx));
let mut read = Box::pin(super::read_config_observation_no_lock(store.clone(), object, 3, &guard));
tokio::time::timeout(std::time::Duration::from_secs(60), async {
tokio::select! {
ready = ready_rx => ready.expect("read reached EOF"),
result = &mut read => assert_eq!(false, true, "I06L_LOSS_AFTER_READ: returned before EOF gate: {result:?}"),
}
}).await.expect("EOF watchdog");
client.reject_refreshes();
tokio::time::timeout(std::time::Duration::from_secs(60), signal.notified())
.await
.expect("actual owner loss watchdog");
assert!(guard.is_lock_lost(), "I06L_LOSS_AFTER_READ");
release_tx.send(()).expect("release EOF");
let result = tokio::time::timeout(std::time::Duration::from_secs(60), read)
.await
.expect("read watchdog");
assert!(
matches!(
result,
Err(Error::NamespaceLockQuorumUnavailable {
mode: "locked_config_observation",
..
})
),
"I06L_LOSS_AFTER_READ"
);
assert_eq!(store.gets.load(Ordering::SeqCst), 1);
} else {
client.reject_refreshes();
tokio::time::timeout(std::time::Duration::from_secs(60), signal.notified())
.await
.expect("actual owner loss watchdog");
assert!(guard.is_lock_lost(), "I06L_LOSS_BEFORE_GET");
let result = super::read_config_observation_no_lock(store.clone(), object, 3, &guard).await;
assert!(
matches!(
result,
Err(Error::NamespaceLockQuorumUnavailable {
mode: "locked_config_observation",
..
})
),
"I06L_LOSS_BEFORE_GET"
);
assert!(store.gets.load(Ordering::SeqCst) == 0, "I06L_LOSS_BEFORE_GET");
}
assert_eq!(store.writes.load(Ordering::SeqCst), 0);
}
}
#[tokio::test]
async fn observation_preserves_options_and_errors() {
for body in [Vec::new(), b"raw config".to_vec()] {
let store = Arc::new(ObservationStore::new(body.clone(), i64::try_from(body.len()).expect("fixture size")));
let result = super::read_config_observation(store.clone(), "config/observation.bin", 32).await;
assert!(result.is_ok(), "observation must preserve empty and nonempty objects: {result:?}");
let (observed, info) = result.expect("successful observation");
assert_eq!(observed, body);
assert_eq!(info.etag.as_deref(), Some("observation-etag"));
assert_eq!(
*store.options.lock().expect("options"),
Some((true, false, false)),
"I05_REPAIR_SUPPRESSED"
);
assert_eq!(store.writes.load(Ordering::SeqCst), 0);
}
for (source, expected) in [
(Error::FileNotFound, Error::ConfigNotFound),
(
Error::ObjectNotFound(RUSTFS_META_BUCKET.to_string(), "config/observation.bin".to_string()),
Error::ConfigNotFound,
),
(Error::ErasureReadQuorum, Error::ErasureReadQuorum),
] {
let mut fixture = ObservationStore::new(Vec::new(), 0);
fixture.error = Some(source);
let store = Arc::new(fixture);
let result = super::read_config_observation(store.clone(), "config/observation.bin", 32).await;
assert_eq!(result.expect_err("read error must propagate"), expected);
assert_eq!(store.writes.load(Ordering::SeqCst), 0);
}
let mut fixture = ObservationStore::new(Vec::new(), 0);
fixture.stream_error = true;
let store = Arc::new(fixture);
let result = super::read_config_observation(store.clone(), "config/observation.bin", 32).await;
let error = result.expect_err("stream error must propagate");
assert!(error.to_string().contains("observation stream failed"));
assert_eq!(store.writes.load(Ordering::SeqCst), 0);
}
#[tokio::test]
async fn observation_enforces_byte_limit() {
let store = Arc::new(ObservationStore::new(vec![7; 64], 0));
let result = super::read_config_observation(store.clone(), "config/observation.bin", 8).await;
assert!(result.is_err(), "I05_BOUND_ENFORCED");
assert_eq!(
result.expect_err("bounded observation error"),
Error::CorruptedFormat,
"I05_BOUND_ENFORCED"
);
assert_eq!(store.consumed.load(Ordering::SeqCst), 9, "I05_BOUND_ENFORCED: drain exactly limit + 1");
let store = Arc::new(ObservationStore::new(vec![7; 8], 8));
let result = super::read_config_observation(store.clone(), "config/observation.bin", 8).await;
assert!(result.is_ok(), "exact byte limit must succeed: {result:?}");
assert_eq!(result.expect("exact limit").0, vec![7; 8]);
assert_eq!(store.consumed.load(Ordering::SeqCst), 8);
}
#[tokio::test]
async fn read_config_preserve_empty_distinguishes_empty_object() {
let store = Arc::new(RecoveryMockStore::new(RecoveryReadState::Blob(Vec::new()), None));
@@ -5799,6 +6189,60 @@ mod tests {
assert!(data.is_empty());
}
#[tokio::test]
async fn read_config_preserve_empty_metadata_keeps_empty_and_options() {
let store = Arc::new(RecoveryMockStore::new(RecoveryReadState::Blob(Vec::new()), None));
for no_lock in [false, true] {
let opts = ObjectOptions {
no_lock,
..Default::default()
};
let (data, info) = read_config_preserve_empty_with_metadata_opts(store.clone(), "config/empty.json", &opts)
.await
.expect("an existing empty config must retain its payload and metadata");
assert!(data.is_empty());
assert_eq!(info.size, 0);
assert_eq!(info.actual_size, 0);
assert_eq!(info.etag.as_deref(), Some("config-1"));
assert_eq!(info.data_dir, Some(uuid::Uuid::from_u128(1)));
assert_eq!(store.last_get_no_lock.load(Ordering::SeqCst), no_lock);
}
let err = read_config(store, "config/empty.json")
.await
.expect_err("the existing reader must still treat empty configs as missing");
assert!(matches!(err, Error::ConfigNotFound));
}
#[tokio::test]
async fn read_config_preserve_empty_metadata_keeps_payload_and_metadata() {
let payload = b"original config bytes".to_vec();
let store = Arc::new(RecoveryMockStore::new(RecoveryReadState::Blob(payload.clone()), None));
let (data, info) =
read_config_preserve_empty_with_metadata_opts(store, "config/nonempty.json", &ObjectOptions::default())
.await
.expect("a nonempty config must retain its payload and metadata");
assert_eq!(data, payload);
let size = i64::try_from(payload.len()).expect("test payload size fits i64");
assert_eq!(info.size, size);
assert_eq!(info.actual_size, size);
assert_eq!(info.etag.as_deref(), Some("config-1"));
assert_eq!(info.data_dir, Some(uuid::Uuid::from_u128(1)));
}
#[tokio::test]
async fn read_config_preserve_empty_metadata_preserves_read_errors() {
let missing = Arc::new(RecoveryMockStore::new(RecoveryReadState::Missing, None));
let err = read_config_preserve_empty_with_metadata_opts(missing, "config/missing.json", &ObjectOptions::default())
.await
.expect_err("physical absence must remain ConfigNotFound");
assert!(matches!(err, Error::ConfigNotFound));
let quorum = Arc::new(RecoveryMockStore::new(RecoveryReadState::QuorumError, None));
let err = read_config_preserve_empty_with_metadata_opts(quorum, "config/quorum.json", &ObjectOptions::default())
.await
.expect_err("a quorum failure must retain its typed read error");
assert!(matches!(err, Error::ErasureReadQuorum));
}
#[async_trait::async_trait]
impl StorageAdminApi for RecoveryMockStore {
type BackendInfo = rustfs_madmin::BackendInfo;
+52 -15
View File
@@ -12431,6 +12431,22 @@ fn resolve_data_movement_lifecycle_expiry_result(action: IlmAction, apply_action
)))
}
const DATA_MOVEMENT_DELETE_ALL_DEFERRED_REASON: &str = "delete_all_needs_bucket_write_lock";
fn log_data_movement_lifecycle_expiry_deferred(bucket: &str, object: &str, action: IlmAction, reason: &str) {
warn!(
event = EVENT_DECOMMISSION_ENTRY,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_POOLS,
state = "lifecycle_expiry_deferred",
bucket = %bucket,
object = %object,
action = ?action,
reason,
"Data movement deferred a version's lifecycle expiry; moving the version"
);
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn should_skip_lifecycle_for_data_movement(
store: Arc<ECStore>,
@@ -12490,6 +12506,21 @@ pub(crate) async fn should_skip_lifecycle_for_data_movement(
}
let applied = !apply_actions
|| match lock_lost_signal {
// A delete-all is a prefix delete that takes the bucket
// lifecycle write lock, which the lifecycle read locks held
// by the inline apply and by the rebalance entry's bucket
// incarnation fence would block until timeout. The version
// is still live, so it moves like any other version and
// lifecycle expires it on the target.
Some(_) if action.delete_all() => {
log_data_movement_lifecycle_expiry_deferred(
bucket,
&version.name,
action,
DATA_MOVEMENT_DELETE_ALL_DEFERRED_REASON,
);
return Ok(false);
}
Some(signal) => {
apply_expiry_rule_for_data_movement(store, &event, event_source, &object_info, Some(signal)).await
}
@@ -12515,16 +12546,15 @@ pub(crate) async fn should_skip_lifecycle_for_data_movement(
// The version is still live, so it moves like any
// other version and lifecycle expires it on the
// target.
warn!(
event = EVENT_DECOMMISSION_ENTRY,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_POOLS,
state = "lifecycle_expiry_deferred",
bucket = %bucket,
object = %version.name,
action = ?action,
reason = if delete_all { "delete_all_needs_bucket_write_lock" } else { "not_applied" },
"Data movement deferred a transitioned version's lifecycle expiry; moving the version"
log_data_movement_lifecycle_expiry_deferred(
bucket,
&version.name,
action,
if delete_all {
DATA_MOVEMENT_DELETE_ALL_DEFERRED_REASON
} else {
"not_applied"
},
);
return Ok(false);
}
@@ -17046,6 +17076,7 @@ impl ECStore {
&[],
data_movement::SourceCleanupBucketFence {
expected_incarnation_id: None,
lifecycle_owner: None,
lifecycle_guard: None,
namespace_lock_lost_signal: pool_meta_guard.lock_lost_signal(),
object_mutation_fence: Some(&object_fence),
@@ -17314,7 +17345,7 @@ impl ECStore {
let operation_gate = self.ctx.data_movement_operation_gate();
let bucket_incarnation_fence = match expected_bucket_incarnation_id {
Some(expected) => Some(self.acquire_bucket_incarnation_fence(&bucket, expected).await?),
Some(expected) => Some(Arc::new(self.acquire_bucket_incarnation_fence(&bucket, expected).await?)),
None => None,
};
@@ -18250,9 +18281,8 @@ impl ECStore {
&cleanup_transferred_tier_versions,
data_movement::SourceCleanupBucketFence {
expected_incarnation_id: expected_bucket_incarnation_id,
lifecycle_guard: bucket_incarnation_fence
.as_ref()
.and_then(|guard| guard.namespace_lock_guard()),
lifecycle_owner: bucket_incarnation_fence.as_ref(),
lifecycle_guard: None,
namespace_lock_lost_signal: None,
object_mutation_fence: Some(&source_cleanup_mutation_fence),
},
@@ -28643,7 +28673,13 @@ impl SetDisks {
min_disks: listing_quorum,
skip_walkdir_total_timeout: true,
walkdir_stall_timeout: Some(DECOMMISSION_BACKGROUND_WALKDIR_STALL_TIMEOUT),
agreed: Some(Box::new(move |entry: MetaCacheEntry| Box::pin(cb1(entry)))),
agreed: Some(Box::new(move |entry: MetaCacheEntry| {
let future = cb1(entry);
Box::pin(async move {
future.await;
Ok(())
})
})),
partial: Some(Box::new(move |entries: MetaCacheEntries, errs: &[Option<DiskError>]| {
let resolver = resolver.clone();
let cb_func = cb_func.clone();
@@ -29372,6 +29408,7 @@ mod pools_tests {
pool_meta_save_gate: tokio::sync::Mutex::new(super::PoolMetaWriteState::for_test_bootstrap()),
ctx,
bucket_fence_registry: Arc::default(),
ordinary_classification: Default::default(),
})
}
+1
View File
@@ -499,6 +499,7 @@ mod decommission_lock_order_tests {
),
ctx,
bucket_fence_registry: Arc::default(),
ordinary_classification: Default::default(),
});
crate::bucket::metadata_sys::init_bucket_metadata_sys(Arc::clone(&store), Vec::new()).await;
(store, refresh_calls)
+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()
};
+9 -4
View File
@@ -31,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};
@@ -1278,6 +1278,7 @@ 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>,
@@ -1486,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"
@@ -1527,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);
}
+74 -25
View File
@@ -216,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()
@@ -237,31 +237,34 @@ struct CachedDataUsageSnapshot {
}
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;
}
@@ -280,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
@@ -1486,12 +1490,21 @@ async fn normalize_loaded_data_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;
}
}
@@ -1503,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;
}
}
@@ -1512,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);
@@ -1532,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;
}
}
@@ -1543,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;
}
}
@@ -1564,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;
}
@@ -2277,8 +2298,10 @@ async fn get_persisted_bucket_usage(bucket: &str) -> Option<u64> {
}
async fn persisted_bucket_usage(store: Arc<ECStore>, bucket: &str) -> Option<u64> {
let info = load_data_usage_from_backend_cached(store).await.ok()?;
info.buckets_usage.get(bucket).map(|usage| usage.size)
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> {
@@ -2375,6 +2398,7 @@ where
tokio::time::Instant::now(),
refresh_generation,
data_usage_snapshot_generation(),
|_| (),
)
.is_some()
}
@@ -2891,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;
@@ -3334,6 +3360,7 @@ mod tests {
pool_meta_save_gate: TokioMutex::default(),
ctx,
bucket_fence_registry: Arc::default(),
ordinary_classification: Default::default(),
})
}
@@ -4572,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]
@@ -4601,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");
}
@@ -4633,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");
@@ -4666,6 +4714,7 @@ mod tests {
loaded_at,
refresh_generation,
data_usage_snapshot_generation(),
DataUsageInfo::clone,
);
assert!(stale_result.is_none());
@@ -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");
}
}
}
}
@@ -393,6 +393,10 @@ pub async fn get_server_info(get_pools: bool) -> InfoMessage {
}
}
fn is_online_drive_state(state: &str) -> bool {
state == DriveState::Ok.to_string() || state == DriveState::Unformatted.to_string()
}
/// Classify every drive into online / offline / unknown buckets.
///
/// `unknown` holds drives synthesized for a member whose properties RPC could
@@ -412,6 +416,7 @@ fn get_online_offline_disks_stats(disks_info: &[Disk]) -> (BackendDisks, Backend
unknown_disks.entry(ep.clone()).or_insert(0);
}
let mut online_root_disk_count = 0;
for disk in disks_info {
let ep = &disk.endpoint;
let state = &disk.state;
@@ -419,30 +424,28 @@ fn get_online_offline_disks_stats(disks_info: &[Disk]) -> (BackendDisks, Backend
*unknown_disks.get_mut(ep).expect("endpoint should be in disk map") += 1;
continue;
}
if *state != DriveState::Ok.to_string() && *state != DriveState::Unformatted.to_string() {
if !is_online_drive_state(state) {
*offline_disks.get_mut(ep).expect("endpoint should be in disk map") += 1;
continue;
}
*online_disks.get_mut(ep).expect("endpoint should be in disk map") += 1;
}
let mut root_disk_count = 0;
for di in disks_info {
if di.root_disk {
root_disk_count += 1;
if disk.root_disk {
online_root_disk_count += 1;
}
}
// When every non-offline, non-unknown drive is a root mount, leave the
// online tally as-is instead of demoting all of them (matches the prior
// behavior; the unknown bucket is simply carried through untouched).
if disks_info.len() == (root_disk_count + offline_disks.values().sum::<usize>() + unknown_disks.values().sum::<usize>()) {
if disks_info.len()
== (online_root_disk_count + offline_disks.values().sum::<usize>() + unknown_disks.values().sum::<usize>())
{
return (BackendDisks(online_disks), BackendDisks(offline_disks), BackendDisks(unknown_disks));
}
for disk in disks_info {
let ep = &disk.endpoint;
if disk.root_disk {
if disk.root_disk && is_online_drive_state(&disk.state) {
*offline_disks.get_mut(ep).expect("endpoint should be in disk map") += 1;
*online_disks.get_mut(ep).expect("endpoint should be in disk map") -= 1;
}
@@ -835,6 +838,35 @@ mod tests {
);
}
#[test]
fn disk_stats_only_demote_online_root_disks() {
let mut offline_root = disk_with_state("http://n3:9000/data", ITEM_OFFLINE);
offline_root.root_disk = true;
let mut unknown_root = disk_with_state("http://n4:9000/data", ITEM_UNKNOWN);
unknown_root.root_disk = true;
let mut online_root = disk_with_state("http://n5:9000/data", "ok");
online_root.root_disk = true;
let disks = vec![
disk_with_state("http://n1:9000/data", "ok"),
disk_with_state("http://n2:9000/data", "ok"),
offline_root,
unknown_root,
online_root,
];
let (online, offline, unknown) = get_online_offline_disks_stats(&disks);
assert_eq!(online.sum(), 2, "only the two non-root online drives remain online");
assert_eq!(offline.sum(), 2, "the offline root drive and demoted online root drive are offline");
assert_eq!(unknown.sum(), 1, "the unknown root drive stays in unknown without underflowing online");
assert_eq!(
online.sum() + offline.sum() + unknown.sum(),
disks.len(),
"online + offline + unknown must equal the total drive count"
);
}
#[test]
fn topology_reconcile_synthesizes_missing_member_as_unknown() {
let endpoints = topology_with_hosts(&["rustfs-1", "rustfs-2", "rustfs-3", "rustfs-4"]);
+62 -4
View File
@@ -16,6 +16,7 @@
//! PUT admission precedes staging; receiver and GC disk work share a separate
//! budget so coordinators waiting for peers cannot exhaust receiver capacity.
use crate::disk::io_admission::{current_resource_unit, with_optional_resource_unit};
use crate::disk::{LOG_COMPONENT_ECSTORE, LOG_SUBSYSTEM_DISK};
use std::collections::BTreeSet;
use std::future::Future;
@@ -422,7 +423,7 @@ impl CleanupRuntime {
// the result receiver's lifetime to discard work cancelled during
// runtime scheduling, before its first poll can start filesystem I/O.
let (result_tx, result_rx) = tokio::sync::oneshot::channel();
let task = runtime.spawn(async move {
let task = runtime.spawn(with_optional_resource_unit(current_resource_unit(), async move {
if result_tx.is_closed() {
return;
}
@@ -430,7 +431,7 @@ impl CleanupRuntime {
// through I/O even if the waiter cancels and closes result_rx.
let result = execute_disk(pending, Some(active), execution, started, future).await;
let _ = result_tx.send(result);
});
}));
match result_rx.await {
Ok(result) => result,
Err(err) => {
@@ -469,11 +470,11 @@ impl CleanupRuntime {
gates: Some((self.disk_admission.clone(), self.disk_execution.clone())),
};
handle
.spawn(async move {
.spawn(with_optional_resource_unit(current_resource_unit(), async move {
let active = execution.acquire_owned().await.map_err(super::error::DiskError::other)?;
let _active = Admission::new(active, "rustfs_cleanup_gc_active");
scan(budget).await
})
}))
.await
.map_err(super::error::DiskError::other)?
}
@@ -582,6 +583,7 @@ where
F::Output: Send + 'static,
{
let started = rustfs_io_metrics::put_stage_metrics_enabled().then(Instant::now);
let future = with_optional_resource_unit(current_resource_unit(), future);
match reservation {
Some(reservation) => reservation.handle.clone().spawn(async move {
// This semaphore is never closed. Both queued work and overflow
@@ -620,6 +622,62 @@ mod tests {
use std::time::Duration;
use tokio::sync::oneshot;
#[tokio::test]
async fn cleanup_dispatch_retains_resource_units_without_inheriting_disk_policy() {
use crate::disk::io_admission::{current_disk_io_context, with_background_disk_io, with_background_resource_unit};
use rustfs_concurrency::adaptive::AdaptiveAdmission;
use std::num::NonZeroU16;
use tokio_util::sync::CancellationToken;
for mode in 0..4 {
let runtime = Arc::new(CleanupRuntime::new(config(1, 1, 1)).expect("cleanup runtime"));
let worker_runtime = runtime.clone();
let gate = AdaptiveAdmission::new(NonZeroU16::new(1).expect("one unit"), 1);
let (started_tx, started_rx) = tokio::sync::oneshot::channel();
let (release_tx, release_rx) = tokio::sync::oneshot::channel();
let work = async move {
started_tx
.send((current_resource_unit().is_some(), current_disk_io_context().is_none()))
.expect("report worker context");
release_rx.await.expect("release owned worker");
Ok::<_, super::super::error::DiskError>(())
};
let task = tokio::spawn(with_background_disk_io(
CancellationToken::new(),
with_background_resource_unit(gate.acquire().await, async move {
match mode {
0 => worker_runtime
.disk_operation(work, DiskAdmission::Request)
.await
.expect("disk operation"),
1 => worker_runtime.gc_operation(move |_| work).await.expect("GC operation"),
2 => {
let reservation = worker_runtime.reserve().expect("cleanup reservation");
run(Some(reservation), async { work.await.expect("cleanup operation") }).await;
}
_ => spawn(None, work)
.await
.expect("unisolated worker")
.expect("unisolated operation"),
}
}),
));
let (inherited, original_policy) = deadline(started_rx).await.expect("owned work starts");
task.abort();
assert!(task.await.expect_err("waiter cancelled").is_cancelled());
let held = gate.snapshot().active;
release_tx.send(()).expect("owned work survives cancellation");
deadline(async {
while gate.snapshot().active != 0 {
tokio::task::yield_now().await;
}
})
.await;
assert!(inherited, "mode {mode} lost resource ownership");
assert!(original_policy, "mode {mode} changed the detached worker's disk policy");
assert_eq!(held, 1, "mode {mode} released a live worker's unit");
}
}
fn config(pending: usize, workers: usize, blocking_threads: usize) -> Config {
Config {
pending,
File diff suppressed because it is too large Load Diff
-30
View File
@@ -159,10 +159,6 @@ pub fn lstat_std(path: impl AsRef<Path>) -> io::Result<Metadata> {
std::fs::symlink_metadata(path)
}
pub async fn make_dir_all(path: impl AsRef<Path>) -> io::Result<()> {
fs::create_dir_all(path.as_ref()).await
}
fn is_dir_error(e: &io::Error) -> bool {
e.raw_os_error() == Some(libc::EISDIR)
|| e.kind() == io::ErrorKind::IsADirectory
@@ -210,10 +206,6 @@ pub fn remove_all_std(path: impl AsRef<Path>) -> io::Result<()> {
}
}
pub async fn mkdir(path: impl AsRef<Path>) -> io::Result<()> {
fs::create_dir(path.as_ref()).await
}
pub async fn rename(from: impl AsRef<Path>, to: impl AsRef<Path>) -> io::Result<()> {
fs::rename(from, to).await
}
@@ -472,17 +464,6 @@ mod tests {
assert!(metadata.file_type().is_symlink());
}
#[tokio::test]
async fn test_make_dir_all() {
let temp_dir = TempDir::new().unwrap();
let nested_path = temp_dir.path().join("level1").join("level2").join("level3");
// Should create nested directories
assert!(make_dir_all(&nested_path).await.is_ok());
assert!(nested_path.exists());
assert!(nested_path.is_dir());
}
#[tokio::test]
async fn test_remove_file() {
let temp_dir = TempDir::new().unwrap();
@@ -555,17 +536,6 @@ mod tests {
assert!(!dir_path.exists());
}
#[tokio::test]
async fn test_mkdir() {
let temp_dir = TempDir::new().unwrap();
let dir_path = temp_dir.path().join("test_mkdir");
// Create directory
assert!(mkdir(&dir_path).await.is_ok());
assert!(dir_path.exists());
assert!(dir_path.is_dir());
}
#[tokio::test]
async fn test_rename() {
let temp_dir = TempDir::new().unwrap();
+243 -7
View File
@@ -24,6 +24,7 @@ use std::task::{Context, Poll};
use std::time::Duration;
use metrics::{counter, gauge, histogram};
use rustfs_concurrency::adaptive::AdaptivePermit;
use rustfs_rio::ChunkReader;
use tokio::io::{AsyncRead, ReadBuf};
use tokio::sync::Notify;
@@ -35,6 +36,7 @@ const BACKGROUND_MAX_WAIT: Duration = Duration::from_millis(100);
tokio::task_local! {
static DISK_IO_CONTEXT: DiskIoContext;
static RESOURCE_IO_UNIT: AdaptivePermit;
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -49,6 +51,7 @@ pub(crate) struct DiskIoContext {
pub(crate) cancellation: CancellationToken,
pub(crate) admission: Option<Arc<DiskIoAdmission>>,
pub(crate) permit: Option<DiskIoPermit>,
pub(crate) resource_unit: Option<AdaptivePermit>,
}
/// Mark disk I/O issued by `future` as background work for local-disk admission.
@@ -60,12 +63,43 @@ pub async fn with_background_disk_io<F: Future>(cancellation: CancellationToken,
cancellation,
admission: None,
permit: None,
resource_unit: current_resource_unit(),
},
future,
)
.await
}
/// Retain an already admitted resource unit without changing disk classification,
/// cancellation or admission. Acquire it before namespace, cache or commit locks.
/// Existing background scopes and disk leases propagate it into native I/O.
/// Use this for physical I/O ownership, not node-wide scheduling capacity that
/// must return with a cancelled waiter so healthy disks can progress independently
/// of a stalled native operation. Escaping buffers need their own memory leases.
#[doc(hidden)]
pub async fn with_background_resource_unit<F: Future>(unit: AdaptivePermit, future: F) -> F::Output {
RESOURCE_IO_UNIT.scope(unit, future).await
}
pub(crate) fn current_resource_unit() -> Option<AdaptivePermit> {
RESOURCE_IO_UNIT.try_with(Clone::clone).ok().or_else(|| {
DISK_IO_CONTEXT
.try_with(|context| context.resource_unit.clone())
.ok()
.flatten()
})
}
/// Transfer only resource ownership into an existing spawned task. Disk
/// classification/permits are not inherited: changing those can introduce a
/// wait behind a disk permit still held by the parent.
pub(crate) async fn with_optional_resource_unit<F: Future>(unit: Option<AdaptivePermit>, future: F) -> F::Output {
match unit {
Some(unit) => with_background_resource_unit(unit, future).await,
None => future.await,
}
}
pub(crate) async fn with_disk_io_context<F: Future>(context: DiskIoContext, future: F) -> F::Output {
DISK_IO_CONTEXT.scope(context, future).await
}
@@ -79,7 +113,11 @@ pub(crate) async fn with_disk_io_permit<F: Future>(permit: Option<DiskIoPermit>,
cancellation: CancellationToken::new(),
admission: None,
permit: None,
resource_unit: None,
});
if context.resource_unit.is_none() {
context.resource_unit = permit.lease.resource_unit.clone();
}
context.admission = Some(permit.admission());
with_disk_io_context(
DiskIoContext {
@@ -91,15 +129,32 @@ pub(crate) async fn with_disk_io_permit<F: Future>(permit: Option<DiskIoPermit>,
.await
}
pub(crate) async fn with_optional_disk_io_context<F: Future>(context: Option<DiskIoContext>, future: F) -> F::Output {
match context {
Some(context) => with_disk_io_context(context, future).await,
None => future.await,
pub(crate) fn with_optional_disk_io_context<F: Future>(
context: Option<DiskIoContext>,
future: F,
) -> impl Future<Output = F::Output> {
// Capture at future construction, before JoinSet/spawn moves polling to a
// different task. A resource-only scope has no DiskIoContext to carry it.
let unit = current_resource_unit();
async move {
let future = with_optional_resource_unit(unit, future);
match context {
Some(context) => with_disk_io_context(context, future).await,
None => future.await,
}
}
}
pub(crate) fn current_disk_io_context() -> Option<DiskIoContext> {
DISK_IO_CONTEXT.try_with(Clone::clone).ok()
DISK_IO_CONTEXT
.try_with(|context| {
let mut context = context.clone();
if let Ok(unit) = RESOURCE_IO_UNIT.try_with(Clone::clone) {
context.resource_unit = Some(unit);
}
context
})
.ok()
}
/// Acquire one low-level permit only when the current walker explicitly owns a gate.
@@ -175,6 +230,10 @@ impl DiskIoAdmission {
}
pub(crate) fn foreground(self: &Arc<Self>) -> DiskIoPermit {
self.foreground_with_resource(current_resource_unit())
}
fn foreground_with_resource(self: &Arc<Self>, resource_unit: Option<AdaptivePermit>) -> DiskIoPermit {
self.foreground_active.fetch_add(1, Ordering::AcqRel);
if self.background_waiters.load(Ordering::Acquire) > 0 {
self.record_state();
@@ -183,11 +242,20 @@ impl DiskIoAdmission {
lease: Arc::new(DiskIoPermitLease {
admission: self.clone(),
class: DiskIoClass::Foreground,
resource_unit,
}),
}
}
pub(crate) async fn background(self: &Arc<Self>, cancellation: &CancellationToken) -> std::io::Result<DiskIoPermit> {
self.background_with_resource(cancellation, current_resource_unit()).await
}
async fn background_with_resource(
self: &Arc<Self>,
cancellation: &CancellationToken,
resource_unit: Option<AdaptivePermit>,
) -> std::io::Result<DiskIoPermit> {
let started_at = Instant::now();
let ticket = {
let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner());
@@ -248,6 +316,7 @@ impl DiskIoAdmission {
lease: Arc::new(DiskIoPermitLease {
admission: self.clone(),
class: DiskIoClass::Background,
resource_unit,
}),
});
}
@@ -360,6 +429,7 @@ impl DiskIoPermit {
struct DiskIoPermitLease {
admission: Arc<DiskIoAdmission>,
class: DiskIoClass,
resource_unit: Option<AdaptivePermit>,
}
pub(crate) fn spawn_blocking_with_disk_io_permit<T, F>(
@@ -370,8 +440,12 @@ where
T: Send + 'static,
F: FnOnce() -> T + Send + 'static,
{
// A supplied disk permit can predate this resource scope or carry another
// owner's unit. Preserve the current unit independently until native I/O ends.
let resource_unit = current_resource_unit();
tokio::task::spawn_blocking(move || {
let _io_permit = io_permit;
let _resource_unit = resource_unit;
operation()
})
}
@@ -436,7 +510,7 @@ impl<R> AdmissionReader<R> {
}
if self.context.class == DiskIoClass::Foreground {
self.permit = Some(self.admission.foreground());
self.permit = Some(self.admission.foreground_with_resource(self.context.resource_unit.clone()));
return Poll::Ready(Ok(()));
}
@@ -444,7 +518,10 @@ impl<R> AdmissionReader<R> {
if wait.is_none() {
let admission = self.admission.clone();
let cancellation = self.context.cancellation.clone();
*wait = Some(Box::pin(async move { admission.background(&cancellation).await }));
let resource_unit = self.context.resource_unit.clone();
*wait = Some(Box::pin(
async move { admission.background_with_resource(&cancellation, resource_unit).await },
));
}
let Some(wait_future) = wait.as_mut() else {
return Poll::Ready(Err(io::Error::other("background disk I/O admission waiter is missing")));
@@ -514,8 +591,166 @@ impl ChunkReader for BoxedChunkReader {
#[cfg(test)]
mod tests {
use super::*;
use rustfs_concurrency::adaptive::AdaptiveAdmission;
use std::num::NonZeroU16;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
#[tokio::test]
async fn resource_unit_outlives_cancelled_scope_until_native_io_finishes() {
for permit_mode in 0..4 {
let gate = AdaptiveAdmission::new(NonZeroU16::new(1).expect("one unit"), 1);
let previous_gate = AdaptiveAdmission::new(NonZeroU16::new(1).expect("previous owner"), 1);
let unit = gate.acquire().await;
let disk = DiskIoAdmission::new("resource-unit-native");
let existing_permit = match permit_mode {
2 => Some(disk.foreground()),
3 => Some(with_background_resource_unit(previous_gate.acquire().await, async { disk.foreground() }).await),
_ => None,
};
let (started_tx, started_rx) = tokio::sync::oneshot::channel();
let (release_tx, release_rx) = tokio::sync::oneshot::channel();
let task = tokio::spawn(with_background_resource_unit(unit, async move {
with_background_disk_io(CancellationToken::new(), async {
let permit = if existing_permit.is_some() {
existing_permit
} else if permit_mode == 1 {
Some(disk.background(&CancellationToken::new()).await.expect("disk permit"))
} else {
None
};
spawn_blocking_with_disk_io_permit(permit, move || {
started_tx.send(()).expect("native I/O started");
release_rx.blocking_recv().expect("native I/O release");
})
.await
.expect("native I/O completed");
})
.await;
}));
started_rx.await.expect("native worker owns unit");
task.abort();
assert!(task.await.expect_err("scope was cancelled").is_cancelled());
let held = gate.snapshot().active;
let previous_held = previous_gate.snapshot().active;
let rejected = gate.try_acquire().is_none();
gate.set_limit(0);
release_tx.send(()).expect("release native I/O");
tokio::time::timeout(Duration::from_secs(5), async {
while gate.snapshot().active != 0 || previous_gate.snapshot().active != 0 {
tokio::task::yield_now().await;
}
})
.await
.expect("native completion releases the unit");
gate.set_limit(1);
assert!(gate.try_acquire().is_some());
assert_eq!(held, 1, "mode {permit_mode}: native I/O still owns the current unit");
assert!(rejected, "mode {permit_mode}: a live native worker must retain admission");
assert_eq!(previous_held, u32::from(permit_mode == 3), "preserve any older disk lease's unit");
}
}
#[tokio::test]
async fn escaped_disk_permit_restores_resource_context_without_a_new_unit() {
let gate = AdaptiveAdmission::new(NonZeroU16::new(1).expect("one unit"), 1);
let disk = DiskIoAdmission::new("resource-unit-escape");
let permit = with_background_resource_unit(gate.acquire().await, async {
disk.background(&CancellationToken::new()).await.expect("disk permit")
})
.await;
assert_eq!(gate.snapshot().active, 1);
with_background_disk_io(CancellationToken::new(), async {
with_disk_io_permit(Some(permit), async {
let unit = current_resource_unit().expect("escaped disk lease restores unit into an existing context");
assert_eq!(gate.snapshot().active, 1, "cloning retains one unit rather than charging twice");
drop(unit);
})
.await;
})
.await;
assert_eq!(gate.snapshot().active, 0);
}
#[tokio::test]
async fn escaped_stream_retains_resource_unit_outside_task_local_scope() {
let gate = AdaptiveAdmission::new(NonZeroU16::new(1).expect("one unit"), 1);
let disk = DiskIoAdmission::new("resource-unit-stream");
let (mut writer, inner) = tokio::io::duplex(8);
let mut reader = with_background_resource_unit(gate.acquire().await, async {
with_background_disk_io(CancellationToken::new(), async {
AdmissionReader::new(inner, disk, current_disk_io_context().expect("resource context"))
})
.await
})
.await;
assert_eq!(gate.snapshot().active, 1);
writer.write_all(b"rust").await.expect("write complete stream");
drop(writer);
let mut bytes = Vec::new();
reader.read_to_end(&mut bytes).await.expect("read outside original scope");
assert_eq!(bytes, b"rust");
assert_eq!(gate.snapshot().active, 1, "reader still owns its context");
drop(reader);
assert_eq!(gate.snapshot().active, 0);
}
#[tokio::test]
async fn resource_scope_preserves_existing_disk_policy_and_cancellation() {
let gate = AdaptiveAdmission::new(NonZeroU16::new(1).expect("one unit"), 1);
with_background_resource_unit(gate.acquire().await, async {
assert!(current_disk_io_context().is_none(), "resource ownership does not classify disk I/O");
assert!(current_resource_unit().is_some());
})
.await;
let cancellation = CancellationToken::new();
let existing = DiskIoContext {
class: DiskIoClass::Foreground,
cancellation: cancellation.clone(),
admission: None,
permit: None,
resource_unit: None,
};
with_disk_io_context(existing, async {
with_background_resource_unit(gate.acquire().await, async {
cancellation.cancel();
let context = current_disk_io_context().expect("existing disk context");
assert_eq!(context.class, DiskIoClass::Foreground);
assert!(
context.resource_unit.is_some(),
"captured disk context must retain the current resource unit"
);
assert!(context.cancellation.is_cancelled(), "retain the original cancellation token");
assert!(current_resource_unit().is_some());
})
.await;
})
.await;
assert_eq!(gate.snapshot().active, 0);
}
#[tokio::test]
async fn resource_only_scope_survives_existing_optional_context_spawn_boundary() {
let gate = AdaptiveAdmission::new(NonZeroU16::new(1).expect("one unit"), 1);
let (release_tx, release_rx) = tokio::sync::oneshot::channel();
let mut worker = None;
with_background_resource_unit(gate.acquire().await, async {
worker = Some(tokio::spawn(with_optional_disk_io_context(current_disk_io_context(), async {
release_rx.await.expect("release child after caller scope ends");
assert!(current_disk_io_context().is_none());
assert!(current_resource_unit().is_some());
})));
})
.await;
let held = gate.snapshot().active;
release_tx.send(()).expect("child remains alive");
worker
.expect("scoped child was spawned")
.await
.expect("child preserves resource-only scope");
assert_eq!(held, 1);
assert_eq!(gate.snapshot().active, 0);
}
struct BlockingWorker(tokio::task::JoinHandle<()>);
async fn wait_for_background_waiters(admission: &DiskIoAdmission, expected: usize) {
@@ -636,6 +871,7 @@ mod tests {
cancellation: CancellationToken::new(),
admission: None,
permit: None,
resource_unit: None,
},
);
let read = tokio::spawn(async move {
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,740 @@
// 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::disk::error::{DiskError, Result};
use std::{fs::File, sync::Arc};
/// A target disk's admitted artifact publication. Physical mutation workers
/// retain this Arc until their final syscall and rollback have finished.
pub struct ArtifactLease {
_active: File,
_key_directory: File,
_seal_directory: File,
_root: File,
}
pub type ArtifactPutLease = ArtifactLease;
pub type ArtifactRepairLease = ArtifactLease;
/// Read-only census owns both already-closed physical mutation domains.
pub(super) struct ArtifactInventoryLease {
_put: Arc<ArtifactLease>,
_repair: Arc<ArtifactLease>,
}
pub(super) async fn drain_closed(root: File, key: [u8; 32], deadline: tokio::time::Instant) -> Result<ArtifactInventoryLease> {
#[cfg(unix)]
{
unix::drain_closed(root, key, deadline).await
}
#[cfg(not(unix))]
{
let _ = (root, key, deadline);
Err(DiskError::UnsupportedDisk)
}
}
#[derive(Clone, Copy)]
pub(crate) enum Domain {
Put,
Repair,
}
impl Domain {
fn directory(self) -> &'static str {
match self {
Self::Put => ".rustfs-artifact-put-seals",
Self::Repair => ".rustfs-artifact-repair-seals",
}
}
fn closed(self) -> &'static [u8] {
match self {
Self::Put => b"rustfs-artifact-put-sealed-v1\n",
Self::Repair => b"rustfs-artifact-repair-sealed-v1\n",
}
}
}
pub(super) async fn pinned_root(disk: &super::LocalDisk) -> Result<File> {
#[cfg(target_os = "linux")]
let root = disk.mount_lease.clone();
#[cfg(all(unix, not(target_os = "linux")))]
let root = disk.artifact_identity_root.clone();
#[cfg(not(unix))]
let _ = disk;
tokio::task::spawn_blocking(move || {
#[cfg(unix)]
{
root.try_clone().map_err(DiskError::from)
}
#[cfg(not(unix))]
{
Err(DiskError::other("artifact disk leases require Unix"))
}
})
.await
.map_err(DiskError::from)?
}
pub(super) async fn acquire(root: File, key: [u8; 32]) -> Result<Arc<ArtifactPutLease>> {
acquire_in(root, key, Domain::Put).await
}
pub(super) async fn acquire_in(root: File, key: [u8; 32], domain: Domain) -> Result<Arc<ArtifactLease>> {
#[cfg(unix)]
{
unix::acquire(root, key, domain).await
}
#[cfg(not(unix))]
{
let _ = (root, key, domain);
Err(DiskError::other("artifact disk leases require Unix"))
}
}
pub(super) async fn seal(root: File, key: [u8; 32]) -> Result<()> {
seal_in(root, key, Domain::Put).await
}
pub(super) async fn seal_in(root: File, key: [u8; 32], domain: Domain) -> Result<()> {
#[cfg(unix)]
{
unix::seal(root, key, domain).await
}
#[cfg(not(unix))]
{
let _ = (root, key, domain);
Err(DiskError::other("artifact disk leases require Unix"))
}
}
#[cfg(unix)]
mod unix {
use super::*;
use rustix::fs::{FlockOperation, Mode, OFlags, flock, mkdirat, openat};
use rustix::io::Errno;
use std::{io, io::Read, io::Write, os::unix::fs::MetadataExt, time::Duration};
const POLL_INTERVAL: Duration = Duration::from_millis(10);
const LOCK_TIMEOUT: Duration = Duration::from_secs(30);
struct Opened {
root: File,
seals: File,
key: File,
admission: File,
active: File,
domain: Domain,
}
fn invalid() -> io::Error {
io::Error::new(io::ErrorKind::InvalidData, "invalid artifact seal filesystem state")
}
fn directory(parent: &File, name: &str, device: u64) -> io::Result<File> {
match mkdirat(parent, name, Mode::RWXU) {
Ok(()) | Err(Errno::EXIST) => {}
Err(error) => return Err(error.into()),
}
let file = File::from(openat(
parent,
name,
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
Mode::empty(),
)?);
let metadata = file.metadata()?;
if !metadata.is_dir() || metadata.dev() != device || metadata.mode() & 0o077 != 0 {
return Err(invalid());
}
Ok(file)
}
fn regular(file: &File, device: u64) -> io::Result<std::fs::Metadata> {
let metadata = file.metadata()?;
if !metadata.is_file() || metadata.dev() != device || metadata.nlink() != 1 || metadata.mode() & 0o077 != 0 {
return Err(invalid());
}
Ok(metadata)
}
fn lock_file(parent: &File, name: &str, device: u64) -> io::Result<File> {
let file = File::from(openat(
parent,
name,
OFlags::CREATE | OFlags::RDWR | OFlags::NOFOLLOW | OFlags::CLOEXEC | OFlags::NONBLOCK,
Mode::RUSR | Mode::WUSR,
)?);
if regular(&file, device)?.len() != 0 {
return Err(invalid());
}
Ok(file)
}
impl Opened {
fn open(root: File, key: [u8; 32], domain: Domain) -> io::Result<Self> {
let metadata = root.metadata()?;
if !metadata.is_dir() {
return Err(invalid());
}
let seals = directory(&root, domain.directory(), metadata.dev())?;
let name = hex_simd::encode_to_string(key, hex_simd::AsciiCase::Lower);
let key = directory(&seals, &name, metadata.dev())?;
// Neither lock inode is ever removed or replaced, including after
// closing. Independent processes must always lock the same inode.
let admission = lock_file(&key, "admission.lock", metadata.dev())?;
let active = lock_file(&key, "active.lock", metadata.dev())?;
Ok(Self {
root,
seals,
key,
admission,
active,
domain,
})
}
fn closed(&self) -> io::Result<Option<File>> {
self.closed_with_flags(OFlags::RDWR)
}
fn closed_with_flags(&self, access: OFlags) -> io::Result<Option<File>> {
let mut file = match openat(
&self.key,
"closed",
access | OFlags::NOFOLLOW | OFlags::CLOEXEC | OFlags::NONBLOCK,
Mode::empty(),
) {
Ok(file) => File::from(file),
Err(Errno::NOENT) => return Ok(None),
Err(error) => return Err(error.into()),
};
let length = u64::try_from(self.domain.closed().len()).map_err(|_| invalid())?;
if regular(&file, self.root.metadata()?.dev())?.len() != length {
return Err(invalid());
}
let mut bytes = vec![0; self.domain.closed().len()];
file.read_exact(&mut bytes)?;
if bytes != self.domain.closed() {
return Err(invalid());
}
Ok(Some(file))
}
fn admit(self) -> io::Result<Arc<ArtifactLease>> {
if self.closed()?.is_some() {
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"artifact attempt mutation domain is closed",
));
}
// Admission serializes the closed check with taking this shared
// physical owner. A sealer cannot pass between those two steps.
flock(&self.active, FlockOperation::NonBlockingLockShared)?;
flock(&self.admission, FlockOperation::Unlock)?;
Ok(Arc::new(ArtifactLease {
_active: self.active,
_key_directory: self.key,
_seal_directory: self.seals,
_root: self.root,
}))
}
fn close(self) -> io::Result<Self> {
let marker = match self.closed()? {
Some(marker) => marker,
None => {
let mut marker = File::from(openat(
&self.key,
"closed",
OFlags::CREATE | OFlags::EXCL | OFlags::RDWR | OFlags::NOFOLLOW | OFlags::CLOEXEC,
Mode::RUSR | Mode::WUSR,
)?);
regular(&marker, self.root.metadata()?.dev())?;
marker.write_all(self.domain.closed())?;
marker
}
};
// No admission is released until the permanent denial and every
// ancestor entry are durable. A failed/cancelled close never removes it.
marker.sync_all()?;
self.admission.sync_all()?;
self.active.sync_all()?;
self.key.sync_all()?;
self.seals.sync_all()?;
self.root.sync_all()?;
flock(&self.admission, FlockOperation::Unlock)?;
Ok(self)
}
}
async fn lock(file: &File, operation: FlockOperation) -> io::Result<()> {
tokio::time::timeout(LOCK_TIMEOUT, async {
loop {
match flock(file, operation) {
Ok(()) => return Ok(()),
Err(Errno::WOULDBLOCK | Errno::INTR) => tokio::time::sleep(POLL_INTERVAL).await,
Err(error) => return Err(error.into()),
}
}
})
.await
.map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "artifact disk lease lock deadline"))?
}
async fn open(root: File, key: [u8; 32], domain: Domain) -> Result<Opened> {
tokio::task::spawn_blocking(move || Opened::open(root, key, domain))
.await
.map_err(DiskError::from)?
.map_err(DiskError::from)
}
fn read_error(error: io::Error) -> DiskError {
match error.kind() {
io::ErrorKind::NotFound => DiskError::other("artifact inventory requires closed PUT and repair domains"),
io::ErrorKind::InvalidData | io::ErrorKind::NotADirectory => DiskError::FileCorrupt,
io::ErrorKind::TimedOut => DiskError::Timeout,
_ if error.raw_os_error() == Some(Errno::LOOP.raw_os_error()) => DiskError::FileCorrupt,
_ => DiskError::from(error),
}
}
fn existing_directory(parent: &File, name: &str, device: u64) -> io::Result<File> {
let file = File::from(openat(
parent,
name,
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
Mode::empty(),
)?);
let metadata = file.metadata()?;
if metadata.dev() != device || metadata.mode() & 0o077 != 0 {
return Err(invalid());
}
Ok(file)
}
fn existing(root: File, key: [u8; 32], domain: Domain) -> io::Result<Opened> {
let device = root.metadata()?.dev();
let seals = existing_directory(&root, domain.directory(), device)?;
let name = hex_simd::encode_to_string(key, hex_simd::AsciiCase::Lower);
let key = existing_directory(&seals, &name, device)?;
let lock = |name| -> io::Result<File> {
let file = File::from(openat(
&key,
name,
OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::CLOEXEC | OFlags::NONBLOCK,
Mode::empty(),
)?);
if regular(&file, device)?.len() != 0 {
return Err(invalid());
}
Ok(file)
};
let admission = lock("admission.lock")?;
let active = lock("active.lock")?;
let opened = Opened {
root,
seals,
key,
admission,
active,
domain,
};
if opened.closed_with_flags(OFlags::RDONLY)?.is_none() {
return Err(io::Error::from(io::ErrorKind::NotFound));
}
Ok(opened)
}
fn retained(opened: Opened) -> Arc<ArtifactLease> {
Arc::new(ArtifactLease {
_active: opened.active,
_key_directory: opened.key,
_seal_directory: opened.seals,
_root: opened.root,
})
}
pub(super) async fn drain_closed(
root: File,
key: [u8; 32],
deadline: tokio::time::Instant,
) -> Result<ArtifactInventoryLease> {
let (put, repair) = tokio::task::spawn_blocking(move || {
let put = existing(root.try_clone()?, key, Domain::Put)?;
let repair = existing(root, key, Domain::Repair)?;
Ok::<_, io::Error>((put, repair))
})
.await
.map_err(DiskError::from)?
.map_err(read_error)?;
// Lock order: closed PUT active exclusive -> closed repair active
// exclusive. No admission lock is acquired and no seal is created.
tokio::time::timeout_at(deadline, async {
lock(&put.active, FlockOperation::NonBlockingLockExclusive).await?;
lock(&repair.active, FlockOperation::NonBlockingLockExclusive).await
})
.await
.map_err(|_| DiskError::Timeout)?
.map_err(read_error)?;
Ok(ArtifactInventoryLease {
_put: retained(put),
_repair: retained(repair),
})
}
pub(super) async fn acquire(root: File, key: [u8; 32], domain: Domain) -> Result<Arc<ArtifactLease>> {
let opened = open(root, key, domain).await?;
// Lock order: admission exclusive -> active shared. Contention sleeps
// asynchronously; no blocking executor thread waits for another owner.
lock(&opened.admission, FlockOperation::NonBlockingLockExclusive).await?;
tokio::task::spawn_blocking(move || opened.admit())
.await
.map_err(DiskError::from)?
.map_err(DiskError::from)
}
pub(super) async fn seal(root: File, key: [u8; 32], domain: Domain) -> Result<()> {
let opened = open(root, key, domain).await?;
lock(&opened.admission, FlockOperation::NonBlockingLockExclusive).await?;
let opened = tokio::task::spawn_blocking(move || opened.close())
.await
.map_err(DiskError::from)??;
// Closing is already durable. Cancellation or timeout here only loses
// the confirmation; admission stays permanently closed across reopen.
lock(&opened.active, FlockOperation::NonBlockingLockExclusive).await?;
Ok(())
}
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
use std::{path::Path, time::Duration};
const KEY: [u8; 32] = [7; 32];
fn root(path: &Path) -> File {
use rustix::fs::{Mode, OFlags, open};
File::from(
open(
path,
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
Mode::empty(),
)
.expect("open pinned test root"),
)
}
fn directory(path: &Path) -> std::path::PathBuf {
path.join(Domain::Put.directory())
.join(hex_simd::encode_to_string(KEY, hex_simd::AsciiCase::Lower))
}
async fn closed(path: &Path) {
tokio::time::timeout(Duration::from_secs(5), async {
while !directory(path).join("closed").exists() {
tokio::time::sleep(Duration::from_millis(5)).await;
}
})
.await
.expect("sealer reaches its monotonic close point");
}
#[tokio::test]
async fn artifact_repair_closing_domain_is_independent_and_survives_reopen() {
let temp = tempfile::tempdir().expect("disk root");
seal(root(temp.path()), KEY).await.expect("PUT closes first");
let repair = acquire_in(root(temp.path()), KEY, Domain::Repair)
.await
.expect("PUT closure permits existing-version repair");
assert!(acquire(root(temp.path()), KEY).await.is_err());
drop(repair);
seal_in(root(temp.path()), KEY, Domain::Repair)
.await
.expect("repair closes independently");
assert!(acquire_in(root(temp.path()), KEY, Domain::Repair).await.is_err());
seal_in(root(temp.path()), KEY, Domain::Repair)
.await
.expect("reopened repair close is durable");
let other = [8; 32];
seal_in(root(temp.path()), other, Domain::Repair)
.await
.expect("close only repair admission");
drop(
acquire(root(temp.path()), other)
.await
.expect("repair closure permits PUT domain"),
);
}
#[tokio::test]
async fn artifact_repair_cancelled_waiter_keeps_physical_owner_and_durable_close() {
let temp = tempfile::tempdir().expect("disk root");
let writer = acquire_in(root(temp.path()), KEY, Domain::Repair)
.await
.expect("repair admitted");
let (entered, started) = tokio::sync::oneshot::channel();
let (release, waiting) = std::sync::mpsc::channel();
let owner = tokio::spawn(async move {
tokio::task::spawn_blocking(move || {
let _owner = writer;
let _ = entered.send(());
let _ = waiting.recv();
})
.await
.expect("repair worker");
});
started.await.expect("physical repair entered");
owner.abort();
assert!(owner.await.expect_err("cancel repair waiter").is_cancelled());
let sealer = tokio::spawn(seal_in(root(temp.path()), KEY, Domain::Repair));
let marker = temp
.path()
.join(Domain::Repair.directory())
.join(hex_simd::encode_to_string(KEY, hex_simd::AsciiCase::Lower))
.join("closed");
tokio::time::timeout(Duration::from_secs(5), async {
while !marker.exists() {
tokio::time::sleep(Duration::from_millis(5)).await;
}
})
.await
.expect("repair admission durably closes");
assert!(!sealer.is_finished(), "ARTIFACT_REPAIR_CANCELLED_PHYSICAL_TAIL");
sealer.abort();
assert!(sealer.await.expect_err("cancel repair close waiter").is_cancelled());
assert!(acquire_in(root(temp.path()), KEY, Domain::Repair).await.is_err());
let pending = seal_in(root(temp.path()), KEY, Domain::Repair);
tokio::pin!(pending);
assert!(
tokio::time::timeout(Duration::from_millis(50), &mut pending).await.is_err(),
"ARTIFACT_REPAIR_REOPEN_MUST_DRAIN_PHYSICAL_TAIL"
);
release.send(()).expect("release physical repair");
tokio::time::timeout(Duration::from_secs(5), &mut pending)
.await
.expect("physical drain bounded")
.expect("reopened close confirms repair drain");
assert!(acquire_in(root(temp.path()), KEY, Domain::Repair).await.is_err());
}
#[tokio::test]
async fn artifact_put_seal_waits_for_independent_openers_and_last_clone() {
let temp = tempfile::tempdir().expect("disk root");
let first = acquire(root(temp.path()), KEY).await.expect("first admitted writer");
let tail = first.clone();
let second = acquire(root(temp.path()), KEY).await.expect("independent shared writer");
let pending = seal(root(temp.path()), KEY);
tokio::pin!(pending);
assert!(
tokio::time::timeout(Duration::from_millis(50), &mut pending).await.is_err(),
"ARTIFACT_PUT_ACTIVE_WRITERS_DRAIN"
);
drop(first);
drop(second);
assert!(
tokio::time::timeout(Duration::from_millis(50), &mut pending).await.is_err(),
"ARTIFACT_PUT_PHYSICAL_CLONE_RETAINED"
);
assert!(acquire(root(temp.path()), KEY).await.is_err(), "ARTIFACT_PUT_CLOSE_REJECTS_ADMISSION");
drop(tail);
tokio::time::timeout(Duration::from_secs(5), &mut pending)
.await
.expect("physical tail drain bounded")
.expect("seal complete");
seal(root(temp.path()), KEY)
.await
.expect("independent reopen preserves closed state");
assert!(acquire(root(temp.path()), KEY).await.is_err(), "ARTIFACT_PUT_CLOSED_AFTER_REOPEN");
}
#[tokio::test]
async fn artifact_put_close_cancellation_leaves_durable_denial() {
let temp = tempfile::tempdir().expect("disk root");
let writer = acquire(root(temp.path()), KEY).await.expect("admitted writer");
let sealer = tokio::spawn(seal(root(temp.path()), KEY));
closed(temp.path()).await;
sealer.abort();
assert!(sealer.await.expect_err("cancel close waiter").is_cancelled());
assert!(
acquire(root(temp.path()), KEY).await.is_err(),
"ARTIFACT_PUT_CANCELLED_CLOSE_STAYS_CLOSED"
);
drop(writer);
seal(root(temp.path()), KEY).await.expect("reopen finishes physical drain");
assert!(acquire(root(temp.path()), KEY).await.is_err(), "ARTIFACT_PUT_CANCELLED_CLOSE_REOPEN");
}
#[tokio::test]
async fn artifact_put_physical_worker_retains_lease_after_waiter_cancellation() {
let temp = tempfile::tempdir().expect("disk root");
let writer = acquire(root(temp.path()), KEY).await.expect("physical writer admitted");
let (entered, started) = tokio::sync::oneshot::channel();
let (release, waiting) = std::sync::mpsc::channel();
let owner = tokio::spawn(async move {
tokio::task::spawn_blocking(move || {
let _physical_owner = writer;
let _ = entered.send(());
let _ = waiting.recv();
})
.await
.expect("physical worker");
});
started.await.expect("physical syscall entered");
owner.abort();
assert!(owner.await.expect_err("cancel async writer").is_cancelled());
let pending = seal(root(temp.path()), KEY);
tokio::pin!(pending);
assert!(
tokio::time::timeout(Duration::from_millis(50), &mut pending).await.is_err(),
"ARTIFACT_PUT_CANCELLED_PHYSICAL_TAIL"
);
release.send(()).expect("release physical worker");
tokio::time::timeout(Duration::from_secs(5), &mut pending)
.await
.expect("physical worker drains")
.expect("seal confirms real completion");
}
#[tokio::test]
async fn artifact_put_seal_waits_for_another_process() {
const CHILD_ROOT: &str = "RUSTFS_TEST_ARTIFACT_PUT_SEAL_ROOT";
if let Some(path) = std::env::var_os(CHILD_ROOT) {
seal(root(Path::new(&path)), KEY)
.await
.expect("child closes and drains parent writer");
assert!(acquire(root(Path::new(&path)), KEY).await.is_err(), "ARTIFACT_PUT_CHILD_CLOSED");
return;
}
struct Child(std::process::Child);
impl Drop for Child {
fn drop(&mut self) {
let _ = self.0.kill();
let _ = self.0.wait();
}
}
let temp = tempfile::tempdir().expect("disk root");
let writer = acquire(root(temp.path()), KEY).await.expect("parent owns physical write");
let mut child = Child(
std::process::Command::new(std::env::current_exe().expect("test executable"))
.args([
"--exact",
"disk::local::artifact_lease::tests::artifact_put_seal_waits_for_another_process",
])
.env(CHILD_ROOT, temp.path())
.spawn()
.expect("start independent sealer"),
);
closed(temp.path()).await;
assert!(child.0.try_wait().expect("probe child").is_none(), "ARTIFACT_PUT_CROSS_PROCESS_DRAIN");
assert!(
acquire(root(temp.path()), KEY).await.is_err(),
"ARTIFACT_PUT_CROSS_PROCESS_ADMISSION_CLOSED"
);
drop(writer);
let status = tokio::time::timeout(Duration::from_secs(5), async {
loop {
if let Some(status) = child.0.try_wait().expect("child wait") {
break status;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("child physically drains");
assert!(status.success(), "ARTIFACT_PUT_CROSS_PROCESS_SEAL");
assert!(acquire(root(temp.path()), KEY).await.is_err(), "ARTIFACT_PUT_PROCESS_REOPEN_CLOSED");
}
#[tokio::test]
async fn artifact_put_lease_rejects_symlinks_at_every_seal_component() {
use std::os::unix::fs::{PermissionsExt, symlink};
for component in ["directory", "key", "admission.lock", "active.lock", "closed"] {
let temp = tempfile::tempdir().expect("disk root");
let outside = tempfile::tempdir().expect("symlink destination");
let sentinel = outside.path().join("untouched");
std::fs::write(&sentinel, b"unchanged").expect("sentinel");
let target = match component {
"directory" => temp.path().join(Domain::Put.directory()),
"key" => {
std::fs::create_dir(temp.path().join(Domain::Put.directory())).expect("seal directory");
std::fs::set_permissions(temp.path().join(Domain::Put.directory()), std::fs::Permissions::from_mode(0o700))
.expect("private seal directory");
directory(temp.path())
}
file => {
drop(acquire(root(temp.path()), KEY).await.expect("initialize seal files"));
let target = directory(temp.path()).join(file);
if file != "closed" {
std::fs::remove_file(&target).expect("replace fixture lock");
}
target
}
};
symlink(
if matches!(component, "directory" | "key") {
outside.path()
} else {
&sentinel
},
target,
)
.expect("symlink fixture");
assert!(
acquire(root(temp.path()), KEY).await.is_err(),
"ARTIFACT_PUT_SYMLINK_REJECTED: {component}"
);
assert!(
seal(root(temp.path()), KEY).await.is_err(),
"ARTIFACT_PUT_SYMLINK_SEAL_REJECTED: {component}"
);
assert_eq!(std::fs::read(&sentinel).expect("sentinel intact"), b"unchanged");
}
}
#[tokio::test]
async fn artifact_put_lease_rejects_corrupt_closed_marker() {
use std::os::unix::fs::PermissionsExt;
let temp = tempfile::tempdir().expect("disk root");
drop(acquire(root(temp.path()), KEY).await.expect("initialize seal files"));
let marker = directory(temp.path()).join("closed");
std::fs::write(&marker, b"partial").expect("corrupt marker fixture");
std::fs::set_permissions(&marker, std::fs::Permissions::from_mode(0o600)).expect("marker permissions");
assert!(acquire(root(temp.path()), KEY).await.is_err(), "ARTIFACT_PUT_CORRUPT_MARKER_FAILS_CLOSED");
assert!(seal(root(temp.path()), KEY).await.is_err(), "ARTIFACT_PUT_CORRUPT_MARKER_NOT_CONFIRMED");
assert_eq!(std::fs::read(marker).expect("corruption retained"), b"partial");
}
#[tokio::test]
async fn artifact_put_lease_stays_on_pinned_root_after_path_replacement() {
let temp = tempfile::tempdir().expect("disk root parent");
let current = temp.path().join("current");
let original = temp.path().join("original");
std::fs::create_dir(&current).expect("initial disk root");
let pin = root(&current);
drop(
acquire(pin.try_clone().expect("root pin clone"), KEY)
.await
.expect("initialize pinned seal"),
);
std::fs::rename(&current, &original).expect("replace configured pathname");
std::fs::create_dir(&current).expect("replacement disk root");
seal(pin, KEY).await.expect("close original disk through its descriptor");
assert!(directory(&original).join("closed").exists(), "ARTIFACT_PUT_ROOT_PINNED");
assert!(!current.join(Domain::Put.directory()).exists(), "ARTIFACT_PUT_REPLACEMENT_UNTOUCHED");
assert!(acquire(root(&original), KEY).await.is_err());
}
}
+204 -65
View File
@@ -345,6 +345,8 @@ async fn prepare_rename_commit_and_destination_metadata(
let publication_root = publication_root.clone();
os::run_blocking_namespace_operation(mutation_lease, move || {
if create_source_parent {
#[cfg(test)]
super::volume_mutation_test_hooks::run(super::volume_mutation_test_hooks::Stage::Create, &source_parent);
std::fs::create_dir_all(&source_parent)?;
}
let guard = match os::prepare_rename_commit_guard(&source_parent, &destination_parent, &base_dir, &publication_root) {
@@ -365,9 +367,15 @@ async fn prepare_rename_commit_and_destination_metadata(
};
#[cfg(not(any(windows, target_os = "linux")))]
let result = {
let _ = mutation_lease;
if create_source_parent {
fs::create_dir_all(source_parent).await.map_err(to_file_error)?;
let source_parent = source_parent.to_path_buf();
os::run_blocking_namespace_operation(mutation_lease, move || {
#[cfg(test)]
super::volume_mutation_test_hooks::run(super::volume_mutation_test_hooks::Stage::Create, &source_parent);
std::fs::create_dir_all(source_parent)
})
.await
.map_err(to_file_error)?;
}
let guard =
os::prepare_rename_commit_guard(source_parent, destination_parent, base_dir, publication_root).map_err(|err| {
@@ -398,30 +406,31 @@ pub(in crate::disk) struct LocalRenamePreflightRejection(());
pub(super) struct RenameDataState {
namespace_owner: Option<Arc<dyn Send + Sync>>,
preflight_rejection: Option<LocalRenamePreflightRejection>,
prepared: Option<Arc<os::NamespaceMutationLease>>,
}
pub(in crate::disk) struct PreparedRenameData {
file_info: FileInfo,
mutation_lease: Arc<os::NamespaceMutationLease>,
}
impl LocalDisk {
#[tracing::instrument(name = "rename_data", target = "rustfs_ecstore::disk::local", level = "trace", skip_all)]
pub(super) async fn rename_data_inner(
async fn prepare_rename_data_admission(
&self,
src_volume: &str,
src_path: &str,
fi: FileInfo,
dst_volume: &str,
dst_path: &str,
state: &mut RenameDataState,
) -> Result<RenameDataResp> {
crate::hp_guard!("LocalDisk::rename_data");
#[cfg(feature = "e2e-test-hooks")]
if fi.is_healing() {
wait_for_heal_commit_test_barrier(&self.root, dst_volume, dst_path).await?;
}
source: (&str, &str),
destination: (&str, &str),
source_volume: Option<&str>,
namespace_owner: Option<Arc<dyn Send + Sync>>,
) -> Result<PreparedRenameData> {
let (src_volume, src_path) = source;
let (dst_volume, dst_path) = destination;
let mut fi = fi;
// A non-force DeleteBucket must not remove a directory while a local
// object commit is publishing into it. The peer's empty scan remains
// optimistic; this lease establishes the local commit/delete order and
// remains owned by any blocking syscall that outlives async cancellation.
let (dst_volume_dir, destination_object_path) = self.io_get_bucket_and_object_path(dst_volume, dst_path)?;
let destination_object_path = self.io_get_object_path(dst_volume, dst_path)?;
let quota_fence_token =
match rustfs_utils::http::metadata_compat::get_consistent_str(&fi.metadata, QUOTA_MUTATION_FENCE_METADATA_SUFFIX) {
Some(value) => {
@@ -455,16 +464,27 @@ impl LocalDisk {
.strip_prefix(self.io_root())
.map_err(|_| DiskError::InvalidPath)?,
);
let metadata_lease =
os::acquire_metadata_mutation_lease(&root_destination_object_path, state.namespace_owner.take()).await;
let mutation_lease = os::acquire_rename_data_mutation_lease_with_owner(
&self.root,
dst_volume,
&destination_object_path,
staging_prefix.as_deref(),
Some(metadata_lease),
)
.await;
let metadata_lease = os::acquire_metadata_mutation_lease(&root_destination_object_path, namespace_owner).await;
let mutation_lease = if let Some(source_volume) = source_volume {
os::acquire_rename_data_mutation_lease_for_volumes(
&self.root,
source_volume,
dst_volume,
&destination_object_path,
staging_prefix.as_deref(),
Some(metadata_lease),
)
.await
} else {
os::acquire_rename_data_mutation_lease_with_owner(
&self.root,
dst_volume,
&destination_object_path,
staging_prefix.as_deref(),
Some(metadata_lease),
)
.await
};
if let Some(claim) = quota_fence_claim {
mutation_lease.attach_external_guard(claim);
}
@@ -472,6 +492,99 @@ impl LocalDisk {
fi.erasure.index = 0;
}
fi.validate_for_metadata_read()?;
Ok(PreparedRenameData {
file_info: fi,
mutation_lease,
})
}
pub(in crate::disk) async fn begin_legacy_rename_data(
&self,
source: (&str, &str),
fi: &FileInfo,
destination: (&str, &str),
permit: crate::disk::io_admission::DiskIoPermit,
namespace_owner: Option<Arc<dyn Send + Sync>>,
) -> Result<PreparedRenameData> {
// Capture the same permit, mount FD and listener-instance owner before
// quota -> metadata -> namespace -> sorted volume READ admission.
#[cfg(target_os = "linux")]
let owner: Arc<dyn Send + Sync> = Arc::new((permit, self.mount_lease.clone(), namespace_owner));
#[cfg(not(target_os = "linux"))]
let owner: Arc<dyn Send + Sync> = Arc::new((permit, namespace_owner));
let prepared = self
.prepare_rename_data_admission(fi.clone(), source, destination, Some(source.0), Some(owner))
.await?;
for volume in [Some(source.0), (source.0 != destination.0).then_some(destination.0)]
.into_iter()
.flatten()
{
if !skip_access_checks(volume) {
let path = self.io_get_bucket_path(volume)?;
let metadata = fs::symlink_metadata(path)
.await
.map_err(crate::disk::error_conv::to_volume_error)?;
if !metadata.is_dir() {
return Err(DiskError::VolumeNotFound);
}
}
}
Ok(prepared)
}
pub(in crate::disk) async fn rename_data_prepared(
&self,
source: (&str, &str),
destination: (&str, &str),
prepared: PreparedRenameData,
) -> Result<RenameDataResp> {
let PreparedRenameData {
file_info,
mutation_lease,
} = prepared;
let mut state = RenameDataState {
prepared: Some(mutation_lease),
..Default::default()
};
self.rename_data_inner(source.0, source.1, file_info, destination.0, destination.1, &mut state)
.await
}
#[tracing::instrument(name = "rename_data", target = "rustfs_ecstore::disk::local", level = "trace", skip_all)]
pub(super) async fn rename_data_inner(
&self,
src_volume: &str,
src_path: &str,
fi: FileInfo,
dst_volume: &str,
dst_path: &str,
state: &mut RenameDataState,
) -> Result<RenameDataResp> {
crate::hp_guard!("LocalDisk::rename_data");
#[cfg(feature = "e2e-test-hooks")]
if fi.is_healing() {
wait_for_heal_commit_test_barrier(&self.root, dst_volume, dst_path).await?;
}
let PreparedRenameData {
file_info: fi,
mutation_lease,
} = if let Some(mutation_lease) = state.prepared.take() {
PreparedRenameData {
file_info: fi,
mutation_lease,
}
} else {
self.prepare_rename_data_admission(
fi,
(src_volume, src_path),
(dst_volume, dst_path),
None,
state.namespace_owner.take(),
)
.await?
};
let no_inline = fi.data.is_none() && fi.size > 0;
let fi_healing = fi.is_healing();
// Snapshot the destination part paths before `fi` is consumed below. These
// are the descriptors a reader may hold for the version this call is about
// to replace (backlog#1145); readers build the identical string in
@@ -507,6 +620,7 @@ impl LocalDisk {
return Err(to_access_error(e, DiskError::VolumeAccessDenied).into());
}
let dst_volume_dir = self.io_get_bucket_path(dst_volume)?;
if !skip_access_checks(dst_volume)
&& let Err(e) = super::super::fs::access_std(&dst_volume_dir)
{
@@ -524,8 +638,15 @@ impl LocalDisk {
return Err(to_access_error(e, DiskError::VolumeAccessDenied).into());
}
// xl.meta path
let src_file_path = self.io_get_object_path(src_volume, format!("{}/{}", src_path, STORAGE_FORMAT_FILE).as_str())?;
// Keep recovery authority separate from active upload metadata so a
// committed upload cannot reopen when set-level cleanup fails.
let preserve_upload_metadata = src_volume == super::super::RUSTFS_META_MULTIPART_BUCKET;
let source_metadata_name = if preserve_upload_metadata {
super::super::MULTIPART_COMMIT_METADATA_FILE
} else {
STORAGE_FORMAT_FILE
};
let src_file_path = self.io_get_object_path(src_volume, format!("{src_path}/{source_metadata_name}").as_str())?;
let dst_file_path = self.io_get_object_open_path(dst_volume, format!("{}/{}", dst_path, STORAGE_FORMAT_FILE).as_str())?;
// data_dir path (only used by the non-inline commit branch)
@@ -590,6 +711,35 @@ impl LocalDisk {
data_paths: has_data_dir_path.as_ref(),
};
if preserve_upload_metadata {
let source_object_path = self.get_object_path(src_volume, src_path)?;
let authority_owner: Arc<dyn Send + Sync> = if source_object_path == self.get_object_path(dst_volume, dst_path)? {
commit.mutation_lease.clone()
} else {
os::try_acquire_metadata_mutation_lease_with_owner(&source_object_path, commit.mutation_lease.clone())?
};
let authority = self.io_get_object_path(src_volume, &format!("{src_path}/{STORAGE_FORMAT_FILE}"))?;
let recovery = self.io_get_object_path(
src_volume,
&format!("{src_path}/{}/{STORAGE_FORMAT_FILE}", super::super::MULTIPART_RECOVERY_DIR),
)?;
check_path_length(recovery.to_string_lossy().as_ref())?;
// Both volume READs are already held. Neither source key may wait
// behind a competing owner that is queued behind a volume WRITE.
let recovery_lease = os::try_acquire_namespace_mutation_lease_with_owner(&recovery, authority_owner.clone())?;
os::rename_all_with_lease(&authority, &recovery, &src_volume_dir, commit.publication_root, recovery_lease).await?;
// This is system metadata authority, so use its existing strict
// durability policy even when the object payload is relaxed.
if effective_durability(src_volume).syncs_commit_metadata() {
os::fsync_dir_with_owner(recovery.parent().ok_or(DiskError::InvalidPath)?, Some(authority_owner.clone()))
.await
.map_err(to_file_error)?;
os::fsync_dir_with_owner(src_file_parent, Some(authority_owner.clone()))
.await
.map_err(to_file_error)?;
}
}
if no_inline {
// Non-inline: read xl.meta, parse, write, rename data dir, rename xl.meta
let mut xlmeta = FileMeta::new();
@@ -798,7 +948,7 @@ impl LocalDisk {
if let (Some(old_data_dir), Some(dst_buf)) = (rollback_data_dir, has_dst_buf.as_ref()) {
let backup_parent = dst_file_parent.join(old_data_dir.to_string());
#[cfg(not(windows))]
if let Err(err) = os::make_dir_all(&backup_parent, &skip_parent).await {
if let Err(err) = os::make_dir_all_with_owner(&backup_parent, &skip_parent, commit.mutation_lease.clone()).await {
commit.restore_published_data_source().await?;
return Err(err);
}
@@ -977,14 +1127,8 @@ impl LocalDisk {
#[cfg(windows)]
drop(commit.directory_guard);
if let Some(src_file_path_parent) = src_file_path.parent() {
if src_volume != super::super::RUSTFS_META_MULTIPART_BUCKET {
let _ = std::fs::remove_dir(src_file_path_parent);
} else {
let _ = self
.delete_file(&dst_volume_dir, &src_file_path_parent.to_path_buf(), true, false)
.await;
}
if !preserve_upload_metadata && let Some(parent) = src_file_path.parent() {
let _ = std::fs::remove_dir(parent);
}
// Heal reuses a version's `data_dir` and lands the rebuilt shard on
@@ -1017,18 +1161,12 @@ impl LocalDisk {
// allowing a retry to reuse the deterministic staging path too early.
let src = src_file_path.clone();
let dst = dst_file_path.clone();
let cleanup_path = if src_volume == super::super::RUSTFS_META_MULTIPART_BUCKET {
src_file_path.parent().map(|p| p.to_path_buf())
} else {
None
};
let dst_path_for_failpoint = dst_path.to_string();
let dst_volume_for_source = dst_volume.to_string();
let dst_path_for_source = dst_path.to_string();
let dst_volume_dir_for_commit = dst_volume_dir.clone();
let publication_root_for_commit = self.publication_root.clone();
#[cfg(not(windows))]
let cleanup_src_parent = cleanup_path.is_none();
let cleanup_src_parent = !preserve_upload_metadata;
#[cfg(windows)]
let source_parent = src_file_parent.to_path_buf();
let rename_commit_guard_for_preparation = commit.directory_guard.clone();
@@ -1228,13 +1366,9 @@ impl LocalDisk {
drop(file_sync_admission.take());
#[cfg(windows)]
drop(commit.directory_guard);
if let Some(ref cleanup) = cleanup_path {
let _ = self.delete_file(&dst_volume_dir, cleanup, true, false).await;
} else {
#[cfg(windows)]
if let Some(parent) = src_file_path.parent() {
let _ = std::fs::remove_dir(parent);
}
#[cfg(windows)]
if cleanup_src_parent && let Some(parent) = src_file_path.parent() {
let _ = std::fs::remove_dir(parent);
}
for part_path in &invalidate_part_paths {
self.io_backend.invalidate_cached_fd(dst_volume, part_path).await;
@@ -1281,18 +1415,25 @@ impl LocalDisk {
let backup_path = dst_parent
.join(rollback_data_dir.to_string())
.join(STORAGE_FORMAT_FILE_BACKUP);
// rename_all acquires the backup path's namespace lease. Do not
// hold a disk admission while acquiring another namespace lock.
// Keep backup mutations serialized, but never wait for a second
// namespace while holding the volume READ: its owner can be
// queued behind a volume DELETE that is waiting for this commit.
drop(file_sync_admission.take());
if let Err(err) = os::rename_all_with_owner(
staged_backup,
&backup_path,
&dst_volume_dir,
commit.publication_root,
Some(commit.mutation_lease.clone()),
)
.await
{
let backup_result =
match os::try_acquire_namespace_mutation_lease_with_owner(&backup_path, commit.mutation_lease.clone()) {
Ok(lease) => {
os::rename_all_with_lease(
staged_backup,
&backup_path,
&dst_volume_dir,
commit.publication_root,
lease,
)
.await
}
Err(err) => Err(err),
};
if let Err(err) = backup_result {
let _ = remove_file_if_exists(staged_backup);
return Err(err);
}
@@ -1452,9 +1593,7 @@ impl LocalDisk {
}
// Cleanup
if let Some(ref cleanup) = cleanup_path {
let _ = self.delete_file(&dst_volume_dir, cleanup, true, false).await;
} else if let Some(parent) = src_file_path.parent() {
if cleanup_src_parent && let Some(parent) = src_file_path.parent() {
let _ = std::fs::remove_dir(parent);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,202 @@
// Copyright 2026 RustFS Team
// Licensed under the Apache License, Version 2.0.
use super::*;
use std::cell::RefCell;
struct Observation {
rebuilds: usize,
normalizations: usize,
before_recheck: Option<Box<dyn FnOnce()>>,
}
thread_local! {
static OBSERVATION: RefCell<Option<Observation>> = const { RefCell::new(None) };
}
struct Observe;
impl Observe {
fn new(before_recheck: Option<Box<dyn FnOnce()>>) -> Self {
OBSERVATION.with(|state| {
assert!(state.borrow().is_none(), "read-path observations must not nest");
*state.borrow_mut() = Some(Observation {
rebuilds: 0,
normalizations: 0,
before_recheck,
});
});
Self
}
fn normalizations(&self) -> usize {
OBSERVATION.with(|state| state.borrow().as_ref().expect("active observation").normalizations)
}
fn rebuilds(&self) -> usize {
OBSERVATION.with(|state| state.borrow().as_ref().expect("active observation").rebuilds)
}
}
impl Drop for Observe {
fn drop(&mut self) {
OBSERVATION.with(|state| *state.borrow_mut() = None);
}
}
pub(super) fn record_bucket_rebuild() {
OBSERVATION.with(|state| {
if let Some(observation) = state.borrow_mut().as_mut() {
observation.rebuilds += 1;
}
});
}
pub(super) fn record_normalization() {
OBSERVATION.with(|state| {
if let Some(observation) = state.borrow_mut().as_mut() {
observation.normalizations += 1;
}
});
}
pub(super) fn before_bucket_recheck() {
let hook = OBSERVATION.with(|state| state.borrow_mut().as_mut().and_then(|value| value.before_recheck.take()));
if let Some(hook) = hook {
hook();
}
}
async fn local_disk(root: &Path) -> LocalDisk {
let endpoint = Endpoint::try_from(root.to_string_lossy().as_ref()).expect("local endpoint");
let disk = LocalDisk::new(&endpoint, false).await.expect("local disk");
disk.make_volume("bucket").await.expect("bucket");
disk
}
#[tokio::test(flavor = "current_thread")]
async fn checked_read_paths_reuse_bucket_construction() {
let root = tempfile::tempdir().expect("disk root");
let disk = local_disk(root.path()).await;
std::fs::create_dir_all(root.path().join("bucket/prefix/object")).expect("object directories");
for key in ["prefix/object/xl.meta", "", "prefix/../object/xl.meta"] {
let expected = build_local_disk_object_path(disk.io_root(), "bucket", key);
let observation = Observe::new(None);
let actual = disk.io_get_checked_read_paths("bucket", key).expect("checked read paths");
assert_eq!(actual, expected, "returned paths must retain their existing spelling");
assert_eq!(observation.rebuilds(), 0, "the validated tuple already owns its bucket path");
// path_join_buf already resolves dot components in object keys.
let expected_normalizations = if cfg!(windows) { 3 } else { 0 };
assert_eq!(observation.normalizations(), expected_normalizations);
#[cfg(not(target_os = "linux"))]
{
assert_eq!(
disk.io_get_bucket_and_object_open_path("bucket", key)
.expect("read-version paths"),
expected
);
assert_eq!(observation.rebuilds(), 0, "read_version shares the same checked path core");
assert_eq!(observation.normalizations(), 2 * expected_normalizations);
}
}
}
#[tokio::test(flavor = "current_thread")]
async fn checked_read_paths_preserve_containment_errors() {
let root = tempfile::tempdir().expect("disk root");
let disk = local_disk(root.path()).await;
for (bucket, key) in [("../outside", "object"), ("bucket", "../outside"), ("bucket", "/outside")] {
let old = disk
.io_get_object_path(bucket, key)
.and_then(|path| disk.io_get_bucket_path(bucket).map(|volume| (volume, path)));
let new = disk.io_get_checked_read_paths(bucket, key);
assert_eq!(new, old, "path and error behavior for {bucket}/{key}");
}
}
#[tokio::test(flavor = "current_thread")]
async fn checked_read_paths_metadata_bytes_and_absence_errors() {
let root = tempfile::tempdir().expect("disk root");
let disk = local_disk(root.path()).await;
let object = root.path().join("bucket/prefix/object");
std::fs::create_dir_all(&object).expect("object");
let metadata = b"opaque metadata bytes passed through the read API";
std::fs::write(object.join(STORAGE_FORMAT_FILE), metadata).expect("metadata");
{
let observation = Observe::new(None);
let actual = disk
.read_metadata("bucket", "prefix/object/xl.meta")
.await
.expect("read metadata");
assert_eq!(actual.as_ref(), metadata);
assert_eq!(observation.rebuilds(), 0, "scanner metadata reads use the shared path core");
assert_eq!(observation.normalizations(), if cfg!(windows) { 3 } else { 0 });
}
assert!(matches!(
disk.read_metadata("bucket", "missing/xl.meta").await,
Err(DiskError::FileNotFound)
));
assert!(matches!(
disk.read_metadata("missing", "object/xl.meta").await,
Err(DiskError::VolumeNotFound)
));
}
#[cfg(unix)]
#[tokio::test(flavor = "current_thread")]
async fn checked_read_paths_recheck_bucket_after_object_validation() {
let root = tempfile::tempdir().expect("disk root");
let outside = tempfile::tempdir().expect("outside root");
let disk = local_disk(root.path()).await;
std::fs::create_dir_all(root.path().join("bucket/object")).expect("object");
std::fs::write(root.path().join("bucket/object/xl.meta"), b"inside").expect("inside metadata");
let bucket = root.path().join("bucket");
let saved = root.path().join("saved");
let target = outside.path().to_path_buf();
let _observation = Observe::new(Some(Box::new(move || {
std::fs::rename(&bucket, saved).expect("replace bucket after full-path validation");
std::os::unix::fs::symlink(target, bucket).expect("bucket replacement symlink");
})));
assert!(
matches!(disk.io_get_checked_read_paths("bucket", "object/xl.meta"), Err(DiskError::InvalidPath)),
"the second bucket check must remain effective before queuing native IO"
);
}
#[test]
fn read_path_normalization_matches_component_normalizer() {
for value in [
"",
".",
"..",
"./a",
"a/./b",
"a//b/",
"a/../b",
"../../a",
"/",
"//a///b/",
"/a/../b",
"/../../a",
r"C:\a\b",
r"C:\a\..\b",
r"\\server\share\a",
r"\\?\C:\a\.\b",
] {
let path = Path::new(value);
let expected = normalize_path_components(path);
let observation = Observe::new(None);
let actual = normalize_path_cow(path);
assert_eq!(
actual.components().collect::<Vec<_>>(),
expected.components().collect::<Vec<_>>(),
"{value}"
);
let needs_buffer = cfg!(windows)
|| path
.components()
.any(|component| matches!(component, std::path::Component::ParentDir | std::path::Component::CurDir));
assert_eq!(matches!(actual, std::borrow::Cow::Owned(_)), needs_buffer, "{value}");
assert_eq!(observation.normalizations(), usize::from(needs_buffer), "{value}");
}
}
+163 -51
View File
@@ -27,12 +27,16 @@ mod io_admission;
#[cfg(test)]
pub(crate) mod lifecycle_test_io;
pub mod local;
pub use local::artifact_inventory::{ArtifactInventoryFrame, ArtifactInventoryVersion};
pub mod os;
#[cfg(test)]
pub(crate) use io_admission::DiskIoClass;
pub use io_admission::with_background_disk_io;
pub(crate) use io_admission::{acquire_context_disk_io_permit, current_disk_io_context, with_optional_disk_io_context};
pub(crate) use io_admission::{
acquire_context_disk_io_permit, current_disk_io_context, current_resource_unit, with_optional_disk_io_context,
with_optional_resource_unit,
};
pub use io_admission::{with_background_disk_io, with_background_resource_unit};
pub const RUSTFS_META_BUCKET: &str = ".rustfs.sys";
pub const MIGRATING_META_BUCKET: &str = ".minio.sys";
@@ -46,6 +50,9 @@ pub const FORMAT_CONFIG_FILE: &str = "format.json";
pub const HEALING_MARKER_PATH: &str = "healing.bin";
pub const STORAGE_FORMAT_FILE: &str = "xl.meta";
pub const STORAGE_FORMAT_FILE_BACKUP: &str = "xl.meta.bkp";
/// Original upload authority after it stops admitting active part mutations.
pub(crate) const MULTIPART_RECOVERY_DIR: &str = ".abort-authority";
pub(crate) const MULTIPART_COMMIT_METADATA_FILE: &str = "xl.meta.complete";
pub const PART_TRANSACTION_NEW_META: &str = "new.meta";
pub const PART_TRANSACTION_OLD_META: &str = "old.meta";
pub const PART_TRANSACTION_ROLLBACK: &str = "rollback";
@@ -67,6 +74,10 @@ use crate::cluster::rpc::RemoteDisk;
use crate::cluster::rpc::build_internode_data_transport_from_env;
use crate::disk::disk_store::DiskStoreRenameDataExt;
use crate::disk::disk_store::LocalDiskWrapper;
pub use crate::disk::disk_store::{
LegacyFileStream, LegacyFileWrite, LegacyMetadataWrite, LegacyPartMutation, LegacyPartOperation, LegacyPathDelete,
LegacyPathDeleteOperation, LegacyRenameData, LegacyRenameFile,
};
use crate::disk::health_state::RuntimeDriveHealthState;
use crate::disk::local::ScanGuard;
use bytes::Bytes;
@@ -79,12 +90,7 @@ use rustfs_filemeta::{FileInfo, ObjectPartInfo, RawFileInfo};
use rustfs_madmin::info_commands::DiskMetrics;
use rustfs_rio::ChunkReaderBox;
use serde::{Deserialize, Serialize};
use std::{
fmt::Debug,
path::PathBuf,
sync::{Arc, atomic::AtomicBool},
time::Duration,
};
use std::{fmt::Debug, path::PathBuf, sync::Arc, time::Duration};
use time::OffsetDateTime;
use tokio::io::{AsyncRead, AsyncWrite};
use uuid::Uuid;
@@ -92,6 +98,7 @@ use uuid::Uuid;
/// Independent admission and physical ownership for one disk rename.
#[derive(Default)]
pub(crate) struct RenameDataGuards {
pub(crate) artifact_put: Option<crate::bucket::artifact::ArtifactPutIdentity>,
pub(crate) scanner_publication_lease_token: Option<Uuid>,
pub(crate) external_guard: Option<Arc<dyn Send + Sync>>,
pub(crate) namespace_owner: Option<Arc<dyn Send + Sync>>,
@@ -233,6 +240,117 @@ pub enum Disk {
}
impl Disk {
/// Capture local file-write admission before authoritative classification.
pub async fn begin_legacy_file_write(&self, volume: &str) -> Result<LegacyFileWrite> {
match self {
Self::Local(disk) => disk.begin_legacy_file_write(volume).await,
Self::Remote(_) => Err(DiskError::DiskNotFound),
}
}
pub(crate) async fn begin_legacy_file_write_with_namespace_owner(
&self,
volume: &str,
owner: Option<Arc<dyn Send + Sync>>,
) -> Result<LegacyFileWrite> {
match self {
Self::Local(disk) => disk.begin_legacy_file_write_with_namespace_owner(volume, owner).await,
Self::Remote(_) => Err(DiskError::DiskNotFound),
}
}
/// Capture metadata publication admission before authoritative classification.
pub async fn begin_legacy_metadata_write(&self, volume: &str, path: &str) -> Result<LegacyMetadataWrite> {
match self {
Self::Local(disk) => disk.begin_legacy_metadata_write(volume, path).await,
Self::Remote(_) => Err(DiskError::DiskNotFound),
}
}
pub(crate) async fn begin_legacy_metadata_write_with_namespace_owner(
&self,
volume: &str,
path: &str,
namespace_owner: Option<Arc<dyn Send + Sync>>,
) -> Result<LegacyMetadataWrite> {
match self {
Self::Local(disk) => {
disk.begin_legacy_metadata_write_with_namespace_owner(volume, path, namespace_owner)
.await
}
Self::Remote(_) => Err(DiskError::DiskNotFound),
}
}
pub(crate) fn record_legacy_admission_failure(
&self,
operation: &'static str,
error: &DiskError,
started: std::time::Instant,
) {
if let Self::Local(disk) = self {
disk.record_legacy_admission_failure(operation, error, started);
}
}
pub(crate) async fn begin_legacy_path_delete(
&self,
volume: &str,
operation: LegacyPathDeleteOperation,
owner: Option<Arc<dyn Send + Sync>>,
deadline: tokio::time::Instant,
) -> Result<LegacyPathDelete> {
match self {
Self::Local(disk) => disk.begin_legacy_path_delete(volume, operation, owner, deadline).await,
Self::Remote(_) => Err(DiskError::DiskNotFound),
}
}
pub(crate) async fn begin_legacy_part_mutation(
&self,
source: Option<(&str, &str)>,
destination: (&str, &str),
namespace_owner: Option<Arc<dyn Send + Sync>>,
operation: LegacyPartOperation,
) -> Result<LegacyPartMutation> {
match self {
Self::Local(disk) => {
disk.begin_legacy_part_mutation(source, destination, namespace_owner, operation)
.await
}
Self::Remote(_) => Err(DiskError::DiskNotFound),
}
}
pub(crate) async fn begin_legacy_rename_file(
&self,
source: (&str, &str),
destination: (&str, &str),
durable: bool,
namespace_owner: Option<Arc<dyn Send + Sync>>,
) -> Result<LegacyRenameFile> {
match self {
Self::Local(disk) => {
disk.begin_legacy_rename_file(source, destination, durable, namespace_owner)
.await
}
Self::Remote(_) => Err(DiskError::DiskNotFound),
}
}
pub(crate) async fn begin_legacy_rename_data(
&self,
source: (&str, &str),
fi: &FileInfo,
destination: (&str, &str),
namespace_owner: Option<Arc<dyn Send + Sync>>,
) -> Result<LegacyRenameData> {
match self {
Self::Local(disk) => disk.begin_legacy_rename_data(source, fi, destination, namespace_owner).await,
Self::Remote(_) => Err(DiskError::DiskNotFound),
}
}
/// A read-only probe; the physical delete repeats the same leaf checks.
pub async fn is_plain_orphan_data_dir(&self, volume: &str, path: &str) -> Result<bool> {
match self {
@@ -1056,13 +1174,27 @@ impl Disk {
dst_volume: &str,
dst_path: &str,
) -> Result<RenameDataResp> {
let Some(scope) = crate::store::bucket_heal_scope_for_object(dst_volume, dst_path) else {
return self
.rename_data_borrowed_with_fence(src_volume, src_path, fi, dst_volume, dst_path, None)
.await;
let bucket_scope = crate::store::bucket_heal_scope_for_object(dst_volume, dst_path);
let repair_scope = crate::bucket::artifact::artifact_repair_scope(dst_volume, dst_path);
if let Some(scope) = &bucket_scope {
scope.check()?;
}
if let Some(scope) = &repair_scope {
scope.check().map_err(DiskError::other)?;
}
let repair_lease = if let (Self::Local(disk), Some(scope)) = (self, &repair_scope) {
if let Some(identity) = scope.receiver_identity() {
scope.check_version(fi.version_id).await.map_err(DiskError::other)?;
Some(disk.acquire_artifact_repair_lease(identity.receiver_key).await?)
} else {
None
}
} else {
None
};
scope.check()?;
self.rename_data_borrowed_with_fence_and_guard(src_volume, src_path, fi, dst_volume, dst_path, None, Some(scope))
let owner: Option<Arc<dyn Send + Sync>> = (bucket_scope.is_some() || repair_scope.is_some())
.then(|| Arc::new((bucket_scope, repair_scope, repair_lease)) as Arc<dyn Send + Sync>);
self.rename_data_borrowed_with_fence_and_guard(src_volume, src_path, fi, dst_volume, dst_path, None, owner)
.await
}
@@ -1073,10 +1205,20 @@ impl Disk {
fi: &FileInfo,
dst_volume: &str,
dst_path: &str,
guards: RenameDataGuards,
mut guards: RenameDataGuards,
) -> RenameDataObservation {
match self {
Disk::Local(local_disk) => {
if let Some(identity) = guards.artifact_put.take() {
let lease = match local_disk.acquire_artifact_put_lease(identity.receiver_key).await {
Ok(lease) => lease,
Err(error) => return RenameDataObservation::unknown(Err(error)),
};
// Keep the existing timeout policy; physical workers own the
// receiver lease even after the caller's disk deadline.
guards.namespace_owner =
Some(Arc::new((guards.external_guard.clone(), guards.namespace_owner.take(), lease)));
}
local_disk
.rename_data_observed_with_guards(src_volume, src_path, fi, dst_volume, dst_path, guards)
.await
@@ -1084,39 +1226,17 @@ impl Disk {
Disk::Remote(remote_disk) => RenameDataObservation::unknown(
remote_disk
.rename_data_borrowed_with_fence(
src_volume,
src_path,
(src_volume, src_path),
fi,
dst_volume,
dst_path,
(dst_volume, dst_path),
guards.scanner_publication_lease_token,
guards.artifact_put.map(|identity| identity.incarnation),
)
.await,
),
}
}
pub(crate) async fn rename_data_borrowed_with_fence(
&self,
src_volume: &str,
src_path: &str,
fi: &FileInfo,
dst_volume: &str,
dst_path: &str,
scanner_publication_lease_token: Option<Uuid>,
) -> Result<RenameDataResp> {
self.rename_data_borrowed_with_fence_and_guard(
src_volume,
src_path,
fi,
dst_volume,
dst_path,
scanner_publication_lease_token,
None,
)
.await
}
#[allow(clippy::too_many_arguments)]
pub async fn rename_data_borrowed_with_fence_and_guard(
&self,
@@ -1137,12 +1257,11 @@ impl Disk {
Disk::Remote(remote_disk) => {
remote_disk
.rename_data_borrowed_with_fence(
src_volume,
src_path,
(src_volume, src_path),
fi,
dst_volume,
dst_path,
(dst_volume, dst_path),
scanner_publication_lease_token,
None,
)
.await
}
@@ -1907,12 +2026,6 @@ pub struct WalkDirOptions {
// Override the remote stream stall timeout for long background walks.
#[serde(default)]
pub stall_timeout_ms: Option<u64>,
/// In-process completion state for bounded local walks. This is skipped
/// from RPC serialization; remote peers retain the legacy natural-EOF
/// behavior until they support an explicit capability.
#[serde(skip)]
pub producer_limit_reached: Option<Arc<AtomicBool>>,
}
impl WalkDirOptions {
@@ -2757,7 +2870,6 @@ mod tests {
skip_total_timeout: false,
timeout_ms: Some(10_000),
stall_timeout_ms: Some(20_000),
producer_limit_reached: None,
};
assert_eq!(opts.bucket, "test-bucket");
File diff suppressed because it is too large Load Diff
@@ -153,10 +153,13 @@ async fn refresh_loss_lock_client_keeps_rejection_observable_for_late_waiters()
);
}
/// Returns the backing [`tempfile::TempDir`]s alongside the set so callers keep
/// them alive for the test's duration and the directories are removed on drop.
pub(crate) async fn make_local_set_disks(drive_count: usize, parity_count: usize) -> (Vec<tempfile::TempDir>, Arc<SetDisks>) {
make_local_set_disks_with_ctx(drive_count, parity_count, crate::runtime::instance::bootstrap_ctx()).await
/// Keep the real metadata owner and backing directories alive together.
pub(crate) async fn make_local_set_disks(
drive_count: usize,
parity_count: usize,
) -> (crate::set_disk::HermeticSetFixture, Arc<SetDisks>) {
let (fixture, _, set) = crate::set_disk::hermetic_set_disks_for_pool_with_default_parity(drive_count, 0, parity_count).await;
(fixture, set)
}
/// Like [`make_local_set_disks`], but binds the set to an explicit instance
@@ -756,6 +759,9 @@ async fn blackbox_range_read_restores_exact_slice_with_one_offline_disk() {
#[tokio::test]
async fn blackbox_delete_marker_hides_object_body_without_erasing_prior_version_metadata() {
let (_dirs, set_disks) = make_local_set_disks(4, 2).await;
let store = crate::bucket::metadata_sys::object_store_in(set_disks.instance_ctx())
.await
.expect("fixture metadata authority should belong to this set");
let bucket = "bb-delete-marker-read-negative";
let object = "object.bin";
let opts = ObjectOptions {
@@ -765,13 +771,26 @@ async fn blackbox_delete_marker_hides_object_body_without_erasing_prior_version_
..Default::default()
};
set_disks
store
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("bucket should be created");
let mut reader = PutObjReader::from_vec(b"body hidden by delete marker".to_vec());
set_disks
.put_object(bucket, object, &mut reader, &opts)
.put_object(
bucket,
object,
&mut reader,
&ObjectOptions {
object_lock_config_snapshot: Some(
store
.object_lock_config_snapshot(bucket)
.await
.expect("PUT fixture should own the bucket lifecycle and Object Lock snapshot"),
),
..opts.clone()
},
)
.await
.expect("object should be written before delete marker");
+2 -2
View File
@@ -55,8 +55,8 @@ mod set_disk;
mod storage_api_contracts;
mod store;
pub use disk::with_background_disk_io;
pub use store::PoolMetaWriteGateStatus;
pub use disk::{with_background_disk_io, with_background_resource_unit};
pub use store::{BucketIncarnationFenceGuard, PoolMetaWriteGateStatus};
// pub mod checksum;
mod event;
+4
View File
@@ -4473,3 +4473,7 @@ mod tests {
.expect_err("a lying frame-layout marker must fail closed, not serve plaintext");
}
}
#[cfg(test)]
#[path = "readers/compression_index_compatibility_tests.rs"]
mod compression_index_compatibility_tests;
@@ -0,0 +1,185 @@
use super::*;
use rustfs_utils::http::metadata_compat::{SUFFIX_ACTUAL_SIZE, SUFFIX_COMPRESSION, insert_str};
use sha2::{Digest, Sha256};
use std::io::Cursor;
use tokio::io::AsyncReadExt;
const STREAM: &[u8] = include_bytes!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/compression-index/legacy-lz4-stream.bin"
));
const HISTORICAL_INDEX: &[u8] = include_bytes!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/compression-index/pre-zigzag-index.bin"
));
#[cfg(not(feature = "rio-v2"))]
const CURRENT_INDEX: &[u8] =
include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/compression-index/zigzag-index.bin"));
const PLAINTEXT_LEN: usize = 3 * 1024 * 1024 + 123;
const PLAINTEXT_SHA256: &str = "68f9edf6f9600395d6d2b5517d97570fecd2a0c6dbe6130617ef415a3b48d89c";
fn fixture_info(index: &[u8]) -> ObjectInfo {
let mut metadata = HashMap::new();
insert_str(&mut metadata, SUFFIX_COMPRESSION, "lz4".to_owned());
insert_str(&mut metadata, SUFFIX_ACTUAL_SIZE, PLAINTEXT_LEN.to_string());
ObjectInfo {
size: i64::try_from(STREAM.len()).expect("fixed fixture size fits i64"),
parts: Arc::new(vec![ObjectPartInfo {
number: 1,
size: STREAM.len(),
actual_size: i64::try_from(PLAINTEXT_LEN).expect("fixed plaintext length fits i64"),
index: Some(Bytes::copy_from_slice(index)),
..Default::default()
}]),
user_defined: Arc::new(metadata),
..Default::default()
}
}
fn fixture_plaintext() -> Vec<u8> {
(0..PLAINTEXT_LEN)
.map(|offset| {
let chunk = offset / 1024;
let lane = offset % 1024;
if lane < 8 {
u64::try_from(chunk).expect("fixed chunk number fits u64").to_le_bytes()[lane]
} else {
b"abcdefghijklmnopqrstuvwxyz"[(offset + chunk) % 26]
}
})
.collect()
}
#[tokio::test]
async fn historical_index_preserves_the_complete_compressed_body() {
let info = fixture_info(HISTORICAL_INDEX);
let (mut reader, storage_offset, _) = GetObjectReader::new(
Box::new(Cursor::new(STREAM.to_vec())),
None,
&info,
&ObjectOptions::default(),
&HeaderMap::new(),
)
.await
.expect("construct reader for fixed historical index");
let mut actual = Vec::new();
reader.read_to_end(&mut actual).await.expect("read complete historical body");
assert_eq!(storage_offset, 0);
assert_eq!(actual.len(), PLAINTEXT_LEN);
assert_eq!(
hex_simd::encode_to_string(Sha256::digest(&actual), hex_simd::AsciiCase::Lower),
PLAINTEXT_SHA256
);
}
#[tokio::test]
async fn historical_index_preserves_ranges_across_compression_blocks() {
let info = fixture_info(HISTORICAL_INDEX);
let expected = fixture_plaintext();
for start in [7, 1024 * 1024 - 17, 2 * 1024 * 1024 + 31, PLAINTEXT_LEN - 97] {
let range = HTTPRangeSpec {
is_suffix_length: false,
start: i64::try_from(start).expect("fixed range start fits i64"),
end: i64::try_from(start + 63).expect("fixed range end fits i64"),
};
let plan = ReadPlan::build(Some(range.clone()), &info, &ObjectOptions::default(), &HeaderMap::new())
.await
.expect("plan historical compressed range");
let physical = plan.storage_offset;
let input = STREAM.get(physical..).expect("planned offset stays within the fixed stream");
let (mut reader, _, _) = GetObjectReader::new(
Box::new(Cursor::new(input.to_vec())),
Some(range),
&info,
&ObjectOptions::default(),
&HeaderMap::new(),
)
.await
.expect("construct historical range reader");
let mut actual = Vec::new();
reader.read_to_end(&mut actual).await.expect("read historical range");
assert_eq!(actual, expected[start..start + 64], "range starting at {start}");
}
}
#[cfg(not(feature = "rio-v2"))]
#[tokio::test]
async fn current_index_selects_a_fixed_compressed_block_for_a_range() {
let info = fixture_info(CURRENT_INDEX);
let start = 2 * 1024 * 1024 + 31;
let range = HTTPRangeSpec {
is_suffix_length: false,
start: i64::try_from(start).expect("fixed range start fits i64"),
end: i64::try_from(start + 63).expect("fixed range end fits i64"),
};
let plan = ReadPlan::build(Some(range.clone()), &info, &ObjectOptions::default(), &HeaderMap::new())
.await
.expect("plan range with fixed current index");
assert_eq!(plan.storage_offset, 34_663, "use the recorded second block boundary");
let physical = plan.storage_offset;
let (mut reader, _, _) = GetObjectReader::new(
Box::new(Cursor::new(STREAM[physical..].to_vec())),
Some(range),
&info,
&ObjectOptions::default(),
&HeaderMap::new(),
)
.await
.expect("construct indexed range reader");
let mut actual = Vec::new();
reader.read_to_end(&mut actual).await.expect("read indexed range");
assert_eq!(actual, fixture_plaintext()[start..start + 64]);
}
#[tokio::test]
async fn truncated_index_falls_back_without_truncating_the_range() {
let info = fixture_info(&HISTORICAL_INDEX[..HISTORICAL_INDEX.len() - 3]);
let start = 2 * 1024 * 1024 + 31;
let range = HTTPRangeSpec {
is_suffix_length: false,
start: i64::try_from(start).expect("fixed range start fits i64"),
end: i64::try_from(start + 96).expect("fixed range end fits i64"),
};
let plan = ReadPlan::build(Some(range.clone()), &info, &ObjectOptions::default(), &HeaderMap::new())
.await
.expect("invalid index still permits a sequential range plan");
assert_eq!(plan.storage_offset, 0, "truncated index must not select a storage offset");
let (mut reader, _, _) = GetObjectReader::new(
Box::new(Cursor::new(STREAM.to_vec())),
Some(range),
&info,
&ObjectOptions::default(),
&HeaderMap::new(),
)
.await
.expect("construct sequential fallback reader");
let mut actual = Vec::new();
reader
.read_to_end(&mut actual)
.await
.expect("read range without the truncated index");
assert_eq!(actual, fixture_plaintext()[start..start + 97]);
}
#[tokio::test]
async fn historical_index_fallback_does_not_hide_a_body_checksum_failure() {
let info = fixture_info(HISTORICAL_INDEX);
let mut corrupted = STREAM.to_vec();
corrupted[4] ^= 1; // Corrupt the first block's stored CRC, leaving its payload intact.
let (mut reader, _, _) = GetObjectReader::new(
Box::new(Cursor::new(corrupted)),
None,
&info,
&ObjectOptions::default(),
&HeaderMap::new(),
)
.await
.expect("construct reader before body checksum verification");
let mut actual = Vec::new();
let error = reader
.read_to_end(&mut actual)
.await
.expect_err("bad body CRC cannot become successful EOF");
assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
assert!(actual.len() < PLAINTEXT_LEN);
}
+50 -2
View File
@@ -152,7 +152,7 @@ pub struct ObjectLockConfigSnapshot {
config_revision: Option<OffsetDateTime>,
state: crate::bucket::metadata_sys::ObjectLockConfigState,
lifecycle_fence: NamespaceLockFence,
_lifecycle_guard: Option<rustfs_lock::NamespaceLockGuard>,
_lifecycle_guard: Option<Arc<rustfs_lock::NamespaceLockGuard>>,
metadata_transaction_guard: Option<Arc<rustfs_lock::NamespaceLockGuard>>,
}
@@ -210,11 +210,36 @@ impl ObjectLockConfigSnapshot {
config_revision: Some(config_revision),
state,
lifecycle_fence,
_lifecycle_guard: Some(lifecycle_guard),
_lifecycle_guard: Some(Arc::new(lifecycle_guard)),
metadata_transaction_guard: Some(Arc::new(metadata_transaction_guard)),
}
}
pub(crate) fn for_store_bucket_under_lifecycle_owner(
store_id: Uuid,
bucket: &str,
bucket_incarnation_id: Uuid,
config_revision: OffsetDateTime,
state: crate::bucket::metadata_sys::ObjectLockConfigState,
lifecycle_guard: Arc<rustfs_lock::NamespaceLockGuard>,
metadata_transaction_guard: Arc<rustfs_lock::NamespaceLockGuard>,
) -> Self {
let mut lifecycle_fence = NamespaceLockFence::new();
if let Some(signal) = lifecycle_guard.lock_lost_signal() {
lifecycle_fence.add_signal(signal);
}
Self {
store_id: Some(store_id),
bucket: Some(bucket.to_owned()),
bucket_incarnation_id: Some(bucket_incarnation_id),
config_revision: Some(config_revision),
state,
lifecycle_fence,
_lifecycle_guard: Some(lifecycle_guard),
metadata_transaction_guard: Some(metadata_transaction_guard),
}
}
pub(crate) fn for_store_bucket_under_lifecycle_fence(
store_id: Uuid,
bucket: &str,
@@ -254,6 +279,15 @@ impl ObjectLockConfigSnapshot {
&self.state
}
pub(crate) fn is_unbound(&self) -> bool {
self.store_id.is_none()
&& self.bucket.is_none()
&& self.bucket_incarnation_id.is_none()
&& self.config_revision.is_none()
&& self._lifecycle_guard.is_none()
&& self.metadata_transaction_guard.is_none()
}
pub(crate) fn is_valid_for_destructive_put(&self, store_id: Uuid, bucket: &str, bucket_incarnation_id: Uuid) -> bool {
self.store_id == Some(store_id)
&& self.bucket.as_deref() == Some(bucket)
@@ -266,6 +300,11 @@ impl ObjectLockConfigSnapshot {
.is_some_and(|guard| !guard.is_lock_lost())
}
pub(crate) fn owns_bucket_lifecycle_fence(&self, store_id: Uuid, bucket: &str, bucket_incarnation_id: Uuid) -> bool {
self.is_valid_for_destructive_put(store_id, bucket, bucket_incarnation_id)
&& self._lifecycle_guard.as_ref().is_some_and(|guard| !guard.is_lock_lost())
}
/// Share the held transaction lock without queuing another reader behind
/// a metadata writer that is itself waiting for this snapshot to drop.
pub(crate) fn metadata_transaction_guard_for(
@@ -962,6 +1001,12 @@ pub struct ObjectOptions {
/// A genuine target write guard; loss fences alone do not authorize a borrowed write.
#[doc(hidden)]
pub write_lock_context: Option<WriteLockContext>,
/// Ordinary ECStore PUT target bound to its bucket's immutable artifact namespace.
#[doc(hidden)]
pub artifact_put_target: Option<Arc<crate::bucket::artifact::ArtifactPutTarget>>,
/// Owned, non-serializable authority for one fully sealed managed version.
#[doc(hidden)]
pub artifact_delete_target: Option<Arc<crate::bucket::artifact::ArtifactDeleteTarget>>,
/// In-process delete ownership for late filesystem completion. Never
/// serialized or copied into object metadata.
#[doc(hidden)]
@@ -1041,6 +1086,9 @@ pub struct ObjectOptions {
/// Proves an upper layer holds the bucket lifecycle sentinel. A separate
/// fence avoids recursively acquiring the read lock behind a queued writer.
pub bucket_lifecycle_lock_fence: Option<NamespaceLockFence>,
/// Actual generation owner retained by storage commits and their physical tails.
#[doc(hidden)]
pub bucket_incarnation_fence: Option<Arc<crate::store::BucketIncarnationFenceGuard>>,
/// Retains the actual lifecycle guard for an owned multipart commit. A
/// lock-loss fence alone does not keep a cancelled caller's lock alive. The
/// owner also retains the continuous incarnation-validation coverage.
@@ -71,7 +71,7 @@ impl WriteCommitGuard {
/// Namespace locks key the disk identity of the name (see `new_ns_lock`),
/// so a guard proves every spelling that resolves to the same file.
fn matches(&self, bucket: &str, object: &str) -> bool {
pub(crate) fn matches(&self, bucket: &str, object: &str) -> bool {
self.bucket.as_ref() == bucket && self.object.as_ref() == object_name_disk_identity(object).as_ref()
}
}
+8
View File
@@ -814,6 +814,14 @@ impl InstanceContext {
self.tier_config_mgr.get_or_init(TierConfigMgr::new).clone()
}
#[cfg(test)]
pub(crate) fn set_test_tier_config_mgr(&self, manager: Arc<RwLock<TierConfigMgr>>) {
assert!(
self.tier_config_mgr.set(manager).is_ok(),
"test tier manager must be installed before use"
);
}
/// This instance's event notifier, materializing it on first access.
/// Shared for the lifetime of the instance.
pub fn event_notifier(&self) -> Arc<RwLock<EventNotifier>> {
+150 -4
View File
@@ -250,7 +250,7 @@ impl ECStore {
}
let bucket_incarnation_fence = match bucket_configs.bucket_incarnation_id {
Some(expected) => Some(self.acquire_bucket_incarnation_fence(&bucket, expected).await?),
Some(expected) => Some(Arc::new(self.acquire_bucket_incarnation_fence(&bucket, expected).await?)),
None => None,
};
@@ -533,9 +533,8 @@ impl ECStore {
&cleanup_transferred_tier_versions,
data_movement::SourceCleanupBucketFence {
expected_incarnation_id: bucket_configs.bucket_incarnation_id,
lifecycle_guard: bucket_incarnation_fence
.as_ref()
.and_then(|guard| guard.namespace_lock_guard()),
lifecycle_owner: bucket_incarnation_fence.as_ref(),
lifecycle_guard: None,
namespace_lock_lost_signal: lock_lost_signal.clone(),
..Default::default()
},
@@ -1729,6 +1728,8 @@ mod tests {
/// Both versions are on the target with their identities, the null
/// version's content and SHA-256 are intact, and the source is gone.
async fn assert_moved(&self) {
use crate::disk::DiskAPI as _;
let target = self.store.pools[1]
.get_disks_by_key(self.object)
.load_file_info_versions_exact(self.bucket, self.object)
@@ -1769,6 +1770,16 @@ mod tests {
self.source_versions().await.is_none(),
"the source entry must be removed only after both versions moved"
);
let source_disks = self.source_set.disk_inventory().await;
assert_eq!(source_disks.iter().flatten().count(), 2, "both source disks must be online");
for disk in source_disks.into_iter().flatten() {
assert!(
!tokio::fs::try_exists(disk.path().join(self.bucket).join(self.object))
.await
.expect("inspect the physical rebalance source path"),
"rebalance must remove source metadata and shards from every disk"
);
}
}
}
@@ -1782,6 +1793,141 @@ mod tests {
fixture.assert_moved().await;
}
/// An `ExpiredObjectAllVersions` expiry is a prefix delete that takes the
/// bucket lifecycle write lock, while the entry holds that lock for read
/// through its bucket incarnation fence. Applied inline, it waited out the
/// lock timeout, stalled every lifecycle read in the bucket behind it, and
/// failed the entry. The version moves instead and lifecycle expires it on
/// the target (rustfs/backlog#2873).
#[test]
#[serial_test::serial]
fn real_rebalance_moves_a_version_expired_by_a_delete_all_rule() {
run_large_stack_async_test("rebalance-delete-all-expiry", run_delete_all_expiry_case);
}
async fn run_delete_all_expiry_case() {
const REBALANCE_ID: &str = "rebalance-delete-all-expiry";
let (_temp_dirs, store, _peer) = crate::services::rebalance::test_two_pool_stores_with_isolated_node_contexts(None).await;
let bucket = "rebalance-delete-all";
let object = "expired.bin";
// An active run blocks the data-usage publication bucket creation needs.
Box::pin(store.make_bucket(
bucket,
&MakeBucketOptions {
versioning_enabled: true,
..Default::default()
},
))
.await
.expect("create bucket");
let incarnation = store.bucket_incarnation_id(bucket).await.expect("bucket incarnation");
let rebalance_meta = active_rebalance_meta(REBALANCE_ID);
rebalance_meta
.save(Arc::clone(&store.pools[0]))
.await
.expect("persist the active rebalance run");
*store.rebalance_meta.write().await = Some(rebalance_meta);
let source_set = store.pools[0].get_disks_by_key(object);
let payload = b"expired by an all-versions rule".to_vec();
let written = Box::pin(source_set.put_object(
bucket,
object,
&mut PutObjReader::from_vec(payload.clone()),
&ObjectOptions {
versioned: true,
mod_time: Some(OffsetDateTime::now_utc() - time::Duration::days(10)),
expected_bucket_incarnation_id: Some(incarnation),
..Default::default()
},
))
.await
.expect("seed the expired source version");
let lifecycle = BucketLifecycleConfiguration {
expiry_updated_at: None,
rules: vec![LifecycleRule {
status: ExpirationStatus::from_static(ExpirationStatus::ENABLED),
expiration: Some(LifecycleExpiration {
days: Some(1),
expired_object_all_versions: Some(true),
..Default::default()
}),
abort_incomplete_multipart_upload: None,
del_marker_expiration: None,
filter: None,
id: Some("delete-all".to_string()),
noncurrent_version_expiration: None,
noncurrent_version_transitions: None,
prefix: None,
transitions: None,
}],
};
let entry = metacache_entry_from_source(&source_set, bucket, object).await;
let lock_timeout = crate::set_disk::get_lock_acquire_timeout();
let started = std::time::Instant::now();
let outcome = tokio::time::timeout(
lock_timeout,
Box::pin(Arc::clone(&store).rebalance_entry(
RebalanceEntryTarget {
bucket: bucket.to_string(),
pool_index: 0,
},
entry,
Arc::clone(&source_set),
Arc::new(RebalanceBucketConfigs {
bucket_incarnation_id: Some(incarnation),
lifecycle_config: Some(lifecycle),
..Default::default()
}),
Arc::from(REBALANCE_ID),
CancellationToken::new(),
)),
)
.await
.expect("the entry must not wait out the bucket lifecycle lock timeout")
.expect("rebalance entry");
let elapsed = started.elapsed();
assert_eq!(outcome, RebalanceEntryOutcome::Completed);
assert!(
elapsed < lock_timeout / 2,
"the entry must finish well under the {lock_timeout:?} lock timeout, took {elapsed:?}"
);
assert!(
source_set
.load_file_info_versions_exact(bucket, object)
.await
.expect("read source versions")
.is_none(),
"the source entry must be removed once the version moved"
);
let target = store.pools[1]
.get_disks_by_key(object)
.load_file_info_versions_exact(bucket, object)
.await
.expect("read target versions")
.expect("the version must reach the target pool");
assert_eq!(target.versions.len(), 1, "{:?}", target.versions);
assert_eq!(target.versions[0].version_id, written.version_id);
let mut reader = store.pools[1]
.get_object_reader(
bucket,
object,
None,
HeaderMap::new(),
&ObjectOptions {
versioned: true,
version_id: written.version_id.map(|version| version.to_string()),
..Default::default()
},
)
.await
.expect("the moved version should be readable");
let mut body = Vec::new();
reader.stream.read_to_end(&mut body).await.expect("drain the moved version");
assert_eq!(body, payload);
}
/// Overwriting after versioning was enabled leaves a newer version above
/// the null version. The old `None` read selected that newer version, the
/// target accepted it as an equivalent copy, and cleanup then deleted the
@@ -88,6 +88,7 @@ pub async fn test_store_with_persisted_rebalance_meta(
pool_meta_save_gate: tokio::sync::Mutex::default(),
ctx,
bucket_fence_registry: std::sync::Arc::default(),
ordinary_classification: Default::default(),
});
(temp_dirs, store)
}
@@ -266,6 +267,7 @@ async fn test_pool_stores_with_contexts(
pool_meta_save_gate: tokio::sync::Mutex::new(pool_meta_write_state.independent_clone_for_test()),
ctx: store_ctx,
bucket_fence_registry: std::sync::Arc::default(),
ordinary_classification: Default::default(),
})
};
let store = make_store(ctx);
@@ -3415,6 +3415,7 @@ fn test_store_with_rebalance_meta(meta: RebalanceMeta) -> Arc<crate::store::ECSt
pool_meta_save_gate: tokio::sync::Mutex::default(),
ctx: crate::runtime::instance::bootstrap_ctx(),
bucket_fence_registry: std::sync::Arc::default(),
ordinary_classification: Default::default(),
})
}
@@ -641,7 +641,13 @@ impl SetDisks {
state = "agreed_entry",
"Rebalance listing agreed entry"
);
Box::pin(cb1(entry))
{
let future = cb1(entry);
Box::pin(async move {
future.await;
Ok(())
})
}
})),
partial: Some(Box::new(move |entries: MetaCacheEntries, _: &[Option<DiskError>]| {
let resolver = resolver.clone();
+134 -12
View File
@@ -3053,7 +3053,7 @@ impl RenameRollbackReceipt {
struct RenameRollbackOwnership {
receipt: Option<RenameRollbackReceipt>,
namespace_commit_guard: Option<Arc<crate::runtime::instance::NamespaceCommitGuard>>,
namespace_commit_guard: Option<Arc<dyn Send + Sync>>,
}
async fn inspect_incomplete_rename_rollback(
@@ -3169,7 +3169,7 @@ async fn rollback_failed_rename(
old_data_dir: rollback_dir,
..Default::default()
},
disk_namespace_commit_guard.map(|owner| owner as Arc<dyn Send + Sync>),
disk_namespace_commit_guard,
)
.await
});
@@ -3281,10 +3281,11 @@ async fn record_rename_rollback_outcomes(
/// quorum and optional scanner lease map together avoids widening either
/// fanout helper's argument list while preserving the fence semantics.
pub(in crate::set_disk) struct RenameDataFenceOptions<'a> {
artifact_put: Option<crate::bucket::artifact::ArtifactPutIdentity>,
write_quorum: usize,
scanner_publication_lease_tokens: Option<&'a HashMap<String, Uuid>>,
scanner_publication_commit_scope: Option<crate::object_api::ScannerPublicationCommitScope>,
namespace_commit_guard: Option<Arc<crate::runtime::instance::NamespaceCommitGuard>>,
namespace_commit_guard: Option<Arc<dyn Send + Sync>>,
rollback_receipt: Option<RenameRollbackReceipt>,
}
@@ -3294,6 +3295,7 @@ impl<'a> RenameDataFenceOptions<'a> {
scanner_publication_lease_tokens: Option<&'a HashMap<String, Uuid>>,
) -> Self {
Self {
artifact_put: None,
write_quorum,
scanner_publication_lease_tokens,
scanner_publication_commit_scope: None,
@@ -3302,6 +3304,14 @@ impl<'a> RenameDataFenceOptions<'a> {
}
}
pub(in crate::set_disk) fn with_artifact_put(
mut self,
identity: Option<crate::bucket::artifact::ArtifactPutIdentity>,
) -> Self {
self.artifact_put = identity;
self
}
pub(in crate::set_disk) fn with_rollback_receipt(mut self, receipt: RenameRollbackReceipt) -> Self {
self.rollback_receipt = Some(receipt);
self
@@ -3317,7 +3327,7 @@ impl<'a> RenameDataFenceOptions<'a> {
pub(in crate::set_disk) fn with_namespace_commit_guard(
mut self,
namespace_commit_guard: Option<Arc<crate::runtime::instance::NamespaceCommitGuard>>,
namespace_commit_guard: Option<Arc<dyn Send + Sync>>,
) -> Self {
self.namespace_commit_guard = namespace_commit_guard;
self
@@ -3770,6 +3780,7 @@ impl SetDisks {
fence_options: RenameDataFenceOptions<'_>,
) -> disk::error::Result<RenameDataCommit> {
let RenameDataFenceOptions {
artifact_put,
write_quorum,
scanner_publication_lease_tokens,
scanner_publication_commit_scope: _scanner_publication_commit_scope,
@@ -3864,11 +3875,10 @@ impl SetDisks {
&file_info,
&dst_bucket,
&dst_object,
disk::RenameDataGuards {
crate::disk::RenameDataGuards {
artifact_put,
scanner_publication_lease_token,
namespace_owner: namespace_commit_guard
.clone()
.map(|owner| owner as Arc<dyn Send + Sync>),
namespace_owner: namespace_commit_guard.clone(),
..Default::default()
},
)
@@ -4177,6 +4187,7 @@ impl SetDisks {
.await;
}
let RenameDataFenceOptions {
artifact_put,
write_quorum,
scanner_publication_lease_tokens,
scanner_publication_commit_scope,
@@ -4297,11 +4308,10 @@ impl SetDisks {
file_info,
&dst_bucket,
&dst_object,
disk::RenameDataGuards {
crate::disk::RenameDataGuards {
artifact_put,
scanner_publication_lease_token,
namespace_owner: namespace_commit_guard
.clone()
.map(|owner| owner as Arc<dyn Send + Sync>),
namespace_owner: namespace_commit_guard.clone(),
..Default::default()
},
)
@@ -12815,6 +12825,118 @@ mod tests {
assert!(missing.below_quorum, "missing disk slots should remain visible in the quorum lens");
}
#[cfg(not(windows))]
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn legacy_path_delete_flow_prefix_delete_keeps_scanner_owner_until_drain() {
use crate::disk::os::{drain_namespace_for_test, prepared_publication_test_hooks as hooks};
use crate::object_api::{
ScannerPublicationCommitScope, ScannerPublicationCommitScopeGuard, ScannerPublicationCommitState,
};
use crate::set_disk::ops::object::hermetic_set_disks_support::hermetic_set_disks_isolated;
use crate::storage_api_contracts::bucket::BucketOperations;
let (_dirs, disks, set) = hermetic_set_disks_isolated(4).await;
let bucket = "path-delete-scanner";
crate::bucket::metadata_sys::object_store_in(&set.ctx)
.await
.expect("fixture object store")
.make_bucket(bucket, &Default::default())
.await
.expect("authoritative bucket creation");
let mut paths = Vec::new();
for disk in &disks {
disk.write_all(bucket, "prefix/part.1", Bytes::from_static(b"old shard"))
.await
.expect("seed prefix on every disk");
paths.push(
disk.get_object_path_for_io_if_local(bucket, "prefix")
.expect("local disk")
.expect("prefix path"),
);
}
let movement = Arc::new(tokio::sync::RwLock::new(()));
let scope = ScannerPublicationCommitScope::new_storage_owned(
7,
tokio::time::Instant::now() + Duration::from_secs(30),
Vec::new(),
movement.clone().read_owned().await,
);
scope.try_begin().expect("begin scanner publication");
let caller_guard = ScannerPublicationCommitScopeGuard::new(scope.clone());
let (entered_tx, entered_rx) = tokio::sync::oneshot::channel();
let (release_tx, release_rx) = std::sync::mpsc::channel::<()>();
let _hook = hooks::install_at(hooks::Stage::Rename, &paths[0], move || {
let _ = entered_tx.send(());
let _ = release_rx.recv();
});
let owner_set = set.clone();
let owner_scope = scope.clone();
let caller = tokio::spawn(async move {
owner_set
.delete_prefix_with_scanner_publication_lease(bucket, "prefix", None, Some(owner_scope))
.await
});
let entered = tokio::time::timeout(Duration::from_secs(5), entered_rx).await;
caller.abort();
let _ = caller.await;
drop(caller_guard);
let state_while_blocked = scope.state();
let movement_while_blocked = movement.clone().try_write_owned().is_ok();
let disk::Disk::Local(local) = disks[0].as_ref() else {
unreachable!("fixture contains local disks");
};
let local = local.get_disk();
let volume_read_held = crate::disk::os::disk_volume_mutation_lock(&local.root, bucket)
.try_write_owned()
.is_err();
let mut deletion = Box::pin(local.delete_volume(bucket, true));
let early_deletion = futures::poll!(deletion.as_mut());
let deletion_was_blocked = early_deletion.is_pending();
// Always release and drain physical work before reporting an ownership failure.
let _ = release_tx.send(());
let mut drained = Vec::new();
for path in &paths {
drained.push(tokio::time::timeout(Duration::from_secs(5), drain_namespace_for_test(path)).await);
}
let deletion_result = match early_deletion {
std::task::Poll::Ready(result) => result,
std::task::Poll::Pending => tokio::time::timeout(Duration::from_secs(5), deletion)
.await
.expect("volume deletion drains"),
};
let terminal = tokio::time::timeout(Duration::from_secs(5), scope.wait_for_completion()).await;
assert!(
entered.is_ok_and(|result| result.is_ok()),
"real prefix deletion must reach its filesystem worker"
);
assert!(drained.into_iter().all(|result| result.is_ok()), "every physical namespace must drain");
deletion_result.expect("volume deletion after physical drain");
assert!(
volume_read_held && deletion_was_blocked,
"volume deletion must wait for the physical prefix worker"
);
assert_eq!(state_while_blocked, ScannerPublicationCommitState::InFlight);
assert!(!movement_while_blocked, "scanner movement remains fenced after caller cancellation");
assert_eq!(terminal.expect("scanner owner completion"), ScannerPublicationCommitState::Committed);
assert!(scope.release_movement_permit().await, "terminal scanner owner releases movement");
assert!(movement.try_write_owned().is_ok());
for path in paths.iter().skip(1) {
assert!(!path.exists(), "successful prefix fanout removes every surviving volume's prefix");
}
disks[0].make_volume(bucket).await.expect("recreate deleted volume");
disks[0]
.write_all(bucket, "prefix/sentinel", Bytes::from_static(b"new generation"))
.await
.expect("new generation sentinel");
assert_eq!(
disks[0]
.read_all(bucket, "prefix/sentinel")
.await
.expect("sentinel survives old deletion"),
b"new generation".as_slice()
);
}
#[tokio::test]
async fn delete_prefix_succeeds_when_present_disks_reach_quorum() {
let bucket = "delete-prefix-bucket";
@@ -1082,6 +1082,8 @@ impl SetDisks {
let response_start = observe.then(Instant::now);
let result = if let Some(disk) = disk {
Self::record_read_version_call(&object, disk_index);
#[cfg(test)]
Self::read_version_fanout_barrier(&object, disk_index).await;
if let Some(delay) = slowtail_fault.as_ref().and_then(|fault| fault.delay_for_disk(disk_index)) {
Self::record_metadata_slowtail_fault(&object, disk_index);
tokio::time::sleep(delay).await;
+392 -11
View File
@@ -906,6 +906,8 @@ pub(crate) use ops::object::body_cache_plaintext_len;
#[cfg(all(test, feature = "test-util"))]
pub(crate) use ops::object::cleanup_rejected_transition_upload_durably;
#[cfg(test)]
pub(crate) use ops::object::hermetic_set_disks_support::{HermeticSetFixture, hermetic_set_disks_for_pool_with_default_parity};
#[cfg(test)]
pub(crate) use ops::object::{DeleteCleanupBarrier, DeleteObjectCommitBarrier};
#[cfg(any(test, feature = "test-util"))]
pub use ops::object::{PutObjectCommitBarrier, PutObjectCommitPause};
@@ -1145,6 +1147,310 @@ mod prepared_get_object_metadata_tests {
use http::HeaderMap;
use tokio::io::AsyncReadExt;
const OBSERVATION_HOOK_BODY_LEN: usize = 1024 * 1024 + 1;
struct ObservationBodyHook {
object: &'static str,
calls: Arc<AtomicU64>,
body: bytes::Bytes,
}
#[async_trait::async_trait]
impl crate::object_api::GetObjectBodyCacheHook for ObservationBodyHook {
async fn lookup(&self, bucket: &str, object: &str, info: &ObjectInfo) -> Option<bytes::Bytes> {
if bucket == crate::disk::RUSTFS_META_BUCKET && object == self.object {
assert_eq!(info.size, i64::try_from(self.body.len()).expect("hook fixture size"));
self.calls.fetch_add(1, Ordering::SeqCst);
Some(self.body.clone())
} else {
None
}
}
}
struct ObservationHookGuard;
impl Drop for ObservationHookGuard {
fn drop(&mut self) {
crate::object_api::unregister_get_object_body_cache_hook();
}
}
async fn ordinary_observation_get(set: &SetDisks, object: &str) -> Vec<u8> {
let mut reader = set
.get_object_reader(crate::disk::RUSTFS_META_BUCKET, object, None, HeaderMap::new(), &ObjectOptions::default())
.await
.expect("ordinary GET positive control");
let mut bytes = Vec::new();
reader.stream.read_to_end(&mut bytes).await.expect("ordinary GET body");
bytes
}
fn observation_register_hook(object: &'static str, body: Vec<u8>) -> (ObservationHookGuard, Arc<AtomicU64>) {
let calls = Arc::new(AtomicU64::new(0));
crate::object_api::register_get_object_body_cache_hook(Arc::new(ObservationBodyHook {
object,
calls: calls.clone(),
body: bytes::Bytes::from(body),
}));
(ObservationHookGuard, calls)
}
#[tokio::test]
#[serial_test::serial(body_cache_hook)]
async fn locked_observation_uses_real_outer_guard() {
let object = "config/locked-observation-real.bin";
let payload = vec![b'p'; OBSERVATION_HOOK_BODY_LEN];
let (_dirs, set) = make_local_set_disks(4, 2).await;
let mut reader = PutObjReader::from_vec(payload.clone());
let written = set
.put_object(crate::disk::RUSTFS_META_BUCKET, object, &mut reader, &ObjectOptions::default())
.await
.expect("persist locked observation fixture");
let (_hook, calls) = observation_register_hook(object, vec![b'x'; payload.len()]);
assert_eq!(ordinary_observation_get(&set, object).await, vec![b'x'; payload.len()]);
assert_eq!(calls.swap(0, Ordering::SeqCst), 1);
let namespace = set
.new_ns_lock(crate::disk::RUSTFS_META_BUCKET, object)
.await
.expect("namespace");
let guard = crate::object_api::WriteCommitGuard::acquire(&namespace, std::time::Duration::from_secs(5))
.await
.expect("matching real owner");
let mut contender = Box::pin(crate::object_api::WriteCommitGuard::acquire(
&namespace,
std::time::Duration::from_secs(30),
));
assert!(futures::poll!(contender.as_mut()).is_pending(), "I06L_OWNER_RETAINED");
let result = tokio::time::timeout(
std::time::Duration::from_secs(10),
crate::config::com::read_config_observation_no_lock(set.clone(), object, payload.len(), &guard),
)
.await
.expect("locked observation hang watchdog");
assert!(result.is_ok(), "I06L_NO_NESTED_LOCK: {result:?}");
let (bytes, info) = result.expect("locked observation");
assert!(bytes == payload, "I06L_HOOK_BYPASS");
assert!(calls.load(Ordering::SeqCst) == 0, "I06L_HOOK_BYPASS");
assert_eq!(info.size, written.size);
assert_eq!(info.etag, written.etag);
assert_eq!(info.data_dir, written.data_dir);
assert_eq!(info.name, object);
assert!(futures::poll!(contender.as_mut()).is_pending(), "I06L_OWNER_RETAINED");
drop(guard);
let next = tokio::time::timeout(std::time::Duration::from_secs(10), contender)
.await
.expect("owner release watchdog")
.expect("competing owner acquired");
drop(next);
assert_eq!(ordinary_observation_get(&set, object).await, vec![b'x'; payload.len()]);
assert_eq!(calls.load(Ordering::SeqCst), 1);
}
#[tokio::test]
#[serial_test::serial(body_cache_hook)]
async fn locked_observation_preserves_empty_missing_and_bound() {
let (_dirs, set) = make_local_set_disks(4, 2).await;
for (object, body) in [
("config/locked-empty.bin", Vec::new()),
("config/locked-bound.bin", b"raw".to_vec()),
] {
let mut reader = PutObjReader::from_vec(body.clone());
let written = set
.put_object(crate::disk::RUSTFS_META_BUCKET, object, &mut reader, &ObjectOptions::default())
.await
.expect("persist boundary fixture");
let namespace = set
.new_ns_lock(crate::disk::RUSTFS_META_BUCKET, object)
.await
.expect("namespace");
let guard = crate::object_api::WriteCommitGuard::acquire(&namespace, std::time::Duration::from_secs(5))
.await
.expect("matching boundary owner");
let result = crate::config::com::read_config_observation_no_lock(set.clone(), object, body.len(), &guard).await;
assert!(result.is_ok(), "I06L_EMPTY_DISTINCT: {result:?}");
let (bytes, info) = result.expect("boundary observation");
assert_eq!(bytes, body);
assert_eq!(info.size, written.size);
if !body.is_empty() {
let result =
crate::config::com::read_config_observation_no_lock(set.clone(), object, body.len() - 1, &guard).await;
assert!(matches!(result, Err(Error::CorruptedFormat)), "I06L_BOUND_ENFORCED");
}
}
let object = "config/locked-absent.bin";
let namespace = set
.new_ns_lock(crate::disk::RUSTFS_META_BUCKET, object)
.await
.expect("namespace");
let guard = crate::object_api::WriteCommitGuard::acquire(&namespace, std::time::Duration::from_secs(5))
.await
.expect("matching missing owner");
let result = crate::config::com::read_config_observation_no_lock(set, object, 0, &guard).await;
assert!(matches!(result, Err(Error::ConfigNotFound)), "I06L_EMPTY_DISTINCT");
}
#[tokio::test]
#[serial_test::serial(body_cache_hook)]
async fn observation_real_backend_boundaries() {
let object = "config/observation-real.bin";
let payload = b"persisted config";
let (_dirs, set) = make_local_set_disks(4, 2).await;
let mut reader = PutObjReader::from_vec(payload.to_vec());
let written = set
.put_object(crate::disk::RUSTFS_META_BUCKET, object, &mut reader, &ObjectOptions::default())
.await
.expect("persist real config fixture");
let result = crate::config::com::read_config_observation(set.clone(), object, payload.len()).await;
assert!(result.is_ok(), "I05_OBSERVATION_SUCCESS: {result:?}");
let (bytes, info) = result.expect("real observation success");
assert_eq!(bytes, payload);
assert_eq!(info.size, written.size);
assert_eq!(info.etag, written.etag);
assert_eq!(info.data_dir, written.data_dir);
assert_eq!(info.name, object);
let over = crate::config::com::read_config_observation(set.clone(), object, payload.len() - 1).await;
assert!(over.is_err(), "I05_BOUND_ENFORCED");
assert_eq!(over.expect_err("over limit must fail"), Error::CorruptedFormat, "I05_BOUND_ENFORCED");
let absent = crate::config::com::read_config_observation(set.clone(), "config/observation-absent.bin", 32).await;
assert_eq!(absent.expect_err("absent config"), Error::ConfigNotFound);
let empty_object = "config/observation-empty.bin";
let mut empty = PutObjReader::from_vec(Vec::new());
set.put_object(crate::disk::RUSTFS_META_BUCKET, empty_object, &mut empty, &ObjectOptions::default())
.await
.expect("persist empty config");
let empty = crate::config::com::read_config_observation(set, empty_object, 0).await;
assert!(empty.is_ok(), "existing empty config must succeed: {empty:?}");
let (bytes, info) = empty.expect("empty observation");
assert!(bytes.is_empty());
assert_eq!(info.size, 0);
}
#[tokio::test]
#[serial_test::serial(body_cache_hook)]
async fn observation_bypasses_registered_body_hook() {
let object = "config/observation-hook.bin";
let (_dirs, set) = make_local_set_disks(4, 2).await;
let mut reader = PutObjReader::from_vec(vec![b'p'; OBSERVATION_HOOK_BODY_LEN]);
set.put_object(crate::disk::RUSTFS_META_BUCKET, object, &mut reader, &ObjectOptions::default())
.await
.expect("persist real config fixture");
let (_hook, calls) = observation_register_hook(object, vec![b'x'; OBSERVATION_HOOK_BODY_LEN]);
assert_eq!(ordinary_observation_get(&set, object).await, vec![b'x'; OBSERVATION_HOOK_BODY_LEN]);
assert_eq!(calls.swap(0, Ordering::SeqCst), 1);
let result = crate::config::com::read_config_observation(set, object, OBSERVATION_HOOK_BODY_LEN).await;
assert!(result.is_ok(), "I05_HOOK_BYPASS: {result:?}");
assert_eq!(
result.expect("observed persisted body").0,
vec![b'p'; OBSERVATION_HOOK_BODY_LEN],
"I05_HOOK_BYPASS"
);
assert_eq!(calls.load(Ordering::SeqCst), 0, "I05_HOOK_BYPASS");
}
#[tokio::test]
#[serial_test::serial(body_cache_hook)]
async fn observation_hook_scope_restores_after_error() {
let object = "config/observation-hook-error.bin";
let (_dirs, set) = make_local_set_disks(4, 2).await;
let mut reader = PutObjReader::from_vec(vec![b'p'; OBSERVATION_HOOK_BODY_LEN]);
set.put_object(crate::disk::RUSTFS_META_BUCKET, object, &mut reader, &ObjectOptions::default())
.await
.expect("persist real config fixture");
let (_hook, calls) = observation_register_hook(object, vec![b'x'; OBSERVATION_HOOK_BODY_LEN]);
let result = crate::config::com::read_config_observation(set.clone(), object, OBSERVATION_HOOK_BODY_LEN - 1).await;
assert_eq!(result.expect_err("bounded observation error"), Error::CorruptedFormat);
assert_eq!(calls.load(Ordering::SeqCst), 0);
assert_eq!(ordinary_observation_get(&set, object).await, vec![b'x'; OBSERVATION_HOOK_BODY_LEN]);
assert_eq!(calls.load(Ordering::SeqCst), 1);
}
struct PendingObservationStore {
set: Arc<SetDisks>,
ready: std::sync::Mutex<Option<tokio::sync::oneshot::Sender<()>>>,
}
impl std::fmt::Debug for PendingObservationStore {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PendingObservationStore").finish()
}
}
#[async_trait::async_trait]
impl crate::storage_api_contracts::object::ObjectIO for PendingObservationStore {
type Error = Error;
type RangeSpec = HTTPRangeSpec;
type HeaderMap = HeaderMap;
type ObjectOptions = ObjectOptions;
type ObjectInfo = ObjectInfo;
type GetObjectReader = GetObjectReader;
type PutObjectReader = PutObjReader;
async fn get_object_reader(
&self,
bucket: &str,
object: &str,
range: Option<HTTPRangeSpec>,
headers: HeaderMap,
opts: &ObjectOptions,
) -> Result<GetObjectReader> {
let mut reader = self.set.get_object_reader(bucket, object, range, headers, opts).await?;
let mut body = Vec::new();
reader.stream.read_to_end(&mut body).await.expect("pending producer real GET");
assert_eq!(
body,
vec![b'p'; OBSERVATION_HOOK_BODY_LEN],
"I05_HOOK_BYPASS: producer must enter its own scope"
);
self.ready
.lock()
.expect("pending signal")
.take()
.expect("one GET")
.send(())
.expect("producer entry signal");
std::future::pending::<Result<GetObjectReader>>().await
}
async fn put_object(
&self,
_bucket: &str,
_object: &str,
_data: &mut PutObjReader,
_opts: &ObjectOptions,
) -> Result<ObjectInfo> {
panic!("observation must not PUT");
}
}
#[tokio::test]
#[serial_test::serial(body_cache_hook)]
async fn observation_hook_scope_restores_after_cancel() {
let object = "config/observation-hook-cancel.bin";
let (_dirs, set) = make_local_set_disks(4, 2).await;
let mut reader = PutObjReader::from_vec(vec![b'p'; OBSERVATION_HOOK_BODY_LEN]);
set.put_object(crate::disk::RUSTFS_META_BUCKET, object, &mut reader, &ObjectOptions::default())
.await
.expect("persist real config fixture");
let (_hook, calls) = observation_register_hook(object, vec![b'x'; OBSERVATION_HOOK_BODY_LEN]);
assert_eq!(ordinary_observation_get(&set, object).await, vec![b'x'; OBSERVATION_HOOK_BODY_LEN]);
assert_eq!(calls.swap(0, Ordering::SeqCst), 1);
let (ready_tx, ready_rx) = tokio::sync::oneshot::channel();
let adapter = Arc::new(PendingObservationStore {
set: set.clone(),
ready: std::sync::Mutex::new(Some(ready_tx)),
});
let mut scope = Box::pin(crate::config::com::read_config_observation(adapter, object, OBSERVATION_HOOK_BODY_LEN));
tokio::select! {
result = ready_rx => { result.expect("producer reached pending GET"); }
result = &mut scope => { panic!("producer must remain pending until cancelled: {result:?}"); }
}
assert_eq!(calls.load(Ordering::SeqCst), 0);
drop(scope);
assert_eq!(ordinary_observation_get(&set, object).await, vec![b'x'; OBSERVATION_HOOK_BODY_LEN]);
assert_eq!(calls.load(Ordering::SeqCst), 1);
}
const READ_VERSION_BARRIER_GUARD: std::time::Duration = std::time::Duration::from_secs(10);
fn object_with_initial_data_shards(bucket: &str, prefix: &str) -> String {
@@ -12753,7 +13059,7 @@ mod tests {
);
}
#[tokio::test(start_paused = true)]
#[tokio::test]
async fn test_get_disks_info_probes_drives_concurrently() {
use crate::disk::disk_store::DISK_INFO_PROBE_DELAY_FOR_TEST;
@@ -12768,6 +13074,8 @@ mod tests {
disks.push(Some(disk));
}
// Complete real filesystem setup before measuring virtual probe delays.
tokio::time::pause();
let probe_delay = std::time::Duration::from_secs(2);
let started = tokio::time::Instant::now();
let info = DISK_INFO_PROBE_DELAY_FOR_TEST
@@ -16614,7 +16922,14 @@ mod tests {
#[tokio::test]
async fn set_level_versioned_delete_marker_hides_object_without_corrupting_version_metadata() {
let set_disks = make_local_bucket_test_set_disks_with_drive_count(4).await;
let (_fixture, _, set_disks) = hermetic_set_disks_for_pool_with_default_parity(4, 0, 2).await;
set_disks.set_test_storage_class_config(
storageclass::lookup_config_for_pools_without_env(&rustfs_config::server_config::KVS::new(), &[4])
.expect("test storage class should resolve for the local drive count"),
);
let store = crate::bucket::metadata_sys::object_store_in(&set_disks.ctx)
.await
.expect("fixture metadata authority should belong to this set");
let bucket = "bucket-versioned-delete";
let object = "object.txt";
let opts = ObjectOptions {
@@ -16623,13 +16938,26 @@ mod tests {
..Default::default()
};
set_disks
store
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("bucket should be created");
let mut reader = PutObjReader::from_vec(b"versioned object body".to_vec());
let written = set_disks
.put_object(bucket, object, &mut reader, &opts)
.put_object(
bucket,
object,
&mut reader,
&ObjectOptions {
object_lock_config_snapshot: Some(
store
.object_lock_config_snapshot(bucket)
.await
.expect("PUT fixture should own the bucket lifecycle and Object Lock snapshot"),
),
..opts.clone()
},
)
.await
.expect("versioned object should be written");
assert!(written.version_id.is_some());
@@ -16946,7 +17274,14 @@ mod tests {
/// `BucketNotEmpty` for a bucket the client has already emptied.
#[tokio::test]
async fn set_level_explicit_null_version_delete_purges_the_null_delete_marker() {
let set_disks = make_local_bucket_test_set_disks().await;
let (_fixture, _, set_disks) = hermetic_set_disks_for_pool_with_default_parity(2, 0, 1).await;
set_disks.set_test_storage_class_config(
storageclass::lookup_config_for_pools_without_env(&rustfs_config::server_config::KVS::new(), &[2])
.expect("test storage class should resolve for the local drive count"),
);
let store = crate::bucket::metadata_sys::object_store_in(&set_disks.ctx)
.await
.expect("fixture metadata authority should belong to this set");
let bucket = "bucket-null-marker-purge";
let object = "object.txt";
// The delete path reads versioned/suspended from the bucket-config
@@ -16968,13 +17303,26 @@ mod tests {
..Default::default()
};
set_disks
store
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("bucket should be created");
let mut reader = PutObjReader::from_vec(b"suspended version body".to_vec());
set_disks
.put_object(bucket, object, &mut reader, &opts)
.put_object(
bucket,
object,
&mut reader,
&ObjectOptions {
object_lock_config_snapshot: Some(
store
.object_lock_config_snapshot(bucket)
.await
.expect("PUT fixture should own the bucket lifecycle and Object Lock snapshot"),
),
..opts.clone()
},
)
.await
.expect("suspended-version object should be written");
@@ -16999,7 +17347,20 @@ mod tests {
let mut replacement_reader = PutObjReader::from_vec(b"replacement suspended version body".to_vec());
set_disks
.put_object(bucket, object, &mut replacement_reader, &opts)
.put_object(
bucket,
object,
&mut replacement_reader,
&ObjectOptions {
object_lock_config_snapshot: Some(
store
.object_lock_config_snapshot(bucket)
.await
.expect("PUT fixture should own the bucket lifecycle and Object Lock snapshot"),
),
..opts.clone()
},
)
.await
.expect("a suspended-version PUT should replace the first null marker");
let replacement_marker = set_disks
@@ -17059,7 +17420,14 @@ mod tests {
#[tokio::test]
async fn set_level_version_suspended_delete_creates_null_delete_marker() {
let set_disks = make_local_bucket_test_set_disks().await;
let (_fixture, _, set_disks) = hermetic_set_disks_for_pool_with_default_parity(2, 0, 1).await;
set_disks.set_test_storage_class_config(
storageclass::lookup_config_for_pools_without_env(&rustfs_config::server_config::KVS::new(), &[2])
.expect("test storage class should resolve for the local drive count"),
);
let store = crate::bucket::metadata_sys::object_store_in(&set_disks.ctx)
.await
.expect("fixture metadata authority should belong to this set");
let bucket = "bucket-version-suspended-delete";
let object = "object.txt";
let opts = ObjectOptions {
@@ -17069,13 +17437,26 @@ mod tests {
..Default::default()
};
set_disks
store
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("bucket should be created");
let mut reader = PutObjReader::from_vec(b"suspended version body".to_vec());
set_disks
.put_object(bucket, object, &mut reader, &opts)
.put_object(
bucket,
object,
&mut reader,
&ObjectOptions {
object_lock_config_snapshot: Some(
store
.object_lock_config_snapshot(bucket)
.await
.expect("PUT fixture should own the bucket lifecycle and Object Lock snapshot"),
),
..opts.clone()
},
)
.await
.expect("suspended-version object should be written");
@@ -113,6 +113,7 @@ pub(crate) struct DeleteRecoveryContext<'a> {
pub repair_marker_source: bool,
pub namespace_owner: Option<Arc<dyn Send + Sync>>,
pub recovery_ticket: Option<crate::store::delete_recovery_runtime::DeleteRecoveryTicket>,
pub published_hint: Option<(Uuid, &'a [Result<DeleteRecoveryInspection>])>,
pub can_commit: &'a (dyn Fn() -> bool + Sync),
}
@@ -125,6 +126,7 @@ impl<'a> DeleteRecoveryContext<'a> {
repair_marker_source: false,
namespace_owner: None,
recovery_ticket: None,
published_hint: None,
can_commit,
}
}
@@ -421,7 +423,9 @@ impl SetDisks {
let cleanup = loop {
#[cfg(test)]
test_hooks::record_cleanup_attempt(bucket, object);
match self.recover_delete_key(bucket, object, context.clone()).await {
let mut attempt_context = context.clone();
attempt_context.published_hint = (attempt == 1).then_some((transaction, published.as_slice()));
match self.recover_delete_key(bucket, object, attempt_context).await {
// A stale settlement fingerprint only means the snapshot raced
// another writer; each attempt re-inspects every participant.
Err(DiskError::OutdatedXLMeta) if attempt < PUBLISHED_CLEANUP_ATTEMPTS && can_commit() => attempt += 1,
@@ -551,6 +555,7 @@ impl SetDisks {
explicit_delete,
repair_marker_source,
namespace_owner,
published_hint,
can_commit,
recovery_ticket: _,
} = context;
@@ -589,7 +594,18 @@ impl SetDisks {
let mut all_states = BTreeMap::new();
for transaction in transactions {
let mut complete = Vec::with_capacity(disks.len());
for state in inspect(&disks, bucket, object, transaction).await {
let inspections = if let Some((published_tx, published_states)) = published_hint
&& !repair_marker_source
&& !dry_run
&& published_tx == transaction
&& published_states.len() == disks.len()
&& published_states.iter().all(Result::is_ok)
{
published_states.to_vec()
} else {
inspect(&disks, bucket, object, transaction).await
};
for state in inspections {
match state {
Ok(state) => {
if let Some(journal) = &state.journal {
@@ -832,6 +848,9 @@ impl SetDisks {
bucket,
copies,
|copy| {
if receipt(copy.state) && copy.commit_data_safe {
return None;
}
Some(
if copy.state == DeleteRecoveryState::Absent
|| (certified_same_transaction
@@ -857,7 +876,10 @@ impl SetDisks {
&disks,
bucket,
copies,
|_| Some(DeleteRecoveryAction::FinishCommit),
|copy| {
(copy.state != DeleteRecoveryState::CommitFinished || !copy.commit_data_safe)
.then_some(DeleteRecoveryAction::FinishCommit)
},
can_commit,
namespace_owner.as_ref(),
&mut summary.removed_versions,
@@ -922,13 +944,25 @@ impl SetDisks {
// GC is a key-wide barrier: every predecessor must have settled
// before the last surviving commit witness can disappear.
let mut settled = Vec::with_capacity(all_states.len());
for transaction in all_states.keys() {
let fresh = inspect(&disks, bucket, object, *transaction).await;
if fresh.iter().any(|state| !matches!(state, Ok(state) if matches!(state.state, DeleteRecoveryState::Absent | DeleteRecoveryState::CommitFinished))) {
if all_states.len() == 1 {
let copies = all_states.into_values().next().ok_or(DiskError::FileCorrupt)?;
if copies
.iter()
.any(|state| !matches!(state.state, DeleteRecoveryState::Absent | DeleteRecoveryState::CommitFinished))
{
summary.pending += 1;
return Ok(summary);
}
settled.push(fresh.into_iter().collect::<Result<Vec<_>>>()?);
settled.push(copies);
} else {
for transaction in all_states.keys() {
let fresh = inspect(&disks, bucket, object, *transaction).await;
if fresh.iter().any(|state| !matches!(state, Ok(state) if matches!(state.state, DeleteRecoveryState::Absent | DeleteRecoveryState::CommitFinished))) {
summary.pending += 1;
return Ok(summary);
}
settled.push(fresh.into_iter().collect::<Result<Vec<_>>>()?);
}
}
for (index, mut copies) in settled.into_iter().enumerate() {
if index > 0 {
@@ -1783,6 +1817,56 @@ mod tests {
.expect("returning old replica must not leave bucket residue");
}
#[tokio::test]
async fn delete_recovery_set_replays_partial_gc_with_empty_transaction_directory() {
let bucket = "recover-empty-gc";
let (dirs, store, disks) = fixture(bucket).await;
put(&store, bucket, &[24; 4096], false).await;
let transaction = applied(&store, &disks, bucket, None, disks.len()).await;
for (index, disk) in disks.iter().enumerate() {
publish(disk, bucket, transaction).await;
let state = disk.inspect_delete_transaction(bucket, "object", transaction).await.unwrap();
let finished = disk
.settle_delete_transaction(bucket, "object", transaction, state.fingerprint, DeleteRecoveryAction::FinishCommit)
.await
.unwrap();
if index > 0 {
disk.settle_delete_transaction(
bucket,
"object",
transaction,
finished.fingerprint,
DeleteRecoveryAction::FinalizeCommit,
)
.await
.unwrap();
}
}
// A crash can persist proof removal before the final empty rmdir.
let empty = dirs[3].path().join(bucket).join("object").join(transaction.to_string());
tokio::fs::create_dir_all(&empty).await.unwrap();
for _ in 0..2 {
store
.recover_delete_transactions(bucket, &HealOpts::default())
.await
.expect("the remaining receipt must settle every participant without a fresh delete");
for disk in &disks {
assert!(discover(disk, bucket, Some("object")).await.unwrap().is_empty());
assert!(
disk.inspect_delete_transaction(bucket, "object", transaction)
.await
.unwrap()
.current_digest
.is_none()
);
}
}
store
.delete_bucket(bucket, &DeleteBucketOptions::default())
.await
.expect("ordinary bucket removal must succeed after replay");
}
#[tokio::test]
async fn delete_recovery_tracks_only_observed_removed_version() {
let bucket = "recover-actual-removal";
File diff suppressed because it is too large Load Diff
@@ -15,7 +15,9 @@
use crate::disk::{DiskAPI as _, ReadOptions};
use crate::object_api::{ObjectOptions, PutObjReader, ShardIntegrityWriteMode};
use crate::set_disk::CompletePart;
use crate::set_disk::ops::object::hermetic_set_disks_support::hermetic_set_disks_for_pool_with_default_parity_isolated;
use crate::set_disk::ops::object::hermetic_set_disks_support::{
hermetic_set_disks_for_pool_with_default_parity_isolated, lifecycle_owned_put_options,
};
use crate::storage_api_contracts::multipart::MultipartOperations as _;
use crate::storage_api_contracts::object::{ObjectIO as _, ObjectOperations as _};
use rustfs_heal_contracts::heal_channel::{HealOpts, HealScanMode};
@@ -39,18 +41,19 @@ async fn remote_metadata_heal_does_not_certify_unread_tier_payload() {
for disk in &disks {
disk.make_volume(bucket).await.expect("fixture bucket");
}
set.put_object(
let opts = lifecycle_owned_put_options(
&set,
bucket,
object,
&mut PutObjReader::from_vec(vec![0x31; 1024 * 1024 + 17]),
&ObjectOptions {
ObjectOptions {
no_lock: true,
shard_integrity_write_mode: Some(ShardIntegrityWriteMode::Protected),
..Default::default()
},
)
.await
.expect("protected source");
.await;
set.put_object(bucket, object, &mut PutObjReader::from_vec(vec![0x31; 1024 * 1024 + 17]), &opts)
.await
.expect("protected source");
for (disk, dir) in disks.iter().zip(&dirs) {
let mut info = disk
.read_version("", bucket, object, "", &ReadOptions::default())
@@ -94,10 +97,15 @@ async fn rollout_requires_both_flags_for_new_puts() {
for disk in &disks {
disk.make_volume(bucket).await.expect("fixture bucket");
}
let opts = ObjectOptions {
no_lock: true,
..Default::default()
};
let opts = lifecycle_owned_put_options(
&set,
bucket,
ObjectOptions {
no_lock: true,
..Default::default()
},
)
.await;
for (requested, confirmed, protected) in [
(false, false, false),
@@ -159,12 +167,17 @@ async fn legacy_shard_repair_and_explicit_version_metadata_recovery_remain_avail
for disk in &disks {
disk.make_volume(bucket).await.expect("fixture bucket");
}
let opts = ObjectOptions {
no_lock: true,
versioned: true,
shard_integrity_write_mode: Some(ShardIntegrityWriteMode::Legacy),
..Default::default()
};
let opts = lifecycle_owned_put_options(
&set,
bucket,
ObjectOptions {
no_lock: true,
versioned: true,
shard_integrity_write_mode: Some(ShardIntegrityWriteMode::Legacy),
..Default::default()
},
)
.await;
let expected = vec![0x67; 1024 * 1024 + 37];
for case in ["missing-part", "bad-bitrot", "missing-version-metadata"] {
let object = case;
@@ -272,10 +285,15 @@ async fn multipart_mode_survives_rollout_switch_changes() {
for disk in &disks {
disk.make_volume(bucket).await.expect("fixture bucket");
}
let opts = ObjectOptions {
no_lock: true,
..Default::default()
};
let opts = lifecycle_owned_put_options(
&set,
bucket,
ObjectOptions {
no_lock: true,
..Default::default()
},
)
.await;
for protected in [false, true] {
let object = format!("upload-{protected}");
let upload =
@@ -354,11 +372,16 @@ async fn physical_copy_inherits_source_mode_instead_of_current_switch() {
};
let object = format!("source-{protected}");
let expected = vec![0x39; 1024 * 1024 + 11];
let seed_opts = ObjectOptions {
no_lock: true,
shard_integrity_write_mode: Some(mode),
..Default::default()
};
let seed_opts = lifecycle_owned_put_options(
&set,
bucket,
ObjectOptions {
no_lock: true,
shard_integrity_write_mode: Some(mode),
..Default::default()
},
)
.await;
let mut source = set
.put_object(bucket, &object, &mut PutObjReader::from_vec(expected.clone()), &seed_opts)
.await
@@ -366,8 +389,8 @@ async fn physical_copy_inherits_source_mode_instead_of_current_switch() {
source.metadata_only = false;
source.put_object_reader = Some(PutObjReader::from_vec(expected.clone()));
let opts = ObjectOptions {
no_lock: true,
..Default::default()
shard_integrity_write_mode: None,
..seed_opts.clone()
};
let copied = temp_env::async_with_vars(
rollout_vars(!protected, !protected),
@@ -358,6 +358,7 @@ impl SetDisks {
let collector = agreed_collector.clone();
Box::pin(async move {
collector.ingest(entry);
Ok(())
})
})),
partial: Some(Box::new(move |entries: MetaCacheEntries, _errs: &[Option<DiskError>]| {
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -16,7 +16,7 @@
//! another object (`a/b` and `a/b/c`). Cleanup of the parent must only remove
//! the parent's own protocol data and never touch the child's xl.meta or data.
use super::hermetic_set_disks_support::hermetic_set_disks_isolated;
use super::hermetic_set_disks_support::{HermeticSetFixture, hermetic_set_disks_isolated};
use super::*;
use crate::data_movement::{SourceCleanupBucketFence, SourceCleanupError, cleanup_source_entry_if_unchanged};
use rustfs_filemeta::FileInfoVersions;
@@ -100,7 +100,7 @@ fn child_tree(temp_dirs: &[TempDir], bucket: &str) -> Vec<Vec<String>> {
.collect()
}
async fn setup(payload_len: usize) -> (Vec<TempDir>, Arc<SetDisks>, &'static str, Vec<u8>, Vec<u8>) {
async fn setup(payload_len: usize) -> (HermeticSetFixture, Arc<SetDisks>, &'static str, Vec<u8>, Vec<u8>) {
let (temp_dirs, disk_stores, set_disks) = hermetic_set_disks_isolated(4).await;
let bucket = "data-movement-prefix-cleanup";
for disk in &disk_stores {
+1 -2
View File
@@ -3885,8 +3885,7 @@ mod metadata_cache_tests {
let bucket = "late-metadata-identity";
let object = "late-metadata-identity-object";
let ctx = Arc::new(crate::runtime::instance::InstanceContext::new());
let (_dirs, set) = crate::ecstore_validation_blackbox::make_local_set_disks_with_ctx(4, 2, ctx).await;
let (_fixture, _disks, set) = crate::set_disk::hermetic_set_disks_isolated(4).await;
set.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("create real late-read bucket");
@@ -8,6 +8,7 @@ pub(crate) mod admin {
}
pub(crate) mod bucket {
pub(crate) use rustfs_storage_api::bucket::valid_artifact_base_prefix;
pub(crate) use rustfs_storage_api::{
BUCKET_LIFECYCLE_LOCK_OBJECT, BucketInfo, BucketOperations, BucketOptions, DeleteBucketOptions, MakeBucketOptions,
SRBucketDeleteOp,
+274 -233
View File
@@ -848,255 +848,293 @@ impl ECStore {
#[instrument(skip(self))]
pub(super) async fn handle_make_bucket(&self, bucket: &str, opts: &MakeBucketOptions) -> Result<()> {
if !is_meta_bucketname(bucket)
&& let Err(err) = check_valid_bucket_name_strict(bucket)
{
return Err(StorageError::BucketNameInvalid(err.to_string()));
}
self.handle_make_bucket_inner(bucket, opts).await
}
// Lock order: bucket lifecycle -> metadata transaction -> exact bucket namespace.
let bucket_lifecycle_guard = if !opts.no_lock {
Some(self.acquire_bucket_lifecycle_write_lock(bucket).await?)
} else {
None
};
let metadata_transaction_guard = if !opts.no_lock && !is_meta_bucketname(bucket) {
Some(metadata_sys::acquire_bucket_metadata_transaction_lock_in(&self.ctx, bucket).await?)
} else {
None
};
let ns_guard = if !opts.no_lock {
let ns_lock = self.new_ns_lock(bucket, bucket).await?;
Some(
await_bucket_namespace_operation(
bucket_lifecycle_guard.as_ref(),
bucket,
"bucket namespace lock acquisition",
async {
ns_lock.get_write_lock(get_lock_acquire_timeout()).await.map_err(|e| match e {
rustfs_lock::error::LockError::QuorumNotReached { required, achieved } => {
StorageError::NamespaceLockQuorumUnavailable {
mode: "write",
bucket: bucket.to_string(),
object: bucket.to_string(),
required,
achieved,
}
}
other => StorageError::Lock(other),
})
},
)
.await?,
)
} else {
None
};
let existing_bucket_info = match self.get_bucket_info_from_sets(bucket, &BucketOptions::default()).await {
Ok(info) => Some(info),
Err(err) => {
if is_err_bucket_not_found(&err) {
None
} else {
return Err(to_object_err(err, vec![bucket]));
}
// Keep the large creation future out of the instrumented handler's poll
// frame. Guards remain owned by the same task.
fn handle_make_bucket_inner<'a>(
&'a self,
bucket: &'a str,
opts: &'a MakeBucketOptions,
) -> futures::future::BoxFuture<'a, Result<()>> {
Box::pin(async move {
if opts.artifact_base_prefix.as_deref().is_some_and(|prefix| {
is_meta_bucketname(bucket)
|| opts.no_lock
|| !crate::storage_api_contracts::bucket::valid_artifact_base_prefix(prefix)
}) {
return Err(Error::other("invalid artifact bucket creation options"));
}
};
let confirmed_missing = existing_bucket_info.is_none();
let mut pending_creation_metadata = None;
if confirmed_missing
&& !opts.no_lock
&& !is_meta_bucketname(bucket)
&& let Some(metadata_store) = metadata_sys::object_store_if_initialized_in(&self.ctx).await
&& let Some(old_incarnation) = crate::bucket::metadata::load_bucket_incarnation(metadata_store, bucket).await?
{
// A quorum-level absence is not proof that every disk lost the old
// generation. Require the explicit recovery path to verify all disks
// and retire this incarnation before a same-name create can mutate
// any volume.
self.ensure_bucket_volume_absent_on_all_sets(bucket)
.await
.map_err(|error| match error {
StorageError::BucketExists(_) => StorageError::InvalidArgument(
if !is_meta_bucketname(bucket)
&& let Err(err) = check_valid_bucket_name_strict(bucket)
{
return Err(StorageError::BucketNameInvalid(err.to_string()));
}
// Lock order: bucket lifecycle -> metadata transaction -> exact bucket namespace.
let bucket_lifecycle_guard = if !opts.no_lock {
Some(self.acquire_bucket_lifecycle_write_lock(bucket).await?)
} else {
None
};
let metadata_transaction_guard = if !opts.no_lock && !is_meta_bucketname(bucket) {
Some(metadata_sys::acquire_bucket_metadata_transaction_lock_in(&self.ctx, bucket).await?)
} else {
None
};
let ns_guard = if !opts.no_lock {
let ns_lock = self.new_ns_lock(bucket, bucket).await?;
Some(
await_bucket_namespace_operation(
bucket_lifecycle_guard.as_ref(),
bucket,
"bucket namespace lock acquisition",
async {
ns_lock.get_write_lock(get_lock_acquire_timeout()).await.map_err(|e| match e {
rustfs_lock::error::LockError::QuorumNotReached { required, achieved } => {
StorageError::NamespaceLockQuorumUnavailable {
mode: "write",
bucket: bucket.to_string(),
object: bucket.to_string(),
required,
achieved,
}
}
other => StorageError::Lock(other),
})
},
)
.await?,
)
} else {
None
};
let existing_bucket_info = match self.get_bucket_info_from_sets(bucket, &BucketOptions::default()).await {
Ok(info) => Some(info),
Err(err) => {
if is_err_bucket_not_found(&err) {
None
} else {
return Err(to_object_err(err, vec![bucket]));
}
}
};
let confirmed_missing = existing_bucket_info.is_none();
let mut pending_creation_metadata = None;
if confirmed_missing
&& !opts.no_lock
&& !is_meta_bucketname(bucket)
&& let Some(metadata_store) = metadata_sys::object_store_if_initialized_in(&self.ctx).await
&& let Some(old_incarnation) = crate::bucket::metadata::load_bucket_incarnation(metadata_store, bucket).await?
{
// A quorum-level absence is not proof that every disk lost the old
// generation. Require the explicit recovery path to verify all disks
// and retire this incarnation before a same-name create can mutate
// any volume.
self.ensure_bucket_volume_absent_on_all_sets(bucket)
.await
.map_err(|error| match error {
StorageError::BucketExists(_) => StorageError::InvalidArgument(
"CreateBucket".to_string(),
"bucket".to_string(),
"an old bucket volume remains on disk; administrator recovery refused".to_string(),
),
other => other,
})?;
let (metadata, persisted) = metadata_sys::get_config_from_disk_with_presence_in(&self.ctx, bucket).await?;
let metadata_store = metadata_sys::object_store_in(&self.ctx).await?;
let pending_intent = persisted
&& metadata.bucket_incarnation_id == old_incarnation
&& metadata.needs_bucket_creation_commit()
&& !crate::bucket::retirement::is_retired(metadata_store, bucket, old_incarnation).await?;
if pending_intent {
pending_creation_metadata = Some(metadata);
} else {
return Err(StorageError::InvalidArgument(
"CreateBucket".to_string(),
"bucket".to_string(),
"an old bucket volume remains on disk; administrator recovery refused".to_string(),
),
other => other,
})?;
let (metadata, persisted) = metadata_sys::get_config_from_disk_with_presence_in(&self.ctx, bucket).await?;
let metadata_store = metadata_sys::object_store_in(&self.ctx).await?;
let pending_intent = persisted
&& metadata.bucket_incarnation_id == old_incarnation
&& metadata.needs_bucket_creation_commit()
&& !crate::bucket::retirement::is_retired(metadata_store, bucket, old_incarnation).await?;
if pending_intent {
pending_creation_metadata = Some(metadata);
} else {
return Err(StorageError::InvalidArgument(
"CreateBucket".to_string(),
"bucket".to_string(),
format!("orphaned bucket generation {old_incarnation} requires administrator recovery before recreation"),
));
}
}
let existing_metadata = if let Some(metadata) = pending_creation_metadata {
Some(metadata)
} else if opts.force_create && !confirmed_missing && !is_meta_bucketname(bucket) {
let (mut metadata, persisted) = metadata_sys::get_config_from_disk_with_presence_in(&self.ctx, bucket).await?;
if !persisted {
metadata = BucketMetadata::new(bucket);
metadata.created = existing_bucket_info
.as_ref()
.and_then(|info| info.created)
.unwrap_or(OffsetDateTime::UNIX_EPOCH);
// A sidecar left by an interrupted legacy migration is the
// bucket's published incarnation; keep it rather than minting
// one that would invalidate fences taken on the stored value.
// A retired incarnation is residue of a deleted bucket and
// must not come back.
let store = metadata_sys::object_store_in(&self.ctx).await?;
if let Some(stored) = crate::bucket::metadata::load_bucket_incarnation(store.clone(), bucket).await?
&& !crate::bucket::retirement::is_retired(store, bucket, stored).await?
{
metadata.bucket_incarnation_id = stored;
format!("orphaned bucket generation {old_incarnation} requires administrator recovery before recreation"),
));
}
} else if !metadata.bucket_incarnation_sidecar && !metadata.bucket_incarnation_id.is_nil() {
return Err(Error::other(format!(
"bucket incarnation sidecar is missing for new-format metadata: {bucket}"
)));
} else if !metadata.bucket_incarnation_sidecar {
metadata.bucket_incarnation_id = Uuid::new_v4();
}
Some(metadata)
} else {
None
};
let mut meta = existing_metadata.unwrap_or_else(|| {
if confirmed_missing && !is_meta_bucketname(bucket) {
BucketMetadata::new_with_default_durability(bucket)
let existing_metadata = if let Some(metadata) = pending_creation_metadata {
Some(metadata)
} else if (opts.force_create || opts.artifact_base_prefix.is_some())
&& !confirmed_missing
&& !is_meta_bucketname(bucket)
{
let (mut metadata, persisted) = metadata_sys::get_config_from_disk_with_presence_in(&self.ctx, bucket).await?;
if !persisted {
metadata = BucketMetadata::new(bucket);
metadata.created = existing_bucket_info
.as_ref()
.and_then(|info| info.created)
.unwrap_or(OffsetDateTime::UNIX_EPOCH);
// A sidecar left by an interrupted legacy migration is the
// bucket's published incarnation; keep it rather than minting
// one that would invalidate fences taken on the stored value.
// A retired incarnation is residue of a deleted bucket and
// must not come back.
let store = metadata_sys::object_store_in(&self.ctx).await?;
if let Some(stored) = crate::bucket::metadata::load_bucket_incarnation(store.clone(), bucket).await?
&& !crate::bucket::retirement::is_retired(store, bucket, stored).await?
{
metadata.bucket_incarnation_id = stored;
}
} else if !metadata.bucket_incarnation_sidecar && !metadata.bucket_incarnation_id.is_nil() {
return Err(Error::other(format!(
"bucket incarnation sidecar is missing for new-format metadata: {bucket}"
)));
} else if !metadata.bucket_incarnation_sidecar {
metadata.bucket_incarnation_id = Uuid::new_v4();
}
Some(metadata)
} else {
BucketMetadata::new(bucket)
}
});
let existing_incarnation_is_authoritative = meta.bucket_incarnation_sidecar;
if confirmed_missing || is_meta_bucketname(bucket) {
meta.set_created(opts.created_at);
None
};
if opts.versioning_enabled {
let has_creation_identity = existing_metadata.is_some();
let mut meta = existing_metadata.unwrap_or_else(|| {
if confirmed_missing && !is_meta_bucketname(bucket) {
BucketMetadata::new_with_default_durability(bucket)
} else {
BucketMetadata::new(bucket)
}
});
if let Some(prefix) = &opts.artifact_base_prefix {
if has_creation_identity {
if meta.artifact_base_prefix.as_ref() != Some(prefix) {
return Err(Error::other("artifact namespace is immutable"));
}
} else if confirmed_missing {
meta.artifact_base_prefix = Some(prefix.clone());
} else {
return Err(Error::other("artifact namespace requires a new bucket"));
}
}
let existing_incarnation_is_authoritative = meta.bucket_incarnation_sidecar;
if confirmed_missing || is_meta_bucketname(bucket) {
meta.set_created(opts.created_at);
if opts.versioning_enabled || meta.artifact_base_prefix.is_some() {
meta.versioning_config_xml =
crate::bucket::utils::serialize::<VersioningConfiguration>(&ENABLED_VERSIONING_CONFIG)?;
}
}
// Object Lock enable is one-way, and it must apply to an existing bucket
// too. Site replication replays make-with-versioning carrying the
// source's lockEnabled against a destination bucket that already exists;
// gating this on `confirmed_missing` returned success while leaving the
// replica unlocked, so replicated versions could be deleted without the
// retention the source enforces.
let lock_newly_enabled = opts.lock_enabled && !meta.lock_enabled;
if opts.lock_enabled {
meta.lock_enabled = true;
meta.object_lock_config_xml = crate::bucket::utils::serialize(&*crate::store::ENABLED_OBJECT_LOCK_CONFIG)?;
meta.versioning_config_xml =
crate::bucket::utils::serialize::<VersioningConfiguration>(&ENABLED_VERSIONING_CONFIG)?;
}
}
// Object Lock enable is one-way, and it must apply to an existing bucket
// too. Site replication replays make-with-versioning carrying the
// source's lockEnabled against a destination bucket that already exists;
// gating this on `confirmed_missing` returned success while leaving the
// replica unlocked, so replicated versions could be deleted without the
// retention the source enforces.
let lock_newly_enabled = opts.lock_enabled && !meta.lock_enabled;
if opts.lock_enabled {
meta.lock_enabled = true;
meta.object_lock_config_xml = crate::bucket::utils::serialize(&*crate::store::ENABLED_OBJECT_LOCK_CONFIG)?;
meta.versioning_config_xml = crate::bucket::utils::serialize::<VersioningConfiguration>(&ENABLED_VERSIONING_CONFIG)?;
}
let metadata_persisted_before_physical = confirmed_missing && !is_meta_bucketname(bucket) && opts.lock_enabled;
if metadata_persisted_before_physical {
metadata_sys::set_new_bucket_metadata_intent_in(&self.ctx, meta.clone()).await?;
if bucket_lifecycle_guard.as_ref().is_some_and(|guard| guard.is_lock_lost())
|| metadata_transaction_guard.as_ref().is_some_and(|guard| guard.is_lock_lost())
|| ns_guard.as_ref().is_some_and(|guard| guard.is_lock_lost())
{
return Err(Error::other(format!("bucket metadata creation intent lock was lost: {bucket}")));
}
}
if confirmed_missing && !is_meta_bucketname(bucket) {
// Fence every scanner cycle that could have observed the namespace
// before physical creation. Creation may become visible even when a
// later metadata write or namespace-lock check fails.
crate::store::list_objects::observe_scanner_namespace_mutations(bucket, 1);
self.cleanup_bucket_usage(bucket, ns_guard.as_ref()).await?;
}
if let Err(err) = await_bucket_lifecycle_operation(
bucket_lifecycle_guard.as_ref(),
ns_guard.as_ref(),
bucket,
"physical bucket creation",
await_bucket_namespace_operation(
metadata_transaction_guard.as_ref(),
bucket,
"bucket creation metadata transaction",
async {
self.make_bucket_on_sets(bucket, opts)
.await
.map_err(|err| to_object_err(err, vec![bucket]))
},
),
)
.await
{
if is_err_bucket_exists(&err)
&& let Err(heal_err) = self
.handle_heal_bucket(
bucket,
&HealOpts {
recreate: true,
..Default::default()
},
)
.await
{
warn!("best-effort bucket heal after BucketExists failed: {heal_err}");
}
if confirmed_missing && !is_err_bucket_exists(&err) && ns_guard.as_ref().is_none_or(|guard| !guard.is_lock_lost()) {
error!("make bucket failed: {err}");
self.rollback_failed_bucket_creation(bucket, bucket_lifecycle_guard.as_ref(), ns_guard.as_ref())
.await;
}
return Err(err);
};
let metadata_result = async {
let metadata_persisted_before_physical =
confirmed_missing && !is_meta_bucketname(bucket) && (opts.lock_enabled || meta.artifact_base_prefix.is_some());
if metadata_persisted_before_physical {
return metadata_sys::commit_bucket_metadata_in(&self.ctx, meta).await;
metadata_sys::set_new_bucket_metadata_intent_in(&self.ctx, meta.clone()).await?;
if bucket_lifecycle_guard.as_ref().is_some_and(|guard| guard.is_lock_lost())
|| metadata_transaction_guard.as_ref().is_some_and(|guard| guard.is_lock_lost())
|| ns_guard.as_ref().is_some_and(|guard| guard.is_lock_lost())
{
return Err(Error::other(format!("bucket metadata creation intent lock was lost: {bucket}")));
}
}
if is_meta_bucketname(bucket) {
metadata_sys::set_bucket_metadata_in(&self.ctx, meta).await
} else if existing_incarnation_is_authoritative && !lock_newly_enabled {
metadata_sys::commit_bucket_metadata_in(&self.ctx, meta).await
} else {
metadata_sys::set_new_bucket_metadata_in(&self.ctx, meta).await
}
}
.await;
let metadata_lock_lost = bucket_lifecycle_guard.as_ref().is_some_and(|guard| guard.is_lock_lost())
|| metadata_transaction_guard.as_ref().is_some_and(|guard| guard.is_lock_lost())
|| ns_guard.as_ref().is_some_and(|guard| guard.is_lock_lost());
if let Err(err) = metadata_result {
if !metadata_lock_lost {
self.rollback_failed_bucket_creation(bucket, bucket_lifecycle_guard.as_ref(), ns_guard.as_ref())
.await;
}
return Err(err);
}
if metadata_lock_lost {
return Err(Error::other(format!("bucket metadata initialization lock was lost: {bucket}")));
}
if confirmed_missing && !is_meta_bucketname(bucket) {
// A scanner may have sampled the first fence before the bucket
// became visible. Fence again after metadata initialization so
// that snapshot cannot publish as complete.
crate::store::list_objects::observe_scanner_namespace_mutations(bucket, 1);
}
if confirmed_missing && !is_meta_bucketname(bucket) {
// Fence every scanner cycle that could have observed the namespace
// before physical creation. Creation may become visible even when a
// later metadata write or namespace-lock check fails.
crate::store::list_objects::observe_scanner_namespace_mutations(bucket, 1);
// Keep cleanup state out of the bucket-creation request future.
Box::pin(self.cleanup_bucket_usage(bucket, ns_guard.as_ref())).await?;
}
Ok(())
if let Err(err) = await_bucket_lifecycle_operation(
bucket_lifecycle_guard.as_ref(),
ns_guard.as_ref(),
bucket,
"physical bucket creation",
await_bucket_namespace_operation(
metadata_transaction_guard.as_ref(),
bucket,
"bucket creation metadata transaction",
async {
self.make_bucket_on_sets(bucket, opts)
.await
.map_err(|err| to_object_err(err, vec![bucket]))
},
),
)
.await
{
if is_err_bucket_exists(&err)
&& let Err(heal_err) = self
.handle_heal_bucket(
bucket,
&HealOpts {
recreate: true,
..Default::default()
},
)
.await
{
warn!("best-effort bucket heal after BucketExists failed: {heal_err}");
}
if confirmed_missing && !is_err_bucket_exists(&err) && ns_guard.as_ref().is_none_or(|guard| !guard.is_lock_lost())
{
error!("make bucket failed: {err}");
self.rollback_failed_bucket_creation(bucket, bucket_lifecycle_guard.as_ref(), ns_guard.as_ref())
.await;
}
return Err(err);
};
let metadata_result = async {
if metadata_persisted_before_physical {
return metadata_sys::commit_bucket_metadata_in(&self.ctx, meta).await;
}
if is_meta_bucketname(bucket) {
metadata_sys::set_bucket_metadata_in(&self.ctx, meta).await
} else if existing_incarnation_is_authoritative && !lock_newly_enabled {
metadata_sys::commit_bucket_metadata_in(&self.ctx, meta).await
} else {
metadata_sys::set_new_bucket_metadata_in(&self.ctx, meta).await
}
}
.await;
let metadata_lock_lost = bucket_lifecycle_guard.as_ref().is_some_and(|guard| guard.is_lock_lost())
|| metadata_transaction_guard.as_ref().is_some_and(|guard| guard.is_lock_lost())
|| ns_guard.as_ref().is_some_and(|guard| guard.is_lock_lost());
if let Err(err) = metadata_result {
if !metadata_lock_lost {
self.rollback_failed_bucket_creation(bucket, bucket_lifecycle_guard.as_ref(), ns_guard.as_ref())
.await;
}
return Err(err);
}
if metadata_lock_lost {
return Err(Error::other(format!("bucket metadata initialization lock was lost: {bucket}")));
}
if confirmed_missing && !is_meta_bucketname(bucket) {
// A scanner may have sampled the first fence before the bucket
// became visible. Fence again after metadata initialization so
// that snapshot cannot publish as complete.
crate::store::list_objects::observe_scanner_namespace_mutations(bucket, 1);
}
Ok(())
})
}
/// Prove a live bucket generation before repairing missing expansion volumes.
@@ -1461,6 +1499,9 @@ impl ECStore {
}
};
if bucket_exists {
if !is_meta_bucketname(bucket) {
self.require_no_artifact_backup_retention(bucket).await?;
}
validate_table_bucket_delete_guard(&self.ctx, bucket).await?;
if !opts.force {
+43 -1
View File
@@ -160,6 +160,7 @@ pub struct BucketIncarnationFenceGuard {
bucket: String,
token: Option<u64>,
memoized: Option<Uuid>,
validated_incarnation: Option<Uuid>,
}
impl BucketIncarnationFenceGuard {
@@ -173,6 +174,7 @@ impl BucketIncarnationFenceGuard {
bucket: bucket.to_owned(),
token,
memoized,
validated_incarnation: None,
}
}
@@ -183,6 +185,7 @@ impl BucketIncarnationFenceGuard {
F: FnOnce() -> Fut,
Fut: Future<Output = Result<Uuid>>,
{
self.validated_incarnation = None;
let cacheable = reuse_memo && self.token.is_some();
let current = match self.memoized.filter(|_| cacheable) {
Some(current) => current,
@@ -197,6 +200,7 @@ impl BucketIncarnationFenceGuard {
}
};
self.ensure_held()?;
self.validated_incarnation = Some(current);
Ok(current)
}
@@ -213,6 +217,19 @@ impl BucketIncarnationFenceGuard {
Ok(())
}
pub(crate) fn matches(&self, store: &super::ECStore, bucket: &str, incarnation: Uuid) -> bool {
Arc::ptr_eq(&self.registry, &store.bucket_fence_registry)
&& self.bucket == bucket
&& self.validated_incarnation == Some(incarnation)
&& !self.is_lock_lost()
}
/// Retain this validated lifecycle owner through the storage write tail.
pub fn attach_owner(self: &Arc<Self>, opts: &mut crate::object_api::ObjectOptions) {
self.attach_to_object_options(opts);
opts.bucket_incarnation_fence = Some(Arc::clone(self));
}
/// Propagate lifecycle lock loss into the storage commit checks.
/// The caller still owns this guard until the complete write tail drains.
pub fn attach_to_object_options(&self, opts: &mut crate::object_api::ObjectOptions) {
@@ -221,7 +238,8 @@ impl BucketIncarnationFenceGuard {
}
}
pub(crate) fn is_lock_lost(&self) -> bool {
/// Report whether the lifecycle lock no longer covers this operation.
pub fn is_lock_lost(&self) -> bool {
self.inner.as_ref().is_some_and(NamespaceLockGuard::is_lock_lost)
}
@@ -401,6 +419,30 @@ mod tests {
BucketIncarnationFenceGuard::new(registry, "b", inner, cacheable)
}
#[tokio::test]
async fn artifact_owner_tracks_validation_instead_of_cached_incarnation() {
let registry = Arc::new(BucketFenceRegistry::default());
let lock = NamespaceLock::new("artifact-owner-validation".to_owned(), Arc::new(LocalClient::new()));
let mut first = test_fence(registry.clone(), &lock, true).await;
first.validate(true, || async { Ok(uuid(7)) }).await.expect("seed memo");
let mut snapshot = test_fence(registry, &lock, true).await;
assert_eq!(snapshot.memoized, Some(uuid(7)));
assert_eq!(snapshot.validated_incarnation, None, "registration alone grants no artifact ownership");
snapshot
.validate(false, || async { Ok(uuid(8)) })
.await
.expect("validate snapshot");
assert_eq!(snapshot.validated_incarnation, Some(uuid(8)));
assert_eq!(snapshot.memoized, Some(uuid(7)), "independent snapshots do not update the shared memo");
assert!(
snapshot
.validate(false, || async { Err(StorageError::ErasureReadQuorum) })
.await
.is_err()
);
assert_eq!(snapshot.validated_incarnation, None, "failed validation cannot retain earlier ownership");
}
#[tokio::test]
async fn overlapping_validations_reuse_one_success_until_coverage_gap() {
use std::sync::atomic::{AtomicUsize, Ordering};
@@ -19,6 +19,7 @@
use super::ECStore;
use crate::bucket::utils::is_meta_bucketname;
use crate::disk::{DeleteRecoveryCursor, DiskAPI, DiskStore};
use futures::{StreamExt, future::BoxFuture, stream::FuturesUnordered};
use rustfs_heal_contracts::heal_channel::HealOpts;
use std::collections::{HashMap, HashSet, VecDeque};
use std::sync::{
@@ -38,6 +39,7 @@ const LOG_COMPONENT_ECSTORE: &str = "ecstore";
const LOG_SUBSYSTEM_DELETE_RECOVERY: &str = "delete_recovery";
const EVENT_DELETE_RECOVERY_STATUS: &str = "delete_recovery_status";
const KEYS_PER_PASS: usize = 16;
const RECOVERY_CONCURRENCY: usize = 4;
const INTERVAL: Duration = Duration::from_secs(1);
const INVENTORY_INTERVAL: Duration = Duration::from_secs(30);
const DISCOVERY_BUDGET: Duration = Duration::from_secs(5);
@@ -225,23 +227,35 @@ impl DeleteRecoveryHints {
}
}
fn take(&self, limit: usize) -> Vec<DeleteRecoveryTicket> {
fn take_available(&self, active: &HashSet<Arc<DeleteRecoveryKey>>) -> Option<DeleteRecoveryTicket> {
let mut state = self.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let mut tickets = Vec::with_capacity(limit.min(state.pending.len()));
while tickets.len() < limit {
let Some(ticket) = state.order.pop_front() else { break };
let queued = state.order.len();
for _ in 0..queued {
let ticket = state.order.pop_front()?;
if let Some(&(generation, slot, _)) = state.pending.get(&ticket.key)
&& slot == ticket.generation
{
if active.contains(&ticket.key) {
// Preserve a newer hint while this key still owns a slot.
// Other keys can proceed without waiting on its lock.
state.order.push_back(ticket);
continue;
}
state.pending.remove(&ticket.key);
tickets.push(DeleteRecoveryTicket {
return Some(DeleteRecoveryTicket {
key: ticket.key,
generation,
admission: None,
});
}
}
tickets
None
}
#[cfg(test)]
fn take(&self, limit: usize) -> Vec<DeleteRecoveryTicket> {
let active = HashSet::new();
std::iter::from_fn(|| self.take_available(&active)).take(limit).collect()
}
fn retry(&self, ticket: DeleteRecoveryTicket) {
@@ -511,6 +525,12 @@ impl RecoveryPass {
}
async fn discover(&mut self, store: &Arc<ECStore>, cancel: &CancellationToken) {
// ECStore starts before the bucket metadata system is published. Do
// not turn that bootstrap state into a deferred disk/bucket failure.
if store.ctx.bucket_metadata_sys().is_none() {
return;
}
let hints = store.ctx.delete_recovery_hints();
// Renew only this worker's bounded local cursors before any await.
// Backpressure and a slow canonical rollback must not erase progress.
@@ -590,49 +610,150 @@ struct RecoveryFailures {
failed: usize,
}
async fn recover_hints(store: &Arc<ECStore>, cancel: &CancellationToken) -> RecoveryFailures {
let hints = store.ctx.delete_recovery_hints();
let deadline = Instant::now() + PASS_START_BUDGET;
let mut failures = RecoveryFailures::default();
// Remove only the selected generation before awaiting. A concurrent
// DELETE can enqueue the same key independently; neither successful
// recovery nor retry of this ticket acknowledges that newer hint.
let attempts = hints.pending_len().min(KEYS_PER_PASS);
for _ in 0..attempts {
if Instant::now() >= deadline || cancel.is_cancelled() {
break;
}
// Only the operation about to start leaves the queue. A slow first
// key must not turn untouched work into evictable retry entries.
let Some(ticket) = hints.take(1).pop() else { break };
let key = &ticket.key;
let opts = HealOpts {
pool: Some(key.pool),
set: Some(key.set),
..Default::default()
};
// The pass deadline limits starting work, not an admitted rollback.
// Canonical disk/RPC recovery owns its configured large-shard timeout.
let outcome = tokio::select! {
_ = cancel.cancelled() => None,
result = store.recover_delete_transactions_at_incarnation(
&key.bucket, Some(&key.object), key.incarnation, &opts,
) => Some(result),
};
if !matches!(outcome, Some(Ok(_))) {
match outcome {
Some(Err(crate::error::StorageError::ErasureReadQuorum)) => failures.pending += 1,
Some(Err(crate::error::StorageError::SlowDown)) => failures.blocked += 1,
Some(Err(crate::error::StorageError::FileCorrupt)) => failures.corrupt += 1,
Some(Err(_)) => failures.failed += 1,
_ => {}
}
hints.retry(ticket);
struct RecoveryWork<'a> {
store: &'a Arc<ECStore>,
cancel: &'a CancellationToken,
tasks: FuturesUnordered<BoxFuture<'a, (Arc<DeleteRecoveryKey>, RecoveryFailures)>>,
active: HashSet<Arc<DeleteRecoveryKey>>,
remaining: usize,
deadline: Instant,
}
impl<'a> RecoveryWork<'a> {
fn new(store: &'a Arc<ECStore>, cancel: &'a CancellationToken) -> Self {
Self {
store,
cancel,
tasks: FuturesUnordered::new(),
active: HashSet::with_capacity(RECOVERY_CONCURRENCY),
remaining: 0,
deadline: Instant::now(),
}
}
fn begin_pass(&mut self) {
self.remaining = self.store.ctx.delete_recovery_hints().pending_len().min(KEYS_PER_PASS);
self.deadline = Instant::now() + PASS_START_BUDGET;
}
fn admit(&mut self) {
while self.remaining > 0
&& self.active.len() < RECOVERY_CONCURRENCY
&& Instant::now() < self.deadline
&& !self.cancel.is_cancelled()
{
let Some(ticket) = self.store.ctx.delete_recovery_hints().take_available(&self.active) else {
break;
};
self.active.insert(ticket.key.clone());
self.remaining -= 1;
self.tasks.push(Box::pin(recover_hint(self.store, self.cancel, ticket)));
}
}
async fn next(&mut self) -> Option<RecoveryFailures> {
let (key, failures) = self.tasks.next().await?;
self.active.remove(&key);
Some(failures)
}
}
impl RecoveryFailures {
fn add(&mut self, other: Self) {
self.pending += other.pending;
self.blocked += other.blocked;
self.corrupt += other.corrupt;
self.failed += other.failed;
}
}
async fn recover_hint(
store: &Arc<ECStore>,
cancel: &CancellationToken,
ticket: DeleteRecoveryTicket,
) -> (Arc<DeleteRecoveryKey>, RecoveryFailures) {
let key = ticket.key.clone();
let opts = HealOpts {
pool: Some(key.pool),
set: Some(key.set),
..Default::default()
};
// The admission budget never truncates an admitted rollback. Canonical
// recovery retains its configured deadline and disk/namespace ownership.
let outcome = tokio::select! {
biased;
_ = cancel.cancelled() => None,
result = store.recover_delete_transactions_at_incarnation(
&key.bucket, Some(&key.object), key.incarnation, &opts,
) => Some(result),
};
let mut failures = RecoveryFailures::default();
if !matches!(outcome, Some(Ok(_))) {
match outcome {
Some(Err(crate::error::StorageError::ErasureReadQuorum)) => failures.pending = 1,
Some(Err(crate::error::StorageError::SlowDown)) => failures.blocked = 1,
Some(Err(crate::error::StorageError::FileCorrupt)) => failures.corrupt = 1,
Some(Err(_)) => failures.failed = 1,
_ => {}
}
store.ctx.delete_recovery_hints().retry(ticket);
}
(key, failures)
}
#[cfg(test)]
async fn recover_hints(store: &Arc<ECStore>, cancel: &CancellationToken) -> RecoveryFailures {
let mut work = RecoveryWork::new(store, cancel);
work.begin_pass();
work.admit();
let mut failures = RecoveryFailures::default();
while let Some(result) = work.next().await {
failures.add(result);
work.admit();
}
failures
}
async fn run_recovery(store: &Arc<ECStore>, cancel: &CancellationToken) {
let mut interval = tokio::time::interval(INTERVAL);
interval.set_missed_tick_behavior(MissedTickBehavior::Delay);
let mut previous = RecoveryFailures::default();
let mut failures = RecoveryFailures::default();
let mut work = RecoveryWork::new(store, cancel);
loop {
tokio::select! {
biased;
_ = cancel.cancelled() => {
// Poll even not-yet-started jobs so each admitted ticket is
// returned without acknowledging a newer foreground hint.
while work.next().await.is_some() {}
return;
}
_ = interval.tick() => {
work.begin_pass();
if failures != previous {
tracing::warn!(
event = EVENT_DELETE_RECOVERY_STATUS,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_DELETE_RECOVERY,
state = "recovery_retry_changed",
pending = failures.pending,
blocked = failures.blocked,
corrupt = failures.corrupt,
failed = failures.failed,
pending_keys = store.ctx.delete_recovery_hints().pending_len(),
"Delete recovery settlement retry state changed"
);
previous = failures;
}
failures = RecoveryFailures::default();
}
Some(result) = work.next(), if !work.tasks.is_empty() => failures.add(result),
}
work.admit();
}
}
pub(super) fn start_delete_recovery(store: Arc<ECStore>, cancel: CancellationToken) {
if !store.ctx.delete_recovery_hints().start(store.id) {
return;
@@ -665,36 +786,9 @@ pub(super) fn start_delete_recovery(store: Arc<ECStore>, cancel: CancellationTok
}
}
};
let recovery = async {
let mut interval = tokio::time::interval(INTERVAL);
interval.set_missed_tick_behavior(MissedTickBehavior::Delay);
let mut previous = RecoveryFailures::default();
loop {
tokio::select! {
biased;
_ = cancel.cancelled() => return,
_ = interval.tick() => {},
}
let failures = recover_hints(&store, &cancel).await;
if failures != previous {
tracing::warn!(
event = EVENT_DELETE_RECOVERY_STATUS,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_DELETE_RECOVERY,
state = "recovery_retry_changed",
pending = failures.pending,
blocked = failures.blocked,
corrupt = failures.corrupt,
failed = failures.failed,
pending_keys = store.ctx.delete_recovery_hints().pending_len(),
"Delete recovery settlement retry state changed"
);
previous = failures;
}
}
};
// Long canonical verification uses its configured deadline while raw
// discovery continues to advance bounded, expiring disk cursors.
let recovery = run_recovery(&store, &cancel);
// Discovery and independent exact keys keep advancing while a slow
// rollback retains its configured deadline and ownership fences.
tokio::join!(discovery, recovery);
});
}
@@ -775,6 +869,26 @@ mod tests {
assert_eq!(hints.take(2).len(), 1, "failed work survives an empty queue");
}
#[test]
fn delete_recovery_active_key_preserves_newer_hint_and_yields_to_other_keys() {
let hints = DeleteRecoveryHints::with_capacity(4);
let original = hints.register(key("active")).expect("original request");
let admitted = hints.take(1).pop().expect("admit original generation");
let active = HashSet::from([admitted.key.clone()]);
let newer = hints.register(key("active")).expect("new foreground generation");
hints.register(key("later")).expect("independent key");
let later = hints.take_available(&active).expect("skip the active key");
assert_eq!(later.key.object, "later");
assert!(hints.take_available(&active).is_none(), "a newer generation cannot occupy a second slot");
hints.ack(&original);
hints.retry(admitted);
assert!(hints.is_pending(&key("active")), "old success or cancellation cannot clear newer work");
let next = hints
.take_available(&HashSet::new())
.expect("active key becomes eligible after completion");
assert_eq!(next.generation, newer.generation, "retry cannot overwrite the foreground generation");
}
#[test]
fn delete_recovery_hint_compaction_stays_amortized_near_capacity() {
let capacity = HINT_CAPACITY;
@@ -924,6 +1038,26 @@ mod tests {
});
}
#[tokio::test]
async fn delete_recovery_waits_for_metadata_before_consuming_startup_discovery() {
let (_dirs, store) = isolated_store_over_temp_disks().await;
store.background_cancel_token().expect("background token").cancel();
tokio::task::yield_now().await;
assert!(store.ctx.bucket_metadata_sys().is_none(), "metadata bootstrap has not completed");
let hints = store.ctx.delete_recovery_hints();
hints.request_rescan();
let generation = hints.rescan().expect("startup discovery obligation");
let mut pass = RecoveryPass::default();
pass.discover(&store, &CancellationToken::new()).await;
assert_eq!(hints.rescan(), Some(generation), "bootstrap cannot consume the discovery obligation");
assert!(pass.deferred.is_empty(), "bootstrap is not a failed disk scope");
assert!(pass.next_inventory.is_none(), "bootstrap must not delay the initial inventory");
init_bucket_metadata_sys(store.clone(), Vec::new()).await;
pass.discover(&store, &CancellationToken::new()).await;
assert!(hints.rescan().is_none(), "the first ready pass fulfills the empty-store scan immediately");
assert!(pass.deferred.is_empty());
}
#[tokio::test]
async fn delete_recovery_clean_startup_discovers_hidden_preparation() {
let bucket = "runtime-clean-start";
@@ -1087,6 +1221,148 @@ mod tests {
assert_eq!(hints.rescan(), Some(foreground), "an older scan cannot clear a foreground obligation");
}
#[tokio::test]
async fn delete_recovery_admission_stays_within_each_tick_budget() {
let bucket = "runtime-admission-budget";
let (_dirs, store, _disks) = fixture(bucket).await;
let incarnation = store.bucket_incarnation_id_from_disk(bucket).await.expect("bucket identity");
let hints = store.ctx.delete_recovery_hints();
let total = KEYS_PER_PASS * 3;
for index in 0..total {
hints.register(DeleteRecoveryKey {
pool: 0,
set: 0,
bucket: bucket.to_owned(),
object: format!("absent-{index}"),
incarnation: Some(incarnation),
});
}
for pass in 1..=3 {
let failures = recover_hints(&store, &CancellationToken::new()).await;
assert!(failures == RecoveryFailures::default(), "absent exact keys settle successfully");
assert_eq!(
hints.pending_len(),
total - pass * KEYS_PER_PASS,
"only the current admission budget may leave the queue"
);
}
}
#[tokio::test]
async fn delete_recovery_cancel_returns_every_unpolled_admission() {
let bucket = "runtime-cancel-admission";
let (_dirs, store, _disks) = fixture(bucket).await;
let hints = store.ctx.delete_recovery_hints();
for index in 0..KEYS_PER_PASS {
hints.register(key(&format!("queued-{index}")));
}
let cancel = CancellationToken::new();
let mut work = RecoveryWork::new(&store, &cancel);
work.begin_pass();
work.admit();
assert_eq!(work.active.len(), RECOVERY_CONCURRENCY, "admission obeys the concurrency cap");
assert_eq!(hints.pending_len(), KEYS_PER_PASS - RECOVERY_CONCURRENCY);
let newer = hints.register(key("queued-0")).expect("foreground request during admission");
cancel.cancel();
while let Some(result) = work.next().await {
assert!(result == RecoveryFailures::default(), "cancellation is not a storage failure");
work.admit();
}
assert!(work.active.is_empty());
assert_eq!(hints.pending_len(), KEYS_PER_PASS, "every admitted key is retained exactly once");
let tickets = hints.take(KEYS_PER_PASS);
assert_eq!(
tickets
.iter()
.find(|ticket| ticket.key.object == "queued-0")
.expect("newer hint")
.generation,
newer.generation
);
}
#[tokio::test]
async fn delete_recovery_slow_key_does_not_block_later_batches() {
temp_env::async_with_vars([(rustfs_config::ENV_OBJECT_LOCK_ACQUIRE_TIMEOUT, Some("60"))], async {
let bucket = "runtime-independent-keys";
let (_dirs, store, disks) = fixture(bucket).await;
prepare(&store, &disks, bucket).await;
let incarnation = store.bucket_incarnation_id_from_disk(bucket).await.expect("bucket identity");
let lock = store.handle_new_ns_lock(bucket, "slow-key").await.expect("slow key lock");
let held = lock
.get_write_lock(Duration::from_secs(60))
.await
.expect("hold only the slow key");
let hints = store.ctx.delete_recovery_hints();
for object in std::iter::once("slow-key".to_owned())
.chain((0..KEYS_PER_PASS * 2).map(|index| format!("healthy-{index}")))
.chain(std::iter::once("object".to_owned()))
{
hints.register(DeleteRecoveryKey {
pool: 0,
set: 0,
bucket: bucket.to_owned(),
object,
incarnation: Some(incarnation),
});
}
let cancel = CancellationToken::new();
let worker_store = store.clone();
let worker_cancel = cancel.clone();
let worker = tokio::spawn(async move { run_recovery(&worker_store, &worker_cancel).await });
let started = Instant::now();
let recovered = tokio::time::timeout(Duration::from_secs(10), async {
loop {
let restored = futures::future::join_all(disks.iter().map(|disk| async move {
disk.read_all(bucket, &format!("object/{STORAGE_FORMAT_FILE}")).await.is_ok()
}))
.await;
if restored.into_iter().all(|restored| restored) {
break;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
})
.await;
eprintln!(
"healthy rollback behind {} keys completed={} after {:.3}s while the first key stayed locked",
KEYS_PER_PASS * 2 + 1,
recovered.is_ok(),
started.elapsed().as_secs_f64()
);
cancel.cancel();
worker.await.expect("cancel every admitted recovery");
drop(held);
assert!(recovered.is_ok(), "one locked key must not block healthy work beyond the first batch");
let mut reader = store
.get_object_reader(bucket, "object", None, http::HeaderMap::new(), &ObjectOptions::default())
.await
.expect("read recovered source");
let mut bytes = Vec::new();
tokio::io::AsyncReadExt::read_to_end(&mut reader.stream, &mut bytes)
.await
.expect("read complete source bytes");
assert_eq!(bytes, vec![19; 4096], "healthy rollback preserves complete original bytes");
for disk in &disks {
assert!(
disk.read_all(bucket, &format!("object/{STORAGE_FORMAT_FILE}")).await.is_ok(),
"rollback restores each participant"
);
}
assert!(
hints.is_pending(&DeleteRecoveryKey {
pool: 0,
set: 0,
bucket: bucket.to_owned(),
object: "slow-key".to_owned(),
incarnation: Some(incarnation),
}),
"cancellation retains the slow key for retry"
);
})
.await;
}
#[tokio::test]
async fn delete_recovery_slow_first_key_preserves_unattempted_backpressure() {
let bucket = "runtime-slow-first";
@@ -1108,20 +1384,29 @@ mod tests {
let worker_store = store.clone();
let worker = tokio::spawn(async move { recover_hints(&worker_store, &CancellationToken::new()).await });
for _ in 0..100 {
if hints.pending_len() != HINT_CAPACITY {
if hints.pending_len() == HINT_CAPACITY - RECOVERY_CONCURRENCY {
break;
}
tokio::task::yield_now().await;
}
assert_eq!(hints.pending_len(), HINT_CAPACITY - 1, "only the admitted key leaves the queue");
assert!(hints.register_discovered(key("newly-discovered")), "one in-flight key releases one slot");
assert_eq!(
hints.pending_len(),
HINT_CAPACITY - RECOVERY_CONCURRENCY,
"only admitted keys leave the queue"
);
for index in 0..RECOVERY_CONCURRENCY {
assert!(
hints.register_discovered(key(&format!("newly-discovered-{index}"))),
"one in-flight key releases one slot"
);
}
assert!(!hints.register_discovered(key("must-wait")), "untouched keys must apply backpressure");
tokio::time::advance(PASS_START_BUDGET + Duration::from_secs(1)).await;
drop(held);
worker.await.expect("finish the admitted operation");
tokio::time::resume();
let state = hints.state.lock().expect("retained pending work");
for index in 1..HINT_CAPACITY {
for index in RECOVERY_CONCURRENCY..HINT_CAPACITY {
let queued = state
.pending
.iter()
+78 -19
View File
@@ -14,7 +14,7 @@
use super::*;
use crate::core::pools::{POOL_META_NAME, load_pool_meta_identity_observing};
use crate::disk::{self, error::DiskError};
use crate::disk::{self, current_resource_unit, error::DiskError, with_optional_resource_unit};
use crate::services::rebalance::{REBAL_META_NAME, RebalStatus};
use crate::set_disk::get_lock_acquire_timeout;
use crate::storage_api_contracts::heal::HealOperations as _;
@@ -39,6 +39,10 @@ pub(crate) struct BucketHealScope {
}
impl BucketHealScope {
pub(crate) fn lifecycle_guard(&self) -> Option<&NamespaceLockGuard> {
self.fence.namespace_lock_guard()
}
pub(crate) fn check(&self) -> disk::error::Result<()> {
if self.fence.is_lock_lost() {
return Err(DiskError::other("bucket heal incarnation fence was lost"));
@@ -301,12 +305,15 @@ impl ECStore {
let opts = *opts;
// Dropping a caller's cancellation/timeout waiter must not release the lifecycle
// owner while a storage operation is still committing.
tokio::spawn(BUCKET_HEAL_SCOPE.scope(scope.clone(), async move {
scope.check().map_err(Error::from)?;
let result = operation(store, bucket, opts).await;
scope.check().map_err(Error::from)?;
result
}))
tokio::spawn(with_optional_resource_unit(
current_resource_unit(),
BUCKET_HEAL_SCOPE.scope(scope.clone(), async move {
scope.check().map_err(Error::from)?;
let result = operation(store, bucket, opts).await;
scope.check().map_err(Error::from)?;
result
}),
))
.await
.map_err(|error| Error::other_with_context("bucket heal owner task failed", error))?
}
@@ -1067,16 +1074,25 @@ impl ECStore {
version_id: &str,
opts: &HealOpts,
) -> Result<(HealResultItem, Option<Error>)> {
self.handle_heal_object_with_absence(
crate::bucket::artifact::with_artifact_repair_scope(
&self.ctx,
bucket,
object,
version_id,
opts,
&mut None,
crate::set_disk::AbsenceProofRequest {
retirement: None,
allow_unversioned: false,
},
Box::pin(async {
self.handle_heal_object_with_absence(
bucket,
object,
version_id,
opts,
&mut None,
crate::set_disk::AbsenceProofRequest {
retirement: None,
allow_unversioned: false,
},
)
.await
}),
)
.await
}
@@ -1110,6 +1126,24 @@ impl ECStore {
version_id: &str,
opts: &HealOpts,
allow_unversioned_absence: bool,
) -> Result<HealObjectStorageResult> {
crate::bucket::artifact::with_artifact_repair_scope(
&self.ctx,
bucket,
object,
opts,
Box::pin(self.heal_object_with_absence_proof_inner(bucket, object, version_id, opts, allow_unversioned_absence)),
)
.await
}
async fn heal_object_with_absence_proof_inner(
&self,
bucket: &str,
object: &str,
version_id: &str,
opts: &HealOpts,
allow_unversioned_absence: bool,
) -> Result<HealObjectStorageResult> {
// Object heal may recreate a missing bucket volume before committing a
// shard. Keep the current bucket generation fenced for the whole
@@ -1128,7 +1162,11 @@ impl ECStore {
// Reuse the admission's owner: reacquiring a read lock behind a queued
// lifecycle writer would deadlock with the read lock we already hold.
let scope = bucket_heal_scope(bucket).filter(|scope| std::ptr::eq(scope.store.as_ref(), self));
let guard = if let Some(scope) = &scope {
let repair = crate::bucket::artifact::artifact_repair_scope(bucket, object);
let guard = if let Some(repair) = &repair {
repair.check()?;
None
} else if let Some(scope) = &scope {
scope.check().map_err(Error::from)?;
None
} else {
@@ -1136,11 +1174,13 @@ impl ECStore {
};
let fence_is_lost = || {
scope.as_ref().is_some_and(|scope| scope.fence.is_lock_lost())
|| repair.as_ref().is_some_and(|scope| scope.check().is_err())
|| guard.as_ref().is_some_and(NamespaceLockGuard::is_lock_lost)
};
let lifecycle_guard = scope
.as_ref()
.and_then(|scope| scope.fence.namespace_lock_guard())
.or_else(|| repair.as_ref().and_then(|scope| scope.lifecycle_guard()))
.or(guard.as_ref())
.ok_or_else(|| Error::other("bucket heal requires a held lifecycle guard"))?;
let retirement = crate::bucket::retirement::MarkerRetirementContext {
@@ -1953,6 +1993,11 @@ mod tests {
#[tokio::test]
#[serial_test::serial]
async fn bucket_incarnation_heal_owner_survives_cancelled_waiter_and_queued_writer() {
use crate::disk::{current_resource_unit, with_background_resource_unit};
use rustfs_concurrency::adaptive::AdaptiveAdmission;
use std::num::NonZeroU16;
let resources = AdaptiveAdmission::new(NonZeroU16::new(1).expect("one unit"), 1);
let (_root, store, shutdown) = multi_pool_heal_store().await;
let bucket = format!("heal-owner-{}", Uuid::new_v4().simple());
store
@@ -1965,14 +2010,16 @@ mod tests {
let (drained_tx, drained_rx) = tokio::sync::oneshot::channel();
let worker_store = store.clone();
let worker_bucket = bucket.clone();
let waiter = tokio::spawn(async move {
let waiter = tokio::spawn(with_background_resource_unit(resources.acquire().await, async move {
worker_store
.run_bucket_heal_at_incarnation(
&worker_bucket,
identity,
&HealOpts::default(),
move |store, bucket, opts| async move {
started_tx.send(()).expect("announce held generation");
started_tx
.send(current_resource_unit().is_some())
.expect("announce held generation");
release_rx.await.expect("release commit");
let result = store
.heal_object_with_proof(&bucket, "history.txt", &Uuid::new_v4().to_string(), &opts)
@@ -1985,10 +2032,11 @@ mod tests {
},
)
.await
});
started_rx.await.expect("operation acquired generation fence");
}));
let inherited = started_rx.await.expect("operation acquired generation fence");
waiter.abort();
assert!(waiter.await.expect_err("waiter aborted").is_cancelled());
let held = resources.snapshot().active;
let writer_store = store.clone();
let writer_bucket = bucket.clone();
let mut writer = Box::pin(writer_store.acquire_bucket_lifecycle_write_lock(&writer_bucket));
@@ -2007,6 +2055,15 @@ mod tests {
.expect("lifecycle writer");
drop(guard);
shutdown.cancel();
tokio::time::timeout(std::time::Duration::from_secs(5), async {
while resources.snapshot().active != 0 {
tokio::task::yield_now().await;
}
})
.await
.expect("drained heal releases its resource unit");
assert!(inherited, "owned bucket heal must inherit resource ownership");
assert_eq!(held, 1, "cancelled waiter must not release a live heal's resource unit");
}
async fn minimal_heal_store() -> ECStore {
@@ -2025,6 +2082,7 @@ mod tests {
pool_meta_save_gate: Mutex::default(),
ctx: crate::runtime::instance::bootstrap_ctx(),
bucket_fence_registry: std::sync::Arc::default(),
ordinary_classification: Default::default(),
}
}
@@ -4128,6 +4186,7 @@ mod tests {
pool_meta_save_gate: Mutex::default(),
ctx: crate::runtime::instance::bootstrap_ctx(),
bucket_fence_registry: std::sync::Arc::default(),
ordinary_classification: Default::default(),
};
let err = store
+259 -13
View File
@@ -428,6 +428,7 @@ impl ECStore {
let mut deployment_id = None;
let mut pool_meta_bootstrap_authorities = Vec::new();
let mut pending_format_disks = Vec::new();
// let (endpoint_pools, _) = EndpointServerPools::create_server_endpoints(address.as_str(), &layouts)?;
@@ -553,6 +554,7 @@ impl ECStore {
// formatted it, `LegacyAdoption` when it verified the migration, and
// `None` when it merely read a format another node created.
pool_meta_bootstrap_authorities.push(loaded_format.pool_meta_bootstrap_authority);
pending_format_disks.extend(loaded_format.pending_disks);
let fm = loaded_format.format;
// Format loading succeeded, enable health monitoring on all disks
@@ -618,6 +620,7 @@ impl ECStore {
// this point) and later reads share one cell.
ctx: instance_ctx.clone(),
bucket_fence_registry: std::sync::Arc::default(),
ordinary_classification: Default::default(),
});
// Only set it when this instance's deployment ID is not yet configured
@@ -627,8 +630,21 @@ impl ECStore {
let wait_sec = 5;
let mut exit_count = 0;
let disk_sets = ec
.pools
.iter()
.flat_map(|pool| pool.disk_set.iter().cloned())
.collect::<Vec<_>>();
loop {
if let Err(err) = ec.init(ctx.clone()).await {
let initialization = async {
ec.init(ctx.clone()).await?;
for pending in &pending_format_disks {
pending.finalize(&instance_ctx, &disk_sets).await?;
}
Ok::<(), Error>(())
}
.await;
if let Err(err) = initialization {
error!("init err: {}", err);
error!("retry after {} second", wait_sec);
sleep(Duration::from_secs(wait_sec)).await;
@@ -652,6 +668,11 @@ impl ECStore {
#[instrument(level = "debug", skip(self, rx))]
pub async fn init(self: &Arc<Self>, rx: CancellationToken) -> Result<()> {
// Keep startup state out of callers and the instrumented wrapper.
Box::pin(self.init_inner(rx)).await
}
async fn init_inner(self: &Arc<Self>, rx: CancellationToken) -> Result<()> {
runtime_sources::ensure_boot_time().await;
let should_persist_pool_meta = self
@@ -869,6 +890,23 @@ impl ECStore {
#[cfg(test)]
mod tests {
#[cfg(feature = "test-util")]
mod artifact_bucket_tests;
#[cfg(unix)]
mod artifact_inventory_tests;
#[cfg(feature = "test-util")]
mod artifact_put_seal_tests;
#[cfg(all(feature = "test-util", unix))]
mod artifact_receiver_tests;
#[cfg(feature = "test-util")]
mod artifact_repair_tests;
#[cfg(feature = "test-util")]
mod artifact_retirement_tests;
#[cfg(feature = "test-util")]
mod artifact_write_entrypoint_tests;
#[cfg(all(feature = "test-util", unix))]
mod artifact_writes_seal_tests;
use super::{
LOCAL_DECOMMISSION_RESUME_RETRY_DELAY, LOCAL_DECOMMISSION_WATCHDOG_MAX_RETRY_DELAY, PoolMetaWriteState,
establish_pool_meta_bootstrap_identity_if_proven, load_pool_meta_for_startup, local_decommission_watchdog_retry_delay,
@@ -1781,7 +1819,7 @@ mod tests {
let mut pools = Vec::new();
for _ in 0..4 {
let (pool_dirs, pool_disks, pool) = hermetic_set_disks_isolated(4).await;
dirs.extend(pool_dirs);
dirs.push(pool_dirs);
disks.push(pool_disks);
pools.push(pool);
}
@@ -7298,8 +7336,8 @@ mod tests {
)
.await
.expect("seed older target metadata on all disks");
let source = store.pools[0]
.get_disks_by_key(object)
let source_set = store.pools[0].get_disks_by_key(object);
let mut source = source_set
.load_file_info_versions_exact(&bucket, object)
.await
.expect("read source marker metadata")
@@ -7308,6 +7346,30 @@ mod tests {
.into_iter()
.find(|version| version.version_id.unwrap_or_default() == marker_id)
.expect("find source delete marker");
if marker_incarnation.is_none() {
// Public writes bind new markers to the current bucket generation.
// Seed legacy disk metadata explicitly without weakening admission.
rustfs_utils::http::metadata_compat::remove_str(
&mut source.metadata,
rustfs_utils::http::metadata_compat::SUFFIX_BUCKET_INCARNATION_ID,
);
for disk in source_set.disk_inventory().await.into_iter().flatten() {
disk.write_metadata("", &bucket, object, source.clone())
.await
.expect("seed legacy source marker metadata");
}
source_set.invalidate_get_object_metadata_cache(&bucket, object).await;
}
let source = source_set
.load_file_info_versions_exact(&bucket, object)
.await
.expect("reload source marker metadata")
.expect("source marker must remain present")
.versions
.into_iter()
.find(|version| version.version_id.unwrap_or_default() == marker_id)
.expect("find reloaded source delete marker");
assert_eq!(source.delete_marker_incarnation(), marker_incarnation, "source fixture identity");
let target_set = store.pools[1].get_disks_by_key(object);
for disk in target_set.disk_inventory().await.into_iter().flatten().take(2) {
disk.write_metadata("", &bucket, object, source.clone())
@@ -7733,6 +7795,103 @@ mod tests {
.expect_err("suspended delete must remove the requested UUID version");
}
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn decommission_entry_retains_lifecycle_owner_through_source_cleanup() {
use crate::disk::DiskAPI as _;
let temp_dir = tempfile::tempdir().expect("create decommission lifecycle-owner store");
let (_ctx, store, shutdown) = Box::pin(without_storage_class_env(build_isolated_test_store(
temp_dir.path(),
"decommission-lifecycle-owner",
&[4, 4],
)))
.await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(Arc::clone(&store), Vec::new()).await;
let bucket = format!("decom-lifecycle-owner-{}", uuid::Uuid::new_v4());
let object = "source.bin";
let payload = vec![b'o'; 1_300_000];
Box::pin(store.make_bucket(&bucket, &MakeBucketOptions::default()))
.await
.expect("create ordinary source bucket");
let incarnation = store.bucket_incarnation_id(&bucket).await.expect("source bucket incarnation");
let opts = ObjectOptions {
expected_bucket_incarnation_id: Some(incarnation),
..Default::default()
};
Box::pin(seed_decommission_source(&store, &bucket, object, payload.clone(), &opts)).await;
let source_set = store.pools[0].get_disks_by_key(object);
let expected = source_set
.load_file_info_versions_exact(&bucket, object)
.await
.expect("load the exact cleanup snapshot")
.expect("source object must exist");
let source_disks = source_set.disk_inventory().await;
assert_eq!(source_disks.iter().flatten().count(), 4, "all source disks must be online");
let owner = store
.acquire_bucket_incarnation_fence(&bucket, incarnation)
.await
.expect("hold source bucket lifecycle");
let rejected = Box::pin(crate::data_movement::cleanup_source_entry_if_unchanged(
Arc::clone(&source_set),
&bucket,
object,
&expected,
&[],
&[],
crate::data_movement::SourceCleanupBucketFence {
expected_incarnation_id: Some(incarnation),
lifecycle_guard: owner.namespace_lock_guard(),
..Default::default()
},
"test_unowned_source_cleanup",
))
.await
.expect_err("a bare lifecycle signal must not authorize source cleanup");
assert!(
rejected
.to_string()
.contains("write requires an owned bucket lifecycle fence"),
"cleanup must reject the missing owner: {rejected}"
);
let mut source = source_set
.get_object_reader(&bucket, object, None, HeaderMap::new(), &opts)
.await
.expect("rejected cleanup must preserve the readable source");
let mut remaining = Vec::new();
source
.stream
.read_to_end(&mut remaining)
.await
.expect("drain preserved source");
assert_eq!(remaining, payload);
drop(source);
drop(owner);
mark_test_pool_decommissioning(&store, 0).await;
Box::pin(run_decommission_entry_retry_test(
&store,
CancellationToken::new(),
&bucket,
object,
Some(incarnation),
Arc::new(AtomicUsize::new(0)),
))
.await
.expect("decommission must copy and clean the ordinary bucket entry with its real owner");
assert_eq!(read_decommission_target_body(&store, &bucket, object, &opts).await, payload);
assert_decommission_source_absent(&store, &bucket, object, &opts).await;
for disk in source_disks.into_iter().flatten() {
assert!(
!tokio::fs::try_exists(disk.path().join(&bucket).join(object))
.await
.expect("inspect the physical source path"),
"source metadata and shards must be removed from every disk"
);
}
shutdown.cancel();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial_test::serial(storage_class_env)]
async fn decommission_entry_carries_migration_and_cleanup_mutation_fences() {
@@ -11861,6 +12020,90 @@ mod tests {
.len()
}
// Recovery removes metadata replicas disk by disk. An unreadable prefix
// is incomplete evidence and must reset, never advance, the empty streak.
#[cfg(feature = "test-util")]
fn tier_delete_recovery_listing_is_empty(prefix: &str, count: crate::error::Result<usize>) -> bool {
match count {
Ok(count) => count == 0,
Err(StorageError::InsufficientReadQuorum(bucket, object)) if bucket == RUSTFS_META_BUCKET && object == prefix => {
false
}
Err(error) => panic!("{prefix} recovery inventory should be listable: {error:?}"),
}
}
#[cfg(feature = "test-util")]
async fn tier_delete_recovery_prefix_is_empty(store: Arc<crate::store::ECStore>, prefix: &str) -> bool {
let store_id = store.id;
let started = std::time::Instant::now();
let count = store
.list_objects_v2(RUSTFS_META_BUCKET, prefix, None, None, 100, false, None, false)
.await
.map(|listing| listing.objects.len());
if let Err(error) = &count {
eprintln!(
"tier-delete recovery inventory unavailable: store={store_id} prefix={prefix} elapsed={:?} error={error:?}",
started.elapsed()
);
}
tier_delete_recovery_listing_is_empty(prefix, count)
}
#[cfg(feature = "test-util")]
#[test]
fn tier_delete_recovery_observations_require_readable_empty_prefixes() {
for prefix in [TIER_DELETE_JOURNAL_PREFIX, TIER_DELETE_DISPATCH_MANIFEST_PREFIX] {
let observations = [
Ok(0),
Err(StorageError::InsufficientReadQuorum(RUSTFS_META_BUCKET.to_string(), prefix.to_string())),
Ok(0),
Ok(1),
Ok(0),
];
let mut stable_empty = 0;
let streaks = observations
.into_iter()
.map(|count| {
if tier_delete_recovery_listing_is_empty(prefix, count) {
stable_empty += 1;
} else {
stable_empty = 0;
}
stable_empty
})
.collect::<Vec<_>>();
assert_eq!(streaks, [1, 0, 1, 0, 1], "unreadable or nonempty {prefix} cannot certify convergence");
for _ in 0..10 {
assert!(!tier_delete_recovery_listing_is_empty(
prefix,
Err(StorageError::InsufficientReadQuorum(RUSTFS_META_BUCKET.to_string(), prefix.to_string())),
));
}
}
}
#[cfg(feature = "test-util")]
#[test]
fn tier_delete_recovery_observations_reject_unrelated_errors() {
for prefix in [TIER_DELETE_JOURNAL_PREFIX, TIER_DELETE_DISPATCH_MANIFEST_PREFIX] {
for error in [
StorageError::InsufficientReadQuorum("other-bucket".to_string(), prefix.to_string()),
StorageError::InsufficientReadQuorum(RUSTFS_META_BUCKET.to_string(), "other-prefix".to_string()),
StorageError::FileNotFound,
StorageError::ErasureReadQuorum,
] {
assert!(
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
tier_delete_recovery_listing_is_empty(prefix, Err(error))
}))
.is_err(),
"only the exact recovery prefix's InsufficientReadQuorum may defer an observation"
);
}
}
}
#[cfg(feature = "test-util")]
async fn install_committed_tier_delete_journals(
store: Arc<crate::store::ECStore>,
@@ -11968,8 +12211,8 @@ mod tests {
recover_tier_delete_journal_entries(store.clone(), 100, None)
.await
.expect("restarted journal recovery pass should finish");
if tier_delete_journal_count(store.clone()).await == 0
&& tier_delete_dispatch_manifest_count(store.clone()).await == 0
if tier_delete_recovery_prefix_is_empty(store.clone(), TIER_DELETE_JOURNAL_PREFIX).await
&& tier_delete_recovery_prefix_is_empty(store.clone(), TIER_DELETE_DISPATCH_MANIFEST_PREFIX).await
&& tier_delete_dispatch_manifest_recovery_count_for_test(&store) == 0
{
stable_empty_observations += 1;
@@ -13397,8 +13640,8 @@ mod tests {
tokio::time::timeout(Duration::from_secs(30), async {
let mut stable_empty_observations = 0;
loop {
if tier_delete_journal_count(restarted_store.clone()).await == 0
&& tier_delete_dispatch_manifest_count(restarted_store.clone()).await == 0
if tier_delete_recovery_prefix_is_empty(restarted_store.clone(), TIER_DELETE_JOURNAL_PREFIX).await
&& tier_delete_recovery_prefix_is_empty(restarted_store.clone(), TIER_DELETE_DISPATCH_MANIFEST_PREFIX).await
&& tier_delete_dispatch_manifest_recovery_count_for_test(&restarted_store) == 0
{
stable_empty_observations += 1;
@@ -13522,8 +13765,8 @@ mod tests {
tokio::time::timeout(Duration::from_secs(30), async {
let mut stable_empty_observations = 0;
loop {
if tier_delete_journal_count(restarted_store.clone()).await == 0
&& tier_delete_dispatch_manifest_count(restarted_store.clone()).await == 0
if tier_delete_recovery_prefix_is_empty(restarted_store.clone(), TIER_DELETE_JOURNAL_PREFIX).await
&& tier_delete_recovery_prefix_is_empty(restarted_store.clone(), TIER_DELETE_DISPATCH_MANIFEST_PREFIX).await
&& tier_delete_dispatch_manifest_recovery_count_for_test(&restarted_store) == 0
{
stable_empty_observations += 1;
@@ -20989,10 +21232,13 @@ mod tests {
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[test]
#[serial_test::serial(storage_class_env)]
async fn legacy_recovery_disposition_removes_only_local_journals_and_replays() {
Box::pin(legacy_recovery_disposition_removes_only_local_journals_and_replays_case()).await;
fn legacy_recovery_disposition_removes_only_local_journals_and_replays() {
run_large_stack_async_test(
"legacy-recovery-disposition",
legacy_recovery_disposition_removes_only_local_journals_and_replays_case,
);
}
#[cfg(feature = "test-util")]
@@ -0,0 +1,757 @@
use super::{build_isolated_test_store, without_storage_class_env};
use crate::{
bucket::{
metadata::{
BUCKET_INCARNATION_FILE, BUCKET_METADATA_FILE, BUCKET_TAGGING_CONFIG, BUCKET_VERSIONING_CONFIG, BucketMetadata,
load_bucket_metadata_parse_with_presence,
},
metadata_sys,
versioning::VersioningApi,
},
disk::{BUCKET_META_PREFIX, DiskAPI as _, RUSTFS_META_BUCKET},
runtime::instance::InstanceContext,
storage_api_contracts::{
bucket::{BucketOperations as _, DeleteBucketOptions, MakeBucketOptions},
namespace::NamespaceLocking as _,
},
store::ECStore,
};
use std::{sync::Arc, time::Duration};
use tokio_util::sync::CancellationToken;
use uuid::Uuid;
const PREFIX: &str = "outputs/";
const ENABLED: &[u8] = b"<VersioningConfiguration><Status>Enabled</Status></VersioningConfiguration>";
const SUSPENDED: &[u8] = b"<VersioningConfiguration><Status>Suspended</Status></VersioningConfiguration>";
const EXCLUDED_PREFIX: &[u8] = b"<VersioningConfiguration><Status>Enabled</Status><ExcludedPrefixes><Prefix>outputs/</Prefix></ExcludedPrefixes></VersioningConfiguration>";
const EXCLUDED_FOLDERS: &[u8] =
b"<VersioningConfiguration><Status>Enabled</Status><ExcludeFolders>true</ExcludeFolders></VersioningConfiguration>";
const TAGGING: &[u8] = b"<Tagging><TagSet><Tag><Key>purpose</Key><Value>artifact-test</Value></Tag></TagSet></Tagging>";
async fn open_store(root: &std::path::Path) -> (Arc<InstanceContext>, Arc<ECStore>, CancellationToken) {
let (ctx, store, shutdown) = without_storage_class_env(build_isolated_test_store(root, "artifact-bucket", &[4])).await;
metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
(ctx, store, shutdown)
}
async fn drain_before_reopen(store: &ECStore, shutdown: &CancellationToken, bucket: &str) {
shutdown.cancel();
assert!(
store.ctx.wait_for_detached_mutations(Duration::from_secs(30)).await,
"ARTIFACT_PREVIOUS_STORE_MUTATIONS_DRAINED"
);
// Internal metadata PUTs can acknowledge quorum before their disk tails finish.
// Acquire each key separately to wait for its actual writer to release the lock.
for file in [BUCKET_METADATA_FILE, BUCKET_INCARNATION_FILE] {
let key = format!("{BUCKET_META_PREFIX}/{bucket}/{file}");
let namespace = store.pools[0].disk_set[0]
.new_ns_lock(RUSTFS_META_BUCKET, &key)
.await
.expect("resolve persisted identity namespace");
drop(
namespace
.get_write_lock(Duration::from_secs(30))
.await
.expect("identity write tail drained"),
);
}
}
async fn dedicated_bucket(store: &ECStore, bucket: &str) {
store
.make_bucket(
bucket,
&MakeBucketOptions {
artifact_base_prefix: Some(PREFIX.to_owned()),
// Creation must enforce versioning without relying on the request flag.
versioning_enabled: false,
..Default::default()
},
)
.await
.expect("create a dedicated artifact bucket on real disks");
}
async fn persisted(store: &Arc<ECStore>, bucket: &str) -> BucketMetadata {
let (metadata, exists) = load_bucket_metadata_parse_with_presence(store.clone(), bucket, true)
.await
.expect("read actual persisted bucket metadata");
assert!(exists, "ARTIFACT_METADATA_PERSISTED");
metadata
}
fn assert_namespace(metadata: &BucketMetadata, incarnation: Uuid) {
assert_eq!(metadata.artifact_base_prefix.as_deref(), Some(PREFIX), "ARTIFACT_NAMESPACE_IMMUTABLE");
assert_eq!(metadata.bucket_incarnation_id, incarnation, "ARTIFACT_INCARNATION_IMMUTABLE");
assert!(
metadata.versioning_config.as_ref().is_some_and(VersioningApi::enabled),
"ARTIFACT_VERSIONING_REQUIRED"
);
}
async fn assert_persisted_and_cached(store: &Arc<ECStore>, bucket: &str, incarnation: Uuid) {
assert_namespace(&persisted(store, bucket).await, incarnation);
let cached = metadata_sys::get_in(&store.ctx, bucket)
.await
.expect("read cached bucket identity");
assert_namespace(&cached, incarnation);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial_test::serial(storage_class_env)]
async fn artifact_bucket_creation_enables_versioning_and_survives_store_reopen() {
let root = tempfile::tempdir().expect("artifact bucket store directory");
let (ctx, store, shutdown) = open_store(root.path()).await;
let bucket = "artifact-created-reopen";
dedicated_bucket(&store, bucket).await;
let created = persisted(&store, bucket).await;
let incarnation = created.bucket_incarnation_id;
assert!(!incarnation.is_nil(), "ARTIFACT_INCARNATION_REQUIRED");
assert!(created.bucket_creation_committed, "ARTIFACT_CREATION_COMMITTED");
assert_namespace(&created, incarnation);
drain_before_reopen(&store, &shutdown, bucket).await;
drop(store);
drop(ctx);
let (_ctx, store, shutdown) = open_store(root.path()).await;
metadata_sys::reload_bucket_metadata(store.clone(), bucket)
.await
.expect("load the reopened bucket from persisted metadata");
assert_persisted_and_cached(&store, bucket, incarnation).await;
store
.make_bucket(
bucket,
&MakeBucketOptions {
force_create: true,
..Default::default()
},
)
.await
.expect("ordinary force-create replay preserves the dedicated namespace");
assert_persisted_and_cached(&store, bucket, incarnation).await;
shutdown.cancel();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial_test::serial(storage_class_env)]
async fn artifact_bucket_ordinary_configuration_update_preserves_namespace() {
let root = tempfile::tempdir().expect("artifact configuration store directory");
let (_ctx, store, shutdown) = open_store(root.path()).await;
let bucket = "artifact-config-update";
dedicated_bucket(&store, bucket).await;
let incarnation = persisted(&store, bucket).await.bucket_incarnation_id;
metadata_sys::update_in(&store.ctx, bucket, BUCKET_TAGGING_CONFIG, TAGGING.to_vec())
.await
.expect("ordinary tagging update remains supported");
let changed = persisted(&store, bucket).await;
assert_eq!(changed.tagging_config_xml, TAGGING, "ARTIFACT_ORDINARY_CONFIG_UPDATED");
assert_persisted_and_cached(&store, bucket, incarnation).await;
let sys = store.ctx.bucket_metadata_sys().expect("instance metadata system");
let sys = sys.read().await.clone();
let suspended = crate::bucket::utils::deserialize(SUSPENDED).expect("valid suspended versioning XML");
for stale_typed_config in [None, Some(suspended)] {
let mut replacement = changed.clone();
replacement.versioning_config = stale_typed_config;
sys.set_validated(replacement)
.await
.expect("validated cache setter reparses authoritative XML");
assert_persisted_and_cached(&store, bucket, incarnation).await;
}
shutdown.cancel();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial_test::serial(storage_class_env)]
async fn artifact_bucket_create_rejects_existing_bucket_type_and_prefix_changes() {
let root = tempfile::tempdir().expect("artifact immutable creation store directory");
let (_ctx, store, shutdown) = open_store(root.path()).await;
let ordinary = "artifact-existing-ordinary";
store
.make_bucket(ordinary, &MakeBucketOptions::default())
.await
.expect("create ordinary bucket");
let original = persisted(&store, ordinary).await;
assert!(original.artifact_base_prefix.is_none());
for force_create in [false, true] {
let result = store
.make_bucket(
ordinary,
&MakeBucketOptions {
artifact_base_prefix: Some(PREFIX.to_owned()),
force_create,
..Default::default()
},
)
.await;
assert!(result.is_err(), "ARTIFACT_NAMESPACE_IMMUTABLE: ordinary bucket cannot change type");
let unchanged = persisted(&store, ordinary).await;
assert_eq!(unchanged.artifact_base_prefix, None);
assert_eq!(unchanged.bucket_incarnation_id, original.bucket_incarnation_id);
assert_eq!(unchanged.versioning_config_xml, original.versioning_config_xml);
}
let dedicated = "artifact-existing-dedicated";
dedicated_bucket(&store, dedicated).await;
let incarnation = persisted(&store, dedicated).await.bucket_incarnation_id;
for force_create in [false, true] {
let result = store
.make_bucket(
dedicated,
&MakeBucketOptions {
artifact_base_prefix: Some("another/".to_owned()),
force_create,
..Default::default()
},
)
.await;
assert!(result.is_err(), "ARTIFACT_NAMESPACE_IMMUTABLE: create cannot replace an existing prefix");
assert_persisted_and_cached(&store, dedicated, incarnation).await;
}
shutdown.cancel();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial_test::serial(storage_class_env)]
async fn artifact_bucket_full_metadata_replacement_cannot_change_namespace() {
let root = tempfile::tempdir().expect("artifact metadata replacement store directory");
let (_ctx, store, shutdown) = open_store(root.path()).await;
let dedicated = "artifact-replacement-dedicated";
let ordinary = "artifact-replacement-ordinary";
dedicated_bucket(&store, dedicated).await;
store
.make_bucket(ordinary, &MakeBucketOptions::default())
.await
.expect("create ordinary replacement fixture");
let original = persisted(&store, dedicated).await;
let incarnation = original.bucket_incarnation_id;
let sys = store.ctx.bucket_metadata_sys().expect("instance metadata system");
let sys = sys.read().await.clone();
for route in 0..3 {
for prefix in [None, Some("replacement/".to_owned())] {
let mut replacement = original.clone();
replacement.artifact_base_prefix = prefix;
let result = match route {
0 => metadata_sys::set_bucket_metadata_in(&store.ctx, replacement).await,
1 => sys.persist_and_set(replacement).await,
_ => sys.set_validated(replacement).await,
};
assert!(
result.is_err(),
"ARTIFACT_NAMESPACE_IMMUTABLE: complete replacement cannot remove or change prefix"
);
assert_persisted_and_cached(&store, dedicated, incarnation).await;
}
let mut replacement = persisted(&store, ordinary).await;
let ordinary_incarnation = replacement.bucket_incarnation_id;
replacement.artifact_base_prefix = Some(PREFIX.to_owned());
replacement.versioning_config_xml = ENABLED.to_vec();
let result = match route {
0 => metadata_sys::set_bucket_metadata_in(&store.ctx, replacement).await,
1 => sys.persist_and_set(replacement).await,
_ => sys.set_validated(replacement).await,
};
assert!(
result.is_err(),
"ARTIFACT_NAMESPACE_IMMUTABLE: complete replacement cannot promote ordinary bucket"
);
let unchanged = persisted(&store, ordinary).await;
assert!(unchanged.artifact_base_prefix.is_none());
assert_eq!(unchanged.bucket_incarnation_id, ordinary_incarnation);
}
shutdown.cancel();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial_test::serial(storage_class_env)]
async fn artifact_bucket_versioning_cannot_be_suspended_or_removed() {
let root = tempfile::tempdir().expect("artifact mandatory versioning store directory");
let (_ctx, store, shutdown) = open_store(root.path()).await;
let bucket = "artifact-required-versioning";
dedicated_bucket(&store, bucket).await;
let original = persisted(&store, bucket).await;
let incarnation = original.bucket_incarnation_id;
let sys = store.ctx.bucket_metadata_sys().expect("instance metadata system");
let sys = sys.read().await.clone();
for xml in [SUSPENDED, b"".as_slice(), EXCLUDED_PREFIX, EXCLUDED_FOLDERS] {
let result = metadata_sys::update_in(&store.ctx, bucket, BUCKET_VERSIONING_CONFIG, xml.to_vec()).await;
assert!(
result.is_err(),
"ARTIFACT_VERSIONING_REQUIRED: configuration update cannot disable versioning"
);
assert_persisted_and_cached(&store, bucket, incarnation).await;
for route in 0..3 {
let mut replacement = original.clone();
replacement.versioning_config_xml = xml.to_vec();
replacement.versioning_config = None;
let result = match route {
0 => metadata_sys::set_bucket_metadata_in(&store.ctx, replacement).await,
1 => sys.persist_and_set(replacement).await,
_ => sys.set_validated(replacement).await,
};
assert!(
result.is_err(),
"ARTIFACT_VERSIONING_REQUIRED: complete replacement cannot disable versioning"
);
assert_persisted_and_cached(&store, bucket, incarnation).await;
}
}
shutdown.cancel();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial_test::serial(storage_class_env)]
async fn artifact_bucket_pending_creation_reopen_retry_preserves_namespace() {
let root = tempfile::tempdir().expect("artifact pending creation store directory");
let (ctx, store, shutdown) = open_store(root.path()).await;
let bucket = "artifact-pending-retry";
let mut intent = BucketMetadata::new_with_default_durability(bucket);
intent.artifact_base_prefix = Some(PREFIX.to_owned());
intent.versioning_config_xml = ENABLED.to_vec();
let incarnation = intent.bucket_incarnation_id;
// Persist through the production intent writer, before any physical bucket exists.
metadata_sys::set_new_bucket_metadata_intent_in(&ctx, intent)
.await
.expect("persist real creation intent");
let pending = persisted(&store, bucket).await;
assert!(!pending.bucket_creation_committed, "ARTIFACT_CREATION_PENDING");
assert_namespace(&pending, incarnation);
for disk in 0..4 {
assert!(
!root.path().join(format!("pool0/set0/disk{disk}/{bucket}")).exists(),
"ARTIFACT_PHYSICAL_BUCKET_NOT_CREATED"
);
}
drain_before_reopen(&store, &shutdown, bucket).await;
drop(store);
drop(ctx);
let (_ctx, store, shutdown) = open_store(root.path()).await;
let result = store
.make_bucket(
bucket,
&MakeBucketOptions {
artifact_base_prefix: Some("replacement/".to_owned()),
..Default::default()
},
)
.await;
assert!(result.is_err(), "ARTIFACT_NAMESPACE_IMMUTABLE: pending intent rejects a changed request");
let pending = persisted(&store, bucket).await;
assert_namespace(&pending, incarnation);
assert!(!pending.bucket_creation_committed);
store
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("retry resumes persisted creation identity");
let completed = persisted(&store, bucket).await;
assert!(completed.bucket_creation_committed, "ARTIFACT_CREATION_COMMITTED");
assert_persisted_and_cached(&store, bucket, incarnation).await;
for disk in 0..4 {
assert!(
root.path().join(format!("pool0/set0/disk{disk}/{bucket}")).is_dir(),
"ARTIFACT_PHYSICAL_BUCKET_CREATED"
);
}
shutdown.cancel();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial_test::serial(storage_class_env)]
async fn artifact_bucket_partial_volume_retry_preserves_pending_identity() {
let root = tempfile::tempdir().expect("artifact partial volume store directory");
let (ctx, store, shutdown) = open_store(root.path()).await;
let bucket = "artifact-partial-volume";
let mut intent = BucketMetadata::new_with_default_durability(bucket);
intent.artifact_base_prefix = Some(PREFIX.to_owned());
intent.versioning_config_xml = ENABLED.to_vec();
let incarnation = intent.bucket_incarnation_id;
metadata_sys::set_new_bucket_metadata_intent_in(&ctx, intent)
.await
.expect("persist real creation intent before a partial physical creation");
let disks = store.pools[0].disk_set[0].disk_inventory().await;
disks[0]
.as_ref()
.expect("first real disk")
.make_volume(bucket)
.await
.expect("create only one physical volume");
assert!(root.path().join(format!("pool0/set0/disk0/{bucket}")).is_dir());
for disk in 1..4 {
assert!(!root.path().join(format!("pool0/set0/disk{disk}/{bucket}")).exists());
}
store
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect_err("partial physical creation requires explicit recovery");
let pending = persisted(&store, bucket).await;
assert_namespace(&pending, incarnation);
assert!(!pending.bucket_creation_committed, "ARTIFACT_PARTIAL_CREATION_STAYS_PENDING");
for disk in 1..4 {
assert!(
!root.path().join(format!("pool0/set0/disk{disk}/{bucket}")).exists(),
"ARTIFACT_PARTIAL_CREATION_NOT_SILENTLY_COMPLETED"
);
}
shutdown.cancel();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial_test::serial(storage_class_env)]
async fn artifact_bucket_metadata_replacement_cannot_spoof_incarnation() {
let root = tempfile::tempdir().expect("artifact spoofed incarnation store directory");
let (_ctx, store, shutdown) = open_store(root.path()).await;
let bucket = "artifact-spoofed-incarnation";
dedicated_bucket(&store, bucket).await;
let original = persisted(&store, bucket).await;
let incarnation = original.bucket_incarnation_id;
let sys = store.ctx.bucket_metadata_sys().expect("instance metadata system");
let sys = sys.read().await.clone();
for route in 0..4 {
for spoofed in [Uuid::nil(), Uuid::new_v4()] {
for (prefix, xml) in [
(None, b"".as_slice()),
(None, SUSPENDED),
(Some("replacement/"), ENABLED),
(Some(PREFIX), ENABLED),
] {
let mut replacement = original.clone();
replacement.bucket_incarnation_id = spoofed;
replacement.artifact_base_prefix = prefix.map(str::to_owned);
replacement.versioning_config_xml = xml.to_vec();
replacement.versioning_config = None;
let result = match route {
0 => metadata_sys::set_bucket_metadata_in(&store.ctx, replacement).await,
1 => sys.persist_and_set(replacement).await,
2 => sys.set_validated(replacement).await,
_ => replacement.save_with_store(store.clone()).await,
};
assert!(
result.is_err(),
"ARTIFACT_INCARNATION_IMMUTABLE: route={route}, spoofed={spoofed}, prefix={prefix:?}"
);
assert_persisted_and_cached(&store, bucket, incarnation).await;
}
}
}
shutdown.cancel();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial_test::serial(storage_class_env)]
async fn artifact_bucket_real_delete_recreate_allows_new_namespace_and_incarnation() {
let root = tempfile::tempdir().expect("artifact genuine recreation store directory");
let (_ctx, store, shutdown) = open_store(root.path()).await;
let bucket = "artifact-genuine-recreation";
dedicated_bucket(&store, bucket).await;
let mut incarnation = persisted(&store, bucket).await.bucket_incarnation_id;
for prefix in [Some("replacement/"), None] {
store
.delete_bucket(bucket, &DeleteBucketOptions::default())
.await
.expect("delete the empty bucket through production storage");
store
.make_bucket(
bucket,
&MakeBucketOptions {
artifact_base_prefix: prefix.map(str::to_owned),
..Default::default()
},
)
.await
.expect("genuine recreation may choose a new bucket namespace");
let recreated = persisted(&store, bucket).await;
assert!(!recreated.bucket_incarnation_id.is_nil(), "ARTIFACT_INCARNATION_REQUIRED");
assert_ne!(recreated.bucket_incarnation_id, incarnation, "ARTIFACT_RECREATION_NEW_INCARNATION");
assert_eq!(recreated.artifact_base_prefix.as_deref(), prefix, "ARTIFACT_RECREATION_NEW_NAMESPACE");
assert_eq!(recreated.versioning_config.as_ref().is_some_and(VersioningApi::enabled), prefix.is_some());
assert!(recreated.bucket_creation_committed, "ARTIFACT_CREATION_COMMITTED");
let cached = metadata_sys::get_in(&store.ctx, bucket)
.await
.expect("recreated identity cached");
assert_eq!(cached.bucket_incarnation_id, recreated.bucket_incarnation_id);
assert_eq!(cached.artifact_base_prefix.as_deref(), prefix);
incarnation = recreated.bucket_incarnation_id;
}
shutdown.cancel();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial_test::serial]
async fn artifact_bucket_create_observes_current_generation() {
let root = tempfile::tempdir().expect("creation observation storage");
let (ctx, store, shutdown) = open_store(root.path()).await;
let bucket = "artifact-create-observation";
dedicated_bucket(&store, bucket).await;
let initial = persisted(&store, bucket).await.bucket_incarnation_id;
store
.delete_bucket(bucket, &DeleteBucketOptions::default())
.await
.expect("delete before observation");
dedicated_bucket(&store, bucket).await;
let current = persisted(&store, bucket).await.bucket_incarnation_id;
assert_ne!(initial, current);
// Invoke the exact observation phase consumed by make_artifact_bucket,
// after real replacement between its creation and observation phases.
assert_eq!(
store
.observe_artifact_bucket_incarnation(bucket, PREFIX)
.await
.expect("observe replacement"),
current,
"ARTIFACT_CREATE_CURRENT_GENERATION"
);
assert!(
store.observe_artifact_bucket_incarnation(bucket, "other/").await.is_err(),
"ARTIFACT_CREATE_CURRENT_GENERATION: mismatched namespace cannot produce a receipt"
);
let attempt = format!("{PREFIX}61/{}/{}/", Uuid::new_v4(), Uuid::new_v4());
assert!(
store
.clone()
.seal_artifact_attempt_puts(bucket, initial, &attempt)
.await
.is_err(),
"ARTIFACT_CREATE_CURRENT_GENERATION: old incarnation cannot seal same prefix"
);
store
.clone()
.seal_artifact_attempt_puts(bucket, current, &attempt)
.await
.expect("current identity seals");
drain_before_reopen(&store, &shutdown, bucket).await;
drop(store);
drop(ctx);
let (_ctx, reopened, stop) = open_store(root.path()).await;
assert_eq!(
reopened
.observe_artifact_bucket_incarnation(bucket, PREFIX)
.await
.expect("reopen observed identity"),
current,
"ARTIFACT_CREATE_CURRENT_GENERATION"
);
stop.cancel();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial_test::serial]
async fn artifact_bucket_create_observation_rejects_corrupt_authority() {
use crate::{
config::com::{read_config, save_config_with_opts},
object_api::{ObjectOptions, WriteCompletion},
};
let root = tempfile::tempdir().expect("creation observation faults");
let (mut ctx, mut store, mut shutdown) = open_store(root.path()).await;
let bucket = "artifact-create-corruption";
let incarnation = store
.clone()
.make_artifact_bucket(bucket, PREFIX)
.await
.expect("actual create and observation");
let original = persisted(&store, bucket).await;
let sys = ctx.bucket_metadata_sys().expect("actual metadata authority");
// These faults target the authoritative cache branch actually read by the
// observation. Public metadata updates reject them before publication.
for fault in 0..6 {
let mut damaged = original.clone();
match fault {
0 => damaged.name = "wrong-bucket".into(),
1 => damaged.artifact_base_prefix = Some("other/".into()),
2 => damaged.versioning_config = None,
3 => damaged.versioning_config.as_mut().expect("versioning").exclude_folders = Some(true),
4 => damaged.versioning_config = Some(crate::bucket::utils::deserialize(SUSPENDED).expect("suspended XML")),
_ => {
damaged.versioning_config = Some(crate::bucket::utils::deserialize(EXCLUDED_PREFIX).expect("excluded prefix XML"))
}
}
sys.read().await.set_for_test(bucket.into(), Arc::new(damaged)).await;
assert!(
store.observe_artifact_bucket_incarnation(bucket, PREFIX).await.is_err(),
"ARTIFACT_CREATE_CURRENT_GENERATION: corrupt cached authority fault={fault}"
);
sys.read().await.set_for_test(bucket.into(), Arc::new(original.clone())).await;
}
drop(sys);
for (file, corrupt, expected_error) in [
(BUCKET_METADATA_FILE, b"corrupt metadata".to_vec(), "read_bucket_metadata: format invalid"),
(
BUCKET_INCARNATION_FILE,
Uuid::nil().as_bytes().to_vec(),
"persisted bucket incarnation is nil",
),
] {
let path = format!("{BUCKET_META_PREFIX}/{bucket}/{file}");
let saved = read_config(store.clone(), &path).await.expect("real persisted record");
let opts = ObjectOptions {
max_parity: true,
write_completion: WriteCompletion::TailDrained,
..Default::default()
};
save_config_with_opts(store.clone(), &path, corrupt, &opts)
.await
.expect("inject physical record fault");
// Reopen with a fresh metadata authority so observation must read the
// actual damaged record, without a cached value or a negative entry.
drain_before_reopen(&store, &shutdown, bucket).await;
drop(store);
drop(ctx);
(ctx, store, shutdown) = open_store(root.path()).await;
let error = store
.observe_artifact_bucket_incarnation(bucket, PREFIX)
.await
.expect_err("physical corruption cannot produce a receipt");
assert!(
error.to_string().contains(expected_error),
"ARTIFACT_CREATE_CURRENT_GENERATION: corrupt physical authority {file}: {error}"
);
save_config_with_opts(store.clone(), &path, saved, &opts)
.await
.expect("restore physical record");
drain_before_reopen(&store, &shutdown, bucket).await;
drop(store);
drop(ctx);
(ctx, store, shutdown) = open_store(root.path()).await;
assert_eq!(
store
.observe_artifact_bucket_incarnation(bucket, PREFIX)
.await
.expect("restored observation"),
incarnation
);
}
shutdown.cancel();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial_test::serial(storage_class_env)]
async fn artifact_bucket_create_requires_namespace_locking_before_creation() {
temp_env::async_with_vars([("RUSTFS_LOCK_ENABLED", Some("false"))], async {
let root = tempfile::tempdir().expect("disabled lock storage");
let (ctx, store, shutdown) = open_store(root.path()).await;
assert!(ctx.lock_manager().is_disabled());
let bucket = "artifact-create-no-lock";
let error = store
.clone()
.make_artifact_bucket(bucket, PREFIX)
.await
.expect_err("creation requires real locks");
assert!(
error
.to_string()
.contains("artifact bucket creation requires namespace locking")
);
assert!(
store
.get_bucket_info(bucket, &crate::storage_api_contracts::bucket::BucketOptions::default())
.await
.is_err(),
"ARTIFACT_CREATE_LOCK_REQUIRED: rejection precedes physical creation"
);
shutdown.cancel();
})
.await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial_test::serial(storage_class_env)]
async fn artifact_bucket_inspect_reopens_without_migrating_other_buckets() {
use crate::config::com::read_config;
let root = tempfile::tempdir().expect("inspect storage");
let (ctx, store, shutdown) = open_store(root.path()).await;
let bucket = "artifact-inspect-reopen";
let incarnation = store
.clone()
.make_artifact_bucket(bucket, PREFIX)
.await
.expect("managed bucket");
let pending = "artifact-inspect-pending";
let mut intent = BucketMetadata::new_with_default_durability(pending);
intent.artifact_base_prefix = Some(PREFIX.to_owned());
intent.versioning_config_xml = ENABLED.to_vec();
metadata_sys::set_new_bucket_metadata_intent_in(&ctx, intent)
.await
.expect("real pending intent");
// Physical quorum exists: a migrating reader could silently complete this intent.
let legacy = "artifact-inspect-legacy";
for disk in store.pools[0].disk_set[0].disk_inventory().await.into_iter().flatten() {
for name in [legacy, pending] {
disk.make_volume(name).await.expect("real preexisting volume");
}
}
let pending_path = format!("{BUCKET_META_PREFIX}/{pending}/{BUCKET_METADATA_FILE}");
let before = read_config(store.clone(), &pending_path).await.expect("pending bytes");
drain_before_reopen(&store, &shutdown, bucket).await;
drain_before_reopen(&store, &shutdown, pending).await;
drop(store);
drop(ctx);
let (_ctx, store, shutdown) = open_store(root.path()).await;
assert_eq!(
store
.inspect_artifact_bucket(bucket, PREFIX)
.await
.expect("fresh-instance inspection"),
incarnation
);
for name in [legacy, pending, "artifact-inspect-absent"] {
assert!(store.inspect_artifact_bucket(name, PREFIX).await.is_err(), "ARTIFACT_INSPECT_READ_ONLY");
}
assert_eq!(
read_config(store.clone(), &pending_path).await.expect("pending remains"),
before,
"ARTIFACT_INSPECT_READ_ONLY"
);
assert!(!persisted(&store, pending).await.bucket_creation_committed);
for name in [legacy, "artifact-inspect-absent"] {
for file in [BUCKET_METADATA_FILE, BUCKET_INCARNATION_FILE] {
let path = format!("{BUCKET_META_PREFIX}/{name}/{file}");
assert!(
read_config(store.clone(), &path).await.is_err(),
"ARTIFACT_INSPECT_READ_ONLY: no migration record"
);
}
}
for disk in 0..4 {
assert!(
!root
.path()
.join(format!("pool0/set0/disk{disk}/artifact-inspect-absent"))
.exists()
);
}
assert!(store.inspect_artifact_bucket(bucket, "wrong/").await.is_err());
assert_eq!(store.inspect_artifact_bucket(bucket, PREFIX).await.unwrap(), incarnation);
store
.ctx
.update_erasure_type(crate::layout::endpoints::SetupType::DistErasure)
.await;
let error = store
.inspect_artifact_bucket(bucket, PREFIX)
.await
.expect_err("file endpoints cannot use distributed lock mode");
assert!(
error
.to_string()
.contains("artifact PUT sealing requires local storage or a distributed Unix URL topology")
);
shutdown.cancel();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial_test::serial(storage_class_env)]
async fn artifact_bucket_inspect_requires_namespace_locking() {
temp_env::async_with_vars([("RUSTFS_LOCK_ENABLED", Some("false"))], async {
let root = tempfile::tempdir().expect("disabled inspect locking");
let (ctx, store, shutdown) = open_store(root.path()).await;
assert!(ctx.lock_manager().is_disabled());
let error = store
.inspect_artifact_bucket("artifact-inspect-no-lock", PREFIX)
.await
.expect_err("locking required before reading");
assert!(
error
.to_string()
.contains("artifact bucket inspection requires namespace locking")
);
shutdown.cancel();
})
.await;
}
@@ -0,0 +1,398 @@
#![cfg(unix)]
use super::{build_isolated_test_store, run_large_stack_async_test, without_storage_class_env};
use crate::{
bucket::metadata_sys,
disk::{Disk, local::artifact_inventory::ArtifactInventoryFrame},
object_api::{ObjectOptions, PutObjReader, ShardIntegrityWriteMode, WriteCompletion},
storage_api_contracts::{
bucket::{BucketOperations as _, MakeBucketOptions},
object::ObjectIO as _,
},
};
use std::time::Duration;
use uuid::Uuid;
#[test]
#[serial_test::serial(storage_class_env)]
fn artifact_inventory_managed_put_zero_and_versions_are_complete() {
assert_managed_put_inventory_is_complete(ShardIntegrityWriteMode::Legacy);
}
#[test]
#[serial_test::serial(storage_class_env)]
fn artifact_inventory_protected_managed_put_versions_are_complete() {
assert_managed_put_inventory_is_complete(ShardIntegrityWriteMode::Protected);
}
fn assert_managed_put_inventory_is_complete(mode: ShardIntegrityWriteMode) {
run_large_stack_async_test("artifact-inventory-managed", move || async move {
let root = tempfile::tempdir().expect("managed inventory disk roots");
let (_ctx, store, shutdown) =
without_storage_class_env(build_isolated_test_store(root.path(), "inventory-managed", &[4])).await;
metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let bucket = "artifact-inventory-managed";
store
.make_bucket(
bucket,
&MakeBucketOptions {
artifact_base_prefix: Some("outputs/".to_owned()),
versioning_enabled: true,
..Default::default()
},
)
.await
.expect("actual managed versioned bucket");
let incarnation = store.bucket_incarnation_id(bucket).await.expect("actual incarnation");
let prefix = format!("outputs/74656e616e74/{}/{}/", Uuid::new_v4(), Uuid::new_v4());
let keys = vec![
(format!("{prefix}zero"), Vec::new()),
(format!("{prefix}inline"), vec![0xA5; 1024]),
(format!("{prefix}payload"), vec![0xA5; 1024 * 1024]),
];
let mut expected = std::collections::BTreeSet::new();
for (key, data) in &keys {
let object = store
.put_object(
bucket,
key,
&mut PutObjReader::from_vec(data.clone()),
&ObjectOptions {
versioned: true,
shard_integrity_write_mode: Some(mode),
write_completion: WriteCompletion::TailDrained,
..Default::default()
},
)
.await
.expect("real managed PUT including empty, inline and sharded payloads");
expected.insert((key.clone(), object.version_id.expect("exact managed version")));
}
let identity = store
.artifact_receiver_identity(bucket, incarnation, &prefix, true)
.await
.expect("metadata-derived identity");
store
.clone()
.seal_artifact_attempt_writes(bucket, incarnation, &prefix)
.await
.expect("actual all-writes seal");
for member in store.pools[0].disk_set[0].disk_inventory().await {
let member = member.expect("configured local member");
let Disk::Local(local) = member.as_ref() else {
panic!("local fixture");
};
let local = local.get_disk();
let id = local.artifact_disk_id().await.expect("fresh actual disk identity");
let (output, mut input) = tokio::sync::mpsc::channel(2);
let (result, frames) = tokio::join!(
local.inventory_artifact_attempt(
id,
bucket,
&prefix,
identity,
tokio::time::Instant::now() + Duration::from_secs(30),
output
),
async {
let mut frames = Vec::new();
while let Some(frame) = input.recv().await {
frames.push(frame);
}
frames
}
);
assert!(result.is_ok(), "A03I1_REAL_MANAGED_PUT_SCAN: {result:?}");
let actual = frames
.iter()
.filter_map(|frame| match frame {
ArtifactInventoryFrame::Entries { versions, .. } => Some(versions),
_ => None,
})
.flatten()
.map(|version| (version.key.clone(), version.version_id))
.collect::<std::collections::BTreeSet<_>>();
assert_eq!(actual, expected, "A03I1_REAL_ZERO_INLINE_AND_SHARDED_VERSIONS");
assert!(matches!(frames.last(), Some(ArtifactInventoryFrame::Complete { entries: 3, .. })));
}
shutdown.cancel();
});
}
async fn inventory_fixture() -> (
tempfile::TempDir,
std::sync::Arc<crate::store::ECStore>,
tokio_util::sync::CancellationToken,
Uuid,
String,
) {
let root = tempfile::tempdir().expect("inventory aggregate roots");
let (_ctx, store, shutdown) =
without_storage_class_env(build_isolated_test_store(root.path(), "inventory-aggregate", &[4])).await;
metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
store
.make_bucket(
"inventory-aggregate",
&MakeBucketOptions {
artifact_base_prefix: Some("outputs/".into()),
versioning_enabled: true,
..Default::default()
},
)
.await
.expect("managed bucket");
let incarnation = store
.bucket_incarnation_id("inventory-aggregate")
.await
.expect("actual incarnation");
let prefix = format!("outputs/74656e616e74/{}/{}/", Uuid::new_v4(), Uuid::new_v4());
store
.put_object(
"inventory-aggregate",
&format!("{prefix}object"),
&mut PutObjReader::from_vec(vec![1; 1024]),
&ObjectOptions {
versioned: true,
write_completion: WriteCompletion::TailDrained,
..Default::default()
},
)
.await
.expect("actual managed object");
store
.clone()
.seal_artifact_attempt_writes("inventory-aggregate", incarnation, &prefix)
.await
.expect("actual two-domain seal");
(root, store, shutdown, incarnation, prefix)
}
async fn aggregate(
store: std::sync::Arc<crate::store::ECStore>,
incarnation: Uuid,
prefix: &str,
) -> (
crate::error::Result<()>,
Vec<crate::bucket::artifact::inventory::ArtifactAttemptInventoryFrame>,
) {
let (output, mut receiver) = tokio::sync::mpsc::channel(1);
let drain = async {
let mut frames = Vec::new();
while let Some(frame) = receiver.recv().await {
frames.push(frame);
}
frames
};
tokio::join!(
store.inventory_artifact_attempt(
"inventory-aggregate",
incarnation,
prefix,
tokio::time::Instant::now() + Duration::from_secs(30),
output
),
drain
)
}
#[test]
#[serial_test::serial(storage_class_env)]
fn artifact_inventory_all_members_required() {
run_large_stack_async_test("artifact-inventory-all-members", || async {
use crate::bucket::artifact::inventory::ArtifactAttemptInventoryFrame;
let (_root, store, shutdown, incarnation, prefix) = inventory_fixture().await;
let (result, frames) = aggregate(store.clone(), incarnation, &prefix).await;
result.expect("all four real sealed disks scanned");
let observed: std::collections::BTreeSet<_> = frames
.iter()
.filter_map(|frame| match frame {
ArtifactAttemptInventoryFrame::Observations {
endpoint,
disk_id,
versions,
} => {
assert_eq!(versions.len(), 1);
assert_eq!(versions[0].key, format!("{prefix}object"));
Some((endpoint.clone(), *disk_id))
}
_ => None,
})
.collect();
assert_eq!(observed.len(), 4, "A03I2_ALL_MEMBERS");
assert!(
matches!(
frames.last(),
Some(ArtifactAttemptInventoryFrame::Complete {
members: 4,
observations: 4
})
),
"A03I2_ALL_MEMBERS"
);
let original = store.pools[0].disk_set[0].disks.write().await[3].take();
let (denied, frames) = aggregate(store.clone(), incarnation, &prefix).await;
assert!(denied.is_err(), "one missing configured member is incomplete");
assert!(
!frames
.iter()
.any(|frame| matches!(frame, ArtifactAttemptInventoryFrame::Complete { .. }))
);
store.pools[0].disk_set[0].disks.write().await[3] = original;
shutdown.cancel();
});
}
#[test]
#[serial_test::serial(storage_class_env)]
fn artifact_inventory_membership_change_is_incomplete() {
run_large_stack_async_test("artifact-inventory-membership", || async {
use crate::bucket::artifact::inventory::ArtifactAttemptInventoryFrame;
let (_root, store, shutdown, incarnation, prefix) = inventory_fixture().await;
let original = store.pools[0].disk_set[0].disk_inventory().await[0]
.clone()
.expect("first member");
let Disk::Local(local) = original.as_ref() else {
panic!("local fixture");
};
let replacement = std::sync::Arc::new(Disk::Local(local.clone()));
let (output, mut receiver) = tokio::sync::mpsc::channel(1);
let owned = store.clone();
let worker = tokio::spawn(async move {
owned
.inventory_artifact_attempt(
"inventory-aggregate",
incarnation,
&prefix,
tokio::time::Instant::now() + Duration::from_secs(30),
output,
)
.await
});
assert!(matches!(receiver.recv().await, Some(ArtifactAttemptInventoryFrame::Observations { .. })));
store.pools[0].disk_set[0].disks.write().await[0] = Some(replacement);
let mut complete = false;
while let Some(frame) = receiver.recv().await {
complete |= matches!(frame, ArtifactAttemptInventoryFrame::Complete { .. });
}
let error = worker
.await
.expect("aggregate worker")
.expect_err("same endpoint/UUID but changed captured Arc is incomplete");
assert!(error.to_string().contains("membership changed"), "{error}");
assert!(!complete);
store.pools[0].disk_set[0].disks.write().await[0] = Some(original);
shutdown.cancel();
});
}
#[test]
#[serial_test::serial(storage_class_env)]
fn artifact_inventory_readonly_authority_cold_cache_and_queued_writer() {
run_large_stack_async_test("artifact-inventory-readonly-authority", || async {
use crate::{
disk::{BUCKET_META_PREFIX, DiskAPI as _, RUSTFS_META_BUCKET},
storage_api_contracts::namespace::NamespaceLocking as _,
};
let (_root, store, shutdown, incarnation, prefix) = inventory_fixture().await;
let member = store.pools[0].disk_set[0].disk_inventory().await[0]
.clone()
.expect("real receiver");
let Disk::Local(local) = member.as_ref() else {
panic!("local");
};
let expected = local.artifact_disk_id().await.expect("actual disk");
let _lifecycle = store
.acquire_bucket_lifecycle_read_lock("inventory-aggregate")
.await
.expect("coordinator lifecycle owner");
metadata_sys::remove_bucket_metadata_in(&store.ctx, "inventory-aggregate")
.await
.expect("cold metadata cache");
let lock = store
.new_ns_lock("inventory-aggregate", "inventory-aggregate")
.await
.expect("business bucket namespace");
let reader = lock
.get_read_lock(crate::set_disk::get_lock_acquire_timeout())
.await
.expect("existing namespace reader");
let mut writer = Box::pin(lock.get_write_lock(crate::set_disk::get_lock_acquire_timeout()));
assert!(
matches!(futures::poll!(writer.as_mut()), std::task::Poll::Pending),
"real queued business bucket WRITE"
);
let (output, mut frames) = tokio::sync::mpsc::channel(1);
let drain = async { while frames.recv().await.is_some() {} };
let endpoint = member.endpoint().to_string();
let receive = store.inventory_local_artifact_attempt(
(&endpoint, expected),
("inventory-aggregate", incarnation),
&prefix,
rustfs_protos::proto_gen::node_service::ArtifactInventoryPhase::ArtifactInventoryScan,
tokio::time::Instant::now() + Duration::from_secs(2),
output,
);
let (result, ()) = tokio::join!(receive, drain);
result.expect("A03I2_READONLY_AUTHORITY_NO_REENTRY");
drop(writer);
drop(reader);
drop(_lifecycle);
// A legacy physical bucket lacking both authoritative records must
// stay legacy after rejected inventory; no implicit getter migration.
store
.make_bucket(
"inventory-legacy",
&MakeBucketOptions {
versioning_enabled: true,
..Default::default()
},
)
.await
.expect("ordinary legacy fixture");
let members = store.pools[0].disk_set[0].disk_inventory().await;
for member in members.iter().flatten() {
for file in [".metadata.bin", ".bucket-incarnation"] {
let path = member
.path()
.join(RUSTFS_META_BUCKET)
.join(BUCKET_META_PREFIX)
.join("inventory-legacy")
.join(file);
if path.exists() {
std::fs::remove_dir_all(&path).expect("remove fixture-only creation facts");
}
}
}
metadata_sys::remove_bucket_metadata_in(&store.ctx, "inventory-legacy")
.await
.expect("cold legacy cache");
let (output, mut frames) = tokio::sync::mpsc::channel(1);
let drain = async { while frames.recv().await.is_some() {} };
let (denied, ()) = tokio::join!(
store.clone().inventory_artifact_attempt(
"inventory-legacy",
Uuid::new_v4(),
&prefix,
tokio::time::Instant::now() + Duration::from_secs(2),
output
),
drain
);
assert!(denied.is_err());
for member in members.iter().flatten() {
for file in [".metadata.bin", ".bucket-incarnation"] {
assert!(
!member
.path()
.join(RUSTFS_META_BUCKET)
.join(BUCKET_META_PREFIX)
.join("inventory-legacy")
.join(file)
.exists(),
"A03I2_READONLY_NO_MIGRATION"
);
}
}
shutdown.cancel();
});
}
@@ -0,0 +1,587 @@
use super::{build_isolated_test_store, without_storage_class_env};
use crate::{
bucket::{artifact::artifact_put_seal_record_path, metadata_sys},
config::com,
disk::{DiskAPI as _, RUSTFS_META_BUCKET},
object_api::{ObjectInfo, ObjectOptions, PutObjReader, WriteCompletion},
runtime::instance::InstanceContext,
set_disk::{PutObjectCommitBarrier, PutObjectCommitPause, rename_fanout_barrier},
storage_api_contracts::{
bucket::{BucketOperations as _, DeleteBucketOptions, MakeBucketOptions},
namespace::NamespaceLocking as _,
object::ObjectIO as _,
},
store::ECStore,
};
use std::{sync::Arc, time::Duration};
use tokio::io::AsyncReadExt as _;
use tokio_util::sync::CancellationToken;
use uuid::Uuid;
const DEADLINE: Duration = Duration::from_secs(30);
fn attempt_prefix() -> String {
format!("outputs/74656e616e74/{}/{}/", Uuid::new_v4(), Uuid::new_v4())
}
async fn open_store(root: &std::path::Path) -> (Arc<InstanceContext>, Arc<ECStore>, CancellationToken) {
let (ctx, store, shutdown) = without_storage_class_env(build_isolated_test_store(root, "artifact-put-seal", &[4])).await;
metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
(ctx, store, shutdown)
}
async fn create_bucket(store: &ECStore, bucket: &str) -> Uuid {
store
.make_bucket(
bucket,
&MakeBucketOptions {
artifact_base_prefix: Some("outputs/".to_owned()),
..Default::default()
},
)
.await
.expect("create a real versioned artifact bucket");
store
.bucket_incarnation_id(bucket)
.await
.expect("read actual bucket incarnation")
}
async fn put(store: &ECStore, bucket: &str, object: &str) -> ObjectInfo {
store
.put_object(
bucket,
object,
&mut PutObjReader::from_vec(vec![0xA5; 273]),
&ObjectOptions {
versioned: true,
write_completion: WriteCompletion::TailDrained,
..Default::default()
},
)
.await
.expect("unsealed PUT must commit on all four disks")
}
async fn disk_metadata(store: &ECStore, bucket: &str, object: &str) -> Vec<Vec<u8>> {
let disks = store.pools[0].disk_set[0].disk_inventory().await;
assert_eq!(disks.len(), 4);
let mut metadata = Vec::new();
for disk in disks {
metadata.push(
disk.expect("real fixture disk is online")
.read_xl(bucket, object, false)
.await
.expect("read actual per-disk versions")
.buf,
);
}
metadata
}
async fn assert_rejected_without_new_version(store: &ECStore, bucket: &str, object: &str, before: &[Vec<u8>]) {
let result = store
.put_object(
bucket,
object,
&mut PutObjReader::from_vec(vec![0xBB; 379]),
&ObjectOptions {
versioned: true,
write_completion: WriteCompletion::TailDrained,
..Default::default()
},
)
.await;
assert!(result.is_err(), "ARTIFACT_PUT_SEALED: later PUT must be rejected");
assert_eq!(
disk_metadata(store, bucket, object).await,
before,
"ARTIFACT_PUT_SEALED: no disk gains a version"
);
}
async fn tail_barrier_case(cancel_put_waiter: bool) {
let root = tempfile::tempdir().expect("artifact tail fixture directory");
let (ctx, store, shutdown) = open_store(root.path()).await;
let bucket = "artifact-put-tail";
let incarnation = create_bucket(&store, bucket).await;
let prefix = attempt_prefix();
let object = format!("{prefix}image.png");
temp_env::async_with_vars([(crate::set_disk::ENV_RUSTFS_PUT_RENAME_EARLY_ACK_ENABLE, Some("true"))], async {
let tracker = rename_fanout_barrier::observe_tasks(&object);
let tail = rename_fanout_barrier::arm(&object, 0, rename_fanout_barrier::PHASE_RENAME);
let quorum = PutObjectCommitBarrier::install(bucket, &object, PutObjectCommitPause::AfterRenameQuorum);
let handoff = PutObjectCommitBarrier::install(bucket, &object, PutObjectCommitPause::AfterRenameHandoff);
let put_store = store.clone();
let put_object = object.clone();
let put_task = tokio::spawn(async move {
put_store
.put_object(
bucket,
&put_object,
&mut PutObjReader::from_vec(vec![0xA5; 273]),
&ObjectOptions {
versioned: true,
..Default::default()
},
)
.await
});
tokio::time::timeout(DEADLINE, tail.wait_until_paused())
.await
.expect("one real disk pauses before rename");
quorum.wait_until_paused().await;
if cancel_put_waiter {
put_task.abort();
}
quorum.release();
handoff.wait_until_paused().await;
handoff.release();
let result = tokio::time::timeout(DEADLINE, put_task)
.await
.expect("PUT waiter returns while its tail remains paused");
if cancel_put_waiter {
assert!(result.expect_err("cancelled PUT waiter").is_cancelled());
} else {
result
.expect("PUT task joins")
.expect("PUT quorum ACK succeeds before tail completion");
}
assert!(tracker.running() > 0, "real rename tail must still be live");
let parallel_object = format!("{prefix}parallel.png");
let parallel = tokio::time::timeout(DEADLINE, put(&store, bucket, &parallel_object))
.await
.expect("ARTIFACT_PUT_SHARED_ADMISSION: another object in the same attempt completes while A is paused");
let mut reader = tokio::time::timeout(
DEADLINE,
store.get_object_reader(
bucket,
&parallel_object,
None,
Default::default(),
&ObjectOptions {
version_id: Some(parallel.version_id.expect("parallel PUT has a real version").to_string()),
..Default::default()
},
),
)
.await
.expect("parallel object GET completes while A is paused")
.expect("parallel object is readable");
let mut bytes = Vec::new();
tokio::time::timeout(DEADLINE, reader.stream.read_to_end(&mut bytes))
.await
.expect("parallel object body completes while A is paused")
.expect("read parallel object body");
assert_eq!(bytes, vec![0xA5; 273], "ARTIFACT_PUT_SHARED_ADMISSION: B publishes exact bytes");
drop(reader);
assert!(tracker.running() > 0, "A's physical tail remains paused throughout B's PUT and GET");
let record = artifact_put_seal_record_path(bucket, incarnation, &prefix);
let attempt_lock = store
.new_ns_lock(RUSTFS_META_BUCKET, &record)
.await
.expect("resolve the actual PUT and seal coordination namespace");
assert!(
attempt_lock.get_write_lock(Duration::from_secs(1)).await.is_err(),
"ARTIFACT_PUT_TAIL_BARRIER: the real commit owner retains the attempt lock until its disk tail drains"
);
let seal_store = store.clone();
let seal_prefix = prefix.clone();
let mut seal =
tokio::spawn(async move { seal_store.seal_artifact_attempt_puts(bucket, incarnation, &seal_prefix).await });
assert!(
tokio::time::timeout(Duration::from_millis(200), &mut seal).await.is_err(),
"ARTIFACT_PUT_TAIL_BARRIER: seal cannot finish while a real rename tail is paused"
);
assert!(tracker.running() > 0, "tail remains paused throughout the seal attempt");
tail.release();
tokio::time::timeout(DEADLINE, seal)
.await
.expect("seal resumes after tail release")
.expect("seal task joins")
.expect("seal persists");
tokio::time::timeout(DEADLINE, async {
while tracker.running() != 0 || ctx.namespace_commits_pending() {
tokio::task::yield_now().await;
}
})
.await
.expect("actual detached write owners drain");
let before = disk_metadata(&store, bucket, &object).await;
assert_rejected_without_new_version(&store, bucket, &object, &before).await;
})
.await;
shutdown.cancel();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial_test::serial(storage_class_env)]
async fn artifact_put_seal_waits_for_quorum_ack_rename_tail() {
tail_barrier_case(false).await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial_test::serial(storage_class_env)]
async fn artifact_put_seal_waits_after_put_waiter_cancellation() {
tail_barrier_case(true).await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial_test::serial(storage_class_env)]
async fn artifact_put_seal_survives_reopen_and_is_attempt_scoped() {
let root = tempfile::tempdir().expect("artifact durable seal directory");
let (ctx, store, shutdown) = open_store(root.path()).await;
let bucket = "artifact-put-reopen";
let incarnation = create_bucket(&store, bucket).await;
let prefix = attempt_prefix();
let object = format!("{prefix}image.png");
put(&store, bucket, &object).await;
let before = disk_metadata(&store, bucket, &object).await;
store
.clone()
.seal_artifact_attempt_puts(bucket, incarnation, &prefix)
.await
.expect("persist first seal");
store
.clone()
.seal_artifact_attempt_puts(bucket, incarnation, &prefix)
.await
.expect("same seal is idempotent");
assert_rejected_without_new_version(&store, bucket, &object, &before).await;
put(&store, bucket, &format!("{}other.png", attempt_prefix())).await;
shutdown.cancel();
assert!(
ctx.wait_for_detached_mutations(DEADLINE).await,
"old instance mutations drain before reopen"
);
drop(store);
drop(ctx);
let (_ctx, store, shutdown) = open_store(root.path()).await;
metadata_sys::reload_bucket_metadata(store.clone(), bucket)
.await
.expect("reload dedicated bucket after restart");
assert_eq!(store.bucket_incarnation_id(bucket).await.expect("reopened incarnation"), incarnation);
assert_rejected_without_new_version(&store, bucket, &object, &before).await;
store
.clone()
.seal_artifact_attempt_puts(bucket, incarnation, &prefix)
.await
.expect("persisted seal remains idempotent");
shutdown.cancel();
}
#[test]
#[serial_test::serial(storage_class_env)]
fn artifact_put_seal_rejects_corrupt_and_wrong_incarnation_records() {
super::run_large_stack_async_test("artifact-put-corrupt", artifact_put_corrupt_record_case);
}
async fn artifact_put_corrupt_record_case() {
let root = tempfile::tempdir().expect("artifact invalid seal record directory");
let (_ctx, store, shutdown) = open_store(root.path()).await;
let bucket = "artifact-put-corrupt";
let prefix = attempt_prefix();
let old_incarnation = create_bucket(&store, bucket).await;
store
.clone()
.seal_artifact_attempt_puts(bucket, old_incarnation, &prefix)
.await
.expect("produce a real previous-generation seal");
let old_record = com::read_config(store.clone(), &artifact_put_seal_record_path(bucket, old_incarnation, &prefix))
.await
.expect("read actual seal bytes");
store
.delete_bucket(bucket, &DeleteBucketOptions::default())
.await
.expect("delete empty old-generation bucket");
let incarnation = create_bucket(&store, bucket).await;
assert_ne!(incarnation, old_incarnation);
assert!(
store
.clone()
.seal_artifact_attempt_puts(bucket, old_incarnation, &prefix)
.await
.is_err(),
"wrong expected incarnation cannot seal current bucket"
);
let object = format!("{prefix}image.png");
put(&store, bucket, &object).await;
let before = disk_metadata(&store, bucket, &object).await;
let record = artifact_put_seal_record_path(bucket, incarnation, &prefix);
for invalid_record in [old_record, Vec::new(), b"not-json".to_vec()] {
com::save_config_with_opts(
store.clone(),
&record,
invalid_record,
&ObjectOptions {
max_parity: true,
write_completion: WriteCompletion::TailDrained,
..Default::default()
},
)
.await
.expect("persist actual invalid seal record on all disks");
assert_rejected_without_new_version(&store, bucket, &object, &before).await;
assert!(
store
.clone()
.seal_artifact_attempt_puts(bucket, incarnation, &prefix)
.await
.is_err(),
"invalid seal cannot be treated as absent or idempotent success"
);
}
shutdown.cancel();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial_test::serial(storage_class_env)]
async fn artifact_put_seal_does_not_change_ordinary_bucket_puts() {
let root = tempfile::tempdir().expect("ordinary bucket seal regression directory");
let (_ctx, store, shutdown) = open_store(root.path()).await;
let bucket = "artifact-put-ordinary";
store
.make_bucket(
bucket,
&MakeBucketOptions {
versioning_enabled: true,
..Default::default()
},
)
.await
.expect("create ordinary versioned bucket");
let incarnation = store.bucket_incarnation_id(bucket).await.expect("ordinary incarnation");
let prefix = attempt_prefix();
let object = format!("{prefix}image.png");
let first = put(&store, bucket, &object).await;
assert!(
store
.clone()
.seal_artifact_attempt_puts(bucket, incarnation, &prefix)
.await
.is_err(),
"ordinary bucket cannot acquire artifact seal semantics"
);
let second = put(&store, bucket, &object).await;
assert_ne!(first.version_id, second.version_id, "ordinary versioned PUT creates another version");
shutdown.cancel();
}
#[cfg(not(windows))]
#[test]
#[serial_test::serial(storage_class_env)]
fn artifact_put_seal_lock_survives_physical_publication_timeout() {
// Match large ECStore fixtures while retaining the current-thread clock required by pause/advance.
std::thread::Builder::new()
.name("artifact-put-physical-timeout".to_owned())
.stack_size(32 * 1024 * 1024)
.spawn(|| {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("physical timeout test runtime should build");
runtime.block_on(artifact_put_physical_timeout_case());
})
.expect("physical timeout test thread should spawn")
.join()
.expect("physical timeout test thread should complete");
}
#[cfg(not(windows))]
async fn artifact_put_physical_timeout_case() {
use crate::disk::{Disk, ReadOptions, STORAGE_FORMAT_FILE, os};
temp_env::async_with_vars(
[
(rustfs_config::ENV_DRIVE_MAX_TIMEOUT_DURATION, Some("60")),
(crate::set_disk::ENV_RUSTFS_PUT_RENAME_EARLY_ACK_ENABLE, Some("false")),
],
async {
let root = tempfile::tempdir().expect("artifact physical timeout directory");
let (_ctx, store, shutdown) = open_store(root.path()).await;
let bucket = "artifact-put-physical-timeout";
let incarnation = create_bucket(&store, bucket).await;
let prefix = attempt_prefix();
let object = format!("{prefix}image.png");
put(&store, bucket, &object).await;
let disks = store.pools[0].disk_set[0].disk_inventory().await;
let mut hooks = Vec::new();
let mut entered = Vec::new();
let mut releases = Vec::new();
let mut destinations = Vec::new();
for disk in disks.iter().flatten() {
let Disk::Local(local) = disk.as_ref() else {
panic!("physical timeout fixture requires local disks");
};
// Resolve through the mount lease; Linux may use an FD-based path.
let destination = local
.get_disk()
.get_object_path_for_io(bucket, &object)
.expect("real publication path");
let (entered_tx, entered_rx) = tokio::sync::oneshot::channel();
let (release_tx, release_rx) = std::sync::mpsc::channel::<()>();
hooks.push(os::prepared_publication_test_hooks::install(
&destination.join(STORAGE_FORMAT_FILE),
move || {
let _ = entered_tx.send(());
// Dropping the sender also releases the syscall during panic unwinding.
let _ = release_rx.recv();
},
));
entered.push(entered_rx);
releases.push(release_tx);
destinations.push(destination);
}
assert_eq!(entered.len(), 4, "all four real disks must be online");
let version = Uuid::new_v4();
let put_store = store.clone();
let put_object = object.clone();
let mut replacement = tokio::spawn(async move {
put_store
.put_object(
bucket,
&put_object,
&mut PutObjReader::from_vec(vec![0xCC; 379]),
&ObjectOptions {
versioned: true,
version_id: Some(version.to_string()),
write_completion: WriteCompletion::TailDrained,
..Default::default()
},
)
.await
});
tokio::time::timeout(DEADLINE, async {
tokio::select! {
signals = futures::future::join_all(entered) => assert!(signals.into_iter().all(|signal| signal.is_ok())),
_ = &mut replacement => panic!("PUT cannot finish before its physical publishers are paused"),
}
})
.await
.expect("all physical xl.meta publications reach the blocking syscall");
// All disk wrapper timers exist, but their physical closures stay blocked.
tokio::time::pause();
tokio::time::advance(Duration::from_secs(61)).await;
tokio::time::resume();
let result = tokio::time::timeout(DEADLINE, replacement)
.await
.expect("PUT returns after disk wrapper timeouts")
.expect("PUT task joins");
assert!(result.is_err(), "four timed-out publishers cannot satisfy write quorum");
let record = artifact_put_seal_record_path(bucket, incarnation, &prefix);
let attempt_lock = store
.new_ns_lock(RUSTFS_META_BUCKET, &record)
.await
.expect("actual attempt coordination namespace");
let physical_owner_kept_lock = attempt_lock.get_write_lock(Duration::from_secs(1)).await.is_err();
// Drain physical syscalls before the regression assertion, including a RED run.
drop(releases);
for (disk, destination) in disks.iter().flatten().zip(&destinations) {
drop(
tokio::time::timeout(DEADLINE, os::acquire_rename_data_mutation_lease(&disk.path(), bucket, destination))
.await
.expect("released physical publisher drains"),
);
let published = disk
.read_version("", bucket, &object, &version.to_string(), &ReadOptions::default())
.await
.expect("timed-out physical writer actually publishes the exact new version");
assert_eq!(published.size, 379);
}
assert!(
physical_owner_kept_lock,
"ARTIFACT_PUT_TAIL_BARRIER: disk workers retain the attempt lock after the PUT coordinator returns its timeout"
);
tokio::time::timeout(DEADLINE, store.clone().seal_artifact_attempt_puts(bucket, incarnation, &prefix))
.await
.expect("seal resumes after physical owners drain")
.expect("seal persists after timeout recovery");
let before = disk_metadata(&store, bucket, &object).await;
assert_rejected_without_new_version(&store, bucket, &object, &before).await;
shutdown.cancel();
},
)
.await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial_test::serial(storage_class_env)]
async fn artifact_put_seal_rejects_mismatched_topology_and_preserves_ordinary_local_puts() {
use crate::layout::endpoints::SetupType;
const TOPOLOGY_ERROR: &str = "artifact PUT sealing requires local storage or a distributed Unix URL topology";
let root = tempfile::tempdir().expect("artifact distributed topology directory");
let (ctx, store, shutdown) = open_store(root.path()).await;
ctx.update_erasure_type(SetupType::Erasure).await;
let bucket = "artifact-put-distributed";
let incarnation = create_bucket(&store, bucket).await;
let ordinary = "artifact-put-distributed-ordinary";
store
.make_bucket(
ordinary,
&MakeBucketOptions {
versioning_enabled: true,
..Default::default()
},
)
.await
.expect("create ordinary versioned control bucket");
let ordinary_incarnation = store.bucket_incarnation_id(ordinary).await.expect("ordinary identity");
let prefix = attempt_prefix();
let object = format!("{prefix}image.png");
put(&store, bucket, &object).await;
let before = disk_metadata(&store, bucket, &object).await;
let first = put(&store, ordinary, &object).await;
// Keep every physical disk online: the gate must inspect configured topology.
ctx.update_erasure_type(SetupType::DistErasure).await;
assert!(ctx.is_dist_erasure().await);
let rejected = store
.put_object(
bucket,
&object,
&mut PutObjReader::from_vec(vec![0xDD; 379]),
&ObjectOptions {
versioned: true,
write_completion: WriteCompletion::TailDrained,
..Default::default()
},
)
.await
.expect_err("file endpoints with distributed lock mode must be rejected before publication");
assert!(rejected.to_string().contains(TOPOLOGY_ERROR), "ARTIFACT_PUT_TOPOLOGY: {rejected}");
assert_eq!(
disk_metadata(&store, bucket, &object).await,
before,
"ARTIFACT_PUT_TOPOLOGY: no disk gains a version"
);
let rejected = store
.clone()
.seal_artifact_attempt_puts(bucket, incarnation, &prefix)
.await
.expect_err("mismatched topology cannot claim a durable PUT seal");
assert!(rejected.to_string().contains(TOPOLOGY_ERROR), "ARTIFACT_PUT_TOPOLOGY: {rejected}");
// This local fixture has no distributed lock clients; ordinary PUT retains its local regression check.
ctx.update_erasure_type(SetupType::Erasure).await;
let second = put(&store, ordinary, &object).await;
assert_ne!(
first.version_id, second.version_id,
"ordinary local PUT stays available after artifact rejection"
);
assert_eq!(
store
.bucket_incarnation_id(ordinary)
.await
.expect("ordinary identity remains available"),
ordinary_incarnation
);
assert_eq!(
store
.bucket_incarnation_id(bucket)
.await
.expect("artifact identity remains available"),
incarnation
);
shutdown.cancel();
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,689 @@
#![cfg(unix)]
use super::{build_isolated_test_store, run_large_stack_async_test, without_storage_class_env};
use crate::{
bucket::{artifact::artifact_version_retirement_record_path, metadata_sys},
config::com,
disk::{DiskAPI as _, DiskStore, RUSTFS_META_BUCKET, ReadOptions, STORAGE_FORMAT_FILE, os},
error::Error,
object_api::{ObjectOptions, PutObjReader, ShardIntegrityWriteMode, WriteCompletion},
runtime::instance::InstanceContext,
storage_api_contracts::{
bucket::{BucketOperations as _, MakeBucketOptions},
heal::HealOperations,
object::ObjectIO as _,
},
store::ECStore,
};
use rustfs_heal_contracts::heal_channel::{HealOpts, HealScanMode};
use rustfs_utils::path::encode_dir_object;
use std::{
path::{Path, PathBuf},
sync::Arc,
time::Duration,
};
use tokio::io::AsyncReadExt as _;
use tokio_util::sync::CancellationToken;
use uuid::Uuid;
const DEADLINE: Duration = Duration::from_secs(30);
const PAYLOAD_SIZE: usize = 1024 * 1024;
fn attempt_prefix() -> String {
format!("outputs/74656e616e74/{}/{}/", Uuid::new_v4(), Uuid::new_v4())
}
fn put_options() -> ObjectOptions {
ObjectOptions {
versioned: true,
write_completion: WriteCompletion::TailDrained,
..Default::default()
}
}
fn heal_options() -> HealOpts {
HealOpts {
scan_mode: HealScanMode::Deep,
..Default::default()
}
}
async fn open_store(root: &std::path::Path) -> (Arc<InstanceContext>, Arc<ECStore>, CancellationToken) {
let (ctx, store, shutdown) = without_storage_class_env(build_isolated_test_store(root, "artifact-retirement", &[4])).await;
metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
(ctx, store, shutdown)
}
async fn create_bucket(store: &ECStore, bucket: &str, artifact: bool) -> Uuid {
store
.make_bucket(
bucket,
&MakeBucketOptions {
artifact_base_prefix: artifact.then(|| "outputs/".to_owned()),
versioning_enabled: true,
..Default::default()
},
)
.await
.expect("create real four-disk versioned bucket");
store
.bucket_incarnation_id(bucket)
.await
.expect("read actual bucket incarnation")
}
async fn disks(store: &ECStore) -> Vec<DiskStore> {
let disks = store.pools[0].disk_set[0]
.disk_inventory()
.await
.into_iter()
.map(|disk| disk.expect("fixture disk remains online"))
.collect::<Vec<_>>();
assert_eq!(disks.len(), 4);
disks
}
fn object_path(disk: &DiskStore, bucket: &str, object: &str) -> PathBuf {
disk.get_object_path_for_io_if_local(bucket, &encode_dir_object(object))
.expect("fixture uses local disks")
.expect("resolve object through held mount")
}
async fn file_bytes(path: &Path) -> Option<Vec<u8>> {
match tokio::fs::read(path).await {
Ok(bytes) => Some(bytes),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
Err(error) => panic!("read actual fixture file: {error}"),
}
}
async fn metadata(store: &ECStore, bucket: &str, object: &str) -> Vec<Option<Vec<u8>>> {
let mut result = Vec::new();
for disk in disks(store).await {
let path = object_path(&disk, bucket, object).join(STORAGE_FORMAT_FILE);
result.push(file_bytes(&path).await);
}
result
}
async fn put_payload(store: &ECStore, bucket: &str, object: &str, byte: u8, legacy: bool) -> Uuid {
let mut options = put_options();
if legacy {
// Exercise genuine supported minority metadata recovery, without fabricated proofs.
options.shard_integrity_write_mode = Some(ShardIntegrityWriteMode::Legacy);
}
let body = if object.ends_with('/') {
Vec::new()
} else {
vec![byte; PAYLOAD_SIZE]
};
store
.put_object(bucket, object, &mut PutObjReader::from_vec(body), &options)
.await
.expect("commit exact payload and version to all four disks")
.version_id
.expect("artifact PUT returns a non-null version")
}
async fn assert_payload(store: &ECStore, bucket: &str, object: &str, version: Uuid, byte: u8) {
let mut reader = store
.get_object_reader(
bucket,
object,
None,
Default::default(),
&ObjectOptions {
version_id: Some(version.to_string()),
..put_options()
},
)
.await
.expect("retirement leaves the exact committed version readable");
assert_eq!(reader.object_info.version_id, Some(version), "GET preserves exact version identity");
let mut body = Vec::new();
reader
.stream
.read_to_end(&mut body)
.await
.expect("read exact committed bytes");
let expected = if object.ends_with('/') {
Vec::new()
} else {
vec![byte; PAYLOAD_SIZE]
};
assert_eq!(body, expected, "retirement does not delete or replace committed bytes");
}
async fn remove_shard(store: &ECStore, bucket: &str, object: &str, version: Uuid) -> (DiskStore, PathBuf) {
let disk = disks(store).await.remove(0);
let info = disk
.read_version("", bucket, &encode_dir_object(object), &version.to_string(), &ReadOptions::default())
.await
.expect("read exact version before removing a real shard");
assert_eq!(info.version_id, Some(version));
let part = object_path(&disk, bucket, object)
.join(info.data_dir.expect("non-inline object has a data directory").to_string())
.join("part.1");
tokio::fs::remove_file(&part).await.expect("remove one real payload shard");
assert!(!part.exists());
(disk, part)
}
async fn assert_healthy_version(store: &ECStore, bucket: &str, object: &str, version: Uuid) {
for disk in disks(store).await {
let info = disk
.read_version("", bucket, &encode_dir_object(object), &version.to_string(), &ReadOptions::default())
.await
.expect("repaired disk retains exact committed metadata");
assert_eq!(info.version_id, Some(version));
assert_eq!(info.size, i64::try_from(PAYLOAD_SIZE).expect("payload size fits i64"));
let checked = disk
.check_parts(bucket, &encode_dir_object(object), &info)
.await
.expect("verify genuine repaired shard");
assert!(!checked.results.is_empty());
assert!(
checked
.results
.iter()
.all(|result| *result == crate::disk::CHECK_PART_SUCCESS),
"unretired version's real repaired shard passes integrity verification"
);
}
}
fn assert_policy_error(error: &Error) {
assert!(
!matches!(
error,
Error::FileNotFound | Error::FileVersionNotFound | Error::ObjectNotFound(_, _) | Error::VersionNotFound(_, _, _)
),
"retirement policy refusal must not claim physical absence: {error:?}"
);
}
async fn assert_repair_rejected(store: &ECStore, bucket: &str, object: &str, version: &str) -> Error {
let result = HealOperations::heal_object(store, bucket, object, version, &heal_options()).await;
let error = match result {
Err(error) | Ok((_, Some(error))) => error,
Ok((item, None)) => {
panic!("ARTIFACT_RETIREMENT_NO_RESURRECTION: retired exact version was accepted for real repair: {item:?}")
}
};
assert_policy_error(&error);
// The proof-returning API must preserve the same refusal without converting it
// into authoritative absence that a later destructive caller could consume.
match store.heal_object_with_proof(bucket, object, version, &heal_options()).await {
Err(error) => assert_policy_error(&error),
Ok(result) => {
assert!(result.absence.is_none(), "retirement never produces a physical absence proof");
assert_policy_error(result.error.as_ref().expect("retired repair returns a policy error"));
}
}
error
}
async fn assert_retired_repair_rejected(store: &ECStore, bucket: &str, object: &str, version: &str) {
let error = assert_repair_rejected(store, bucket, object, version).await;
assert!(
error.to_string().contains("artifact object version is retired"),
"ARTIFACT_RETIREMENT_NO_RESURRECTION: expected retirement refusal, received {error:?}"
);
}
async fn seal(store: &Arc<ECStore>, bucket: &str, incarnation: Uuid, prefix: &str) {
store
.clone()
.seal_artifact_attempt_puts(bucket, incarnation, prefix)
.await
.expect("persist the attempt PUT seal before retirement");
}
#[test]
#[serial_test::serial(storage_class_env)]
fn artifact_retirement_requires_seal_exact_identity_and_canonical_directory() {
run_large_stack_async_test("artifact-retirement-identity", || async {
let root = tempfile::tempdir().expect("retirement identity directory");
let (ctx, store, shutdown) = open_store(root.path()).await;
let bucket = "artifact-retirement-identity";
let incarnation = create_bucket(&store, bucket, true).await;
let prefix = attempt_prefix();
let object = format!("{prefix}image.png");
let directory = format!("{prefix}folder/");
let version = put_payload(&store, bucket, &object, 0xA5, false).await;
let directory_version = put_payload(&store, bucket, &directory, 0, false).await;
let before = metadata(&store, bucket, &object).await;
assert!(
store
.clone()
.retire_artifact_object_version(bucket, incarnation, &prefix, &object, version)
.await
.is_err(),
"unsealed attempt cannot retire a version"
);
seal(&store, bucket, incarnation, &prefix).await;
let wrong_prefix = attempt_prefix();
seal(&store, bucket, incarnation, &wrong_prefix).await;
for (expected, attempt, target, selected) in [
(Uuid::new_v4(), prefix.clone(), object.clone(), version),
(incarnation, wrong_prefix, object.clone(), version),
(incarnation, prefix.clone(), object.clone(), Uuid::nil()),
(incarnation, prefix.clone(), prefix.clone(), version),
] {
assert!(
store
.clone()
.retire_artifact_object_version(bucket, expected, &attempt, &target, selected)
.await
.is_err(),
"retirement requires the exact incarnation, attempt, inner object, and non-null version"
);
}
assert_eq!(metadata(&store, bucket, &object).await, before);
let ordinary = "artifact-retirement-ordinary";
let ordinary_incarnation = create_bucket(&store, ordinary, false).await;
assert!(
store
.clone()
.retire_artifact_object_version(ordinary, ordinary_incarnation, &prefix, &object, version)
.await
.is_err(),
"retirement is limited to dedicated artifact buckets"
);
store
.clone()
.retire_artifact_object_version(bucket, incarnation, &prefix, &directory, directory_version)
.await
.expect("retire logical directory identity");
let record = artifact_version_retirement_record_path(bucket, incarnation, &prefix, &directory, directory_version);
assert_eq!(
record,
artifact_version_retirement_record_path(
bucket,
incarnation,
&prefix,
&encode_dir_object(&directory),
directory_version
)
);
let bytes = com::read_config(store.clone(), &record)
.await
.expect("read durable directory retirement");
store
.clone()
.retire_artifact_object_version(bucket, incarnation, &prefix, &encode_dir_object(&directory), directory_version)
.await
.expect("encoded directory retirement retry is idempotent");
assert_eq!(com::read_config(store.clone(), &record).await.expect("read unchanged record"), bytes);
assert_retired_repair_rejected(&store, bucket, &directory, &directory_version.to_string()).await;
assert_retired_repair_rejected(&store, bucket, &encode_dir_object(&directory), &directory_version.to_string()).await;
assert_payload(&store, bucket, &directory, directory_version, 0).await;
// Rejected identity requests must not retire the valid regular object.
let (_, missing) = remove_shard(&store, bucket, &object, version).await;
let (_, error) = HealOperations::heal_object(store.as_ref(), bucket, &object, &version.to_string(), &heal_options())
.await
.expect("unretired version remains repairable after rejected retirement");
assert!(error.is_none(), "unretired repair failed: {error:?}");
assert!(missing.exists(), "unretired version's real shard is restored");
assert_healthy_version(&store, bucket, &object, version).await;
assert_payload(&store, bucket, &object, version, 0xA5).await;
shutdown.cancel();
assert!(ctx.wait_for_detached_mutations(DEADLINE).await);
});
}
#[test]
#[serial_test::serial(storage_class_env)]
fn artifact_retirement_rejects_explicit_and_latest_repair_but_preserves_other_version_after_reopen() {
run_large_stack_async_test("artifact-retirement-versions", || async {
let root = tempfile::tempdir().expect("retired version fixture directory");
let (ctx, store, shutdown) = open_store(root.path()).await;
let bucket = "artifact-retirement-versions";
let incarnation = create_bucket(&store, bucket, true).await;
let prefix = attempt_prefix();
let object = format!("{prefix}image.png");
let retained = put_payload(&store, bucket, &object, 0xA5, false).await;
let retired = put_payload(&store, bucket, &object, 0xB6, false).await;
assert_ne!(retained, retired);
seal(&store, bucket, incarnation, &prefix).await;
let before = metadata(&store, bucket, &object).await;
let mut physical_parts = Vec::new();
for disk in disks(&store).await {
for version in [retained, retired] {
let info = disk
.read_version("", bucket, &object, &version.to_string(), &ReadOptions::default())
.await
.expect("snapshot exact version before retirement");
assert_eq!(info.version_id, Some(version));
let part = object_path(&disk, bucket, &object)
.join(info.data_dir.expect("payload data directory").to_string())
.join("part.1");
physical_parts.push((part.clone(), tokio::fs::read(part).await.expect("snapshot original shard bytes")));
}
}
store
.clone()
.retire_artifact_object_version(bucket, incarnation, &prefix, &object, retired)
.await
.expect("persist exact latest-version retirement");
assert_eq!(
metadata(&store, bucket, &object).await,
before,
"retirement itself does not edit object metadata"
);
for (part, bytes) in physical_parts {
assert_eq!(
tokio::fs::read(part).await.expect("retirement leaves every physical shard"),
bytes,
"retirement changes neither version's physical data"
);
}
assert_payload(&store, bucket, &object, retired, 0xB6).await;
assert_payload(&store, bucket, &object, retained, 0xA5).await;
let (_, retired_part) = remove_shard(&store, bucket, &object, retired).await;
let before = metadata(&store, bucket, &object).await;
for selected in [retired.to_string(), String::new()] {
assert_retired_repair_rejected(&store, bucket, &object, &selected).await;
assert!(
!retired_part.exists(),
"ARTIFACT_RETIREMENT_NO_RESURRECTION: retired shard must stay absent"
);
assert_eq!(metadata(&store, bucket, &object).await, before, "retired repair publishes no metadata");
}
let (_, retained_part) = remove_shard(&store, bucket, &object, retained).await;
let (_, error) = HealOperations::heal_object(store.as_ref(), bucket, &object, &retained.to_string(), &heal_options())
.await
.expect("another version on the same object remains repairable");
assert!(error.is_none(), "other-version repair failed: {error:?}");
assert!(retained_part.exists(), "other version's actual shard is restored");
assert_healthy_version(&store, bucket, &object, retained).await;
assert!(!retired_part.exists(), "other-version repair must not restore the retired shard");
assert_payload(&store, bucket, &object, retained, 0xA5).await;
assert_payload(&store, bucket, &object, retired, 0xB6).await;
shutdown.cancel();
assert!(ctx.wait_for_detached_mutations(DEADLINE).await);
drop(store);
drop(ctx);
let (ctx, store, shutdown) = open_store(root.path()).await;
assert_eq!(store.bucket_incarnation_id(bucket).await.expect("reopened incarnation"), incarnation);
assert_retired_repair_rejected(&store, bucket, &object, &retired.to_string()).await;
assert!(!retired_part.exists(), "reopen cannot forget exact-version retirement");
assert_payload(&store, bucket, &object, retired, 0xB6).await;
assert_payload(&store, bucket, &object, retained, 0xA5).await;
shutdown.cancel();
assert!(ctx.wait_for_detached_mutations(DEADLINE).await);
});
}
#[test]
#[serial_test::serial(storage_class_env)]
fn artifact_retirement_blocks_minority_metadata_regeneration() {
run_large_stack_async_test("artifact-retirement-regeneration", || async {
let root = tempfile::tempdir().expect("minority metadata fixture directory");
let (ctx, store, shutdown) = open_store(root.path()).await;
let bucket = "artifact-retirement-regeneration";
let incarnation = create_bucket(&store, bucket, true).await;
let prefix = attempt_prefix();
let object = format!("{prefix}image.png");
let version = put_payload(&store, bucket, &object, 0xA5, true).await;
seal(&store, bucket, incarnation, &prefix).await;
store
.clone()
.retire_artifact_object_version(bucket, incarnation, &prefix, &object, version)
.await
.expect("persist version retirement before minority metadata damage");
assert_payload(&store, bucket, &object, version, 0xA5).await;
let mut parts = Vec::new();
for (index, disk) in disks(&store).await.iter().enumerate() {
let info = disk
.read_version("", bucket, &object, &version.to_string(), &ReadOptions::default())
.await
.expect("read genuine legacy version before damage");
assert_eq!(info.version_id, Some(version));
let part = object_path(disk, bucket, &object)
.join(info.data_dir.expect("legacy payload has data directory").to_string())
.join("part.1");
parts.push((part.clone(), tokio::fs::read(&part).await.expect("snapshot real shard bytes")));
if index > 0 {
tokio::fs::remove_file(object_path(disk, bucket, &object).join(STORAGE_FORMAT_FILE))
.await
.expect("leave exactly one genuine metadata copy");
}
}
let before = metadata(&store, bucket, &object).await;
assert_eq!(before.iter().filter(|entry| entry.is_some()).count(), 1);
assert_retired_repair_rejected(&store, bucket, &object, &version.to_string()).await;
assert_eq!(
metadata(&store, bucket, &object).await,
before,
"ARTIFACT_RETIREMENT_NO_RESURRECTION: minority regeneration must not publish retired metadata"
);
for (path, bytes) in parts {
assert_eq!(tokio::fs::read(path).await.expect("retired shard remains physically present"), bytes);
}
shutdown.cancel();
assert!(ctx.wait_for_detached_mutations(DEADLINE).await);
});
}
#[test]
#[serial_test::serial(storage_class_env)]
fn artifact_retirement_corrupt_or_wrong_identity_records_fail_closed() {
run_large_stack_async_test("artifact-retirement-records", || async {
let root = tempfile::tempdir().expect("corrupt retirement fixture directory");
let (ctx, store, shutdown) = open_store(root.path()).await;
let bucket = "artifact-retirement-records";
let incarnation = create_bucket(&store, bucket, true).await;
let prefix = attempt_prefix();
let object = format!("{prefix}image.png");
let version = put_payload(&store, bucket, &object, 0xA5, false).await;
seal(&store, bucket, incarnation, &prefix).await;
store
.clone()
.retire_artifact_object_version(bucket, incarnation, &prefix, &object, version)
.await
.expect("create genuine retirement record");
let record = artifact_version_retirement_record_path(bucket, incarnation, &prefix, &object, version);
let original = com::read_config(store.clone(), &record)
.await
.expect("read genuine retirement JSON");
let decoded: serde_json::Value = serde_json::from_slice(&original).expect("retirement record is JSON");
let mut invalid = vec![Vec::new(), b"not-json".to_vec()];
for (field, value) in [
("format_version", serde_json::json!(2)),
("bucket", serde_json::json!("different-bucket")),
("base_prefix", serde_json::json!("other/")),
("incarnation_id", serde_json::json!(Uuid::new_v4())),
("attempt_prefix", serde_json::json!(attempt_prefix())),
("object", serde_json::json!(format!("{prefix}other.png"))),
("version_id", serde_json::json!(Uuid::new_v4())),
("unexpected_field", serde_json::json!(true)),
] {
let mut changed = decoded.clone();
changed
.as_object_mut()
.expect("strict identity record is an object")
.insert(field.to_owned(), value);
invalid.push(serde_json::to_vec(&changed).expect("encode invalid persisted identity"));
}
let (_, missing) = remove_shard(&store, bucket, &object, version).await;
let before = metadata(&store, bucket, &object).await;
for bytes in invalid {
// Inject corrupt persisted bytes through real guarded config I/O; the
// fixture does not emulate the production retirement state machine.
com::with_config_object_write_lock(store.clone(), record.clone(), {
let store = store.clone();
let record = record.clone();
move |guard| async move { com::save_config_no_lock(store, &record, bytes, &guard).await }
})
.await
.expect("hold matching record guard")
.expect("persist invalid record on real disks");
assert!(
store
.clone()
.retire_artifact_object_version(bucket, incarnation, &prefix, &object, version)
.await
.is_err(),
"corrupt or wrong-identity record cannot confirm idempotent retirement"
);
assert_repair_rejected(&store, bucket, &object, &version.to_string()).await;
assert!(!missing.exists(), "invalid record must not authorize real repair publication");
assert_eq!(metadata(&store, bucket, &object).await, before);
assert_payload(&store, bucket, &object, version, 0xA5).await;
}
shutdown.cancel();
assert!(ctx.wait_for_detached_mutations(DEADLINE).await);
});
}
#[test]
#[serial_test::serial(storage_class_env)]
fn artifact_retirement_waits_for_cancelled_physical_repair_and_retries_cancelled_retirement() {
run_large_stack_async_test("artifact-retirement-physical-tail", || async {
let root = tempfile::tempdir().expect("physical retirement fixture directory");
let (ctx, store, shutdown) = open_store(root.path()).await;
let bucket = "artifact-retirement-physical-tail";
let incarnation = create_bucket(&store, bucket, true).await;
let prefix = attempt_prefix();
let object = format!("{prefix}image.png");
let version = put_payload(&store, bucket, &object, 0xA5, false).await;
seal(&store, bucket, incarnation, &prefix).await;
let (disk, part) = remove_shard(&store, bucket, &object, version).await;
let destination = object_path(&disk, bucket, &object);
let (entered_tx, entered_rx) = tokio::sync::oneshot::channel();
let (release_tx, release_rx) = std::sync::mpsc::channel::<()>();
let repair_hook = os::prepared_publication_test_hooks::install_at(
os::prepared_publication_test_hooks::Stage::PreparedRename,
&destination.join(STORAGE_FORMAT_FILE),
move || {
let _ = entered_tx.send(());
// Sender drop also releases physical work when an assertion unwinds.
let _ = release_rx.recv();
},
);
let repair_store = store.clone();
let repair_object = object.clone();
let mut repair = tokio::spawn(async move {
HealOperations::heal_object(repair_store.as_ref(), bucket, &repair_object, &version.to_string(), &heal_options())
.await
});
tokio::time::timeout(DEADLINE, async {
tokio::select! {
entered = entered_rx => entered.expect("repair reaches real physical rename syscall"),
result = &mut repair => panic!("repair returned before syscall pause: {result:?}"),
}
})
.await
.expect("real repair worker is paused");
repair.abort();
assert!(repair.await.expect_err("cancel repair caller").is_cancelled());
let retire_store = store.clone();
let retire_prefix = prefix.clone();
let retire_object = object.clone();
let mut retirement = tokio::spawn(async move {
retire_store
.retire_artifact_object_version(bucket, incarnation, &retire_prefix, &retire_object, version)
.await
});
let blocked = tokio::time::timeout(Duration::from_millis(200), &mut retirement)
.await
.is_err();
drop(release_tx);
drop(
tokio::time::timeout(DEADLINE, os::acquire_rename_data_mutation_lease(&disk.path(), bucket, &destination))
.await
.expect("cancelled physical repair drains"),
);
drop(repair_hook);
if blocked {
tokio::time::timeout(DEADLINE, retirement)
.await
.expect("retirement resumes after real repair tail")
.expect("retirement task joins")
.expect("retirement persists");
}
assert!(
blocked,
"ARTIFACT_RETIREMENT_NO_RESURRECTION: retirement must wait for cancelled repair's physical publication tail"
);
assert_payload(&store, bucket, &object, version, 0xA5).await;
// A cancelled repair may roll back its destination; retirement must stop
// any later repair regardless of whether that first publication committed.
let before = metadata(&store, bucket, &object).await;
let part_before = file_bytes(&part).await;
assert_retired_repair_rejected(&store, bucket, &object, &version.to_string()).await;
assert_eq!(metadata(&store, bucket, &object).await, before);
assert_eq!(file_bytes(&part).await, part_before);
let second_prefix = attempt_prefix();
let second_object = format!("{second_prefix}cancelled.bin");
let second_version = put_payload(&store, bucket, &second_object, 0xB6, false).await;
seal(&store, bucket, incarnation, &second_prefix).await;
let record = artifact_version_retirement_record_path(bucket, incarnation, &second_prefix, &second_object, second_version);
let record_disk = disks(&store).await.remove(0);
let record_path = object_path(&record_disk, RUSTFS_META_BUCKET, &record).join(STORAGE_FORMAT_FILE);
let (entered_tx, entered_rx) = tokio::sync::oneshot::channel();
let (release_tx, release_rx) = std::sync::mpsc::channel::<()>();
let _retirement_hook = os::prepared_publication_test_hooks::install_at(
os::prepared_publication_test_hooks::Stage::PreparedRename,
&record_path,
move || {
let _ = entered_tx.send(());
let _ = release_rx.recv();
},
);
let retire_store = store.clone();
let retire_prefix = second_prefix.clone();
let retire_object = second_object.clone();
let mut retirement = tokio::spawn(async move {
retire_store
.retire_artifact_object_version(bucket, incarnation, &retire_prefix, &retire_object, second_version)
.await
});
tokio::time::timeout(DEADLINE, async {
tokio::select! {
entered = entered_rx => entered.expect("retirement reaches real record rename syscall"),
result = &mut retirement => panic!("retirement returned before durable record publication: {result:?}"),
}
})
.await
.expect("retirement's real config publication is paused");
retirement.abort();
assert!(retirement.await.expect_err("cancel retirement caller").is_cancelled());
let retry_store = store.clone();
let retry_prefix = second_prefix.clone();
let retry_object = second_object.clone();
let mut retry = tokio::spawn(async move {
retry_store
.retire_artifact_object_version(bucket, incarnation, &retry_prefix, &retry_object, second_version)
.await
});
let retry_blocked = tokio::time::timeout(Duration::from_millis(200), &mut retry).await.is_err();
drop(release_tx);
if retry_blocked {
tokio::time::timeout(DEADLINE, retry)
.await
.expect("retry waits for original publication tail")
.expect("retry task joins")
.expect("cancelled retirement is confirmed idempotently");
}
assert!(retry_blocked, "retirement retry cannot pass a physically incomplete original publication");
let persisted = com::read_config(store.clone(), &record)
.await
.expect("cancelled retirement left durable record");
store
.clone()
.retire_artifact_object_version(bucket, incarnation, &second_prefix, &second_object, second_version)
.await
.expect("idempotently confirm cancelled retirement");
assert_eq!(com::read_config(store.clone(), &record).await.expect("read confirmed record"), persisted);
assert_retired_repair_rejected(&store, bucket, &second_object, &second_version.to_string()).await;
assert_payload(&store, bucket, &second_object, second_version, 0xB6).await;
shutdown.cancel();
assert!(ctx.wait_for_detached_mutations(DEADLINE).await);
});
}

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