Compare commits

..

112 Commits

Author SHA1 Message Date
cxymds 3e337bad97 ci(validation): bind recovery to retained runner evidence 2026-10-26 15:31:40 +08:00
cxymds 12a23859a1 ci(validation): retain interrupted evidence without fleet recovery 2026-10-26 15:08:15 +08:00
cxymds 081d3b1469 ci(validation): isolate each client virtual environment 2026-10-26 14:39:21 +08:00
cxymds 56a80f7b62 ci(validation): prepare Python before creating client environments 2026-10-26 14:37:43 +08:00
overtrue 0a366d967b chore: merge main into validation runner branch 2026-10-26 12:43:57 +08:00
Hauser fea5a584c3 fix(storage): fence multipart and metadata recovery (#8919)
* perf(ilm): back off idle tier-delete journal recovery

Use bounded idle backoff only after complete empty journal and dispatch scans, while preserving immediate notifications and the retry cadence for observed work.

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

* fix(ecstore): reconcile interrupted pool metadata publication

Bind pool metadata CAS and Undo to exact native predecessors. Recover an interrupted physical publication only from an ordinary committed authority and complete verified identity, current and backup evidence, then require normal V3 CAS and quorum readback.

Preserve known-conflict gates, startup and heal lock ordering, targeted admission and the original kill-recovery deadline. Wake proof-ready recovery immediately and bound parallel capacity sampling without removing mutation fences.

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

* perf(ecstore): reuse proven pool metadata durability

Publish native rollback backups directly in their owned UUID directories. Reuse a one-shot data-durability receipt only for an exact hardlink to the pinned, fully synchronized head and root.

Preserve new-head and directory synchronization, native predecessor CAS, conditional Undo and uncertainty gates. Keep unknown, copied, raw-parent, cancelled and failed operations cold. Add bounded diagnostics, sync-call regressions and path-isolated test hooks.

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

* test(multipart): exercise selective RPC delay and peer loss

Add authenticated method-entry barriers for metadata and lifecycle locks under e2e-test-hooks. Verify real dispatch, write quorum, prior part payloads, lock drain and incarnation isolation without changing production budgets.

Record cancellation separately from normal exits so late filesystem completion cannot overwrite Drop evidence.

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

* docs: define multipart delay gates and receipt invariants

Document RPC entry evidence, peer-loss and cancellation oracles, remaining incident attribution limits, and immutable head inodes required for durability receipts.

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

* fix(readiness): expose missing runtime drive slots

Report configured slots without a runtime handle separately from unavailable inventoried drives. Preserve quorum and probe status semantics. Wait for every coordinator to regain its complete live inventory before multipart RPC fault windows and single-attempt recovery oracles.

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

* fix(ecstore): retry committed promotion convergence safely

Require peer reload convergence on every worker attempt, including unchanged retries. Recheck durable identity, replicas, namespace fence, generation and cancellation before allowing any data movement.

Keep known committed promotions active on a verified propagation pause. Release precise owners, retain queued successors and reuse the existing watchdog. Preserve uncertainty gates and terminal operation behavior.

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

* fix(ecstore): guard native metadata mutation aliases

Reject protected RenamePart source and destination paths before I/O. Classify the protected namespace through the native path resolver, and require canonical conditional RenameData and Undo keys so aliases cannot create separate lease domains.

Preserve ordinary multipart and canonical CAS behavior, warm durability receipts and zero-allocation canonical fast paths. Cover real alias, byte identity, receipt and normal mutation behavior.

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

* test(e2e): register Darwin multipart RPC delay gates

Update distributed membership from the actual Darwin listing. Preserve the full-profile digest and leave Linux unchanged until its own listing is produced, as required by the contributor guide.

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

* fix(multipart): fence native publication and recovery

Publish multipart data through private hardlinks and atomically consume the
prepared source head. Bind exact compensation capabilities and native recovery
to the physical cohort, source generation and durable publication state. Require
fresh terminal confirmation for native replay and preserve indeterminate data
when no safe recovery witness exists.

Release logical guards only after validated irreversible quorum success while
retaining physical mutation leases, accounting and resource ownership through
actual callbacks. Preserve local instance proof authority and historical Copy
metadata without granting healed donors fresh publication authority.

Keep general movement identity strict while preserving directory capacity
compatibility in a separate checked comparison.

Cover native fault and cancellation boundaries, zero-byte objects, real Copy,
heal and migration, exact physical readback and distributed RPC delay. Retain
all quorum and durability requirements and document rollout limitations.

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

* test(ecstore): fix ratchet scan and Windows fault hooks

Move native pool metadata inventory tests after production items so the
error-format ratchet sees the existing call site. Preserve the baseline
instead of accepting a false count decrease.

Make the directory-barrier failure hook available on every test platform
and exercise the parent-barrier failure/retry cases in Windows CI. Keep
production synchronization behavior and all existing assertions intact.

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

* test(protos): remove redundant recovery request clone

Move the baseline into its final struct update while retaining the earlier
clone needed to construct the first request variant.

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

* test(e2e): align RPC delay gates with native abort

* fix(storage): validate native source recovery and CI gates

Preserve wire layouts while bounding native response sizes and fix the
last-use recovery request clone. Avoid destination replay reads only for
fully observed initial native sources; keep upgraded and recovered sources
conservative. Cover real legacy publication after a cold restart.

Align upgrade acceptance with the native mixed-version mutation pause and
use exact upload-scoped test completion evidence before advancing time.
Keep all original quorum, ownership, byte and deadline assertions.

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

* test(e2e): verify native abort under partial admission

Delay native cleanup at the exact upload root after all-participant closure.
Verify quorum-only admissions retain every source byte on refused Abort and
complete the same upload without creating a missing original participant.
Keep sub-quorum orphans and their discoverable identities isolated while a
fresh all-participant upload proves service recovery. Route shared constants
through the existing E2E storage facade.

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

* test(e2e): avoid formatting a fixed invalid upload path

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

* test(e2e): align native MPU fault recovery assertions

Verify real source participants, preserved orphan bytes and same-upload completion after quorum-only admission. Require full physical inventories so an expected read-quorum error cannot hide unrelated leakage. Preserve ordinary quorum and recovery budgets, and document listing availability through the affected set.

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

* test(ecstore): keep MPU tail deadlines on the wall clock

Global Tokio time advancement can expire drive-health probes while real
filesystem callbacks are pending. Use a short real drive deadline with the
existing physical-entry and cleanup barriers, retaining the byte, lock,
resource-permit and final-shard assertions.

Fixes #8937

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

* test(storage): align native fixtures with instance ownership

Open independent disks for the simulated capacity-loss instance, scope four coordinator journal disks without rebinding source authority, and probe configured namespace keys while retaining physical I/O hooks.

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

* fix(ecstore): isolate native multipart future construction

Construct optional native publication futures behind synchronous heap boundaries so ordinary rename callers do not carry their state machines. Preserve native guards and ownership without adding allocations to ordinary PUT, copy or heal branches.

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

* test(heal): assert coordinator journal scope after rejoin

Check the exact original four-member journal map after every rejoin and manager stop while preserving all sixteen native data slots. Reconnect expansion fails the test rather than being filtered away.

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

* fix(heal): bound object repair future construction

Construct the normal and FileCorrupt deep-retry state machines behind a synchronous heap boundary. Preserve guards and cancellation while bounding the shared rename and deletion call stacks without increasing test stacks.

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

* test(ecstore): qualify fixture disk identity checks

Call the same DiskAPI method explicitly so the independently reopened disk identity assertion compiles under every target and feature combination.

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

* fix(ecstore): isolate native inspection from mutation state

Select a dedicated read-only inspection future behind the existing single Box allocation. Preserve validation, source ownership and the canonical inspection body while keeping mutation state out of inline read callers. Reject misrouted operations with typed errors and retain source evidence.

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

* test(ecstore): pause abort time after native cleanup

Wait for durable source deletion on the wall clock before advancing the
capacity lease clock at the post-delete barrier. Paused time can otherwise
expire the barrier while native filesystem cleanup is still pending.

Retain lease-loss injection, durable inflight-byte accounting and restart
reconciliation assertions. Document the test-util feature required to
select the native decommission fixtures in package-scoped regressions.

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

* fix(ecstore): reclaim data from owned multipart undo

Retain exact runtime-only Undo ownership through deferred snapshot cleanup. Revalidate every target metadata version and the retirement anchor, keep the delete claim through the physical callback, and remove the anchor after the payload barrier. Preserve generic cleanup refusal when metadata is missing.

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

* fix(ecstore): preserve unformatted bootstrap routes

Keep explicitly unformatted local paths reachable by an in-flight elected formatter while quarantining their service slots and disk-ID membership. Own bounded legacy-read handles so the actual asynchronous creator future remains Send.

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

* fix(s3): bound CopyObject tracing poll frames

Construct the business future behind an outlined single-Box boundary and lazily instrument its handle. Preserve Send, Unpin, span timing and cancellation without embedding concrete business-state construction in the tracing poll.

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

* test(mpu): observe recovery after direct legacy bootstrap

Seed all sixteen real previous-release sources before attaching the same-port method observers. Require exact per-peer disk-route Unimplemented responses with zero publication calls, retain byte and cleanup assertions, and remove superseded raw transport. Register the two new regressions from actual Linux and macOS listings.

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

* test(mpu): prepare the homogeneous previous-release fleet

Reuse the existing release baseline before creating the genuine legacy multipart sources. Restart old processes with their normal budgets and require every drive through the reduced-redundancy probes before installing the mixed-version observers and current fault budgets.

Keep standard EC:4, single-attempt calls, all mixed-version byte and reclamation assertions, and their original deadlines unchanged.

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

* test(mpu): isolate release compatibility from fault profiles

Keep normal component configuration for the observed mixed-release rollout, which injects no RPC delay or loss. Retain the short component profile in actual method-fault scenarios and preserve the single-attempt 45-second probe and every typed error, routing, source byte and reclamation assertion.

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

* test(mpu): use normal shutdown for planned release replacements

Use the existing bounded SIGTERM path during homogeneous release preparation and the observed mixed-version rollout. Preserve every multipart inventory, routing, quorum and byte assertion, and keep actual crash recovery on its original hard-kill path.

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

* revert: retain the verified release compatibility fixture

Restore the published fixture after the local configuration and lifecycle experiments failed to resolve the macOS startup failures. Keep their diagnostic records separate from the delivered implementation.

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

* fix(copy): bound inherent handler polling stack

Build the inherent CopyObject future through a synchronous noinline
factory at the existing trait await. This keeps its large state out of
the trait wrapper's active polling frame during historical self-copy.
Preserve cancellation, tracing and post-write notifications, with one
additional allocation restricted to COPY.

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

* test(e2e): validate authoritative quota and native pool heals

Require actual authoritative quota usage in the existing readiness loop
instead of treating HTTP success or unknown usage as a numeric baseline.
Keep the original quota assertions and convergence budgets.

Allow the owning pool metadata CAS to republish into a fresh valid data
directory while retaining exact version, layout, completeness and shard
fingerprints. Require every node's real metadata/read/write readiness
before accepting recovery. Keep ordinary object manifests and the original
180-second recovery deadline unchanged, with negative oracle regressions.

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

* test(e2e): distinguish certified native Abort recovery

Require native participants with a persisted full closure certificate to retire their heads and reclaim every surviving source on retry. Keep uncertified legacy authority below identity quorum conservative, while retaining actual partial-delete and committed-object byte checks.

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

---------

Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
Co-authored-by: overtrue <anzhengchao@gmail.com>
2026-10-26 12:39:54 +08:00
Chris An e28ac2c9d4 test(s3-client): make range preflight checks deterministic (#8952) 2026-10-26 12:03:45 +08:00
Chris An 4a96f06983 refactor(listing): remove env-gated ListObjects index provider (#8948)
* refactor(listing): keep paginated listing on ECStore only

`Sets` and `SetDisks` carried their own copies of `inner_list_objects_v2`,
`list_objects_generic`, `inner_list_object_versions`, `list_path` and
`list_merged`, plus a third copy of the task that converts merged raw entries
into the walk result stream. Only `ECStore` can answer a paginated listing,
since a page merges every set of every pool; the copies drifted (the `Sets`
copy still rejected `//` prefixes that #8605 made literal on `ECStore`) and
were reachable only from tests and one decommission helper.

Remove the `ListOperations` impls for `Sets` and `SetDisks` together with the
duplicated pagination code. `SetDisks::walk` stays as an inherent method for
set-scoped callers and now honors `versions_sort` like the store-wide walk;
`SetDisks::list_path_result` stays for the recursive delete lister. The walk
entry conversion moves into one `spawn_walk_entry_forwarder` shared by both
walks. The decommission receipt lister walks its pool's sets instead of
paginating through the removed `Sets` listing.

Tests that exercised the removed layers now exercise `ECStore` for pagination
or `SetDisks::walk` for per-pool version inspection. The architecture and
logging guard rows that pinned the removed `SetDisks`/`Sets` listing entry
points are dropped, and the error-other ratchet baseline shrinks by one.

* fix(listing): exclude synthetic directories from receipt walks

* refactor(listing): remove env-gated ListObjects index provider

Delete the experimental, default-off key-only / verified-page /
metadata-fast index provider from `ECStore::list_objects_generic` together
with everything only it drove: the `RUSTFS_LIST_OBJECTS_INDEX_*`,
`RUSTFS_LIST_OBJECTS_NAMESPACE_JOURNAL_*` and
`RUSTFS_LIST_OBJECTS_METADATA_FAST_*` readers, the persistent key-only
index and its on-disk format, the namespace mutation journal (state,
chaos injector, system/local backends, degraded set, global lock), the
index lifecycle/health/source-selection types, the verified-index
candidate pager, the `list-chaos` cargo feature, the
`rustfs_s3_list_objects_index_*` metrics and the archived bench script.

`list_objects_generic` now always walks. The write-path hooks
`observe_list_objects_mutation(s)` are gone; their callers bump the scanner
namespace generation directly through `observe_scanner_namespace_mutations`,
which is the sub-step the old hook performed before taking the journal
lock, so the generation observed by the scanner, the node RPC service and
the data-movement lease checks is unchanged while PUT/DELETE no longer take
a global tokio RwLock or read environment variables.

`ListSourceMode::{metadata_authority, requires_live_metadata_verification,
can_satisfy_strong_listing}` and `ListMetadataAuthority` were only reachable
from the provider and are removed as well; the enum, its variants, the
cursor encoding and `ListContinuationV2` are left untouched.

* docs(listing): define index retirement upgrade prerequisites

* fix(ci): retire the removed listing index test requirement
2026-10-26 12:02:15 +08:00
Hauser 927b3969bf test(e2e): validate conservative Abort recovery boundaries (#8941)
test(e2e): assert Abort refusal below identity quorum

Keep exactly-quorum success and recoverable cleanup assertions. For
minority staging authority, require typed refusal, zero further Delete
RPCs, unchanged per-disk metadata and the previous object body. This
aligns the phase matrix with the existing conservative recovery contract.

Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-10-26 10:19:05 +08:00
cxymds 31c9d85645 ci(validation): inspect shared runner without fleet access 2026-10-26 09:59:52 +08:00
Hauser fab4bb0586 test(e2e): cover simultaneous multipart terminal contenders (#8943)
* test(e2e): assert Abort refusal below identity quorum

Keep exactly-quorum success and recoverable cleanup assertions. For
minority staging authority, require typed refusal, zero further Delete
RPCs, unchanged per-disk metadata and the previous object body. This
aligns the phase matrix with the existing conservative recovery contract.

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

* test(e2e): exercise simultaneous multipart terminal contenders

Retain the four pairwise resend cases and add live/cancelled three-way Complete, Abort and resend races. Observe distinct forwarded lock owners and verify terminal bytes and physical cleanup.

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

* test(e2e): freeze contender evidence before multipart cleanup

Retire the passive lock observer before terminal ListParts checks so the reported count identifies only the two contenders at the preparation barrier.

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

* test(e2e): require both multipart terminal race outcomes

Retain the free races and select both terminal winners with independently released, owner-scoped lock gates. Verify real contender admission, unchanged signed packets and exact terminal bytes for live and cancelled part waiters.

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

* test(e2e): isolate losing coordinator lock votes

Hold every remote vote for the losing coordinator while the same gate observes the winner. Prevent partial lock grants from delaying the requested terminal order and retain ordinary RPC caller lifetime.

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-26 09:24:38 +08:00
Chris An b7b26a6a49 refactor(listing): keep paginated listing on ECStore only (#8936)
* refactor(listing): keep paginated listing on ECStore only

`Sets` and `SetDisks` carried their own copies of `inner_list_objects_v2`,
`list_objects_generic`, `inner_list_object_versions`, `list_path` and
`list_merged`, plus a third copy of the task that converts merged raw entries
into the walk result stream. Only `ECStore` can answer a paginated listing,
since a page merges every set of every pool; the copies drifted (the `Sets`
copy still rejected `//` prefixes that #8605 made literal on `ECStore`) and
were reachable only from tests and one decommission helper.

Remove the `ListOperations` impls for `Sets` and `SetDisks` together with the
duplicated pagination code. `SetDisks::walk` stays as an inherent method for
set-scoped callers and now honors `versions_sort` like the store-wide walk;
`SetDisks::list_path_result` stays for the recursive delete lister. The walk
entry conversion moves into one `spawn_walk_entry_forwarder` shared by both
walks. The decommission receipt lister walks its pool's sets instead of
paginating through the removed `Sets` listing.

Tests that exercised the removed layers now exercise `ECStore` for pagination
or `SetDisks::walk` for per-pool version inspection. The architecture and
logging guard rows that pinned the removed `SetDisks`/`Sets` listing entry
points are dropped, and the error-other ratchet baseline shrinks by one.

* fix(listing): exclude synthetic directories from receipt walks

* fix(listing): stop abandoned walks and filter directory entries
2026-10-26 09:05:11 +08:00
Chris An 197ab19f0c test(heal): verify backup recovery and tier owner handoff (#8935)
* test(heal): verify backup recovery and tier owner handoff

* test: prepare tier mover before holding remote delete
2026-10-26 08:00:42 +08:00
Hauser a5e1e9312d test(admin): cover target inventory HTTP acceptance (#8942)
* test(admin): cover target inventory HTTP acceptance

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

* test(admin): wire inventory suite and cover temporary sessions

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

* test(ci): pin expanded inventory acceptance membership

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-26 06:17:12 +08:00
Remi Cattiau 62d148b045 fix(scanner): retry unreadable cycle state in remote walk fence poll (#8939)
While a remote namespace scanner walk runs, the fence watcher reads the
persisted cycle state every 5 s and stops the walk on any error. A read
that hits the 5 s distributed-lock timeout on .bloomcycle.bin is then
reported as a fence change: the bucket scan fails, the set cache is not
published and the cycle ends without a durable usage snapshot.

Return ScannerError::CycleStateUnavailable (same message as before) when
the state cannot be read, and let the watcher tolerate up to 12
consecutive such polls before stopping, logging a warning each time and
resetting the count after a successful poll. A mismatching fence and any
other error still stop the walk at once; the fence check when the stream
opens is unchanged.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 06:09:06 +08:00
Chris An 8e6dc42772 docs(release): reconcile main and clean completed release branches (#8945) 2026-10-26 05:46:16 +08:00
Chris An af10369f34 chore(release): align installation references for 1.0.4 (#8940) 2026-10-26 05:31:13 +08:00
Hauser c610d11e8f fix(storage): guard MPU recovery and startup RPC admission (#8934)
* fix(ecstore): preserve uncertainty after partial multipart abort

Keep recovery callers from reporting NoSuchUpload while minority primary metadata remains. Retain all quorum and deletion checks. Exercise actual partial Abort and explicitly retained late RPC packets after caller retirement, staging cleanup and bucket recreation.

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

* fix(ecstore): prevent retired multipart staging resurrection

Require the upload root to exist before preparing recovery publication. Preserve rooted directory walks for nested bases on non-Linux Unix and cover missing-source native work, configured aliases and pinned mixed-version coordinator recovery.

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

* test(e2e): use IPv4 peer aliases for pinned mixed recovery

Keep direct IPv6 S3 listeners behind IPv4 RPC proxies for the pinned previous release, whose peer health resolver rejects bracketed IPv6 hosts. Preserve signed authorities, ports and strict recovery assertions.

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

* test(e2e): separate pinned startup from MPU fault budgets

Use the old fleet defaults during bootstrap, then apply the original short fault budgets to the current replacement. Keep direct S3 URLs aligned with IPv6 listeners and preserve all quorum and physical cleanup assertions.

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

* test(e2e): seed legacy MPU through transparent transport

Retire raw proxy connections before enabling method faults so legacy streaming setup stays byte-transparent and every later scoped mutation must traverse the fault barrier. Verify raw and adapted protocol authority, bodies and trailers.

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

* test(e2e): retain direct listeners for legacy MPU peers

Advertise same-port localhost peers for the pinned resolver while retaining direct IPv4 S3 listeners. Exact scoped RPC hit counts still reject bypassed injection and all physical authority assertions remain unchanged.

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

* fix(server): keep startup gRPC on the authenticated router

Use the hybrid protocol predicate for startup RPC admission so early control responses retain production authentication and owner validation. Keep REST FullReady gating and pinned replay capability rejection intact. Add deterministic regressions, scoped diagnostics and bounded legacy fixture resources.

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

* test(ci): verify MPU recovery across upgrade source versions

Run the exact sixteen-drive mixed MPU case for every checksum-verified upgrade source, independently of generic compatibility failures. Require one listed test and retain per-version logs while preserving the historical distributed source pin and every recovery assertion.

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

* test(ci): trigger upgrade MPU checks for fixture changes

Include distributed fixtures and the RPC fault proxy in the upgrade
workflow paths so test-only changes schedule the strict recovery gate.

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

* test(storage): verify resend owners across MPU finalization

Hold real part-transaction RPCs while another coordinator completes or
aborts the upload, with both live and cancelled client response waiters.
Require full object bytes, terminal upload state, and all staging roots
to remain consistent after the part owner drains.

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-26 01:29:43 +08:00
Hauser c721416e4c test(ecstore): verify encoding inputs against released shards (#8933)
Compare borrowed, owned Vec, and BytesMut encoder outputs with pinned GF8 and historical GF16 shard payloads. Check pre-reserved buffer reuse and stale-tail padding without changing production behavior.

Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-10-26 00:01:50 +08:00
Chris An 7b29e9d65f test(scanner): verify checkpoint debt across process restarts (#8938)
test(scanner): preserve checkpoint debt across process restarts
2026-10-25 23:43:41 +08:00
Chris An 85231b71e6 fix(heal): gate unresolved MRF lifecycle writes on reader support (#8918)
* feat(heal): read retained MRF lifecycle v2 checkpoints

* feat(heal): advertise MRF lifecycle reader capability

* fix(heal): gate unresolved MRF lifecycle writes on reader support

* test(heal): use native path for CAS publication hook
2026-10-25 22:59:49 +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
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
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
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
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
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
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
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
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
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 b1f7fb2769 test(scanner): adapt scope regression to admission receipts 2026-10-24 09:35:28 +08:00
overtrue c20448dfce chore: merge main into validation runner 2026-10-24 08:00:59 +08:00
overtrue 887716ca61 chore: merge main into validation runner 2026-10-23 19:34:20 +08:00
cxymds dae95d7afe fix(heal): retain resolved object identity on terminal failures 2026-10-23 11:58:02 +08:00
cxymds b0f60ed8d9 fix(ci): retain validation evidence outside runner temp 2026-10-23 09:43:20 +08:00
cxymds 78c803e1ef fix(heal): retain cancellation after successful bucket storage I/O 2026-10-23 06:26:00 +08:00
cxymds a2d6aa5a7f test(admin): reuse error facade in heal query regression 2026-10-23 05:12:08 +08:00
cxymds 553be4b4c6 fix(heal): query automatic tasks on their owning node 2026-10-23 04:45:20 +08:00
overtrue 96858cd1f1 style: format RPC digest precedence assertion 2026-10-23 04:37:42 +08:00
overtrue 3d357cec44 chore: merge main into validation runner 2026-10-23 04:30:19 +08:00
cxymds 9bce929ae5 test(scanner): reuse storage boundary for scope regression 2026-10-23 01:58:03 +08:00
cxymds a74ddfab3c fix(scanner): bind object heals to the scanning disk scope 2026-10-23 01:22:15 +08:00
cxymds 1676878676 test(validation): wire I05 queued-successor native prerequisites 2026-10-22 21:05:52 +08:00
overtrue b3031bed59 chore: merge main into validation runner 2026-10-21 21:16:33 +08:00
cxymds c86faf193a fix(ci): require complete native admission for multipart races 2026-10-21 10:09:01 +08:00
cxymds e30012c4ef test(scanner): assert typed corruption after metadata truncation 2026-10-20 12:47:13 +08:00
cxymds 2babf7624d ci(validation): admit source-bound owner and copy race proofs 2026-10-20 12:16:00 +08:00
cxymds 757b751482 test(ecstore): cover default heal owners and copy metadata races 2026-10-20 11:32:20 +08:00
cxymds 09fb813a00 test(ecstore): fence multipart continue complete and abort during heal 2026-10-20 10:06:54 +08:00
cxymds 738e5fa14a ci(validation): require G04 native and runtime admission 2026-10-20 09:31:57 +08:00
cxymds 8380466f3a test(ecstore): fence marker and batch deletes during heal 2026-10-20 09:26:19 +08:00
cxymds 170ec8d983 ci(validation): require complete race native runtime preflight 2026-10-20 08:59:50 +08:00
cxymds bd0671ba78 test(ecstore): fence version races and bind cleanup slots 2026-10-20 06:27:54 +08:00
cxymds f954164048 ci(validation): admit source-bound G01 native evidence before fleet execution 2026-10-20 04:35:09 +08:00
cxymds 6afdec9fe1 test(ecstore): exercise heal during committed overwrite cleanup 2026-10-20 04:07:05 +08:00
cxymds 87bd36b313 merge: sync validation fixes with current main 2026-10-20 03:35:22 +08:00
cxymds a4c02d5ac3 fix(filemeta): classify supported inline framing damage as corrupt 2026-10-20 01:59:26 +08:00
cxymds 62ff96774f fix(s3): map typed RPC unavailability to service refusal 2026-10-20 00:34:17 +08:00
cxymds 85c706cac0 fix(observability): retain typed object error diagnostics 2026-10-19 22:13:24 +08:00
cxymds 7951bb6298 fix(heal): retain exhausted admin batch repair receipts 2026-10-16 15:33:15 +08:00
cxymds a3230599a8 fix(ci): allow recovery of completed cancelled validation runs 2026-10-15 19:19:31 +08:00
cxymds 1c7fc818dd fix(ecstore): retain below-quorum data during no-remove heal 2026-10-13 02:02:13 +08:00
cxymds 5475bd06bc chore(ecstore): shrink typed-error ratchet baseline 2026-10-13 01:07:12 +08:00
cxymds a3ba311ab0 fix(ecstore): retain typed quorum errors for missing parts 2026-10-13 00:59:50 +08:00
cxymds e517d35fa2 test(ci): archive scoped F07 directory fsync evidence 2026-10-12 23:47:15 +08:00
cxymds 1b7a197267 fix(ci): isolate validation package publication credentials 2026-10-12 23:30:45 +08:00
Chris 3b416b76cd Merge branch 'main' into cxymds/validation-runner 2026-10-12 06:18:15 -07:00
cxymds bebee3d5d0 ci(validation): verify C11 native storage evidence 2026-10-12 19:07:12 +08:00
cxymds 1aa9be87d1 test(ecstore): verify incarnation fence at repair commit 2026-10-12 18:38:26 +08:00
cxymds e224eb48e5 fix(heal): deeply verify read repair payload failures 2026-10-12 17:40:36 +08:00
cxymds 16cf19817e ci(validation): dispatch isolated packages through the driver 2026-10-12 12:29:11 +08:00
cxymds 32fd1e704a test(validation): verify isolated package publication failures 2026-10-12 12:22:53 +08:00
cxymds ac197ea655 test(validation): enforce private candidate manifest schema 2026-10-12 12:18:15 +08:00
cxymds e18ed73942 ci(validation): publish isolated immutable candidate packages 2026-10-12 12:06:59 +08:00
cxymds 3c131f2b2e fix(heal): preserve typed errors and SSE-C response headers 2026-10-12 11:36:29 +08:00
cxymds c5f4b5a4f7 fix(heal): preserve typed bucket errors for bounded retries 2026-10-12 10:45:27 +08:00
cxymds 03fb3d78a0 ci(validation): consume pinned manager native prerequisites 2026-10-11 11:55:36 +08:00
cxymds b8126e50f7 test(ci): expose manager regressions through tools-only dispatch 2026-10-11 11:21:35 +08:00
cxymds 59fb72aa82 test(ci): add isolated manager native validation builds 2026-10-11 11:20:46 +08:00
cxymds 9de01c9170 docs(ci): describe supported validation recovery 2026-10-11 11:07:20 +08:00
cxymds 2ec26fa4b9 feat(ci): expose read-only validation fleet inventory 2026-10-11 10:42:48 +08:00
cxymds f4507178a3 test(ci): bind C10 execution to the verified shared codec 2026-10-11 10:09:26 +08:00
cxymds 9b4cc40abd test(validation): build optional C10 fixture codec on hosted Linux 2026-10-11 09:44:09 +08:00
cxymds b14ff4eeb1 test(validation): bind C07 to pinned native authority evidence 2026-10-11 09:27:59 +08:00
cxymds ae9b5cbabf Run validation tool resolution on hosted Linux without acquiring the fleet lock 2026-10-11 07:49:26 +08:00
cxymds 6c78b0d996 test(validation): route native tools through the existing driver 2026-10-11 06:06:17 +08:00
cxymds 0066e34764 test(validation): add optional native Heal regression tooling 2026-10-11 06:03:43 +08:00
cxymds fbddcc2965 ci: audit retained validation environments before finalization 2026-10-10 23:28:39 +08:00
cxymds b1793b55a0 test(validation): verify Heal tools against released shard captures 2026-10-10 21:11:43 +08:00
cxymds 835bd3f52d test(validation): verify pinned Heal tools before fleet execution 2026-10-10 20:46:40 +08:00
cxymds cb32d55878 test(validation): expose tool artifacts through the existing manual lane 2026-10-10 20:35:14 +08:00
cxymds 864e58592c test(validation): build the candidate-bound Heal inspector artifact 2026-10-10 20:32:34 +08:00
cxymds 3016209330 test(validation): provide the Python client for Heal cases 2026-10-10 20:30:10 +08:00
cxymds 64e0eeba91 Install validation client for replication cases 2026-10-10 18:52:42 +08:00
cxymds 35fa7488a5 Prepare Python client for pool validation cases 2026-10-10 18:41:48 +08:00
cxymds 1b77846f8e Install pinned ILM client for canonical validation lane 2026-10-10 18:26:49 +08:00
cxymds b0c861841e test(validation): add pinned auto-testing manual lane 2026-10-10 17:34:37 +08:00
616 changed files with 66611 additions and 41706 deletions
@@ -25,7 +25,7 @@ Read only the current phase's procedure; retain completed evidence across turns.
| 4–5 | [Run the downloaded binary, Console CRUD, and latest-rc matrix](references/preview-acceptance.md). Every check must pass. |
| Confirmation | Complete the gate below and end the turn. |
| 6 | After fresh confirmation, [publish and verify the final tag](references/final-release.md) at `PREVIEW_HASH`. |
| 7 | [Reconcile Security Advisories, milestones, installation references, and the website](references/post-release-updates.md) only after complete publication. |
| 7 | [Reconcile Security Advisories, milestones, installation references, and the website; reconcile main and remove the completed release branch](references/post-release-updates.md) only after complete publication. |
## Human Confirmation
@@ -37,6 +37,6 @@ Only a new affirmative user reply to that exact report authorizes Phase 6. The o
- Before the final tag exists, land fixes on main, backport to the candidate, verify target versions, and restart branch CI/acceptance. Use a new preview iteration once a preview was tagged; main may advance independently.
- Once the final tag exists, retry publication jobs for that exact tag. A source fix requires a newly confirmed target, not retagging or a newer preview. Phase 7 retries recheck artifact availability and superseding releases.
- If abandoned after the bump merged, report the unpublished version on main and record whether to revert or let the next release overwrite it. Deleting candidate branches or preview tags requires an explicit decision.
- If abandoned after the bump merged, report the unpublished version on main and record whether to revert or let the next release overwrite it. Deleting unpublished candidate branches or preview tags requires an explicit decision. Completed release branches follow Phase 7 cleanup; keep release tags.
Report completed phase results and incomplete work with evidence: Console previous/latest tags, release need, hash and run/Release URLs; target, branch CI/SHA, previews and hash, confirmation state; preview/final assets, latest-channel state, Console/rc results and cleanup; advisory reconciliation results and pending disclosure decisions; both milestone states, installation PR checks/merge state, and website text/link/PR/deployment state. Keep artifact publication separate from follow-up delivery; do not call an open PR a live update. Report deviations and their authorization.
@@ -48,3 +48,10 @@ Use the existing announcement configuration from [website PR #112](https://githu
3. Use the site's current package manager and required checks. For this config-only change, type-check and lint the changed file, build the site, and inspect the generated homepage for the version, visible link text, and destination. Confirm `/download` actually offers the announced version before using that link.
4. Commit, push, and create or update the website PR when included in the authorized release scope. Include the published RustFS Release URL and checks run. Keep merge and production-deployment authority separate; reuse permission already given, but this reference grants none by itself.
5. After an authorized merge/deployment, verify the live `https://rustfs.com/` banner and its destination. If only the PR is ready, report that state and URL; do not report the banner as deployed. A website failure does not invalidate or move the published release tag: retry this phase after resolving the failure.
## Reconcile main and remove the completed release branch
1. Compare the exact published tag with current main. Audit every release-side commit since their merge base, including inherited backports. An ahead count is a history difference, not proof of missing changes. Use ancestry and patch equivalence, then inspect current code and regression evidence for squashed, adapted, superseded, or reverted changes. Record each commit as present, superseded, release-only, or missing; a positive `git cherry` result alone does not prove a missing fix.
2. Bring missing shared changes to main through a reviewed PR and the required checks before closing the follow-up. Preserve main's later changes; decide version bumps and pinned Console configuration separately. If a history merge is needed, use a merge commit and verify the release commit is an ancestor of main; squash does not establish that ancestry. Do not merge main into the published release branch or move its tags. Report unresolved changes instead of claiming reconciliation is complete.
3. After publication and authorized follow-ups are complete, delete the remote `release/<target>` branch. Immediately before deletion, verify its HEAD still equals the published tag's commit and no open PR targets or originates from it. Preserve a branch with newer work or open PRs and report the blocker. Use a deletion with an expected-old-SHA check to avoid removing a concurrently advanced branch.
4. Verify the remote branch is absent and the final and preview tags still resolve to their recorded commits. Remove a local release branch only if it is task-owned, unchecked out, and has no unpreserved work. Record cleanup separately from artifact publication; an explicitly authorized deletion before reconciliation does not make unresolved main changes complete.
+2 -2
View File
@@ -1,2 +1,2 @@
sha256-linux=cf31ed27e4e6bf42da5bf81f248d9d2619ac8e8615186cdacb30bd63996d7607
sha256-darwin=cf31ed27e4e6bf42da5bf81f248d9d2619ac8e8615186cdacb30bd63996d7607
sha256-linux=9bd4b5ed4d135c6f61e0d144ddeb6b897fd5a6037b51f31088693cf1b23e0718
sha256-darwin=9bd4b5ed4d135c6f61e0d144ddeb6b897fd5a6037b51f31088693cf1b23e0718
+2 -2
View File
@@ -1,2 +1,2 @@
sha256-darwin=db39d25da89d9584f2f73bd27271022a93b77613a80e69ea85a6f5cc1c8b1f39
sha256-linux=61e8fe9426f4e6a1a578e684079f0bb81425eff4f851c333c4158f2ff79518f5
sha256-darwin=da00472db3d8181ed413b8e9919f43e86fe644ccee444d92035ce189a33ab037
sha256-linux=42e95c9480e5bfa0d717be6652c6500b43e3a337cb63907869104bb155b5bd91
+1 -1
View File
@@ -1 +1 @@
sha256=4eac269f9a59b2f9be4e7a87ea6aa38454f5ebe10ede35c9b8d4218137d2cd35
sha256=e4ee3898b840a8374cb02c64342cc15f8d7ecd1becaf4b4a2a4657290f9e7ff1
+1 -1
View File
@@ -1 +1 @@
sha256=aa603dbbae2e0dc0803489a88887a630c542a6949466c2b0e73b8a6b5e57fcaa
sha256=f065e7071aac9d48a7c1b301f328fa7cc14f11c4e7dc46da3e55057c4ea29bd5
-5
View File
@@ -241,11 +241,6 @@
"suite": "rustfs-ecstore",
"name": "store::list_objects::differential_tests::list_objects_shared_corpus_cancelled_quorum_walk_releases_senders_and_disk_lock"
},
{
"invariant": "list-objects-shared-corpus-enabled-index-contracts",
"suite": "rustfs-ecstore",
"name": "store::list_objects::differential_tests::list_objects_shared_corpus_enabled_index_contracts"
},
{
"invariant": "borrowed-context-boundary",
"suite": "rustfs-ecstore",
-5
View File
@@ -70,11 +70,6 @@ s3s-footprint-check: ## Check the s3s dependency footprint ratchet stays frozen
@echo "📦 Checking s3s footprint ratchet..."
./scripts/check_s3s_footprint.sh
.PHONY: test-count-ratchet-check
test-count-ratchet-check: ## Check no workspace crate lost tests or gained #[ignore] against the committed baseline
@echo "🧪 Checking per-crate test-count ratchet..."
./scripts/check_test_count_ratchet.sh
.PHONY: fips-wording-check
fips-wording-check: ## Check docs and crates/kms do not over-claim crypto capabilities
@echo "📣 Checking cryptographic capability wording guard..."
+2 -2
View File
@@ -20,11 +20,11 @@ planning-docs-check: ## Check that no planning-type documents are committed
./scripts/check_no_planning_docs.sh
.PHONY: pre-commit
pre-commit: fmt-check unsafe-code-check architecture-migration-check logging-guardrails-check error-other-ratchet-check tokio-io-uring-check extension-schema-check body-cache-whitelist-check s3s-footprint-check test-count-ratchet-check fips-wording-check embedded-secrets-check test-wiring-check doc-paths-check planning-docs-check quick-check ## Run fast pre-commit checks without clippy/full tests
pre-commit: fmt-check unsafe-code-check architecture-migration-check logging-guardrails-check error-other-ratchet-check tokio-io-uring-check extension-schema-check body-cache-whitelist-check s3s-footprint-check fips-wording-check embedded-secrets-check test-wiring-check doc-paths-check planning-docs-check quick-check ## Run fast pre-commit checks without clippy/full tests
@echo "✅ All pre-commit checks passed!"
.PHONY: pre-pr
pre-pr: fmt-check unsafe-code-check architecture-migration-check logging-guardrails-check error-other-ratchet-check tokio-io-uring-check extension-schema-check body-cache-whitelist-check s3s-footprint-check test-count-ratchet-check fips-wording-check embedded-secrets-check test-wiring-check doc-paths-check planning-docs-check log-analyzer-rules-check offline-enrollment-e2e-check clippy-check test ## Run full pre-PR checks with clippy and tests
pre-pr: fmt-check unsafe-code-check architecture-migration-check logging-guardrails-check error-other-ratchet-check tokio-io-uring-check extension-schema-check body-cache-whitelist-check s3s-footprint-check fips-wording-check embedded-secrets-check test-wiring-check doc-paths-check planning-docs-check log-analyzer-rules-check offline-enrollment-e2e-check clippy-check test ## Run full pre-PR checks with clippy and tests
@echo "✅ All pre-PR checks passed!"
.PHONY: dev-check
+2 -2
View File
@@ -50,9 +50,7 @@ script-tests: ## Run shell script tests
./scripts/test_fuzz_runner.sh
./scripts/test_python_bin.sh
./scripts/check_embedded_secrets.sh --self-test
./scripts/check_s3s_footprint.sh --self-test
$(RUSTFS_PYTHON_BIN) ./scripts/check_test_wiring.py --self-test
$(RUSTFS_PYTHON_BIN) ./scripts/codemods/s3_error_to_s3_types.py --self-test
$(RUSTFS_PYTHON_BIN) ./scripts/test_e2e_binary.py
$(RUSTFS_PYTHON_BIN) ./scripts/test_release_build_workflow.py
$(RUSTFS_PYTHON_BIN) ./scripts/test_release_glibc.py
@@ -65,6 +63,8 @@ script-tests: ## Run shell script tests
$(RUSTFS_PYTHON_BIN) ./scripts/test_nightly_candidate.py
$(RUSTFS_PYTHON_BIN) ./scripts/test_functional_chain.py
$(RUSTFS_PYTHON_BIN) ./scripts/test_functional_chain_health.py
$(RUSTFS_PYTHON_BIN) ./scripts/test_inspect_validation_runner.py
$(RUSTFS_PYTHON_BIN) ./scripts/test_retain_validation_evidence.py
$(RUSTFS_PYTHON_BIN) ./scripts/test_ci_timing_report.py
$(RUSTFS_PYTHON_BIN) ./scripts/s3-tests/test_report_compat.py
$(RUSTFS_PYTHON_BIN) ./scripts/s3-tests/test_runner_tools.py
-21
View File
@@ -334,12 +334,6 @@ threads-required = "num-test-threads"
filter = 'package(rustfs-ecstore) & test(=store::init::tests::dispatch_manifest_rollback_bounded_concurrency_reaches_tail_behind_slow_member)'
threads-required = "num-test-threads"
# These short-deadline real-disk progress probes need the test run's capacity
# for scanner and object-lock handoffs; keep their assertions and deadlines.
[[profile.default.overrides]]
filter = '(package(rustfs-scanner) & (test(=scanner::tests::cycle_persist_failure::later_cycle_post_commit_failure_reloads_durable_state) | test(=scanner::tests::running_main_loop_catches_up_pause_cleared_after_startup_observe))) | (package(rustfs-ecstore) & test(=store::init::tests::restored_transitioned_delete_causally_enqueues_free_version))'
threads-required = "num-test-threads"
# ---------------------------------------------------------------------------
# ci profile — the strict CI gate (ci.yml `cargo nextest run --profile ci`)
# ---------------------------------------------------------------------------
@@ -397,12 +391,6 @@ threads-required = "num-test-threads"
filter = 'package(rustfs-scanner) & (test(/^scanner::backlog::tests::native_/) | test(=scanner_folder::tests::mrf_ownership::mrf_ownership_manager_completion_preserves_scanner_pending) | test(/^scanner_io::tests::service_cohort::/))'
threads-required = "num-test-threads"
# Keep short-deadline real-disk progress probes from competing with unrelated
# tests for scanner and object-lock capacity. Assertions and deadlines stay on.
[[profile.ci.overrides]]
filter = '(package(rustfs-scanner) & (test(=scanner::tests::cycle_persist_failure::later_cycle_post_commit_failure_reloads_durable_state) | test(=scanner::tests::running_main_loop_catches_up_pause_cleared_after_startup_observe))) | (package(rustfs-ecstore) & test(=store::init::tests::restored_transitioned_delete_causally_enqueues_free_version))'
threads-required = "num-test-threads"
[[profile.ci.scripts]]
filter = 'package(rustfs-ecstore) & test(/^(bucket::lifecycle::bucket_lifecycle_ops::tests::manual_transition_worker_result_recovery_marks_unknown_for_corrupt_marker|services::rebalance::entry::tests::real_rebalance_run_fence_loss_blocks_multipart_publication|store::init::tests::(batch_transitioned_delete_uses_free_version_per_item|decommission_entry_(allows_free_version_consumed_before_source_lock|rejects_subquorum_free_version_conflict_and_retains_source|skips_cleanup_only_marker_when_free_version_is_present)|dispatched_tier_delete_recovery_(checks_later_pool_then_commits_after_source_removal|finds_directory_source_on_encoded_set|retains_journal_on_source_metadata_error)|force_tier_remove_blocks_on_physical_free_version_hidden_by_other_pool|legacy_unknown_transition_delete_falls_back_for_single_batch_and_blocks_prefix|multi_pool_(recursive_prefix_rejects_legacy_or_hidden_merge_loser_before_delete|same_remote_tuple_(batch|single)_delete_waits_for_all_sources|same_tuple_recursive_prefix_uses_one_journal_owner|transitioned_delete_persists_one_free_version_per_remote_tuple)|recursive_prefix_partial_(pool|set)_failure_keeps_prepared_cleanup_owners|restored_transitioned_delete_uses_free_version_as_cleanup_owner|stable_transitioned_recursive_prefix_delete_uses_journal_owners|suspended_null_transition_delete_uses_free_version_as_sole_owner|tier_mutation_peer_handler_applies_prepare_commit_and_abort_idempotently|transition_response_loss_persists_unknown_outcome_for_provider_recovery|transition_transaction_recovery_(drops_record_after_confirmed_local_commit|keeps_cleanup_pending_local_commit)|transitioned_delete_(free_version_replays_after_store_restart|local_quorum_failure_rolls_back_without_cleanup_owner|uses_free_version_as_cleanup_owner)|versioned_delete_marker_keeps_transitioned_source_and_remote_object|versioned_explicit_transition_delete_preserves_other_version_then_allows_bucket_delete))$/)'
setup = 'ecstore-large-stack'
@@ -662,14 +650,6 @@ test-group = 'ecstore-serial-flaky'
# the PR-lane e2e coverage for scanner usage snapshots consumed by quota and
# admin surfaces. It uses the same single-node, random-port, isolated-temp-dir
# fixture as the existing smoke modules.
#
# Dual-stack differential (rustfs/backlog#2740): `dual_stack_differential::`
# starts two single-node servers from the same binary, one per
# RUSTFS_S3_STACK value, on random ports with isolated temp dirs, and runs one
# SDK script covering every operation of `impl S3 for FS` against both. Its
# comparator unit tests need no server. It meets the admission criteria
# unchanged; the module clause admits a new differential test without another
# filter edit.
[profile.e2e-smoke]
default-filter = """
package(e2e_test) & (
@@ -678,7 +658,6 @@ default-filter = """
| test(/^reliant::lifecycle::/)
| test(/^conditional_delete_objects_test::/)
| test(/^reliant::tiering::/)
| test(/^dual_stack_differential::/)
| test(/^upgrade_compatibility_test::upgrade_write_readiness_tests::/)
| test(/^on_demand_migration::(get_basic_test::(get_miss_pulls_inline_and_serves_locally_afterwards|head_miss_answers_from_the_source_without_persisting)|interaction_test::test_odm_admin_config_is_redacted_and_status_counts_match_the_source)$/)
)
-37
View File
@@ -1,37 +0,0 @@
# Files that may still reference s3s (`s3s::` or `s3s_sigv4`) when Phase 1 of
# the gateway migration ends (rustfs/backlog#2734 task T0.6, rustfs/backlog#2741).
# Consumed only by `scripts/check_s3s_footprint.sh --mode allowlist`.
#
# One entry per line: a repository-relative file path or a git `:(glob)`
# pathspec. `**` matches across directories, `*` stays within one path
# component, and a directory entry matches everything under it. Blank lines
# and lines starting with `#` are ignored; an entry may not contain whitespace,
# so a note belongs on its own comment line.
#
# Every Rust file listed by `git grep -l '\bs3s\(::\|_sigv4\)' -- '*.rs'` that
# no entry matches fails the allowlist mode. Task T1.9 switches CI to that
# mode; until then `--dry-run` prints the files still outside this list, which
# is the Phase 1 tracking list.
#
# Never add crates/** other than crates/e2e_test: the engine crates leave s3s
# entirely, and the script rejects such an entry. Only the s3s-facing edge of
# the rustfs binary and the e2e oracle keep it.
# rustfs binary: the s3s-facing edge that survives Phase 1
rustfs/src/storage/ecfs.rs
rustfs/src/storage/s3_compat/**
rustfs/src/storage/access.rs
rustfs/src/auth.rs
rustfs/src/error.rs
rustfs/src/storage_api.rs
rustfs/src/app/storage_api.rs
rustfs/src/admin/router.rs
rustfs/src/app/metadata_route.rs
rustfs/src/server/http.rs
rustfs/src/server/layer.rs
rustfs/src/server/hybrid.rs
rustfs/src/protocols/client.rs
# e2e oracle: the test target that verifies S3 behaviour against s3s
crates/e2e_test/src/fake_s3_target/**
crates/e2e_test/src/**
-17
View File
@@ -1,17 +0,0 @@
# Exact baseline for the direct s3s footprint, consumed only by
# scripts/check_s3s_footprint.sh (rustfs/backlog#1677; rustfs/backlog#2734 T0.4).
#
# Each value is the exact measurement of the tree, not a ceiling. CI fails when a
# measured counter differs from its line here in either direction:
# - a larger measurement means new code widened the s3s surface: route it
# through the gateway abstractions instead; never raise a value here;
# - a smaller measurement means the PR shrank the surface: lower the matching
# value to the measured number in the same PR.
#
# files Rust files referencing s3s paths, repo-wide minus crates/e2e_test
# s3_error_lines lines holding s3_error!, same scope, minus those the script
# header's rule clears (rustfs_s3_types's macro); fails closed
# ecstore_files Rust files referencing s3s paths under crates/ecstore/src
files=107
s3_error_lines=289
ecstore_files=0
-63
View File
@@ -1,63 +0,0 @@
# Per-crate test-count baseline consumed by scripts/check_test_count_ratchet.sh
# (rustfs/backlog#2734 section 1 rule 3; T0.3 in rustfs/backlog#2738).
#
# One row per workspace member: <package>, tests, ignored, tab-separated.
# tests counts #[test], #[tokio::test], #[tokio::test(...)] and #[test_case(...)]
# attribute lines under the package directory; ignored counts #[ignore] and
# #[ignore = "..."]. Both columns are exact: CI fails when a crate's tests fall
# below or rise above its row, or when its ignored count changes. Raise a row in
# the PR that adds tests; lowering one is a reviewed statement that tests were
# intentionally removed. Regenerate with:
# scripts/check_test_count_ratchet.sh --update
e2e_test 954 20
rustfs 5930 64
rustfs-audit 70 0
rustfs-checksums 26 0
rustfs-common 36 0
rustfs-concurrency 53 0
rustfs-config 43 0
rustfs-connect-symbol-catalog 6 0
rustfs-credentials 24 0
rustfs-crypto 67 0
rustfs-data-usage 55 0
rustfs-ecstore 7065 1
rustfs-extension-schema 23 0
rustfs-filemeta 355 0
rustfs-heal 976 3
rustfs-heal-contracts 7 0
rustfs-iam 394 0
rustfs-io-core 49 0
rustfs-io-metrics 153 0
rustfs-keystone 29 0
rustfs-kms 715 25
rustfs-license 2 0
rustfs-lifecycle 155 0
rustfs-lock 138 0
rustfs-log-analyzer 90 0
rustfs-madmin 210 0
rustfs-notify 137 0
rustfs-object-capacity 92 0
rustfs-object-data-cache 140 0
rustfs-obs 379 0
rustfs-policy 725 0
rustfs-protocols 738 1
rustfs-protos 66 0
rustfs-replication 209 0
rustfs-rio 205 0
rustfs-rio-v2 34 6
rustfs-s3-client 69 0
rustfs-s3-ops 8 0
rustfs-s3-types 99 0
rustfs-s3select-api 207 0
rustfs-s3select-query 189 2
rustfs-scanner 1205 20
rustfs-scanner-metrics 87 0
rustfs-security-governance 20 0
rustfs-signer 31 0
rustfs-storage-api 34 0
rustfs-targets 603 26
rustfs-test-utils 1 0
rustfs-tls-runtime 43 0
rustfs-trusted-proxies 68 0
rustfs-utils 247 0
rustfs-zip 30 0
-4
View File
@@ -86,10 +86,6 @@ runs:
shell: bash
run: ./scripts/check_s3s_footprint.sh
- name: Check per-crate test-count ratchet
shell: bash
run: ./scripts/check_test_count_ratchet.sh
- name: Check cryptographic capability wording
shell: bash
run: ./scripts/check_fips_wording.sh
+5 -96
View File
@@ -16,9 +16,7 @@ name: Continuous Integration
on:
push:
# gateway/integration receives the daily merge of main as a direct push
# (rustfs/backlog#2734), so its pushes must prove the result like main's.
branches: [ main, gateway/integration ]
branches: [ main ]
paths-ignore:
- "**.md"
- "docs/**"
@@ -739,8 +737,6 @@ jobs:
needs: [ build-rustfs-debug-binary ]
runs-on: sm-standard-2
timeout-minutes: 30
env:
RUSTFS_S3_STACK: legacy
steps:
- name: Checkout repository
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
@@ -859,105 +855,18 @@ jobs:
path: ${{ runner.temp }}/rustfs-e2e-*/rustfs.log
retention-days: 3
# The same e2e-smoke suite against the same debug binary with the gateway
# S3 stack selected (rustfs/backlog#2734). Report-only: it is outside the
# Test and Lint aggregate and becomes required at T2.10. The archive and run
# steps must stay identical to e2e-tests; scripts/ci_gate.py enforces that.
e2e-tests-gateway:
name: e2e-smoke (gateway, report-only until T2.10)
needs: [ build-rustfs-debug-binary ]
runs-on: sm-standard-2
timeout-minutes: 30
continue-on-error: true
env:
RUSTFS_S3_STACK: gateway
steps:
- name: Checkout repository
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
- name: Setup Rust environment
uses: ./.github/actions/setup
with:
rust-version: stable
cache-shared-key: ci-dev
cache-save-if: 'false'
install-build-packaging-tools: 'false'
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
- name: Install awscurl
run: |
python3 -m pip install --user --upgrade pip "awscurl==0.44"
echo "AWSCURL_PATH=$HOME/.local/bin/awscurl" >> "$GITHUB_ENV"
- name: Verify awscurl
run: test -x "$AWSCURL_PATH"
- name: Download debug binary
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: rustfs-debug-binary
path: target/debug
- name: Make binary executable
run: chmod +x ./target/debug/rustfs
- name: Archive e2e smoke test binaries
env:
NEXTEST_ARCHIVE: ${{ runner.temp }}/rustfs-e2e-smoke.tar.zst
NEXTEST_LISTING: ${{ runner.temp }}/rustfs-e2e-smoke-list.json
run: |
cargo nextest archive --profile e2e-smoke -p e2e_test --archive-file "${NEXTEST_ARCHIVE}"
cargo nextest list --profile e2e-smoke --archive-file "${NEXTEST_ARCHIVE}" --message-format json > "${NEXTEST_LISTING}"
python3 ./scripts/check_test_wiring.py --check-profile e2e-smoke "${NEXTEST_LISTING}"
./scripts/check_security_smoke_count.sh check "${NEXTEST_LISTING}"
- name: Run e2e smoke suite
env:
NEXTEST_ARCHIVE: ${{ runner.temp }}/rustfs-e2e-smoke.tar.zst
RUSTFS_E2E_LOG_DIR: ${{ runner.temp }}/rustfs-e2e-smoke-logs
SAMPLER_INTERVAL_SECS: "15"
run: |
./scripts/ci/resource_sampler.sh start e2e-smoke
trap './scripts/ci/resource_sampler.sh stop' EXIT
python3 scripts/e2e_binary.py run --features e2e-test-hooks -- cargo nextest run --profile e2e-smoke --archive-file "${NEXTEST_ARCHIVE}" \
--status-level all --final-status-level all --failure-output final
- name: Upload e2e smoke diagnostics
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: e2e-smoke-gateway-diagnostics-${{ github.run_number }}
path: |
${{ runner.temp }}/rustfs-e2e-smoke-logs/
${{ runner.temp }}/rustfs-e2e-smoke-list.json
if-no-files-found: warn
- name: Upload e2e smoke JUnit report
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: e2e-smoke-gateway-junit-${{ github.run_number }}
path: target/nextest/e2e-smoke/junit.xml
if-no-files-found: warn
e2e-full:
name: End-to-End Tests (full merge gate)
# Merge gate only (backlog#1149 ci-5): the never-automated user-visible
# suites — KMS, object_lock, multipart_auth, quota, checksum, encryption,
# security-boundary, ... — via the e2e-full nextest profile. Too heavy for
# every PR, so it is gated to main and gateway/integration pushes, the merge
# queue, and manual dispatch. protocols / the 7 cluster suites / replication
# / #[ignore] are owned by other lanes (see .config/nextest.toml profile.e2e-full).
# every PR, so it is gated to main pushes, the merge queue, and manual
# dispatch. protocols / the 7 cluster suites / replication / #[ignore] are
# owned by other lanes (see .config/nextest.toml profile.e2e-full).
if: >-
github.event_name == 'workflow_dispatch' ||
github.event_name == 'merge_group' ||
(github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/gateway/integration'))
(github.event_name == 'push' && github.ref == 'refs/heads/main')
needs: [ build-rustfs-debug-binary ]
runs-on: sm-standard-2
timeout-minutes: 55
+25
View File
@@ -19,9 +19,11 @@ on:
paths:
- ".github/workflows/e2e-upgrade.yml"
- "crates/e2e_test/src/common.rs"
- "crates/e2e_test/src/distributed/**"
- "crates/e2e_test/src/fake_s3_target/**"
- "crates/e2e_test/src/lib.rs"
- "crates/e2e_test/src/replication_extension_test.rs"
- "crates/e2e_test/src/rpc_fault_proxy.rs"
- "crates/e2e_test/src/upgrade_compatibility_test.rs"
- "crates/ecstore/**"
- "crates/filemeta/**"
@@ -128,6 +130,7 @@ jobs:
run: chmod +x target/debug/rustfs
- name: Verify and unpack pinned previous release
id: verify_source
env:
SOURCE_DIR: ${{ runner.temp }}/rustfs-upgrade-source
run: |
@@ -185,6 +188,28 @@ jobs:
done
exit "$status"
- name: Run mixed-version MPU recovery
if: ${{ !cancelled() && steps.verify_source.outcome == 'success' }}
timeout-minutes: 15
env:
NO_PROXY: 127.0.0.1,localhost,::1
HTTP_PROXY: ""
HTTPS_PROXY: ""
ALL_PROXY: ""
RUSTFS_E2E_LOG_FILTER: "warn,rustfs_ecstore::cluster::rpc::client=debug"
run: |
set -euo pipefail
export no_proxy="$NO_PROXY"
export RUSTFS_E2E_LOG_DIR="$RUNNER_TEMP/rustfs-upgrade-logs/mpu-mixed-recovery"
mkdir -p "$RUSTFS_E2E_LOG_DIR"
mpu_test='distributed::multipart_fence_acceptance_test::mixed_version_failed_complete_requires_current_coordinator_for_abort'
python3 scripts/e2e_binary.py run -- cargo test --locked -p e2e_test --lib \
"$mpu_test" -- --list --exact | tee "$RUSTFS_E2E_LOG_DIR/list.log"
test "$(grep -Fxc "${mpu_test}: test" "$RUSTFS_E2E_LOG_DIR/list.log")" -eq 1
python3 scripts/e2e_binary.py run -- cargo test --locked -p e2e_test --lib \
"$mpu_test" -- --exact --nocapture --test-threads=1 \
2>&1 | tee "$RUSTFS_E2E_LOG_DIR/test.log"
- name: Upload scanner/heal G09 evidence
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+563 -5
View File
@@ -20,13 +20,91 @@ on:
workflow_dispatch:
inputs:
build_run_id:
description: Successful nightly build run on main
description: Successful nightly or validation package run; empty for package-only build
type: string
required: true
required: false
default: ''
build_run_attempt:
description: Exact successful build attempt
type: string
required: true
required: false
default: ''
validation_package_sha:
description: Exact source SHA for an isolated package-only build
type: string
required: false
default: ''
validation_case:
description: Canonical case ID, environment.inspect for read-only resources, or empty for functional chain
type: string
required: false
default: ''
i05_diagnostic_branch:
description: I05 diagnostic-only branch; queued-successor requires the exact native evidence bundle
type: choice
options: [timeout, queued-successor]
required: false
default: timeout
auto_testing_sha:
description: Exact auto-testing commit for a validation case
type: string
required: false
default: ''
validation_retain_evidence_run_id:
description: Upload existing persistent evidence from one interrupted validation run without fleet access
type: string
required: false
default: ''
validation_retain_evidence_run_attempt:
description: Exact interrupted run attempt to retain
type: string
required: false
default: '1'
validation_runner_inspect_only:
description: Inspect GitHub runner registration and queued jobs on a hosted runner without fleet access
type: boolean
required: false
default: false
validation_tools_only:
description: Build candidate-bound validation tools without running a fleet case
type: boolean
required: false
default: false
validation_native_tools:
description: Include exact-source native regressions in a tools-only build
type: boolean
required: false
default: false
validation_shared_tools:
description: Include the controlled C10 shared-reference encoder in a tools-only build
type: boolean
required: false
default: false
validation_manager_tools:
description: Include exact checkpoint/MRF/incarnation regressions in a tools-only build
type: boolean
required: false
default: false
validation_cleanup_run_id:
description: Failed validation run whose supported retained environment needs evidence-based finalization
type: string
required: false
default: ''
validation_cleanup_retained_evidence:
description: Recover from verified persistent runner-local evidence when the failed run uploaded no artifact
type: boolean
required: false
default: false
validation_tools_run_id:
description: Successful tool build run; required by Heal physical metadata checks
type: string
required: false
default: ''
validation_tools_run_attempt:
description: Exact tool build attempt
type: string
required: false
default: '1'
workflow_run:
workflows: ["Nightly GNU Build"]
types: [completed]
@@ -36,12 +114,103 @@ permissions:
actions: read
concurrency:
group: rustfs-functional-chain-runs
group: ${{ github.event_name == 'workflow_dispatch' && inputs.validation_runner_inspect_only && 'rustfs-functional-validation-runner-inspection' || github.event_name == 'workflow_dispatch' && inputs.validation_package_sha != '' && format('rustfs-functional-validation-package-{0}', inputs.validation_package_sha) || github.event_name == 'workflow_dispatch' && inputs.validation_tools_only && 'rustfs-functional-validation-tools' || 'rustfs-functional-chain-runs' }}
cancel-in-progress: false
jobs:
validation-runner-inspection:
if: ${{ github.event_name == 'workflow_dispatch' && inputs.validation_runner_inspect_only }}
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
- name: Inspect shared runner registration and queue without fleet access
env:
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
RUNNER_INSPECTION_INPUTS: ${{ toJSON(inputs) }}
run: >-
python3 -B scripts/inspect_validation_runner.py
--output "${RUNNER_TEMP}/validation-runner-inspection/report.json"
- name: Upload read-only runner inspection including incomplete reports
if: ${{ always() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: validation-runner-inspection-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/validation-runner-inspection/report.json
if-no-files-found: error
validation-evidence-retention:
if: ${{ github.event_name == 'workflow_dispatch' && !inputs.validation_runner_inspect_only && inputs.validation_retain_evidence_run_id != '' }}
# The original interrupted evidence exists only on this long-lived runner.
runs-on: smoke-testing
timeout-minutes: 15
concurrency:
group: rustfs-shared-functional-tests-v2
cancel-in-progress: false
steps:
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
- name: Verify existing interrupted evidence without changing it
env:
GH_TOKEN: ${{ github.token }}
EVIDENCE_RETAIN_INPUTS: ${{ toJSON(inputs) }}
run: >-
python3 -B scripts/retain_validation_evidence.py
--report "${RUNNER_TEMP}/validation-evidence-retention/report.json"
- name: Upload admitted original evidence under collection provenance
if: ${{ env.VALIDATION_RETAIN_ROOT != '' }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: rustfs-validation-retained-${{ env.VALIDATION_RETAIN_SOURCE_RUN }}-${{ env.VALIDATION_RETAIN_SOURCE_ATTEMPT }}-by-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ env.VALIDATION_RETAIN_ROOT }}
if-no-files-found: error
include-hidden-files: true
- name: Upload retention receipt including incomplete reports
if: ${{ always() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: validation-evidence-retention-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/validation-evidence-retention/report.json
if-no-files-found: error
validation-package-request:
if: ${{ github.event_name == 'workflow_dispatch' && !inputs.validation_runner_inspect_only && inputs.validation_retain_evidence_run_id == '' && inputs.validation_package_sha != '' }}
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Validate exclusive package-only request
env:
PACKAGE_INPUTS: ${{ toJSON(inputs) }}
run: |
python3 - <<'PY'
import json
import os
import re
value = json.loads(os.environ["PACKAGE_INPUTS"])
if re.fullmatch(r"[0-9a-f]{40}", value["validation_package_sha"]) is None:
raise ValueError("package build requires an exact source SHA")
exclusive = ("validation_retain_evidence_run_id", "validation_runner_inspect_only", "build_run_id", "build_run_attempt", "validation_case", "auto_testing_sha",
"validation_tools_only", "validation_native_tools", "validation_shared_tools",
"validation_manager_tools", "validation_cleanup_run_id", "validation_cleanup_retained_evidence", "validation_tools_run_id")
if any(value.get(name) for name in exclusive):
raise ValueError("package-only build cannot select existing candidate, tools, fleet or recovery execution")
PY
validation-package:
needs: validation-package-request
if: ${{ github.event_name == 'workflow_dispatch' && !inputs.validation_runner_inspect_only && inputs.validation_retain_evidence_run_id == '' && inputs.validation_package_sha != '' }}
uses: ./.github/workflows/rustfs-validation-package.yml
with:
source_sha: ${{ inputs.validation_package_sha }}
secrets:
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
R2_BUCKET: ${{ secrets.R2_BUCKET }}
prepare:
if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.event == 'schedule' }}
if: ${{ (github.event_name == 'workflow_dispatch' && !inputs.validation_runner_inspect_only && inputs.validation_retain_evidence_run_id == '' && inputs.validation_package_sha == '' && inputs.validation_case == '' && !inputs.validation_tools_only && !inputs.validation_native_tools && !inputs.validation_shared_tools && !inputs.validation_manager_tools && !inputs.validation_cleanup_retained_evidence && inputs.validation_cleanup_run_id == '') || github.event.workflow_run.event == 'schedule' }}
runs-on: smoke-testing
timeout-minutes: 10
outputs:
@@ -89,6 +258,395 @@ jobs:
echo "performance_ready=offline" >> "$GITHUB_OUTPUT"
fi
validation:
if: ${{ github.event_name == 'workflow_dispatch' && !inputs.validation_runner_inspect_only && inputs.validation_retain_evidence_run_id == '' && inputs.validation_package_sha == '' && (inputs.validation_case != '' || inputs.validation_tools_only || inputs.validation_native_tools || inputs.validation_shared_tools || inputs.validation_manager_tools || inputs.validation_cleanup_run_id != '' || inputs.validation_cleanup_retained_evidence) }}
runs-on: ${{ inputs.validation_tools_only && 'ubuntu-latest' || 'smoke-testing' }}
timeout-minutes: 90
outputs:
source_sha: ${{ steps.candidate.outputs.source_sha }}
concurrency:
group: ${{ inputs.validation_tools_only && 'rustfs-validation-tools-resolver' || 'rustfs-shared-functional-tests-v2' }}
cancel-in-progress: false
env:
RUSTFS_ACCESS_KEY: ${{ !inputs.validation_tools_only && secrets.RUSTFS_ACCESS_KEY || '' }}
RUSTFS_SECRET_KEY: ${{ !inputs.validation_tools_only && secrets.RUSTFS_SECRET_KEY || '' }}
RUSTFS_API_ENDPOINT: ${{ !inputs.validation_tools_only && (secrets.RUSTFS_API_ENDPOINT || vars.RUSTFS_API_ENDPOINT || vars.RUSTFS_RC_ENDPOINT) || '' }}
RUSTFS_NODES: ${{ !inputs.validation_tools_only && (secrets.RUSTFS_NODES || vars.RUSTFS_NODES) || '' }}
RUSTFS_SSH_USER: ${{ !inputs.validation_tools_only && (secrets.RUSTFS_SSH_USER || vars.RUSTFS_SSH_USER) || '' }}
steps:
- name: Checkout candidate resolver
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
- name: Initialize validation evidence
env:
VALIDATION_TOOLS_ONLY: ${{ inputs.validation_tools_only }}
run: |
set -euo pipefail
umask 077
python3 -B scripts/initialize_validation_evidence.py
- name: Resolve immutable candidate
id: candidate
env:
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
BUILD_RUN_ID: ${{ inputs.build_run_id }}
BUILD_RUN_ATTEMPT: ${{ inputs.build_run_attempt }}
AUTO_TESTING_SHA: ${{ inputs.auto_testing_sha }}
CLEANUP_RUN_ID: ${{ inputs.validation_cleanup_run_id }}
CLEANUP_RETAINED_EVIDENCE: ${{ inputs.validation_cleanup_retained_evidence }}
SELECTED_CASE: ${{ inputs.validation_case }}
TOOLS_ONLY: ${{ inputs.validation_tools_only }}
NATIVE_TOOLS: ${{ inputs.validation_native_tools }}
SHARED_TOOLS: ${{ inputs.validation_shared_tools }}
MANAGER_TOOLS: ${{ inputs.validation_manager_tools }}
I05_DIAGNOSTIC_BRANCH: ${{ inputs.i05_diagnostic_branch }}
run: |
set -euo pipefail
python3 - <<'PY'
import json
import os
from pathlib import Path
import re
from scripts.resolve_functional_candidate import resolve
revision = os.environ["AUTO_TESTING_SHA"]
cleanup = os.environ["CLEANUP_RUN_ID"]
if os.environ["CLEANUP_RETAINED_EVIDENCE"] == "true" and not cleanup:
raise ValueError("persistent recovery input requires one exact cleanup run")
if os.environ["TOOLS_ONLY"] == "true" and (cleanup or os.environ["SELECTED_CASE"]):
raise ValueError("tools-only execution cannot select a fleet case or cleanup")
if os.environ["NATIVE_TOOLS"] == "true" and (os.environ["TOOLS_ONLY"] != "true" or cleanup or os.environ["SELECTED_CASE"]):
raise ValueError("native regressions require tools-only execution, without a fleet case or cleanup")
if os.environ["SHARED_TOOLS"] == "true" and (os.environ["TOOLS_ONLY"] != "true" or cleanup or os.environ["SELECTED_CASE"]):
raise ValueError("shared fixture builds require tools-only execution, without a fleet case or cleanup")
if os.environ["MANAGER_TOOLS"] == "true" and (os.environ["TOOLS_ONLY"] != "true" or cleanup or os.environ["SELECTED_CASE"]):
raise ValueError("manager regressions require tools-only execution, without a fleet case or cleanup")
if cleanup and (not cleanup.isdigit() or int(cleanup) <= 0 or os.environ["SELECTED_CASE"] or os.environ["TOOLS_ONLY"] == "true"):
raise ValueError("cleanup requires one exact failed run and is exclusive with case/tool execution")
diagnostic_branch = os.environ["I05_DIAGNOSTIC_BRANCH"] or "timeout"
if diagnostic_branch not in {"timeout", "queued-successor"}:
raise ValueError("I05 diagnostic branch is not registered")
if diagnostic_branch == "queued-successor" and os.environ["SELECTED_CASE"] != "heal.lock-timeout-and-caller-abort":
raise ValueError("queued-successor is available only for the I05 diagnostic case")
if re.fullmatch(r"[0-9a-f]{40}", revision) is None:
raise ValueError("validation requires an exact auto-testing commit SHA")
candidate = resolve(int(os.environ["BUILD_RUN_ID"]), int(os.environ["BUILD_RUN_ATTEMPT"]), allow_validation=True)
manifest = candidate["manifest"]
root = Path(os.environ["VALIDATION_ROOT"])
(root / "candidate-source.json").write_text(json.dumps(candidate, sort_keys=True) + "\n")
(root / "candidate.json").write_text(json.dumps({
"package_url": manifest["package_url"],
"package_sha256": manifest["package_sha256"],
"source_sha": manifest["source_sha"],
}, sort_keys=True) + "\n")
with open(os.environ["GITHUB_OUTPUT"], "a") as output:
output.write("source_sha=" + manifest["source_sha"] + "\n")
PY
- name: Checkout exact auto-testing revision
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
repository: rustfs/auto-testing
ref: ${{ inputs.auto_testing_sha }}
token: ${{ secrets.PF_TESTING_GH_TOKEN }}
path: auto-testing
persist-credentials: false
- name: Prepare validation Python runtime
if: ${{ !inputs.validation_tools_only && (startsWith(inputs.validation_case, 'ilm.') || startsWith(inputs.validation_case, 'pool.') || startsWith(inputs.validation_case, 'bucket-replication.') || startsWith(inputs.validation_case, 'site-replication.') || startsWith(inputs.validation_case, 'heal.')) }}
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
- name: Install validation Python client
if: ${{ !inputs.validation_tools_only && (startsWith(inputs.validation_case, 'ilm.') || startsWith(inputs.validation_case, 'pool.') || startsWith(inputs.validation_case, 'bucket-replication.') || startsWith(inputs.validation_case, 'site-replication.') || startsWith(inputs.validation_case, 'heal.')) }}
run: |
set -euo pipefail
validation_python="${RUNNER_TEMP}/rustfs-validation-python-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
test ! -e "${validation_python}"
python3 -m venv "${validation_python}"
"${validation_python}/bin/python" -m pip install \
-r auto-testing/scripts/validation_ilm/requirements.txt
printf '%s\n' "${validation_python}/bin" >> "${GITHUB_PATH}"
- name: Validate Heal tool build identity
if: ${{ !inputs.validation_tools_only && startsWith(inputs.validation_case, 'heal.') }}
env:
GH_TOKEN: ${{ github.token }}
TOOL_RUN_ID: ${{ inputs.validation_tools_run_id }}
TOOL_RUN_ATTEMPT: ${{ inputs.validation_tools_run_attempt }}
run: |
set -euo pipefail
[[ "${TOOL_RUN_ID}" =~ ^[1-9][0-9]*$ ]]
[[ "${TOOL_RUN_ATTEMPT}" =~ ^[1-9][0-9]*$ ]]
gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${TOOL_RUN_ID}/attempts/${TOOL_RUN_ATTEMPT}" \
> "${VALIDATION_ROOT}/tool-run.json"
python3 - <<'PY'
import json
import os
from pathlib import Path
value = json.loads((Path(os.environ["VALIDATION_ROOT"]) / "tool-run.json").read_text())
if (value.get("conclusion") != "success" or value.get("status") != "completed"
or value.get("run_attempt") != int(os.environ["TOOL_RUN_ATTEMPT"])
or value.get("path") not in {".github/workflows/rustfs-functional-chain.yml", ".github/workflows/rustfs-validation-tools.yml"}):
raise ValueError("Heal tool run is not an exact successful validation build attempt")
PY
- name: Download pinned Heal metadata inspector
if: ${{ !inputs.validation_tools_only && startsWith(inputs.validation_case, 'heal.') }}
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
github-token: ${{ github.token }}
run-id: ${{ inputs.validation_tools_run_id }}
name: rustfs-validation-tools-${{ inputs.validation_tools_run_id }}-${{ inputs.validation_tools_run_attempt }}
path: ${{ runner.temp }}/rustfs-validation-tools-${{ github.run_id }}-${{ github.run_attempt }}
- name: Verify Heal metadata inspector
if: ${{ !inputs.validation_tools_only && startsWith(inputs.validation_case, 'heal.') }}
run: |
set -euo pipefail
TOOL_DIRECTORY="${RUNNER_TEMP}/rustfs-validation-tools-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
python3 -B auto-testing/scripts/validation_heal/inspector.py \
--directory "${TOOL_DIRECTORY}" --candidate "${VALIDATION_ROOT}/candidate.json" \
> "${VALIDATION_ROOT}/tool-manifest.json"
printf 'VALIDATION_HEAL_TOOL_DIRECTORY=%s\n' "${TOOL_DIRECTORY}" >> "${GITHUB_ENV}"
- name: Select exact-source race native evidence
id: race_native
if: ${{ inputs.validation_case == 'heal.race-unversioned-overwrite' || inputs.validation_case == 'heal.race-versioned-put' || inputs.validation_case == 'heal.race-version-delete' || inputs.validation_case == 'heal.race-markers-and-batch-delete' || inputs.validation_case == 'heal.race-multipart-complete-abort' || inputs.validation_case == 'heal.admin-nolock-safety' || inputs.validation_case == 'heal.race-copy-and-metadata-update' }}
env:
CANDIDATE_SHA: ${{ steps.candidate.outputs.source_sha }}
VALIDATION_CASE_ID: ${{ inputs.validation_case }}
run: |
set -euo pipefail
required=false
if [ -f auto-testing/scripts/validation_heal/race_native.py ]; then
status=0
python3 -B auto-testing/scripts/validation_heal/race_native.py supports \
--commit "${CANDIDATE_SHA}" --case-id "${VALIDATION_CASE_ID}" || status=$?
if [ "${status}" -eq 0 ]; then
required=true
elif [ "${status}" -ne 2 ]; then
exit "${status}"
fi
fi
reviewed=false
case "${CANDIDATE_SHA}:${VALIDATION_CASE_ID}" in
6afdec9fe1b0b88007f2e5b445c19bf73d51d3ca:heal.race-unversioned-overwrite | \
bd0671ba781d8ebf509f4f03533228dfb44496f1:heal.race-unversioned-overwrite | \
bd0671ba781d8ebf509f4f03533228dfb44496f1:heal.race-versioned-put | \
bd0671ba781d8ebf509f4f03533228dfb44496f1:heal.race-version-delete | \
8380466f3a27c3c3ef4115084178a0ab99a78f32:heal.race-unversioned-overwrite | \
8380466f3a27c3c3ef4115084178a0ab99a78f32:heal.race-versioned-put | \
8380466f3a27c3c3ef4115084178a0ab99a78f32:heal.race-version-delete | \
8380466f3a27c3c3ef4115084178a0ab99a78f32:heal.race-markers-and-batch-delete | \
757b751482bb970bc321e92059690a7698554358:heal.admin-nolock-safety | \
757b751482bb970bc321e92059690a7698554358:heal.race-multipart-complete-abort | \
757b751482bb970bc321e92059690a7698554358:heal.race-copy-and-metadata-update) reviewed=true ;;
esac
if [ "${reviewed}" = true ] && [ "${required}" != true ]; then
printf 'Race candidate requires its reviewed native collector before fleet execution\n' >&2
exit 1
fi
printf 'required=%s\n' "${required}" >> "${GITHUB_OUTPUT}"
- name: Download pinned native storage evidence
if: ${{ inputs.validation_case == 'heal.conflicting-subquorum-authority' || inputs.validation_case == 'heal.bucket-incarnation-recreation' || steps.race_native.outputs.required == 'true' || (inputs.validation_case == 'heal.lock-timeout-and-caller-abort' && inputs.i05_diagnostic_branch == 'queued-successor') }}
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
github-token: ${{ github.token }}
run-id: ${{ inputs.validation_tools_run_id }}
name: rustfs-validation-native-${{ inputs.validation_tools_run_id }}-${{ inputs.validation_tools_run_attempt }}
path: ${{ runner.temp }}/rustfs-validation-native-${{ github.run_id }}-${{ github.run_attempt }}
- name: Verify C07 native source and exact test evidence
if: ${{ inputs.validation_case == 'heal.conflicting-subquorum-authority' }}
run: |
set -euo pipefail
NATIVE_DIRECTORY="${RUNNER_TEMP}/rustfs-validation-native-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
cp "${VALIDATION_ROOT}/tool-run.json" "${NATIVE_DIRECTORY}/tool-run.json"
python3 -B auto-testing/scripts/validation_heal/authority.py \
--directory "${NATIVE_DIRECTORY}" --candidate "${VALIDATION_ROOT}/candidate.json" \
> "${VALIDATION_ROOT}/native-authority-manifest.json"
printf 'VALIDATION_HEAL_NATIVE_DIRECTORY=%s\n' "${NATIVE_DIRECTORY}" >> "${GITHUB_ENV}"
- name: Verify C11 physical storage and exact final-rename barrier
if: ${{ inputs.validation_case == 'heal.bucket-incarnation-recreation' }}
run: |
set -euo pipefail
NATIVE_DIRECTORY="${RUNNER_TEMP}/rustfs-validation-native-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
cp "${VALIDATION_ROOT}/tool-run.json" "${NATIVE_DIRECTORY}/tool-run.json"
python3 -B auto-testing/scripts/validation_heal/incarnation_native.py \
--directory "${NATIVE_DIRECTORY}" --candidate "${VALIDATION_ROOT}/candidate.json" \
--testing-commit "${{ inputs.auto_testing_sha }}" \
> "${VALIDATION_ROOT}/native-incarnation-manifest.json"
printf 'VALIDATION_HEAL_NATIVE_DIRECTORY=%s\n' "${NATIVE_DIRECTORY}" >> "${GITHUB_ENV}"
- name: Verify exact-source race native and runtime evidence
if: ${{ steps.race_native.outputs.required == 'true' }}
env:
AUTO_TESTING_SHA: ${{ inputs.auto_testing_sha }}
VALIDATION_CASE_ID: ${{ inputs.validation_case }}
run: |
set -euo pipefail
NATIVE_DIRECTORY="${RUNNER_TEMP}/rustfs-validation-native-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
cp "${VALIDATION_ROOT}/tool-run.json" "${NATIVE_DIRECTORY}/tool-run.json"
python3 -B auto-testing/scripts/validation_heal/race_native_evidence.py \
--directory "${NATIVE_DIRECTORY}" --candidate "${VALIDATION_ROOT}/candidate.json" \
--testing-commit "${AUTO_TESTING_SHA}" --case-id "${VALIDATION_CASE_ID}" \
> "${VALIDATION_ROOT}/native-race-manifest.json"
runtime_driver=race.py
if [ "${VALIDATION_CASE_ID}" = 'heal.admin-nolock-safety' ]; then
runtime_driver=nolock.py
fi
VALIDATION_HEAL_NATIVE_DIRECTORY="${NATIVE_DIRECTORY}" \
python3 -B "auto-testing/scripts/validation_heal/${runtime_driver}" preflight \
--candidate "${VALIDATION_ROOT}/candidate.json" --testing-commit "${AUTO_TESTING_SHA}" \
--case-id "${VALIDATION_CASE_ID}" \
> "${VALIDATION_ROOT}/native-race-runtime-preflight.json"
printf 'VALIDATION_HEAL_NATIVE_DIRECTORY=%s\n' "${NATIVE_DIRECTORY}" >> "${GITHUB_ENV}"
- name: Verify I05 queued-successor native prerequisite
if: ${{ inputs.validation_case == 'heal.lock-timeout-and-caller-abort' && inputs.i05_diagnostic_branch == 'queued-successor' }}
env:
AUTO_TESTING_SHA: ${{ inputs.auto_testing_sha }}
run: |
set -euo pipefail
NATIVE_DIRECTORY="${RUNNER_TEMP}/rustfs-validation-native-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
cp "${VALIDATION_ROOT}/tool-run.json" "${NATIVE_DIRECTORY}/tool-run.json"
python3 -B auto-testing/scripts/validation_heal/race_native_evidence.py \
--directory "${NATIVE_DIRECTORY}" --candidate "${VALIDATION_ROOT}/candidate.json" \
--testing-commit "${AUTO_TESTING_SHA}" --case-id heal.lock-timeout-and-caller-abort \
> "${VALIDATION_ROOT}/native-i05-prerequisite.json"
printf 'VALIDATION_HEAL_NATIVE_DIRECTORY=%s\n' "${NATIVE_DIRECTORY}" >> "${GITHUB_ENV}"
- name: Download pinned manager persistence and incarnation evidence
if: ${{ inputs.validation_case == 'heal.checkpoint-and-mrf-corruption' || inputs.validation_case == 'heal.bucket-incarnation-recreation' || inputs.validation_case == 'heal.outcome-counters-and-error-truth' }}
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
github-token: ${{ github.token }}
run-id: ${{ inputs.validation_tools_run_id }}
name: rustfs-validation-manager-native-${{ inputs.validation_tools_run_id }}-${{ inputs.validation_tools_run_attempt }}
path: ${{ runner.temp }}/rustfs-validation-manager-native-${{ github.run_id }}-${{ github.run_attempt }}
- name: Verify exact manager native prerequisites before fleet mutation
if: ${{ inputs.validation_case == 'heal.checkpoint-and-mrf-corruption' || inputs.validation_case == 'heal.bucket-incarnation-recreation' || inputs.validation_case == 'heal.outcome-counters-and-error-truth' }}
env:
VALIDATION_CASE: ${{ inputs.validation_case }}
run: |
set -euo pipefail
MANAGER_DIRECTORY="${RUNNER_TEMP}/rustfs-validation-manager-native-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
cp "${VALIDATION_ROOT}/tool-run.json" "${MANAGER_DIRECTORY}/tool-run.json"
python3 -B auto-testing/scripts/validation_heal/manager_fixture.py \
--directory "${MANAGER_DIRECTORY}" --candidate "${VALIDATION_ROOT}/candidate.json" \
--case-id "${VALIDATION_CASE}" > "${VALIDATION_ROOT}/manager-prerequisite-manifest.json"
printf 'VALIDATION_HEAL_MANAGER_DIRECTORY=%s\n' "${MANAGER_DIRECTORY}" >> "${GITHUB_ENV}"
- name: Download pinned C10 shared-reference encoder
if: ${{ inputs.validation_case == 'heal.dangling-and-orphan-safety' }}
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
github-token: ${{ github.token }}
run-id: ${{ inputs.validation_tools_run_id }}
name: rustfs-validation-shared-${{ inputs.validation_tools_run_id }}-${{ inputs.validation_tools_run_attempt }}
path: ${{ runner.temp }}/rustfs-validation-shared-${{ github.run_id }}-${{ github.run_attempt }}
- name: Verify C10 exact-source codec and original tests
if: ${{ inputs.validation_case == 'heal.dangling-and-orphan-safety' }}
run: |
set -euo pipefail
SHARED_DIRECTORY="${RUNNER_TEMP}/rustfs-validation-shared-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
cp "${VALIDATION_ROOT}/tool-run.json" "${SHARED_DIRECTORY}/tool-run.json"
python3 -B auto-testing/scripts/validation_heal/shared_fixture.py \
--directory "${SHARED_DIRECTORY}" --candidate "${VALIDATION_ROOT}/candidate.json" \
> "${VALIDATION_ROOT}/shared-fixture-manifest.json"
printf 'VALIDATION_HEAL_SHARED_DIRECTORY=%s\n' "${SHARED_DIRECTORY}" >> "${GITHUB_ENV}"
- name: Run selected canonical validation case
if: ${{ !inputs.validation_tools_only && inputs.validation_cleanup_run_id == '' && inputs.validation_case != 'environment.inspect' }}
env:
VALIDATION_CASE: ${{ inputs.validation_case }}
AUTO_TESTING_SHA: ${{ inputs.auto_testing_sha }}
I05_DIAGNOSTIC_BRANCH: ${{ inputs.i05_diagnostic_branch }}
run: |
set -euo pipefail
test "$(git -C auto-testing rev-parse HEAD)" = "${AUTO_TESTING_SHA}"
./auto-testing/scripts/run_validation_case.sh \
"${VALIDATION_CASE}" "${VALIDATION_ROOT}/candidate.json" \
"${AUTO_TESTING_SHA}" "${VALIDATION_ROOT}/run"
- name: Inspect current Heal fleet resources without mutation
if: ${{ !inputs.validation_tools_only && inputs.validation_case == 'environment.inspect' }}
env:
AUTO_TESTING_SHA: ${{ inputs.auto_testing_sha }}
run: |
set -euo pipefail
test "$(git -C auto-testing rev-parse HEAD)" = "${AUTO_TESTING_SHA}"
python3 -B auto-testing/scripts/validation_heal/environment.py \
--candidate "${VALIDATION_ROOT}/candidate.json" \
--testing-commit "${AUTO_TESTING_SHA}" --evidence "${VALIDATION_ROOT}/environment"
- name: Audit and finalize retained validation environment
if: ${{ inputs.validation_cleanup_run_id != '' }}
env:
GH_TOKEN: ${{ github.token }}
FAILED_RUN_ID: ${{ inputs.validation_cleanup_run_id }}
AUTO_TESTING_SHA: ${{ inputs.auto_testing_sha }}
CLEANUP_RETAINED_EVIDENCE: ${{ inputs.validation_cleanup_retained_evidence }}
run: |
set -euo pipefail
test "$(git -C auto-testing rev-parse HEAD)" = "${AUTO_TESTING_SHA}"
gh run view "${FAILED_RUN_ID}" --repo "${GITHUB_REPOSITORY}" \
--json status,conclusion,headBranch,workflowName > "${VALIDATION_ROOT}/failed-run.json"
python3 - <<'PY'
import json
import os
from pathlib import Path
run = json.loads((Path(os.environ["VALIDATION_ROOT"]) / "failed-run.json").read_text())
if (run["status"] != "completed" or run["conclusion"] not in {"failure", "cancelled"}
or run["workflowName"] != "RustFS Functional Chain" or run["headBranch"] != os.environ["GITHUB_REF_NAME"]):
raise ValueError("cleanup source must be a completed failed or cancelled Functional Chain run")
PY
if [ "${CLEANUP_RETAINED_EVIDENCE}" = true ]; then
python3 -B scripts/prepare_retained_validation_recovery.py \
--report "${VALIDATION_ROOT}/retained-recovery-input.json"
else
gh run download "${FAILED_RUN_ID}" --repo "${GITHUB_REPOSITORY}" \
--pattern "rustfs-validation-${FAILED_RUN_ID}-*" --dir "${VALIDATION_ROOT}/failed-artifact"
fi
python3 -B auto-testing/scripts/validation_heal/recover_environment.py \
--failed-run-id "${FAILED_RUN_ID}" --artifact "${VALIDATION_ROOT}/failed-artifact" \
--candidate "${VALIDATION_ROOT}/candidate.json" --evidence "${VALIDATION_ROOT}/recovery"
- name: Show validation report
if: ${{ always() }}
run: |
if [ -f "${VALIDATION_ROOT}/run/report.md" ]; then
cat "${VALIDATION_ROOT}/run/report.md" >> "${GITHUB_STEP_SUMMARY}"
fi
- name: Upload validation evidence
if: ${{ always() && env.VALIDATION_ROOT != '' }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: rustfs-validation-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ env.VALIDATION_ROOT }}
if-no-files-found: error
validation-tools:
needs: [validation]
if: ${{ github.event_name == 'workflow_dispatch' && inputs.validation_package_sha == '' && inputs.validation_tools_only }}
uses: ./.github/workflows/rustfs-validation-tools.yml
with:
candidate_sha: ${{ needs.validation.outputs.source_sha }}
auto_testing_sha: ${{ inputs.auto_testing_sha }}
build_native_heal: ${{ inputs.validation_native_tools }}
build_shared_heal: ${{ inputs.validation_shared_tools }}
build_manager_native: ${{ inputs.validation_manager_tools }}
secrets: inherit
upgrade:
needs: [prepare]
if: ${{ always() && needs.prepare.result == 'success' }}
@@ -0,0 +1,244 @@
# Copyright 2024 RustFS Team
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
name: RustFS Validation Package
on:
workflow_call:
inputs:
source_sha:
description: Exact RustFS commit SHA to build
required: true
type: string
secrets:
R2_ACCESS_KEY_ID:
required: true
R2_SECRET_ACCESS_KEY:
required: true
R2_ENDPOINT:
required: true
R2_BUCKET:
required: true
workflow_dispatch:
inputs:
source_sha:
description: Exact RustFS commit SHA to build
required: true
type: string
permissions:
contents: read
concurrency:
group: rustfs-validation-package-${{ inputs.source_sha }}
cancel-in-progress: false
jobs:
resolve-source:
if: github.repository == 'rustfs/rustfs'
name: Resolve exact validation source
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
source_sha: ${{ steps.source.outputs.sha }}
source_ref: ${{ steps.source.outputs.ref }}
steps:
- name: Validate requested source
id: source
env:
REQUESTED_SHA: ${{ inputs.source_sha }}
run: |
set -euo pipefail
[[ "${REQUESTED_SHA}" =~ ^[0-9a-f]{40}$ ]]
source_directory="$(mktemp -d "${RUNNER_TEMP}/validation-source.XXXXXX")"
trap 'rm -rf "${source_directory}"' EXIT
git init -q "${source_directory}"
git -C "${source_directory}" fetch --no-tags --depth=1 https://github.com/rustfs/rustfs.git "${REQUESTED_SHA}"
test "$(git -C "${source_directory}" rev-parse FETCH_HEAD)" = "${REQUESTED_SHA}"
echo "sha=${REQUESTED_SHA}" >> "${GITHUB_OUTPUT}"
echo "ref=${REQUESTED_SHA}" >> "${GITHUB_OUTPUT}"
build:
needs: resolve-source
name: Build immutable validation DEB
runs-on: ubuntu-latest
timeout-minutes: 150
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
VALIDATION_SOURCE_SHA: ${{ needs.resolve-source.outputs.source_sha }}
steps:
- name: Checkout exact source
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
ref: ${{ needs.resolve-source.outputs.source_sha }}
- name: Confirm checked out source
env:
EXPECTED_SHA: ${{ needs.resolve-source.outputs.source_sha }}
run: test "$(git rev-parse HEAD)" = "${EXPECTED_SHA}"
- name: Setup Rust environment
uses: ./.github/actions/setup
with:
cache-shared-key: validation-package-hosted-x86_64-unknown-linux-gnu
cache-save-if: "false"
install-build-packaging-tools: "false"
install-test-tools: "false"
- name: Build RustFS
run: cargo build --release --locked --target x86_64-unknown-linux-gnu -p rustfs --bins
- name: Build validation DEB
shell: bash
run: |
set -euo pipefail
command -v fakeroot >/dev/null 2>&1 || sudo apt-get install -y -qq fakeroot
package_dir="rustfs-validation-${VALIDATION_SOURCE_SHA}"
package_file="${RUNNER_TEMP}/validation-package/rustfs.deb"
mkdir -p "${RUNNER_TEMP}/validation-package"
binary="target/x86_64-unknown-linux-gnu/release/rustfs"
test -x "${binary}"
mkdir -p "${package_dir}/DEBIAN" "${package_dir}/usr/bin" "${package_dir}/etc/default" \
"${package_dir}/lib/systemd/system" "${package_dir}/usr/share/doc/rustfs"
cp "${binary}" "${package_dir}/usr/bin/rustfs"
chmod 755 "${package_dir}/usr/bin/rustfs"
cp deploy/build/rustfs.service "${package_dir}/lib/systemd/system/"
cat > "${package_dir}/etc/default/rustfs" <<'ENVEOF'
# RustFS Environment Configuration
# RUSTFS_ACCESS_KEY=<changeme>
# RUSTFS_SECRET_KEY=<changeme>
# RUSTFS_VOLUMES=""
# RUSTFS_ADDRESS=":9000"
ENVEOF
cat > "${package_dir}/DEBIAN/control" <<'CONTROLEOF'
Package: rustfs
Version: 0.0.0~validation
Section: utils
Priority: optional
Architecture: amd64
Depends: libc6 (>= 2.31)
Maintainer: RustFS Team <support@rustfs.com>
Description: RustFS immutable validation package
Candidate package for RustFS validation only.
CONTROLEOF
printf '%s\n' /etc/default/rustfs > "${package_dir}/DEBIAN/conffiles"
./scripts/release/package_service_scripts.sh after-install > "${package_dir}/DEBIAN/postinst"
./scripts/release/package_service_scripts.sh before-remove > "${package_dir}/DEBIAN/prerm"
./scripts/release/package_service_scripts.sh after-remove > "${package_dir}/DEBIAN/postrm"
chmod 755 "${package_dir}/DEBIAN/postinst" "${package_dir}/DEBIAN/prerm" "${package_dir}/DEBIAN/postrm"
cp LICENSE README.md "${package_dir}/usr/share/doc/rustfs/"
fakeroot dpkg-deb --build "${package_dir}" "${package_file}"
test -s "${package_file}"
- name: Upload opaque validation package
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: validation-package-bytes-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/validation-package/rustfs.deb
if-no-files-found: error
publish:
needs: [resolve-source, build]
name: Publish immutable validation DEB
# Candidate-controlled build scripts never run on this fresh hosted runner.
runs-on: ubuntu-latest
timeout-minutes: 20
env:
VALIDATION_SOURCE_SHA: ${{ needs.resolve-source.outputs.source_sha }}
VALIDATION_SOURCE_REF: ${{ needs.resolve-source.outputs.source_ref }}
steps:
- name: Download opaque validation package
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: validation-package-bytes-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/validation-package-input
- name: Validate and stage opaque package bytes
shell: bash
run: |
set -euo pipefail
python3 -I - <<'PYTHON'
import os
from pathlib import Path
import shutil
import stat
root = Path(os.environ["RUNNER_TEMP"])
incoming = root / "validation-package-input"
if incoming.is_symlink() or not incoming.is_dir() or sorted(p.name for p in incoming.iterdir()) != ["rustfs.deb"]:
raise SystemExit("Validation artifact must contain exactly one rustfs.deb file")
fd = os.open(incoming / "rustfs.deb", os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
with os.fdopen(fd, "rb") as source:
info = os.fstat(source.fileno())
if not stat.S_ISREG(info.st_mode) or info.st_size == 0:
raise SystemExit("Validation package must be a nonempty regular file")
staged = root / "validation-publication"
staged.mkdir(mode=0o700)
with (staged / "rustfs.deb").open("xb") as target:
shutil.copyfileobj(source, target)
PYTHON
- name: Prepare trusted publication tools
id: tools
env:
AWS_EC2_METADATA_DISABLED: true
shell: bash
run: |
set -euo pipefail
AWS_CLI=aws
if ! "${AWS_CLI}" s3api put-object --generate-cli-skeleton input | jq -e 'has("IfNoneMatch")' >/dev/null; then
sudo apt-get update
sudo apt-get install -y -qq python3-venv
AWS_CLI_DIR="$(mktemp -d "${RUNNER_TEMP}/validation-awscli.XXXXXX")"
python3 -m venv "${AWS_CLI_DIR}"
"${AWS_CLI_DIR}/bin/python" -m pip install --disable-pip-version-check 'awscli==1.44.79'
AWS_CLI="${AWS_CLI_DIR}/bin/aws"
fi
"${AWS_CLI}" s3api put-object --generate-cli-skeleton input | jq -e 'has("IfNoneMatch")' >/dev/null
echo "aws_cli=${AWS_CLI}" >> "${GITHUB_OUTPUT}"
- name: Upload validation package to immutable R2 path
id: publish
env:
PACKAGE_FILE: ${{ runner.temp }}/validation-publication/rustfs.deb
AWS_CLI: ${{ steps.tools.outputs.aws_cli }}
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
R2_BUCKET: ${{ secrets.R2_BUCKET }}
AWS_EC2_METADATA_DISABLED: true
shell: bash
run: |
set -euo pipefail
if [[ -z "${R2_ACCESS_KEY_ID}" || -z "${R2_SECRET_ACCESS_KEY}" || -z "${R2_ENDPOINT}" || -z "${R2_BUCKET}" ]]; then
echo "Validation package publication requires complete R2 configuration" >&2
exit 1
fi
[[ "${VALIDATION_SOURCE_SHA}" =~ ^[0-9a-f]{40}$ ]]
test "${VALIDATION_SOURCE_REF}" = "${VALIDATION_SOURCE_SHA}"
test -s "${PACKAGE_FILE}" && test ! -L "${PACKAGE_FILE}"
export AWS_ACCESS_KEY_ID="${R2_ACCESS_KEY_ID}" AWS_SECRET_ACCESS_KEY="${R2_SECRET_ACCESS_KEY}" AWS_DEFAULT_REGION=auto
package_sha256="$(sha256sum "${PACKAGE_FILE}" | cut -d ' ' -f 1)"
key="artifacts/rustfs/packages/validation/runs/${GITHUB_RUN_ID}/${GITHUB_RUN_ATTEMPT}/${package_sha256}/rustfs.deb"
url="https://dl.rustfs.com/${key}"
"${AWS_CLI}" s3api put-object --bucket "${R2_BUCKET}" --key "${key}" --body "${PACKAGE_FILE}" \
--if-none-match '*' --endpoint-url "${R2_ENDPOINT}"
downloaded_sha256="$(curl -fsSL --retry 3 --connect-timeout 15 --max-time 300 "${url}" | sha256sum | cut -d ' ' -f 1)"
test "${downloaded_sha256}" = "${package_sha256}"
manifest="${RUNNER_TEMP}/validation-candidate-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}.json"
jq -n --arg workflow_sha "${GITHUB_SHA}" --arg source_ref "${VALIDATION_SOURCE_REF}" \
--arg source_sha "${VALIDATION_SOURCE_SHA}" --arg channel validation \
--argjson build_run_id "${GITHUB_RUN_ID}" --argjson build_run_attempt "${GITHUB_RUN_ATTEMPT}" \
--arg package_url "${url}" --arg package_sha256 "${package_sha256}" \
'{schema: 3, workflow_sha: $workflow_sha, source_ref: $source_ref, source_sha: $source_sha, channel: $channel, build_run_id: $build_run_id, build_run_attempt: $build_run_attempt, package_url: $package_url, package_sha256: $package_sha256}' > "${manifest}"
echo "candidate_file=${manifest}" >> "${GITHUB_OUTPUT}"
- name: Upload validation candidate manifest
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: validation-candidate-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.publish.outputs.candidate_file }}
if-no-files-found: error
@@ -0,0 +1,343 @@
# Build source-bound helpers separately so live validation needs only artifacts.
name: RustFS Validation Tools
on:
workflow_call:
inputs:
candidate_sha:
type: string
required: true
auto_testing_sha:
type: string
required: true
build_native_heal:
type: boolean
required: false
default: false
build_shared_heal:
type: boolean
required: false
default: false
build_manager_native:
type: boolean
required: false
default: false
secrets:
PF_TESTING_GH_TOKEN:
required: true
workflow_dispatch:
inputs:
candidate_sha:
description: Exact RustFS source SHA from the validation candidate manifest
type: string
required: true
auto_testing_sha:
description: Exact auto-testing SHA containing the Heal metadata inspector
type: string
required: true
build_native_heal:
description: Also compile and run separate exact-source native Heal regressions
type: boolean
required: false
default: false
build_shared_heal:
description: Build the controlled C10 shared-reference fixture encoder
type: boolean
required: false
default: false
build_manager_native:
description: Build and run exact candidate checkpoint/MRF/incarnation regressions
type: boolean
required: false
default: false
permissions:
contents: read
jobs:
manager-native-regressions:
if: ${{ inputs.build_manager_native }}
runs-on: ubuntu-22.04
timeout-minutes: 180
env:
CANDIDATE_SHA: ${{ inputs.candidate_sha }}
AUTO_TESTING_SHA: ${{ inputs.auto_testing_sha }}
steps:
- name: Validate immutable manager native inputs
run: |
set -euo pipefail
[[ "${CANDIDATE_SHA}" =~ ^[0-9a-f]{40}$ ]]
[[ "${AUTO_TESTING_SHA}" =~ ^[0-9a-f]{40}$ ]]
- name: Checkout exact manager candidate source
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
ref: ${{ inputs.candidate_sha }}
path: candidate
persist-credentials: false
- name: Checkout exact manager collector source
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
repository: rustfs/auto-testing
ref: ${{ inputs.auto_testing_sha }}
token: ${{ secrets.PF_TESTING_GH_TOKEN }}
path: auto-testing
persist-credentials: false
- name: Install manager native build dependencies
run: |
set -euo pipefail
sudo apt-get update
sudo apt-get install -y clang lld cmake pkg-config libssl-dev protobuf-compiler
df -h .
- name: Build exact unmodified candidate manager test executable
run: |
set -euo pipefail
test "$(git -C auto-testing rev-parse HEAD)" = "${AUTO_TESTING_SHA}"
python3 -B auto-testing/scripts/validation_heal/manager_native.py build \
--source candidate --commit "${CANDIDATE_SHA}" --out manager-tools
- name: Run exact manager regressions independently
run: |
set -euo pipefail
python3 -B auto-testing/scripts/validation_heal/manager_native.py run \
--directory manager-tools --out manager-evidence
- name: Upload manager executable identities and original diagnostics
if: ${{ always() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: rustfs-validation-manager-native-${{ github.run_id }}-${{ github.run_attempt }}
path: |
manager-tools/heal-manager-native-tests
manager-tools/*.json
manager-tools/*.jsonl
manager-tools/*.stderr
manager-tools/*.log
manager-evidence/
if-no-files-found: warn
native-heal-regressions:
if: ${{ inputs.build_native_heal }}
runs-on: ubuntu-22.04
timeout-minutes: 180
env:
CANDIDATE_SHA: ${{ inputs.candidate_sha }}
AUTO_TESTING_SHA: ${{ inputs.auto_testing_sha }}
steps:
- name: Validate immutable native source inputs
run: |
set -euo pipefail
[[ "${CANDIDATE_SHA}" =~ ^[0-9a-f]{40}$ ]]
[[ "${AUTO_TESTING_SHA}" =~ ^[0-9a-f]{40}$ ]]
- name: Checkout exact native candidate source
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
ref: ${{ inputs.candidate_sha }}
path: candidate
persist-credentials: false
- name: Checkout exact native collector source
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
repository: rustfs/auto-testing
ref: ${{ inputs.auto_testing_sha }}
token: ${{ secrets.PF_TESTING_GH_TOKEN }}
path: auto-testing
persist-credentials: false
- name: Install native build dependencies
run: |
set -euo pipefail
sudo apt-get update
sudo apt-get install -y clang lld cmake pkg-config libssl-dev protobuf-compiler
df -h .
- name: Build and enumerate the unmodified candidate test executable
run: |
set -euo pipefail
test "$(git -C auto-testing rev-parse HEAD)" = "${AUTO_TESTING_SHA}"
use_race_native=false
if [[ -f auto-testing/scripts/validation_heal/race_native.py ]]; then
support_rc=0
python3 -B auto-testing/scripts/validation_heal/race_native.py supports \
--commit "${CANDIDATE_SHA}" || support_rc=$?
case "${support_rc}" in
0) use_race_native=true ;;
2) ;; # The original exact-source collector handles legacy candidates.
*) exit "${support_rc}" ;;
esac
fi
if [[ "${use_race_native}" == true ]]; then
python3 -B auto-testing/scripts/validation_heal/race_native.py build \
--source candidate --commit "${CANDIDATE_SHA}" --testing-commit "${AUTO_TESTING_SHA}" --out native-tools
else
python3 -B auto-testing/scripts/validation_heal/native_regressions.py build \
--source candidate --commit "${CANDIDATE_SHA}" --out native-tools
fi
- name: Run exact native regressions separately from fleet validation
run: |
set -euo pipefail
if [[ -f native-tools/race-native.json ]]; then
test ! -e native-tools/native-regressions.json
python3 -B auto-testing/scripts/validation_heal/race_native.py run \
--directory native-tools --out native-evidence
else
python3 -B auto-testing/scripts/validation_heal/native_regressions.py run \
--directory native-tools --out native-evidence
fi
- name: Run existing F07 directory fsync failure regressions
if: ${{ inputs.candidate_sha == '1aa9be87d18cf941e5020f19e8622c26ba4b10ac' }}
run: |
set -euo pipefail
# Older immutable harness pins retain their original native suite;
# absence of this additive collector must never fabricate its proof.
if [[ ! -f auto-testing/scripts/validation_heal/persistence_native.py ]]; then
mkdir native-persistence-evidence
echo '{"complete":false,"reason":"pinned harness predates F07 directory-fsync collector","requiredNativeCoverageComplete":false,"canonicalCasePassClaimed":false}' \
> native-persistence-evidence/not-available.json
exit 0
fi
python3 -B auto-testing/scripts/validation_heal/persistence_native.py run \
--directory native-tools --native-evidence native-evidence --out native-persistence-evidence \
--testing-commit "${AUTO_TESTING_SHA}"
python3 -B auto-testing/scripts/validation_heal/persistence_native.py verify \
--directory native-tools --native-evidence native-evidence --out native-persistence-evidence \
--testing-commit "${AUTO_TESTING_SHA}"
- name: Upload native executable identities and original diagnostics
if: ${{ always() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: rustfs-validation-native-${{ github.run_id }}-${{ github.run_attempt }}
path: |
native-tools/ecstore-native-tests
native-tools/ecstore-race-native-tests
native-tools/*.json
native-tools/*.jsonl
native-tools/*.stderr
native-tools/*.log
native-evidence/
native-persistence-evidence/
if-no-files-found: warn
heal-metadata-inspector:
runs-on: ubuntu-22.04
timeout-minutes: 30
env:
CANDIDATE_SHA: ${{ inputs.candidate_sha }}
AUTO_TESTING_SHA: ${{ inputs.auto_testing_sha }}
steps:
- name: Validate immutable source inputs
run: |
set -euo pipefail
[[ "${CANDIDATE_SHA}" =~ ^[0-9a-f]{40}$ ]]
[[ "${AUTO_TESTING_SHA}" =~ ^[0-9a-f]{40}$ ]]
- name: Checkout exact candidate source
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
ref: ${{ inputs.candidate_sha }}
path: candidate
persist-credentials: false
- name: Checkout exact inspector source
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
repository: rustfs/auto-testing
ref: ${{ inputs.auto_testing_sha }}
token: ${{ secrets.PF_TESTING_GH_TOKEN }}
path: auto-testing
persist-credentials: false
- name: Build the read-only candidate metadata decoder
run: |
set -euo pipefail
test "$(git -C candidate rev-parse HEAD)" = "${CANDIDATE_SHA}"
test "$(git -C auto-testing rev-parse HEAD)" = "${AUTO_TESTING_SHA}"
df -h .
python3 auto-testing/scripts/validation_heal/prepare_heal_meta_inspector.py \
--source candidate --commit "${CANDIDATE_SHA}" --out inspector-build
# Seed the helper's separate graph from the candidate's frozen graph.
# Cargo adds the helper package; both lock identities are retained.
cp candidate/Cargo.lock inspector-build/Cargo.lock
cargo build --release --manifest-path inspector-build/Cargo.toml
python3 auto-testing/scripts/validation_heal/check_bitrot_tool.py \
--binary inspector-build/target/release/heal-meta-inspect --source candidate
mkdir validation-tools
cp inspector-build/target/release/heal-meta-inspect validation-tools/
cp inspector-build/Cargo.lock validation-tools/inspector-Cargo.lock
python3 - <<'PY'
import hashlib
import json
import os
from pathlib import Path
import subprocess
source = json.loads(Path("inspector-build/source.json").read_text())
binary = Path("validation-tools/heal-meta-inspect")
source.update(built=True, testingCommit=os.environ["AUTO_TESTING_SHA"],
binarySha256=hashlib.sha256(binary.read_bytes()).hexdigest(),
inspectorLockSha256=hashlib.sha256(Path("validation-tools/inspector-Cargo.lock").read_bytes()).hexdigest(),
toolchain=subprocess.check_output(["rustc", "-vV"], text=True),
target="x86_64-unknown-linux-gnu", runId=os.environ["GITHUB_RUN_ID"],
runAttempt=os.environ["GITHUB_RUN_ATTEMPT"])
Path("validation-tools/heal-meta-inspect.json").write_text(json.dumps(source, indent=2) + "\n")
PY
git -C candidate diff --exit-code
- name: Build and test the controlled C10 metadata encoder
if: ${{ inputs.build_shared_heal }}
run: |
set -euo pipefail
python3 -B auto-testing/scripts/validation_heal/prepare_heal_shared_fixture.py \
--source candidate --commit "${CANDIDATE_SHA}" --out shared-build
cp candidate/Cargo.lock shared-build/Cargo.lock
export CARGO_TARGET_DIR="${GITHUB_WORKSPACE}/inspector-build/target"
cargo build --release --manifest-path shared-build/Cargo.toml
mkdir shared-tools
if ! cargo test --release --manifest-path shared-build/Cargo.toml -- --test-threads=1 \
> shared-tools/shared-fixture-tests.log 2>&1; then
cat shared-tools/shared-fixture-tests.log
exit 1
fi
cp "${CARGO_TARGET_DIR}/release/heal-shared-fixture" shared-tools/
cp shared-build/Cargo.lock shared-tools/shared-Cargo.lock
python3 - <<'PY'
import hashlib
import json
import os
from pathlib import Path
import subprocess
source = json.loads(Path("shared-build/source.json").read_text())
source.update(schemaVersion=1, built=True, sourceUnmodified=True,
candidateSourceTree=subprocess.check_output(["git", "-C", "candidate", "rev-parse", "HEAD^{tree}"], text=True).strip(),
testingCommit=os.environ["AUTO_TESTING_SHA"],
binarySha256=hashlib.sha256(Path("shared-tools/heal-shared-fixture").read_bytes()).hexdigest(),
sharedLockSha256=hashlib.sha256(Path("shared-tools/shared-Cargo.lock").read_bytes()).hexdigest(),
toolchain=subprocess.check_output(["rustc", "-vV"], text=True), target="x86_64-unknown-linux-gnu",
runId=os.environ["GITHUB_RUN_ID"], runAttempt=os.environ["GITHUB_RUN_ATTEMPT"])
Path("shared-tools/heal-shared-fixture.json").write_text(json.dumps(source, indent=2) + "\n")
PY
git -C candidate diff --exit-code
- name: Upload controlled C10 fixture encoder evidence
if: ${{ always() && inputs.build_shared_heal }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: rustfs-validation-shared-${{ github.run_id }}-${{ github.run_attempt }}
path: shared-tools/
if-no-files-found: warn
- name: Upload immutable validation tools
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: rustfs-validation-tools-${{ github.run_id }}-${{ github.run_attempt }}
path: validation-tools/
if-no-files-found: error
+4
View File
@@ -103,6 +103,10 @@ jobs:
shell: pwsh
run: cargo test -p rustfs-ecstore --lib rename_all_ -- --nocapture
- name: Test multipart parent durability barriers
shell: pwsh
run: cargo test -p rustfs-ecstore --lib multipart_owned_record_parent_barriers_ -- --nocapture
- name: Test Windows path conversion
shell: pwsh
run: "cargo test -p rustfs-utils --no-default-features --features path --lib dunce:: -- --nocapture"
+1 -1
View File
@@ -155,7 +155,7 @@ default build (lifecycle:
- ⚠️ PARTIALLY VIOLATED: serving-side `s3s` references remain in ecstore
(bucket metadata/replication/lifecycle DTOs and error mapping). The
count is ratcheted shrink-only by `scripts/check_s3s_footprint.sh`
(the `ecstore_files` line of `.config/s3s-footprint-baseline.txt`; the `object_lock` module was converted to
(`S3S_ECSTORE_FILES_BASELINE`; the `object_lock` module was converted to
storage-level types as the first ratchet step). Target state: the
baseline reaches zero and ecstore's `Cargo.toml` drops `s3s`.
Generated
+65 -138
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",
@@ -8513,15 +8507,6 @@ version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
[[package]]
name = "quick-xml"
version = "0.41.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
dependencies = [
"memchr",
]
[[package]]
name = "quick-xml"
version = "0.42.0"
@@ -8589,7 +8574,7 @@ dependencies = [
"once_cell",
"socket2",
"tracing",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -9386,7 +9371,7 @@ dependencies = [
"proptest",
"prost 0.14.4",
"pyroscope",
"quick-xml 0.42.0",
"quick-xml",
"quinn",
"rand 0.10.3",
"rcgen",
@@ -9405,7 +9390,6 @@ dependencies = [
"rustfs-ecstore",
"rustfs-extension-schema",
"rustfs-filemeta",
"rustfs-gateway-types",
"rustfs-heal",
"rustfs-heal-contracts",
"rustfs-iam",
@@ -9684,7 +9668,6 @@ dependencies = [
"rustfs-data-usage",
"rustfs-erasure-codec",
"rustfs-filemeta",
"rustfs-gateway-types",
"rustfs-heal-contracts",
"rustfs-io-metrics",
"rustfs-lifecycle",
@@ -9790,48 +9773,6 @@ dependencies = [
"xxhash-rust",
]
[[package]]
name = "rustfs-gateway-stream"
version = "0.2.0"
source = "git+https://github.com/rustfs/gateway.git?rev=21283e874c2381bab611e9347acbc5b92e9e2b3c#21283e874c2381bab611e9347acbc5b92e9e2b3c"
dependencies = [
"bitflags 2.13.2",
"bytes",
"http 1.5.0",
"http-body",
]
[[package]]
name = "rustfs-gateway-types"
version = "0.32.0+aws.2026-10-02"
source = "git+https://github.com/rustfs/gateway.git?rev=21283e874c2381bab611e9347acbc5b92e9e2b3c#21283e874c2381bab611e9347acbc5b92e9e2b3c"
dependencies = [
"bytes",
"crc-fast",
"futures-core",
"hex",
"http 1.5.0",
"md-5 0.11.0",
"percent-encoding",
"rustfs-gateway-stream",
"rustfs-gateway-xml",
"s3s",
"sha1 0.11.0",
"sha2 0.11.1",
"time",
"twox-hash",
"unicode-normalization",
"zeroize",
]
[[package]]
name = "rustfs-gateway-xml"
version = "0.4.0"
source = "git+https://github.com/rustfs/gateway.git?rev=21283e874c2381bab611e9347acbc5b92e9e2b3c#21283e874c2381bab611e9347acbc5b92e9e2b3c"
dependencies = [
"quick-xml 0.41.0",
]
[[package]]
name = "rustfs-heal"
version = "1.0.2"
@@ -10102,10 +10043,10 @@ dependencies = [
"metrics-util",
"proptest",
"rustfs-config",
"rustfs-gateway-types",
"rustfs-replication",
"rustfs-scanner-metrics",
"rustfs-storage-api",
"s3s",
"serial_test",
"temp-env",
"time",
@@ -10168,6 +10109,7 @@ dependencies = [
"reqwest",
"rmp-serde",
"rustfs-signer",
"s3s",
"serde",
"serde_json",
"sysinfo",
@@ -10177,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",
]
@@ -10207,7 +10149,7 @@ dependencies = [
"jiff",
"metrics",
"percent-encoding",
"quick-xml 0.42.0",
"quick-xml",
"rayon",
"rustc-hash",
"rustfs-config",
@@ -10388,13 +10330,10 @@ dependencies = [
"rustfs-config",
"rustfs-credentials",
"rustfs-ecstore",
"rustfs-gateway-stream",
"rustfs-gateway-types",
"rustfs-iam",
"rustfs-keystone",
"rustfs-policy",
"rustfs-rio",
"rustfs-s3-types",
"rustfs-storage-api",
"rustfs-test-utils",
"rustfs-tls-runtime",
@@ -10402,13 +10341,13 @@ dependencies = [
"rustfs-trusted-proxies",
"rustfs-utils",
"rustls",
"s3s",
"serde",
"serde_json",
"sha1 0.11.0",
"sha2 0.11.1",
"socket2",
"subtle",
"sync_wrapper",
"tempfile",
"thiserror 2.0.21",
"time",
@@ -10458,7 +10397,7 @@ dependencies = [
"regex",
"rmp",
"rmp-serde",
"rustfs-gateway-types",
"s3s",
"serde",
"time",
"tokio",
@@ -10553,13 +10492,11 @@ dependencies = [
"hyper-util",
"lazy_static",
"md-5 0.11.0",
"quick-xml 0.42.0",
"quick-xml",
"rand 0.10.3",
"rustfs-checksums",
"rustfs-config",
"rustfs-gateway-types",
"rustfs-rio",
"rustfs-s3-types",
"rustfs-signer",
"rustfs-storage-api",
"rustfs-tls-runtime",
@@ -10593,19 +10530,9 @@ dependencies = [
name = "rustfs-s3-types"
version = "1.0.2"
dependencies = [
"bytes",
"futures-core",
"hotpath",
"http 1.5.0",
"http-body",
"http-body-util",
"hyper",
"hyper-util",
"pin-project-lite",
"s3s",
"serde",
"serde_json",
"tokio",
]
[[package]]
@@ -10629,10 +10556,9 @@ dependencies = [
"parking_lot 0.13.0",
"rustfs-common",
"rustfs-ecstore",
"rustfs-gateway-types",
"rustfs-s3-types",
"rustfs-storage-api",
"rustfs-test-utils",
"s3s",
"serde_json",
"serial_test",
"thiserror 2.0.21",
@@ -10658,6 +10584,7 @@ dependencies = [
"parking_lot 0.13.0",
"rustfs-s3select-api",
"rustfs-test-utils",
"s3s",
"serde",
"serde_json",
"tokio",
@@ -10686,7 +10613,6 @@ dependencies = [
"rustfs-data-usage",
"rustfs-ecstore",
"rustfs-filemeta",
"rustfs-gateway-types",
"rustfs-heal",
"rustfs-heal-contracts",
"rustfs-lifecycle",
@@ -10743,10 +10669,9 @@ dependencies = [
"bytes",
"hotpath",
"http 1.5.0",
"http-body",
"hyper",
"rustfs-s3-types",
"rustfs-utils",
"s3s",
"serde_urlencoded",
"thiserror 2.0.21",
"time",
@@ -10804,6 +10729,7 @@ dependencies = [
"rustfs-utils",
"rustls",
"rustls-native-certs",
"s3s",
"serde",
"serde_json",
"sha2 0.11.1",
@@ -11028,7 +10954,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -11111,7 +11037,7 @@ dependencies = [
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -11195,7 +11121,7 @@ dependencies = [
"numeric_cast",
"phf 0.14.0",
"pin-project-lite",
"quick-xml 0.42.0",
"quick-xml",
"regex",
"s3s-multipart",
"s3s-rfc2047",
@@ -11872,9 +11798,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",
]
@@ -11913,7 +11839,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]]
@@ -12064,7 +11990,7 @@ dependencies = [
"cfg-if",
"libc",
"psm",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -12370,10 +12296,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]]
@@ -13046,9 +12972,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"
@@ -13467,7 +13393,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]]
@@ -14028,9 +13954,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",
@@ -14049,6 +13975,7 @@ dependencies = [
"sha1 0.11.0",
"time",
"typed-path",
"windows-sys 0.61.2",
"zeroize",
"zopfli",
"zstd 0.14.3",
+5 -17
View File
@@ -48,7 +48,7 @@ members = [
"crates/replication", # Replication contracts and wire formats
"crates/concurrency", # Concurrency management for RustFS - timeout, locking, backpressure, and I/O scheduling
"crates/s3-client", # S3 client for engine-side consumption of remote S3 endpoints (tiering, transition targets)
"crates/s3-types", # S3 event and error type definitions
"crates/s3-types", # S3 event type definitions
"crates/s3-ops", # S3 operation definitions and mapping
"crates/s3select-api", # S3 Select API interface
"crates/s3select-query", # S3 Select query engine
@@ -142,13 +142,6 @@ rustfs-tls-runtime = { path = "crates/tls-runtime", version = "1.0.2" }
rustfs-utils = { path = "crates/utils", version = "1.0.2" }
rustfs-zip = { path = "./crates/zip", version = "1.0.2" }
# The gateway's S3 types: the DTOs and persisted bucket-configuration codecs the
# engine crates move onto from s3s (rustfs/backlog#2745). The one pin of the
# gateway in this workspace; no crate may enable a `compat-s3s*` feature except
# the s3s-facing edge of the rustfs binary.
rustfs-gateway-types = { git = "https://github.com/rustfs/gateway.git", rev = "21283e874c2381bab611e9347acbc5b92e9e2b3c", version = "0.32.0", default-features = false }
rustfs-gateway-stream = { git = "https://github.com/rustfs/gateway.git", rev = "21283e874c2381bab611e9347acbc5b92e9e2b3c", version = "0.2.0", default-features = false }
# Async Runtime and Networking
async-channel = "2.5.0"
async_zip = { default-features = false, version = "0.0.19" }
@@ -331,19 +324,14 @@ rust-embed = { version = "8.13.0" }
rustc-hash = { version = "2.1.3" }
s3s = { git = "https://github.com/s3s-project/s3s", rev = "5761ddfe4c509fcac87e3b99fd5bd9e176b442c0", features = ["minio"] }
s3s-sigv4 = { git = "https://github.com/s3s-project/s3s", rev = "5761ddfe4c509fcac87e3b99fd5bd9e176b442c0" }
# The same s3s pin under the name the legacy edge of the gateway migration uses
# (crates/s3-types, feature `compat-s3s`). Keep `rev` identical to `s3s` above.
# DELETE BY T4.2 (rustfs/backlog#2784).
legacy-s3s = { package = "s3s", git = "https://github.com/s3s-project/s3s", rev = "5761ddfe4c509fcac87e3b99fd5bd9e176b442c0", features = ["minio"] }
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" }
strum = { version = "0.28.0" }
sync_wrapper = "1.0.2"
sysinfo = "0.39.6"
temp-env = "0.3.6"
tempfile = "3.27.0"
@@ -366,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
@@ -394,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" }
+3 -3
View File
@@ -141,7 +141,7 @@ chown -R 10001:10001 data logs
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:latest
# Using specific version
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.3
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.4
```
If you use [podman](https://github.com/containers/podman) instead of docker, you can install the RustFS with the below command
@@ -220,7 +220,7 @@ For developers who want to build RustFS Docker images from source with multi-arc
./docker-buildx.sh --push
# Build specific version
./docker-buildx.sh --release 1.0.3 --push
./docker-buildx.sh --release 1.0.4 --push
# Build for custom registry
./docker-buildx.sh --registry your-registry.com --namespace yourname --push
@@ -238,7 +238,7 @@ You can also use Make targets for convenience:
```bash
make docker-buildx # Build locally
make docker-buildx-push # Build and push
make docker-buildx-version VERSION=1.0.3 # Build specific version
make docker-buildx-version VERSION=1.0.4 # Build specific version
make help-docker # Show all Docker-related commands
```
+3 -3
View File
@@ -138,7 +138,7 @@ chown -R 10001:10001 data logs
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:latest
# 使用指定版本运行
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.3
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.4
```
如果您通过绑定挂载启用 TLS 证书目录,也请用同样方式准备该目录:
@@ -176,7 +176,7 @@ docker compose -f docker-compose-simple.yml up -d
./docker-buildx.sh --push
# 构建指定版本
./docker-buildx.sh --release 1.0.3 --push
./docker-buildx.sh --release 1.0.4 --push
# 构建并推送到自定义仓库
./docker-buildx.sh --registry your-registry.com --namespace yourname --push
@@ -193,7 +193,7 @@ docker compose -f docker-compose-simple.yml up -d
```bash
make docker-buildx # 本地构建
make docker-buildx-push # 构建并推送
make docker-buildx-version VERSION=1.0.3 # 构建指定版本
make docker-buildx-version VERSION=1.0.4 # 构建指定版本
make help-docker # 显示所有 Docker 相关命令
```
+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);
}
}
+4
View File
@@ -197,6 +197,10 @@ pub const ENV_HEAL_MRF_REPLAY_BATCH: &str = "RUSTFS_HEAL_MRF_REPLAY_BATCH";
/// Default behavior keeps the MRF intent pipeline enabled.
pub const DEFAULT_HEAL_MRF_ENABLE: bool = true;
/// Opt in to lifecycle V2 transitions after qualifying every reader and the rollback binary.
pub const ENV_MRF_LIFECYCLE_V2_ENABLE: &str = "RUSTFS_MRF_LIFECYCLE_V2_ENABLE";
pub const DEFAULT_MRF_LIFECYCLE_V2_ENABLE: bool = false;
/// Default MRF queue capacity (matches MinIO's 100k MRF list ceiling).
pub const DEFAULT_HEAL_MRF_QUEUE_SIZE: usize = 100_000;
+4
View File
@@ -29,6 +29,10 @@
//! 3. Starting with the default `rustfsadmin` credentials emits the
//! default-credentials startup warning (observable operator signal).
// Keep inventory acceptance in the admin-auth PR smoke selection.
#[cfg(test)]
mod target_inventory_test;
#[cfg(test)]
mod tests {
use crate::common::{RustFSTestEnvironment, init_logging, rustfs_binary_path};
@@ -0,0 +1,205 @@
// 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.
//! Exercise the target inventory through HTTP authentication and authorization.
//! Handler-level tests separately control reconciliation locks and config faults.
use crate::common::{
RustFSTestEnvironment, admin_create_user, admin_request, admin_request_with_session_token, build_test_sts_client,
init_logging, local_http_client,
};
use http::header::{AUTHORIZATION, HOST};
use reqwest::StatusCode;
use rustfs_signer::{constants::UNSIGNED_PAYLOAD, sign_v4};
use s3s::Body;
use serde_json::Value;
use std::{error::Error, time::Duration};
type TestResult = Result<(), Box<dyn Error + Send + Sync>>;
const SECRET: &str = "inventory-acceptance-token";
const LIMITED_KEY: &str = "inventory-limited";
const LIMITED_SECRET: &str = "inventory-limited-secret";
const PATHS: [&str; 5] = [
"/rustfs/admin/v3/target/list",
"/rustfs/admin/v3/audit/target/list",
"/rustfs/admin/v4/plugins/instances",
"/rustfs/admin/v4/plugins/instances/builtin:webhook:notify:acceptance",
"/rustfs/admin/v4/extensions/instances",
];
async fn duplicate_authorization(
env: &RustFSTestEnvironment,
path: &str,
) -> Result<reqwest::Response, Box<dyn Error + Send + Sync>> {
let url = format!("{}{path}", env.url);
let uri: http::Uri = url.parse()?;
let authority = uri.authority().ok_or("inventory URL missing authority")?.to_string();
let request = http::Request::builder()
.method(http::Method::GET)
.uri(uri)
.header(HOST, authority)
.header("x-amz-content-sha256", UNSIGNED_PAYLOAD)
.body(Body::empty())?;
let signed = sign_v4(request, 0, &env.access_key, &env.secret_key, "", "us-east-1");
let mut headers = signed.headers().clone();
let authorization = headers.get(AUTHORIZATION).ok_or("signer omitted authorization")?.clone();
headers.append(AUTHORIZATION, authorization);
assert_eq!(headers.get_all(AUTHORIZATION).iter().count(), 2);
Ok(local_http_client().get(url).headers(headers).send().await?)
}
fn assert_unloaded_target(entry: &Value, path: &str) {
assert_eq!(entry["service"], "webhook", "{path}: unexpected target service");
assert!(
entry["account_id"]
.as_str()
.is_some_and(|id| id.eq_ignore_ascii_case("acceptance")),
"{path}: fixture target is missing"
);
assert_eq!(entry["status"], "offline", "{path}: unloaded targets must remain visible as offline");
if !path.contains("/extensions/") {
assert_eq!(entry["health_reason"], "not_loaded_in_runtime", "{path}: unexpected health reason");
}
}
#[tokio::test(flavor = "multi_thread")]
async fn target_inventory_preserves_unloaded_targets_and_http_auth_boundaries() -> TestResult {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
// Enabled destinations remain configured while both delivery modules are
// disabled, so inventory reads must not require a loaded target runtime.
env.start_rustfs_server_with_env(
vec![],
&[
("RUSTFS_NOTIFY_ENABLE", "false"),
("RUSTFS_AUDIT_ENABLE", "false"),
("RUSTFS_NOTIFY_WEBHOOK_ENABLE_ACCEPTANCE", "on"),
("RUSTFS_NOTIFY_WEBHOOK_ENDPOINT_ACCEPTANCE", "http://127.0.0.1:9/hook"),
("RUSTFS_NOTIFY_WEBHOOK_AUTH_TOKEN_ACCEPTANCE", SECRET),
("RUSTFS_AUDIT_WEBHOOK_ENABLE_ACCEPTANCE", "on"),
("RUSTFS_AUDIT_WEBHOOK_ENDPOINT_ACCEPTANCE", "http://127.0.0.1:9/hook"),
("RUSTFS_AUDIT_WEBHOOK_AUTH_TOKEN_ACCEPTANCE", SECRET),
],
)
.await?;
admin_create_user(&env, LIMITED_KEY, LIMITED_SECRET).await?;
tokio::time::timeout(Duration::from_secs(60), async {
let assumed = build_test_sts_client(&env.url, &env.access_key, &env.secret_key, None, "e2e-target-inventory")
.assume_role()
.role_arn("arn:aws:iam::123456789012:role/target-inventory")
.role_session_name("target-inventory-console-session")
.send()
.await?;
let temporary = assumed
.credentials()
.ok_or("AssumeRole should return temporary inventory credentials")?;
assert_ne!(
temporary.access_key_id(),
env.access_key,
"inventory must exercise a distinct session credential"
);
assert!(
!temporary.session_token().is_empty(),
"temporary inventory credentials must include a token"
);
for path in PATHS {
for (principal, access_key, secret_key, session_token) in [
("root", env.access_key.as_str(), env.secret_key.as_str(), None),
(
"temporary admin",
temporary.access_key_id(),
temporary.secret_access_key(),
Some(temporary.session_token()),
),
] {
let (status, body) = admin_request_with_session_token(
&env.url,
http::Method::GET,
path,
None,
access_key,
secret_key,
session_token,
)
.await?;
assert_eq!(status, StatusCode::OK, "{path}: signed {principal} inventory failed");
assert!(!body.contains(SECRET), "{path}: inventory exposed a target secret");
let value: Value = serde_json::from_str(&body)?;
let entries: Vec<&Value> = if path.ends_with("/target/list") {
let field = if path.contains("/audit/") {
"audit_endpoints"
} else {
"notification_endpoints"
};
value[field].as_array().ok_or("missing endpoint inventory")?.iter().collect()
} else if path.ends_with("/instances") {
value["instances"]
.as_array()
.ok_or("missing instance inventory")?
.iter()
.collect()
} else {
vec![&value]
};
let targets: Vec<_> = entries
.into_iter()
.filter(|entry| {
entry["account_id"]
.as_str()
.is_some_and(|id| id.eq_ignore_ascii_case("acceptance"))
})
.collect();
let expected = if path.ends_with("/instances") { 2 } else { 1 };
assert_eq!(targets.len(), expected, "{path}: configured targets disappeared");
for entry in &targets {
assert_unloaded_target(entry, path);
if path.contains("/v4/") {
assert_eq!(entry["config"]["auth_token"], "***redacted***", "{path}: missing secret redaction");
}
}
if expected == 2 {
for domain in ["notify", "audit"] {
assert!(targets.iter().any(|entry| entry["domain"] == domain), "{path}: missing {domain} domain");
}
}
}
let unsigned = local_http_client().get(format!("{}{path}", env.url)).send().await?;
assert_eq!(unsigned.status(), StatusCode::FORBIDDEN, "{path}: unsigned request was not denied");
let body = unsigned.text().await?;
assert!(body.contains("<Code>AccessDenied</Code>") && body.contains("Signature is required"));
let ambiguous = duplicate_authorization(&env, path).await?;
assert_eq!(
ambiguous.status(),
StatusCode::FORBIDDEN,
"{path}: duplicate authorization was not rejected"
);
let body = ambiguous.text().await?;
assert!(body.contains("<Code>AccessDenied</Code>") && body.contains("Signature is required"));
let (status, body) = admin_request(&env.url, http::Method::GET, path, None, LIMITED_KEY, LIMITED_SECRET).await?;
assert_eq!(status, StatusCode::FORBIDDEN, "{path}: principal without admin policy was not denied");
assert!(body.contains("<Code>AccessDenied</Code>"), "{path}: unexpected authorization error");
assert!(!body.contains("Signature is required") && !body.contains("SignatureDoesNotMatch"));
}
Ok::<(), Box<dyn Error + Send + Sync>>(())
})
.await??;
env.stop_server();
Ok(())
}
+1 -94
View File
@@ -17,8 +17,7 @@
//! when PublicAccessBlock configuration is missing or explicitly set.
use crate::common::{RustFSTestEnvironment, init_logging, local_http_client};
use crate::sts_query_compat_test::{OPA_AUTH_TOKEN, OpaMock};
use aws_sdk_s3::types::{PublicAccessBlockConfiguration, Tag, Tagging};
use aws_sdk_s3::types::PublicAccessBlockConfiguration;
use tracing::info;
async fn setup_public_bucket(
@@ -70,98 +69,6 @@ async fn anonymous_get_object(
local_http_client().get(&url).send().await
}
#[tokio::test]
async fn test_plain_http_matches_secure_transport_false_bucket_policy() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
assert!(env.url.starts_with("http://"), "fixture must exercise plaintext HTTP: {}", env.url);
let bucket = "secure-transport-policy";
let key = "plain-http.txt";
let admin = env.create_s3_client();
admin.create_bucket().bucket(bucket).send().await?;
admin
.put_object()
.bucket(bucket)
.key(key)
.body(aws_sdk_s3::primitives::ByteStream::from_static(b"plain http"))
.send()
.await?;
admin
.put_bucket_policy()
.bucket(bucket)
.policy(
serde_json::json!({
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": format!("arn:aws:s3:::{bucket}/*"),
"Condition": {"Bool": {"aws:SecureTransport": "false"}}
}]
})
.to_string(),
)
.send()
.await?;
let response = anonymous_get_object(&env, bucket, key).await?;
assert_eq!(response.status().as_u16(), 200);
assert_eq!(response.bytes().await?.as_ref(), b"plain http");
Ok(())
}
/// Root credentials have no attached IAM policy, so these requests depend on
/// the owner flag reaching policy evaluation unchanged.
#[tokio::test]
async fn test_root_requests_keep_policy_owner_flag() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
let opa = OpaMock::start().await?;
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server_with_env(
vec![],
&[
("RUSTFS_POLICY_PLUGIN_URL", opa.url.as_str()),
("RUSTFS_POLICY_PLUGIN_AUTH_TOKEN", OPA_AUTH_TOKEN),
],
)
.await?;
let bucket = "root-owner-flag";
let key = "owner.txt";
let client = env.create_s3_client();
client.create_bucket().bucket(bucket).send().await?;
client
.put_object()
.bucket(bucket)
.key(key)
.body(aws_sdk_s3::primitives::ByteStream::from_static(b"owner access"))
.send()
.await?;
// Duplicate stored tags are ambiguous. An owner request must not enter the
// OPA tag-loading path, which rejects this metadata before evaluation.
client
.put_bucket_tagging()
.bucket(bucket)
.tagging(
Tagging::builder()
.tag_set(Tag::builder().key("department").value("finance").build()?)
.tag_set(Tag::builder().key("department").value("engineering").build()?)
.build()?,
)
.send()
.await?;
let object = client.get_object().bucket(bucket).key(key).send().await?;
assert_eq!(object.body.collect().await?.into_bytes().as_ref(), b"owner access");
Ok(())
}
/// Issue #2036: Anonymous GetObject should succeed when bucket policy allows it
/// and no PublicAccessBlock configuration exists (ConfigNotFound).
#[tokio::test]
@@ -347,6 +347,9 @@ fn relocate_pool_storage(cluster: &mut RustFSTestClusterEnvironment, node_idx: u
}
pub(super) fn configure_node_logs(cluster: &mut RustFSTestClusterEnvironment, capture_dir: Option<&Path>) -> TestResult {
if let Ok(filter) = std::env::var("RUSTFS_E2E_LOG_FILTER") {
cluster.set_env("RUST_LOG", filter);
}
if let Some(directory) = capture_dir {
std::fs::create_dir_all(directory)?;
for node_idx in 0..cluster.nodes.len() {
@@ -4,12 +4,13 @@
use super::harness::{DistCluster, DistLayout, TestResult, unique_bucket};
use crate::tls_gen;
use aws_sdk_s3::{Client, config::retry::RetryConfig, primitives::ByteStream};
use futures::future::try_join_all;
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);
@@ -85,8 +86,10 @@ async fn verified_get(
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 started = Instant::now();
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}"));
@@ -98,24 +101,42 @@ async fn verified_get(
} else {
assert_eq!(output.content_range(), None);
}
let mut count = 0u64;
stage = "response body";
let mut digest = Sha256::new();
while let Some(chunk) = output.body.next().await {
let chunk = chunk?;
count += u64::try_from(chunk.len())?;
assert!(count <= expected.length, "node {node}: GET exceeded the requested length");
let chunk = chunk.inspect_err(|error| {
eprintln!(
"TLS acceptance: GET body failed: node={node}, key={}, range={:?}, concurrent={concurrent}, received={received}/{}, elapsed={:?}, error={error}",
object.key, expected.range, expected.length, started.elapsed()
);
})?;
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!(count, expected.length, "node {node}: truncated GET {}", object.key);
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": count, "sha256": actual_sha256,
"range": expected.range, "bytes": received, "sha256": actual_sha256,
"concurrent": concurrent, "elapsed_ms": started.elapsed().as_secs_f64() * 1000.0,
}))
})
.await?
.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
@@ -157,9 +178,13 @@ async fn four_node_tls_large_and_range_gets_preserve_bytes() -> TestResult {
{
return Err("acceptance report paths must be unused".into());
}
let log_directory = report_path
.as_ref()
.map_or_else(|| directory.join("logs"), |path| path.with_extension("logs"));
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?;
@@ -183,7 +208,8 @@ async fn four_node_tls_large_and_range_gets_preserve_bytes() -> TestResult {
let mut results = Vec::new();
for length in [32 * 1024, 100 * MIB, 1024 * MIB] {
let fixture_directory = directory.clone();
let object = tokio::task::spawn_blocking(move || fixture(&fixture_directory, length)).await??;
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,
@@ -194,7 +220,18 @@ async fn four_node_tls_large_and_range_gets_preserve_bytes() -> TestResult {
.body(ByteStream::from_path(&object.path).await?)
.send(),
)
.await??;
.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}),
);
@@ -216,15 +253,19 @@ async fn four_node_tls_large_and_range_gets_preserve_bytes() -> TestResult {
results.push(verified_get(client, node, &bucket, &object, expected, false).await?);
}
}
results.extend(
try_join_all(
clients
.iter()
.enumerate()
.map(|(node, client)| verified_get(client, node, &bucket, &object, &object.reads[0], true)),
)
.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() {
+2
View File
@@ -28,6 +28,7 @@ mod harness;
mod heal_test;
mod internode_tls_test;
mod multipart_fence_acceptance_test;
mod multipart_rpc_delay_test;
mod object_lock_test;
mod observability_test;
mod replication_delete_marker_test;
@@ -36,6 +37,7 @@ mod s3_basic_test;
mod s3_during_data_movement_test;
mod scanner_heal_topology_fixture;
mod scanner_heal_topology_test;
mod scanner_heal_topology_tier_test;
mod site_replication_test;
mod upgrade_test;
mod versioning_test;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -216,6 +216,10 @@ pub(crate) struct TopologyRun {
impl TopologyRun {
pub(crate) async fn start(label: &str) -> TestResult<Self> {
Self::start_with_env(label, &[]).await
}
pub(crate) async fn start_with_env(label: &str, extra_env: &[(&str, &str)]) -> TestResult<Self> {
let run_name = format!("scanner-heal-topology-{label}-{}", Uuid::new_v4().simple());
// CI uploads RUSTFS_E2E_LOG_DIR; keep node logs, barrier observations
// and evidence there so a failed lane can be diagnosed.
@@ -236,6 +240,7 @@ impl TopologyRun {
let mut env: Vec<(&str, &str)> = TOPOLOGY_ENV.to_vec();
env.extend_from_slice(FAST_DATA_USAGE_SCANNER_ENV);
env.push((MOVEMENT_BARRIER_DIR_ENV, movement_dir_value.as_str()));
env.extend_from_slice(extra_env);
let mut dist = DistCluster::new_stopped_with_env(DistLayout::SingleNodeFourDrive, &env).await?;
dist.capture_logs_under(&log_dir)?;
dist.cluster.start().await?;
@@ -329,6 +334,11 @@ impl FileBarrier {
&self.nonce
}
/// Keep the previous phase's release handle while arming a fresh nonce.
pub(crate) fn next_phase(&self) -> Self {
Self::new(self.dir.clone())
}
fn write_atomic(&self, name: &str, body: &[u8]) -> TestResult {
let tmp = self.dir.join(format!(".{name}.{}.tmp", Uuid::new_v4().simple()));
std::fs::write(&tmp, body)?;
@@ -360,6 +370,10 @@ impl FileBarrier {
self.write_atomic(&format!("release.{}", self.nonce), b"io_error")
}
pub(crate) fn release_movement(&self) -> TestResult {
self.write_atomic(&format!("release.{}", self.nonce), b"continue")
}
pub(crate) fn reached_observations(&self) -> TestResult<Vec<Value>> {
let prefix = format!("reached.{}.", self.nonce);
let mut observations = Vec::new();
@@ -528,7 +528,7 @@ async fn scanner_heal_topology_decommission_kill_before_source_cleanup_resumes_w
run.movement_barrier.disarm()?;
let restarted_pid = run.restart_killed_node(SEED_POOL).await?;
let label = "after the killed leader resumed decommission";
let outcome = settle_decommission(&run, &manifest, label, DECOMMISSION_RESUME_COMPLETE_TIMEOUT).await;
let outcome = settle_decommission(&run, &manifest, label, MOVEMENT_COMPLETE_TIMEOUT).await;
let strict_nodes = assert_process_durability_strict(&run.node_log_paths());
record(
&run,
@@ -0,0 +1,619 @@
// 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.
//! G-TIER: real mover processes contend with a held remote DELETE while a
//! free version owns the old remote tuple beside a live transitioned version.
//! The remote is the HTTP fake S3 target, not a provider interoperability test.
use super::harness::{
DECOMMISSION_POOL_ID, DRIVES_PER_NODE, TestResult, cluster_admin_ok, decommission_status_json, enable_versioning,
rebalance_status_json, sha256_hex, start_decommission, start_rebalance, unique_bucket, wait_for_decommission_complete,
wait_for_rebalance_complete,
};
use super::scanner_heal_topology_fixture::{
MOVEMENT_COMPLETE_TIMEOUT, MovementOperation, SEED_POOL, STAGE_AFTER_RESPONSIBILITY_PERSISTED, STAGE_BEFORE_SOURCE_CLEANUP,
TopologyRun, assert_bucket_durability_strict, assert_process_durability_strict, require_test_hooks,
};
use crate::common::init_logging;
use crate::fake_s3_target::{FAKE_ACCESS_KEY, FAKE_SECRET_KEY, FakeS3Target, Operation, RequestRecord};
use crate::storage_api::OBJECT_LOCK_TRACE_FILTER;
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::{
BucketLifecycleConfiguration, ExpirationStatus, LifecycleRule, LifecycleRuleFilter, Transition, TransitionStorageClass,
};
use http::Method;
use rustfs_filemeta::{FileMeta, TransitionVersionState};
use rustfs_utils::http::{SUFFIX_TRANSITION_TIER_DESTINATION_ID, get_consistent_str};
use serde::Serialize;
use serde_json::{Value, json};
use std::collections::BTreeSet;
use std::path::Path;
use std::time::Duration;
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
use tokio::time::{Instant, sleep, timeout, timeout_at};
const TIER: &str = "TOPOLOGYCOLD";
const KEY: &str = "000-tier-owner.bin";
const TRANSITION_TIMEOUT: Duration = Duration::from_secs(90);
// Free-version cleanup holds its locks for at most 30 s during remote DELETE.
// Fail before that deadline instead of confusing a retry with the held attempt.
const DELETE_OVERLAP_TIMEOUT: Duration = Duration::from_secs(20);
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
struct RemoteTuple {
bucket: String,
key: String,
version: String,
tier: String,
destination: String,
}
#[derive(Debug, PartialEq, Eq, Serialize)]
struct Owner {
pool: usize,
drive: usize,
version: String,
free: bool,
remote: RemoteTuple,
}
fn owners(run: &TopologyRun, bucket: &str, remote_bucket: &str) -> TestResult<Vec<Owner>> {
let mut owners = Vec::new();
for node in &run.dist.cluster.nodes {
for (drive, root) in node.data_dirs.iter().enumerate() {
let bytes = match std::fs::read(Path::new(root).join(bucket).join(KEY).join("xl.meta")) {
Ok(bytes) => bytes,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
Err(error) => return Err(error.into()),
};
for version in FileMeta::load(&bytes)?.get_file_info_versions(bucket, KEY, true)?.versions {
if version.transition_status != "complete" && !version.tier_free_version() {
continue;
}
assert_eq!(version.transition_version_state, TransitionVersionState::Exact);
owners.push(Owner {
pool: node.pool_idx,
drive,
version: version.version_id.ok_or("tier owner omitted its local version")?.to_string(),
free: version.tier_free_version(),
remote: RemoteTuple {
bucket: remote_bucket.to_string(),
key: version.transitioned_objname.clone(),
version: version
.transition_version
.clone()
.ok_or("tier owner omitted its remote version")?,
tier: version.transition_tier.clone(),
destination: get_consistent_str(&version.metadata, SUFFIX_TRANSITION_TIER_DESTINATION_ID)
.ok_or("tier owner omitted its destination identity")?
.to_string(),
},
});
}
}
}
Ok(owners)
}
fn assert_remote(target: &FakeS3Target, remote: &RemoteTuple, expected: &[u8]) -> TestResult {
assert_eq!(target.stored_versions(&remote.bucket, &remote.key), vec![(remote.version.clone(), false)]);
let (body, metadata) = target
.stored_object(&remote.bucket, &remote.key)
.ok_or("remote bytes are missing")?;
assert_eq!(body.as_ref(), expected, "remote bytes changed for {remote:?}");
assert_eq!(
get_consistent_str(&metadata, SUFFIX_TRANSITION_TIER_DESTINATION_ID),
Some(remote.destination.as_str())
);
assert_eq!(remote.tier, TIER);
Ok(())
}
async fn transition_version(
run: &TopologyRun,
target: &FakeS3Target,
bucket: &str,
remote_bucket: &str,
body: &[u8],
) -> TestResult<(String, RemoteTuple)> {
let client = run.dist.client(0)?;
let put = client
.put_object()
.bucket(bucket)
.key(KEY)
.body(ByteStream::from(body.to_vec()))
.send()
.await?;
let version = put.version_id().ok_or("versioned PUT omitted its version")?.to_string();
cluster_admin_ok(
&run.dist.cluster,
Method::POST,
&format!("/rustfs/admin/v3/ilm/transition/run?bucket={bucket}&prefix={KEY}&tier={TIER}&dryRun=false"),
None,
)
.await?;
let deadline = Instant::now() + TRANSITION_TIMEOUT;
loop {
let current = owners(run, bucket, remote_bucket)?;
let shards: Vec<_> = current
.iter()
.filter(|owner| owner.version == version && !owner.free)
.collect();
if let Some(first) = shards.first()
&& shards.len() == DRIVES_PER_NODE
&& shards
.iter()
.all(|owner| owner.pool == SEED_POOL && owner.remote == first.remote)
{
let head = client
.head_object()
.bucket(bucket)
.key(KEY)
.version_id(&version)
.send()
.await?;
assert_eq!(head.storage_class().map(|class| class.as_str()), Some(TIER));
assert_remote(target, &first.remote, body)?;
return Ok((version, first.remote.clone()));
}
if Instant::now() >= deadline {
return Err(format!("transition of {bucket}/{KEY}@{version} did not reach every seed drive: {current:?}").into());
}
sleep(Duration::from_millis(200)).await;
}
}
fn lock_events(run: &TopologyRun, bucket: &str) -> TestResult<Vec<Value>> {
let mut events = Vec::new();
for path in run.node_log_paths() {
let log = std::fs::read_to_string(path)?;
for line in log.lines() {
let Ok(row) = serde_json::from_str::<Value>(line) else {
continue;
};
let fields = row.get("fields").unwrap_or(&row);
if fields.get("event").and_then(Value::as_str) == Some("object_namespace_lock")
&& fields.get("op").and_then(Value::as_str) == Some("data_movement_publication")
&& fields.get("bucket").and_then(Value::as_str) == Some(bucket)
&& fields.get("object").and_then(Value::as_str) == Some(KEY)
{
let mut event = fields.clone();
event["timestamp"] = row["timestamp"].clone();
events.push(event);
}
}
}
Ok(events)
}
fn event_time(event: &Value) -> TestResult<OffsetDateTime> {
Ok(OffsetDateTime::parse(
event["timestamp"].as_str().ok_or("lock event omitted its timestamp")?,
&Rfc3339,
)?)
}
async fn pending_publication(run: &TopologyRun, bucket: &str, started: OffsetDateTime) -> TestResult<Value> {
loop {
let events = lock_events(run, bucket)?;
for event in &events {
if event_time(event)? < started {
continue;
}
assert_ne!(
event["state"], "acquired",
"mover acquired its publication lock while remote DELETE was held"
);
if event["state"] == "acquiring"
&& !events
.iter()
.any(|later| later["lock_attempt_id"] == event["lock_attempt_id"] && later["state"] != "acquiring")
{
assert!(event["lock_attempt_id"].as_str().is_some_and(|id| !id.is_empty()));
return Ok(event.clone());
}
}
sleep(Duration::from_millis(50)).await;
}
}
async fn acquired_publication(run: &TopologyRun, bucket: &str, pending: &Value) -> TestResult<Value> {
loop {
for event in lock_events(run, bucket)? {
if event["lock_attempt_id"] != pending["lock_attempt_id"] {
continue;
}
match event["state"].as_str() {
Some("acquired") => return Ok(event),
Some("failed" | "cancelled") => return Err(format!("held publication attempt did not acquire: {event}").into()),
_ => {}
}
}
sleep(Duration::from_millis(50)).await;
}
}
fn deletes(target: &FakeS3Target, remote: &RemoteTuple) -> Vec<RequestRecord> {
target
.requests()
.into_iter()
.filter(|request| {
request.operation == Operation::DeleteObject
&& request.bucket == remote.bucket
&& request.key.as_deref() == Some(remote.key.as_str())
})
.collect()
}
async fn verify_live(run: &TopologyRun, bucket: &str, live_version: &str, old_version: &str, body: &[u8]) -> TestResult {
for client in run.dist.clients()? {
let got = client
.get_object()
.bucket(bucket)
.key(KEY)
.version_id(live_version)
.send()
.await?;
assert_eq!(got.body.collect().await?.into_bytes().as_ref(), body);
let latest = client.head_object().bucket(bucket).key(KEY).send().await?;
assert_eq!(latest.version_id(), Some(live_version));
assert_eq!(latest.storage_class().map(|class| class.as_str()), Some(TIER));
let deleted = client
.get_object()
.bucket(bucket)
.key(KEY)
.version_id(old_version)
.send()
.await;
let error = deleted.expect_err("deleted source version must not be visible");
assert_eq!(error.raw_response().map(|response| response.status().as_u16()), Some(404));
let listed = client.list_object_versions().bucket(bucket).prefix(KEY).send().await?;
assert_eq!(listed.versions().len(), 1, "free owners must not appear as object versions");
assert_eq!(listed.versions()[0].version_id(), Some(live_version));
assert!(
listed.delete_markers().is_empty(),
"moving the free owner must not create a delete marker"
);
}
Ok(())
}
async fn tier_delete_during_movement(decommission: bool) -> TestResult {
init_logging();
require_test_hooks();
let operation = if decommission { "decommission" } else { "rebalance" };
let target = FakeS3Target::start().await?;
let remote_bucket = unique_bucket("sht-cold");
target.create_bucket(&remote_bucket);
let mut run = TopologyRun::start_with_env(
&format!("tier-{operation}"),
&[
("RUSTFS_TIER_RUSTFS_ALLOW_LOOPBACK_ENDPOINT", "true"),
("RUSTFS_OBJECT_LOCK_DIAG_ENABLE", "true"),
("RUST_LOG", OBJECT_LOCK_TRACE_FILTER),
],
)
.await?;
let tier = json!({
"type": "rustfs",
"rustfs": {
"name": TIER, "endpoint": target.endpoint(), "accessKey": FAKE_ACCESS_KEY,
"secretKey": FAKE_SECRET_KEY, "bucket": remote_bucket, "prefix": "",
"region": "us-east-1", "storageClass": ""
}
});
let deadline = Instant::now() + Duration::from_secs(30);
loop {
match cluster_admin_ok(&run.dist.cluster, Method::PUT, "/rustfs/admin/v3/tier", Some(tier.to_string())).await {
Ok(_) => break,
Err(_) if Instant::now() < deadline => sleep(Duration::from_millis(250)).await,
Err(error) => return Err(error),
}
}
let bucket = unique_bucket("sht-tier");
run.dist.create_bucket(&bucket).await?;
let client = run.dist.client(0)?;
enable_versioning(&client, &bucket).await?;
let rule = LifecycleRule::builder()
.id("tier-owner")
.status(ExpirationStatus::Enabled)
.filter(LifecycleRuleFilter::builder().prefix(KEY).build())
.transitions(
Transition::builder()
.days(0)
.storage_class(TransitionStorageClass::from(TIER))
.build(),
)
.build()?;
client
.put_bucket_lifecycle_configuration()
.bucket(&bucket)
.lifecycle_configuration(BucketLifecycleConfiguration::builder().rules(rule).build()?)
.send()
.await?;
let old_body = vec![0x31; 4096];
let live_body = vec![0x92; 8192];
let (old_version, old_remote) = transition_version(&run, &target, &bucket, &remote_bucket, &old_body).await?;
let (live_version, live_remote) = transition_version(&run, &target, &bucket, &remote_bucket, &live_body).await?;
assert_ne!(
old_remote.key, live_remote.key,
"the obsolete and live versions need distinct remote tuples"
);
// Keep the seed pool above the rebalance goal until the first key is visited.
// These local objects are outside the transition rule's prefix.
for index in 0..16 {
client
.put_object()
.bucket(&bucket)
.key(format!("zz-load/{index:02}.bin"))
.body(ByteStream::from(vec![index as u8; 1024 * 1024]))
.send()
.await?;
}
let bucket_durability = assert_bucket_durability_strict(&run.dist.cluster, &[&bucket]).await?;
run.dist.expand_to_four_pools().await?;
assert_bucket_durability_strict(&run.dist.cluster, &[&bucket]).await?;
let seeded = owners(&run, &bucket, &remote_bucket)?;
assert_eq!(seeded.len(), 2 * DRIVES_PER_NODE);
assert!(seeded.iter().all(|owner| owner.pool == SEED_POOL && !owner.free));
let mut decommission_start = Box::pin(start_decommission(&run.dist.cluster, DECOMMISSION_POOL_ID));
let mut startup = Value::Null;
if decommission {
run.movement_barrier.arm_movement(
"decommission_start",
".rustfs.sys",
"pool.bin",
STAGE_AFTER_RESPONSIBILITY_PERSISTED,
)?;
let requested_at = OffsetDateTime::now_utc();
let preparation_started = Instant::now();
let reached = tokio::select! {
reached = run.movement_barrier.wait_reached(MOVEMENT_COMPLETE_TIMEOUT) => {
reached.map_err(|error| format!(
"G-TIER decommission start preparation failed before DELETE: {error}"
))?
}
result = &mut decommission_start => {
return Err(format!(
"G-TIER decommission start preparation returned before durable-start barrier {}: {result:?}",
run.movement_barrier.nonce()
).into());
}
};
startup = json!({
"requested_at": requested_at.format(&Rfc3339)?,
"parked_at": OffsetDateTime::now_utc().format(&Rfc3339)?,
"preparation_elapsed_ms": preparation_started.elapsed().as_millis(),
"barrier": reached,
});
}
// The durable-start barrier has released the movement writer but has not
// started a worker. Install a separate release nonce before holding DELETE.
let cleanup_barrier = run.movement_barrier.next_phase();
cleanup_barrier.arm_movement(operation, &bucket, KEY, STAGE_BEFORE_SOURCE_CLEANUP)?;
let mut delete_gate = target.hold_delete_object(&remote_bucket, &old_remote.key);
// The public DELETE persists a free owner and enqueues its remote cleanup.
let client = run.dist.client(0)?;
client
.delete_object()
.bucket(&bucket)
.key(KEY)
.version_id(&old_version)
.send()
.await
.map_err(|error| format!("G-TIER {operation} public DELETE before remote gate failed: {error}; startup={startup}"))?;
timeout(TRANSITION_TIMEOUT, delete_gate.wait_until_entered())
.await
.map_err(|error| format!("G-TIER {operation} DELETE gate entry timed out: {error}; startup={startup}"))?
.map_err(|error| format!("G-TIER {operation} DELETE gate entry failed: {error}; startup={startup}"))?;
let delete_gate_entered_at = OffsetDateTime::now_utc();
let overlap_started = Instant::now();
let overlap_deadline = overlap_started + DELETE_OVERLAP_TIMEOUT;
let held = owners(&run, &bucket, &remote_bucket)?;
assert_eq!(held.len(), 2 * DRIVES_PER_NODE);
let old_owners: Vec<_> = held.iter().filter(|owner| owner.remote == old_remote).collect();
assert_eq!(old_owners.len(), DRIVES_PER_NODE);
assert!(
old_owners
.iter()
.all(|owner| owner.pool == SEED_POOL && owner.free && owner.version != old_version)
);
assert_eq!(old_owners.iter().map(|owner| &owner.version).collect::<BTreeSet<_>>().len(), 1);
assert_eq!(
held.iter()
.filter(|owner| {
owner.pool == SEED_POOL && !owner.free && owner.version == live_version && owner.remote == live_remote
})
.count(),
DRIVES_PER_NODE
);
assert_remote(&target, &old_remote, &old_body)?;
assert_remote(&target, &live_remote, &live_body)?;
let held_deletes = deletes(&target, &old_remote);
assert!(!held_deletes.is_empty());
assert!(held_deletes.iter().all(|request| {
request.prepared_response.is_none() && request.version_id.as_deref() == Some(old_remote.version.as_str())
}));
assert!(deletes(&target, &live_remote).is_empty());
let movement_started = OffsetDateTime::now_utc();
if decommission {
startup["released_at"] = json!(movement_started.format(&Rfc3339)?);
run.movement_barrier.release_movement()?;
}
let rebalance_id = timeout_at(overlap_deadline, async {
if decommission {
decommission_start.as_mut().await?;
Ok::<_, Box<dyn std::error::Error + Send + Sync>>(None)
} else {
Ok(Some(start_rebalance(&run.dist.cluster).await?))
}
})
.await
.map_err(|error| {
format!("G-TIER {operation} start acknowledgment timed out within DELETE overlap: {error}; startup={startup}")
})?
.map_err(|error| {
format!("G-TIER {operation} start acknowledgment failed within DELETE overlap: {error}; startup={startup}")
})?;
drop(decommission_start);
let movement_acknowledged_at = OffsetDateTime::now_utc();
let pending = timeout_at(overlap_deadline, pending_publication(&run, &bucket, movement_started))
.await
.map_err(|error| format!(
"G-TIER {operation} publication pending timed out within DELETE overlap: {error}; startup={startup}; lock_events={:?}; cleanup_barrier={:?}",
lock_events(&run, &bucket), cleanup_barrier.reached_observations()
))?
.map_err(|error| format!("G-TIER {operation} publication pending failed within DELETE overlap: {error}"))?;
// Positive gate entry, exact physical ownership and the pending lock attempt
// establish the overlap; an absent log alone is not an ordering assertion.
assert_eq!(owners(&run, &bucket, &remote_bucket)?, held);
assert_remote(&target, &old_remote, &old_body)?;
let released_at = OffsetDateTime::now_utc();
let delete_overlap_elapsed_ms = overlap_started.elapsed().as_millis();
drop(delete_gate);
let acquired = timeout(DELETE_OVERLAP_TIMEOUT, acquired_publication(&run, &bucket, &pending))
.await
.map_err(|error| format!(
"G-TIER {operation} publication acquired after DELETE release timed out: {error}; pending={pending}; lock_events={:?}; cleanup_barrier={:?}",
lock_events(&run, &bucket), cleanup_barrier.reached_observations()
))?
.map_err(|error| format!("G-TIER {operation} publication acquired after DELETE release failed: {error}"))?;
assert!(event_time(&pending)? >= movement_started && event_time(&pending)? < released_at);
assert!(
event_time(&acquired)? >= released_at,
"the same publication attempt must acquire after DELETE is released"
);
let reached = cleanup_barrier
.wait_reached_during(
&run.dist.cluster,
match rebalance_id.as_deref() {
Some(id) => MovementOperation::Rebalance(id),
None => MovementOperation::Decommission(DECOMMISSION_POOL_ID),
},
MOVEMENT_COMPLETE_TIMEOUT,
)
.await
.map_err(|error| format!("G-TIER {operation} before-source-cleanup barrier failed: {error}"))?;
let published = owners(&run, &bucket, &remote_bucket)?;
assert!(
published
.iter()
.all(|owner| !owner.free && owner.version == live_version && owner.remote == live_remote)
);
let publication_pools: BTreeSet<_> = published.iter().map(|owner| owner.pool).collect();
assert!(publication_pools.contains(&SEED_POOL) && publication_pools.iter().any(|pool| *pool != SEED_POOL));
assert_eq!(published.len(), publication_pools.len() * DRIVES_PER_NODE);
assert!(target.stored_versions(&remote_bucket, &old_remote.key).is_empty());
assert_remote(&target, &live_remote, &live_body)?;
cleanup_barrier.release_movement()?;
cleanup_barrier.disarm()?;
let completion = match rebalance_id.as_deref() {
Some(id) => {
wait_for_rebalance_complete(&run.dist.cluster, id, MOVEMENT_COMPLETE_TIMEOUT).await?;
rebalance_status_json(&run.dist.cluster).await?
}
None => {
wait_for_decommission_complete(&run.dist.cluster, DECOMMISSION_POOL_ID, MOVEMENT_COMPLETE_TIMEOUT).await?;
decommission_status_json(&run.dist.cluster).await?
}
};
let settled = owners(&run, &bucket, &remote_bucket)?;
assert_eq!(settled.len(), DRIVES_PER_NODE);
assert_eq!(settled.iter().map(|owner| owner.pool).collect::<BTreeSet<_>>().len(), 1);
assert!(
settled
.iter()
.all(|owner| owner.pool != SEED_POOL && !owner.free && owner.remote == live_remote)
);
verify_live(&run, &bucket, &live_version, &old_version, &live_body).await?;
if decommission {
run.dist.remove_retired_pools_and_restart(&[SEED_POOL]).await?;
} else {
run.dist.restart_current_binary_gracefully().await?;
}
verify_live(&run, &bucket, &live_version, &old_version, &live_body).await?;
let restarted = owners(&run, &bucket, &remote_bucket)?;
assert_eq!(restarted.len(), DRIVES_PER_NODE);
assert_eq!(restarted.iter().map(|owner| owner.pool).collect::<BTreeSet<_>>().len(), 1);
assert!(
restarted
.iter()
.all(|owner| !owner.free && owner.version == live_version && owner.remote == live_remote)
);
assert_remote(&target, &live_remote, &live_body)?;
assert!(target.stored_versions(&remote_bucket, &old_remote.key).is_empty());
assert!(
deletes(&target, &live_remote).is_empty(),
"the live transitioned tuple must never receive DELETE"
);
let remote_deletes = deletes(&target, &old_remote);
assert!(!remote_deletes.is_empty());
assert!(
remote_deletes
.iter()
.all(|request| request.version_id.as_deref() == Some(old_remote.version.as_str()))
);
let successful = remote_deletes
.iter()
.filter_map(|request| request.prepared_response.as_ref())
.find(|response| response.status == 204)
.ok_or("old remote tuple never completed DELETE")?;
let deleted_at_ms = i128::from(
successful
.prepared_at_unix_millis
.ok_or("DELETE response omitted its timestamp")?,
);
assert!(deleted_at_ms >= released_at.unix_timestamp_nanos() / 1_000_000);
assert!(event_time(&acquired)?.unix_timestamp_nanos() / 1_000_000 >= deleted_at_ms);
assert_bucket_durability_strict(&run.dist.cluster, &[&bucket]).await?;
let strict_nodes = assert_process_durability_strict(&run.node_log_paths())?;
run.write_evidence(
&format!("g-tier-{operation}"),
&json!({
"gate": "G-TIER", "operation": operation, "rebalance_id": rebalance_id,
"startup": startup,
"delete_gate_entered_at": delete_gate_entered_at.format(&Rfc3339)?,
"movement_overlap_started_at": movement_started.format(&Rfc3339)?,
"movement_acknowledged_at": movement_acknowledged_at.format(&Rfc3339)?,
"delete_overlap_timeout_ms": DELETE_OVERLAP_TIMEOUT.as_millis(),
"delete_overlap_elapsed_ms": delete_overlap_elapsed_ms,
"remote_backend": "HTTP fake_s3_target with versioning enabled",
"ordering": "remote DELETE held, mover publication waiting, DELETE released, same publication acquired",
"bucket": bucket, "key": KEY, "bucket_durability": bucket_durability,
"process_durability_strict_nodes": strict_nodes,
"old_version": old_version, "old_remote": old_remote, "old_sha256": sha256_hex(&old_body),
"live_version": live_version, "live_remote": live_remote, "live_sha256": sha256_hex(&live_body),
"owners_while_delete_held": held, "publication_waiting": pending, "publication_acquired": acquired,
"held_delete_sequences": held_deletes.iter().map(|request| request.sequence).collect::<Vec<_>>(),
"delete_released_at": released_at.format(&Rfc3339)?, "remote_delete_completed_at_unix_millis": deleted_at_ms,
"barrier": reached, "owners_before_source_cleanup": published, "owners_after_movement": settled,
"owners_after_restart": restarted, "completion": completion,
"old_remote_delete_attempts": remote_deletes.len(), "live_remote_delete_attempts": 0,
"source_readback_nodes": run.dist.cluster.nodes.len(),
}),
)?;
Ok(())
}
#[tokio::test]
async fn scanner_heal_topology_tier_free_owner_delete_overlaps_rebalance() -> TestResult {
tier_delete_during_movement(false).await
}
#[tokio::test]
async fn scanner_heal_topology_tier_free_owner_delete_overlaps_decommission() -> TestResult {
tier_delete_during_movement(true).await
}
@@ -60,7 +60,7 @@ struct UpgradeSeed {
iam_secret: &'static str,
}
fn source_binary() -> TestResult<PathBuf> {
pub(super) fn source_binary() -> TestResult<PathBuf> {
let path = std::env::var_os(SOURCE_BINARY_ENV).map(PathBuf::from).ok_or_else(|| {
format!(
"{SOURCE_BINARY_ENV} must point to the pinned previous release binary (the e2e-distributed workflow downloads it)"
@@ -1,512 +0,0 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! What one stack answered, and how two answers are compared.
//!
//! An [`Observation`] keeps only what a client acts on: the HTTP status, the S3 error code, the
//! allowlisted response headers and a few typed fields decoded from the response. The error
//! message is kept for diagnostics and never compared: message wording is judged by the wire
//! corpus, not here. The comparator is pure so it can be tested without a server.
use serde::Serialize;
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
/// Response headers compared exactly (after [`normalized_header_value`]).
const HEADER_ALLOWLIST: [&str; 7] = [
"content-type",
"x-amz-version-id",
"etag",
"x-amz-delete-marker",
"content-range",
"accept-ranges",
"x-amz-request-charged",
];
/// Every response header with this prefix is compared as well.
const HEADER_ALLOWLIST_PREFIX: &str = "x-amz-server-side-encryption";
/// Placeholder for a field one side did not send.
const ABSENT: &str = "<absent>";
/// The lowercase header name if the comparator looks at this header.
pub(crate) fn compared_header(name: &str) -> Option<String> {
let name = name.to_ascii_lowercase();
(HEADER_ALLOWLIST.contains(&name.as_str()) || name.starts_with(HEADER_ALLOWLIST_PREFIX)).then_some(name)
}
/// A version ID is minted per process, so its value never matches across two servers; only
/// whether it is the literal `null` (an unversioned write) carries meaning.
fn normalized_header_value(name: &str, value: &str) -> String {
if name == "x-amz-version-id" && value != "null" {
"<version-id>".to_owned()
} else {
value.to_owned()
}
}
/// One request's outcome as a client sees it.
#[derive(Clone, Debug, Serialize)]
pub(crate) struct Observation {
/// The S3 operation, named as in the `s3s::S3` trait (`GetObject`).
pub op: &'static str,
/// Which request of the script this was (`normal`, `negative`, or a setup label).
pub case: &'static str,
/// HTTP status; 0 when no response arrived.
pub status: u16,
/// The S3 error code, or the SDK's error kind when there was no service error.
pub error_code: Option<String>,
/// Allowlisted headers only, lowercase names, normalized values.
pub headers: BTreeMap<String, String>,
/// Typed values decoded from the response body, never free text.
pub fields: BTreeMap<String, String>,
/// Diagnostic only; never compared.
#[serde(skip)]
pub message: Option<String>,
}
impl Observation {
/// Builds an observation, keeping only the allowlisted headers.
pub(crate) fn new<'h>(
op: &'static str,
case: &'static str,
status: u16,
error_code: Option<String>,
headers: impl IntoIterator<Item = (&'h str, &'h str)>,
) -> Self {
let headers = headers
.into_iter()
.filter_map(|(name, value)| compared_header(name).map(|name| (name, value)))
.map(|(name, value)| {
let value = normalized_header_value(&name, value);
(name, value)
})
.collect();
Self {
op,
case,
status,
error_code,
headers,
fields: BTreeMap::new(),
message: None,
}
}
pub(crate) fn with_field(mut self, name: &str, value: impl Into<String>) -> Self {
self.fields.insert(name.to_owned(), value.into());
self
}
pub(crate) fn is_success(&self) -> bool {
(200..300).contains(&self.status)
}
}
/// One field on which the two stacks answered differently.
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub(crate) struct Difference {
pub op: String,
pub case: String,
pub field: String,
pub a: String,
pub b: String,
}
/// Every difference between two transcripts of the same script, matched by `(op, case)`.
pub(crate) fn compare(a: &[Observation], b: &[Observation]) -> Vec<Difference> {
let mut differences = Vec::new();
let (index_a, duplicates_a) = index(a);
let (index_b, duplicates_b) = index(b);
for (op, case) in duplicates_a.union(&duplicates_b) {
let count = |side: &[Observation]| {
side.iter()
.filter(|observation| (observation.op, observation.case) == (*op, *case))
.count()
.to_string()
};
differences.push(difference(op, case, "duplicate_case", count(a), count(b)));
}
let keys: BTreeSet<_> = index_a.keys().chain(index_b.keys()).copied().collect();
for key @ (op, case) in keys {
match (index_a.get(&key), index_b.get(&key)) {
(Some(a), Some(b)) => compare_one(a, b, &mut differences),
(a, b) => {
let presence = |side: Option<&&Observation>| if side.is_some() { "present" } else { ABSENT }.to_owned();
differences.push(difference(op, case, "observation", presence(a), presence(b)));
}
}
}
differences
}
type Key = (&'static str, &'static str);
fn index(side: &[Observation]) -> (BTreeMap<Key, &Observation>, BTreeSet<Key>) {
let mut index = BTreeMap::new();
let mut duplicates = BTreeSet::new();
for observation in side {
let key = (observation.op, observation.case);
if index.insert(key, observation).is_some() {
duplicates.insert(key);
}
}
(index, duplicates)
}
fn difference(op: &str, case: &str, field: &str, a: String, b: String) -> Difference {
Difference {
op: op.to_owned(),
case: case.to_owned(),
field: field.to_owned(),
a,
b,
}
}
fn compare_one(a: &Observation, b: &Observation, differences: &mut Vec<Difference>) {
let mut field = |name: String, value_a: Option<&str>, value_b: Option<&str>| {
if value_a != value_b {
let show = |value: Option<&str>| value.unwrap_or(ABSENT).to_owned();
differences.push(difference(a.op, a.case, &name, show(value_a), show(value_b)));
}
};
field(
"status".to_owned(),
Some(a.status.to_string().as_str()),
Some(b.status.to_string().as_str()),
);
field("error_code".to_owned(), a.error_code.as_deref(), b.error_code.as_deref());
let headers: BTreeSet<_> = a.headers.keys().chain(b.headers.keys()).collect();
for name in headers {
field(
format!("header.{name}"),
a.headers.get(name).map(String::as_str),
b.headers.get(name).map(String::as_str),
);
}
let fields: BTreeSet<_> = a.fields.keys().chain(b.fields.keys()).collect();
for name in fields {
field(
format!("body.{name}"),
a.fields.get(name).map(String::as_str),
b.fields.get(name).map(String::as_str),
);
}
}
/// Operations observed on both sides.
pub(crate) fn compared_ops(a: &[Observation], b: &[Observation]) -> BTreeSet<&'static str> {
let ops_b: BTreeSet<_> = b.iter().map(|observation| observation.op).collect();
a.iter()
.map(|observation| observation.op)
.filter(|op| ops_b.contains(op))
.collect()
}
/// `(op, case)` pairs observed on both sides.
pub(crate) fn compared_cases(a: &[Observation], b: &[Observation]) -> usize {
let cases_b: BTreeSet<_> = b.iter().map(|observation| (observation.op, observation.case)).collect();
a.iter()
.map(|observation| (observation.op, observation.case))
.collect::<BTreeSet<_>>()
.intersection(&cases_b)
.count()
}
/// The JSON report written for every run.
#[derive(Debug, Serialize)]
pub(crate) struct Report {
pub schema: u32,
pub run: String,
pub stack_a: String,
pub stack_b: String,
pub region_a: Option<String>,
pub region_b: Option<String>,
pub compared_ops: usize,
pub compared_cases: usize,
pub differences: Vec<Difference>,
}
impl Report {
/// Writes the report as `<directory>/<run>.json` and returns its path.
pub(crate) fn write_to(&self, directory: &Path) -> std::io::Result<PathBuf> {
std::fs::create_dir_all(directory)?;
let path = directory.join(format!("{}.json", self.run));
let json = serde_json::to_vec_pretty(self).map_err(std::io::Error::other)?;
std::fs::write(&path, json)?;
Ok(path)
}
}
/// One line per difference, for a failing assertion.
pub(crate) fn render(differences: &[Difference]) -> String {
differences
.iter()
.map(|d| format!("({}, {}, {}, a={:?}, b={:?})", d.op, d.case, d.field, d.a, d.b))
.collect::<Vec<_>>()
.join("\n")
}
#[cfg(test)]
mod tests {
use super::*;
fn ok(op: &'static str) -> Observation {
Observation::new(op, "normal", 200, None, [("content-type", "application/xml")])
}
fn fields(differences: &[Difference]) -> Vec<&str> {
differences.iter().map(|d| d.field.as_str()).collect()
}
#[test]
fn identical_transcripts_have_no_difference() {
let a = vec![
ok("HeadBucket"),
ok("GetBucketLocation").with_field("location_constraint", "us-east-1"),
];
let b = a.clone();
assert_eq!(compare(&a, &b), Vec::new());
assert_eq!(compared_ops(&a, &b).len(), 2);
}
#[test]
fn a_status_difference_is_reported() {
let a = vec![ok("PutBucketAcl")];
let b = vec![Observation::new(
"PutBucketAcl",
"normal",
403,
None,
[("content-type", "application/xml")],
)];
assert_eq!(
compare(&a, &b),
vec![Difference {
op: "PutBucketAcl".into(),
case: "normal".into(),
field: "status".into(),
a: "200".into(),
b: "403".into(),
}]
);
}
#[test]
fn an_error_code_difference_is_reported() {
let a = vec![Observation::new("GetObject", "negative", 404, Some("NoSuchKey".into()), [])];
let b = vec![Observation::new(
"GetObject",
"negative",
404,
Some("NoSuchBucket".into()),
[],
)];
let differences = compare(&a, &b);
assert_eq!(fields(&differences), ["error_code"]);
assert_eq!((differences[0].a.as_str(), differences[0].b.as_str()), ("NoSuchKey", "NoSuchBucket"));
}
#[test]
fn an_error_code_on_one_side_only_is_reported() {
let a = vec![Observation::new("HeadBucket", "negative", 404, Some("NotFound".into()), [])];
let b = vec![Observation::new("HeadBucket", "negative", 404, None, [])];
let differences = compare(&a, &b);
assert_eq!(fields(&differences), ["error_code"]);
assert_eq!(differences[0].b, ABSENT);
}
#[test]
fn an_allowlisted_header_difference_is_reported() {
let a = vec![Observation::new("HeadObject", "normal", 200, None, [("ETag", "\"aa\"")])];
let b = vec![Observation::new("HeadObject", "normal", 200, None, [("etag", "\"bb\"")])];
assert_eq!(fields(&compare(&a, &b)), ["header.etag"]);
}
#[test]
fn a_header_sent_by_one_side_only_is_reported() {
let a = vec![Observation::new(
"DeleteObject",
"normal",
204,
None,
[("x-amz-delete-marker", "true")],
)];
let b = vec![Observation::new("DeleteObject", "normal", 204, None, [])];
let differences = compare(&a, &b);
assert_eq!(fields(&differences), ["header.x-amz-delete-marker"]);
assert_eq!((differences[0].a.as_str(), differences[0].b.as_str()), ("true", ABSENT));
}
#[test]
fn every_server_side_encryption_header_is_compared() {
let header = "x-amz-server-side-encryption-customer-key-md5";
let a = vec![Observation::new("PutObject", "sse-c", 200, None, [(header, "one")])];
let b = vec![Observation::new("PutObject", "sse-c", 200, None, [(header, "two")])];
assert_eq!(fields(&compare(&a, &b)), [format!("header.{header}")]);
}
#[test]
fn headers_outside_the_allowlist_are_not_compared() {
let a = vec![Observation::new(
"ListBuckets",
"normal",
200,
None,
[
("date", "Mon"),
("x-amz-request-id", "1"),
("server", "a"),
("content-length", "10"),
],
)];
let b = vec![Observation::new(
"ListBuckets",
"normal",
200,
None,
[
("date", "Tue"),
("x-amz-request-id", "2"),
("server", "b"),
("content-length", "11"),
],
)];
assert_eq!(compare(&a, &b), Vec::new());
assert!(a[0].headers.is_empty(), "non-allowlisted headers were kept: {:?}", a[0].headers);
}
#[test]
fn distinct_version_ids_are_not_a_difference() {
let a = vec![Observation::new(
"PutObject",
"normal",
200,
None,
[("x-amz-version-id", "0b1c")],
)];
let b = vec![Observation::new(
"PutObject",
"normal",
200,
None,
[("x-amz-version-id", "9f8e")],
)];
assert_eq!(compare(&a, &b), Vec::new());
}
#[test]
fn a_null_version_id_against_a_real_one_is_reported() {
let a = vec![Observation::new(
"PutObject",
"normal",
200,
None,
[("x-amz-version-id", "null")],
)];
let b = vec![Observation::new(
"PutObject",
"normal",
200,
None,
[("x-amz-version-id", "9f8e")],
)];
assert_eq!(fields(&compare(&a, &b)), ["header.x-amz-version-id"]);
}
#[test]
fn a_body_field_difference_is_reported() {
let a = vec![ok("GetBucketLocation").with_field("location_constraint", "us-east-1")];
let b = vec![ok("GetBucketLocation").with_field("location_constraint", "eu-west-1")];
let differences = compare(&a, &b);
assert_eq!(fields(&differences), ["body.location_constraint"]);
assert_eq!((differences[0].a.as_str(), differences[0].b.as_str()), ("us-east-1", "eu-west-1"));
}
#[test]
fn a_body_field_on_one_side_only_is_reported() {
let a = vec![ok("ListObjectsV2").with_field("keys", "[]")];
let b = vec![ok("ListObjectsV2")];
assert_eq!(fields(&compare(&a, &b)), ["body.keys"]);
}
#[test]
fn error_messages_are_never_compared() {
let mut a = Observation::new("GetObject", "negative", 404, Some("NoSuchKey".into()), []);
let mut b = a.clone();
a.message = Some("The specified key does not exist.".into());
b.message = Some("Object not found".into());
assert_eq!(compare(&[a], &[b]), Vec::new());
}
#[test]
fn a_case_observed_on_one_side_only_is_reported() {
let a = vec![ok("HeadBucket"), ok("ListBuckets")];
let b = vec![ok("HeadBucket")];
let differences = compare(&a, &b);
assert_eq!(fields(&differences), ["observation"]);
assert_eq!(differences[0].op, "ListBuckets");
assert_eq!(compared_ops(&a, &b).into_iter().collect::<Vec<_>>(), ["HeadBucket"]);
}
#[test]
fn a_case_recorded_twice_is_reported() {
let a = vec![ok("HeadBucket"), ok("HeadBucket")];
let b = vec![ok("HeadBucket")];
assert_eq!(fields(&compare(&a, &b)), ["duplicate_case"]);
}
#[test]
fn compared_ops_counts_operations_not_cases() {
let negative = Observation::new("HeadBucket", "negative", 404, Some("NotFound".into()), []);
let a = vec![ok("HeadBucket"), negative, ok("ListBuckets")];
let b = a.clone();
assert_eq!(compared_ops(&a, &b).len(), 2);
}
#[test]
fn the_report_lists_each_difference_as_op_field_a_b() {
let directory = std::env::temp_dir().join(format!("dual-stack-report-{}", uuid::Uuid::new_v4()));
let report = Report {
schema: 1,
run: "unit".into(),
stack_a: "legacy".into(),
stack_b: "legacy".into(),
region_a: None,
region_b: Some("eu-west-1".into()),
compared_ops: 1,
compared_cases: 1,
differences: compare(
&[ok("GetBucketLocation").with_field("location_constraint", "us-east-1")],
&[ok("GetBucketLocation").with_field("location_constraint", "eu-west-1")],
),
};
let path = report.write_to(&directory).expect("write the report");
let json: serde_json::Value = serde_json::from_slice(&std::fs::read(&path).expect("read the report")).expect("parse");
let _ = std::fs::remove_dir_all(&directory);
assert_eq!(path.file_name().and_then(|name| name.to_str()), Some("unit.json"));
assert_eq!(json["compared_ops"], 1);
assert_eq!(
json["differences"],
serde_json::json!([{
"op": "GetBucketLocation",
"case": "normal",
"field": "body.location_constraint",
"a": "us-east-1",
"b": "eu-west-1",
}])
);
}
}
@@ -1,199 +0,0 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Dual-stack differential: one `rustfs` binary, two processes, one SDK script (rustfs/backlog#2740).
//!
//! Stack A always runs `RUSTFS_S3_STACK=legacy`. Stack B runs the value of `RUSTFS_E2E_STACK_B`
//! (default `legacy` until the gateway stack is assembled), and `RUSTFS_E2E_STACK_B_REGION`, when
//! set, starts B in another region so that a difference can be injected by hand. Both servers
//! answer the script in `scenarios`, which covers every operation of `impl S3 for FS`; `compare`
//! reports each `(op, field, a, b)` on which the answers differ, and every run writes its report
//! to `<target>/dual-stack/<run>.json`. Two processes, not two embedded servers: bucket metadata
//! is process-global, so two stacks in one process would read each other's buckets.
mod compare;
mod scenarios;
use crate::common::{RustFSTestEnvironment, init_logging, workspace_root};
use compare::{Difference, Observation, Report, compare, compared_cases, compared_ops, render};
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
type BoxError = Box<dyn std::error::Error + Send + Sync>;
const STACK_A: &str = "legacy";
const ENV_STACK_B: &str = "RUSTFS_E2E_STACK_B";
const ENV_STACK_B_REGION: &str = "RUSTFS_E2E_STACK_B_REGION";
/// Becomes `gateway` once rustfs/backlog#2734 T2.5 assembles the gateway stack.
const DEFAULT_STACK_B: &str = "legacy";
/// The region the negative control gives stack A explicitly, so an inherited `RUSTFS_REGION`
/// cannot erase the injected difference. Not `us-east-1`: that region answers GetBucketLocation
/// with an empty LocationConstraint and re-creating an owned bucket with 200, so it would not
/// report its own name and would add CreateBucket differences to the control.
const CONTROL_REGION_A: &str = "us-west-2";
const CONTROL_REGION_B: &str = "eu-west-1";
/// `normal` cases the legacy stack must answer with a 2xx. The comparison alone cannot tell a
/// script that exercises real behavior from one in which both stacks fail the same way. Five of
/// the 73 fail on legacy by design of this script: PutBucketReplication (no registered remote
/// target), GetBucketReplication (so no configuration), PutBucketNotificationConfiguration (no
/// notification target configured), RestoreObject (the object is not archived) and
/// GetObjectTorrent (legacy answers `NoSuchKey`).
const MIN_NORMAL_SUCCESSES: usize = 68;
struct PairConfig {
stack_b: String,
region_a: Option<String>,
region_b: Option<String>,
}
impl PairConfig {
fn from_env() -> Self {
Self {
stack_b: std::env::var(ENV_STACK_B).unwrap_or_else(|_| DEFAULT_STACK_B.to_owned()),
region_a: None,
region_b: std::env::var(ENV_STACK_B_REGION).ok(),
}
}
}
/// Starts one server on `stack`, overriding its region only when asked.
async fn start_stack(stack: &str, region: Option<&str>) -> Result<RustFSTestEnvironment, BoxError> {
let mut env = RustFSTestEnvironment::new().await?;
let mut vars = vec![("RUSTFS_S3_STACK", stack)];
if let Some(region) = region {
vars.push(("RUSTFS_REGION", region));
}
env.start_rustfs_server_without_cleanup_with_env(&vars).await?;
Ok(env)
}
/// Starts stack A and stack B side by side, each with its own port and data directory.
async fn spawn_pair(config: &PairConfig) -> Result<(RustFSTestEnvironment, RustFSTestEnvironment), BoxError> {
tokio::try_join!(
start_stack(STACK_A, config.region_a.as_deref()),
start_stack(&config.stack_b, config.region_b.as_deref())
)
}
fn report_directory() -> PathBuf {
let target = std::env::var_os("CARGO_TARGET_DIR").map_or_else(|| PathBuf::from("target"), PathBuf::from);
let target = if target.is_absolute() {
target
} else {
workspace_root().join(target)
};
target.join("dual-stack")
}
struct Run {
report: Report,
path: PathBuf,
a: Vec<Observation>,
}
/// Runs the script against both stacks concurrently and writes the report.
async fn run_differential(label: &str, config: &PairConfig) -> Run {
let (server_a, server_b) = spawn_pair(config).await.expect("start stack A and stack B");
let (a, b) = tokio::join!(Box::pin(scenarios::run(&server_a)), Box::pin(scenarios::run(&server_b)));
let millis = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |elapsed| elapsed.as_millis());
let report = Report {
schema: 1,
run: format!("{label}-{millis}-{}", std::process::id()),
stack_a: STACK_A.to_owned(),
stack_b: config.stack_b.clone(),
region_a: config.region_a.clone(),
region_b: config.region_b.clone(),
compared_ops: compared_ops(&a, &b).len(),
compared_cases: compared_cases(&a, &b),
differences: compare(&a, &b),
};
let path = report.write_to(&report_directory()).expect("write the dual-stack report");
Run { report, path, a }
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn both_stacks_answer_every_operation_identically() {
init_logging();
let config = PairConfig::from_env();
let run = run_differential("self-comparison", &config).await;
let report = &run.report;
let mut observed: Vec<_> = run.a.iter().map(|observation| observation.op).collect();
observed.sort_unstable();
observed.dedup();
assert_eq!(
observed,
scenarios::OPERATIONS,
"the script must drive exactly the operations of impl S3 for FS"
);
assert_eq!(report.compared_ops, scenarios::OPERATIONS.len(), "report: {}", run.path.display());
assert!(
report.differences.is_empty(),
"stack {} and stack {} differ on {} of {} cases (report: {}):\n{}",
report.stack_a,
report.stack_b,
report.differences.len(),
report.compared_cases,
run.path.display(),
render(&report.differences)
);
let not_4xx: Vec<_> = run
.a
.iter()
.filter(|observation| observation.case == "negative" && !(400..500).contains(&observation.status))
.collect();
assert!(not_4xx.is_empty(), "negative cases must fail with a 4xx on {STACK_A}: {not_4xx:#?}");
let successes = run
.a
.iter()
.filter(|observation| observation.case == "normal" && observation.is_success())
.count();
assert!(
successes >= MIN_NORMAL_SUCCESSES,
"only {successes} normal cases succeeded on {STACK_A}: {:#?}",
run.a
);
}
#[tokio::test]
async fn differential_detects_an_injected_difference() {
init_logging();
let config = PairConfig {
stack_b: STACK_A.to_owned(),
region_a: Some(CONTROL_REGION_A.to_owned()),
region_b: Some(CONTROL_REGION_B.to_owned()),
};
let run = run_differential("injected-region", &config).await;
let expected = Difference {
op: "GetBucketLocation".to_owned(),
case: "normal".to_owned(),
field: "body.location_constraint".to_owned(),
a: CONTROL_REGION_A.to_owned(),
b: CONTROL_REGION_B.to_owned(),
};
assert!(
run.report.differences.contains(&expected),
"B started in {CONTROL_REGION_B} but GetBucketLocation was not reported (report: {}):\n{}",
run.path.display(),
render(&run.report.differences)
);
}
}
File diff suppressed because it is too large Load Diff
+108 -14
View File
@@ -487,36 +487,43 @@ impl JournaledHeaders {
struct ControlState {
scripts: HashMap<Operation, VecDeque<FaultAction>>,
keyed_scripts: HashMap<(Operation, String), VecDeque<FaultAction>>,
held_get: Option<HeldGetObject>,
held_get: Option<HeldObject>,
held_delete: Option<HeldObject>,
requests: VecDeque<RequestRecord>,
next_sequence: u64,
}
#[derive(Clone)]
struct HeldGetObject {
struct HeldObject {
bucket: String,
key: String,
entered: watch::Sender<usize>,
released: watch::Receiver<bool>,
}
/// Holds every GET of one object, including retries, until this guard is dropped.
#[must_use = "dropping the guard releases the held GET requests"]
pub struct GetObjectGate {
/// Holds every matching operation, including retries, until this guard is dropped.
#[must_use = "dropping the guard releases the held requests"]
pub struct ObjectGate {
control: Arc<Mutex<ControlState>>,
operation: Operation,
entered: watch::Receiver<usize>,
released: watch::Sender<bool>,
}
impl GetObjectGate {
impl ObjectGate {
pub async fn wait_until_entered(&mut self) -> Result<(), watch::error::RecvError> {
self.entered.wait_for(|count| *count > 0).await.map(|_| ())
}
}
impl Drop for GetObjectGate {
impl Drop for ObjectGate {
fn drop(&mut self) {
lock(&self.control).held_get = None;
let mut state = lock(&self.control);
match self.operation {
Operation::GetObject => state.held_get = None,
Operation::DeleteObject => state.held_delete = None,
_ => unreachable!("only GET and DELETE have object gates"),
}
self.released.send_replace(true);
}
}
@@ -1108,24 +1115,40 @@ impl FakeS3Target {
}
/// Hold one exact bucket/key before any GET response can reach the client.
/// The fixture supports one live gate; request and connection deadlines still apply.
pub fn hold_get_object(&self, bucket: &str, key: &str) -> GetObjectGate {
/// The fixture supports one live GET gate; request and connection deadlines still apply.
pub fn hold_get_object(&self, bucket: &str, key: &str) -> ObjectGate {
self.hold_object_operation(Operation::GetObject, bucket, key)
}
/// Hold one exact bucket/key before DELETE changes any stored version.
/// The fixture supports one live DELETE gate; request deadlines still apply.
pub fn hold_delete_object(&self, bucket: &str, key: &str) -> ObjectGate {
self.hold_object_operation(Operation::DeleteObject, bucket, key)
}
fn hold_object_operation(&self, operation: Operation, bucket: &str, key: &str) -> ObjectGate {
assert!(
bucket.len() <= MAX_RETAINED_IDENTIFIER_BYTES && key.len() <= MAX_RETAINED_IDENTIFIER_BYTES,
"held GET identifiers exceed the fixture limit"
"held object identifiers exceed the fixture limit"
);
let mut state = lock(&self.control);
assert!(state.held_get.is_none(), "fake target already holds a GET gate");
let slot = match operation {
Operation::GetObject => &mut state.held_get,
Operation::DeleteObject => &mut state.held_delete,
_ => unreachable!("only GET and DELETE have object gates"),
};
assert!(slot.is_none(), "fake target already holds a {operation:?} gate");
let (entered, entered_rx) = watch::channel(0);
let (released, released_rx) = watch::channel(false);
state.held_get = Some(HeldGetObject {
*slot = Some(HeldObject {
bucket: bucket.to_string(),
key: key.to_string(),
entered,
released: released_rx,
});
GetObjectGate {
ObjectGate {
control: Arc::clone(&self.control),
operation,
entered: entered_rx,
released,
}
@@ -2795,6 +2818,15 @@ impl S3 for FakeBackend {
apply_non_body_fault(fault.as_ref(), &self.control).await?;
let headers = req.headers;
let input = req.input;
let held_delete = lock(&self.control)
.held_delete
.as_ref()
.filter(|held| held.bucket == input.bucket && held.key == input.key)
.cloned();
if let Some(mut held) = held_delete {
held.entered.send_modify(|count| *count += 1);
let _ = held.released.wait_for(|released| *released).await;
}
let mut state = lock(&self.store);
let Some(versioned) = state.buckets.get(&input.bucket).map(|bucket| bucket.versioned) else {
return Err(s3s::s3_error!(NoSuchBucket, "bucket does not exist"));
@@ -3436,6 +3468,68 @@ mod tests {
Ok(())
}
#[tokio::test]
async fn delete_object_gate_retains_exact_version_until_release() -> Result<(), BoxError> {
let target = FakeS3Target::start().await?;
let bucket = "delete-gated-target";
target.create_bucket(bucket);
for key in ["held", "unrelated"] {
target.put_seed_object(bucket, key, Bytes::from_static(b"payload"), &SeedMetadata::default());
}
let versions = target.stored_versions(bucket, "held");
let mut gate = target.hold_delete_object(bucket, "held");
let client = client(&target);
let held_client = client.clone();
let version = versions[0].0.clone();
let mut requests = tokio::task::JoinSet::new();
requests.spawn(async move {
held_client
.delete_object()
.bucket(bucket)
.key("held")
.version_id(version)
.send()
.await
});
timeout(Duration::from_secs(2), gate.wait_until_entered()).await??;
assert_eq!(
target.stored_versions(bucket, "held"),
versions,
"the gate must precede the storage mutation"
);
assert!(requests.try_join_next().is_none());
let held = target
.requests()
.into_iter()
.find(|record| record.operation == Operation::DeleteObject && record.key.as_deref() == Some("held"))
.expect("the held DELETE must be journaled");
assert_eq!(held.version_id.as_deref(), Some(versions[0].0.as_str()));
assert_eq!(held.prepared_response, None);
client
.delete_object()
.bucket(bucket)
.key("unrelated")
.version_id(&target.stored_versions(bucket, "unrelated")[0].0)
.send()
.await?;
assert!(target.stored_versions(bucket, "unrelated").is_empty());
assert_eq!(target.stored_versions(bucket, "held"), versions);
drop(gate);
timeout(Duration::from_secs(2), requests.join_next())
.await?
.expect("held DELETE task")??;
assert!(target.stored_versions(bucket, "held").is_empty());
let released = target
.requests()
.into_iter()
.find(|record| record.sequence == held.sequence)
.and_then(|record| record.prepared_response)
.expect("released DELETE response");
assert_eq!(released.status, 204);
assert_eq!(released.version_id.as_deref(), Some(versions[0].0.as_str()));
Ok(())
}
#[tokio::test]
async fn object_lock_target_requires_a_checksum_on_locked_puts() -> Result<(), BoxError> {
use aws_sdk_s3::error::ProvideErrorMetadata;
@@ -21,7 +21,7 @@ mod tests {
};
use crate::common::{
ClusterTopology, FAST_DATA_USAGE_SCANNER_ENV, RustFSTestClusterEnvironment, RustFSTestEnvironment, admin_request,
init_logging, rustfs_binary_path,
init_logging, local_http_client, rustfs_binary_path,
};
use crate::storage_api::RUSTFS_META_BUCKET;
use aws_sdk_s3::{
@@ -53,6 +53,196 @@ mod tests {
expected_pool_metadata: Option<VersionShardCensus>,
}
fn pool_metadata_matches_republished_manifest(actual: &VersionShardCensus, expected: &VersionShardCensus) -> bool {
for data_dir in [&actual.data_dir, &expected.data_dir] {
if data_dir
.as_deref()
.and_then(|value| uuid::Uuid::parse_str(value).ok())
.is_none_or(|value| value.is_nil())
{
return false;
}
}
// The owning CAS republishes the same canonical payload with a fresh
// physical identity. Every shard and layout field must still match.
let mut republished = actual.clone();
republished.data_dir = expected.data_dir.clone();
republished.matches_manifest(expected)
}
fn pool_metadata_node_health_is_ready(status: u16, payload: &serde_json::Value) -> bool {
(200..300).contains(&status)
&& payload["ready"] == true
&& payload["details"]["poolMetadata"]["ready"] == true
&& payload["details"]["storage"]["readQuorum"] == true
&& payload["details"]["storage"]["writeQuorum"] == true
}
async fn all_nodes_pool_metadata_readiness(
cluster: &RustFSTestClusterEnvironment,
deadline: Instant,
) -> (bool, serde_json::Value) {
let http = local_http_client();
let mut ready = !cluster.nodes.is_empty();
let mut observations = Vec::with_capacity(cluster.nodes.len());
for (node, environment) in cluster.nodes.iter().enumerate() {
let response = tokio::time::timeout_at(deadline, async {
let response = http
.get(format!("{}/health/ready", environment.url))
.timeout(Duration::from_secs(3))
.send()
.await?;
let status = response.status().as_u16();
let payload = response.json::<serde_json::Value>().await?;
Ok::<_, reqwest::Error>((status, payload))
})
.await;
let observation = match response {
Ok(Ok((status, payload))) => {
let node_ready = pool_metadata_node_health_is_ready(status, &payload);
ready &= node_ready;
serde_json::json!({"node": node, "status": status, "ready": node_ready, "payload": payload})
}
Ok(Err(error)) => {
ready = false;
serde_json::json!({"node": node, "error": error.to_string()})
}
Err(_) => {
ready = false;
serde_json::json!({"node": node, "error": "pool metadata health deadline elapsed"})
}
};
observations.push(observation);
}
let deadline_elapsed = Instant::now() >= deadline;
(
ready && !deadline_elapsed,
serde_json::json!({"deadline_elapsed": deadline_elapsed, "nodes": observations}),
)
}
fn pool_metadata_manifest_fixture() -> VersionShardCensus {
VersionShardCensus {
version_id: None,
has_xl_meta: true,
data_dir: Some(uuid::Uuid::from_u128(1).to_string()),
erasure_index: Some(1),
erasure_distribution: Some(vec![1, 2, 3, 4]),
data_blocks: Some(2),
parity_blocks: Some(2),
expected_part_numbers: Default::default(),
present_part_fingerprints: Default::default(),
inline_data_fingerprint: Some(crate::chaos::PartShardFingerprint {
size: 7,
sha256: sha256_hex(b"payload"),
}),
}
}
#[test]
fn pool_metadata_republication_allows_only_valid_data_dir_change() {
let expected = pool_metadata_manifest_fixture();
let mut actual = expected.clone();
actual.data_dir = Some(uuid::Uuid::from_u128(2).to_string());
assert!(pool_metadata_matches_republished_manifest(&actual, &expected));
assert!(
!actual.matches_manifest(&expected),
"ordinary object manifests must retain their original identity"
);
for invalid in [
None,
Some(String::new()),
Some("invalid".to_string()),
Some(uuid::Uuid::nil().to_string()),
] {
let mut changed = actual.clone();
changed.data_dir = invalid.clone();
assert!(!pool_metadata_matches_republished_manifest(&changed, &expected));
let mut changed_expected = expected.clone();
changed_expected.data_dir = invalid;
assert!(!pool_metadata_matches_republished_manifest(&actual, &changed_expected));
}
let mut changed = actual.clone();
changed
.inline_data_fingerprint
.as_mut()
.expect("fixture has inline payload")
.sha256 = sha256_hex(b"corrupt");
assert!(!pool_metadata_matches_republished_manifest(&changed, &expected));
let mut changed = actual.clone();
changed.erasure_index = Some(2);
assert!(!pool_metadata_matches_republished_manifest(&changed, &expected));
let mut changed = actual.clone();
changed.erasure_distribution = Some(vec![2, 1, 3, 4]);
assert!(!pool_metadata_matches_republished_manifest(&changed, &expected));
let mut changed = actual.clone();
changed.data_blocks = Some(1);
assert!(!pool_metadata_matches_republished_manifest(&changed, &expected));
let mut changed = actual.clone();
changed.parity_blocks = Some(3);
assert!(!pool_metadata_matches_republished_manifest(&changed, &expected));
let mut changed = actual.clone();
changed.version_id = Some(uuid::Uuid::from_u128(3).to_string());
assert!(!pool_metadata_matches_republished_manifest(&changed, &expected));
let mut changed = actual;
changed.has_xl_meta = false;
assert!(!pool_metadata_matches_republished_manifest(&changed, &expected));
}
#[test]
fn pool_metadata_republication_requires_exact_complete_part_fingerprints() {
let mut expected = pool_metadata_manifest_fixture();
expected.inline_data_fingerprint = None;
expected.expected_part_numbers.insert(1);
expected.present_part_fingerprints.insert(
1,
crate::chaos::PartShardFingerprint {
size: 7,
sha256: sha256_hex(b"payload"),
},
);
let mut actual = expected.clone();
actual.data_dir = Some(uuid::Uuid::from_u128(2).to_string());
assert!(pool_metadata_matches_republished_manifest(&actual, &expected));
let mut changed = actual.clone();
changed.present_part_fingerprints.remove(&1);
assert!(!pool_metadata_matches_republished_manifest(&changed, &expected));
let mut changed = actual;
changed
.present_part_fingerprints
.get_mut(&1)
.expect("fixture has part 1")
.sha256 = sha256_hex(b"corrupt");
assert!(!pool_metadata_matches_republished_manifest(&changed, &expected));
}
#[test]
fn pool_metadata_node_health_requires_explicit_metadata_and_both_quorums() {
let expected = serde_json::json!({"ready": true, "details": {
"poolMetadata": {"ready": true},
"storage": {"readQuorum": true, "writeQuorum": true}
}});
assert!(pool_metadata_node_health_is_ready(200, &expected));
assert!(!pool_metadata_node_health_is_ready(503, &expected));
assert!(!pool_metadata_node_health_is_ready(200, &serde_json::json!({"ready": true})));
for path in [
"/ready",
"/details/poolMetadata/ready",
"/details/storage/readQuorum",
"/details/storage/writeQuorum",
] {
for value in [
serde_json::Value::Bool(false),
serde_json::Value::Null,
serde_json::json!("true"),
] {
let mut changed = expected.clone();
*changed.pointer_mut(path).expect("fixture contains required health field") = value;
assert!(!pool_metadata_node_health_is_ready(200, &changed), "required health field {path}");
}
}
}
#[derive(serde::Deserialize)]
struct EvidenceBuild {
sha256: String,
@@ -1206,7 +1396,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 +1415,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 +1440,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 +1487,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.
@@ -2213,6 +2422,7 @@ mod tests {
.and_then(|value| value.parse::<u64>().ok())
.unwrap_or(180);
let heal_deadline = Instant::now() + Duration::from_secs(heal_timeout_secs);
let mut last_pool_metadata_readiness = serde_json::Value::Null;
loop {
if restart_recovery_admin_after_failure && !recovery_admin_task_restarted {
let task_state = timeout(
@@ -2254,14 +2464,23 @@ mod tests {
let outage_census = census_object_version_on_disk(&replaced_disk, bucket, &outage_key, None)?;
let pool_metadata_matches = match &expected_pool_metadata {
Some(expected) => {
census_object_version_on_disk(&replaced_disk, RUSTFS_META_BUCKET, POOL_METADATA_OBJECT, None)?
.matches_manifest(expected)
let actual =
census_object_version_on_disk(&replaced_disk, RUSTFS_META_BUCKET, POOL_METADATA_OBJECT, None)?;
pool_metadata_matches_republished_manifest(&actual, expected)
}
None => true,
};
let pool_metadata_ready = if expected_pool_metadata.is_some() && pool_metadata_matches {
let (ready, observation) = all_nodes_pool_metadata_readiness(&cluster, heal_deadline).await;
last_pool_metadata_readiness = observation;
ready
} else {
expected_pool_metadata.is_none()
};
if matching == expected_manifests.len()
&& (!outage_target_manifest_required || outage_census.is_complete())
&& pool_metadata_matches
&& pool_metadata_ready
{
break;
}
@@ -2271,6 +2490,15 @@ mod tests {
let outage_census = census_object_version_on_disk(&replaced_disk, bucket, &outage_key, None)?;
let pool_metadata =
census_object_version_on_disk(&replaced_disk, RUSTFS_META_BUCKET, POOL_METADATA_OBJECT, None)?;
// This post-deadline observation is diagnostic only. It cannot
// make an expired recovery attempt satisfy the original budget.
let pool_metadata_readiness = if expected_pool_metadata.is_some() {
let (_, observation) =
all_nodes_pool_metadata_readiness(&cluster, Instant::now() + Duration::from_secs(3)).await;
serde_json::json!({"last_eligible_check": last_pool_metadata_readiness, "timeout_check": observation})
} else {
serde_json::Value::Null
};
let final_status = signed_admin_post(&status_url, None, &cluster.access_key, &cluster.secret_key)
.await
.unwrap_or_else(|err| format!("status request failed: {err}"));
@@ -2294,7 +2522,7 @@ mod tests {
Err(_) => "replacement status request exceeded 5s diagnostic budget".to_string(),
};
return Err(format!(
"root heal did not recover after {interruption_kind} within {heal_timeout_secs}s: baseline={matching}/{}, outage={outage_census:?}, pool_metadata={pool_metadata:?}, status={final_status}, task_status={task_status}, pre_interrupt_status={pre_interrupt_status}, pre_heal_replacement={pre_heal_replacement}, pre_interrupt_replacement={pre_interrupt_replacement}, replacement_status={replacement_status}",
"root heal did not recover after {interruption_kind} within {heal_timeout_secs}s: baseline={matching}/{}, outage={outage_census:?}, pool_metadata_expected={expected_pool_metadata:?}, pool_metadata={pool_metadata:?}, pool_metadata_readiness={pool_metadata_readiness}, status={final_status}, task_status={task_status}, pre_interrupt_status={pre_interrupt_status}, pre_heal_replacement={pre_heal_replacement}, pre_interrupt_replacement={pre_interrupt_replacement}, replacement_status={replacement_status}",
expected_manifests.len()
)
.into());
-6
View File
@@ -31,12 +31,6 @@ pub mod chaos;
#[cfg(test)]
pub mod fake_s3_target;
// Dual-stack differential (rustfs/backlog#2740): one binary, two processes on
// different RUSTFS_S3_STACK values, the same SDK script for every S3 operation,
// compared on status, error code, allowlisted headers and typed body fields.
#[cfg(test)]
mod dual_stack_differential;
// On-demand migration (backlog#2147): shared two-server environment, admin
// wrappers, and the harness self-test (backlog#2151). Behavior scenarios are
// added by later ODM tasks.
+1 -78
View File
@@ -32,9 +32,7 @@
//! Advisory: <https://github.com/rustfs/rustfs/security/advisories/GHSA-3p3x-734c-h5vx>
use crate::common::rustfs_binary_path_with_features;
use crate::common::{
AdminTransport, admin_add_canned_policy_via, admin_attach_user_policy_via, admin_create_user_via, signed_s3_request,
};
use crate::common::{AdminTransport, admin_add_canned_policy_via, admin_attach_user_policy_via, admin_create_user_via};
use crate::common::{FAST_DATA_USAGE_SCANNER_ENV, admin_request};
use crate::protocols::test_env::{DEFAULT_ACCESS_KEY, DEFAULT_SECRET_KEY, ProtocolTestEnvironment};
use anyhow::Result;
@@ -776,81 +774,6 @@ async fn test_webdav_core_operations_direct() -> Result<()> {
test_webdav_core_operations().await
}
#[tokio::test]
async fn test_webdav_content_type_directory_marker() -> Result<()> {
let env = ProtocolTestEnvironment::new().map_err(|e| anyhow::anyhow!("{}", e))?;
let admin_base_url = format!("http://{}", S3_TEST_ADDRESS);
let binary_path = rustfs_binary_path_with_features(Some("webdav"));
let mut server_process = Command::new(&binary_path)
.arg("--address")
.arg(S3_TEST_ADDRESS)
.env("RUSTFS_CONSOLE_ENABLE", "false")
.env("RUSTFS_WEBDAV_ENABLE", "true")
.env("RUSTFS_WEBDAV_ADDRESS", WEBDAV_ADDRESS)
.env("RUSTFS_WEBDAV_TLS_ENABLED", "false")
.arg(&env.temp_dir)
.spawn()?;
let result = async {
ProtocolTestEnvironment::wait_for_port_ready(WEBDAV_PORT, 30)
.await
.map_err(|e| anyhow::anyhow!("{}", e))?;
let client = create_client();
let bucket_name = "webdav-content-type-bucket";
let directory_name = "content-type-directory";
let webdav_base_url = format!("http://{}", WEBDAV_ADDRESS);
let mkcol_bucket = client
.request(
reqwest::Method::from_bytes(b"MKCOL").expect("MKCOL is a valid HTTP method"),
format!("{}/{}", webdav_base_url, bucket_name),
)
.header("Authorization", basic_auth_header())
.send()
.await?;
anyhow::ensure!(
mkcol_bucket.status().is_success() || mkcol_bucket.status().as_u16() == 201,
"MKCOL bucket should succeed, got: {}",
mkcol_bucket.status()
);
let mkcol_directory = client
.request(
reqwest::Method::from_bytes(b"MKCOL").expect("MKCOL is a valid HTTP method"),
format!("{}/{}/{}", webdav_base_url, bucket_name, directory_name),
)
.header("Authorization", basic_auth_header())
.send()
.await?;
anyhow::ensure!(
mkcol_directory.status().is_success() || mkcol_directory.status().as_u16() == 201,
"MKCOL directory should succeed, got: {}",
mkcol_directory.status()
);
let head_url = format!("{}/{}/{}/", admin_base_url, bucket_name, directory_name);
let head = signed_s3_request(http::Method::HEAD, &head_url, None, None, DEFAULT_ACCESS_KEY, DEFAULT_SECRET_KEY)
.await
.map_err(|error| anyhow::anyhow!("{error}"))?;
anyhow::ensure!(head.status().is_success(), "HEAD directory marker should succeed, got: {}", head.status());
let content_type = head
.headers()
.get("content-type")
.and_then(|value| value.to_str().ok())
.unwrap_or_default();
anyhow::ensure!(
content_type == "application/x-directory",
"WebDAV directory marker must preserve its content type, got: {content_type}"
);
Ok(())
}
.await;
let _ = server_process.kill().await;
let _ = server_process.wait().await;
result
}
/// Extract the numeric value of the first occurrence of a DAV property in a
/// PROPFIND multistatus body. Namespace-prefix agnostic; a self-closing
/// (unreported) property yields None.
+13 -6
View File
@@ -147,14 +147,19 @@ impl QuotaTestEnv {
pub async fn get_bucket_usage(&self) -> Result<u64, Box<dyn std::error::Error + Send + Sync>> {
let stats = self.get_bucket_quota_stats().await?;
Ok(stats.get("current_usage").and_then(|v| v.as_u64()).unwrap_or(0))
stats
.get("current_usage")
.and_then(|value| value.as_u64())
.ok_or_else(|| format!("bucket quota usage is not authoritative for {}", self.bucket_name).into())
}
async fn wait_for_bucket_usage(&self, expected: u64) -> Result<u64, Box<dyn std::error::Error + Send + Sync>> {
let convergence = async {
loop {
let usage = self.get_bucket_usage().await?;
if usage == expected {
let stats = self.get_bucket_quota_stats().await?;
if let Some(usage) = stats.get("current_usage").and_then(|value| value.as_u64())
&& usage == expected
{
return Ok::<u64, Box<dyn std::error::Error + Send + Sync>>(usage);
}
sleep(Duration::from_millis(100)).await;
@@ -214,9 +219,11 @@ impl QuotaTestEnv {
admin_request(&self.env.url, Method::GET, &stats_path, None, &self.env.access_key, &self.env.secret_key)
.await?;
if status.is_success() {
return Ok::<(), Box<dyn std::error::Error + Send + Sync>>(());
}
if status != StatusCode::SERVICE_UNAVAILABLE {
let stats: serde_json::Value = serde_json::from_str(&response)?;
if stats.get("current_usage").and_then(|value| value.as_u64()).is_some() {
return Ok::<(), Box<dyn std::error::Error + Send + Sync>>(());
}
} else if status != StatusCode::SERVICE_UNAVAILABLE {
return Err(format!("quota usage readiness failed for {bucket}: {status} {response}").into());
}
sleep(Duration::from_secs(1)).await;
@@ -548,6 +548,20 @@ impl NodeService for MinimalLockNodeService {
Err(Status::unimplemented("lock-only test server"))
}
async fn rename_multipart_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 recover_multipart_source(
&self,
_request: Request<rustfs_protos::proto_gen::node_service::RecoverMultipartSourceRequest>,
) -> Result<Response<rustfs_protos::proto_gen::node_service::RecoverMultipartSourceResponse>, Status> {
Err(Status::unimplemented("lock-only test server"))
}
async fn rename_data_at_incarnation(
&self,
_request: Request<rustfs_protos::proto_gen::node_service::RenameDataRequest>,
@@ -562,6 +576,25 @@ impl NodeService for MinimalLockNodeService {
Err(Status::unimplemented("lock-only test server"))
}
async fn rename_data_with_pool_meta_cas(
&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 undo_pool_meta_write(
&self,
_request: Request<rustfs_protos::proto_gen::node_service::DeleteVersionRequest>,
) -> Result<Response<rustfs_protos::proto_gen::node_service::DeleteVersionResponse>, Status> {
Err(Status::unimplemented("lock-only test server"))
}
async fn pool_meta_native_recovery(
&self,
_request: Request<rustfs_protos::proto_gen::node_service::PoolMetaNativeRecoveryRequest>,
) -> Result<Response<rustfs_protos::proto_gen::node_service::PoolMetaNativeRecoveryResponse>, Status> {
Err(Status::unimplemented("lock-only test server"))
}
async fn make_volumes(
&self,
_request: Request<rustfs_protos::proto_gen::node_service::MakeVolumesRequest>,
File diff suppressed because it is too large Load Diff
+25 -2
View File
@@ -15,7 +15,11 @@
#[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) const OBJECT_LOCK_TRACE_FILTER: &str = "info,rustfs_ecstore::object_lock_diag=trace";
#[cfg(test)]
pub(crate) use rustfs_ecstore::api::disk::{
DiskOption, Endpoint, RUSTFS_META_BUCKET, RUSTFS_META_MULTIPART_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::{
@@ -55,5 +59,24 @@ pub(crate) mod replication_extension {
#[cfg(test)]
pub(crate) mod upgrade_compatibility {
pub(crate) use super::{DiskOption, Endpoint, new_disk};
pub(crate) use super::{DiskOption, Endpoint, RUSTFS_META_MULTIPART_BUCKET, new_disk};
}
#[cfg(test)]
pub(crate) mod multipart_rpc_delay {
pub(crate) use super::RUSTFS_META_MULTIPART_BUCKET;
}
#[cfg(test)]
pub(crate) mod multipart_fence_acceptance {
pub(crate) use super::RUSTFS_META_MULTIPART_BUCKET;
}
#[cfg(test)]
pub(crate) mod multipart_source_rpc {
pub(crate) use rustfs_ecstore::api::disk::{
MULTIPART_SOURCE_PROTOCOL_VERSION, MultipartAbortCertificate, MultipartAbortProof, MultipartAbortProofKind,
MultipartAbortRound, MultipartSourceOperation, MultipartSourcePhase, MultipartSourceRequest, MultipartSourceSnapshot,
PublicationIdentity, SourceGeneration, SourceGeometry, SourceLineage,
};
}
+513 -66
View File
@@ -21,6 +21,7 @@ use crate::on_demand_migration::common::{ODM_SERVER_ENV, OdmTestEnv, SeedObject,
use crate::replication_extension_test::{
LOOPBACK_REPLICATION_TARGET_ENV, ReplicationTargetOptions, put_bucket_replication, set_replication_target_with_options,
};
use crate::storage_api::upgrade_compatibility::RUSTFS_META_MULTIPART_BUCKET;
use aws_sdk_s3::Client;
use aws_sdk_s3::error::ProvideErrorMetadata;
use aws_sdk_s3::primitives::ByteStream;
@@ -52,6 +53,8 @@ const SSE_MASTER_KEY: &str = "QkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkI=";
const PLAIN_BUCKET: &str = "upgrade-plain-data";
const VERSIONED_BUCKET: &str = "upgrade-versioned-data";
const MIXED_BUCKET: &str = "upgrade-mixed-version-data";
const PENDING_MULTIPART_BUCKET: &str = "upgrade-mpu-continuity";
const PENDING_MULTIPART_KEY: &str = "pending/legacy-multipart.bin";
const MIXED_NODE_COUNT: usize = 4;
const UPGRADE_READINESS_BODY: &[u8] = b"upgrade write readiness";
// Written by the "previous-seed" readiness probe through node 0 before any
@@ -301,18 +304,27 @@ async fn read_object(
Ok((encryption, body))
}
async fn write_multipart(client: &Client, bucket: &str, key: &str, parts: &[Vec<u8>]) -> TestResult {
let created = client.create_multipart_upload().bucket(bucket).key(key).send().await?;
let upload_id = created.upload_id().ok_or("CreateMultipartUpload omitted upload ID")?;
let mut completed_parts = Vec::with_capacity(parts.len());
struct PendingMultipart {
upload_id: String,
completed_parts: Vec<CompletedPart>,
part_sizes: Vec<i64>,
}
async fn stage_multipart(client: &Client, bucket: &str, key: &str, parts: &[Vec<u8>]) -> Result<PendingMultipart, BoxError> {
let created = client.create_multipart_upload().bucket(bucket).key(key).send().await?;
let upload_id = created
.upload_id()
.ok_or("CreateMultipartUpload omitted upload ID")?
.to_owned();
let mut completed_parts = Vec::with_capacity(parts.len());
let mut part_sizes = Vec::with_capacity(parts.len());
for (index, part) in parts.iter().enumerate() {
let part_number = i32::try_from(index + 1)?;
let uploaded = client
.upload_part()
.bucket(bucket)
.key(key)
.upload_id(upload_id)
.upload_id(&upload_id)
.part_number(part_number)
.body(ByteStream::from(part.clone()))
.send()
@@ -323,16 +335,38 @@ async fn write_multipart(client: &Client, bucket: &str, key: &str, parts: &[Vec<
.e_tag(uploaded.e_tag().ok_or("UploadPart omitted ETag")?)
.build(),
);
part_sizes.push(i64::try_from(part.len())?);
}
Ok(PendingMultipart {
upload_id,
completed_parts,
part_sizes,
})
}
client
async fn complete_pending_multipart(
client: &Client,
bucket: &str,
key: &str,
pending: &PendingMultipart,
) -> Result<aws_sdk_s3::operation::complete_multipart_upload::CompleteMultipartUploadOutput, BoxError> {
Ok(client
.complete_multipart_upload()
.bucket(bucket)
.key(key)
.upload_id(upload_id)
.multipart_upload(CompletedMultipartUpload::builder().set_parts(Some(completed_parts)).build())
.upload_id(&pending.upload_id)
.multipart_upload(
CompletedMultipartUpload::builder()
.set_parts(Some(pending.completed_parts.clone()))
.build(),
)
.send()
.await?;
.await?)
}
async fn write_multipart(client: &Client, bucket: &str, key: &str, parts: &[Vec<u8>]) -> TestResult {
let pending = stage_multipart(client, bucket, key, parts).await?;
complete_pending_multipart(client, bucket, key, &pending).await?;
Ok(())
}
@@ -379,6 +413,330 @@ async fn write_multipart_load(clients: &[Client], phase: &str) -> Result<Vec<Str
Ok(keys)
}
async fn write_mixed_put_load(clients: &[Client], phase: &str) -> Result<Vec<String>, BoxError> {
let mut tasks = JoinSet::new();
for worker in 0..MULTIPART_WORKERS {
let node = worker % clients.len();
let client = clients[node].clone();
let phase = phase.to_owned();
tasks.spawn(async move {
let mut keys = Vec::with_capacity(MULTIPART_UPLOADS_PER_WORKER);
for upload in 0..MULTIPART_UPLOADS_PER_WORKER {
let key = format!("{phase}/put/{worker:02}/{upload:02}");
tokio::time::timeout(
MIXED_MULTIPART_UPLOAD_TIMEOUT,
client
.put_object()
.bucket(MIXED_BUCKET)
.key(&key)
.body(ByteStream::from(vec![u8::try_from(worker)?; 64 * 1024]))
.send(),
)
.await
.map_err(|_| format!("node {node}: PUT {key} acknowledgement deadline exceeded"))??;
keys.push(key);
}
Ok::<_, BoxError>(keys)
});
}
let mut keys = Vec::with_capacity(MULTIPART_WORKERS * MULTIPART_UPLOADS_PER_WORKER);
while let Some(result) = tasks.join_next().await {
keys.extend(result??);
}
Ok(keys)
}
async fn verify_load_bodies(clients: &[Client], phase: &str, operation: &str) -> TestResult {
for worker in 0..MULTIPART_WORKERS {
let reader = &clients[(worker + 1) % clients.len()];
for upload in 0..MULTIPART_UPLOADS_PER_WORKER {
let key = format!("{phase}/{operation}/{worker:02}/{upload:02}");
assert_eq!(
read_object(reader, MIXED_BUCKET, &key, None).await?.1,
vec![u8::try_from(worker)?; 64 * 1024],
"{phase}: acknowledged {key} body"
);
}
}
Ok(())
}
async fn exercise_homogeneous_multipart(cluster: &RustFSTestClusterEnvironment, phase: &str) -> TestResult {
let clients = cluster.create_all_clients()?;
wait_for_upgrade_write_readiness(&clients, MIXED_BUCKET, phase, LISTING_CONVERGENCE_TIMEOUT).await?;
wait_for_drive_readmission(&clients, MIXED_BUCKET, phase, DRIVE_READMISSION_TIMEOUT).await?;
let keys = match write_multipart_load(&clients, phase).await {
Ok(keys) => keys,
Err(error) => {
let log_dir = std::env::var_os("RUSTFS_E2E_LOG_DIR");
return observe_mixed_multipart_failure(cluster, phase, error, log_dir.as_deref().map(Path::new)).await;
}
};
assert_eq!(keys.len(), MULTIPART_WORKERS * MULTIPART_UPLOADS_PER_WORKER);
verify_load_bodies(&clients, phase, "multipart").await?;
for (reader, client) in clients.iter().enumerate() {
wait_for_phase_listing(client, phase, keys.len(), &format!("homogeneous reader {reader}")).await?;
}
Ok(())
}
struct PendingSourceBytes {
root: PathBuf,
files: Vec<(PathBuf, Vec<u8>)>,
}
fn pending_source_bytes(
cluster: &RustFSTestClusterEnvironment,
pending: &PendingMultipart,
) -> Result<Vec<PendingSourceBytes>, BoxError> {
let source_hash =
rustfs_utils::hex_sha256(format!("{PENDING_MULTIPART_BUCKET}/{PENDING_MULTIPART_KEY}").as_bytes(), str::to_owned);
let mut sources = Vec::new();
for volume in cluster.nodes.iter().flat_map(|node| &node.data_dirs) {
let hash_root = Path::new(volume).join(RUSTFS_META_MULTIPART_BUCKET).join(&source_hash);
let roots = std::fs::read_dir(&hash_root)?
.map(|entry| {
let entry = entry?;
Ok(entry.file_type()?.is_dir().then(|| entry.path()))
})
.collect::<Result<Vec<_>, std::io::Error>>()?
.into_iter()
.flatten()
.collect::<Vec<_>>();
if roots.is_empty() {
return Err(std::io::Error::new(std::io::ErrorKind::NotFound, "pending upload source has not settled").into());
}
assert_eq!(roots.len(), 1, "the dedicated key has exactly one real pending upload");
let root = roots.into_iter().next().ok_or("pending upload source missing")?;
let mut files = vec![(PathBuf::from("xl.meta"), std::fs::read(root.join("xl.meta"))?)];
let data_dirs = std::fs::read_dir(&root)?
.map(|entry| {
let entry = entry?;
Ok((entry.file_type()?.is_dir() && entry.path().join("part.1").is_file()).then(|| entry.path()))
})
.collect::<Result<Vec<_>, std::io::Error>>()?
.into_iter()
.flatten()
.collect::<Vec<_>>();
if data_dirs.is_empty() {
return Err(std::io::Error::new(std::io::ErrorKind::NotFound, "selected part publication has not settled").into());
}
assert_eq!(data_dirs.len(), 1, "the upload has one actual selected-part directory");
let data_dir = data_dirs.into_iter().next().ok_or("pending upload parts missing")?;
for part in &pending.completed_parts {
let number = part.part_number().ok_or("acknowledged part omitted its number")?;
for name in [format!("part.{number}"), format!("part.{number}.meta")] {
let path = data_dir.join(name);
files.push((path.strip_prefix(&root)?.to_owned(), std::fs::read(path)?));
}
}
sources.push(PendingSourceBytes { root, files });
}
assert_eq!(
sources.len(),
MIXED_NODE_COUNT,
"the controlled legacy fixture requires every physical source"
);
Ok(sources)
}
async fn wait_for_pending_source_bytes(
cluster: &RustFSTestClusterEnvironment,
pending: &PendingMultipart,
) -> Result<Vec<PendingSourceBytes>, BoxError> {
let deadline = Instant::now() + DRIVE_READMISSION_TIMEOUT;
loop {
match pending_source_bytes(cluster, pending) {
Ok(sources) => return Ok(sources),
Err(error)
if error
.downcast_ref::<std::io::Error>()
.is_some_and(|error| error.kind() == std::io::ErrorKind::NotFound)
&& Instant::now() < deadline =>
{
sleep(Duration::from_millis(500)).await
}
Err(error) => return Err(error),
}
}
}
async fn assert_pending_parts(client: &Client, pending: &PendingMultipart) -> TestResult {
let listed = client
.list_parts()
.bucket(PENDING_MULTIPART_BUCKET)
.key(PENDING_MULTIPART_KEY)
.upload_id(&pending.upload_id)
.send()
.await?;
assert_eq!(listed.upload_id(), Some(pending.upload_id.as_str()));
assert_ne!(listed.is_truncated(), Some(true));
assert_eq!(listed.parts().len(), pending.completed_parts.len());
for ((actual, expected), expected_size) in listed.parts().iter().zip(&pending.completed_parts).zip(&pending.part_sizes) {
assert_eq!(actual.part_number(), expected.part_number());
assert_eq!(actual.e_tag(), expected.e_tag());
assert_eq!(actual.size(), Some(*expected_size));
}
Ok(())
}
async fn assert_mixed_pending_refusal(
cluster: &RustFSTestClusterEnvironment,
pending: &PendingMultipart,
sources: &[PendingSourceBytes],
) -> TestResult {
let clients = cluster.create_all_clients()?;
assert_pending_parts(&clients[0], pending).await?;
let error = upgrade_probe_client(&clients[0])
.complete_multipart_upload()
.bucket(PENDING_MULTIPART_BUCKET)
.key(PENDING_MULTIPART_KEY)
.upload_id(&pending.upload_id)
.multipart_upload(
CompletedMultipartUpload::builder()
.set_parts(Some(pending.completed_parts.clone()))
.build(),
)
.send()
.await
.expect_err("one native participant cannot authorize a legacy MPU completion");
assert_eq!(error.raw_response().map(|response| response.status().as_u16()), Some(503), "{error:?}");
assert_eq!(error.as_service_error().and_then(ProvideErrorMetadata::code), Some("ServiceUnavailable"));
for client in &clients {
assert_pending_parts(client, pending).await?;
let error = client
.get_object()
.bucket(PENDING_MULTIPART_BUCKET)
.key(PENDING_MULTIPART_KEY)
.send()
.await
.expect_err("the rejected Complete must not publish a target");
assert_eq!(error.as_service_error().and_then(ProvideErrorMetadata::code), Some("NoSuchKey"));
}
for source in sources {
for entry in std::fs::read_dir(&source.root)? {
assert!(
!entry?.file_name().to_string_lossy().starts_with(".mpu-"),
"the failed preflight must not upgrade one physical legacy source"
);
}
for (relative, expected) in &source.files {
let actual = std::fs::read(source.root.join(relative))?;
assert!(actual == *expected, "rejected Complete changed actual source {}", relative.display());
}
}
Ok(())
}
struct CompletedPendingMultipart {
version_id: String,
etag: String,
body: Vec<u8>,
}
async fn assert_resumed_multipart(clients: &[Client], completed: &CompletedPendingMultipart) -> TestResult {
for (node, client) in clients.iter().enumerate() {
let response = client
.get_object()
.bucket(PENDING_MULTIPART_BUCKET)
.key(PENDING_MULTIPART_KEY)
.version_id(&completed.version_id)
.send()
.await?;
assert_eq!(response.version_id(), Some(completed.version_id.as_str()), "reader {node}");
assert_eq!(response.e_tag(), Some(completed.etag.as_str()), "reader {node}");
assert_eq!(
response.body.collect().await?.into_bytes().as_ref(),
completed.body.as_slice(),
"reader {node}"
);
assert_eq!(
read_object(client, PENDING_MULTIPART_BUCKET, PENDING_MULTIPART_KEY, None)
.await?
.1,
completed.body
);
let versions = client
.list_object_versions()
.bucket(PENDING_MULTIPART_BUCKET)
.prefix(PENDING_MULTIPART_KEY)
.send()
.await?;
assert_eq!(versions.versions().len(), 1, "same-upload replay must not add a version");
assert_eq!(versions.versions()[0].version_id(), Some(completed.version_id.as_str()));
assert!(versions.delete_markers().is_empty());
}
Ok(())
}
async fn finish_pending_multipart(
clients: &[Client],
pending: &PendingMultipart,
body: Vec<u8>,
) -> Result<CompletedPendingMultipart, BoxError> {
let complete = complete_pending_multipart(&clients[0], PENDING_MULTIPART_BUCKET, PENDING_MULTIPART_KEY, pending).await?;
let version_id = complete
.version_id()
.filter(|version| !version.is_empty() && *version != "null")
.ok_or("versioned legacy upload completion omitted a concrete version")?
.to_owned();
let etag = complete.e_tag().ok_or("CompleteMultipartUpload omitted ETag")?.to_owned();
let replay = complete_pending_multipart(&clients[0], PENDING_MULTIPART_BUCKET, PENDING_MULTIPART_KEY, pending).await?;
assert_eq!(replay.version_id(), Some(version_id.as_str()));
assert_eq!(replay.e_tag(), Some(etag.as_str()));
let completed = CompletedPendingMultipart { version_id, etag, body };
assert_resumed_multipart(clients, &completed).await?;
Ok(completed)
}
async fn wait_for_multipart_quiescence(cluster: &RustFSTestClusterEnvironment, sources: &[PendingSourceBytes]) -> TestResult {
let deadline = Instant::now() + LISTING_CONVERGENCE_TIMEOUT;
loop {
let pending_roots = sources.iter().filter(|source| source.root.exists()).count();
let mut native_controls = 0;
let mut remaining_upload_roots = 0;
for volume in cluster.nodes.iter().flat_map(|node| &node.data_dirs) {
let root = Path::new(volume).join(RUSTFS_META_MULTIPART_BUCKET);
if !root.exists() {
continue;
}
for entry in walkdir::WalkDir::new(root) {
let entry = match entry {
Ok(entry) => entry,
Err(error)
if error
.io_error()
.is_some_and(|error| error.kind() == std::io::ErrorKind::NotFound) =>
{
native_controls += 1;
continue;
}
Err(error) => return Err(error.into()),
};
// Every fixture upload lives at MPI/<SHA>/<upload>. A born-only
// source can contain just xl.meta and its data directory, with
// no .mpu-* record. Only empty SHA parents may survive rollback.
if entry.depth() == 2 || (entry.depth() == 1 && !entry.file_type().is_dir()) {
remaining_upload_roots += 1;
}
if entry.file_name().to_string_lossy().starts_with(".mpu-") {
native_controls += 1;
}
}
}
if pending_roots == 0 && remaining_upload_roots == 0 && native_controls == 0 {
return Ok(());
}
if Instant::now() >= deadline {
return Err(format!(
"downgrade requires settled native MPU state: {pending_roots} fixed pending roots, {remaining_upload_roots} inventoried upload roots, {native_controls} native controls"
)
.into());
}
sleep(Duration::from_millis(500)).await;
}
}
async fn mixed_multipart_before(
deadline: Instant,
node: usize,
@@ -625,14 +983,11 @@ async fn exercise_mixed_cluster(
.await?;
assert_eq!(read_object(current_client, MIXED_BUCKET, &previous_key, None).await?.1, previous_body);
let multipart_keys = match write_multipart_load(&clients, phase).await {
Ok(keys) => keys,
Err(error) => {
let log_dir = std::env::var_os("RUSTFS_E2E_LOG_DIR");
return observe_mixed_multipart_failure(cluster, phase, error, log_dir.as_deref().map(Path::new)).await;
}
};
let expected_count = multipart_keys.len() + 2;
// Native MPU mutations are operationally paused while RPC generations
// differ. Keep the same mixed traffic volume with ordinary PUT instead.
let written_keys = write_mixed_put_load(&clients, phase).await?;
let expected_count = written_keys.len() + 2;
verify_load_bodies(&clients, phase, "put").await?;
for (label, client) in [("current", current_client), ("previous", previous_client)] {
assert_eq!(
read_object(client, MIXED_BUCKET, PREVIOUS_RELEASE_SEED_KEY, None).await?.1,
@@ -648,9 +1003,9 @@ async fn exercise_mixed_cluster(
.await?;
}
let last_multipart_key = format!("{phase}/multipart/{:02}/{:02}", MULTIPART_WORKERS - 1, MULTIPART_UPLOADS_PER_WORKER - 1);
let last_put_key = format!("{phase}/put/{:02}/{:02}", MULTIPART_WORKERS - 1, MULTIPART_UPLOADS_PER_WORKER - 1);
assert_eq!(
read_object(previous_client, MIXED_BUCKET, &last_multipart_key, None).await?.1,
read_object(previous_client, MIXED_BUCKET, &last_put_key, None).await?.1,
vec![u8::try_from(MULTIPART_WORKERS - 1)?; 64 * 1024]
);
@@ -790,12 +1145,20 @@ fn previous_baseline_writers(clients: &[Client], restarted_node: usize) -> &[Cli
}
async fn prepare_previous_release_baseline(cluster: &mut RustFSTestClusterEnvironment, previous_binary: &Path) -> TestResult {
prepare_release_baseline(cluster, previous_binary, "previous").await
}
async fn prepare_release_baseline(
cluster: &mut RustFSTestClusterEnvironment,
previous_binary: &Path,
prefix: &str,
) -> TestResult {
let clients: Vec<_> = cluster.create_all_clients()?.iter().map(upgrade_probe_client).collect();
// rc.5 can latch a non-elected node's write fence while its first startup
// waits for pool metadata (#7473). First prove the elected writer can
// persist data, then restart each old process once with three peers still
// readable. This preparation ends before any current binary is started.
wait_for_upgrade_write_readiness(&clients[..1], MIXED_BUCKET, "previous-seed", LISTING_CONVERGENCE_TIMEOUT).await?;
// readable. The same preparation is reused only after a quiescent homogeneous rollback.
wait_for_upgrade_write_readiness(&clients[..1], MIXED_BUCKET, &format!("{prefix}-seed"), LISTING_CONVERGENCE_TIMEOUT).await?;
// A node that just came back is still replaying its startup traffic against
// every peer. Killing the next node during that burst gives the fresh node
// two failures in a row and it drops that peer's drive to Offline, where
@@ -805,7 +1168,7 @@ async fn prepare_previous_release_baseline(cluster: &mut RustFSTestClusterEnviro
for node in [1, 2, 3, 0] {
cluster.stop_node(node)?;
cluster.start_node_from_binary(node, previous_binary).await?;
let context = format!("previous-restart-{node}");
let context = format!("{prefix}-restart-{node}");
wait_for_upgrade_write_readiness(
std::slice::from_ref(&clients[node]),
MIXED_BUCKET,
@@ -822,15 +1185,17 @@ async fn prepare_previous_release_baseline(cluster: &mut RustFSTestClusterEnviro
.await?;
}
wait_for_upgrade_write_readiness(&clients, MIXED_BUCKET, "previous-baseline", LISTING_CONVERGENCE_TIMEOUT).await?;
wait_for_upgrade_write_readiness(&clients, MIXED_BUCKET, &format!("{prefix}-baseline"), LISTING_CONVERGENCE_TIMEOUT).await?;
for (reader, client) in clients.iter().enumerate() {
assert_eq!(
read_object(client, MIXED_BUCKET, PREVIOUS_RELEASE_SEED_KEY, None).await?.1,
read_object(client, MIXED_BUCKET, &format!(".upgrade-readiness/{prefix}-seed/node-0"), None)
.await?
.1,
UPGRADE_READINESS_BODY,
"previous-release node {reader} must retain the seed across its rolling restart"
);
for writer in 0..clients.len() {
let key = format!(".upgrade-readiness/previous-baseline/node-{writer}");
let key = format!(".upgrade-readiness/{prefix}-baseline/node-{writer}");
assert_eq!(
read_object(client, MIXED_BUCKET, &key, None).await?.1,
UPGRADE_READINESS_BODY,
@@ -1883,10 +2248,21 @@ async fn rolling_upgrade_from_previous_release_preserves_mixed_version_contracts
cluster.start_with_binary(&previous_binary).await?;
cluster.create_test_bucket(MIXED_BUCKET).await?;
prepare_previous_release_baseline(&mut cluster, &previous_binary).await?;
exercise_homogeneous_multipart(&cluster, "homogeneous-previous").await?;
cluster.create_test_bucket(PENDING_MULTIPART_BUCKET).await?;
let previous_clients = cluster.create_all_clients()?;
enable_versioning(&previous_clients[0], PENDING_MULTIPART_BUCKET).await?;
let pending_parts = vec![vec![0x63; 5 * 1024 * 1024], vec![0x7d; 4096]];
let pending = stage_multipart(&previous_clients[0], PENDING_MULTIPART_BUCKET, PENDING_MULTIPART_KEY, &pending_parts).await?;
let sources = wait_for_pending_source_bytes(&cluster, &pending).await?;
assert_pending_parts(&previous_clients[0], &pending).await?;
cluster.stop_node(0)?;
cluster.start_node_from_binary(0, &current_binary).await?;
exercise_mixed_cluster(&cluster, "one-current-node", 0, 1).await?;
// One controlled out-of-policy attempt proves fail-closed behavior below
// native write quorum; positive MPU writers remain paused while mixed.
assert_mixed_pending_refusal(&cluster, &pending, &sources).await?;
for (node_idx, phase) in [(1, "two-current-nodes"), (2, "one-previous-node")] {
cluster.stop_node(node_idx)?;
@@ -1898,6 +2274,9 @@ async fn rolling_upgrade_from_previous_release_preserves_mixed_version_contracts
cluster.start_node_from_binary(3, &current_binary).await?;
let clients = cluster.create_all_clients()?;
wait_for_drive_readmission(&clients, MIXED_BUCKET, "homogeneous-current", DRIVE_READMISSION_TIMEOUT).await?;
let completed_pending = finish_pending_multipart(&clients, &pending, pending_parts.concat()).await?;
exercise_homogeneous_multipart(&cluster, "homogeneous-current").await?;
verify_load_bodies(&clients, "homogeneous-previous", "multipart").await?;
for (node_idx, client) in clients.iter().enumerate() {
assert_eq!(
@@ -1916,47 +2295,6 @@ async fn rolling_upgrade_from_previous_release_preserves_mixed_version_contracts
}
}
if let Some(context) = evidence_context.as_ref() {
let phases = ["one-current-node", "two-current-nodes", "one-previous-node"];
let objects_per_phase = MULTIPART_WORKERS * MULTIPART_UPLOADS_PER_WORKER + 2;
write_g09_evidence(
context,
"mixed_version_reader_evidence",
"mixed-version-reader",
&["old-writer-new-reader", "new-writer-old-reader"],
"upgrade_compatibility_test::rolling_upgrade_from_previous_release_preserves_mixed_version_contracts",
serde_json::json!({
"bucket": MIXED_BUCKET,
"phases": phases,
"objects_per_phase": objects_per_phase,
"assertions": [
"current node reads objects written through previous-release client",
"previous-release node reads objects written through current client",
"all nodes list every mixed-version object after homogeneous-current convergence",
"the previous-release seed survives the baseline restart and every upgrade phase"
],
}),
)?;
write_g09_evidence(
context,
"mixed_version_writer_evidence",
"mixed-version-writer",
&["old-reader-new-writer", "new-reader-old-writer"],
"upgrade_compatibility_test::rolling_upgrade_from_previous_release_preserves_mixed_version_contracts",
serde_json::json!({
"bucket": MIXED_BUCKET,
"phases": phases,
"objects_per_phase": objects_per_phase,
"assertions": [
"current writer publishes objects readable by previous-release node",
"previous-release writer publishes objects readable by current node",
"multipart writers continue at every individual node upgrade",
"modern DELETE fails closed and preserves every byte while old peers remain"
],
}),
)?;
}
let clients = cluster.create_all_clients()?;
wait_for_upgrade_write_readiness(&clients, MIXED_BUCKET, "all-current", LISTING_CONVERGENCE_TIMEOUT).await?;
clients[0]
@@ -1980,6 +2318,115 @@ async fn rolling_upgrade_from_previous_release_preserves_mixed_version_contracts
);
}
wait_for_multipart_quiescence(&cluster, &sources).await?;
// No MPU mutation runs during a heterogeneous downgrade. All current
// callbacks and source controls settle before old binaries reopen volumes.
cluster.stop();
cluster.start_with_binary(&previous_binary).await?;
prepare_release_baseline(&mut cluster, &previous_binary, "rollback").await?;
let old_clients = cluster.create_all_clients()?;
assert_resumed_multipart(&old_clients, &completed_pending).await?;
for phase in ["homogeneous-previous", "homogeneous-current"] {
verify_load_bodies(&old_clients, phase, "multipart").await?;
for client in &old_clients {
wait_for_phase_listing(
client,
phase,
MULTIPART_WORKERS * MULTIPART_UPLOADS_PER_WORKER,
"previous reader after rollback",
)
.await?;
}
}
for phase in ["one-current-node", "two-current-nodes", "one-previous-node"] {
verify_load_bodies(&old_clients, phase, "put").await?;
for client in &old_clients {
wait_for_phase_listing(
client,
phase,
MULTIPART_WORKERS * MULTIPART_UPLOADS_PER_WORKER + 2,
"previous reader after rollback",
)
.await?;
for (key, body) in [
("written-by-current", format!("{phase}: current RustFS build")),
("written-by-previous", format!("{phase}: previous RustFS release")),
] {
assert_eq!(
read_object(client, MIXED_BUCKET, &format!("{phase}/{key}"), None).await?.1,
body.as_bytes()
);
}
}
}
for (node, client) in old_clients.iter().enumerate() {
let error = client
.get_object()
.bucket(MIXED_BUCKET)
.key(PREVIOUS_RELEASE_SEED_KEY)
.send()
.await
.expect_err("current DELETE must remain visible to previous readers after rollback");
assert_eq!(
error.as_service_error().and_then(ProvideErrorMetadata::code),
Some("NoSuchKey"),
"reader {node}"
);
}
exercise_homogeneous_multipart(&cluster, "homogeneous-rollback").await?;
if let Some(context) = evidence_context.as_ref() {
let phases = ["one-current-node", "two-current-nodes", "one-previous-node"];
let objects_per_phase = MULTIPART_WORKERS * MULTIPART_UPLOADS_PER_WORKER + 2;
write_g09_evidence(
context,
"mixed_version_reader_evidence",
"mixed-version-reader",
&["old-writer-new-reader", "new-writer-old-reader"],
"upgrade_compatibility_test::rolling_upgrade_from_previous_release_preserves_mixed_version_contracts",
serde_json::json!({
"bucket": MIXED_BUCKET,
"phases": phases,
"objects_per_phase": objects_per_phase,
"mixed_load": "plain-put",
"homogeneous_multipart_phases": ["homogeneous-previous", "homogeneous-current", "homogeneous-rollback"],
"multipart_objects_per_homogeneous_phase": MULTIPART_WORKERS * MULTIPART_UPLOADS_PER_WORKER,
"assertions": [
"current node reads objects written through previous-release client",
"previous-release node reads objects written through current client",
"all nodes list every mixed-version object after homogeneous-current convergence",
"the previous-release seed survives the baseline restart and every upgrade phase",
"previous readers read every current-native MPU body after quiescent rollback",
"same legacy upload resumes with identical body, ETag and version after full upgrade"
],
}),
)?;
write_g09_evidence(
context,
"mixed_version_writer_evidence",
"mixed-version-writer",
&["old-reader-new-writer", "new-reader-old-writer"],
"upgrade_compatibility_test::rolling_upgrade_from_previous_release_preserves_mixed_version_contracts",
serde_json::json!({
"bucket": MIXED_BUCKET,
"phases": phases,
"objects_per_phase": objects_per_phase,
"mixed_load": "plain-put",
"homogeneous_multipart_phases": ["homogeneous-previous", "homogeneous-current", "homogeneous-rollback"],
"multipart_objects_per_homogeneous_phase": MULTIPART_WORKERS * MULTIPART_UPLOADS_PER_WORKER,
"assertions": [
"current writer publishes objects readable by previous-release node",
"previous-release writer publishes objects readable by current node",
"mixed phases retain 16x16 plain PUT writers while positive multipart writers are paused",
"16x16 multipart load runs in homogeneous previous, current and rollback phases",
"one-current-node Complete returns typed 503 and preserves actual source authority and parts",
"the same legacy upload completes and replays once fully upgraded without adding a version",
"modern DELETE fails closed and preserves every byte while old peers remain"
],
}),
)?;
}
Ok(())
}
+1 -7
View File
@@ -33,11 +33,6 @@ workspace = true
default = []
diagnostic-read-version-caller = ["rustfs-io-metrics/diagnostic-read-version-caller"]
gcs = ["dep:google-cloud-storage", "dep:google-cloud-auth"]
# Compiles the controlled list-objects namespace-journal chaos injector into a
# production binary (it is always available to tests). Off by default so the
# RUSTFS_LIST_OBJECTS_NAMESPACE_JOURNAL_CHAOS_* env vars cannot rewrite journal
# state in a stock build (backlog#1832).
list-chaos = []
rio-v2 = ["dep:rustfs-rio-v2"]
hotpath = [
"hotpath/hotpath",
@@ -142,7 +137,6 @@ rustfs-lifecycle.workspace = true
rustfs-s3-client = { workspace = true }
rustfs-s3-types = { workspace = true }
rustfs-data-usage.workspace = true
rustfs-gateway-types.workspace = true
rustfs-object-capacity.workspace = true
rustfs-object-data-cache = { workspace = true, features = ["runtime-memory"] }
arc-swap.workspace = true
@@ -161,6 +155,7 @@ serde = { workspace = true, features = ["derive"] }
time = { workspace = true, features = ["parsing", "formatting", "macros", "serde"] }
bytesize.workspace = true
serde_json = { workspace = true, features = ["raw_value"] }
s3s = { workspace = true, features = ["minio"] }
http.workspace = true
opentelemetry.workspace = true
http-body = { workspace = true }
@@ -249,7 +244,6 @@ windows-sys = { workspace = true, features = [
windows-sys = { workspace = true, features = ["Win32_System_Ioctl"] }
[dev-dependencies]
s3s = { workspace = true, features = ["minio"] }
aws-smithy-async.workspace = true
rustfs-s3-ops.workspace = true
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "test-util", "fs"] }
+32 -18
View File
@@ -62,8 +62,6 @@ pub mod bucket {
}
pub mod bucket_lifecycle_ops {
pub use rustfs_lifecycle::{RestoreOutputLocation, RestoreRequest, RestoreS3Location, RestoreTag, RestoreTagging};
pub use crate::bucket::lifecycle::bucket_lifecycle_ops::{
ExpiryState, LifecycleOps, ManualTransitionCancelCheck, ManualTransitionProgressSink,
ManualTransitionQueueSnapshot, ManualTransitionRunExecution, ManualTransitionRunOptions,
@@ -135,7 +133,7 @@ pub mod bucket {
pub use crate::bucket::lifecycle::lifecycle::{
Event, ExpirationOptions, IlmAction, LIFECYCLE_MALFORMED_XML_ERROR_KIND, Lifecycle, LifecycleCalculate,
ObjectOpts, RuleValidate, TRANSITION_COMPLETE, TRANSITION_PENDING, TransitionOptions, expected_expiry_time,
format_lifecycle_date, object_opts_from_object_info, parse_lifecycle_date,
object_opts_from_object_info,
};
}
@@ -227,7 +225,7 @@ pub mod bucket {
}
pub mod object_lock {
pub use crate::bucket::object_lock::ObjectLockApi;
pub use crate::bucket::object_lock::{ObjectLockApi, ObjectLockStatusExt};
pub mod types {
pub use crate::bucket::object_lock::types::{
@@ -322,9 +320,9 @@ pub mod bucket {
pub mod utils {
pub use crate::bucket::utils::{
BucketConfigXml, BucketConfigXmlError, check_bucket_and_object_names, check_list_objs_args,
check_object_name_for_length_and_slash, check_valid_bucket_name_strict, deserialize, has_bad_path_component,
is_meta_bucketname, is_valid_object_prefix, serialize,
check_bucket_and_object_names, check_list_objs_args, check_object_name_for_length_and_slash,
check_valid_bucket_name_strict, deserialize, has_bad_path_component, is_meta_bucketname, is_valid_object_prefix,
serialize,
};
}
@@ -351,7 +349,7 @@ pub mod capacity {
}
pub mod object_api_utils {
pub use crate::object_api::object_api_utils::{ObjReaderFn, PutObjReader, get_raw_etag, new_getobjectreader};
pub use crate::object_api::object_api_utils::{ObjReaderFn, PutObjReader, get_raw_etag, new_getobjectreader, to_s3s_etag};
}
pub mod cluster {
@@ -431,6 +429,16 @@ pub mod data_usage {
pub mod disk {
pub use crate::disk::disk_store::get_object_disk_read_timeout;
pub use crate::disk::local::{ReplacementExecutionLease, ScanGuard};
#[cfg(feature = "test-util")]
pub use crate::disk::local::{local_durability_modes, set_local_rename_data_fail_after_metadata_commit};
pub use crate::disk::multipart_source::{
MULTIPART_SOURCE_PROTOCOL_VERSION, MultipartAbortCertificate, MultipartAbortProof, MultipartAbortProofKind,
MultipartAbortRound, MultipartSourceOperation, MultipartSourcePhase, MultipartSourceRequest, MultipartSourceResponse,
MultipartSourceSnapshot, PublicationIdentity, SourceGeneration, SourceGeometry, SourceLineage, capture_source_condition,
require_source_condition, strip_native_controls,
};
#[cfg(all(feature = "test-util", unix))]
pub use crate::disk::os::set_local_dir_fsync_failure;
#[cfg(all(feature = "test-util", not(windows)))]
pub use crate::disk::os::{LocalPublicationPause, LocalPublicationStage};
pub use crate::disk::{
@@ -440,9 +448,12 @@ pub mod disk {
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,
POOL_META_NATIVE_CAS_METADATA_SUFFIX, PartTransactionAction, PoolMetaNativeBackup, PoolMetaNativeInspection,
PoolMetaNativeObject, PoolMetaNativeRecoveryRequest, PoolMetaNativeRecoveryResponse,
QUOTA_MUTATION_FENCE_METADATA_SUFFIX, RUSTFS_META_BUCKET, RUSTFS_META_MULTIPART_BUCKET, RUSTFS_META_TMP_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;
@@ -515,13 +526,16 @@ pub mod metrics {
pub mod notification {
pub use crate::services::notification_sys::{
ClusterTierDailyStats, CrossPoolFenceFleetProofToken, IlmRecoveryExportFleetProofToken,
LegacyTransitionStateReconcileFleetProofToken, NotificationPeerErr, NotificationSys, ScannerPublicationLeaseGrant,
acquire_cross_pool_fence_fleet_proof, acquire_ilm_recovery_export_fleet_proof,
acquire_legacy_transition_state_reconcile_fleet_proof, configured_fleet_topology_fingerprint,
cross_pool_fence_fleet_proof_matches, get_global_notification_sys, ilm_recovery_export_fleet_proof_matches,
ilm_recovery_export_local_process_epoch, ilm_recovery_export_member_epochs_sha256,
ilm_recovery_export_topology_generation, legacy_transition_state_reconcile_fleet_proof_matches,
new_global_notification_sys, scanner_peer_transport_error_message_is_retryable, start_remote_version_state_fleet_probe,
LegacyTransitionStateReconcileFleetProofToken, MrfLifecycleWriterFleetProofToken, NotificationPeerErr, NotificationSys,
ScannerPublicationLeaseGrant, acquire_cross_pool_fence_fleet_proof, acquire_ilm_recovery_export_fleet_proof,
acquire_legacy_transition_state_reconcile_fleet_proof, acquire_mrf_lifecycle_writer_fleet_proof,
configured_fleet_topology_fingerprint, cross_pool_fence_fleet_proof_matches, get_global_notification_sys,
ilm_recovery_export_fleet_proof_matches, ilm_recovery_export_local_process_epoch,
ilm_recovery_export_member_epochs_sha256, ilm_recovery_export_topology_generation,
legacy_transition_state_reconcile_fleet_proof_matches, mrf_lifecycle_reader_local_process_epoch,
mrf_lifecycle_writer_fleet_proof_current, mrf_lifecycle_writer_fleet_proof_matches, new_global_notification_sys,
scanner_peer_transport_error_message_is_retryable, start_remote_version_state_fleet_probe,
with_mrf_lifecycle_writer_fleet_proof,
};
#[cfg(any(test, feature = "test-util"))]
pub use crate::services::notification_sys::{
@@ -87,14 +87,12 @@ use rustfs_filemeta::{
FileInfo, FileInfoOpts, NULL_VERSION_ID, RestoreStatus, RestoreStatusOps, TRANSITION_COMPLETE, get_file_info,
is_restored_object_on_disk,
};
use rustfs_gateway_types::persistence::PersistedLifecycleConfiguration;
use rustfs_gateway_types::persistence::PersistedObjectLockConfiguration as ObjectLockConfiguration;
use rustfs_lifecycle::{RestoreRequest, RestoreS3Location};
use rustfs_lock::{NamespaceLockGuard, error::LockError};
use rustfs_scanner_metrics::metrics::{
IlmAction, Metrics, ScannerLifecycleExpiryStateUpdate, ScannerLifecycleTransitionStateUpdate, global_metrics,
};
use rustfs_utils::{get_env_i64, get_env_usize, path::encode_dir_object, string::parse_bool};
use s3s::dto::{BucketLifecycleConfiguration, ExpirationStatus, ObjectLockConfiguration, RestoreRequest, RestoreRequestType};
use sha2::{Digest, Sha256};
use std::any::Any;
use std::collections::{BTreeMap, HashMap, HashSet};
@@ -300,7 +298,7 @@ impl LifecycleSys {
Arc::new(Self)
}
pub async fn get(&self, bucket: &str) -> Option<PersistedLifecycleConfiguration> {
pub async fn get(&self, bucket: &str) -> Option<BucketLifecycleConfiguration> {
match metadata_boundary::get_lifecycle_config(bucket).await {
Ok((lc, _)) => Some(lc),
Err(Error::ConfigNotFound) => None,
@@ -3936,7 +3934,7 @@ pub fn init_background_stale_multipart_upload_cleanup(api: Arc<ECStore>) {
});
}
pub async fn validate_transition_tier(lc: &PersistedLifecycleConfiguration) -> Result<(), std::io::Error> {
pub async fn validate_transition_tier(lc: &BucketLifecycleConfiguration) -> Result<(), std::io::Error> {
for rule in &lc.rules {
if let Some(transitions) = &rule.transitions {
for transition in transitions {
@@ -4053,9 +4051,8 @@ pub(crate) async fn enqueue_immediate_expiry(api: Arc<ECStore>, oi: &ObjectInfo,
.map(lifecycle::object_opts_from_object_info)
.collect::<Vec<ObjectOpts>>();
let lock_config = configs.object_lock;
let evaluator_lock_config = lock_config.clone();
let events = match Evaluator::new(lifecycle)
.with_lock_retention(evaluator_lock_config)
.with_lock_retention(lock_config.clone())
.eval(&object_opts)
.await
{
@@ -4535,26 +4532,19 @@ pub async fn enqueue_transition_for_existing_objects_scoped(
}
}
fn lifecycle_rule_has_date_expiration(lc: &PersistedLifecycleConfiguration, rule_id: &str) -> bool {
fn lifecycle_rule_has_date_expiration(lc: &BucketLifecycleConfiguration, rule_id: &str) -> bool {
lc.rules.iter().any(|rule| {
rule.status == "Enabled"
rule.status == ExpirationStatus::from_static(ExpirationStatus::ENABLED)
&& rule.id.as_deref() == Some(rule_id)
&& rule.expiration.as_ref().is_some_and(|expiration| expiration.date.is_some())
})
}
fn should_defer_date_expiry_for_recent_config_update(lc: &PersistedLifecycleConfiguration, now: OffsetDateTime) -> bool {
lc.expiry_updated_at
.as_ref()
.is_some_and(|updated_at| match rustfs_lifecycle::parse_lifecycle_date(updated_at) {
Ok(updated_at) => {
now.unix_timestamp().saturating_sub(updated_at.unix_timestamp()) < DATE_EXPIRY_EXISTING_OBJECTS_GRACE_SECS
}
Err(error) => {
warn!(error = %error, "Deferring date expiry with an unreadable update timestamp");
true
}
})
fn should_defer_date_expiry_for_recent_config_update(lc: &BucketLifecycleConfiguration, now: OffsetDateTime) -> bool {
lc.expiry_updated_at.as_ref().is_some_and(|updated_at| {
let updated_at = OffsetDateTime::from(updated_at.clone());
now.unix_timestamp().saturating_sub(updated_at.unix_timestamp()) < DATE_EXPIRY_EXISTING_OBJECTS_GRACE_SECS
})
}
async fn apply_existing_object_expiry(
@@ -4574,7 +4564,7 @@ async fn apply_existing_object_expiry(
struct ExistingObjectExpiryContext<'a> {
api: Arc<ECStore>,
bucket: &'a str,
lc: Arc<PersistedLifecycleConfiguration>,
lc: Arc<BucketLifecycleConfiguration>,
lock_config: Option<Arc<ObjectLockConfiguration>>,
bucket_incarnation_id: Uuid,
src: &'a LcEventSrc,
@@ -4594,9 +4584,8 @@ async fn enqueue_expiry_for_existing_object_group(
.iter()
.map(lifecycle::object_opts_from_object_info)
.collect::<Vec<ObjectOpts>>();
let evaluator_lock_config = context.lock_config.clone();
let events = match Evaluator::new(context.lc.clone())
.with_lock_retention(evaluator_lock_config)
.with_lock_retention(context.lock_config.clone())
.eval(&object_opts)
.await
{
@@ -4790,7 +4779,7 @@ fn manual_transition_version_marker(oi: &ObjectInfo) -> String {
async fn enqueue_transition_with_lifecycle_report(
api: Option<Arc<ECStore>>,
oi: &ObjectInfo,
lc: &PersistedLifecycleConfiguration,
lc: &BucketLifecycleConfiguration,
src: &LcEventSrc,
options: &ManualTransitionRunOptions,
report: &mut ManualTransitionRunReport,
@@ -4856,7 +4845,7 @@ async fn enqueue_transition_with_lifecycle_report(
false
}
async fn enqueue_transition_with_lifecycle(oi: &ObjectInfo, lc: &PersistedLifecycleConfiguration, src: &LcEventSrc) -> bool {
async fn enqueue_transition_with_lifecycle(oi: &ObjectInfo, lc: &BucketLifecycleConfiguration, src: &LcEventSrc) -> bool {
let event = lc.eval(&oi.to_lifecycle_opts()).await;
match event.action {
IlmAction::TransitionAction | IlmAction::TransitionVersionAction => {
@@ -5370,8 +5359,12 @@ pub async fn post_restore_opts(version_id: &str, bucket: &str, object: &str) ->
})
}
fn select_restore_s3_location(rreq: &RestoreRequest) -> Result<Option<&RestoreS3Location>, std::io::Error> {
if rreq.type_.as_ref().is_none_or(|type_| type_.as_str() != "SELECT") {
fn select_restore_s3_location(rreq: &RestoreRequest) -> Result<Option<&s3s::dto::S3Location>, std::io::Error> {
if rreq
.type_
.as_ref()
.is_none_or(|type_| type_.as_str() != RestoreRequestType::SELECT)
{
return Ok(None);
}
let output_location = rreq
@@ -5413,25 +5406,27 @@ pub async fn put_restore_opts(
// the OutputLocation metadata instead of a write to `OutputLocation.S3`
// (backlog#1341). RestoreObject rejects SELECT at the API boundary; this
// is the fail-closed backstop for any other caller.
if rreq.type_.as_ref().is_some_and(|type_| type_.as_str() == "SELECT") {
if rreq
.type_
.as_ref()
.is_some_and(|type_| type_.as_str() == RestoreRequestType::SELECT)
{
return Err(std::io::Error::other("SELECT restore requests are not supported"));
}
for (k, v) in oi.user_defined.iter() {
meta.insert(k.to_string(), v.clone());
}
// The fleet capability gate guarantees physical copy-back support. Keep
// the completed transition identity for orphan recovery and remote cleanup.
rustfs_utils::http::metadata_compat::remove_str(&mut meta, rustfs_utils::http::metadata_compat::SUFFIX_RESTORE_OPERATION_ID);
rustfs_utils::http::metadata_compat::remove_str(&mut meta, rustfs_utils::http::metadata_compat::SUFFIX_RESTORE_WORKER_LOCK);
if !oi.user_tags.is_empty() {
meta.insert(AMZ_OBJECT_TAGGING.to_string(), (*oi.user_tags).clone());
}
// Finalization publishes completion and its expiry under the object lock.
// The local copy-back commit must not make unfinished work eligible for expiry.
let restore_expiry = lifecycle::expected_expiry_time(OffsetDateTime::now_utc(), rreq.days.unwrap_or(1));
meta.insert(
metadata_keys::RESTORE.to_string(),
RestoreStatus {
is_restore_in_progress: Some(true),
restore_expiry_date: None,
is_restore_in_progress: Some(false),
restore_expiry_date: Some(restore_expiry),
}
.to_string(),
);
@@ -5445,7 +5440,6 @@ pub async fn put_restore_opts(
// computed MD5 is not the object's public plaintext ETag.
preserve_etag: oi.etag.clone(),
shard_integrity_write_mode: Some(oi.shard_integrity_write_mode()),
restore_copyback: true,
//expires: oi.expires,
..Default::default()
})
@@ -5465,18 +5459,6 @@ impl LifecycleOps for ObjectInfo {
if self.transitioned_object.status != lifecycle::TRANSITION_COMPLETE {
return false;
}
if rustfs_utils::http::metadata_compat::get_consistent_str(
&self.user_defined,
rustfs_utils::http::metadata_compat::SUFFIX_RESTORE_COPYBACK,
) == Some(rustfs_utils::http::metadata_compat::RESTORE_COPYBACK_PROTOCOL_V1)
&& self
.user_defined
.get(metadata_keys::RESTORE)
.and_then(|value| rustfs_filemeta::parse_restore_obj_status(value).ok())
.is_some_and(|status| status.is_restore_in_progress == Some(true))
{
return false;
}
!is_restored_object_on_disk(&self.user_defined)
}
}
@@ -5488,25 +5470,25 @@ pub trait RestoreRequestOps {
impl RestoreRequestOps for RestoreRequest {
fn validate(&self, _api: Arc<ECStore>) -> Result<(), std::io::Error> {
// SELECT type requires select_parameters, and vice versa
if self.type_.as_ref().is_none_or(|t| t.as_str() != "SELECT") && self.select_parameters.is_some() {
if self.type_.as_ref().is_none_or(|t| t.as_str() != RestoreRequestType::SELECT) && self.select_parameters.is_some() {
return Err(std::io::Error::other("Select parameters can only be specified with SELECT request type"));
}
if let Some(type_) = &self.type_
&& type_.as_str() == "SELECT"
&& type_.as_str() == RestoreRequestType::SELECT
&& self.select_parameters.is_none()
{
return Err(std::io::Error::other("SELECT restore request requires select parameters to be specified"));
}
// OutputLocation is only valid for SELECT requests
if self.type_.as_ref().is_none_or(|t| t.as_str() != "SELECT") && self.output_location.is_some() {
if self.type_.as_ref().is_none_or(|t| t.as_str() != RestoreRequestType::SELECT) && self.output_location.is_some() {
return Err(std::io::Error::other("OutputLocation can only be specified with SELECT request type"));
}
select_restore_s3_location(self)?;
// Days must not be specified with SELECT requests
if let Some(type_) = &self.type_
&& type_.as_str() == "SELECT"
&& type_.as_str() == RestoreRequestType::SELECT
&& self.days.is_some_and(|d| d > 0)
{
return Err(std::io::Error::other("Days cannot be specified with SELECT restore request"));
@@ -5548,7 +5530,7 @@ pub fn lifecycle_version_delete_target(oi: &ObjectInfo) -> Option<ObjectToDelete
}
pub async fn eval_action_from_lifecycle(
lc: &PersistedLifecycleConfiguration,
lc: &BucketLifecycleConfiguration,
lock_config: Option<&ObjectLockConfiguration>,
oi: &ObjectInfo,
) -> lifecycle::Event {
@@ -6253,19 +6235,14 @@ mod tests {
use rustfs_data_usage::TierStats;
use rustfs_filemeta::metadata_keys;
use rustfs_filemeta::{FileInfo, FileMeta};
use rustfs_gateway_types::dto::MetadataEntry;
use rustfs_gateway_types::persistence::{
PersistedDefaultRetention as DefaultRetention, PersistedObjectLockConfiguration as ObjectLockConfiguration,
PersistedObjectLockRule as ObjectLockRule,
};
use rustfs_gateway_types::persistence::{
PersistedLifecycleConfiguration, PersistedLifecycleExpiration, PersistedLifecycleRule,
PersistedNoncurrentVersionExpiration, PersistedTransition,
};
use rustfs_lifecycle::{RestoreOutputLocation as OutputLocation, RestoreRequest, RestoreS3Location as S3Location};
#[cfg(feature = "test-util")]
use rustfs_s3_client::transition_api::ReaderImpl;
use rustfs_scanner_metrics::metrics::{IlmAction, global_metrics};
use s3s::dto::{
BucketLifecycleConfiguration, DefaultRetention, ExpirationStatus, LifecycleExpiration, LifecycleRule, MetadataEntry,
NoncurrentVersionExpiration, ObjectLockConfiguration, ObjectLockEnabled, ObjectLockRetentionMode, ObjectLockRule,
OutputLocation, RestoreRequest, RestoreRequestType, S3Location, Timestamp, Transition, TransitionStorageClass,
};
use serial_test::serial;
use sha2::{Digest, Sha256};
use std::collections::HashMap;
@@ -9272,7 +9249,7 @@ mod tests {
output_location,
select_parameters: None,
tier: None,
type_: Some("SELECT".to_owned()),
type_: Some(RestoreRequestType::from_static(RestoreRequestType::SELECT)),
}
}
@@ -9589,12 +9566,12 @@ mod tests {
#[test]
fn lifecycle_rule_has_date_expiration_detects_enabled_date_rule() {
let lc = PersistedLifecycleConfiguration {
let lc = BucketLifecycleConfiguration {
expiry_updated_at: None,
rules: vec![PersistedLifecycleRule {
status: "Enabled".to_owned(),
expiration: Some(PersistedLifecycleExpiration {
date: Some(lifecycle::format_lifecycle_date(OffsetDateTime::now_utc()).unwrap()),
rules: vec![LifecycleRule {
status: ExpirationStatus::from_static(ExpirationStatus::ENABLED),
expiration: Some(LifecycleExpiration {
date: Some(Timestamp::from(OffsetDateTime::now_utc())),
..Default::default()
}),
id: Some("rule-date".to_string()),
@@ -10111,15 +10088,14 @@ mod tests {
#[test]
fn should_defer_date_expiry_for_recent_config_update_respects_grace_window() {
let now = OffsetDateTime::now_utc();
let recent = PersistedLifecycleConfiguration {
expiry_updated_at: Some(lifecycle::format_lifecycle_date(now - time::Duration::seconds(1)).unwrap()),
let recent = BucketLifecycleConfiguration {
expiry_updated_at: Some(Timestamp::from(now - time::Duration::seconds(1))),
rules: Vec::new(),
};
let stale = PersistedLifecycleConfiguration {
expiry_updated_at: Some(
lifecycle::format_lifecycle_date(now - time::Duration::seconds(DATE_EXPIRY_EXISTING_OBJECTS_GRACE_SECS + 1))
.unwrap(),
),
let stale = BucketLifecycleConfiguration {
expiry_updated_at: Some(Timestamp::from(
now - time::Duration::seconds(DATE_EXPIRY_EXISTING_OBJECTS_GRACE_SECS + 1),
)),
rules: Vec::new(),
};
@@ -10247,12 +10223,12 @@ mod tests {
assert_eq!(deleted.version_id, None);
}
fn expired_delete_marker_lifecycle() -> PersistedLifecycleConfiguration {
PersistedLifecycleConfiguration {
fn expired_delete_marker_lifecycle() -> BucketLifecycleConfiguration {
BucketLifecycleConfiguration {
expiry_updated_at: None,
rules: vec![PersistedLifecycleRule {
status: "Enabled".to_owned(),
expiration: Some(PersistedLifecycleExpiration {
rules: vec![LifecycleRule {
status: ExpirationStatus::from_static(ExpirationStatus::ENABLED),
expiration: Some(LifecycleExpiration {
expired_object_delete_marker: Some(true),
..Default::default()
}),
@@ -10268,12 +10244,12 @@ mod tests {
}
}
fn latest_expiration_lifecycle() -> PersistedLifecycleConfiguration {
PersistedLifecycleConfiguration {
fn latest_expiration_lifecycle() -> BucketLifecycleConfiguration {
BucketLifecycleConfiguration {
expiry_updated_at: None,
rules: vec![PersistedLifecycleRule {
status: "Enabled".to_owned(),
expiration: Some(PersistedLifecycleExpiration {
rules: vec![LifecycleRule {
status: ExpirationStatus::from_static(ExpirationStatus::ENABLED),
expiration: Some(LifecycleExpiration {
days: Some(1),
..Default::default()
}),
@@ -10289,12 +10265,12 @@ mod tests {
}
}
fn current_and_noncurrent_expiration_lifecycle() -> PersistedLifecycleConfiguration {
PersistedLifecycleConfiguration {
fn current_and_noncurrent_expiration_lifecycle() -> BucketLifecycleConfiguration {
BucketLifecycleConfiguration {
expiry_updated_at: None,
rules: vec![PersistedLifecycleRule {
status: "Enabled".to_owned(),
expiration: Some(PersistedLifecycleExpiration {
rules: vec![LifecycleRule {
status: ExpirationStatus::from_static(ExpirationStatus::ENABLED),
expiration: Some(LifecycleExpiration {
days: Some(30),
..Default::default()
}),
@@ -10302,7 +10278,7 @@ mod tests {
del_marker_expiration: None,
filter: None,
id: Some("expire-current-and-noncurrent".to_string()),
noncurrent_version_expiration: Some(PersistedNoncurrentVersionExpiration {
noncurrent_version_expiration: Some(NoncurrentVersionExpiration {
noncurrent_days: Some(1),
newer_noncurrent_versions: None,
}),
@@ -10313,12 +10289,12 @@ mod tests {
}
}
fn all_versions_expiration_lifecycle() -> PersistedLifecycleConfiguration {
PersistedLifecycleConfiguration {
fn all_versions_expiration_lifecycle() -> BucketLifecycleConfiguration {
BucketLifecycleConfiguration {
expiry_updated_at: None,
rules: vec![PersistedLifecycleRule {
status: "Enabled".to_owned(),
expiration: Some(PersistedLifecycleExpiration {
rules: vec![LifecycleRule {
status: ExpirationStatus::from_static(ExpirationStatus::ENABLED),
expiration: Some(LifecycleExpiration {
days: Some(1),
expired_object_all_versions: Some(true),
..Default::default()
@@ -10337,29 +10313,29 @@ mod tests {
fn lock_enabled_without_default_retention() -> ObjectLockConfiguration {
ObjectLockConfiguration {
object_lock_enabled: Some("Enabled".to_owned()),
object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)),
rule: None,
}
}
fn lock_enabled_with_default_retention() -> ObjectLockConfiguration {
ObjectLockConfiguration {
object_lock_enabled: Some("Enabled".to_owned()),
object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)),
rule: Some(ObjectLockRule {
default_retention: Some(DefaultRetention {
days: Some(30),
mode: Some("COMPLIANCE".to_owned()),
mode: Some(ObjectLockRetentionMode::from_static(ObjectLockRetentionMode::COMPLIANCE)),
years: None,
}),
}),
}
}
fn latest_transition_lifecycle() -> PersistedLifecycleConfiguration {
PersistedLifecycleConfiguration {
fn latest_transition_lifecycle() -> BucketLifecycleConfiguration {
BucketLifecycleConfiguration {
expiry_updated_at: None,
rules: vec![PersistedLifecycleRule {
status: "Enabled".to_owned(),
rules: vec![LifecycleRule {
status: ExpirationStatus::from_static(ExpirationStatus::ENABLED),
expiration: None,
abort_incomplete_multipart_upload: None,
del_marker_expiration: None,
@@ -10368,10 +10344,10 @@ mod tests {
noncurrent_version_expiration: None,
noncurrent_version_transitions: None,
prefix: None,
transitions: Some(vec![PersistedTransition {
transitions: Some(vec![Transition {
days: Some(1),
date: None,
storage_class: Some("WARM".to_owned()),
storage_class: Some(TransitionStorageClass::from_static("WARM")),
}]),
}],
}
@@ -12914,9 +12890,8 @@ mod tests {
for (name, object, lock_config, expected) in cases {
let object_opts = lifecycle::object_opts_from_object_info(&object);
let evaluator_lock_config = lock_config.clone().map(Arc::new);
let batch_event = Evaluator::new(Arc::new(lifecycle.clone()))
.with_lock_retention(evaluator_lock_config)
.with_lock_retention(lock_config.clone().map(Arc::new))
.eval(&[object_opts])
.await
.expect("batch lifecycle evaluation should succeed")
@@ -14173,7 +14148,10 @@ mod tests {
let object = current_object_with_metadata(
ReplicationStatusType::Completed,
HashMap::from([
(metadata_keys::OBJECT_LOCK_MODE.to_string(), "COMPLIANCE".to_string()),
(
metadata_keys::OBJECT_LOCK_MODE.to_string(),
s3s::dto::ObjectLockRetentionMode::COMPLIANCE.to_string(),
),
(metadata_keys::OBJECT_LOCK_RETAIN_UNTIL_DATE.to_string(), retain_until),
]),
);
@@ -14185,7 +14163,7 @@ mod tests {
#[tokio::test]
async fn restored_copy_expiry_is_not_blocked_by_retention() {
let lifecycle = PersistedLifecycleConfiguration {
let lifecycle = BucketLifecycleConfiguration {
expiry_updated_at: None,
rules: Vec::new(),
};
@@ -14195,7 +14173,10 @@ mod tests {
let mut object = current_object_with_metadata(
ReplicationStatusType::Completed,
HashMap::from([
(metadata_keys::OBJECT_LOCK_MODE.to_string(), "COMPLIANCE".to_string()),
(
metadata_keys::OBJECT_LOCK_MODE.to_string(),
ObjectLockRetentionMode::COMPLIANCE.to_string(),
),
(metadata_keys::OBJECT_LOCK_RETAIN_UNTIL_DATE.to_string(), retain_until),
]),
);
@@ -14412,7 +14393,7 @@ mod tests {
"removed" => {
metadata_sys::delete(&bucket, BUCKET_LIFECYCLE_CONFIG).await.unwrap();
}
"disabled" => changed.rules[0].status = "Disabled".to_owned(),
"disabled" => changed.rules[0].status = ExpirationStatus::from_static(ExpirationStatus::DISABLED),
"longer-days" => {
changed.rules[0]
.noncurrent_version_expiration
@@ -14528,7 +14509,7 @@ mod tests {
);
}
async fn install_test_expiry_policy(ecstore: &Arc<ECStore>, bucket: &str) -> Arc<PersistedLifecycleConfiguration> {
async fn install_test_expiry_policy(ecstore: &Arc<ECStore>, bucket: &str) -> Arc<BucketLifecycleConfiguration> {
let xml = br#"<LifecycleConfiguration><Rule><ID>expire</ID><Status>Enabled</Status><Filter><Prefix></Prefix></Filter><Expiration><Days>0</Days></Expiration><NoncurrentVersionExpiration><NoncurrentDays>0</NoncurrentDays></NoncurrentVersionExpiration></Rule></LifecycleConfiguration>"#;
metadata_sys::update_in(&ecstore.ctx, bucket, BUCKET_LIFECYCLE_CONFIG, xml.to_vec())
.await
@@ -15001,11 +14982,7 @@ mod tests {
#[tokio::test]
#[serial]
async fn tier_free_version_recovery_production_entrypoint_enqueues_seeded_item() {
let (disk_paths, ecstore) =
temp_env::async_with_vars([(super::ENV_TIER_FREE_VERSION_RECOVERY_ENABLED, Some("false"))], async {
setup_test_env().await
})
.await;
let (disk_paths, ecstore) = setup_test_env().await;
let tier = format!("RECOVERY{}", Uuid::new_v4().simple()).to_uppercase();
crate::services::tier::test_util::register_mock_tier(&ecstore.tier_config_mgr(), &tier).await;
let runtime_state = install_unconsumed_runtime_expiry_worker(&ecstore, 1).await;
+1 -1
View File
@@ -17,7 +17,7 @@ use crate::object_api::ObjectInfo;
pub use rustfs_lifecycle::{
Event, ExpirationOptions, IlmAction, LIFECYCLE_MALFORMED_XML_ERROR_KIND, Lifecycle, LifecycleCalculate, ObjectOpts,
RuleValidate, TRANSITION_COMPLETE, TRANSITION_PENDING, TransitionOptions, abort_incomplete_multipart_upload_due,
expected_expiry_time, expiration_action_has_valid_target, format_lifecycle_date, parse_lifecycle_date,
expected_expiry_time, expiration_action_has_valid_target,
};
pub fn object_opts_from_object_info(oi: &ObjectInfo) -> ObjectOpts {
@@ -14,8 +14,7 @@
use std::sync::Arc;
use rustfs_gateway_types::persistence::PersistedLifecycleConfiguration;
use rustfs_gateway_types::persistence::PersistedObjectLockConfiguration as ObjectLockConfiguration;
use s3s::dto::{BucketLifecycleConfiguration, ObjectLockConfiguration};
use time::OffsetDateTime;
use uuid::Uuid;
@@ -25,7 +24,7 @@ use crate::error::{Error, Result};
#[derive(Debug)]
pub(crate) struct LifecycleExpiryConfigs {
pub(crate) lifecycle: Option<Arc<PersistedLifecycleConfiguration>>,
pub(crate) lifecycle: Option<Arc<BucketLifecycleConfiguration>>,
pub(crate) object_lock: Option<Arc<ObjectLockConfiguration>>,
pub(crate) bucket_incarnation_id: Uuid,
pub(crate) table_bucket_enabled: bool,
@@ -108,7 +107,7 @@ pub(crate) async fn get_expiry_configs_for_options(
})
}
pub(crate) async fn get_lifecycle_config(bucket: &str) -> Result<(PersistedLifecycleConfiguration, OffsetDateTime)> {
pub(crate) async fn get_lifecycle_config(bucket: &str) -> Result<(BucketLifecycleConfiguration, OffsetDateTime)> {
metadata_sys::get_lifecycle_config(bucket).await
}
@@ -118,15 +117,15 @@ mod tests {
use crate::bucket::metadata::BucketMetadata;
use crate::bucket::metadata_sys::{self, test_support::isolated_store_over_temp_disks};
use crate::storage_api_contracts::bucket::MakeBucketOptions;
use rustfs_gateway_types::persistence::{PersistedLifecycleExpiration, PersistedLifecycleRule};
use s3s::dto::{ExpirationStatus, LifecycleExpiration, LifecycleRule};
use serial_test::serial;
fn lifecycle_config() -> PersistedLifecycleConfiguration {
PersistedLifecycleConfiguration {
fn lifecycle_config() -> BucketLifecycleConfiguration {
BucketLifecycleConfiguration {
expiry_updated_at: None,
rules: vec![PersistedLifecycleRule {
status: "Enabled".to_owned(),
expiration: Some(PersistedLifecycleExpiration {
rules: vec![LifecycleRule {
status: ExpirationStatus::from_static(ExpirationStatus::ENABLED),
expiration: Some(LifecycleExpiration {
days: Some(1),
..Default::default()
}),
@@ -22,7 +22,7 @@ pub(crate) fn is_object_locked_by_metadata(user_defined: &HashMap<String, String
}
pub(crate) fn check_object_lock_for_deletion_with_config(
config: Option<&rustfs_gateway_types::persistence::PersistedObjectLockConfiguration>,
config: Option<&s3s::dto::ObjectLockConfiguration>,
obj_info: &ObjectInfo,
bypass_governance: bool,
) -> crate::error::Result<Option<ObjectLockBlockReason>> {
@@ -82,13 +82,12 @@ mod tests {
use crate::bucket::replication::{DeleteReplicationConfigSnapshot, ReplicationObjectBridge};
use crate::object_api::{ObjectInfo, ObjectOptions};
use crate::storage_api_contracts::object::ObjectToDelete;
use rustfs_gateway_types::dto::Status;
use rustfs_gateway_types::persistence::PersistedVersioningConfiguration as VersioningConfiguration;
use rustfs_gateway_types::persistence::{
PersistedOptionalReplicationStatus, PersistedReplicationConfiguration, PersistedReplicationDestination,
PersistedReplicationRule, PersistedReplicationStatus,
};
use rustfs_scanner_metrics::metrics::IlmAction;
use s3s::dto::{
BucketVersioningStatus, DeleteMarkerReplication, DeleteMarkerReplicationStatus, DeleteReplication,
DeleteReplicationStatus, Destination, ReplicationConfiguration, ReplicationRule, ReplicationRuleStatus,
VersioningConfiguration,
};
use uuid::Uuid;
use super::*;
@@ -161,27 +160,27 @@ mod tests {
for purge_enabled in [false, true] {
let snapshot = DeleteReplicationConfigSnapshot::from_configs_for_test(
VersioningConfiguration {
status: Some("Enabled".to_owned()),
status: Some(BucketVersioningStatus::from_static(BucketVersioningStatus::ENABLED)),
..Default::default()
},
Some(PersistedReplicationConfiguration {
Some(ReplicationConfiguration {
role: String::new(),
rules: vec![PersistedReplicationRule {
delete_marker_replication: Some(PersistedOptionalReplicationStatus {
rules: vec![ReplicationRule {
delete_marker_replication: Some(DeleteMarkerReplication {
status: Some(if marker_enabled {
Status::ENABLED.to_string()
DeleteMarkerReplicationStatus::from_static(DeleteMarkerReplicationStatus::ENABLED)
} else {
Status::DISABLED.to_string()
DeleteMarkerReplicationStatus::from_static(DeleteMarkerReplicationStatus::DISABLED)
}),
}),
delete_replication: Some(PersistedReplicationStatus {
delete_replication: Some(DeleteReplication {
status: if purge_enabled {
Status::ENABLED.to_string()
DeleteReplicationStatus::from_static(DeleteReplicationStatus::ENABLED)
} else {
Status::DISABLED.to_string()
DeleteReplicationStatus::from_static(DeleteReplicationStatus::DISABLED)
},
}),
destination: PersistedReplicationDestination {
destination: Destination {
bucket: "arn:rustfs:replication:target".to_string(),
..Default::default()
},
@@ -191,7 +190,7 @@ mod tests {
prefix: Some(String::new()),
priority: Some(1),
source_selection_criteria: None,
status: Status::ENABLED.to_string(),
status: ReplicationRuleStatus::from_static(ReplicationRuleStatus::ENABLED),
}],
}),
);
@@ -14,7 +14,7 @@
use std::sync::Arc;
use rustfs_gateway_types::persistence::PersistedLifecycleConfiguration;
use s3s::dto::BucketLifecycleConfiguration;
use tokio::sync::RwLock;
use tokio_util::sync::CancellationToken;
@@ -52,7 +52,7 @@ pub(crate) fn deployment_id() -> Option<String> {
sources::deployment_id()
}
pub(crate) async fn bucket_lifecycle_config(bucket: &str) -> Option<PersistedLifecycleConfiguration> {
pub(crate) async fn bucket_lifecycle_config(bucket: &str) -> Option<BucketLifecycleConfiguration> {
sources::bucket_lifecycle_config(bucket).await
}
@@ -23,6 +23,7 @@ use std::{
};
use futures::StreamExt;
use rand::RngExt as _;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use tokio_util::sync::CancellationToken;
@@ -70,6 +71,7 @@ pub(crate) const EVENT_LIFECYCLE_TIER_DELETE_JOURNAL: &str = "lifecycle_tier_del
// successful pages while its wall-clock budget remains.
pub const DEFAULT_TIER_DELETE_JOURNAL_RECOVERY_LIMIT: usize = 8;
const TIER_DELETE_JOURNAL_RECOVERY_INTERVAL: Duration = Duration::from_secs(60);
const TIER_DELETE_JOURNAL_RECOVERY_MAX_IDLE_INTERVAL: Duration = Duration::from_secs(5 * 60);
const TIER_DELETE_JOURNAL_RECOVERY_TIMEOUT: Duration = Duration::from_secs(300);
const TIER_DELETE_JOURNAL_RECOVERY_PASS_BUDGET: Duration = Duration::from_secs(240);
const TIER_DELETE_REMOTE_DEADLINE: Duration = Duration::from_secs(30);
@@ -6104,57 +6106,114 @@ pub(crate) async fn recover_test_tier_delete_journal_pass_with_budget(
recover_tier_delete_journal_pass(api, cancel_token, manifest_marker, journal_marker, budget).await
}
pub async fn run_tier_delete_journal_recovery_loop(api: Arc<ECStore>, cancel_token: CancellationToken) {
fn tier_delete_journal_recovery_pass_is_idle(stats: &TierDeleteJournalRecoveryPassStats) -> bool {
stats.manifest_pages > 0
&& stats.journal_pages > 0
&& stats.manifest_errors == 0
&& stats.journal_errors == 0
&& !stats.deadline_exhausted
&& !stats.canceled
&& stats.manifests == TierDeleteDispatchManifestRecoveryStats::default()
&& stats.journals == TierDeleteJournalRecoveryStats::default()
}
fn bounded_tier_delete_journal_recovery_delay(delay: Duration, percent: u32) -> Duration {
(delay.saturating_mul(percent) / 100)
.clamp(TIER_DELETE_JOURNAL_RECOVERY_INTERVAL, TIER_DELETE_JOURNAL_RECOVERY_MAX_IDLE_INTERVAL)
}
fn jitter_tier_delete_journal_recovery_delay(delay: Duration) -> Duration {
bounded_tier_delete_journal_recovery_delay(delay, rand::rng().random_range(90..=110))
}
async fn drive_tier_delete_journal_recovery(
cancel_token: &CancellationToken,
wait_for_wakeup: impl AsyncFn(),
mut recover: impl AsyncFnMut() -> TierDeleteJournalRecoveryPassStats,
jitter_delay: fn(Duration) -> Duration,
) {
let mut interval = tokio::time::interval(TIER_DELETE_JOURNAL_RECOVERY_INTERVAL);
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
let mut marker: Option<String> = None;
let mut manifest_marker: Option<String> = None;
let mut idle_interval = TIER_DELETE_JOURNAL_RECOVERY_INTERVAL;
loop {
tokio::select! {
biased;
_ = cancel_token.cancelled() => return,
_ = wait_for_wakeup() => {
idle_interval = TIER_DELETE_JOURNAL_RECOVERY_INTERVAL;
interval.reset();
},
_ = interval.tick() => {},
_ = api.ctx.wait_for_tier_delete_journal_recovery() => {},
}
let stats = recover_tier_delete_journal_pass(
api.clone(),
&cancel_token,
&mut manifest_marker,
&mut marker,
TIER_DELETE_JOURNAL_RECOVERY_PASS_BUDGET,
)
.await;
let stats = recover().await;
if stats.canceled {
return;
}
debug!(
event = EVENT_LIFECYCLE_TIER_DELETE_JOURNAL,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_LIFECYCLE,
manifest_pages = stats.manifest_pages,
manifest_scanned = stats.manifests.scanned,
manifest_advanced = stats.manifests.advanced,
manifest_deleted = stats.manifests.deleted,
manifest_retained = stats.manifests.retained,
manifest_failed = stats.manifests.failed,
manifest_errors = stats.manifest_errors,
manifest_truncated = stats.manifests.truncated,
manifest_next_marker = ?manifest_marker,
journal_pages = stats.journal_pages,
journal_scanned = stats.journals.scanned,
journal_deleted = stats.journals.deleted,
journal_failed = stats.journals.failed,
journal_errors = stats.journal_errors,
journal_truncated = stats.journals.truncated,
journal_next_marker = ?marker,
deadline_exhausted = stats.deadline_exhausted,
"Recovered tier delete journal pass"
);
if tier_delete_journal_recovery_pass_is_idle(&stats) {
// Only a completed empty sweep may defer discovery. Retained,
// failed, or paginated work keeps the original retry tick budget.
interval.reset_after(jitter_delay(idle_interval));
idle_interval = idle_interval
.saturating_mul(2)
.min(TIER_DELETE_JOURNAL_RECOVERY_MAX_IDLE_INTERVAL);
} else {
idle_interval = TIER_DELETE_JOURNAL_RECOVERY_INTERVAL;
}
}
}
pub async fn run_tier_delete_journal_recovery_loop(api: Arc<ECStore>, cancel_token: CancellationToken) {
let mut marker: Option<String> = None;
let mut manifest_marker: Option<String> = None;
let wake_context = api.ctx.clone();
let recovery_cancel_token = cancel_token.clone();
drive_tier_delete_journal_recovery(
&cancel_token,
async move || wake_context.wait_for_tier_delete_journal_recovery().await,
async move || {
let stats = recover_tier_delete_journal_pass(
api.clone(),
&recovery_cancel_token,
&mut manifest_marker,
&mut marker,
TIER_DELETE_JOURNAL_RECOVERY_PASS_BUDGET,
)
.await;
if stats.canceled {
return stats;
}
debug!(
event = EVENT_LIFECYCLE_TIER_DELETE_JOURNAL,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_LIFECYCLE,
manifest_pages = stats.manifest_pages,
manifest_scanned = stats.manifests.scanned,
manifest_advanced = stats.manifests.advanced,
manifest_deleted = stats.manifests.deleted,
manifest_retained = stats.manifests.retained,
manifest_failed = stats.manifests.failed,
manifest_errors = stats.manifest_errors,
manifest_truncated = stats.manifests.truncated,
manifest_next_marker = ?manifest_marker,
journal_pages = stats.journal_pages,
journal_scanned = stats.journals.scanned,
journal_deleted = stats.journals.deleted,
journal_failed = stats.journals.failed,
journal_errors = stats.journal_errors,
journal_truncated = stats.journals.truncated,
journal_next_marker = ?marker,
deadline_exhausted = stats.deadline_exhausted,
"Recovered tier delete journal pass"
);
stats
},
jitter_tier_delete_journal_recovery_delay,
)
.await;
}
async fn await_tier_delete_journal_recovery<T, F>(
cancel_token: &CancellationToken,
timeout: Duration,
@@ -6180,16 +6239,20 @@ mod tests {
use super::{
PersistedTierDeleteJournalEntry, TIER_DELETE_DISPATCH_MANIFEST_VERSION, TIER_DELETE_DISPATCH_PARENT_RECORD_TYPE,
TIER_DELETE_DISPATCH_PARENT_VERSION, TIER_DELETE_JOURNAL_EXACT_VERSION, TIER_DELETE_JOURNAL_LEGACY_PREFIX,
TIER_DELETE_JOURNAL_RECOVERY_INTERVAL, TIER_DELETE_JOURNAL_RECOVERY_MAX_IDLE_INTERVAL,
TIER_DELETE_JOURNAL_SOLE_OWNER_VERSION, TIER_DELETE_JOURNAL_STATE_VERSION, TIER_DELETE_JOURNAL_TRANSACTION_VERSION,
TIER_DELETE_JOURNAL_V1_RECOVERY_SCHEMA, TIER_DELETE_JOURNAL_V2_RECOVERY_SCHEMA, TIER_DELETE_JOURNAL_V6_PREFIX,
TIER_DELETE_JOURNAL_VERSION, TierDeleteDispatchChunkBinding, TierDeleteDispatchManifest, TierDeleteDispatchManifestState,
TierDeleteDispatchParent, TierDeleteDispatchParentState, TierDeleteDispatchRecord, await_tier_delete_journal_recovery,
decode_tier_delete_dispatch_record, decode_tier_delete_journal_entry, encode_tier_delete_dispatch_manifest,
TIER_DELETE_JOURNAL_VERSION, TierDeleteDispatchChunkBinding, TierDeleteDispatchManifest,
TierDeleteDispatchManifestRecoveryStats, TierDeleteDispatchManifestState, TierDeleteDispatchParent,
TierDeleteDispatchParentState, TierDeleteDispatchRecord, TierDeleteJournalRecoveryPassStats,
await_tier_delete_journal_recovery, bounded_tier_delete_journal_recovery_delay, decode_tier_delete_dispatch_record,
decode_tier_delete_journal_entry, drive_tier_delete_journal_recovery, encode_tier_delete_dispatch_manifest,
encode_tier_delete_dispatch_parent, encode_tier_delete_journal_entry, object_info_references_tier_delete,
record_tier_delete_journal_backend_identity, same_tier_delete_authorization_identity, same_tier_delete_journal_identity,
tier_delete_dispatch_child_matches_parent, tier_delete_dispatch_chunk_manifest_object_name,
tier_delete_dispatch_journal_set_digest, tier_delete_dispatch_manifest_object_name, tier_delete_journal_object_name,
tier_delete_source_matches_dispatch_scope, validate_legacy_tier_delete_recovery_source,
tier_delete_journal_recovery_pass_is_idle, tier_delete_source_matches_dispatch_scope,
validate_legacy_tier_delete_recovery_source,
};
use crate::bucket::lifecycle::tier_sweeper::{
Jentry, TierDeleteDispatchBinding, TierDeleteJournalState, TierDeleteSourceIdentity,
@@ -6833,6 +6896,213 @@ mod tests {
assert!(err.to_string().contains("decode tier delete journal failed"));
}
fn empty_recovery_pass() -> TierDeleteJournalRecoveryPassStats {
TierDeleteJournalRecoveryPassStats {
manifest_pages: 1,
journal_pages: 1,
..Default::default()
}
}
#[test]
fn tier_delete_journal_recovery_requires_both_completed_empty_queues_for_idle() {
assert!(tier_delete_journal_recovery_pass_is_idle(&empty_recovery_pass()));
let mut cases = Vec::new();
cases.push(TierDeleteJournalRecoveryPassStats::default());
for change in [
|stats: &mut TierDeleteJournalRecoveryPassStats| stats.manifest_pages = 0,
|stats: &mut TierDeleteJournalRecoveryPassStats| stats.journal_pages = 0,
|stats: &mut TierDeleteJournalRecoveryPassStats| stats.manifest_errors = 1,
|stats: &mut TierDeleteJournalRecoveryPassStats| stats.journal_errors = 1,
|stats: &mut TierDeleteJournalRecoveryPassStats| stats.deadline_exhausted = true,
|stats: &mut TierDeleteJournalRecoveryPassStats| stats.canceled = true,
|stats: &mut TierDeleteJournalRecoveryPassStats| stats.manifests.scanned = 1,
|stats: &mut TierDeleteJournalRecoveryPassStats| stats.manifests.retained = 1,
|stats: &mut TierDeleteJournalRecoveryPassStats| stats.manifests.failed = 1,
|stats: &mut TierDeleteJournalRecoveryPassStats| stats.manifests.truncated = true,
|stats: &mut TierDeleteJournalRecoveryPassStats| stats.manifests.next_marker = Some("next".to_owned()),
|stats: &mut TierDeleteJournalRecoveryPassStats| stats.journals.scanned = 1,
|stats: &mut TierDeleteJournalRecoveryPassStats| stats.journals.failed = 1,
|stats: &mut TierDeleteJournalRecoveryPassStats| stats.journals.truncated = true,
|stats: &mut TierDeleteJournalRecoveryPassStats| stats.journals.next_marker = Some("next".to_owned()),
] {
let mut stats = empty_recovery_pass();
change(&mut stats);
cases.push(stats);
}
for stats in cases {
assert!(
!tier_delete_journal_recovery_pass_is_idle(&stats),
"unfinished recovery was considered idle: {stats:?}"
);
}
}
#[test]
fn tier_delete_journal_recovery_jitter_preserves_discovery_bounds() {
for delay in [
Duration::ZERO,
Duration::from_secs(60),
Duration::from_secs(120),
Duration::from_secs(300),
Duration::MAX,
] {
for percent in [90, 100, 110] {
let jittered = bounded_tier_delete_journal_recovery_delay(delay, percent);
assert!(jittered >= TIER_DELETE_JOURNAL_RECOVERY_INTERVAL);
assert!(jittered <= TIER_DELETE_JOURNAL_RECOVERY_MAX_IDLE_INTERVAL);
if delay == Duration::from_secs(120) {
assert!((Duration::from_secs(108)..=Duration::from_secs(132)).contains(&jittered));
}
}
}
}
#[tokio::test(start_paused = true)]
async fn tier_delete_journal_recovery_loop_starts_immediately_backs_off_and_wakes() {
let cancel = CancellationToken::new();
let wake = tokio::sync::Notify::new();
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let started = tokio::time::Instant::now();
let recovery = drive_tier_delete_journal_recovery(
&cancel,
async || wake.notified().await,
async || {
tx.send(tokio::time::Instant::now()).expect("test must observe each pass");
empty_recovery_pass()
},
|delay| bounded_tier_delete_journal_recovery_delay(delay, 110),
);
let exercise = async {
assert_eq!(rx.recv().await.expect("startup recovery must run"), started);
let mut elapsed = Duration::ZERO;
for delay in [66, 132, 264, 300, 300] {
tokio::time::advance(Duration::from_secs(delay - 1)).await;
tokio::task::yield_now().await;
assert!(rx.try_recv().is_err(), "idle pass ran before its backoff");
tokio::time::advance(Duration::from_secs(1)).await;
elapsed += Duration::from_secs(delay);
assert_eq!(rx.recv().await.expect("bounded idle recovery must run"), started + elapsed);
}
wake.notify_one();
assert_eq!(rx.recv().await.expect("notification must bypass idle delay"), started + elapsed);
let first_idle_delay = Duration::from_secs(66);
tokio::time::advance(first_idle_delay).await;
assert_eq!(
rx.recv().await.expect("wakeup must reset idle backoff"),
started + elapsed + first_idle_delay
);
cancel.cancel();
};
tokio::join!(recovery, exercise);
}
#[tokio::test(start_paused = true)]
async fn tier_delete_journal_recovery_loop_preserves_known_work_tick_deadline() {
let cancel = CancellationToken::new();
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let started = tokio::time::Instant::now();
let mut pass = 0;
let recovery = drive_tier_delete_journal_recovery(
&cancel,
async || std::future::pending::<()>().await,
async || {
tx.send(tokio::time::Instant::now()).expect("test must observe each pass");
pass += 1;
if pass <= 3 {
return empty_recovery_pass();
}
if pass == 4 {
tokio::time::sleep(Duration::from_secs(40)).await;
}
let mut stats = empty_recovery_pass();
match pass {
4 => {
stats.manifests = TierDeleteDispatchManifestRecoveryStats {
retained: 1,
scanned: 1,
..Default::default()
}
}
5 => stats.manifest_errors = 1,
6 => stats.journal_errors = 1,
7 => stats.manifests.truncated = true,
8 => stats.journals.failed = 1,
9 => stats.deadline_exhausted = true,
_ => stats.journals.next_marker = Some("next-page".to_owned()),
}
stats
},
std::convert::identity,
);
let exercise = async {
for expected in [0, 60, 180, 420] {
assert_eq!(
rx.recv().await.expect("idle and work passes must run"),
started + Duration::from_secs(expected)
);
}
tokio::time::advance(Duration::from_secs(59)).await;
tokio::task::yield_now().await;
assert!(rx.try_recv().is_err(), "known work ran before the next tick");
tokio::time::advance(Duration::from_secs(1)).await;
for expected in [480, 540, 600, 660, 720, 780, 840] {
assert_eq!(
rx.recv().await.expect("unfinished work must retain the base tick deadline"),
started + Duration::from_secs(expected)
);
}
cancel.cancel();
};
tokio::join!(recovery, exercise);
}
#[tokio::test(start_paused = true)]
async fn tier_delete_journal_recovery_loop_keeps_wakeup_received_during_a_pass() {
let cancel = CancellationToken::new();
let wake = tokio::sync::Notify::new();
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let started = tokio::time::Instant::now();
let mut first = true;
let recovery = drive_tier_delete_journal_recovery(
&cancel,
async || wake.notified().await,
async || {
tx.send(tokio::time::Instant::now()).expect("test must observe each pass");
if first {
first = false;
tokio::time::sleep(Duration::from_secs(40)).await;
}
empty_recovery_pass()
},
std::convert::identity,
);
let exercise = async {
assert_eq!(rx.recv().await.expect("startup recovery must run"), started);
wake.notify_one();
tokio::time::advance(Duration::from_secs(40)).await;
assert_eq!(
rx.recv().await.expect("a notification during recovery must remain pending"),
started + Duration::from_secs(40)
);
cancel.cancel();
};
tokio::join!(recovery, exercise);
}
#[tokio::test(start_paused = true)]
async fn tier_delete_journal_recovery_loop_cancellation_wins_over_ready_wakeup() {
let cancel = CancellationToken::new();
cancel.cancel();
drive_tier_delete_journal_recovery(
&cancel,
async || {},
async || panic!("shutdown must prevent admission even when a wakeup and startup tick are ready"),
std::convert::identity,
)
.await;
}
#[tokio::test]
async fn tier_delete_journal_recovery_has_a_hard_outer_timeout() {
let result = await_tier_delete_journal_recovery(
+40 -51
View File
@@ -13,7 +13,7 @@
// limitations under the License.
use super::msgp_decode::{read_msgp_ext8_time, skip_msgp_value, write_msgp_time};
use super::object_lock::ObjectLockApi;
use super::object_lock::{ObjectLockApi, ObjectLockStatusExt};
use super::versioning::VersioningApi;
use super::{quota::BucketQuota, target::BucketTargets};
use crate::bucket::replication::invalid_replication_config_status_field;
@@ -25,17 +25,12 @@ use crate::object_api::WriteCompletion;
use crate::runtime::sources as runtime_sources;
use crate::store::ECStore;
use byteorder::{BigEndian, ByteOrder, LittleEndian};
use rustfs_gateway_types::cors_tagging::{PersistedCorsConfiguration as CORSConfiguration, PersistedTagging as Tagging};
use rustfs_gateway_types::persistence::{
PersistedAccelerateConfiguration as AccelerateConfiguration,
PersistedBucketEncryptionConfiguration as ServerSideEncryptionConfiguration,
PersistedBucketLoggingStatus as BucketLoggingStatus, PersistedLifecycleConfiguration,
PersistedNotificationConfiguration as NotificationConfiguration, PersistedObjectLockConfiguration as ObjectLockConfiguration,
PersistedPublicAccessBlockConfiguration as PublicAccessBlockConfiguration, PersistedReplicationConfiguration,
PersistedRequestPaymentConfiguration as RequestPaymentConfiguration,
PersistedVersioningConfiguration as VersioningConfiguration, PersistedWebsiteConfiguration as WebsiteConfiguration,
};
use rustfs_policy::policy::BucketPolicy;
use s3s::dto::{
AccelerateConfiguration, BucketLifecycleConfiguration, BucketLoggingStatus, BucketVersioningStatus, CORSConfiguration,
NotificationConfiguration, ObjectLockConfiguration, PublicAccessBlockConfiguration, ReplicationConfiguration,
RequestPaymentConfiguration, ServerSideEncryptionConfiguration, Tagging, VersioningConfiguration, WebsiteConfiguration,
};
use serde::Serializer;
use sha2::{Digest, Sha256};
use std::collections::HashMap;
@@ -45,11 +40,23 @@ use time::{Date, OffsetDateTime, PrimitiveDateTime, Time as CivilTime, UtcOffset
use tracing::error;
use uuid::Uuid;
// The persisted Object Lock configuration answers the storage-level trait
// here because this module owns the persisted `ObjectLockConfiguration`.
// The serving-layer DTO impls for the storage-level Object Lock traits live
// here because this module owns the persisted `ObjectLockConfiguration`
// during the s3s ratchet migration (rustfs/backlog#1842).
impl ObjectLockApi for ObjectLockConfiguration {
fn enabled(&self) -> bool {
self.object_lock_enabled.as_deref() == Some("Enabled")
self.object_lock_enabled
.as_ref()
.is_some_and(|v| v.as_str() == s3s::dto::ObjectLockEnabled::ENABLED)
}
}
impl ObjectLockStatusExt for s3s::dto::ObjectLockLegalHoldStatus {
fn valid(&self) -> bool {
matches!(
self.as_str(),
s3s::dto::ObjectLockLegalHoldStatus::ON | s3s::dto::ObjectLockLegalHoldStatus::OFF
)
}
}
@@ -432,8 +439,6 @@ pub struct BucketMetadata {
pub bucket_incarnation_id: Uuid,
pub(crate) bucket_incarnation_sidecar: bool,
pub(crate) bucket_creation_committed: bool,
// A legacy default is not proof of an interrupted creation.
pub(crate) bucket_creation_commit_record_present: bool,
pub policy_config_json: Vec<u8>,
pub notification_config_xml: Vec<u8>,
pub lifecycle_config_xml: Vec<u8>,
@@ -482,13 +487,13 @@ pub struct BucketMetadata {
pub policy_config: Option<BucketPolicy>,
pub notification_config: Option<NotificationConfiguration>,
pub lifecycle_config: Option<PersistedLifecycleConfiguration>,
pub lifecycle_config: Option<BucketLifecycleConfiguration>,
pub object_lock_config: Option<ObjectLockConfiguration>,
pub versioning_config: Option<VersioningConfiguration>,
pub sse_config: Option<ServerSideEncryptionConfiguration>,
pub tagging_config: Option<Tagging>,
pub quota_config: Option<BucketQuota>,
pub replication_config: Option<PersistedReplicationConfiguration>,
pub replication_config: Option<ReplicationConfiguration>,
pub bucket_target_config: Option<BucketTargets>,
pub bucket_target_config_meta: Option<HashMap<String, String>>,
pub cors_config: Option<CORSConfiguration>,
@@ -509,7 +514,6 @@ impl Default for BucketMetadata {
bucket_incarnation_id: Uuid::nil(),
bucket_incarnation_sidecar: false,
bucket_creation_committed: false,
bucket_creation_commit_record_present: false,
policy_config_json: Default::default(),
notification_config_xml: Default::default(),
lifecycle_config_xml: Default::default(),
@@ -748,10 +752,7 @@ impl BucketMetadata {
"TableBucketConfigUpdatedAt" => self.table_bucket_config_updated_at = read_msgp_time_value(rd)?,
"DurabilityConfigUpdatedAt" => self.durability_config_updated_at = read_msgp_time_value(rd)?,
"OnDemandMigrationConfigUpdatedAt" => self.on_demand_migration_config_updated_at = read_msgp_time_value(rd)?,
"BucketCreationCommitted" => {
self.bucket_creation_committed = read_msgp_bool(rd)?;
self.bucket_creation_commit_record_present = true;
}
"BucketCreationCommitted" => self.bucket_creation_committed = read_msgp_bool(rd)?,
other => {
tracing::debug!(field = %other, "BucketMetadata decode_from: skipping unknown field");
skip_msgp_value(rd)?;
@@ -764,10 +765,8 @@ impl BucketMetadata {
/// Encode to msgp bytes. Field order follows MinIO BucketMetadata for compatibility.
pub fn encode_to<W: Write>(&self, wr: &mut W) -> Result<()> {
// Preserve the absence of a legacy commit record across config rewrites.
let commit_record_present = self.bucket_creation_commit_record_present || self.bucket_creation_committed;
// Map size: MinIO fields (25) + RustFS extensions (21) + optional commit record.
let map_len: u32 = 46 + u32::from(commit_record_present);
// Map size: MinIO fields (25) + RustFS extensions (22)
let map_len: u32 = 47;
rmp::encode::write_map_len(wr, map_len)?;
// MinIO field order (same as Go struct)
@@ -848,10 +847,8 @@ impl BucketMetadata {
write_msgp_time(wr, self.durability_config_updated_at)?;
rmp::encode::write_str(wr, "OnDemandMigrationConfigUpdatedAt")?;
write_msgp_time(wr, self.on_demand_migration_config_updated_at)?;
if commit_record_present {
rmp::encode::write_str(wr, "BucketCreationCommitted")?;
rmp::encode::write_bool(wr, self.bucket_creation_committed)?;
}
rmp::encode::write_str(wr, "BucketCreationCommitted")?;
rmp::encode::write_bool(wr, self.bucket_creation_committed)?;
Ok(())
}
@@ -1021,11 +1018,9 @@ impl BucketMetadata {
None
} else {
let config = deserialize::<VersioningConfiguration>(&data)?;
if config
.status
.as_ref()
.is_some_and(|status| !matches!(status.as_str(), "Enabled" | "Suspended"))
{
if config.status.as_ref().is_some_and(|status| {
!matches!(status.as_str(), BucketVersioningStatus::ENABLED | BucketVersioningStatus::SUSPENDED)
}) {
return Err(Error::other("bucket versioning configuration has an invalid status"));
}
Some(config)
@@ -1038,7 +1033,7 @@ impl BucketMetadata {
let config = if data.is_empty() {
None
} else {
let config = deserialize::<PersistedReplicationConfiguration>(&data)?;
let config = deserialize::<ReplicationConfiguration>(&data)?;
if let Some(field) = invalid_replication_config_status_field(&config) {
return Err(Error::other(format!("replication field {field} has an invalid status")));
}
@@ -1232,8 +1227,8 @@ impl BucketMetadata {
}
self.lifecycle_config = None;
if !self.lifecycle_config_xml.is_empty()
&& let Err(e) = deserialize::<PersistedLifecycleConfiguration>(&self.lifecycle_config_xml)
.map(|c| self.lifecycle_config = Some(c))
&& let Err(e) =
deserialize::<BucketLifecycleConfiguration>(&self.lifecycle_config_xml).map(|c| self.lifecycle_config = Some(c))
{
tracing::warn!(
event = "bucket_metadata_parse_failed",
@@ -1319,8 +1314,8 @@ impl BucketMetadata {
}
self.replication_config = None;
if !self.replication_config_xml.is_empty()
&& let Err(e) = deserialize::<PersistedReplicationConfiguration>(&self.replication_config_xml)
.map(|c| self.replication_config = Some(c))
&& let Err(e) =
deserialize::<ReplicationConfiguration>(&self.replication_config_xml).map(|c| self.replication_config = Some(c))
{
tracing::warn!(
event = "bucket_metadata_parse_failed",
@@ -1696,13 +1691,6 @@ mod test {
let mut bm = BucketMetadata::unmarshal(&body[4..]).expect("unmarshal recovered blob");
assert_eq!(bm.name, "interop");
assert!(!bm.bucket_creation_committed, "legacy metadata must default to uncommitted");
assert!(!bm.bucket_creation_commit_record_present, "legacy metadata has no creation-commit record");
let rewritten = BucketMetadata::unmarshal(&bm.marshal_msg().expect("rewrite legacy metadata"))
.expect("read rewritten legacy metadata");
assert!(
!rewritten.bucket_creation_commit_record_present,
"rewriting legacy metadata must not fabricate a creation intent"
);
bm.parse_all_configs().expect("parse recovered configs");
assert!(bm.lifecycle_config.is_some());
}
@@ -1720,7 +1708,6 @@ mod test {
assert_eq!(bm.name, new.name);
assert!(new.bucket_creation_committed);
assert!(new.bucket_creation_commit_record_present);
assert!(!bm.bucket_incarnation_id.is_nil());
assert_eq!(bm.bucket_incarnation_id, new.bucket_incarnation_id);
}
@@ -1825,15 +1812,17 @@ mod test {
#[test]
fn object_locking_requires_lock_metadata_not_plain_versioning() {
use s3s::dto::ObjectLockEnabled;
let mut bm = BucketMetadata::new("test-bucket");
bm.versioning_config = Some(VersioningConfiguration {
status: Some("Enabled".to_owned()),
status: Some(s3s::dto::BucketVersioningStatus::from_static("Enabled")),
..Default::default()
});
assert!(!bm.object_locking());
bm.object_lock_config = Some(ObjectLockConfiguration {
object_lock_enabled: Some("Enabled".to_owned()),
object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)),
..Default::default()
});
assert!(bm.object_locking());
+60 -330
View File
@@ -30,18 +30,14 @@ use crate::storage_api_contracts::heal::HealOperations as _;
use crate::storage_api_contracts::namespace::NamespaceLocking as _;
use crate::store::{ECStore, await_bucket_namespace_operation};
use futures::future::join_all;
use rustfs_gateway_types::cors_tagging::{PersistedCorsConfiguration as CORSConfiguration, PersistedTagging as Tagging};
use rustfs_gateway_types::persistence::{
PersistedAccelerateConfiguration as AccelerateConfiguration,
PersistedBucketEncryptionConfiguration as ServerSideEncryptionConfiguration,
PersistedBucketLoggingStatus as BucketLoggingStatus, PersistedLifecycleConfiguration,
PersistedNotificationConfiguration as NotificationConfiguration, PersistedObjectLockConfiguration as ObjectLockConfiguration,
PersistedPublicAccessBlockConfiguration as PublicAccessBlockConfiguration, PersistedReplicationConfiguration,
PersistedRequestPaymentConfiguration as RequestPaymentConfiguration,
PersistedVersioningConfiguration as VersioningConfiguration, PersistedWebsiteConfiguration as WebsiteConfiguration,
};
use rustfs_heal_contracts::heal_channel::HealOpts;
use rustfs_policy::policy::BucketPolicy;
use s3s::dto::ReplicationConfiguration;
use s3s::dto::{
AccelerateConfiguration, BucketLifecycleConfiguration, BucketLoggingStatus, CORSConfiguration, NotificationConfiguration,
ObjectLockConfiguration, ObjectLockEnabled, ObjectLockRetentionMode, PublicAccessBlockConfiguration,
RequestPaymentConfiguration, ServerSideEncryptionConfiguration, Tagging, VersioningConfiguration, WebsiteConfiguration,
};
use std::collections::HashSet;
use std::time::Duration;
use std::{
@@ -172,7 +168,7 @@ pub(crate) fn object_lock_config_state_from_authoritative_metadata(bm: &BucketMe
if bm.lock_enabled {
return Ok(ObjectLockConfigState::Configured {
config: ObjectLockConfiguration {
object_lock_enabled: Some("Enabled".to_owned()),
object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)),
rule: None,
},
updated_at: bm.object_lock_config_updated_at,
@@ -216,10 +212,17 @@ pub(crate) fn configured_object_lock_state_for_tests(
) -> ObjectLockConfigState {
ObjectLockConfigState::Configured {
config: ObjectLockConfiguration {
object_lock_enabled: Some("Enabled".to_owned()),
rule: Some(rustfs_gateway_types::persistence::PersistedObjectLockRule {
default_retention: Some(rustfs_gateway_types::persistence::PersistedDefaultRetention {
mode: Some(mode.as_str().to_owned()),
object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)),
rule: Some(s3s::dto::ObjectLockRule {
default_retention: Some(s3s::dto::DefaultRetention {
mode: Some(s3s::dto::ObjectLockRetentionMode::from_static(match mode {
crate::bucket::object_lock::types::RetentionMode::Governance => {
s3s::dto::ObjectLockRetentionMode::GOVERNANCE
}
crate::bucket::object_lock::types::RetentionMode::Compliance => {
s3s::dto::ObjectLockRetentionMode::COMPLIANCE
}
})),
days: Some(days),
years: None,
}),
@@ -230,7 +233,7 @@ pub(crate) fn configured_object_lock_state_for_tests(
}
fn validate_authoritative_object_lock_config(config: &ObjectLockConfiguration) -> Result<()> {
if config.object_lock_enabled.as_deref() != Some("Enabled") {
if config.object_lock_enabled.as_ref().map(ObjectLockEnabled::as_str) != Some(ObjectLockEnabled::ENABLED) {
return Err(Error::other("persisted bucket Object Lock enabled state is invalid"));
}
@@ -243,7 +246,7 @@ fn validate_authoritative_object_lock_config(config: &ObjectLockConfiguration) -
if !retention
.mode
.as_ref()
.is_some_and(|mode| matches!(mode.as_str(), "COMPLIANCE" | "GOVERNANCE"))
.is_some_and(|mode| matches!(mode.as_str(), ObjectLockRetentionMode::COMPLIANCE | ObjectLockRetentionMode::GOVERNANCE))
{
return Err(Error::other("persisted bucket Object Lock retention mode is invalid"));
}
@@ -1365,7 +1368,7 @@ pub async fn get_public_access_block_config(bucket: &str) -> Result<(PublicAcces
bucket_meta_sys.get_public_access_block_config(bucket).await
}
pub async fn get_lifecycle_config(bucket: &str) -> Result<(PersistedLifecycleConfiguration, OffsetDateTime)> {
pub async fn get_lifecycle_config(bucket: &str) -> Result<(BucketLifecycleConfiguration, OffsetDateTime)> {
let bucket_meta_sys_lock = get_bucket_metadata_sys()?;
let bucket_meta_sys = bucket_meta_sys_lock.read().await;
@@ -1584,7 +1587,7 @@ pub(crate) async fn get_cached_quota_config_and_incarnation_in(
bucket_meta_sys.cached_quota_config_and_incarnation(bucket).await
}
pub async fn get_replication_config(bucket: &str) -> Result<(PersistedReplicationConfiguration, OffsetDateTime)> {
pub async fn get_replication_config(bucket: &str) -> Result<(ReplicationConfiguration, OffsetDateTime)> {
let bucket_meta_sys_lock = get_bucket_metadata_sys()?;
let bucket_meta_sys = bucket_meta_sys_lock.read().await;
@@ -1594,7 +1597,7 @@ pub async fn get_replication_config(bucket: &str) -> Result<(PersistedReplicatio
pub(crate) async fn get_replication_config_in(
ctx: &crate::runtime::instance::InstanceContext,
bucket: &str,
) -> Result<(PersistedReplicationConfiguration, OffsetDateTime)> {
) -> Result<(ReplicationConfiguration, OffsetDateTime)> {
let bucket_meta_sys_lock = bucket_metadata_sys_of(ctx)?;
let bucket_meta_sys = bucket_meta_sys_lock.read().await;
@@ -1720,27 +1723,9 @@ struct MetadataPublishGuard {
_guard: tokio::sync::OwnedMutexGuard<MetadataPublishLockState>,
}
#[derive(Debug, Clone)]
pub(crate) struct PendingBucketCreationProof {
pub(crate) generation: Uuid,
pub(crate) intent: Vec<u8>,
pub(crate) versioning_requested: bool,
pub(crate) witnesses: Vec<crate::disk::os::NativeBucketCreationWitness>,
}
impl PendingBucketCreationProof {
pub(crate) fn matches_metadata(&self, metadata: &BucketMetadata) -> Result<bool> {
Ok(!metadata.bucket_creation_committed
&& metadata.bucket_incarnation_sidecar
&& metadata.bucket_incarnation_id == self.generation
&& metadata.marshal_msg()? == self.intent)
}
}
#[derive(Debug, Clone)]
pub struct BucketMetadataSys {
metadata_map: Arc<RwLock<HashMap<String, Arc<BucketMetadata>>>>,
pending_bucket_creations: Arc<RwLock<HashMap<String, PendingBucketCreationProof>>>,
/// Serializes metadata-map commits and their derived cache updates for one
/// bucket. Namespace locks, when present, are acquired before this lock.
metadata_publish_locks: Arc<MetadataPublishLockRegistry>,
@@ -1784,7 +1769,6 @@ impl BucketMetadataSys {
pub fn new(api: Arc<ECStore>) -> Self {
Self {
metadata_map: Arc::new(RwLock::new(HashMap::new())),
pending_bucket_creations: Arc::new(RwLock::new(HashMap::new())),
metadata_publish_locks: Arc::new(MetadataPublishLockRegistry {
locks: StdMutex::new(HashMap::new()),
}),
@@ -1815,81 +1799,6 @@ impl BucketMetadataSys {
.expect("bucket metadata object store should still be live")
}
pub(crate) async fn pending_bucket_creation(&self, bucket: &str) -> Option<PendingBucketCreationProof> {
self.pending_bucket_creations.read().await.get(bucket).cloned()
}
pub(crate) async fn grant_pending_bucket_creation(&self, bucket: &str, proof: PendingBucketCreationProof) {
self.pending_bucket_creations.write().await.insert(bucket.to_owned(), proof);
}
pub(crate) async fn revoke_pending_bucket_creation(&self, bucket: &str) {
self.pending_bucket_creations.write().await.remove(bucket);
}
pub(crate) async fn consume_pending_bucket_creation(
&self,
bucket: &str,
metadata: &BucketMetadata,
witnesses: &[crate::disk::os::NativeBucketCreationWitness],
) -> Result<bool> {
let mut pending = self.pending_bucket_creations.write().await;
let Some(proof) = pending.get(bucket) else {
return Ok(false);
};
if !proof.matches_metadata(metadata)?
|| proof.witnesses.len() != witnesses.len()
|| !proof
.witnesses
.iter()
.zip(witnesses)
.all(|(old, current)| old.matches(current))
|| !crate::disk::os::consume_native_creation_witnesses(witnesses)
{
return Ok(false);
}
pending.remove(bucket);
Ok(true)
}
#[cfg(test)]
pub(crate) async fn refresh_bucket_for_test(&self, bucket: &str) -> Result<()> {
let expected = self.metadata_map.read().await.get(bucket).cloned();
let lock = self.object_store().new_ns_lock(bucket, bucket).await?;
let guard = lock.get_read_lock(crate::set_disk::get_lock_acquire_timeout()).await?;
self.load_bucket_under_namespace(bucket, MetadataLoadMode::Refresh, expected.as_ref(), &guard)
.await
}
#[cfg(test)]
pub(crate) async fn publish_persisted_metadata_for_test(&self, bucket: &str) -> Result<()> {
let expected = self.metadata_map.read().await.get(bucket).cloned();
let lock = self.object_store().new_ns_lock(bucket, bucket).await?;
let guard = lock.get_read_lock(crate::set_disk::get_lock_acquire_timeout()).await?;
let (metadata, persisted) = await_bucket_namespace_operation(
Some(&guard),
bucket,
"test cached metadata load",
load_bucket_metadata_parse_with_presence(self.object_store(), bucket, true),
)
.await?;
self.publish_if_unchanged(bucket, expected.as_ref(), metadata, persisted, &guard)
.await
}
#[cfg(test)]
pub(crate) async fn migrate_bucket_creation_commit_for_test(&self, bucket: &str) -> Result<()> {
self.migrate_bucket_creation_commit(bucket).await.map(|_| ())
}
#[cfg(test)]
pub(crate) async fn legacy_migration_reports_missing_bucket_for_test(&self, bucket: &str) -> Result<bool> {
Ok(matches!(
self.migrate_legacy_metadata(bucket).await?,
BucketMetadataAuthority::MissingBucket
))
}
fn object_store_if_live(&self) -> Option<Arc<ECStore>> {
self.api.upgrade()
}
@@ -2080,7 +1989,6 @@ impl BucketMetadataSys {
expected: Option<&Arc<BucketMetadata>>,
namespace_guard: &rustfs_lock::NamespaceLockGuard,
) -> Result<()> {
self.revoke_pending_bucket_creation(bucket).await;
if !await_bucket_namespace_operation(
Some(namespace_guard),
bucket,
@@ -2164,7 +2072,6 @@ impl BucketMetadataSys {
persisted: bool,
namespace_guard: &rustfs_lock::NamespaceLockGuard,
) -> Result<()> {
self.revoke_pending_bucket_creation(bucket).await;
if !persisted {
let _publish_guard = self
.lock_metadata_publish(bucket, namespace_guard, "refreshed bucket metadata absence publish")
@@ -2226,7 +2133,6 @@ impl BucketMetadataSys {
}
pub async fn set(&self, bucket: String, bm: Arc<BucketMetadata>) {
self.revoke_pending_bucket_creation(&bucket).await;
if !is_meta_bucketname(&bucket) {
let publish_lock = self.metadata_publish_lock(&bucket);
let _publish_guard = publish_lock.lock().await;
@@ -2248,7 +2154,6 @@ impl BucketMetadataSys {
if is_meta_bucketname(bucket) {
return false;
}
self.revoke_pending_bucket_creation(bucket).await;
let publish_lock = self.metadata_publish_lock(bucket);
let _publish_guard = publish_lock.lock().await;
let mut map = self.metadata_map.write().await;
@@ -2266,7 +2171,6 @@ impl BucketMetadataSys {
}
async fn _reset(&mut self) {
self.pending_bucket_creations.write().await.clear();
let mut map = self.metadata_map.write().await;
map.clear();
drop(map);
@@ -2405,7 +2309,6 @@ impl BucketMetadataSys {
/// server's metadata never leaks into the ambient (first) instance.
pub(crate) async fn persist_and_set(&self, bm: BucketMetadata) -> Result<()> {
let mut bm = bm;
self.revoke_pending_bucket_creation(&bm.name).await;
bm.save_with_store(self.object_store()).await?;
self.set(bm.name.clone(), Arc::new(bm)).await;
@@ -2414,7 +2317,6 @@ impl BucketMetadataSys {
}
async fn persist_new_and_set(&self, mut bm: BucketMetadata) -> Result<()> {
self.revoke_pending_bucket_creation(&bm.name).await;
bm.bucket_creation_committed = true;
bm.save_with_store_committed(self.object_store()).await?;
save_bucket_incarnation(self.object_store(), &bm.name, bm.bucket_incarnation_id).await?;
@@ -2424,9 +2326,7 @@ impl BucketMetadataSys {
}
async fn persist_new_bucket_metadata_intent(&self, mut bm: BucketMetadata) -> Result<()> {
self.revoke_pending_bucket_creation(&bm.name).await;
bm.bucket_creation_committed = false;
bm.bucket_creation_commit_record_present = true;
bm.save_with_store(self.object_store()).await?;
save_bucket_incarnation(self.object_store(), &bm.name, bm.bucket_incarnation_id).await?;
bm.bucket_incarnation_sidecar = true;
@@ -2436,7 +2336,6 @@ impl BucketMetadataSys {
}
async fn commit_bucket_metadata(&self, mut bm: BucketMetadata) -> Result<()> {
self.revoke_pending_bucket_creation(&bm.name).await;
bm.parse_all_configs()?;
bm.bucket_incarnation_sidecar = true;
// The caller holds the full bucket creation fence (lifecycle, metadata
@@ -2485,7 +2384,6 @@ impl BucketMetadataSys {
bucket: &str,
namespace_guard: &rustfs_lock::NamespaceLockGuard,
) -> Result<()> {
self.revoke_pending_bucket_creation(bucket).await;
let expected = self.metadata_map.read().await.get(bucket).cloned();
if !self
.bucket_exists(bucket, namespace_guard, "peer bucket metadata existence check")
@@ -2542,7 +2440,6 @@ impl BucketMetadataSys {
#[cfg(test)]
self.lazy_disk_loads.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
self.revoke_pending_bucket_creation(bucket).await;
let lock = self.object_store().new_ns_lock(bucket, bucket).await?;
let guard = lock.get_read_lock(crate::set_disk::get_lock_acquire_timeout()).await?;
#[cfg(test)]
@@ -2868,7 +2765,6 @@ impl BucketMetadataSys {
/// revalidated at write quorum, so a stale snapshot can neither revert an
/// acknowledged configuration update nor outlive a delete/recreate.
async fn migrate_bucket_creation_commit(&self, bucket: &str) -> Result<BucketMetadataAuthority> {
self.revoke_pending_bucket_creation(bucket).await;
let transaction_lock = self
.object_store()
.new_ns_lock(RUSTFS_META_BUCKET, &bucket_metadata_transaction_lock_key(bucket))
@@ -2946,7 +2842,6 @@ impl BucketMetadataSys {
&self,
bucket: &str,
) -> Result<BucketMetadataAuthority> {
self.revoke_pending_bucket_creation(bucket).await;
#[cfg(test)]
if self.object_lock_disk_read_errors.write().await.remove(bucket) {
return Err(Error::other(format!("injected Object Lock metadata disk read failure: {bucket}")));
@@ -3122,7 +3017,7 @@ impl BucketMetadataSys {
}
}
pub async fn get_lifecycle_config(&self, bucket: &str) -> Result<(PersistedLifecycleConfiguration, OffsetDateTime)> {
pub async fn get_lifecycle_config(&self, bucket: &str) -> Result<(BucketLifecycleConfiguration, OffsetDateTime)> {
let (bm, _) = self.get_config(bucket).await?;
match ConfigState::of(&bm.lifecycle_config_xml, &bm.lifecycle_config).require(bucket, BUCKET_LIFECYCLE_CONFIG)? {
@@ -3245,7 +3140,7 @@ impl BucketMetadataSys {
}))
}
pub async fn get_replication_config(&self, bucket: &str) -> Result<(PersistedReplicationConfiguration, OffsetDateTime)> {
pub async fn get_replication_config(&self, bucket: &str) -> Result<(ReplicationConfiguration, OffsetDateTime)> {
let (bm, _) = self.get_config(bucket).await?;
if !bm.replication_config_xml.is_empty() && bm.replication_config.is_none() {
@@ -3291,9 +3186,17 @@ pub mod test_support {
use crate::store::init_local_disks_with_instance_ctx;
pub async fn isolated_store_over_temp_disks() -> (Vec<tempfile::TempDir>, Arc<ECStore>) {
let mut dirs = Vec::with_capacity(4);
let mut endpoints = Vec::with_capacity(4);
for disk_idx in 0..4 {
isolated_store_over_temp_disks_with_count(4).await
}
pub async fn isolated_store_over_temp_disks_with_count(disk_count: usize) -> (Vec<tempfile::TempDir>, Arc<ECStore>) {
assert!(
matches!(disk_count, 4 | 16),
"isolated store fixture requires a registered logical set geometry"
);
let mut dirs = Vec::with_capacity(disk_count);
let mut endpoints = Vec::with_capacity(disk_count);
for disk_idx in 0..disk_count {
let dir = tempfile::tempdir().expect("tempdir should be created");
let mut endpoint =
Endpoint::try_from(dir.path().to_str().expect("tempdir path should be utf8")).expect("endpoint should parse");
@@ -3306,7 +3209,7 @@ pub mod test_support {
let endpoint_pools = EndpointServerPools(vec![PoolEndpoints {
legacy: false,
set_count: 1,
drives_per_set: 4,
drives_per_set: disk_count,
endpoints: Endpoints::from(endpoints),
cmd_line: "metadata-sys-cache-test".to_string(),
platform: "test".to_string(),
@@ -3339,7 +3242,6 @@ mod tests {
};
use crate::bucket::target::{BucketTarget, BucketTargetType, Credentials};
use crate::config::com::read_config;
use crate::disk::{DiskAPI as _, RUSTFS_META_TMP_BUCKET};
use crate::storage_api_contracts::bucket::{BucketOperations as _, DeleteBucketOptions, MakeBucketOptions};
use byteorder::{ByteOrder as _, LittleEndian};
use serial_test::serial;
@@ -3394,180 +3296,6 @@ mod tests {
),
];
#[tokio::test]
#[serial(storage_class_env)]
async fn persistence_entry_failures_revoke_pending_creation_before_cache_publish() {
let (dirs, store) = isolated_store_over_temp_disks().await;
init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let sys = bucket_metadata_sys_of(&store.ctx).expect("instance metadata system");
let sys = sys.read().await.clone();
let options = MakeBucketOptions {
lock_enabled: true,
..Default::default()
};
for entry in ["save", "persist", "persist-new", "commit"] {
let bucket = format!("revoke-{entry}-{}", Uuid::new_v4());
store.fail_next_bucket_creation_after_intent_for_test(&bucket);
store
.make_bucket(&bucket, &options)
.await
.expect_err("stop the real producer before physical publication");
sys.pending_bucket_creation(&bucket)
.await
.expect("the producer must leave a real live creation permission");
let (intent, persisted) = get_config_from_disk_with_presence_in(&store.ctx, &bucket)
.await
.expect("read the original producer intent");
assert!(persisted, "{entry}: the complete original intent must be persisted");
let original_intent = intent.marshal_msg().expect("encode the original intent");
// Keep the creation fence order used by the real producer.
let lifecycle_guard = store
.acquire_bucket_lifecycle_write_lock(&bucket)
.await
.expect("hold the bucket lifecycle fence");
let transaction_guard = acquire_bucket_metadata_transaction_lock_in(&store.ctx, &bucket)
.await
.expect("hold the metadata transaction fence");
let namespace_lock = store
.new_ns_lock(&bucket, &bucket)
.await
.expect("create the exact bucket namespace lock");
let namespace_guard = namespace_lock
.get_write_lock(crate::set_disk::get_lock_acquire_timeout())
.await
.expect("hold the exact bucket namespace fence");
// A file at each native staging root causes actual IO to fail before
// metadata can reach the cache-publishing set call. Preserve the
// original staging directories so no fixture data is discarded.
for dir in &dirs {
let staging = dir.path().join(RUSTFS_META_TMP_BUCKET);
let saved = dir.path().join(".rustfs.sys/revocation-saved-tmp");
std::fs::rename(&staging, &saved).expect("preserve the native staging directory");
std::fs::write(&staging, b"block metadata staging with a non-directory")
.expect("install an actual non-directory IO obstruction");
}
let result = match entry {
"save" => sys.save(intent).await,
"persist" => sys.persist_and_set(intent).await,
"persist-new" => sys.persist_new_and_set(intent).await,
"commit" => sys.commit_bucket_metadata(intent).await,
_ => unreachable!(),
};
for dir in &dirs {
let staging = dir.path().join(RUSTFS_META_TMP_BUCKET);
let saved = dir.path().join(".rustfs.sys/revocation-saved-tmp");
std::fs::remove_file(&staging).expect("remove only the fixture's staging obstruction");
std::fs::rename(&saved, &staging).expect("restore the complete original staging directory");
}
drop((namespace_guard, transaction_guard, lifecycle_guard));
result.expect_err("native persistence must fail before its downstream cache set");
assert!(
sys.pending_bucket_creation(&bucket).await.is_none(),
"{entry}: the entry revoker must retire live permission even when persistence fails"
);
let (after_failure, present) = get_config_from_disk_with_presence_in(&store.ctx, &bucket)
.await
.expect("read the intent after the failed persistence entry");
assert!(present, "{entry}: a failed persistence entry must retain the original metadata");
assert_eq!(
after_failure
.marshal_msg()
.expect("encode the original intent after persistence failure"),
original_intent,
"{entry}: native IO failure must not change the original generation or complete intent"
);
let error = store
.make_bucket(&bucket, &options)
.await
.expect_err("a failed ordinary persistence entry cannot preserve creation permission");
assert!(error.to_string().contains("administrator recovery"), "{entry}: {error}");
let (after_retry, _) = get_config_from_disk_with_presence_in(&store.ctx, &bucket)
.await
.expect("read the original intent after the rejected retry");
assert_eq!(
after_retry.marshal_msg().expect("encode the intent after the rejected retry"),
original_intent,
"{entry}: the rejected retry must preserve the complete original intent"
);
for disk in store.pools[0].disk_set[0].disks.read().await.iter() {
assert!(
matches!(
disk.as_ref().expect("native disk").stat_volume(&bucket).await,
Err(crate::disk::error::DiskError::VolumeNotFound)
),
"{entry}: failed persistence and the rejected retry must not publish a physical volume"
);
}
}
}
#[tokio::test]
#[serial(storage_class_env)]
async fn metadata_reset_revokes_real_pending_creation_without_changing_persisted_intent() {
let (_dirs, store) = isolated_store_over_temp_disks().await;
init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let bucket = format!("revoke-reset-{}", Uuid::new_v4());
let options = MakeBucketOptions {
lock_enabled: true,
..Default::default()
};
store.fail_next_bucket_creation_after_intent_for_test(&bucket);
store
.make_bucket(&bucket, &options)
.await
.expect_err("stop the real producer before physical publication");
let sys = bucket_metadata_sys_of(&store.ctx).expect("instance metadata system");
let mut sys = sys.read().await.clone();
sys.pending_bucket_creation(&bucket)
.await
.expect("the real producer must leave live creation permission before reset");
let (intent, persisted) = get_config_from_disk_with_presence_in(&store.ctx, &bucket)
.await
.expect("read the original intent before cache reset");
assert!(persisted, "cache reset starts from a complete real persisted intent");
let original_intent = intent.marshal_msg().expect("encode the original reset intent");
sys._reset().await;
assert!(
sys.pending_bucket_creation(&bucket).await.is_none(),
"reset must revoke the producer-issued permission shared with the instance metadata system"
);
let (after_reset, present) = get_config_from_disk_with_presence_in(&store.ctx, &bucket)
.await
.expect("read the original intent after cache reset");
assert!(present, "cache reset must retain the persisted intent");
assert_eq!(
after_reset.marshal_msg().expect("encode the intent after cache reset"),
original_intent,
"cache reset must leave the original generation and complete persisted intent unchanged"
);
let error = store
.make_bucket(&bucket, &options)
.await
.expect_err("reset cannot regrant the producer's old creation permission");
assert!(error.to_string().contains("administrator recovery"), "{error}");
let (after_retry, _) = get_config_from_disk_with_presence_in(&store.ctx, &bucket)
.await
.expect("read the intent after the reset-induced retry rejection");
assert_eq!(
after_retry
.marshal_msg()
.expect("encode the intent after the rejected reset retry"),
original_intent,
"the reset-induced rejection must preserve the original complete intent"
);
for disk in store.pools[0].disk_set[0].disks.read().await.iter() {
assert!(
matches!(
disk.as_ref().expect("native disk").stat_volume(&bucket).await,
Err(crate::disk::error::DiskError::VolumeNotFound)
),
"cache reset and the rejected retry must not publish a physical volume"
);
}
}
#[tokio::test]
async fn g_d3_003_new_writer_replication_loads_without_fail_closed_state() {
let (dirs, store) = isolated_store_over_temp_disks().await;
@@ -3756,7 +3484,7 @@ mod tests {
let mut versioned = BucketMetadata::new("authoritative-versioned");
versioned.versioning_config_xml = b"<VersioningConfiguration><Status>Enabled</Status></VersioningConfiguration>".to_vec();
versioned.versioning_config = Some(VersioningConfiguration {
status: Some("Enabled".to_owned()),
status: Some(s3s::dto::BucketVersioningStatus::from_static(s3s::dto::BucketVersioningStatus::ENABLED)),
..Default::default()
});
sys.set("authoritative-versioned".to_string(), Arc::new(versioned)).await;
@@ -3879,24 +3607,25 @@ mod tests {
else {
panic!("legacy lock-enabled metadata must remain configured");
};
assert_eq!(config.object_lock_enabled.as_deref(), Some("Enabled"));
assert_eq!(
config.object_lock_enabled.as_ref().map(|value| value.as_str()),
Some(ObjectLockEnabled::ENABLED)
);
assert!(config.rule.is_none());
}
#[test]
fn authoritative_object_lock_state_rejects_invalid_retention_periods() {
use rustfs_gateway_types::persistence::{
PersistedDefaultRetention as DefaultRetention, PersistedObjectLockRule as ObjectLockRule,
};
use s3s::dto::{DefaultRetention, ObjectLockRule};
for (days, years) in [(Some(0), None), (Some(-1), None), (Some(1), Some(1)), (None, None)] {
let mut metadata = BucketMetadata::new("invalid-object-lock");
metadata.object_lock_config = Some(ObjectLockConfiguration {
object_lock_enabled: Some("Enabled".to_owned()),
object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)),
rule: Some(ObjectLockRule {
default_retention: Some(DefaultRetention {
days,
mode: Some("COMPLIANCE".to_owned()),
mode: Some(ObjectLockRetentionMode::from_static(ObjectLockRetentionMode::COMPLIANCE)),
years,
}),
}),
@@ -3911,11 +3640,9 @@ mod tests {
#[test]
fn authoritative_object_lock_state_rejects_invalid_enabled_mode_and_rule() {
use rustfs_gateway_types::persistence::{
PersistedDefaultRetention as DefaultRetention, PersistedObjectLockRule as ObjectLockRule,
};
use s3s::dto::{DefaultRetention, ObjectLockRule};
for enabled in [None, Some("Disabled".to_string())] {
for enabled in [None, Some(ObjectLockEnabled::from("Disabled".to_string()))] {
let mut metadata = BucketMetadata::new("invalid-object-lock-enabled");
metadata.lock_enabled = true;
metadata.object_lock_config = Some(ObjectLockConfiguration {
@@ -3927,11 +3654,11 @@ mod tests {
let mut invalid_mode = BucketMetadata::new("invalid-object-lock-mode");
invalid_mode.object_lock_config = Some(ObjectLockConfiguration {
object_lock_enabled: Some("Enabled".to_owned()),
object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)),
rule: Some(ObjectLockRule {
default_retention: Some(DefaultRetention {
days: Some(1),
mode: Some("invalid".to_string()),
mode: Some(ObjectLockRetentionMode::from("invalid".to_string())),
years: None,
}),
}),
@@ -3940,7 +3667,7 @@ mod tests {
let mut missing_retention = BucketMetadata::new("missing-object-lock-retention");
missing_retention.object_lock_config = Some(ObjectLockConfiguration {
object_lock_enabled: Some("Enabled".to_owned()),
object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)),
rule: Some(ObjectLockRule { default_retention: None }),
});
assert!(object_lock_config_state_from_authoritative_metadata(&missing_retention).is_err());
@@ -3950,7 +3677,7 @@ mod tests {
fn object_lock_ever_enabled_marker_survives_config_removal() {
let mut metadata = BucketMetadata::new("lock-config-removed");
let config = ObjectLockConfiguration {
object_lock_enabled: Some("Enabled".to_owned()),
object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)),
rule: None,
};
metadata
@@ -3968,7 +3695,10 @@ mod tests {
else {
panic!("an ever-enabled bucket must remain Object Lock configured");
};
assert_eq!(config.object_lock_enabled.as_deref(), Some("Enabled"));
assert_eq!(
config.object_lock_enabled.as_ref().map(ObjectLockEnabled::as_str),
Some(ObjectLockEnabled::ENABLED)
);
}
#[tokio::test]
@@ -3984,7 +3714,7 @@ mod tests {
let node = Arc::new(RwLock::new(BucketMetadataSys::new(ecstore.clone())));
let config = ObjectLockConfiguration {
object_lock_enabled: Some("Enabled".to_owned()),
object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)),
rule: None,
};
update_with_sys(
@@ -4965,7 +4695,7 @@ mod tests {
#[tokio::test]
async fn update_config_with_persists_tagging_rewrite_across_disk_reload() {
use crate::bucket::metadata::BUCKET_TAGGING_CONFIG;
use rustfs_gateway_types::cors_tagging::PersistedTag as Tag;
use s3s::dto::Tag;
let (dirs, ecstore) = isolated_store_over_temp_disks().await;
@@ -5129,7 +4859,7 @@ mod tests {
#[tokio::test]
async fn concurrent_update_config_with_calls_do_not_lose_writes() {
use crate::bucket::metadata::BUCKET_TAGGING_CONFIG;
use rustfs_gateway_types::cors_tagging::PersistedTag as Tag;
use s3s::dto::Tag;
let (dirs, ecstore) = isolated_store_over_temp_disks().await;
let sys = Arc::new(RwLock::new(BucketMetadataSys::new(ecstore)));
@@ -5229,7 +4959,7 @@ mod tests {
let mut persisted = BucketMetadata::new("reload-bucket");
persisted.policy_config_json = b"persisted-marker".to_vec();
let lock_config = ObjectLockConfiguration {
object_lock_enabled: Some("Enabled".to_owned()),
object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)),
rule: None,
};
persisted
@@ -5508,7 +5238,7 @@ mod tests {
#[serial]
async fn bucket_delete_waits_for_config_mutation_fence() {
use crate::bucket::metadata::BUCKET_TAGGING_CONFIG;
use rustfs_gateway_types::cors_tagging::PersistedTag as Tag;
use s3s::dto::Tag;
let (_dirs, store) = isolated_store_over_temp_disks().await;
init_bucket_metadata_sys(store.clone(), Vec::new()).await;
+9 -2
View File
@@ -17,8 +17,15 @@ pub mod objectlock_sys;
pub mod types;
/// Whether a bucket Object Lock configuration has locking enabled. The
/// persisted `ObjectLockConfiguration` implements this in the bucket-metadata
/// module, which owns the persisted configuration type.
/// serving-layer `ObjectLockConfiguration` DTO implements this in
/// the bucket-metadata module, which owns the persisted configuration type
/// during the s3s ratchet migration (rustfs/backlog#1842).
pub trait ObjectLockApi {
fn enabled(&self) -> bool;
}
/// Whether a legal-hold status value is one of the two valid wire values.
/// Implemented for the serving-layer DTO in the bucket-metadata module.
pub trait ObjectLockStatusExt {
fn valid(&self) -> bool;
}
+12 -11
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,
@@ -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(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(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"
);
}
@@ -707,13 +707,6 @@ struct ShardedReservationData {
}
impl ReservationLedgerData {
fn store(&self) -> &Arc<ECStore> {
match self {
Self::Single(data) => &data.store,
Self::Sharded(data) => &data.store,
}
}
fn bucket(&self) -> &str {
match self {
Self::Single(data) => &data.bucket,
@@ -765,7 +758,7 @@ impl QuotaReservation {
let Some(ledger) = self.ledger.as_ref() else {
return;
};
crate::store::list_objects::observe_list_objects_mutation(ledger.store(), ledger.bucket()).await;
crate::store::list_objects::observe_scanner_namespace_mutations(ledger.bucket(), 1);
match settle(ledger, true).await {
Ok(()) => self.ledger = None,
Err(err) => log_deferred_settlement(ledger, "commit_deferred", &err),
@@ -1290,7 +1283,7 @@ async fn reconcile_exact(store: &Arc<ECStore>, bucket: &str, ledger: &mut QuotaL
}
async fn exact_bucket_usage(store: &Arc<ECStore>, bucket: &str) -> Result<u64> {
crate::store::list_objects::observe_list_objects_mutation(store, bucket).await;
crate::store::list_objects::observe_scanner_namespace_mutations(bucket, 1);
Ok(compute_bucket_usage(Arc::clone(store), bucket).await?.size)
}
@@ -12,7 +12,6 @@
// See the License for the specific language governing permissions and
// limitations under the License.
pub use rustfs_replication::config_cache_serde;
pub use rustfs_replication::{
ObjectOpts, OperatorRuleContract, REMOTE_TARGET_CAPABILITY_CONTRACT_VERSION, REMOTE_TARGET_READ_ONLY_HISTORICAL_FIELDS,
REMOTE_TARGET_UNSUPPORTED_FIELDS, REMOTE_TARGET_WRITABLE_FIELDS, REPLICATION_CAPABILITY_CONTRACT_VERSION,
@@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use rustfs_gateway_types::persistence::PersistedReplicationConfiguration;
use s3s::dto::ReplicationConfiguration;
use super::replication_config_boundary::ReplicationConfigurationExt as _;
use super::replication_filemeta_boundary::{
@@ -42,7 +42,7 @@ impl ReplicationLifecycleBridge {
dead_code,
reason = "declared boundary surface for the ECStore replication split plan; no caller in this port (backlog#1823)"
)]
pub(crate) fn new_config(config: PersistedReplicationConfiguration) -> ReplicationLifecycleConfig {
pub(crate) fn new_config(config: ReplicationConfiguration) -> ReplicationLifecycleConfig {
ReplicationConfig::new(Some(config), None)
}
@@ -118,17 +118,16 @@ impl ReplicationLifecycleBridge {
#[cfg(test)]
mod tests {
use rustfs_gateway_types::dto::Status;
use rustfs_gateway_types::persistence::{PersistedReplicationDestination, PersistedReplicationRule};
use s3s::dto::{Destination, ReplicationRule, ReplicationRuleStatus};
use super::super::replication_filemeta_boundary::{ReplicateTargetDecision, VersionPurgeStatusType};
use super::*;
fn replication_rule() -> PersistedReplicationRule {
PersistedReplicationRule {
fn replication_rule() -> ReplicationRule {
ReplicationRule {
delete_marker_replication: None,
delete_replication: None,
destination: PersistedReplicationDestination {
destination: Destination {
bucket: "arn:aws:s3:::target-bucket".to_string(),
..Default::default()
},
@@ -138,13 +137,13 @@ mod tests {
prefix: Some(String::new()),
priority: Some(1),
source_selection_criteria: None,
status: Status::ENABLED.to_string(),
status: ReplicationRuleStatus::from_static(ReplicationRuleStatus::ENABLED),
}
}
#[test]
fn has_pending_version_purge_preserves_replication_active_rule_behavior() {
let config = ReplicationLifecycleBridge::new_config(PersistedReplicationConfiguration {
let config = ReplicationLifecycleBridge::new_config(ReplicationConfiguration {
role: String::new(),
rules: vec![replication_rule()],
});
@@ -17,8 +17,8 @@ use std::sync::Arc;
use crate::bucket::{metadata::BucketMetadata, metadata_sys};
use crate::disk::{BUCKET_META_PREFIX, RUSTFS_META_BUCKET};
use crate::runtime::instance::InstanceContext;
use rustfs_gateway_types::persistence::PersistedReplicationConfiguration;
use rustfs_utils::path::path_join_buf;
use s3s::dto::ReplicationConfiguration;
use time::OffsetDateTime;
use super::replication_error_boundary::{Error, Result};
@@ -36,11 +36,11 @@ impl ReplicationMetadataStore {
pub(crate) const FORCE_DELETE_REPLICATION_FILE: &'static str = "config/replication/force-delete.bin";
pub(crate) const FORCE_DELETE_REPLICATION_TRANSACTION_LOCK: &'static str = "config/replication/force-delete.bin.transaction";
pub(crate) async fn replication_config(bucket: &str) -> Result<(PersistedReplicationConfiguration, OffsetDateTime)> {
pub(crate) async fn replication_config(bucket: &str) -> Result<(ReplicationConfiguration, OffsetDateTime)> {
metadata_sys::get_replication_config(bucket).await
}
pub(crate) async fn optional_replication_config(bucket: &str) -> Result<Option<PersistedReplicationConfiguration>> {
pub(crate) async fn optional_replication_config(bucket: &str) -> Result<Option<ReplicationConfiguration>> {
let config = match Self::replication_config(bucket).await {
Ok((config, _)) => Some(config),
Err(err) => {
@@ -16,9 +16,7 @@ use std::{collections::HashMap, fmt, sync::Arc};
use super::replication_filemeta_boundary::metadata_keys;
use crate::bucket::metadata::BucketMetadata;
use rustfs_gateway_types::dto::Status;
use rustfs_gateway_types::persistence::PersistedReplicationConfiguration;
use rustfs_gateway_types::persistence::PersistedVersioningConfiguration as VersioningConfiguration;
use s3s::dto::{BucketVersioningStatus, ReplicationConfiguration, ReplicationRuleStatus, VersioningConfiguration};
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
use tracing::error;
@@ -41,7 +39,7 @@ use super::replication_target_boundary::{BucketTargets, ReplicationTargetStore};
use super::replication_versioning_boundary::ReplicationVersioningStore;
use super::runtime_boundary as runtime_sources;
pub(crate) async fn get_replication_config(bucket: &str) -> Result<Option<PersistedReplicationConfiguration>> {
pub(crate) async fn get_replication_config(bucket: &str) -> Result<Option<ReplicationConfiguration>> {
let config = ReplicationMetadataStore::optional_replication_config(bucket).await?;
validate_delete_replication_config(&VersioningConfiguration::default(), config.as_ref())?;
Ok(config)
@@ -66,7 +64,7 @@ impl DeleteReplicationConfigSnapshot {
#[cfg(test)]
pub(crate) fn from_configs_for_test(
versioning: VersioningConfiguration,
replication: Option<PersistedReplicationConfiguration>,
replication: Option<ReplicationConfiguration>,
) -> Self {
let metadata = replication.map(|config| {
let mut metadata = BucketMetadata::new("test-bucket");
@@ -80,7 +78,7 @@ impl DeleteReplicationConfigSnapshot {
&self.versioning
}
pub fn replication_config(&self) -> Option<&PersistedReplicationConfiguration> {
pub fn replication_config(&self) -> Option<&ReplicationConfiguration> {
self.metadata
.as_ref()
.and_then(|metadata| metadata.replication_config.as_ref())
@@ -103,7 +101,7 @@ impl DeleteReplicationConfigSnapshot {
pub(crate) fn active_delete_marker_rules_require_tags(&self, object: &str) -> bool {
self.replication_config().is_some_and(|config| {
config.rules.iter().any(|rule| {
if rule.status == Status::DISABLED.as_str() {
if rule.status == ReplicationRuleStatus::from_static(ReplicationRuleStatus::DISABLED) {
return false;
}
if !object.starts_with(rule.prefix()) {
@@ -124,12 +122,12 @@ impl DeleteReplicationConfigSnapshot {
fn validate_delete_replication_config(
versioning: &VersioningConfiguration,
config: Option<&PersistedReplicationConfiguration>,
config: Option<&ReplicationConfiguration>,
) -> Result<()> {
if versioning
.status
.as_ref()
.is_some_and(|status| !matches!(status.as_str(), "Enabled" | "Suspended"))
.is_some_and(|status| !matches!(status.as_str(), BucketVersioningStatus::ENABLED | BucketVersioningStatus::SUSPENDED))
{
return Err(super::replication_error_boundary::Error::other(
"bucket versioning configuration has an invalid status",
@@ -146,7 +144,7 @@ fn validate_delete_replication_config(
let role = config.role.trim();
let mut role_destination = None;
for rule in &config.rules {
if rule.status.as_str() == Status::ENABLED.as_str() {
if rule.status.as_str() == ReplicationRuleStatus::ENABLED {
let destination = rule.destination.bucket.trim();
if role.is_empty() && destination.is_empty() {
return Err(super::replication_error_boundary::Error::other(
@@ -171,7 +169,7 @@ fn validate_delete_replication_config(
Ok(())
}
fn replication_config_from_metadata(metadata: &BucketMetadata) -> Result<Option<&PersistedReplicationConfiguration>> {
fn replication_config_from_metadata(metadata: &BucketMetadata) -> Result<Option<&ReplicationConfiguration>> {
if !metadata.replication_config_xml.is_empty() && metadata.replication_config.is_none() {
return Err(super::replication_error_boundary::Error::other(
"persisted bucket replication configuration is invalid",
@@ -252,15 +250,12 @@ pub(crate) async fn load_delete_replication_config_in(
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ReplicationConfig {
/// Carried in the scanner's persisted data usage cache, so its serde form
/// is the one the s3s DTO wrote there (see `config_cache_serde` in the replication crate).
#[serde(with = "super::replication_config_boundary::config_cache_serde")]
pub config: Option<PersistedReplicationConfiguration>,
pub config: Option<ReplicationConfiguration>,
pub remotes: Option<BucketTargets>,
}
impl ReplicationConfig {
pub fn new(config: Option<PersistedReplicationConfiguration>, remotes: Option<BucketTargets>) -> Self {
pub fn new(config: Option<ReplicationConfiguration>, remotes: Option<BucketTargets>) -> Self {
Self { config, remotes }
}
@@ -473,7 +468,7 @@ fn check_replicate_delete_with_config(
oi: &ObjectInfo,
del_opts: &ObjectOptions,
source_error: bool,
config: Option<&PersistedReplicationConfiguration>,
config: Option<&ReplicationConfiguration>,
existing_delete_marker: bool,
trust_persisted_replica_status: bool,
) -> ReplicateDecision {
@@ -592,7 +587,7 @@ pub(crate) async fn must_replicate(bucket: &str, object: &str, mopts: MustReplic
}
fn metadata_target_should_replicate(
cfg: &PersistedReplicationConfiguration,
cfg: &ReplicationConfiguration,
opts: &ObjectOpts,
mopts: &MustReplicateOptions,
arn: &str,
@@ -602,21 +597,20 @@ fn metadata_target_should_replicate(
#[cfg(test)]
mod tests {
use rustfs_gateway_types::dto::Status;
use rustfs_gateway_types::persistence::{
PersistedOptionalReplicationStatus, PersistedReplicationDestination, PersistedReplicationFilter,
PersistedReplicationRule, PersistedReplicationStatus, PersistedReplicationTag, PersistedSourceSelectionCriteria,
use s3s::dto::{
DeleteMarkerReplication, DeleteMarkerReplicationStatus, DeleteReplication, DeleteReplicationStatus, Destination,
ReplicaModifications, ReplicationRule, ReplicationRuleFilter, ReplicationRuleStatus, SourceSelectionCriteria, Tag,
};
use super::super::replication_filemeta_boundary::VersionPurgeStatusType;
use super::super::replication_target_boundary::BucketTarget;
use super::*;
fn replication_rule() -> PersistedReplicationRule {
PersistedReplicationRule {
fn replication_rule() -> ReplicationRule {
ReplicationRule {
delete_marker_replication: None,
delete_replication: None,
destination: PersistedReplicationDestination {
destination: Destination {
bucket: "arn:aws:s3:::target-bucket".to_string(),
..Default::default()
},
@@ -626,7 +620,7 @@ mod tests {
prefix: Some(String::new()),
priority: Some(1),
source_selection_criteria: None,
status: Status::ENABLED.to_string(),
status: ReplicationRuleStatus::from_static(ReplicationRuleStatus::ENABLED),
}
}
@@ -635,11 +629,11 @@ mod tests {
let arn = "arn:rustfs:replication:us-east-1:target:bucket";
let mut rule = replication_rule();
rule.destination.bucket = arn.to_string();
rule.filter = Some(PersistedReplicationFilter {
rule.filter = Some(ReplicationRuleFilter {
prefix: Some("admitted/".to_string()),
..Default::default()
});
let cfg = PersistedReplicationConfiguration {
let cfg = ReplicationConfiguration {
role: String::new(),
rules: vec![rule],
};
@@ -677,7 +671,7 @@ mod tests {
assert!(!empty.replicate(&ObjectOpts::default()));
let config = ReplicationConfig::new(
Some(PersistedReplicationConfiguration {
Some(ReplicationConfiguration {
role: String::new(),
rules: vec![replication_rule()],
}),
@@ -731,7 +725,7 @@ mod tests {
fn delete_snapshot_rejects_enabled_rules_without_a_destination() {
let mut rule = replication_rule();
rule.destination.bucket.clear();
let config = PersistedReplicationConfiguration {
let config = ReplicationConfiguration {
role: String::new(),
rules: vec![rule],
};
@@ -747,7 +741,7 @@ mod tests {
let first = replication_rule();
let mut second = replication_rule();
second.destination.bucket = "arn:aws:s3:::other-target".to_string();
let config = PersistedReplicationConfiguration {
let config = ReplicationConfiguration {
role: "arn:aws:s3:::role-target".to_string(),
rules: vec![first, second],
};
@@ -758,47 +752,47 @@ mod tests {
#[test]
fn delete_snapshot_rejects_unknown_status_values() {
let invalid_versioning = VersioningConfiguration {
status: Some("Enabld".to_string()),
status: Some("Enabld".to_string().into()),
..Default::default()
};
assert!(validate_delete_replication_config(&invalid_versioning, None).is_err());
let mut invalid_rule = replication_rule();
invalid_rule.status = "Enabld".to_string();
let config = PersistedReplicationConfiguration {
invalid_rule.status = "Enabld".to_string().into();
let config = ReplicationConfiguration {
role: String::new(),
rules: vec![invalid_rule],
};
assert!(validate_delete_replication_config(&VersioningConfiguration::default(), Some(&config)).is_err());
let mut invalid_delete = replication_rule();
invalid_delete.delete_replication = Some(PersistedReplicationStatus {
status: "Enabld".to_string(),
invalid_delete.delete_replication = Some(DeleteReplication {
status: "Enabld".to_string().into(),
});
let config = PersistedReplicationConfiguration {
let config = ReplicationConfiguration {
role: String::new(),
rules: vec![invalid_delete],
};
assert!(validate_delete_replication_config(&VersioningConfiguration::default(), Some(&config)).is_err());
let mut invalid_delete_marker = replication_rule();
invalid_delete_marker.delete_marker_replication = Some(PersistedOptionalReplicationStatus {
status: Some("Enabld".to_string()),
invalid_delete_marker.delete_marker_replication = Some(DeleteMarkerReplication {
status: Some("Enabld".to_string().into()),
});
let config = PersistedReplicationConfiguration {
let config = ReplicationConfiguration {
role: String::new(),
rules: vec![invalid_delete_marker],
};
assert!(validate_delete_replication_config(&VersioningConfiguration::default(), Some(&config)).is_err());
let mut invalid_replica_modifications = replication_rule();
invalid_replica_modifications.source_selection_criteria = Some(PersistedSourceSelectionCriteria {
replica_modifications: Some(PersistedReplicationStatus {
status: "Enabld".to_string(),
invalid_replica_modifications.source_selection_criteria = Some(SourceSelectionCriteria {
replica_modifications: Some(ReplicaModifications {
status: "Enabld".to_string().into(),
}),
sse_kms_encrypted_objects: None,
});
let config = PersistedReplicationConfiguration {
let config = ReplicationConfiguration {
role: String::new(),
rules: vec![invalid_replica_modifications],
};
@@ -810,11 +804,11 @@ mod tests {
let mut metadata = BucketMetadata::new("bucket");
metadata.versioning_config_xml = b"configured".to_vec();
metadata.versioning_config = Some(VersioningConfiguration {
status: Some("Enabled".to_owned()),
status: Some(BucketVersioningStatus::from_static(BucketVersioningStatus::ENABLED)),
..Default::default()
});
metadata.replication_config_xml = b"configured".to_vec();
metadata.replication_config = Some(PersistedReplicationConfiguration {
metadata.replication_config = Some(ReplicationConfiguration {
role: String::new(),
rules: vec![replication_rule()],
});
@@ -854,7 +848,7 @@ mod tests {
);
let mut inconsistent = BucketMetadata::new("bucket");
inconsistent.replication_config = Some(PersistedReplicationConfiguration {
inconsistent.replication_config = Some(ReplicationConfiguration {
role: String::new(),
rules: vec![replication_rule()],
});
@@ -869,11 +863,11 @@ mod tests {
let arn = "arn:rustfs:replication:us-east-1:target:bucket";
let mut rule = replication_rule();
rule.destination.bucket = arn.to_string();
rule.delete_marker_replication = Some(PersistedOptionalReplicationStatus {
status: Some(Status::ENABLED.to_string()),
rule.delete_marker_replication = Some(DeleteMarkerReplication {
status: Some(DeleteMarkerReplicationStatus::from_static(DeleteMarkerReplicationStatus::ENABLED)),
});
let mut metadata = BucketMetadata::new("bucket");
metadata.replication_config = Some(PersistedReplicationConfiguration {
metadata.replication_config = Some(ReplicationConfiguration {
role: String::new(),
rules: vec![rule],
});
@@ -904,27 +898,27 @@ mod tests {
fn delete_marker_source_read_is_required_only_for_tag_filtered_rules() {
let mut prefix_rule = replication_rule();
prefix_rule.prefix = Some("logs/".to_string());
prefix_rule.delete_marker_replication = Some(PersistedOptionalReplicationStatus {
status: Some(Status::ENABLED.to_string()),
prefix_rule.delete_marker_replication = Some(DeleteMarkerReplication {
status: Some(DeleteMarkerReplicationStatus::from_static(DeleteMarkerReplicationStatus::ENABLED)),
});
let prefix_snapshot = DeleteReplicationConfigSnapshot::from_configs_for_test(
VersioningConfiguration {
status: Some("Enabled".to_owned()),
status: Some(BucketVersioningStatus::from_static(BucketVersioningStatus::ENABLED)),
..Default::default()
},
Some(PersistedReplicationConfiguration {
Some(ReplicationConfiguration {
role: String::new(),
rules: vec![prefix_rule],
}),
);
let mut tag_rule = replication_rule();
tag_rule.delete_marker_replication = Some(PersistedOptionalReplicationStatus {
status: Some(Status::ENABLED.to_string()),
tag_rule.delete_marker_replication = Some(DeleteMarkerReplication {
status: Some(DeleteMarkerReplicationStatus::from_static(DeleteMarkerReplicationStatus::ENABLED)),
});
tag_rule.filter = Some(PersistedReplicationFilter {
tag: Some(PersistedReplicationTag {
tag_rule.filter = Some(ReplicationRuleFilter {
tag: Some(Tag {
key: Some("class".to_string()),
value: Some("audit".to_string()),
}),
@@ -932,10 +926,10 @@ mod tests {
});
let tag_snapshot = DeleteReplicationConfigSnapshot::from_configs_for_test(
VersioningConfiguration {
status: Some("Enabled".to_owned()),
status: Some(BucketVersioningStatus::from_static(BucketVersioningStatus::ENABLED)),
..Default::default()
},
Some(PersistedReplicationConfiguration {
Some(ReplicationConfiguration {
role: String::new(),
rules: vec![tag_rule],
}),
@@ -950,14 +944,14 @@ mod tests {
let arn = "arn:rustfs:replication:us-east-1:target:bucket";
let mut rule = replication_rule();
rule.destination.bucket = arn.to_string();
rule.delete_replication = Some(PersistedReplicationStatus {
status: Status::ENABLED.to_string(),
rule.delete_replication = Some(DeleteReplication {
status: DeleteReplicationStatus::from_static(DeleteReplicationStatus::ENABLED),
});
rule.delete_marker_replication = Some(PersistedOptionalReplicationStatus {
status: Some(Status::DISABLED.to_string()),
rule.delete_marker_replication = Some(DeleteMarkerReplication {
status: Some(DeleteMarkerReplicationStatus::from_static(DeleteMarkerReplicationStatus::DISABLED)),
});
let config = ReplicationConfig::new(
Some(PersistedReplicationConfiguration {
Some(ReplicationConfiguration {
role: String::new(),
rules: vec![rule],
}),
@@ -991,22 +985,22 @@ mod tests {
#[test]
fn live_delete_does_not_trust_persisted_replica_status() {
let mut rule = replication_rule();
rule.delete_replication = Some(PersistedReplicationStatus {
status: Status::ENABLED.to_string(),
rule.delete_replication = Some(DeleteReplication {
status: DeleteReplicationStatus::from_static(DeleteReplicationStatus::ENABLED),
});
rule.source_selection_criteria = Some(PersistedSourceSelectionCriteria {
replica_modifications: Some(PersistedReplicationStatus {
status: Status::DISABLED.to_string(),
rule.source_selection_criteria = Some(SourceSelectionCriteria {
replica_modifications: Some(ReplicaModifications {
status: s3s::dto::ReplicaModificationsStatus::from_static(s3s::dto::ReplicaModificationsStatus::DISABLED),
}),
sse_kms_encrypted_objects: None,
});
let replication = PersistedReplicationConfiguration {
let replication = ReplicationConfiguration {
role: String::new(),
rules: vec![rule],
};
let snapshot = DeleteReplicationConfigSnapshot::from_configs_for_test(
VersioningConfiguration {
status: Some("Enabled".to_owned()),
status: Some(BucketVersioningStatus::from_static(BucketVersioningStatus::ENABLED)),
..Default::default()
},
Some(replication.clone()),
@@ -1042,14 +1036,14 @@ mod tests {
let arn = "arn:rustfs:replication:us-east-1:target:bucket";
let mut rule = replication_rule();
rule.destination.bucket = arn.to_string();
rule.delete_replication = Some(PersistedReplicationStatus {
status: Status::ENABLED.to_string(),
rule.delete_replication = Some(DeleteReplication {
status: DeleteReplicationStatus::from_static(DeleteReplicationStatus::ENABLED),
});
rule.delete_marker_replication = Some(PersistedOptionalReplicationStatus {
status: Some(Status::DISABLED.to_string()),
rule.delete_marker_replication = Some(DeleteMarkerReplication {
status: Some(DeleteMarkerReplicationStatus::from_static(DeleteMarkerReplicationStatus::DISABLED)),
});
let config = ReplicationConfig::new(
Some(PersistedReplicationConfiguration {
Some(ReplicationConfiguration {
role: String::new(),
rules: vec![rule],
}),
@@ -4794,10 +4794,9 @@ mod tests {
#[tokio::test]
async fn heal_queue_marks_missing_versioning_state_as_missed() {
use super::super::replication_target_boundary::BucketTargets;
use rustfs_gateway_types::dto::Status;
use rustfs_gateway_types::persistence::{
PersistedReplicationConfiguration, PersistedReplicationDestination, PersistedReplicationRule,
PersistedReplicationStatus,
use s3s::dto::{
DeleteReplication, DeleteReplicationStatus, Destination, ReplicationConfiguration, ReplicationRule,
ReplicationRuleStatus,
};
let arn = "arn:rustfs:replication:us-east-1:target:bucket";
@@ -4812,14 +4811,14 @@ mod tests {
..Default::default()
},
ReplicationConfig::new(
Some(PersistedReplicationConfiguration {
Some(ReplicationConfiguration {
role: String::new(),
rules: vec![PersistedReplicationRule {
rules: vec![ReplicationRule {
delete_marker_replication: None,
delete_replication: Some(PersistedReplicationStatus {
status: Status::ENABLED.to_string(),
delete_replication: Some(DeleteReplication {
status: DeleteReplicationStatus::from_static(DeleteReplicationStatus::ENABLED),
}),
destination: PersistedReplicationDestination {
destination: Destination {
bucket: arn.to_string(),
..Default::default()
},
@@ -4829,7 +4828,7 @@ mod tests {
prefix: Some(String::new()),
priority: Some(1),
source_selection_criteria: None,
status: Status::ENABLED.to_string(),
status: ReplicationRuleStatus::from_static(ReplicationRuleStatus::ENABLED),
}],
}),
Some(BucketTargets::default()),
@@ -85,8 +85,6 @@ use http_body_util::StreamBody;
use metrics::counter;
#[cfg(test)]
use rmp_serde;
#[cfg(test)]
use rustfs_gateway_types::persistence::PersistedReplicationConfiguration;
use rustfs_s3_types::EventName;
use rustfs_utils::http::{
AMZ_OBJECT_LOCK_LEGAL_HOLD, AMZ_OBJECT_LOCK_MODE, AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE, AMZ_TAGGING_DIRECTIVE,
@@ -94,6 +92,8 @@ use rustfs_utils::http::{
insert_str, replication_target_versions,
};
use rustfs_utils::{DEFAULT_SIP_HASH_KEY, get_env_usize, sip_hash};
#[cfg(test)]
use s3s::dto::ReplicationConfiguration;
use std::collections::{HashMap, HashSet};
use std::fmt::Display;
use std::pin::Pin;
@@ -6500,10 +6500,9 @@ mod tests {
use super::super::replication_target_boundary::{BucketTarget, BucketTargets};
use super::*;
use rustfs_gateway_types::dto::Status;
use rustfs_gateway_types::persistence::PersistedVersioningConfiguration as VersioningConfiguration;
use rustfs_gateway_types::persistence::{
PersistedReplicationDestination, PersistedReplicationRule, PersistedReplicationStatus,
use s3s::dto::{
BucketVersioningStatus, DeleteReplication, DeleteReplicationStatus, Destination, ExcludedPrefix, ReplicationRule,
ReplicationRuleStatus, VersioningConfiguration,
};
use std::collections::HashMap;
use time::OffsetDateTime;
@@ -8569,7 +8568,7 @@ mod tests {
..Default::default()
};
let rcfg = ReplicationConfig::new(
Some(PersistedReplicationConfiguration {
Some(ReplicationConfiguration {
role: role.to_string(),
rules: vec![],
}),
@@ -8595,7 +8594,7 @@ mod tests {
ReplicationVersioningStore::install_prefix_state_test_config(
&bucket,
VersioningConfiguration {
status: Some("Enabled".to_owned()),
status: Some(BucketVersioningStatus::from_static(BucketVersioningStatus::ENABLED)),
..Default::default()
},
);
@@ -8628,20 +8627,22 @@ mod tests {
ReplicationVersioningStore::install_prefix_state_test_config(
&bucket,
VersioningConfiguration {
status: Some("Enabled".to_owned()),
excluded_prefixes: Some(vec![Some("archive/".to_string())]),
status: Some(BucketVersioningStatus::from_static(BucketVersioningStatus::ENABLED)),
excluded_prefixes: Some(vec![ExcludedPrefix {
prefix: Some("archive/".to_string()),
}]),
..Default::default()
},
);
let rcfg = ReplicationConfig::new(
Some(PersistedReplicationConfiguration {
Some(ReplicationConfiguration {
role: String::new(),
rules: vec![PersistedReplicationRule {
rules: vec![ReplicationRule {
delete_marker_replication: None,
delete_replication: Some(PersistedReplicationStatus {
status: Status::ENABLED.to_string(),
delete_replication: Some(DeleteReplication {
status: DeleteReplicationStatus::from_static(DeleteReplicationStatus::ENABLED),
}),
destination: PersistedReplicationDestination {
destination: Destination {
bucket: arn.to_string(),
..Default::default()
},
@@ -8651,7 +8652,7 @@ mod tests {
prefix: Some(String::new()),
priority: Some(1),
source_selection_criteria: None,
status: Status::ENABLED.to_string(),
status: ReplicationRuleStatus::from_static(ReplicationRuleStatus::ENABLED),
}],
}),
Some(BucketTargets {
@@ -8687,14 +8688,14 @@ mod tests {
let admitted_arn = "arn:rustfs:replication:us-east-1:target:admitted";
let current_role = "arn:rustfs:replication:us-east-1:target:current";
let rcfg = ReplicationConfig::new(
Some(PersistedReplicationConfiguration {
Some(ReplicationConfiguration {
role: current_role.to_string(),
rules: vec![PersistedReplicationRule {
rules: vec![ReplicationRule {
delete_marker_replication: None,
delete_replication: Some(PersistedReplicationStatus {
status: Status::DISABLED.to_string(),
delete_replication: Some(DeleteReplication {
status: DeleteReplicationStatus::from_static(DeleteReplicationStatus::DISABLED),
}),
destination: PersistedReplicationDestination {
destination: Destination {
bucket: current_role.to_string(),
..Default::default()
},
@@ -8704,7 +8705,7 @@ mod tests {
prefix: Some(String::new()),
priority: Some(1),
source_selection_criteria: None,
status: Status::ENABLED.to_string(),
status: ReplicationRuleStatus::from_static(ReplicationRuleStatus::ENABLED),
}],
}),
Some(BucketTargets {
@@ -8752,12 +8753,12 @@ mod tests {
#[tokio::test]
async fn heal_rejects_semantically_invalid_replication_config() {
let rcfg = ReplicationConfig::new(
Some(PersistedReplicationConfiguration {
Some(ReplicationConfiguration {
role: String::new(),
rules: vec![PersistedReplicationRule {
rules: vec![ReplicationRule {
delete_marker_replication: None,
delete_replication: None,
destination: PersistedReplicationDestination {
destination: Destination {
bucket: "arn:rustfs:replication:us-east-1:target:bucket".to_string(),
..Default::default()
},
@@ -8767,7 +8768,7 @@ mod tests {
prefix: Some(String::new()),
priority: Some(1),
source_selection_criteria: None,
status: "Enabld".to_owned(),
status: ReplicationRuleStatus::from_static("Enabld"),
}],
}),
Some(BucketTargets::default()),
@@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use rustfs_gateway_types::persistence::PersistedReplicationConfiguration;
use s3s::dto::ReplicationConfiguration;
use super::replication_config_boundary::{ObjectOpts, ReplicationConfigurationExt};
use super::replication_filemeta_boundary::ReplicationType;
@@ -20,7 +20,7 @@ use super::replication_filemeta_boundary::ReplicationType;
pub(crate) struct ReplicationTargetConfigBridge;
impl ReplicationTargetConfigBridge {
pub(crate) fn target_is_used_by_rules(config: &PersistedReplicationConfiguration, arn: &str) -> bool {
pub(crate) fn target_is_used_by_rules(config: &ReplicationConfiguration, arn: &str) -> bool {
config
.filter_target_arns(&ObjectOpts {
op_type: ReplicationType::All,
@@ -37,7 +37,7 @@ mod tests {
#[test]
fn target_config_bridge_matches_role_target() {
let config = PersistedReplicationConfiguration {
let config = ReplicationConfiguration {
role: " arn:target ".to_string(),
rules: Vec::new(),
};
@@ -15,7 +15,7 @@
use super::replication_error_boundary::Result;
use crate::bucket::{versioning::VersioningApi as _, versioning_sys::BucketVersioningSys};
#[cfg(test)]
use rustfs_gateway_types::persistence::PersistedVersioningConfiguration as VersioningConfiguration;
use s3s::dto::VersioningConfiguration;
#[cfg(test)]
use std::{collections::HashMap, sync::LazyLock, sync::Mutex};
+1 -1
View File
@@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use rustfs_gateway_types::cors_tagging::PersistedTag as Tag;
use s3s::dto::Tag;
use std::cmp::Ordering;
use std::collections::HashMap;
use url::form_urlencoded;
+27 -211
View File
@@ -15,23 +15,9 @@
use crate::disk::{MIGRATING_META_BUCKET, RUSTFS_META_BUCKET};
use crate::error::{Error, Result, StorageError};
use regex::Regex;
use rustfs_gateway_types::cors_tagging::{
CorsTaggingCodecError, PersistedCorsConfiguration, PersistedTagging, parse_cors, parse_tagging, serialize_cors,
serialize_tagging,
};
use rustfs_gateway_types::persistence::{
PersistedAccelerateConfiguration, PersistedBucketEncryptionConfiguration, PersistedBucketLoggingStatus,
PersistedLifecycleConfiguration, PersistedNotificationConfiguration, PersistedObjectLockConfiguration,
PersistedPublicAccessBlockConfiguration, PersistedReplicationConfiguration, PersistedRequestPaymentConfiguration,
PersistedVersioningConfiguration, PersistedWebsiteConfiguration, PersistenceCodecError, parse_accelerate,
parse_bucket_encryption, parse_bucket_logging, parse_lifecycle_legacy_dates, parse_notification, parse_object_lock,
parse_public_access_block, parse_replication, parse_request_payment, parse_versioning, parse_website, serialize_accelerate,
serialize_bucket_encryption, serialize_bucket_logging, serialize_lifecycle_legacy_dates, serialize_notification,
serialize_object_lock, serialize_public_access_block, serialize_replication, serialize_request_payment, serialize_versioning,
serialize_website,
};
use rustfs_utils::WindowsFileNameError;
use rustfs_utils::path::SLASH_SEPARATOR;
use s3s::xml;
use tracing::instrument;
pub fn is_meta_bucketname(name: &str) -> bool {
@@ -88,173 +74,37 @@ pub fn check_valid_bucket_name_strict(bucket_name: &str) -> Result<()> {
check_bucket_name_common(bucket_name, true)
}
/// A bucket configuration persisted as XML in the bucket metadata, together
/// with the codec that reads and writes its stored bytes.
///
/// Every family reads and writes its stored bytes through the gateway
/// persistence codecs (`rustfs_gateway_types::persistence` and
/// `rustfs_gateway_types::cors_tagging`, rustfs/backlog#2745). The codecs keep
/// the persisted bytes the `bucket_config_goldens` pin, so a document the old
/// s3s codec wrote reads back and re-encodes unchanged.
pub trait BucketConfigXml: Sized {
/// Decodes the stored bytes of one configuration document.
fn decode_config_xml(input: &[u8]) -> std::result::Result<Self, BucketConfigXmlError>;
/// Encodes the value as RustFS stores it.
fn encode_config_xml(&self) -> std::result::Result<Vec<u8>, BucketConfigXmlError>;
// RUSTFS_COMPAT_TODO(s3gate-metadata-xml): the s3s codec reads persisted XML during migration. Remove after every supported writer uses the gateway codec and every retained metadata object and backup archive is verified or rewritten.
pub fn deserialize<T>(input: &[u8]) -> xml::DeResult<T>
where
T: for<'xml> xml::Deserialize<'xml>,
{
let mut d = xml::Deserializer::new(input);
let ans = T::deserialize(&mut d)?;
d.expect_eof()?;
Ok(ans)
}
/// Why a persisted bucket configuration could not be decoded or encoded.
#[derive(Debug, thiserror::Error)]
pub enum BucketConfigXmlError {
/// A gateway persistence codec refused the bytes or the value.
#[error(transparent)]
Persisted(#[from] PersistenceCodecError),
/// The gateway CORS or Tagging codec refused the bytes or the value.
#[error(transparent)]
CorsTagging(#[from] CorsTaggingCodecError),
#[allow(
dead_code,
reason = "xml serialize helper with no caller in this port; the live sibling is deserialize (backlog#1823)"
)]
pub fn serialize_content<T: xml::SerializeContent>(val: &T) -> xml::SerResult<String> {
let mut buf = Vec::with_capacity(256);
{
let mut ser = xml::Serializer::new(&mut buf);
val.serialize_content(&mut ser)?;
}
Ok(String::from_utf8(buf).unwrap())
}
/// Decodes one persisted bucket configuration document.
pub fn deserialize<T: BucketConfigXml>(input: &[u8]) -> std::result::Result<T, BucketConfigXmlError> {
T::decode_config_xml(input)
}
/// Encodes one bucket configuration as RustFS persists it.
pub fn serialize<T: BucketConfigXml>(val: &T) -> std::result::Result<Vec<u8>, BucketConfigXmlError> {
val.encode_config_xml()
}
impl BucketConfigXml for PersistedReplicationConfiguration {
fn decode_config_xml(input: &[u8]) -> std::result::Result<Self, BucketConfigXmlError> {
Ok(parse_replication(input)?)
}
fn encode_config_xml(&self) -> std::result::Result<Vec<u8>, BucketConfigXmlError> {
Ok(serialize_replication(self)?)
}
}
impl BucketConfigXml for PersistedLifecycleConfiguration {
fn decode_config_xml(input: &[u8]) -> std::result::Result<Self, BucketConfigXmlError> {
Ok(parse_lifecycle_legacy_dates(input)?)
}
fn encode_config_xml(&self) -> std::result::Result<Vec<u8>, BucketConfigXmlError> {
Ok(serialize_lifecycle_legacy_dates(self)?)
}
}
// The gateway writers of these families are infallible: a value that exists is
// always encodable, so only the readers can refuse bytes.
impl BucketConfigXml for PersistedAccelerateConfiguration {
fn decode_config_xml(input: &[u8]) -> std::result::Result<Self, BucketConfigXmlError> {
Ok(parse_accelerate(input)?)
}
fn encode_config_xml(&self) -> std::result::Result<Vec<u8>, BucketConfigXmlError> {
Ok(serialize_accelerate(self))
}
}
impl BucketConfigXml for PersistedBucketLoggingStatus {
fn decode_config_xml(input: &[u8]) -> std::result::Result<Self, BucketConfigXmlError> {
Ok(parse_bucket_logging(input)?)
}
fn encode_config_xml(&self) -> std::result::Result<Vec<u8>, BucketConfigXmlError> {
Ok(serialize_bucket_logging(self))
}
}
impl BucketConfigXml for PersistedCorsConfiguration {
fn decode_config_xml(input: &[u8]) -> std::result::Result<Self, BucketConfigXmlError> {
Ok(parse_cors(input)?)
}
fn encode_config_xml(&self) -> std::result::Result<Vec<u8>, BucketConfigXmlError> {
Ok(serialize_cors(self))
}
}
impl BucketConfigXml for PersistedNotificationConfiguration {
fn decode_config_xml(input: &[u8]) -> std::result::Result<Self, BucketConfigXmlError> {
Ok(parse_notification(input)?)
}
fn encode_config_xml(&self) -> std::result::Result<Vec<u8>, BucketConfigXmlError> {
Ok(serialize_notification(self))
}
}
impl BucketConfigXml for PersistedObjectLockConfiguration {
fn decode_config_xml(input: &[u8]) -> std::result::Result<Self, BucketConfigXmlError> {
Ok(parse_object_lock(input)?)
}
fn encode_config_xml(&self) -> std::result::Result<Vec<u8>, BucketConfigXmlError> {
Ok(serialize_object_lock(self))
}
}
impl BucketConfigXml for PersistedPublicAccessBlockConfiguration {
fn decode_config_xml(input: &[u8]) -> std::result::Result<Self, BucketConfigXmlError> {
Ok(parse_public_access_block(input)?)
}
fn encode_config_xml(&self) -> std::result::Result<Vec<u8>, BucketConfigXmlError> {
Ok(serialize_public_access_block(self))
}
}
impl BucketConfigXml for PersistedRequestPaymentConfiguration {
fn decode_config_xml(input: &[u8]) -> std::result::Result<Self, BucketConfigXmlError> {
Ok(parse_request_payment(input)?)
}
fn encode_config_xml(&self) -> std::result::Result<Vec<u8>, BucketConfigXmlError> {
Ok(serialize_request_payment(self))
}
}
impl BucketConfigXml for PersistedBucketEncryptionConfiguration {
fn decode_config_xml(input: &[u8]) -> std::result::Result<Self, BucketConfigXmlError> {
Ok(parse_bucket_encryption(input)?)
}
fn encode_config_xml(&self) -> std::result::Result<Vec<u8>, BucketConfigXmlError> {
Ok(serialize_bucket_encryption(self))
}
}
impl BucketConfigXml for PersistedTagging {
fn decode_config_xml(input: &[u8]) -> std::result::Result<Self, BucketConfigXmlError> {
Ok(parse_tagging(input)?)
}
fn encode_config_xml(&self) -> std::result::Result<Vec<u8>, BucketConfigXmlError> {
Ok(serialize_tagging(self))
}
}
impl BucketConfigXml for PersistedVersioningConfiguration {
fn decode_config_xml(input: &[u8]) -> std::result::Result<Self, BucketConfigXmlError> {
Ok(parse_versioning(input)?)
}
fn encode_config_xml(&self) -> std::result::Result<Vec<u8>, BucketConfigXmlError> {
Ok(serialize_versioning(self))
}
}
impl BucketConfigXml for PersistedWebsiteConfiguration {
fn decode_config_xml(input: &[u8]) -> std::result::Result<Self, BucketConfigXmlError> {
Ok(parse_website(input)?)
}
fn encode_config_xml(&self) -> std::result::Result<Vec<u8>, BucketConfigXmlError> {
Ok(serialize_website(self))
pub fn serialize<T: xml::Serialize>(val: &T) -> xml::SerResult<Vec<u8>> {
let mut buf = Vec::with_capacity(256);
{
let mut ser = xml::Serializer::new(&mut buf);
val.serialize(&mut ser)?;
}
Ok(buf)
}
pub fn has_bad_path_component(path: &str) -> bool {
@@ -584,40 +434,6 @@ mod tests {
use super::*;
use proptest::prelude::*;
/// Every bucket-configuration family reads and writes through its own
/// gateway codec (rustfs/backlog#2745): the family's golden document
/// decodes and re-encodes to the same bytes, and a document of another
/// family is refused instead of decoding into an empty configuration.
#[test]
fn every_bucket_config_family_uses_its_own_gateway_codec() {
fn assert_family<T: BucketConfigXml>(family: &str, own: &[u8], foreign: &[u8]) {
let value = deserialize::<T>(own).unwrap_or_else(|err| panic!("{family}: own document must decode: {err}"));
let encoded = serialize(&value).unwrap_or_else(|err| panic!("{family}: value must encode: {err}"));
assert_eq!(encoded, own, "{family}: re-encoding must keep the persisted bytes");
assert!(deserialize::<T>(foreign).is_err(), "{family}: a foreign root element must be refused");
}
macro_rules! golden {
($family:literal) => {
include_bytes!(concat!("../../tests/fixtures/bucket-config-goldens/", $family, "/minio.out.xml"))
};
}
let tagging = golden!("tagging");
let versioning = golden!("versioning");
assert_family::<PersistedAccelerateConfiguration>("accelerate", golden!("accelerate"), tagging);
assert_family::<PersistedBucketLoggingStatus>("logging", golden!("logging"), tagging);
assert_family::<PersistedCorsConfiguration>("cors", golden!("cors"), tagging);
assert_family::<PersistedNotificationConfiguration>("notification", golden!("notification"), tagging);
assert_family::<PersistedObjectLockConfiguration>("object_lock", golden!("object_lock"), tagging);
assert_family::<PersistedPublicAccessBlockConfiguration>("public_access_block", golden!("public_access_block"), tagging);
assert_family::<PersistedRequestPaymentConfiguration>("request_payment", golden!("request_payment"), tagging);
assert_family::<PersistedBucketEncryptionConfiguration>("encryption", golden!("encryption"), tagging);
assert_family::<PersistedTagging>("tagging", tagging, versioning);
assert_family::<PersistedVersioningConfiguration>("versioning", versioning, tagging);
assert_family::<PersistedWebsiteConfiguration>("website", golden!("website"), tagging);
}
/// rustfs#5785: keys whose path segments exceed the on-disk NAME_MAX
/// budget must be rejected up front as ObjectNameInvalid (4xx), not leak
/// ENAMETOOLONG as InternalError 500 from the disk layer.
+25 -17
View File
@@ -12,8 +12,8 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use rustfs_gateway_types::persistence::PersistedVersioningConfiguration as VersioningConfiguration;
use rustfs_utils::string::match_simple;
use s3s::dto::{BucketVersioningStatus, VersioningConfiguration};
pub trait VersioningApi {
fn enabled(&self) -> bool;
@@ -28,10 +28,10 @@ pub trait VersioningApi {
impl VersioningApi for VersioningConfiguration {
fn enabled(&self) -> bool {
self.status.as_deref() == Some("Enabled")
self.status == Some(BucketVersioningStatus::from_static(BucketVersioningStatus::ENABLED))
}
fn prefix_enabled(&self, prefix: &str) -> bool {
if self.status.as_deref() != Some("Enabled") {
if self.status != Some(BucketVersioningStatus::from_static(BucketVersioningStatus::ENABLED)) {
return false;
}
@@ -44,10 +44,12 @@ impl VersioningApi for VersioningConfiguration {
}
if let Some(ref excluded_prefixes) = self.excluded_prefixes {
for sprefix in excluded_prefixes.iter().flatten() {
let pattern = format!("{sprefix}*");
if match_simple(&pattern, prefix) {
return false;
for p in excluded_prefixes.iter() {
if let Some(ref sprefix) = p.prefix {
let pattern = format!("{sprefix}*");
if match_simple(&pattern, prefix) {
return false;
}
}
}
}
@@ -56,11 +58,11 @@ impl VersioningApi for VersioningConfiguration {
}
fn prefix_suspended(&self, prefix: &str) -> bool {
if self.status.as_deref() == Some("Suspended") {
if self.status == Some(BucketVersioningStatus::from_static(BucketVersioningStatus::SUSPENDED)) {
return true;
}
if self.status.as_deref() == Some("Enabled") {
if self.status == Some(BucketVersioningStatus::from_static(BucketVersioningStatus::ENABLED)) {
if prefix.is_empty() {
return false;
}
@@ -73,10 +75,12 @@ impl VersioningApi for VersioningConfiguration {
}
if let Some(ref excluded_prefixes) = self.excluded_prefixes {
for sprefix in excluded_prefixes.iter().flatten() {
let pattern = format!("{sprefix}*");
if match_simple(&pattern, prefix) {
return true;
for p in excluded_prefixes.iter() {
if let Some(ref sprefix) = p.prefix {
let pattern = format!("{sprefix}*");
if match_simple(&pattern, prefix) {
return true;
}
}
}
}
@@ -88,12 +92,14 @@ impl VersioningApi for VersioningConfiguration {
self.prefix_enabled(prefix) || self.prefix_suspended(prefix)
}
fn suspended(&self) -> bool {
self.status.as_deref() == Some("Suspended")
self.status == Some(BucketVersioningStatus::from_static(BucketVersioningStatus::SUSPENDED))
}
}
#[cfg(test)]
mod tests {
use s3s::dto::{BucketVersioningStatus, ExcludedPrefix};
use super::*;
struct LegacyVersioning;
@@ -128,8 +134,10 @@ mod tests {
#[test]
fn delete_state_treats_excluded_prefixes_as_unversioned() {
let config = VersioningConfiguration {
status: Some("Enabled".to_owned()),
excluded_prefixes: Some(vec![Some("archive/".to_string())]),
status: Some(BucketVersioningStatus::from_static(BucketVersioningStatus::ENABLED)),
excluded_prefixes: Some(vec![ExcludedPrefix {
prefix: Some("archive/".to_string()),
}]),
..Default::default()
};
@@ -138,7 +146,7 @@ mod tests {
assert_eq!(config.delete_state("live/object"), (true, false));
let suspended = VersioningConfiguration {
status: Some("Suspended".to_owned()),
status: Some(BucketVersioningStatus::from_static(BucketVersioningStatus::SUSPENDED)),
..Default::default()
};
assert_eq!(suspended.delete_state("archive/object"), (false, true));
+2 -2
View File
@@ -17,7 +17,7 @@ use super::metadata::unreadable_config_refusal;
use super::{metadata_sys::get_bucket_metadata_sys, versioning::VersioningApi};
use crate::disk::RUSTFS_META_BUCKET;
use crate::error::Result;
use rustfs_gateway_types::persistence::PersistedVersioningConfiguration as VersioningConfiguration;
use s3s::dto::VersioningConfiguration;
use tracing::{error, warn};
pub struct BucketVersioningSys {}
@@ -163,7 +163,7 @@ mod tests {
fn enabled() -> VersioningConfiguration {
VersioningConfiguration {
status: Some("Enabled".to_owned()),
status: Some(s3s::dto::BucketVersioningStatus::from_static(s3s::dto::BucketVersioningStatus::ENABLED)),
..Default::default()
}
}
+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, _| {
+12 -2
View File
@@ -105,7 +105,7 @@ pub async fn tier_mutation_control_time_out_client(
/// Build a `NodeServiceClient` bound to the [`ChannelClass`]-appropriate channel for `addr`.
///
/// Bulk `bytes`-carrying RPCs (ReadAll/WriteAll/CompareAndUpdateFile/ReadMultiple/BatchReadVersion) pass
/// Bulk `bytes`-carrying RPCs, including native pool metadata recovery inventories, pass
/// [`ChannelClass::Bulk`] so, when channel isolation is enabled, they are physically isolated
/// from lock/health RPCs; everything else uses [`ChannelClass::Control`]. When isolation is
/// disabled the two classes resolve to the same cached channel, i.e. legacy behavior.
@@ -357,6 +357,7 @@ fn rename_data_grpc_stage(path: &str) -> bool {
matches!(
path,
"/node_service.NodeService/RenameData"
| "/node_service.NodeService/RenameMultipartData"
| "/node_service.NodeService/RenameDataAtIncarnation"
| "/node_service.NodeService/RenameDataWithQuotaFence"
)
@@ -379,6 +380,7 @@ where
}
fn call(&mut self, mut request: HttpRequest<ReqBody>) -> Self::Future {
let rpc_method = request.extensions().get::<tonic::GrpcMethod<'_>>().cloned();
let observe_rename_data = rustfs_io_metrics::put_stage_metrics_enabled() && rename_data_grpc_stage(request.uri().path());
let request_scope_started = if observe_rename_data {
rustfs_io_metrics::put_stage_timer()
@@ -447,13 +449,21 @@ where
let response_state = verify_tonic_peer_replay_capabilities_response(&audience, challenge, response.headers());
if let Err(error) = &response_state
&& (response.headers().contains_key(RPC_REPLAY_CACHE_CAPABILITY_HEADER)
|| response.headers().contains_key(RPC_REPLAY_CACHE_CAPABILITY_PROOF_HEADER))
|| response.headers().contains_key(RPC_REPLAY_CACHE_CAPABILITY_PROOF_HEADER)
|| matches!(sent_state.cache_capability, Some(PeerReplayCapability::Capable { .. })))
{
debug!(
event = "internode_rpc_capability_proof_rejected",
component = "ecstore",
subsystem = "rpc_client",
result = "rejected",
audience = audience.rsplit('@').next().unwrap_or("unknown"),
rpc_service = rpc_method.as_ref().map(|method| method.service()).unwrap_or("unknown"),
rpc_method = rpc_method.as_ref().map(|method| method.method()).unwrap_or("unknown"),
http_status = response.status().as_u16(),
grpc_status = response.headers().get("grpc-status").and_then(|value| value.to_str().ok()).unwrap_or("trailers"),
capability_present = response.headers().contains_key(RPC_REPLAY_CACHE_CAPABILITY_HEADER),
capability_proof_present = response.headers().contains_key(RPC_REPLAY_CACHE_CAPABILITY_PROOF_HEADER),
error = %error,
"internode RPC capability proof rejected"
)
@@ -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>,
@@ -1954,15 +1965,6 @@ impl PeerRestClient {
Ok((self.topology_member.clone(), epoch))
}
pub async fn probe_restore_copyback_capability(&self, topology_fingerprint: String) -> Result<(String, Uuid)> {
let probe = rustfs_protos::restore_copyback_capability_probe(Uuid::new_v4().as_bytes());
let result = self
.heal_control(rustfs_protos::HEAL_CONTROL_PROTOCOL_VERSION, topology_fingerprint, probe)
.await?;
let epoch = decode_remote_version_state_capability(&self.topology_member, &result)?;
Ok((self.topology_member.clone(), epoch))
}
pub async fn probe_cross_pool_fence(&self, topology_fingerprint: String) -> Result<(String, u32, Uuid)> {
let mut probe = rustfs_protos::CROSS_POOL_FENCE_CAPABILITY_PROBE_PREFIX.to_vec();
probe.extend_from_slice(Uuid::new_v4().as_bytes());
@@ -1973,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
@@ -4342,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();
+718 -20
View File
@@ -22,13 +22,17 @@ use crate::cluster::rpc::internode_data_transport::{
WriteStreamRequest,
};
use crate::disk::error::{Error, Result};
use crate::disk::multipart_source::{
MULTIPART_SOURCE_PROTOCOL_VERSION, MultipartSourceOperation, MultipartSourceRequest, MultipartSourceResponse,
};
use crate::disk::{
BatchReadVersionReq, BatchReadVersionResp, CheckPartsResp, ConditionalFileUpdate, DELETE_RECOVERY_PROTOCOL_VERSION,
DeleteOptions, DeleteRecoveryAction, DeleteRecoveryCursor, DeleteRecoveryDigest, DeleteRecoveryInspection,
DeleteRecoveryPage, DeleteRecoveryRpcOperation, DeleteRecoveryRpcReply, DiskAPI, DiskInfo, DiskInfoOptions, DiskLocation,
DiskOption, FileInfoVersions, FileReader, FileWriter, GuardedDeleteVersionsPayload, GuardedFileInfoVersions,
PartTransactionAction, ReadMultipleReq, ReadMultipleResp, ReadOptions, RenameDataResp, SnapshotLeaseToken,
UpdateMetadataOpts, VolumeInfo, WalkDirOptions, batch_read_version_one_by_one,
PartTransactionAction, PoolMetaNativeRecoveryRequest, PoolMetaNativeRecoveryResponse, ReadMultipleReq, ReadMultipleResp,
ReadOptions, RenameDataResp, SnapshotLeaseToken, UpdateMetadataOpts, VolumeInfo, WalkDirOptions,
batch_read_version_one_by_one,
disk_store::{
DEFAULT_RUSTFS_DRIVE_ACTIVE_MONITORING, ENV_RUSTFS_DRIVE_ACTIVE_MONITORING, SKIP_IF_SUCCESS_BEFORE,
get_drive_active_check_interval, get_drive_active_check_timeout, get_drive_disk_info_timeout, get_drive_list_dir_timeout,
@@ -62,11 +66,14 @@ use rustfs_protos::proto_gen::node_service::{
BatchReadVersionRequest, BatchReadVersionResponse, CheckPartsRequest, CompareAndUpdateFileOutcome,
CompareAndUpdateFileRequest, DeletePathsRequest, DeleteRequest, DeleteVersionRequest, DeleteVersionsRequest,
DeleteVersionsResponse, DeleteVolumeRequest, DiskInfoRequest, GuardedDeleteVersionsRequest, ListDirRequest,
ListVolumesRequest, MakeVolumeRequest, MakeVolumesRequest, PreparePartTransactionRequest, ReadAllRequest,
ReadMetadataRequest, ReadMultipleRequest, ReadMultipleResponse, ReadPartsRequest, ReadVersionRequest, ReadXlRequest,
RecoverDeleteTransactionRequest, RenameDataRequest, RenameFileRequest, SettlePartTransactionRequest,
SnapshotLeaseReleaseRequest, SnapshotLeaseRenewRequest, SnapshotLeaseRequest, SnapshotLeaseResponse, StatVolumeRequest,
UpdateMetadataRequest, VerifyFileRequest, WriteAllRequest, WriteMetadataRequest, node_service_client::NodeServiceClient,
ListVolumesRequest, MakeVolumeRequest, MakeVolumesRequest, PoolMetaNativeRecoveryRequest as PoolMetaNativeRecoveryRpcRequest,
PreparePartTransactionRequest, ReadAllRequest, ReadMetadataRequest, ReadMultipleRequest, ReadMultipleResponse,
ReadPartsRequest, ReadVersionRequest, ReadXlRequest, RecoverDeleteTransactionRequest,
RecoverMultipartSourceRequest as RecoverMultipartSourceRpcRequest,
RecoverMultipartSourceResponse as RecoverMultipartSourceRpcResponse, RenameDataRequest, RenameFileRequest,
SettlePartTransactionRequest, SnapshotLeaseReleaseRequest, SnapshotLeaseRenewRequest, SnapshotLeaseRequest,
SnapshotLeaseResponse, StatVolumeRequest, UpdateMetadataRequest, VerifyFileRequest, WriteAllRequest, WriteMetadataRequest,
node_service_client::NodeServiceClient,
};
use serde::{Serialize, de::DeserializeOwned};
use std::{
@@ -237,6 +244,36 @@ fn attach_quota_mutation_body_digest<T>(
crate::cluster::rpc::set_tonic_canonical_body_digest(request, &body).map_err(Error::other)
}
fn decode_multipart_source_recovery_response(
request: &MultipartSourceRequest,
response: RecoverMultipartSourceRpcResponse,
) -> Result<MultipartSourceResponse> {
if response.protocol_version != u32::from(MULTIPART_SOURCE_PROTOCOL_VERSION) {
return Err(DiskError::UnsupportedDisk);
}
if response.result.len() > MultipartSourceOperation::max_wire_payload_bytes() {
return Err(DiskError::FileCorrupt);
}
if !response.success {
if !response.result.is_empty() {
return Err(DiskError::FileCorrupt);
}
let error = response.error.ok_or(DiskError::FileCorrupt)?;
if DiskError::from_u32(error.code).is_none() {
return Err(DiskError::FileCorrupt);
}
return Err(error.into());
}
if response.error.is_some() {
return Err(DiskError::FileCorrupt);
}
let result: MultipartSourceResponse =
rustfs_protos::decode_multipart_source_recovery_payload(&response.result).map_err(|_| DiskError::FileCorrupt)?;
result.validate_wire()?;
result.validate_for(request)?;
Ok(result)
}
fn decode_volume_infos(volume_infos: Vec<String>) -> Result<Vec<VolumeInfo>> {
volume_infos
.into_iter()
@@ -2128,7 +2165,7 @@ impl RemoteDisk {
.map_err(|err| Error::RemoteClientUnavailable(err.to_string()))
}
/// Client for large `bytes`-carrying RPCs (ReadAll/WriteAll/CompareAndUpdateFile/ReadMultiple/BatchReadVersion).
/// Client for large `bytes`-carrying RPCs, including native pool metadata recovery inventories.
/// Routes onto the isolated bulk channel pool so large transfers cannot head-of-line block
/// lock/health RPCs (grpc-optimization P1). Falls back to the control channel when isolation
/// is disabled.
@@ -2224,6 +2261,38 @@ fn decode_msgpack_or_json<T: DeserializeOwned>(binary: &[u8], json: &str, value_
}
}
fn decode_multipart_publication_response(binary: &[u8]) -> Result<RenameDataResp> {
let result = if binary.len() > MultipartSourceOperation::max_wire_payload_bytes() {
Err(DiskError::FileCorrupt)
} else {
rustfs_protos::decode_multipart_source_recovery_payload(binary).map_err(|_| DiskError::FileCorrupt)
};
if result.is_ok() {
crate::cluster::rpc::runtime_sources::record_response_msgpack_decode("RenameDataResp");
} else {
crate::cluster::rpc::runtime_sources::record_response_msgpack_decode_error("RenameDataResp");
}
// A codec error must never echo a malformed opaque capability.
result
}
fn validate_multipart_publication_envelope(response: &rustfs_protos::proto_gen::node_service::RenameDataResponse) -> Result<()> {
if !response.success {
if !response.rename_data_resp.is_empty() || !response.rename_data_resp_bin.is_empty() {
return Err(DiskError::FileCorrupt);
}
let error = response.error.as_ref().ok_or(DiskError::FileCorrupt)?;
if DiskError::from_u32(error.code).is_none() {
return Err(DiskError::FileCorrupt);
}
return Err(error.clone().into());
}
if response.error.is_some() {
return Err(DiskError::FileCorrupt);
}
Ok(())
}
fn read_version_stage_timer(attribution_enabled: bool) -> Option<Instant> {
attribution_enabled.then(Instant::now)
}
@@ -2399,6 +2468,18 @@ impl RemoteDisk {
dst_path: &str,
scanner_publication_lease_token: Option<Uuid>,
) -> Result<RenameDataResp> {
let multipart_rename = src_volume == crate::disk::RUSTFS_META_MULTIPART_BUCKET;
let pool_meta_cas = rustfs_utils::http::metadata_compat::contains_key_str(
&fi.metadata,
crate::disk::POOL_META_NATIVE_CAS_METADATA_SUFFIX,
);
if pool_meta_cas
&& (dst_volume != crate::disk::RUSTFS_META_BUCKET
|| dst_path != "pool.bin"
|| scanner_publication_lease_token.is_some())
{
return Err(DiskError::UnsupportedDisk);
}
trace!(
event = EVENT_REMOTE_DISK_RPC,
component = LOG_COMPONENT_ECSTORE,
@@ -2441,9 +2522,12 @@ impl RemoteDisk {
&fi.metadata,
crate::disk::QUOTA_MUTATION_FENCE_METADATA_SUFFIX,
);
if quota_fenced {
if pool_meta_cas {
let body = canonical_body.map_err(|_| Error::other("pool metadata CAS body length cannot be represented"))?;
crate::cluster::rpc::set_tonic_canonical_body_digest(&mut request, &body).map_err(Error::other)?;
} else if quota_fenced {
attach_quota_mutation_body_digest(&mut request, canonical_body)?;
} else if scanner_publication_lease_token.is_some() || incarnation_bound {
} else if multipart_rename || scanner_publication_lease_token.is_some() || incarnation_bound {
let canonical_body =
canonical_body.map_err(|_| Error::other("rename_data request length cannot be represented"))?;
crate::cluster::rpc::set_tonic_canonical_body_digest(&mut request, &canonical_body).map_err(Error::other)?;
@@ -2458,7 +2542,12 @@ 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 multipart_rename {
// Old peers must reject before an upload source can be changed.
client.rename_multipart_data(request).await
} else if pool_meta_cas {
client.rename_data_with_pool_meta_cas(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 {
@@ -2472,7 +2561,14 @@ impl RemoteDisk {
rustfs_io_metrics::PUT_STAGE_SET_DISK_RENAME_REMOTE_CLIENT_RPC_AWAIT_POLL_PENDING_COUNT,
rustfs_io_metrics::PUT_STAGE_SET_DISK_RENAME_REMOTE_CLIENT_RPC_AWAIT_FIRST_PENDING_TO_READY,
)
.await?;
.await
.map_err(|status| {
if (multipart_rename || pool_meta_cas) && status.code() == Code::Unimplemented {
DiskError::UnsupportedDisk
} else {
status.into()
}
})?;
let response_process_started = rustfs_io_metrics::put_stage_timer();
let response_headers_started = rustfs_io_metrics::put_stage_timer();
let _ = response.metadata().len();
@@ -2492,7 +2588,9 @@ impl RemoteDisk {
);
let success_check_started = rustfs_io_metrics::put_stage_timer();
if !response.success {
if multipart_rename {
validate_multipart_publication_envelope(&response)?;
} else if !response.success {
return Err(response.error.unwrap_or_default().into());
}
rustfs_io_metrics::record_put_object_stage_duration_from(
@@ -2501,11 +2599,15 @@ impl RemoteDisk {
);
let decode_started = rustfs_io_metrics::put_stage_timer();
let rename_data_resp = decode_msgpack_or_json::<RenameDataResp>(
&response.rename_data_resp_bin,
&response.rename_data_resp,
"RenameDataResp",
)?;
let rename_data_resp = if multipart_rename {
decode_multipart_publication_response(&response.rename_data_resp_bin)?
} else {
decode_msgpack_or_json::<RenameDataResp>(
&response.rename_data_resp_bin,
&response.rename_data_resp,
"RenameDataResp",
)?
};
rustfs_io_metrics::record_put_object_stage_duration_from(
rustfs_io_metrics::PUT_STAGE_SET_DISK_RENAME_REMOTE_CLIENT_DECODE,
decode_started,
@@ -2515,6 +2617,14 @@ impl RemoteDisk {
response_process_started,
);
if multipart_rename {
let publication = rename_data_resp.multipart_source.as_ref().ok_or(DiskError::FileCorrupt)?;
publication.validate_wire()?;
let expected = crate::disk::multipart_source::publication_identity(fi)?.ok_or(DiskError::FileCorrupt)?;
if publication.publication != expected {
return Err(DiskError::FileCorrupt);
}
}
Ok(rename_data_resp)
},
get_max_timeout_duration(),
@@ -2907,6 +3017,20 @@ impl DiskAPI for RemoteDisk {
let file_info_bin = encode_file_info_msgpack(&fi)?;
let opts_bin = encode_delete_options_msgpack(&opts)?;
let conditional_marker = opts.expected_delete_marker.is_some();
let pool_meta_undo = rustfs_utils::http::metadata_compat::contains_key_str(
&fi.metadata,
crate::disk::POOL_META_NATIVE_CAS_METADATA_SUFFIX,
);
if pool_meta_undo
&& (volume != crate::disk::RUSTFS_META_BUCKET
|| path != "pool.bin"
|| !opts.undo_write
|| opts.undo_delete
|| force_del_marker
|| conditional_marker)
{
return Err(DiskError::UnsupportedDisk);
}
let file_info = serde_json::to_string(&fi)?;
let opts = serde_json::to_string(&opts)?;
@@ -2926,7 +3050,7 @@ impl DiskAPI for RemoteDisk {
});
let canonical_body = rustfs_protos::canonical_delete_version_request_body(request.get_ref());
let incarnation_bound = !request.get_ref().bucket_incarnation_id.is_empty();
if incarnation_bound {
if incarnation_bound || pool_meta_undo {
let body = canonical_body.map_err(|_| Error::other("delete-version body length cannot be represented"))?;
crate::cluster::rpc::set_tonic_canonical_body_digest(&mut request, &body).map_err(Error::other)?;
} else {
@@ -2935,7 +3059,15 @@ impl DiskAPI for RemoteDisk {
// The marker-specific method rejects older peers; its body digest
// also binds any incarnation, so neither precondition can be lost.
let response = if conditional_marker {
let response = if pool_meta_undo {
client.undo_pool_meta_write(request).await.map_err(|status| {
if status.code() == Code::Unimplemented {
DiskError::UnsupportedDisk
} else {
status.into()
}
})?
} else if conditional_marker {
client.delete_retired_marker(request).await?
} else if incarnation_bound {
client.delete_version_at_incarnation(request).await?
@@ -3617,6 +3749,107 @@ impl DiskAPI for RemoteDisk {
.await
}
async fn rename_data_with_pool_meta_cas(
&self,
src_volume: &str,
src_path: &str,
fi: FileInfo,
dst_volume: &str,
dst_path: &str,
) -> Result<RenameDataResp> {
if !rustfs_utils::http::metadata_compat::contains_key_str(&fi.metadata, crate::disk::POOL_META_NATIVE_CAS_METADATA_SUFFIX)
{
return Err(DiskError::UnsupportedDisk);
}
self.rename_data_borrowed(src_volume, src_path, &fi, dst_volume, dst_path)
.await
}
async fn undo_pool_meta_write(&self, volume: &str, path: &str, fi: FileInfo, opts: DeleteOptions) -> Result<()> {
if !rustfs_utils::http::metadata_compat::contains_key_str(&fi.metadata, crate::disk::POOL_META_NATIVE_CAS_METADATA_SUFFIX)
{
return Err(DiskError::UnsupportedDisk);
}
self.delete_version(volume, path, fi, false, opts).await
}
async fn pool_meta_native_recovery(
&self,
operation: PoolMetaNativeRecoveryRequest,
) -> Result<PoolMetaNativeRecoveryResponse> {
self.execute_with_timeout_for_op(
"pool_meta_native_recovery",
|| async {
let payload = rmp_serde::to_vec_named(&operation).map_err(Error::other)?;
if payload.len() > rustfs_protos::POOL_META_NATIVE_RECOVERY_MAX_PAYLOAD_BYTES {
return Err(DiskError::FileCorrupt);
}
let mut client = self.get_bulk_client().await?;
let mut request = Request::new(PoolMetaNativeRecoveryRpcRequest {
disk: self.endpoint.to_string(),
payload: payload.into(),
});
let body = rustfs_protos::canonical_pool_meta_native_recovery_request_body(request.get_ref())
.map_err(|_| Error::other("pool metadata recovery body length cannot be represented"))?;
crate::cluster::rpc::set_tonic_canonical_body_digest(&mut request, &body).map_err(Error::other)?;
let response = client
.pool_meta_native_recovery(request)
.await
.map_err(|status| {
if status.code() == Code::Unimplemented {
DiskError::UnsupportedDisk
} else {
status.into()
}
})?
.into_inner();
if !response.success {
return Err(response.error.unwrap_or_default().into());
}
rustfs_protos::decode_pool_meta_native_recovery_payload(&response.payload).map_err(Error::other)
},
get_max_timeout_duration(),
)
.await
}
async fn recover_multipart_source(&self, mut source: MultipartSourceRequest) -> Result<MultipartSourceResponse> {
source.prepare_wire()?;
let payload = rmp_serde::to_vec_named(&source.operation).map_err(Error::other)?;
if payload.len() > MultipartSourceOperation::max_wire_payload_bytes() {
return Err(DiskError::FileCorrupt);
}
self.execute_with_timeout_for_op(
"recover_multipart_source",
|| async {
let mut client = self.get_bulk_client().await?;
let mut request = Request::new(RecoverMultipartSourceRpcRequest {
disk: self.endpoint.to_string(),
path: source.path.clone(),
protocol_version: u32::from(source.protocol_version),
operation: payload.into(),
});
let body = rustfs_protos::canonical_recover_multipart_source_request_body(request.get_ref())
.map_err(|_| Error::other("multipart source recovery request length cannot be represented"))?;
crate::cluster::rpc::set_tonic_canonical_body_digest(&mut request, &body).map_err(Error::other)?;
let response = client
.recover_multipart_source(request)
.await
.map_err(|status| {
if status.code() == Code::Unimplemented {
DiskError::UnsupportedDisk
} else {
status.into()
}
})?
.into_inner();
decode_multipart_source_recovery_response(&source, response)
},
get_max_timeout_duration(),
)
.await
}
#[tracing::instrument(level = "trace", skip_all)]
async fn list_dir(&self, _origvolume: &str, volume: &str, dir_path: &str, count: i32) -> Result<Vec<String>> {
trace!(volume, dir_path, "Remote disk list_dir RPC started");
@@ -4553,6 +4786,322 @@ mod tests {
static INIT: Once = Once::new();
fn multipart_source_claim_request() -> MultipartSourceRequest {
use crate::disk::multipart_source::{
MultipartRecoveryRound, MultipartSourcePhase, MultipartSourceSnapshot, SourceGeneration, SourceGeometry,
SourceLineage,
};
let original_data_dir = Uuid::new_v4();
let lineage = SourceLineage {
bucket: "bucket".to_string(),
object: "object".to_string(),
path: "upload".to_string(),
original_data_dir,
original_version_id: None,
created_unix_nanos: 1,
original_identity: [1; 32],
geometry: SourceGeometry {
data_blocks: 2,
parity_blocks: 2,
block_size: 1024,
distribution: vec![1, 2, 3, 4],
},
};
let generation = SourceGeneration {
epoch: 0,
nonce: original_data_dir,
};
MultipartSourceRequest {
protocol_version: MULTIPART_SOURCE_PROTOCOL_VERSION,
path: lineage.path.clone(),
operation: MultipartSourceOperation::Claim {
expected: MultipartSourceSnapshot {
disk_id: Uuid::new_v4(),
physical_slot: 0,
lineage: lineage.clone(),
generation,
revision: 0,
phase: MultipartSourcePhase::Active,
state_sha256: [2; 32],
head_sha256: Some([3; 32]),
inventory_sha256: [4; 32],
capability_sha256: None,
publication: None,
},
round: MultipartRecoveryRound {
lineage,
from_generation: generation,
old_attempt: Uuid::new_v4(),
generation: SourceGeneration {
epoch: 1,
nonce: Uuid::new_v4(),
},
round: Uuid::new_v4(),
},
},
}
}
#[tokio::test]
async fn multipart_source_recovery_old_peer_rejects_without_generic_fallback() {
runtime_sources::ensure_test_rpc_secret();
let peer = TestGrpcPeer::spawn(Bytes::new(), Bytes::new())
.await
.expect("old peer must bind");
let remote = remote_disk_for_addr(&peer.addr).await;
for request in [
MultipartSourceRequest {
protocol_version: MULTIPART_SOURCE_PROTOCOL_VERSION,
path: "upload".to_string(),
operation: MultipartSourceOperation::Inspect { lineage: None },
},
multipart_source_claim_request(),
] {
assert_eq!(
remote
.recover_multipart_source(request)
.await
.expect_err("old peer must reject before recovery work"),
DiskError::UnsupportedDisk
);
}
assert_eq!(
*peer.peer.unsupported_methods.lock().expect("observed methods lock"),
vec!["/node_service.NodeService/RecoverMultipartSource"; 2],
"read and mutation requests must never fall back to generic reads, writes or legacy recovery",
);
peer.stop().await;
}
#[test]
fn multipart_publication_response_is_strict_and_redacts_codec_errors() {
let encoded = rmp_serde::to_vec_named(&RenameDataResp::default()).expect("publication response");
assert!(decode_multipart_publication_response(&encoded).is_ok());
let mut trailing = encoded;
trailing.push(0xc0);
let secret = "private-undo-capability";
let malformed =
rmp_serde::to_vec_named(&serde_json::json!({ "multipart_source": secret })).expect("malformed capability response");
for binary in [Vec::new(), trailing, malformed] {
let error = decode_multipart_publication_response(&binary).expect_err("invalid publication response");
assert!(matches!(error, DiskError::FileCorrupt));
assert!(!error.to_string().contains(secret));
}
use rustfs_protos::proto_gen::node_service::RenameDataResponse;
for response in [
RenameDataResponse {
success: true,
error: Some(DiskError::FileNotFound.into()),
..Default::default()
},
RenameDataResponse::default(),
RenameDataResponse {
success: false,
error: Some(rustfs_protos::proto_gen::node_service::Error {
code: 0,
error_info: secret.to_string(),
}),
..Default::default()
},
] {
assert!(matches!(validate_multipart_publication_envelope(&response), Err(DiskError::FileCorrupt)));
}
let response = RenameDataResponse {
success: false,
error: Some(DiskError::ConditionalDeleteConflict.into()),
..Default::default()
};
assert!(matches!(
validate_multipart_publication_envelope(&response),
Err(DiskError::ConditionalDeleteConflict)
));
}
#[test]
fn multipart_source_recovery_rejects_malformed_and_unrelated_replies() {
use crate::disk::multipart_source::MultipartSourceInspection;
let source = MultipartSourceRequest {
protocol_version: MULTIPART_SOURCE_PROTOCOL_VERSION,
path: "upload".to_string(),
operation: MultipartSourceOperation::Inspect { lineage: None },
};
let result = MultipartSourceResponse::Inspected(MultipartSourceInspection {
disk_id: Uuid::new_v4(),
snapshot: None,
original: None,
recovery: None,
negative_certificate: None,
ready_certificate: None,
terminal_certificate: Vec::new(),
root_missing: false,
abort_round: None,
abort_certificate: None,
});
let valid = RecoverMultipartSourceRpcResponse {
success: true,
protocol_version: u32::from(MULTIPART_SOURCE_PROTOCOL_VERSION),
result: rmp_serde::to_vec_named(&result).expect("inspection reply").into(),
error: None,
};
assert!(decode_multipart_source_recovery_response(&source, valid.clone()).is_ok());
let claim = multipart_source_claim_request();
let MultipartSourceOperation::Claim { mut expected, .. } = claim.operation else { unreachable!() };
expected.lineage.path = "other-upload".to_string();
let wrong_scope = MultipartSourceResponse::Inspected(MultipartSourceInspection {
disk_id: expected.disk_id,
snapshot: Some(expected),
original: None,
recovery: None,
negative_certificate: None,
ready_certificate: None,
terminal_certificate: Vec::new(),
root_missing: false,
abort_round: None,
abort_certificate: None,
});
let wrong_scope = RecoverMultipartSourceRpcResponse {
result: rmp_serde::to_vec_named(&wrong_scope).expect("unrelated inspection").into(),
..valid.clone()
};
assert!(decode_multipart_source_recovery_response(&source, wrong_scope).is_err());
let mut wrong_version = valid.clone();
wrong_version.protocol_version += 1;
assert!(matches!(
decode_multipart_source_recovery_response(&source, wrong_version),
Err(DiskError::UnsupportedDisk)
));
for invalid in [
RecoverMultipartSourceRpcResponse {
error: Some(DiskError::FileNotFound.into()),
..valid.clone()
},
RecoverMultipartSourceRpcResponse {
success: false,
error: None,
result: Bytes::new(),
..valid.clone()
},
RecoverMultipartSourceRpcResponse {
success: false,
error: Some(rustfs_protos::proto_gen::node_service::Error {
code: 0,
error_info: "invalid code".to_string(),
}),
result: Bytes::new(),
..valid.clone()
},
RecoverMultipartSourceRpcResponse {
success: false,
error: Some(DiskError::FileNotFound.into()),
..valid.clone()
},
RecoverMultipartSourceRpcResponse {
result: Bytes::from_static(b"invalid-msgpack"),
..valid.clone()
},
{
let mut trailing = valid;
let mut bytes = trailing.result.to_vec();
bytes.push(0xc0);
trailing.result = bytes.into();
trailing
},
] {
assert!(decode_multipart_source_recovery_response(&source, invalid).is_err());
}
let native_error = RecoverMultipartSourceRpcResponse {
success: false,
protocol_version: u32::from(MULTIPART_SOURCE_PROTOCOL_VERSION),
result: Bytes::new(),
error: Some(DiskError::ConditionalDeleteConflict.into()),
};
assert!(matches!(
decode_multipart_source_recovery_response(&source, native_error),
Err(DiskError::ConditionalDeleteConflict)
));
}
#[test]
fn multipart_source_recovery_does_not_accept_caller_constructed_claim_proofs() {
use crate::disk::multipart_source::{MultipartProofKind, MultipartSourcePhase, MultipartSourceProof};
runtime_sources::ensure_test_rpc_secret();
let request = multipart_source_claim_request();
let MultipartSourceOperation::Claim { expected, round } = &request.operation else {
unreachable!()
};
let mut snapshot = expected.clone();
snapshot.generation = round.generation;
snapshot.phase = MultipartSourcePhase::Recovering;
snapshot.revision += 1;
for authentication in [
serde_json::json!({ "class": "cluster_hmac", "mac": vec![0_u8; 32] }),
serde_json::json!({ "class": "native_local" }),
] {
let proof: MultipartSourceProof = serde_json::from_value(serde_json::json!({
"kind": MultipartProofKind::Claimed,
"challenge": round.round,
"recovery": round,
"snapshot": snapshot,
"authentication": authentication,
}))
.expect("untrusted wire fields can decode without gaining native authority");
let response = RecoverMultipartSourceRpcResponse {
success: true,
protocol_version: u32::from(MULTIPART_SOURCE_PROTOCOL_VERSION),
result: rmp_serde::to_vec_named(&MultipartSourceResponse::Claimed(proof))
.expect("forged claim fields")
.into(),
error: None,
};
assert!(
decode_multipart_source_recovery_response(&request, response).is_err(),
"matching caller fields cannot mint a native H vote"
);
}
}
#[tokio::test]
async fn multipart_rename_old_peer_rejects_without_legacy_fallback() {
runtime_sources::ensure_test_rpc_secret();
let peer = TestGrpcPeer::spawn(Bytes::new(), Bytes::new())
.await
.expect("old peer must bind");
let remote = remote_disk_for_addr(&peer.addr).await;
let file_info = sample_rename_data_file_info();
for scanner_lease in [None, Some(Uuid::new_v4())] {
assert_eq!(
remote
.rename_data_borrowed_with_fence(
crate::disk::RUSTFS_META_MULTIPART_BUCKET,
"upload",
&file_info,
"bucket",
"object",
scanner_lease,
)
.await
.expect_err("old peer cannot preserve multipart source data"),
DiskError::UnsupportedDisk,
);
}
assert!(
remote
.rename_data("tmp", "source", file_info, "bucket", "object")
.await
.is_err()
);
assert_eq!(
*peer.peer.unsupported_methods.lock().expect("observed methods lock"),
vec![
"/node_service.NodeService/RenameMultipartData",
"/node_service.NodeService/RenameMultipartData",
"/node_service.NodeService/RenameData",
],
"each dispatch must use exactly one method; multipart publication must never downgrade",
);
peer.stop().await;
}
#[tokio::test]
async fn quota_fence_old_peer_rejects_without_legacy_fallback() {
runtime_sources::ensure_test_rpc_secret();
@@ -4607,6 +5156,153 @@ mod tests {
peer.stop().await;
}
#[tokio::test]
async fn pool_meta_native_recovery_uses_bulk_channel_without_replacing_control() {
runtime_sources::ensure_test_rpc_secret();
temp_env::async_with_vars(
[
(rustfs_config::ENV_INTERNODE_CHANNEL_ISOLATION, Some("true")),
(rustfs_config::ENV_INTERNODE_BULK_CHANNELS, Some("1")),
],
async {
let peer = TestGrpcPeer::spawn(Bytes::new(), Bytes::new())
.await
.expect("route test peer must bind");
let remote = remote_disk_for_addr(&peer.addr).await;
let bulk_key = format!("{}\u{0}bulk\u{0}0", peer.addr);
drop(remote.get_client().await.expect("control channel must connect"));
assert!(rustfs_common::has_cached_connection(&peer.addr).await, "control channel must be warm");
assert!(
!rustfs_common::has_cached_connection(&bulk_key).await,
"the unique peer must have no bulk channel yet"
);
assert_eq!(
remote
.pool_meta_native_recovery(PoolMetaNativeRecoveryRequest::Probe {})
.await,
Err(DiskError::UnsupportedDisk),
"the old-peer response must still traverse the real recovery RPC route",
);
assert!(
rustfs_common::has_cached_connection(&peer.addr).await,
"recovery must retain the control cache"
);
assert!(
rustfs_common::has_cached_connection(&bulk_key).await,
"recovery must establish its isolated bulk channel"
);
for request in [
MultipartSourceRequest {
protocol_version: MULTIPART_SOURCE_PROTOCOL_VERSION,
path: "upload".to_owned(),
operation: MultipartSourceOperation::Inspect { lineage: None },
},
multipart_source_claim_request(),
] {
rustfs_common::evict_connection(&bulk_key).await;
assert!(
!rustfs_common::has_cached_connection(&bulk_key).await,
"each multipart recovery must establish its own observed bulk route"
);
assert_eq!(
remote
.recover_multipart_source(request)
.await
.expect_err("old peer must reject before recovery work"),
DiskError::UnsupportedDisk,
);
assert!(
rustfs_common::has_cached_connection(&peer.addr).await,
"multipart recovery must retain the control channel"
);
assert!(
rustfs_common::has_cached_connection(&bulk_key).await,
"multipart inspection and mutation must both establish an isolated bulk channel"
);
}
assert_eq!(
*peer.peer.unsupported_methods.lock().expect("observed methods lock"),
vec![
"/node_service.NodeService/PoolMetaNativeRecovery",
"/node_service.NodeService/RecoverMultipartSource",
"/node_service.NodeService/RecoverMultipartSource",
],
);
remote.cancel_token.cancel();
drop(remote);
rustfs_common::evict_connection(&peer.addr).await;
rustfs_common::evict_connection(&bulk_key).await;
peer.stop().await;
},
)
.await;
}
#[tokio::test]
async fn pool_meta_native_old_peer_rejects_without_legacy_fallback() {
runtime_sources::ensure_test_rpc_secret();
let peer = TestGrpcPeer::spawn(Bytes::new(), Bytes::new())
.await
.expect("old peer must bind");
let remote = remote_disk_for_addr(&peer.addr).await;
let mut file_info = sample_rename_data_file_info();
crate::disk::PoolMetaNativeCasCondition {
attempt_id: Uuid::new_v4(),
parent: None,
parent_raw_sha256: None,
}
.insert_into(&mut file_info.metadata)
.expect("native condition must encode");
assert_eq!(
remote
.rename_data_borrowed_with_fence(
crate::disk::RUSTFS_META_TMP_BUCKET,
"staged",
&file_info,
crate::disk::RUSTFS_META_BUCKET,
"pool.bin",
None
)
.await
.expect_err("old peer must reject native CAS"),
DiskError::UnsupportedDisk,
);
assert_eq!(
remote
.delete_version(
crate::disk::RUSTFS_META_BUCKET,
"pool.bin",
file_info,
false,
DeleteOptions {
undo_write: true,
old_data_dir: Some(Uuid::new_v4()),
..Default::default()
}
)
.await,
Err(DiskError::UnsupportedDisk),
);
assert_eq!(
remote
.pool_meta_native_recovery(PoolMetaNativeRecoveryRequest::Probe {})
.await,
Err(DiskError::UnsupportedDisk),
);
assert_eq!(
*peer.peer.unsupported_methods.lock().expect("observed methods lock"),
vec![
"/node_service.NodeService/RenameDataWithPoolMetaCas",
"/node_service.NodeService/UndoPoolMetaWrite",
"/node_service.NodeService/PoolMetaNativeRecovery"
],
"protected mutations and capability probes must never fall back to legacy RPCs",
);
peer.stop().await;
}
#[tokio::test]
async fn guarded_delete_old_peer_rejects_without_legacy_fallback() {
runtime_sources::ensure_test_rpc_secret();
@@ -5494,6 +6190,7 @@ mod tests {
fn rename_data_response_accepts_legacy_json_without_decode_error() {
crate::cluster::rpc::runtime_sources::reset_internode_metrics_for_test();
let response = RenameDataResp {
multipart_source: None,
old_data_dir: Some(Uuid::new_v4()),
rollback_data_dir: Some(Uuid::new_v4()),
cleanup_data_dir: Some(Uuid::new_v4()),
@@ -5898,6 +6595,7 @@ mod tests {
#[test]
fn rename_data_resp_named_msgpack_is_smaller_than_json() {
let response = RenameDataResp {
multipart_source: None,
old_data_dir: Some(Uuid::new_v4()),
rollback_data_dir: Some(Uuid::new_v4()),
cleanup_data_dir: Some(Uuid::new_v4()),
@@ -0,0 +1,630 @@
// Copyright 2026 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use super::*;
use crate::disk::error::DiskError;
use crate::storage_api_contracts::namespace::NamespaceLocking as _;
use codec::{fixture_inline_pool_meta_replicas as replicas, fixture_split_inline_pool_meta_replicas as split};
use std::path::PathBuf;
use tokio::fs;
struct PhysicalFixture {
_directories: Vec<tempfile::TempDir>,
store: Arc<ECStore>,
canonical: Vec<u8>,
pending: Vec<u8>,
identity: Vec<u8>,
revision: PoolMetaRevision,
}
fn object_path(disk: &DiskStore) -> PathBuf {
disk.get_object_path_if_local(RUSTFS_META_BUCKET, POOL_META_NAME)
.expect("physical fixture must use local disks")
.expect("pool metadata path should resolve")
}
async fn disks(store: &ECStore) -> Vec<Vec<DiskStore>> {
let mut all = Vec::with_capacity(store.pools.len());
for pool in &store.pools {
let set = pool.get_disks_by_key(POOL_META_NAME);
let disks = set
.disks
.read()
.await
.iter()
.cloned()
.map(|disk| disk.expect("physical fixture must keep every disk online"))
.collect::<Vec<_>>();
assert_eq!(disks.len(), 4, "fixture requires an EC 2+2 set");
all.push(disks);
}
all
}
async fn owner(store: &ECStore) -> Arc<rustfs_lock::NamespaceLockGuard> {
let lock = store.pools[0]
.new_ns_lock(RUSTFS_META_BUCKET, POOL_META_NAME)
.await
.expect("real namespace lock should be created");
Arc::new(
lock.get_write_lock(get_lock_acquire_timeout())
.await
.expect("physical recovery must hold the namespace writer"),
)
}
fn clean_state(fixture: &PhysicalFixture) -> PoolMetaWriteState {
PoolMetaWriteState::for_startup(fixture.revision.cluster_id.expect("fixture V3 revision requires identity"), false)
}
async fn snapshots(store: &ECStore) -> Vec<Vec<PoolMetaNativeInspection>> {
let mut all = Vec::new();
for pool in disks(store).await {
let mut current = Vec::new();
for disk in pool {
match disk
.pool_meta_native_recovery(Request::Inspect {
object: PoolMetaNativeObject::PoolBin,
})
.await
.expect("physical native inspection should succeed")
{
Response::Inspected(inspection) => current.push(inspection),
_ => panic!("inspection must return a disk snapshot"),
}
}
all.push(current);
}
all
}
async fn fixture(split_pools: usize) -> PhysicalFixture {
let (directories, store, _other_store) =
crate::services::rebalance::test_three_pool_stores_with_four_disk_sets_with_isolated_node_contexts(None).await;
persist_v3_pool_meta_for_test(&store).await;
let previous = load_pool_meta_replicas(store.pools.clone(), true)
.await
.expect("baseline V3 pool metadata must be ordinary-readable");
let revision = PoolMetaRevision {
generation: previous
.revision
.generation
.checked_add(1)
.expect("test generation should increment"),
transaction_id: Some(uuid::Uuid::new_v4()),
..previous.revision
};
let candidate = PoolMetaCommittedCandidate {
canonical: previous.canonical.expect("baseline must have a canonical V3 COMMIT"),
meta: previous.meta.clone(),
revision: previous.revision,
};
let canonical = encode_pool_meta_v3_envelope(&previous.meta, revision, true, None).expect("next COMMIT should encode");
let pending =
encode_pool_meta_v3_envelope(&previous.meta, revision, false, Some(&candidate)).expect("next PREPARE should encode");
let identity = load_pool_meta_identity(
store.pools.clone(),
revision
.cluster_id
.expect("baseline V3 metadata should have a cluster identity"),
)
.await
.expect("real initialized identities must agree")
.identity
.expect("real fixture identity must be present");
let identity = encode_pool_meta_identity(identity).expect("real fixture identity should encode canonically");
let modified = OffsetDateTime::now_utc();
let split = split(&pending, &canonical, modified).await;
let commit = replicas(&canonical, modified + Duration::seconds(1)).await;
let prepared = replicas(&pending, modified).await;
let directory = uuid::Uuid::new_v4();
for (pool_index, pool) in disks(&store).await.into_iter().enumerate() {
let heads = if pool_index < split_pools { &split } else { &commit };
for (disk_index, disk) in pool.into_iter().enumerate() {
let object = object_path(&disk);
fs::remove_dir_all(&object)
.await
.expect("fault injection should remove only its own pool.bin fixture");
fs::create_dir_all(object.join(directory.to_string()))
.await
.expect("fixture rollback directory should be created");
fs::write(object.join("xl.meta"), &heads[disk_index])
.await
.expect("physical split head should be installed");
fs::write(object.join(directory.to_string()).join("xl.meta.bkp"), &prepared[disk_index])
.await
.expect("physical PREPARE backup should be installed");
}
}
PhysicalFixture {
_directories: directories,
store,
canonical,
pending,
identity,
revision,
}
}
async fn assert_committed(fixture: &PhysicalFixture) {
let confirmed = load_pool_meta_replicas(fixture.store.pools.clone(), true)
.await
.expect("ordinary full-pool read must succeed after physical recovery");
assert_eq!(confirmed.canonical.as_deref(), Some(fixture.canonical.as_slice()));
assert_eq!(confirmed.revision, fixture.revision);
assert!(!confirmed.replica_state.needs_repair);
assert!(confirmed.replica_state.repair_write_safe);
for pool in snapshots(&fixture.store).await {
let raw = pool
.iter()
.map(|disk| disk.current.clone().expect("every recovered physical head must exist"))
.collect::<Vec<_>>();
assert_eq!(
codec::decode_inline_pool_meta_replicas(&raw)
.await
.expect("every recovered shard must verify"),
vec![Bytes::copy_from_slice(&fixture.canonical)]
);
}
}
#[tokio::test]
#[serial_test::serial(pool_meta_version_env)]
async fn pool_meta_native_physical_split_repair_completes_normal_cas_and_quorum_readback() {
let fixture = fixture(1).await;
let before = read_pool_meta_replicas(fixture.store.pools.clone(), true).await;
assert!(matches!(before[0].replica, PoolMetaReplica::Unreadable(_)));
assert!(matches!(before[1].replica, PoolMetaReplica::Valid { committed: true, .. }));
let mut state = clean_state(&fixture);
let fence = PoolMetaPersistenceFence::Owned(owner(&fixture.store).await);
assert!(
repair(&fixture.store, &mut state, &fence)
.await
.expect("healthy ordinary COMMIT must authorize physical repair")
);
state
.ensure_write_safe("physical recovery readback")
.expect("completed recovery must disarm its transaction");
assert_committed(&fixture).await;
}
#[tokio::test]
#[serial_test::serial(pool_meta_version_env)]
async fn pool_meta_native_physical_split_without_ordinary_commit_performs_no_mutation() {
let fixture = fixture(3).await;
let before = snapshots(&fixture.store).await;
let mut state = clean_state(&fixture);
let fence = PoolMetaPersistenceFence::Owned(owner(&fixture.store).await);
assert!(
repair(&fixture.store, &mut state, &fence).await.is_err(),
"reconstruction alone must never authorize a COMMIT"
);
assert_eq!(
snapshots(&fixture.store).await,
before,
"all current heads, identities and complete backups must remain unchanged"
);
assert_eq!(state.active_transactions.load(Ordering::SeqCst), 0);
}
struct IdentityEpochFork {
path: PathBuf,
original: Bytes,
replacement: Bytes,
original_identity: PersistedPoolMetaIdentity,
}
async fn identity_epoch_fork(fixture: &PhysicalFixture) -> IdentityEpochFork {
let identity_set = fixture.store.pools[0].get_disks_by_key(POOL_META_IDENTITY_NAME);
assert!(
!Arc::ptr_eq(&identity_set, &fixture.store.pools[0].get_disks_by_key(POOL_META_NAME)),
"the identity evidence must come from its independently placed physical set"
);
let identity_disks = identity_set
.disks
.read()
.await
.iter()
.cloned()
.map(|disk| disk.expect("actual identity set must retain all four disks"))
.collect::<Vec<_>>();
let mut raw = Vec::with_capacity(identity_disks.len());
for disk in &identity_disks {
match disk
.pool_meta_native_recovery(Request::InspectIdentity {})
.await
.expect("actual identity set must support native inspection")
{
Response::IdentityInspected { disk_id, raw: current } => {
assert!(!disk_id.is_nil());
raw.push(current);
}
_ => panic!("identity inspection must return its physical raw shard"),
}
}
assert_eq!(raw.len(), 4);
let original_identity = match decode_pool_meta_identity(&fixture.identity) {
PoolMetaIdentityReplica::Valid(identity) => identity,
_ => panic!("fixture must provide a valid initialized identity"),
};
let forked_identity = PersistedPoolMetaIdentity {
epoch: original_identity
.epoch
.checked_add(1)
.expect("test identity epoch should increment"),
..original_identity
};
let forked = encode_pool_meta_identity(forked_identity).expect("foreign epoch identity should remain validly encoded");
assert!(matches!(decode_pool_meta_identity(&forked), PoolMetaIdentityReplica::Valid(_)));
let modified = raw
.iter()
.map(|raw| {
codec::parse_inline_info(raw)
.expect("actual identity shard layout should parse")
.mod_time
})
.collect::<Option<Vec<_>>>()
.expect("every actual identity shard should have a modification time")
.into_iter()
.max()
.expect("actual identity set should contain four shards")
+ Duration::nanoseconds(1);
let replacements = codec::encode_inline_pool_meta_replicas(&raw, &forked, modified)
.await
.expect("epoch fork must retain the actual set's valid erasure layout");
assert_eq!(
codec::parse_inline_info(&raw[0]).expect("original layout").erasure,
codec::parse_inline_info(&replacements[0])
.expect("replacement layout")
.erasure
);
codec::validate_inline_pool_meta_shard(&replacements[0], 0, &forked)
.await
.expect("forked shard must pass the physical integrity checks");
let identity_path = identity_disks[0]
.get_object_path_if_local(RUSTFS_META_BUCKET, POOL_META_IDENTITY_NAME)
.expect("identity fixture must use local storage")
.expect("actual identity metadata path should resolve");
IdentityEpochFork {
path: identity_path.join("xl.meta"),
original: raw[0].clone(),
replacement: replacements[0].clone(),
original_identity,
}
}
#[tokio::test]
#[serial_test::serial(pool_meta_version_env)]
async fn pool_meta_native_physical_identity_actual_set_minority_epoch_fork_performs_no_apply() {
let fixture = fixture(1).await;
let fork = identity_epoch_fork(&fixture).await;
let heads = |pools: Vec<Vec<PoolMetaNativeInspection>>| {
pools
.into_iter()
.map(|pool| pool.into_iter().map(|disk| (disk.current, disk.backups)).collect::<Vec<_>>())
.collect::<Vec<_>>()
};
let before = heads(snapshots(&fixture.store).await);
fs::write(&fork.path, &fork.replacement)
.await
.expect("only one actual identity-set disk should receive the epoch fork");
let selected = load_pool_meta_identity(fixture.store.pools.clone(), fork.original_identity.cluster_id)
.await
.expect("three matching identity shards must remain ordinary-readable");
assert_eq!(selected.identity, Some(fork.original_identity));
let reads = read_pool_meta_replicas(fixture.store.pools.clone(), true).await;
assert!(matches!(reads[0].replica, PoolMetaReplica::Unreadable(_)));
assert!(matches!(reads[1].replica, PoolMetaReplica::Valid { committed: true, .. }));
let mut state = clean_state(&fixture);
let fence = PoolMetaPersistenceFence::Owned(owner(&fixture.store).await);
let error = repair(&fixture.store, &mut state, &fence)
.await
.expect_err("ordinary identity quorum must not hide a physical minority epoch fork");
assert_eq!(
error
.pool_metadata_failure()
.expect("known identity fork must retain typed failure")
.kind,
crate::error::PoolMetadataFailure::RecoveryRequired
);
assert!(state.write_blocked, "known minority fork must latch the write gate");
assert_eq!(
heads(snapshots(&fixture.store).await),
before,
"identity rejection must precede every pool.bin Apply"
);
assert_eq!(state.active_transactions.load(Ordering::SeqCst), 0);
fs::write(&fork.path, &fork.original)
.await
.expect("restoring the input identity must not clear the observed fork latch");
let error = repair(&fixture.store, &mut state, &fence)
.await
.expect_err("the same state must retain operator recovery after identity becomes consistent");
assert_eq!(
error
.pool_metadata_failure()
.expect("sticky failure must retain its classification")
.kind,
crate::error::PoolMetadataFailure::RecoveryRequired
);
assert!(state.write_blocked);
assert_eq!(heads(snapshots(&fixture.store).await), before);
}
#[tokio::test]
#[serial_test::serial(pool_meta_version_env)]
async fn pool_meta_native_physical_runtime_fork_after_apply_latches_original_gate() {
let fixture = fixture(1).await;
let fork = identity_epoch_fork(&fixture).await;
let since = OffsetDateTime::from_unix_timestamp(1_700_000_000).expect("fixed original failure timestamp should be valid");
{
let mut state = fixture.store.pool_meta_save_gate.lock().await;
assert_eq!(state.active_transactions.load(Ordering::SeqCst), 0);
state.write_blocked = false;
state.block_context = None;
state.aborted_transaction.store(true, Ordering::SeqCst);
let mut failure = pool_metadata_error(crate::error::PoolMetadataFailure::TransactionUnknown, "commit_cas", None);
failure.since = since;
*state
.transaction_failure
.lock()
.expect("original transaction failure mutex should lock") = Some(failure);
*state
.block_started_at
.lock()
.expect("original block timestamp mutex should lock") = Some(since);
}
let pause = Arc::new(NativeApplyPause {
reached: tokio::sync::Notify::new(),
resume: tokio::sync::Notify::new(),
});
let reached = pause.reached.notified();
tokio::pin!(reached);
reached.as_mut().enable();
let recovery = tokio::spawn({
let store = Arc::clone(&fixture.store);
let pause = Arc::clone(&pause);
async move {
AFTER_NATIVE_APPLY
.scope(pause, async move { store.recover_pool_meta_transaction().await })
.await
}
});
tokio::time::timeout(std::time::Duration::from_secs(10), reached)
.await
.expect("real runtime recovery should pause after completing physical Apply");
let readable = load_pool_meta_replicas(fixture.store.pools.clone(), true)
.await
.expect("physical Apply must already make every pool ordinary-readable");
assert_eq!(readable.canonical.as_deref(), Some(fixture.canonical.as_slice()));
fs::write(&fork.path, &fork.replacement)
.await
.expect("a valid minority identity fork should arrive after physical repair");
pause.resume.notify_one();
let error = tokio::time::timeout(std::time::Duration::from_secs(10), recovery)
.await
.expect("resumed runtime recovery should finish its validation")
.expect("runtime recovery task should not panic")
.expect_err("post-Apply identity fork must reject runtime recovery");
assert_eq!(
error
.pool_metadata_failure()
.expect("runtime fork failure must remain typed")
.kind,
crate::error::PoolMetadataFailure::RecoveryRequired
);
assert_committed(&fixture).await;
{
let state = fixture.store.pool_meta_save_gate.lock().await;
assert!(
state.write_blocked,
"candidate validation must propagate its permanent latch to the original state"
);
let context = state
.block_context
.as_ref()
.expect("original state must retain the known fork context");
assert_eq!(context.kind, crate::error::PoolMetadataFailure::RecoveryRequired);
assert_eq!(context.since, since, "candidate failure must preserve the original outage timestamp");
assert_eq!(*state.block_started_at.lock().expect("original block timestamp should lock"), Some(since));
}
assert!(
!fixture
.store
.recover_pool_meta_transaction()
.await
.expect("permanently blocked recovery should return without mutation"),
"ordinary-readable repaired heads must not clear the original known-fork latch"
);
let state = fixture.store.pool_meta_save_gate.lock().await;
assert!(state.write_blocked);
assert_eq!(
state
.block_context
.as_ref()
.expect("sticky fork context must remain present")
.since,
since
);
}
#[tokio::test]
#[serial_test::serial(pool_meta_version_env)]
async fn pool_meta_native_physical_partial_apply_resumes_after_conditional_rejection() {
let fixture = fixture(1).await;
let fence = PoolMetaPersistenceFence::Owned(owner(&fixture.store).await);
let inspected = inspect(&fixture.store.pools, &fence)
.await
.expect("real native snapshots should be obtained");
let replacements = plan(&inspected, &fixture.canonical, fixture.revision, &fixture.identity)
.await
.expect("physical recovery plan should validate");
let snapshot = &inspected[0].snapshots[0];
assert_eq!(
inspected[0].disks[0]
.pool_meta_native_recovery(Request::Apply {
object: PoolMetaNativeObject::PoolBin,
expected_disk_id: snapshot.disk_id,
expected_current_sha256: snapshot.current.as_ref().map(|raw| Sha256::digest(raw).into()),
expected_backup_inventory_sha256: snapshot.inventory_sha256().expect("snapshot inventory should hash"),
replacement: replacements[0][0].clone(),
})
.await
.expect("first real Apply must atomically publish and sync"),
Response::Applied
);
let before_rejected = snapshots(&fixture.store).await;
let snapshot = &inspected[0].snapshots[1];
let mut stale_inventory = snapshot.inventory_sha256().expect("snapshot inventory should hash");
stale_inventory[0] ^= 1;
assert!(matches!(
inspected[0].disks[1]
.pool_meta_native_recovery(Request::Apply {
object: PoolMetaNativeObject::PoolBin,
expected_disk_id: snapshot.disk_id,
expected_current_sha256: snapshot.current.as_ref().map(|raw| Sha256::digest(raw).into()),
expected_backup_inventory_sha256: stale_inventory,
replacement: replacements[0][1].clone(),
})
.await,
Err(DiskError::ConditionalDeleteConflict)
));
assert_eq!(
snapshots(&fixture.store).await,
before_rejected,
"rejected Apply must preserve the partial repair evidence"
);
drop(inspected);
drop(fence);
let fence = PoolMetaPersistenceFence::Owned(owner(&fixture.store).await);
let mut state = clean_state(&fixture);
assert!(
repair(&fixture.store, &mut state, &fence)
.await
.expect("a fresh owner should resume the partial physical repair")
);
assert_committed(&fixture).await;
}
#[tokio::test]
#[serial_test::serial(pool_meta_version_env)]
async fn pool_meta_native_physical_noninline_conditional_io_round_trip() {
let fixture = fixture(0).await;
let pool = Arc::clone(&fixture.store.pools[0]);
let fence = PoolMetaPersistenceFence::Owned(owner(&fixture.store).await);
let (original, info) = read_config_no_lock_preserve_empty_with_metadata(Arc::clone(&pool), POOL_META_NAME)
.await
.expect("baseline conditional object must be readable");
let token = pool_meta_cas_token_from_info(info);
// This smoke test targets the physical conditional I/O boundary. The large
// opaque body deliberately exceeds the inline writer's one-block limit.
let payload = vec![0x5a; rustfs_filemeta::BLOCK_SIZE_V2 + 137];
let state = clean_state(&fixture);
let mut arm = state.arm_transaction();
save_pool_meta_object_cas(
Arc::clone(&pool),
POOL_META_NAME,
payload.clone(),
&token,
&fence,
"noninline_smoke",
&mut arm,
)
.await
.expect("normal native CAS must support noninline pool metadata storage");
let (readback, info) = read_config_no_lock_preserve_empty_with_metadata(Arc::clone(&pool), POOL_META_NAME)
.await
.expect("noninline conditional write must be ordinary-readable");
assert_eq!(readback, payload);
for disk in &snapshots(&fixture.store).await[0] {
let raw = disk
.current
.as_ref()
.expect("noninline head should be installed on every disk");
let info = rustfs_filemeta::get_file_info(
raw,
RUSTFS_META_BUCKET,
POOL_META_NAME,
"",
rustfs_filemeta::FileInfoOpts {
data: true,
include_free_versions: false,
include_part_checksums: true,
},
)
.expect("physical noninline metadata should decode");
assert!(!info.inline_data());
assert!(info.data.is_none());
}
save_pool_meta_object_cas(
pool,
POOL_META_NAME,
original.clone(),
&pool_meta_cas_token_from_info(info),
&fence,
"noninline_to_inline_smoke",
&mut arm,
)
.await
.expect("normal CAS must also replace a noninline parent with inline metadata");
arm.disarm();
assert_committed(&fixture).await;
}
#[tokio::test]
#[serial_test::serial(pool_meta_version_env)]
async fn pool_meta_native_physical_three_plus_one_checkpoint_converges_all_shards() {
let fixture = fixture(0).await;
let pending = replicas(&fixture.pending, OffsetDateTime::now_utc() - Duration::seconds(1)).await;
let all_disks = disks(&fixture.store).await;
fs::write(object_path(&all_disks[0][0]).join("xl.meta"), &pending[0])
.await
.expect("one stale physical PREPARE head should be installed");
let selection = load_pool_meta_replicas(fixture.store.pools.clone(), true)
.await
.expect("three matching COMMIT shards must remain ordinary-readable");
assert_eq!(selection.canonical.as_deref(), Some(fixture.canonical.as_slice()));
let guard = owner(&fixture.store).await;
let fence = PoolMetaPersistenceFence::Owned(Arc::clone(&guard));
let mut state = clean_state(&fixture);
assert!(
!repair(&fixture.store, &mut state, &fence)
.await
.expect("ordinary-readable pools should use normal checkpoint recovery")
);
selection
.meta
.save_for_startup_guarded(fixture.store.pools.clone(), &mut state, guard)
.await
.expect("normal checkpoint must converge a readable 3+1 pool without an unsafe native overwrite");
let checkpoint = load_pool_meta_replicas(fixture.store.pools.clone(), true)
.await
.expect("checkpoint must remain ordinary-readable across all pools");
assert!(!checkpoint.replica_state.needs_repair);
assert!(checkpoint.replica_state.repair_write_safe);
assert!(checkpoint.revision.generation > fixture.revision.generation);
let canonical = checkpoint.canonical.expect("checkpoint should expose a canonical COMMIT");
for pool in snapshots(&fixture.store).await {
let raw = pool
.into_iter()
.map(|disk| disk.current.expect("checkpoint head must exist"))
.collect::<Vec<_>>();
assert_eq!(
codec::decode_inline_pool_meta_replicas(&raw)
.await
.expect("all four checkpoint shards must verify"),
vec![Bytes::copy_from_slice(&canonical)]
);
}
}
@@ -0,0 +1,736 @@
// 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::pool_meta_recovery_codec as codec;
use super::*;
use crate::disk::{
DiskAPI, DiskStore, PoolMetaNativeInspection, PoolMetaNativeObject, PoolMetaNativeRecoveryRequest as Request,
PoolMetaNativeRecoveryResponse as Response,
};
use bytes::Bytes;
#[cfg(test)]
#[derive(Default)]
pub(super) struct NativeApplyPause {
pub(super) reached: tokio::sync::Notify,
pub(super) resume: tokio::sync::Notify,
}
#[cfg(test)]
tokio::task_local! {
pub(super) static AFTER_NATIVE_APPLY: Arc<NativeApplyPause>;
}
struct InspectedPool {
disks: Vec<DiskStore>,
snapshots: Vec<PoolMetaNativeInspection>,
}
fn invalid() -> Error {
Error::other("native pool metadata recovery requires one proven V3 transaction and complete matching disk evidence")
}
fn known_validation(source: Error, phase: &'static str) -> Error {
Error::other(pool_metadata_error(
crate::error::PoolMetadataFailure::RecoveryRequired,
phase,
Some(Arc::new(source)),
))
}
fn codec_validation(source: codec::PoolMetaCodecError, phase: &'static str) -> Error {
// A supported transport may carry a valid layout outside this narrow
// recovery codec. That is not evidence of corruption or a transaction fork.
if matches!(source, codec::PoolMetaCodecError::UnsupportedLayout) {
return Error::other(source);
}
known_validation(Error::other(source), phase)
}
fn latch_validation(state: &mut PoolMetaWriteState, error: Error) -> Error {
if let Some(context) = error.pool_metadata_failure()
&& context.kind == crate::error::PoolMetadataFailure::RecoveryRequired
{
state.block_with_context(context.clone());
}
error
}
fn current(pool: &InspectedPool) -> Result<Vec<Bytes>> {
pool.snapshots
.iter()
.map(|snapshot| snapshot.current.clone().ok_or_else(invalid))
.collect()
}
async fn inspect(pools: &[Arc<Sets>], fence: &PoolMetaPersistenceFence<'_>) -> Result<Vec<InspectedPool>> {
let mut inspected = Vec::with_capacity(pools.len());
// Pool -> disk order is stable; all capability probes precede every mutation.
for pool in pools {
fence.ensure_held()?;
let set = pool.get_disks_by_key(POOL_META_NAME);
let disks = set
.disks
.read()
.await
.iter()
.cloned()
.map(|disk| disk.ok_or_else(invalid))
.collect::<Result<Vec<_>>>()?;
let probes = futures::stream::iter(disks.iter().cloned().map(|disk| async move {
match disk.pool_meta_native_recovery(Request::Probe {}).await? {
Response::Probed => Ok(()),
_ => Err(invalid()),
}
}))
.buffered(4)
.collect::<Vec<Result<()>>>()
.await;
for result in probes {
result?;
}
inspected.push(InspectedPool {
disks,
snapshots: Vec::new(),
});
}
for pool in &mut inspected {
fence.ensure_held()?;
let results = futures::stream::iter(pool.disks.iter().cloned().map(|disk| async move {
match disk
.pool_meta_native_recovery(Request::Inspect {
object: PoolMetaNativeObject::PoolBin,
})
.await?
{
Response::Inspected(snapshot) if !snapshot.disk_id.is_nil() => Ok(snapshot),
_ => Err(invalid()),
}
}))
.buffered(4)
.collect::<Vec<Result<PoolMetaNativeInspection>>>()
.await;
for result in results {
pool.snapshots.push(result?);
}
}
fence.ensure_held()?;
Ok(inspected)
}
async fn validate_identities(pools: &[Arc<Sets>], canonical: &[u8], fence: &PoolMetaPersistenceFence<'_>) -> Result<()> {
for pool in pools {
fence.ensure_held()?;
// Identity and pool.bin have independent placement hashes. Read every
// member of the identity's actual set instead of assuming co-location.
let set = pool.get_disks_by_key(POOL_META_IDENTITY_NAME);
let disks = set
.disks
.read()
.await
.iter()
.cloned()
.map(|disk| disk.ok_or_else(invalid))
.collect::<Result<Vec<_>>>()?;
let results = futures::stream::iter(disks.into_iter().enumerate().map(|(index, disk)| async move {
match disk.pool_meta_native_recovery(Request::InspectIdentity {}).await? {
Response::IdentityInspected { disk_id, raw } if !disk_id.is_nil() => {
codec::validate_inline_pool_meta_shard(&raw, index, canonical)
.await
.map_err(|error| codec_validation(error, "native_identity_validation"))
}
_ => Err(invalid()),
}
}))
.buffered(4)
.collect::<Vec<Result<()>>>()
.await;
for result in results {
result?;
}
}
fence.ensure_held()
}
async fn plan(
inspected: &[InspectedPool],
canonical: &[u8],
revision: PoolMetaRevision,
identity: &[u8],
) -> Result<Vec<Vec<Bytes>>> {
let mut pending = Vec::<Vec<u8>>::new();
let mut predecessors = Vec::<Vec<u8>>::new();
let mut decoded = Vec::<Vec<u8>>::new();
let mut newest = OffsetDateTime::now_utc();
for pool in inspected {
let current = current(pool)?;
for (index, snapshot) in pool.snapshots.iter().enumerate() {
if let Some(local_identity) = snapshot.identity.as_ref() {
codec::validate_inline_pool_meta_shard(local_identity, index, identity)
.await
.map_err(|error| codec_validation(error, "native_metadata_validation"))?;
}
for raw in std::iter::once(current[index].clone()).chain(snapshot.backups.iter().map(|backup| backup.data.clone())) {
let info =
codec::parse_inline_info(&raw).map_err(|error| codec_validation(error, "native_metadata_validation"))?;
newest = newest.max(info.mod_time.ok_or_else(invalid)?);
}
}
let mut inventories = vec![current.clone()];
let directories = pool
.snapshots
.iter()
.flat_map(|snapshot| snapshot.backups.iter().map(|backup| backup.directory))
.collect::<HashSet<_>>();
for directory in directories {
// A missing backup contributes its actual current shard only to
// integrity reconstruction. It never invents backup evidence.
inventories.push(
pool.snapshots
.iter()
.enumerate()
.map(|(index, snapshot)| {
snapshot
.backups
.iter()
.find(|backup| backup.directory == directory)
.map(|backup| backup.data.clone())
.unwrap_or_else(|| current[index].clone())
})
.collect(),
);
}
for raw in inventories {
for generation in codec::decode_inline_pool_meta_replicas(&raw)
.await
.map_err(|error| codec_validation(error, "native_metadata_validation"))?
{
let payload = generation.to_vec();
if !decoded.contains(&payload) {
decoded.push(payload);
}
}
}
}
let mut obsolete = Vec::<Vec<u8>>::new();
for payload in &decoded {
match decode_pool_meta_replica(payload.clone()) {
PoolMetaReplica::Valid {
canonical: candidate,
revision: candidate_revision,
committed: false,
previous: Some(previous),
..
} if candidate_revision == revision && candidate == canonical => {
if !pending.contains(payload) {
pending.push(payload.clone());
}
if !predecessors.contains(&previous.canonical) {
predecessors.push(previous.canonical);
}
}
PoolMetaReplica::Valid { revision: older, .. }
if older.is_generation_protocol()
&& older.cluster_id == revision.cluster_id
&& older.epoch == revision.epoch
&& older.generation < revision.generation =>
{
// Retained evidence from an earlier successful recovery is
// obsolete, not a competing commit authority. It remains
// fully reconstructed and bound into the native CAS.
obsolete.push(payload.clone());
}
PoolMetaReplica::Valid {
canonical: candidate,
revision: candidate_revision,
committed: true,
..
} if candidate_revision == revision && candidate == canonical => {}
PoolMetaReplica::Valid { revision: conflict, .. } if conflict.is_generation_protocol() => {
return Err(known_validation(
Error::other("native V3 transaction conflicts with committed authority"),
"native_transaction_validation",
));
}
PoolMetaReplica::Corrupt(_) | PoolMetaReplica::Incompatible(_) => {
return Err(known_validation(
Error::other("native V3 payload is corrupt or incompatible"),
"native_transaction_validation",
));
}
_ => {}
}
}
if pending.len() > 1 {
return Err(known_validation(
Error::other("native V3 prepare records disagree on the predecessor"),
"native_transaction_validation",
));
}
if pending.is_empty() {
return Err(invalid());
}
for payload in &decoded {
if payload.as_slice() != canonical
&& !pending.contains(payload)
&& !predecessors.contains(payload)
&& !obsolete.contains(payload)
{
return Err(invalid());
}
}
for pool in inspected {
for (index, snapshot) in pool.snapshots.iter().enumerate() {
let raw = snapshot.current.as_ref().ok_or_else(invalid)?;
let mut matched = false;
for payload in std::iter::once(canonical).chain(pending.iter().map(Vec::as_slice)) {
matched |= codec::validate_inline_pool_meta_shard(raw, index, payload).await.is_ok();
}
if !matched {
return Err(invalid());
}
for backup in &snapshot.backups {
let mut matched = false;
for payload in std::iter::once(canonical).chain(
pending
.iter()
.chain(predecessors.iter())
.chain(obsolete.iter())
.map(Vec::as_slice),
) {
matched |= codec::validate_inline_pool_meta_shard(&backup.data, index, payload)
.await
.is_ok();
}
if !matched {
return Err(invalid());
}
}
}
}
let modified = newest.checked_add(Duration::nanoseconds(1)).ok_or_else(invalid)?;
let mut replacements = Vec::with_capacity(inspected.len());
for pool in inspected {
replacements.push(
codec::encode_inline_pool_meta_replicas(&current(pool)?, canonical, modified)
.await
.map_err(|error| codec_validation(error, "native_metadata_validation"))?,
);
}
Ok(replacements)
}
pub(super) async fn repair(
store: &ECStore,
state: &mut PoolMetaWriteState,
fence: &PoolMetaPersistenceFence<'_>,
) -> Result<bool> {
fence.ensure_held()?;
let reads = read_pool_meta_replicas(store.pools.clone(), true).await;
if !reads
.iter()
.any(|read| matches!(read.replica, PoolMetaReplica::Unreadable(_)))
{
return Ok(false);
}
state.ensure_write_safe("native pool metadata recovery")?;
if reads
.iter()
.any(|read| matches!(read.replica, PoolMetaReplica::Corrupt(_) | PoolMetaReplica::Incompatible(_)))
{
return Err(latch_validation(
state,
known_validation(Error::other("pool metadata is corrupt or incompatible"), "native_metadata_validation"),
));
}
if reads
.iter()
.any(|read| !matches!(read.replica, PoolMetaReplica::Valid { .. } | PoolMetaReplica::Unreadable(_)))
{
return Err(invalid());
}
// Only an ordinary, successfully read COMMIT grants authority. Rebuilding
// two erasure shards below proves integrity, never a commit decision.
let ordinary_commits = reads
.iter()
.filter_map(|read| match &read.replica {
PoolMetaReplica::Valid {
raw,
canonical,
revision,
committed: true,
..
} if revision.is_generation_protocol() && raw == canonical => Some((canonical.clone(), *revision)),
_ => None,
})
.collect::<Vec<_>>();
if ordinary_commits.is_empty() {
return Err(invalid());
}
let selection = select_pool_meta_replica_reads(reads)
.map_err(|error| latch_validation(state, known_validation(error, "native_metadata_validation")))?;
state
.validate_selection(&selection)
.map_err(|error| latch_validation(state, known_validation(error, "native_metadata_validation")))?;
if selection.meta.pools.len() != store.pools.len()
|| selection
.meta
.pools
.iter()
.zip(&store.pools)
.enumerate()
.any(|(index, (meta, pool))| meta.id != index || meta.cmd_line != pool.endpoints.cmd_line)
{
return Err(invalid());
}
let canonical = selection.canonical.as_ref().ok_or_else(invalid)?;
if !selection.revision.is_generation_protocol()
|| !ordinary_commits
.iter()
.any(|(raw, revision)| raw == canonical && *revision == selection.revision)
{
return Err(invalid());
}
let cluster_id = selection.revision.cluster_id.ok_or_else(invalid)?;
if state.expected_cluster_id != Some(cluster_id) {
return Err(invalid());
}
let identities = join_all(store.pools.iter().cloned().map(read_pool_meta_identity_replica)).await;
if identities.iter().any(|read| {
matches!(
read.replica,
PoolMetaIdentityReplica::Corrupt(_) | PoolMetaIdentityReplica::Incompatible(_)
)
}) {
return Err(latch_validation(
state,
known_validation(Error::other("pool identity is corrupt or incompatible"), "native_identity_validation"),
));
}
if identities
.iter()
.any(|read| !matches!(read.replica, PoolMetaIdentityReplica::Valid(_)))
{
return Err(invalid());
}
let identity = select_pool_meta_identity(identities, cluster_id)
.map_err(|error| latch_validation(state, known_validation(error, "native_identity_validation")))?;
state
.validate_identity(&identity)
.map_err(|error| latch_validation(state, known_validation(error, "native_identity_validation")))?;
let identity = identity.identity.ok_or_else(invalid)?;
if !identity.initialized {
return Err(invalid());
}
if identity.epoch != selection.revision.epoch {
return Err(latch_validation(
state,
known_validation(Error::other("pool identity epoch conflicts with metadata"), "native_identity_validation"),
));
}
let identity_bytes = encode_pool_meta_identity(identity)?;
let repair_started = std::time::Instant::now();
validate_identities(&store.pools, &identity_bytes, fence)
.await
.map_err(|error| latch_validation(state, error))?;
let inspected = inspect(&store.pools, fence).await?;
let replacements = plan(&inspected, canonical, selection.revision, &identity_bytes)
.await
.map_err(|error| latch_validation(state, error))?;
fence.ensure_held()?;
let mut arm = state.arm_transaction();
arm.phase = Some("native_repair_apply");
for (pool, replacements) in inspected.iter().zip(replacements) {
for ((disk, snapshot), replacement) in pool.disks.iter().zip(&pool.snapshots).zip(replacements) {
fence.ensure_held()?;
let request = Request::Apply {
object: PoolMetaNativeObject::PoolBin,
expected_disk_id: snapshot.disk_id,
expected_current_sha256: snapshot.current.as_ref().map(|raw| Sha256::digest(raw).into()),
expected_backup_inventory_sha256: snapshot.inventory_sha256()?,
replacement,
};
match disk.pool_meta_native_recovery(request).await? {
Response::Applied => {}
_ => return Err(invalid()),
}
}
}
#[cfg(test)]
if let Ok(pause) = AFTER_NATIVE_APPLY.try_with(Arc::clone) {
pause.reached.notify_one();
pause.resume.notified().await;
}
fence.ensure_held()?;
let confirmed = load_pool_meta_replicas(store.pools.clone(), true).await?;
if confirmed.canonical.as_ref() != Some(canonical)
|| confirmed.revision != selection.revision
|| !confirmed.replica_state.repair_write_safe
|| confirmed.replica_state.needs_repair
{
return Err(invalid());
}
// Re-enter the normal V3 conditional-write path, including native CAS,
// full tail completion, and the final ordinary quorum readback.
for (pool, token) in store.pools.iter().cloned().zip(&confirmed.cas_tokens) {
save_pool_meta_object_cas(pool, POOL_META_NAME, canonical.clone(), token, fence, "native_repair_confirm", &mut arm)
.await?;
}
let confirmed = load_pool_meta_replicas(store.pools.clone(), true).await?;
if confirmed.canonical.as_ref() != Some(canonical)
|| confirmed.revision != selection.revision
|| confirmed.replica_state.needs_repair
|| !confirmed.replica_state.repair_write_safe
{
return Err(invalid());
}
validate_identities(&store.pools, &identity_bytes, fence)
.await
.map_err(|error| latch_validation(state, error))?;
let identities = join_all(store.pools.iter().cloned().map(read_pool_meta_identity_replica)).await;
if identities.iter().any(|read| {
matches!(
read.replica,
PoolMetaIdentityReplica::Corrupt(_) | PoolMetaIdentityReplica::Incompatible(_)
)
}) {
return Err(latch_validation(
state,
known_validation(Error::other("pool identity is corrupt or incompatible"), "native_identity_validation"),
));
}
if identities
.iter()
.any(|read| !matches!(read.replica, PoolMetaIdentityReplica::Valid(_)))
{
return Err(invalid());
}
let identity = select_pool_meta_identity(identities, cluster_id)
.map_err(|error| latch_validation(state, known_validation(error, "native_identity_validation")))?;
state
.validate_identity(&identity)
.map_err(|error| latch_validation(state, known_validation(error, "native_identity_validation")))?;
if identity.identity.map(encode_pool_meta_identity).transpose()?.as_deref() != Some(identity_bytes.as_slice()) {
return Err(latch_validation(
state,
known_validation(Error::other("native identity changed after repair"), "native_identity_validation"),
));
}
fence.ensure_held()?;
arm.disarm();
info!(
event = EVENT_DECOMMISSION_STATE,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_POOLS,
state = "pool_metadata_native_repaired",
generation = selection.revision.generation,
pools = inspected.len(),
disks = inspected.iter().map(|pool| pool.disks.len()).sum::<usize>(),
elapsed_ms = repair_started.elapsed().as_secs_f64() * 1000.0,
"Pool metadata native replicas repaired"
);
Ok(true)
}
#[cfg(test)]
mod tests {
use super::*;
use codec::{fixture_inline_pool_meta_replicas as replicas, fixture_split_inline_pool_meta_replicas as split};
struct Fixture {
pools: Vec<InspectedPool>,
commit: Vec<u8>,
pending: Vec<u8>,
identity: Vec<u8>,
revision: PoolMetaRevision,
modified: OffsetDateTime,
}
async fn fixture() -> Fixture {
let cluster_id = uuid::Uuid::new_v4();
let revision = PoolMetaRevision {
version: POOL_META_GENERATION_VERSION,
cluster_id: Some(cluster_id),
epoch: 1,
generation: 1,
transaction_id: Some(uuid::Uuid::new_v4()),
};
let meta = PoolMeta {
version: POOL_META_GENERATION_VERSION,
..Default::default()
};
let previous_meta = PoolMeta {
version: POOL_META_V1_VERSION,
..Default::default()
};
let previous = PoolMetaCommittedCandidate {
canonical: previous_meta.encode_config_data_for_v2_gate(true).unwrap(),
meta: previous_meta,
revision: PoolMetaRevision::legacy(POOL_META_V1_VERSION),
};
let pending = encode_pool_meta_v3_envelope(&meta, revision, false, Some(&previous)).unwrap();
let commit = encode_pool_meta_v3_envelope(&meta, revision, true, None).unwrap();
let identity = encode_pool_meta_identity(PersistedPoolMetaIdentity {
version: POOL_META_IDENTITY_VERSION,
cluster_id,
epoch: 1,
initialized: true,
fresh_bootstrap_nonce: None,
})
.unwrap();
let modified = OffsetDateTime::from_unix_timestamp(1_700_000_000).unwrap();
let current_split = split(&pending, &commit, modified).await;
let current_commit = replicas(&commit, modified + Duration::seconds(1)).await;
let backups = replicas(&pending, modified).await;
let identities = replicas(&identity, modified).await;
let directory = uuid::Uuid::new_v4();
let pools = [current_split.clone(), current_split, current_commit.clone(), current_commit]
.into_iter()
.map(|current| InspectedPool {
disks: Vec::new(),
snapshots: current
.into_iter()
.enumerate()
.map(|(index, current)| PoolMetaNativeInspection {
disk_id: uuid::Uuid::new_v4(),
identity: Some(identities[index].clone()),
current: Some(current),
backups: vec![crate::disk::PoolMetaNativeBackup {
directory,
data: backups[index].clone(),
}],
})
.collect(),
})
.collect();
Fixture {
pools,
commit,
pending,
identity,
revision,
modified,
}
}
#[tokio::test]
async fn pool_meta_native_plan_restores_physical_split_with_fresh_common_identity() {
let f = fixture().await;
let repaired = plan(&f.pools, &f.commit, f.revision, &f.identity).await.unwrap();
assert_eq!(repaired.len(), 4);
for pool in repaired {
assert_eq!(
codec::decode_inline_pool_meta_replicas(&pool).await.unwrap(),
vec![Bytes::copy_from_slice(&f.commit)]
);
let times = pool
.iter()
.map(|raw| codec::parse_inline_info(raw).unwrap().mod_time.unwrap())
.collect::<HashSet<_>>();
assert_eq!(times.len(), 1);
assert!(*times.iter().next().unwrap() > f.modified + Duration::seconds(1));
}
}
#[tokio::test]
async fn pool_meta_native_plan_rejects_single_hidden_identity_fork() {
let mut f = fixture().await;
let wrong = replicas(b"different identity", f.modified).await;
f.pools[0].snapshots[0].identity = Some(wrong[0].clone());
assert!(plan(&f.pools, &f.commit, f.revision, &f.identity).await.is_err());
}
#[tokio::test]
async fn pool_meta_native_plan_rejects_hidden_higher_generation_and_unknown_backup() {
let mut f = fixture().await;
let higher_revision = PoolMetaRevision {
generation: 2,
transaction_id: Some(uuid::Uuid::new_v4()),
..f.revision
};
let higher = encode_pool_meta_v3_envelope(
&PoolMeta {
version: POOL_META_GENERATION_VERSION,
..Default::default()
},
higher_revision,
true,
None,
)
.unwrap();
let wrong = replicas(&higher, f.modified + Duration::seconds(2)).await;
f.pools[0].snapshots[0].current = Some(wrong[0].clone());
assert!(plan(&f.pools, &f.commit, f.revision, &f.identity).await.is_err());
f.pools[0].snapshots[0].current = Some(replicas(&f.pending, f.modified).await[0].clone());
f.pools[0].snapshots[0].backups[0].data = wrong[0].clone();
assert!(plan(&f.pools, &f.commit, f.revision, &f.identity).await.is_err());
}
#[tokio::test]
async fn pool_meta_native_plan_accepts_retained_older_recovery_backups() {
let mut f = fixture().await;
let older_commit = f.commit.clone();
let older_pending = f.pending.clone();
let next = PoolMetaRevision {
generation: 2,
transaction_id: Some(uuid::Uuid::new_v4()),
..f.revision
};
let PoolMetaReplica::Valid { meta, .. } = decode_pool_meta_replica(older_commit.clone()) else {
panic!("valid fixture")
};
let previous = PoolMetaCommittedCandidate {
canonical: older_commit.clone(),
meta: meta.clone(),
revision: f.revision,
};
f.commit = encode_pool_meta_v3_envelope(&meta, next, true, None).unwrap();
f.pending = encode_pool_meta_v3_envelope(&meta, next, false, Some(&previous)).unwrap();
f.revision = next;
let current = split(&f.pending, &f.commit, f.modified + Duration::seconds(2)).await;
let old_commit = replicas(&older_commit, f.modified + Duration::seconds(1)).await;
let current_pending = replicas(&f.pending, f.modified + Duration::seconds(2)).await;
let new_directory = uuid::Uuid::new_v4();
let old_directory = uuid::Uuid::new_v4();
for pool in &mut f.pools {
for (index, snapshot) in pool.snapshots.iter_mut().enumerate() {
snapshot.current = Some(current[index].clone());
snapshot.backups.push(crate::disk::PoolMetaNativeBackup {
directory: new_directory,
data: current_pending[index].clone(),
});
snapshot.backups.push(crate::disk::PoolMetaNativeBackup {
directory: old_directory,
data: old_commit[index].clone(),
});
}
}
assert!(older_pending != f.pending);
assert!(plan(&f.pools, &f.commit, f.revision, &f.identity).await.is_ok());
}
#[tokio::test]
async fn pool_meta_native_plan_rejects_missing_or_corrupt_evidence() {
let mut f = fixture().await;
let before = f.pools[1].snapshots[2].current.take();
assert!(plan(&f.pools, &f.commit, f.revision, &f.identity).await.is_err());
f.pools[1].snapshots[2].current = before;
f.pools[1].snapshots[2].backups[0].data = Bytes::from_static(b"invalid metadata");
assert!(plan(&f.pools, &f.commit, f.revision, &f.identity).await.is_err());
}
}
#[cfg(all(test, feature = "test-util"))]
#[path = "pool_meta_native_integration_tests.rs"]
mod integration_tests;
@@ -0,0 +1,569 @@
// 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.
//! Integrity-only codec for conditional recovery of an inline `pool.bin` head.
//! Reconstructing a payload here never grants authority to publish it.
use crate::disk::RUSTFS_META_BUCKET;
use crate::erasure::coding::encode::IntegrityEncodeMode;
use crate::erasure::coding::{BitrotReader, BitrotWriterWrapper, Erasure, calc_shard_size};
use crate::io_support::shard_integrity::{PartProofReader, ShardVerifier};
use bytes::Bytes;
use rustfs_filemeta::{
BLOCK_SIZE_V2, ChecksumAlgo, ChecksumInfo, ERASURE_ALGORITHM, FileInfo, FileInfoOpts, FileMeta, get_file_info,
};
use rustfs_utils::HashAlgorithm;
use rustfs_utils::http::{SUFFIX_ACTUAL_SIZE, SUFFIX_INLINE_DATA, get_consistent_str, insert_str, remove_str};
use std::io::{self, Cursor};
use std::sync::Arc;
use time::OffsetDateTime;
use uuid::Uuid;
const OBJECT: &str = "pool.bin";
const MAX_SHARDS: usize = 16;
#[derive(Debug, thiserror::Error)]
pub(super) enum PoolMetaCodecError {
#[error("unsupported inline pool metadata layout")]
UnsupportedLayout,
#[error("pool metadata shard does not match its physical disk")]
ShardPlacement,
#[error("pool metadata replicas disagree on persistent attributes")]
InconsistentMetadata,
#[error("pool metadata payload does not match its metadata or shards")]
PayloadMismatch,
#[error("invalid inline pool metadata")]
Metadata(#[source] rustfs_filemeta::Error),
#[error("inline pool metadata integrity verification failed")]
Integrity(#[source] io::Error),
}
impl From<rustfs_filemeta::Error> for PoolMetaCodecError {
fn from(value: rustfs_filemeta::Error) -> Self {
Self::Metadata(value)
}
}
impl From<io::Error> for PoolMetaCodecError {
fn from(value: io::Error) -> Self {
Self::Integrity(value)
}
}
type Result<T> = std::result::Result<T, PoolMetaCodecError>;
/// Rejects layouts outside the narrow, current inline pool metadata writer.
pub(super) fn parse_inline_info(raw: &Bytes) -> Result<FileInfo> {
if FileMeta::read_format_versions(raw)? != (1, 3, 3, 3) {
return Err(PoolMetaCodecError::UnsupportedLayout);
}
let meta = FileMeta::load(raw)?;
if meta.versions.len() != 1 {
return Err(PoolMetaCodecError::UnsupportedLayout);
}
if meta.versions[0]
.parse_version_meta()?
.object
.is_none_or(|object| object.bitrot_checksum_algo != ChecksumAlgo::HighwayHash)
{
return Err(PoolMetaCodecError::UnsupportedLayout);
}
let info = get_file_info(
raw,
RUSTFS_META_BUCKET,
OBJECT,
"",
FileInfoOpts {
data: true,
include_free_versions: true,
include_part_checksums: true,
},
)?;
let size = usize::try_from(info.size).map_err(|_| PoolMetaCodecError::UnsupportedLayout)?;
let erasure = &info.erasure;
let shards = erasure.data_blocks.checked_add(erasure.parity_blocks);
if !info.is_valid()
|| info.deleted
|| info.mark_deleted
|| info.version_id.is_some_and(|id| !id.is_nil())
|| info.versioned
|| info.uses_legacy_checksum
|| info.data_dir.is_none_or(|id| id.is_nil())
|| info.mod_time.is_none_or(|modified| modified <= OffsetDateTime::UNIX_EPOCH)
|| info.checksum.is_some()
|| erasure.algorithm != ERASURE_ALGORITHM
|| erasure.block_size != BLOCK_SIZE_V2
|| erasure.data_blocks == 0
|| erasure.data_blocks != erasure.parity_blocks
|| shards.is_none_or(|count| !(2..=MAX_SHARDS).contains(&count))
|| size == 0
|| size > BLOCK_SIZE_V2
|| info.parts.len() != 1
|| info.data.is_none()
|| get_consistent_str(&info.metadata, SUFFIX_INLINE_DATA) != Some("true")
{
return Err(PoolMetaCodecError::UnsupportedLayout);
}
let part = &info.parts[0];
let etag = info.metadata.get("etag").ok_or(PoolMetaCodecError::UnsupportedLayout)?;
if part.number != 1
|| part.size != size
|| part.actual_size != info.size
|| part.etag != *etag
|| part.index.is_some()
|| part.checksums.is_some()
|| part.error.is_some()
|| etag.len() != 32
|| !etag
.bytes()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
|| get_consistent_str(&info.metadata, SUFFIX_ACTUAL_SIZE).and_then(|actual| actual.parse::<usize>().ok()) != Some(size)
|| !erasure.checksums.is_empty()
{
return Err(PoolMetaCodecError::UnsupportedLayout);
}
Ok(info)
}
fn validate_placement(info: &FileInfo, physical_index: usize, disk_count: usize) -> Result<()> {
if info.erasure.distribution.len() != disk_count
|| info.erasure.data_blocks + info.erasure.parity_blocks != disk_count
|| info.erasure.distribution.get(physical_index).copied() != Some(info.erasure.index)
{
return Err(PoolMetaCodecError::ShardPlacement);
}
Ok(())
}
fn erasure(info: &FileInfo) -> Result<Erasure> {
Erasure::try_new(info.erasure.data_blocks, info.erasure.parity_blocks, info.erasure.block_size)
.map_err(|_| PoolMetaCodecError::UnsupportedLayout)
}
fn md5(payload: &[u8]) -> String {
hex_simd::encode_to_string(HashAlgorithm::Md5.hash_encode(payload).as_ref(), hex_simd::AsciiCase::Lower)
}
async fn verified_shard(info: &FileInfo) -> Result<Vec<u8>> {
let size = usize::try_from(info.size).map_err(|_| PoolMetaCodecError::UnsupportedLayout)?;
let shard_size = calc_shard_size(size, info.erasure.data_blocks);
let data = info.data.as_ref().ok_or(PoolMetaCodecError::UnsupportedLayout)?;
if data.len() != shard_size + HashAlgorithm::HighwayHash256S.size() {
return Err(PoolMetaCodecError::PayloadMismatch);
}
let mut reader = BitrotReader::new(Cursor::new(data.clone()), shard_size, HashAlgorithm::HighwayHash256S, false);
if let Some(integrity) = &info.parts[0].integrity {
let proof = PartProofReader::new(integrity.clone(), std::slice::from_ref(info), &[None], RUSTFS_META_BUCKET, OBJECT)?;
reader.set_integrity(ShardVerifier::new(proof, info.erasure.index - 1, 0, None)?)?;
}
let mut shard = vec![0; shard_size];
if reader.read(&mut shard).await? != shard_size {
return Err(PoolMetaCodecError::PayloadMismatch);
}
Ok(shard)
}
fn generation_key(info: &FileInfo) -> FileInfo {
let mut key = info.clone();
key.erasure.index = 0;
key.data = None;
key
}
/// Decodes every reconstructable generation without making a quorum decision.
///
/// All physical disks must be present and every supplied shard must verify.
/// Groups below the erasure reconstruction threshold are left undecoded; the
/// caller must match every such shard against an independently known payload
/// with [`validate_inline_pool_meta_shard`] before proposing any mutation.
pub(super) async fn decode_inline_pool_meta_replicas(raw: &[Bytes]) -> Result<Vec<Bytes>> {
if !(2..=MAX_SHARDS).contains(&raw.len()) {
return Err(PoolMetaCodecError::UnsupportedLayout);
}
struct Group {
key: FileInfo,
present: usize,
shards: Vec<Option<Vec<u8>>>,
}
let mut groups: Vec<Group> = Vec::new();
for (physical_index, bytes) in raw.iter().enumerate() {
let info = parse_inline_info(bytes)?;
validate_placement(&info, physical_index, raw.len())?;
let shard = verified_shard(&info).await?;
let key = generation_key(&info);
let group_index = if let Some(index) = groups.iter().position(|group| group.key == key) {
index
} else {
groups.push(Group {
key,
present: 0,
shards: vec![None; raw.len()],
});
groups.len() - 1
};
let group = &mut groups[group_index];
let slot = &mut group.shards[info.erasure.index - 1];
if slot.is_some() {
return Err(PoolMetaCodecError::ShardPlacement);
}
*slot = Some(shard);
group.present += 1;
}
let mut decoded = Vec::with_capacity(groups.len());
for mut group in groups {
if group.present < group.key.erasure.data_blocks {
continue;
}
let codec = erasure(&group.key)?;
codec.decode_data(&mut group.shards)?;
let size = usize::try_from(group.key.size).map_err(|_| PoolMetaCodecError::UnsupportedLayout)?;
let mut payload = Vec::with_capacity(size);
for shard in group.shards.iter().take(group.key.erasure.data_blocks) {
let shard = shard.as_ref().ok_or(PoolMetaCodecError::PayloadMismatch)?;
let remaining = size.saturating_sub(payload.len());
payload.extend_from_slice(&shard[..remaining.min(shard.len())]);
}
if payload.len() != size || group.key.metadata.get("etag") != Some(&md5(&payload)) {
return Err(PoolMetaCodecError::PayloadMismatch);
}
let encoded = codec.encode_data(&payload)?;
if group
.shards
.iter()
.zip(&encoded)
.any(|(actual, expected)| actual.as_ref().is_some_and(|actual| actual.as_slice() != expected.as_ref()))
{
return Err(PoolMetaCodecError::PayloadMismatch);
}
decoded.push(Bytes::from(payload));
}
Ok(decoded)
}
/// Verifies a minority shard against a payload whose authority was established elsewhere.
pub(super) async fn validate_inline_pool_meta_shard(raw: &Bytes, physical_index: usize, payload: &[u8]) -> Result<()> {
let info = parse_inline_info(raw)?;
validate_placement(&info, physical_index, info.erasure.distribution.len())?;
if usize::try_from(info.size).ok() != Some(payload.len()) || info.metadata.get("etag") != Some(&md5(payload)) {
return Err(PoolMetaCodecError::PayloadMismatch);
}
let actual = verified_shard(&info).await?;
let encoded = erasure(&info)?.encode_data(payload)?;
if encoded
.get(info.erasure.index - 1)
.is_none_or(|expected| expected.as_ref() != actual.as_slice())
{
return Err(PoolMetaCodecError::PayloadMismatch);
}
Ok(())
}
fn persistent_attributes(info: &FileInfo) -> FileInfo {
let mut attributes = generation_key(info);
attributes.data_dir = None;
attributes.mod_time = None;
attributes.size = 0;
attributes.parts.clear();
attributes.erasure.checksums.clear();
attributes.metadata.remove("etag");
remove_str(&mut attributes.metadata, SUFFIX_ACTUAL_SIZE);
rustfs_filemeta::shard_integrity::clear_integrity_metadata(&mut attributes.metadata);
attributes
}
/// Builds disk-specific inline heads while retaining each disk's existing FileMeta.
/// The caller supplies the independently authorized canonical COMMIT payload.
pub(super) async fn encode_inline_pool_meta_replicas(
raw: &[Bytes],
canonical: &[u8],
modified: OffsetDateTime,
) -> Result<Vec<Bytes>> {
if !(2..=MAX_SHARDS).contains(&raw.len()) || canonical.is_empty() || canonical.len() > BLOCK_SIZE_V2 {
return Err(PoolMetaCodecError::UnsupportedLayout);
}
let mut infos = Vec::with_capacity(raw.len());
for (physical_index, bytes) in raw.iter().enumerate() {
let info = parse_inline_info(bytes)?;
validate_placement(&info, physical_index, raw.len())?;
verified_shard(&info).await?;
if info.mod_time.is_none_or(|previous| modified <= previous) {
return Err(PoolMetaCodecError::UnsupportedLayout);
}
if let Some(first) = infos.first()
&& persistent_attributes(first) != persistent_attributes(&info)
{
return Err(PoolMetaCodecError::InconsistentMetadata);
}
infos.push(info);
}
let mut base = infos.first().ok_or(PoolMetaCodecError::UnsupportedLayout)?.clone();
let protected = infos.iter().any(|info| info.parts[0].integrity.is_some());
let codec = Arc::new(erasure(&base)?);
let mut writers: Vec<Option<BitrotWriterWrapper>> = std::iter::repeat_with(|| None).take(raw.len()).collect();
let (_, written, shards, integrity) = Arc::clone(&codec)
.encode_with_shard_integrity(
Cursor::new(Bytes::copy_from_slice(canonical)),
&mut writers,
raw.len(),
1,
IntegrityEncodeMode::Inline(canonical.len()),
protected,
)
.await?;
if written != canonical.len() {
return Err(PoolMetaCodecError::PayloadMismatch);
}
let shards = shards.ok_or(PoolMetaCodecError::PayloadMismatch)?;
let etag = md5(canonical);
base.data_dir = Some(Uuid::new_v4());
base.mod_time = Some(modified);
base.size = i64::try_from(canonical.len()).map_err(|_| PoolMetaCodecError::UnsupportedLayout)?;
base.metadata.insert("etag".to_owned(), etag.clone());
insert_str(&mut base.metadata, SUFFIX_ACTUAL_SIZE, canonical.len().to_string());
base.parts.clear();
base.add_object_part(1, etag, canonical.len(), Some(modified), base.size, None, None);
rustfs_filemeta::shard_integrity::clear_integrity_metadata(&mut base.metadata);
if let Some(integrity) = integrity {
integrity.set_inline_metadata(&mut base)?;
base.parts[0].integrity = Some(integrity.part);
}
base.persist_shard_integrity()?;
base.erasure.checksums = vec![ChecksumInfo {
part_number: 1,
algorithm: HashAlgorithm::HighwayHash256S,
hash: Bytes::new(),
}];
base.set_inline_data();
let mut replacement = Vec::with_capacity(raw.len());
for (bytes, original) in raw.iter().zip(infos) {
let mut info = base.clone();
info.erasure.index = original.erasure.index;
info.data = Some(
shards
.get(info.erasure.index - 1)
.ok_or(PoolMetaCodecError::ShardPlacement)?
.clone(),
);
let mut meta = FileMeta::load(bytes)?;
meta.add_version(info)?;
replacement.push(Bytes::from(meta.marshal_msg()?));
}
Ok(replacement)
}
#[cfg(test)]
pub(super) async fn fixture_inline_pool_meta_replicas(payload: &[u8], modified: OffsetDateTime) -> Vec<Bytes> {
use crate::erasure::coding::{BitrotWriterWrapper, CustomWriter};
let mut base = FileInfo::new(".rustfs.sys/pool.bin", 2, 2);
base.volume = RUSTFS_META_BUCKET.to_owned();
base.name = OBJECT.to_owned();
base.data_dir = Some(Uuid::new_v4());
base.mod_time = Some(modified);
base.size = i64::try_from(payload.len()).expect("test payload should fit i64");
let etag = md5(payload);
base.metadata.insert("etag".to_owned(), etag.clone());
base.metadata
.insert("content-type".to_owned(), "application/octet-stream".to_owned());
insert_str(&mut base.metadata, SUFFIX_ACTUAL_SIZE, payload.len().to_string());
base.set_inline_data();
base.add_object_part(1, etag, payload.len(), Some(modified), base.size, None, None);
let codec = erasure(&base).expect("fixture layout should construct");
let shards = codec.encode_data(payload).expect("fixture erasure encoding should succeed");
let mut raw = Vec::with_capacity(4);
for coding_index in &base.erasure.distribution {
let mut writer =
BitrotWriterWrapper::new(CustomWriter::new_inline_buffer(), codec.shard_size(), HashAlgorithm::HighwayHash256S);
writer
.write(&shards[*coding_index - 1])
.await
.expect("fixture bitrot writer should write");
writer.shutdown().await.expect("fixture bitrot writer should finish");
let mut info = base.clone();
info.erasure.index = *coding_index;
info.data = Some(Bytes::from(writer.into_inline_data().expect("fixture writer should retain inline bytes")));
let mut meta = FileMeta::new();
meta.add_version(info).expect("test inline FileInfo should serialize");
raw.push(Bytes::from(meta.marshal_msg().expect("test xl.meta should serialize")));
}
raw
}
#[cfg(test)]
pub(super) async fn fixture_split_inline_pool_meta_replicas(
prepare: &[u8],
commit: &[u8],
modified: OffsetDateTime,
) -> Vec<Bytes> {
let mut raw = fixture_inline_pool_meta_replicas(prepare, modified).await;
let committed = fixture_inline_pool_meta_replicas(commit, modified + time::Duration::seconds(1)).await;
raw[2..].clone_from_slice(&committed[2..]);
raw
}
#[cfg(test)]
mod tests {
use super::*;
use fixture_inline_pool_meta_replicas as fixtures;
fn time(seconds: i64) -> OffsetDateTime {
OffsetDateTime::from_unix_timestamp(seconds).expect("test timestamp should be valid")
}
fn metadata(info: FileInfo) -> Bytes {
let mut meta = FileMeta::new();
meta.add_version(info).expect("test inline FileInfo should serialize");
Bytes::from(meta.marshal_msg().expect("test xl.meta should serialize"))
}
#[tokio::test]
async fn pool_meta_codec_reconstructs_both_halves_without_treating_them_as_authority() {
let raw = fixture_split_inline_pool_meta_replicas(b"prepare with predecessor", b"commit", time(10)).await;
let decoded = decode_inline_pool_meta_replicas(&raw)
.await
.expect("two shards should reconstruct each payload");
assert_eq!(decoded.len(), 2);
assert_eq!(decoded[0].as_ref(), b"prepare with predecessor");
assert_eq!(decoded[1].as_ref(), b"commit");
}
#[tokio::test]
async fn pool_meta_codec_requires_known_payload_for_single_minority_shard() {
let previous = fixtures(b"previous", time(10)).await;
let commit = fixtures(b"commit", time(11)).await;
let raw = vec![previous[0].clone(), commit[1].clone(), commit[2].clone(), commit[3].clone()];
let decoded = decode_inline_pool_meta_replicas(&raw)
.await
.expect("three commit shards should reconstruct");
assert_eq!(decoded.len(), 1);
assert_eq!(decoded[0].as_ref(), b"commit");
validate_inline_pool_meta_shard(&raw[0], 0, b"previous")
.await
.expect("known predecessor should verify");
assert!(matches!(
validate_inline_pool_meta_shard(&raw[0], 0, b"commit").await,
Err(PoolMetaCodecError::PayloadMismatch)
));
}
#[tokio::test]
async fn pool_meta_codec_rejects_bitrot_even_with_other_reconstructable_shards() {
let mut raw = fixtures(b"commit", time(10)).await;
let mut info = parse_inline_info(&raw[0]).expect("fixture should parse");
let mut data = info.data.take().expect("fixture should be inline").to_vec();
data[HashAlgorithm::HighwayHash256S.size()] ^= 1;
info.data = Some(Bytes::from(data));
raw[0] = metadata(info);
assert!(matches!(
decode_inline_pool_meta_replicas(&raw).await,
Err(PoolMetaCodecError::Integrity(_))
));
}
#[tokio::test]
async fn pool_meta_codec_rejects_trailing_inline_bytes() {
let mut raw = fixtures(b"commit", time(10)).await;
let mut info = parse_inline_info(&raw[0]).expect("fixture should parse");
let mut data = info.data.take().expect("fixture should be inline").to_vec();
data.push(0);
info.data = Some(Bytes::from(data));
raw[0] = metadata(info);
assert!(matches!(
decode_inline_pool_meta_replicas(&raw).await,
Err(PoolMetaCodecError::PayloadMismatch)
));
}
#[tokio::test]
async fn pool_meta_codec_rejects_duplicate_physical_shard_mapping() {
let mut raw = fixtures(b"commit", time(10)).await;
raw.swap(0, 1);
assert!(matches!(
decode_inline_pool_meta_replicas(&raw).await,
Err(PoolMetaCodecError::ShardPlacement)
));
}
#[tokio::test]
async fn pool_meta_codec_rejects_payload_md5_disagreement_after_valid_bitrot() {
let mut raw = fixtures(b"commit", time(10)).await;
for bytes in &mut raw {
let mut info = parse_inline_info(bytes).expect("fixture should parse");
info.metadata
.insert("etag".to_owned(), "00000000000000000000000000000000".to_owned());
info.parts[0].etag = "00000000000000000000000000000000".to_owned();
*bytes = metadata(info);
}
assert!(matches!(
decode_inline_pool_meta_replicas(&raw).await,
Err(PoolMetaCodecError::PayloadMismatch)
));
}
#[tokio::test]
async fn pool_meta_codec_reencodes_split_heads_to_one_canonical_generation() {
let prepare = fixtures(b"prepare with predecessor", time(10)).await;
let commit = fixtures(b"commit", time(11)).await;
let raw = vec![prepare[0].clone(), prepare[1].clone(), commit[2].clone(), commit[3].clone()];
let repaired = encode_inline_pool_meta_replicas(&raw, b"commit", time(12))
.await
.expect("split heads should encode");
let decoded = decode_inline_pool_meta_replicas(&repaired)
.await
.expect("repaired set should decode");
assert_eq!(decoded.len(), 1);
assert_eq!(decoded[0].as_ref(), b"commit");
let info = parse_inline_info(&repaired[0]).expect("repaired metadata should parse");
assert_eq!(info.mod_time, Some(time(12)));
assert_eq!(info.metadata.get("content-type").map(String::as_str), Some("application/octet-stream"));
for (index, bytes) in repaired.iter().enumerate() {
validate_inline_pool_meta_shard(bytes, index, b"commit")
.await
.expect("every repaired disk should match commit");
}
}
#[tokio::test]
async fn pool_meta_codec_preserves_required_persistent_metadata_agreement() {
let mut raw = fixtures(b"commit", time(10)).await;
let mut info = parse_inline_info(&raw[0]).expect("fixture should parse");
info.metadata.insert("content-type".to_owned(), "text/plain".to_owned());
raw[0] = metadata(info);
assert!(matches!(
encode_inline_pool_meta_replicas(&raw, b"commit", time(11)).await,
Err(PoolMetaCodecError::InconsistentMetadata)
));
}
#[tokio::test]
async fn pool_meta_codec_requires_strictly_newer_common_modification_time() {
let raw = fixtures(b"commit", time(10)).await;
assert!(matches!(
encode_inline_pool_meta_replicas(&raw, b"commit", time(10)).await,
Err(PoolMetaCodecError::UnsupportedLayout)
));
}
#[tokio::test]
async fn pool_meta_codec_rejects_noninline_heads_and_wrong_disk_count() {
let mut raw = fixtures(b"commit", time(10)).await;
assert!(matches!(
decode_inline_pool_meta_replicas(&raw[..3]).await,
Err(PoolMetaCodecError::ShardPlacement)
));
let mut info = parse_inline_info(&raw[0]).expect("fixture should parse");
info.data = None;
raw[0] = metadata(info);
assert!(matches!(parse_inline_info(&raw[0]), Err(PoolMetaCodecError::UnsupportedLayout)));
}
}
File diff suppressed because it is too large Load Diff
+38 -3
View File
@@ -473,7 +473,6 @@ mod decommission_lock_order_tests {
let ctx = Arc::new(crate::runtime::instance::InstanceContext::new());
ctx.update_erasure_type(crate::layout::endpoints::SetupType::DistErasure)
.await;
*ctx.local_disk_map().write().await = other_store.ctx.local_disk_map().read().await.clone();
let endpoints = other_store
.ctx
.endpoints()
@@ -482,9 +481,43 @@ mod decommission_lock_order_tests {
if let Some(deployment_id) = other_store.ctx.deployment_id() {
ctx.set_deployment_id(deployment_id);
}
let authority = ctx
.bind_multipart_proof_topology(&endpoints)
.expect("lease-loss fixture must own its complete native proof topology");
let mut local_disk_map = HashMap::new();
for pool in &mut pools {
Arc::make_mut(pool).set_instance_ctx_for_test(Arc::clone(&ctx));
let pool = Arc::make_mut(pool);
pool.set_instance_ctx_for_test(Arc::clone(&ctx));
for set in &mut pool.disk_set {
let set = Arc::make_mut(set);
let source_disks = set.disks.read().await.clone();
let mut disks = Vec::with_capacity(set.set_endpoints.len());
for (slot, endpoint) in set.set_endpoints.iter().enumerate() {
// The logical node clone shares durable contents, never a
// native handle already owned by another instance scope.
let disk = crate::disk::new_disk(
endpoint,
&crate::disk::DiskOption {
cleanup: false,
health_check: false,
},
)
.await
.expect("open the lease-loss node's independently owned native handle");
assert!(!Arc::ptr_eq(&disk, source_disks[slot].as_ref().expect("source fixture disk")));
assert_eq!(
crate::disk::DiskAPI::get_disk_id(disk.as_ref()).await.unwrap(),
Some(pool.format.erasure.sets[set.set_index][slot])
);
local_disk_map.insert(endpoint.to_string(), Some(disk.clone()));
disks.push(Some(disk));
}
crate::disk::multipart_source::bind_multipart_proof_snapshot(&disks, &authority)
.expect("lease-loss native handles must bind only to this fixture instance");
set.disks = Arc::new(tokio::sync::RwLock::new(disks));
}
}
*ctx.local_disk_map().write().await = local_disk_map;
let store = Arc::new(crate::store::ECStore {
id: uuid::Uuid::new_v4(),
disk_map: other_store.disk_map.clone(),
@@ -6886,7 +6919,6 @@ mod decommission_lock_order_tests {
};
owner.apply_to(&mut abort_opts);
let barrier = MultipartCommitBarrier::install(&bucket, object, MultipartCommitPause::AbortAfterDelete);
tokio::time::pause();
let abort = tokio::spawn({
let abort_store = Arc::clone(&lossy_store);
let abort_bucket = bucket.clone();
@@ -6906,6 +6938,9 @@ mod decommission_lock_order_tests {
}
});
barrier.wait_until_paused().await;
// Native cleanup performs real filesystem I/O. Pause time only after
// durable deletion, when the abort is blocked before its release save.
tokio::time::pause();
tokio::task::yield_now().await;
refresh_calls.arm();
tokio::time::advance(Duration::from_secs(11)).await;
-204
View File
@@ -19,7 +19,6 @@ use crate::layout::set_heal::{formats_to_drives_info, new_heal_format_sets};
use crate::multipart_listing::paginate_multipart_listing;
use crate::storage_api_contracts::{
bucket::{BucketInfo, BucketOperations, BucketOptions, DeleteBucketOptions, MakeBucketOptions},
list::{StorageListObjectVersionsInfo, StorageListObjectsV2Info, StorageObjectInfoOrErr, StorageWalkOptions},
multipart::{CompletePart, ListMultipartsInfo, ListPartsInfo, MultipartInfo, MultipartUploadResult, PartInfo},
object::{DeleteAccounting, DeletedObject, ObjectIO as _, ObjectOperations as _, ObjectToDelete},
range::HTTPRangeSpec,
@@ -60,50 +59,12 @@ use std::{
use tokio::sync::RwLock;
use tokio::sync::broadcast::{Receiver, Sender};
use tokio::time::Duration;
use tokio_util::sync::CancellationToken;
use tracing::warn;
use tracing::{error, info};
use uuid::Uuid;
type ListObjectsV2Info = StorageListObjectsV2Info<ObjectInfo>;
type ListObjectVersionsInfo = StorageListObjectVersionsInfo<ObjectInfo>;
type ObjectInfoOrErr = StorageObjectInfoOrErr<ObjectInfo, Error>;
type WalkOptions = StorageWalkOptions<fn(&FileInfo) -> bool>;
const LIST_MULTIPART_SETS_CONCURRENCY: usize = 4;
#[cfg(test)]
type TestSetLockers = HashMap<usize, Vec<Arc<dyn rustfs_lock::LockClient>>>;
#[cfg(test)]
static TEST_SET_LOCKERS: std::sync::LazyLock<std::sync::Mutex<TestSetLockers>> =
std::sync::LazyLock::new(|| std::sync::Mutex::new(HashMap::new()));
/// Keep a fixture's real locker domain scoped to its owning instance.
#[cfg(test)]
pub(crate) struct ScopedSetLockers(Arc<InstanceContext>);
#[cfg(test)]
impl ScopedSetLockers {
pub(crate) fn install(ctx: Arc<InstanceContext>, lockers: Vec<Arc<dyn rustfs_lock::LockClient>>) -> Self {
let key = Arc::as_ptr(&ctx) as usize;
let mut domains = TEST_SET_LOCKERS.lock().expect("test set lockers mutex");
assert!(!domains.contains_key(&key), "the instance already owns a test locker domain");
domains.insert(key, lockers);
Self(ctx)
}
}
#[cfg(test)]
impl Drop for ScopedSetLockers {
fn drop(&mut self) {
TEST_SET_LOCKERS
.lock()
.expect("test set lockers mutex")
.remove(&(Arc::as_ptr(&self.0) as usize));
}
}
fn is_idempotent_delete_prefix_error(err: &Error) -> bool {
is_err_object_not_found(err) || is_err_strict_volume_not_found(err)
}
@@ -305,14 +266,6 @@ impl Sets {
}
}
#[cfg(test)]
let pool_lockers = TEST_SET_LOCKERS
.lock()
.expect("test set lockers mutex")
.get(&(Arc::as_ptr(&instance_ctx) as usize))
.cloned()
.unwrap_or_else(|| pool_lockers.clone());
let set_disks = SetDisks::new_with_instance_ctx(
runtime_sources::local_node_name().await,
Arc::new(RwLock::new(set_drive)),
@@ -922,67 +875,6 @@ impl crate::storage_api_contracts::object::ObjectOperations for Sets {
}
}
#[async_trait::async_trait]
impl crate::storage_api_contracts::list::ListOperations for Sets {
type Error = Error;
type ListObjectsV2Info = ListObjectsV2Info;
type ListObjectVersionsInfo = ListObjectVersionsInfo;
type ObjectInfoOrErr = ObjectInfoOrErr;
type WalkOptions = WalkOptions;
type WalkCancellation = CancellationToken;
type WalkResultSender = tokio::sync::mpsc::Sender<ObjectInfoOrErr>;
#[tracing::instrument(level = "trace", skip(self))]
async fn list_objects_v2(
self: Arc<Self>,
bucket: &str,
prefix: &str,
continuation_token: Option<String>,
delimiter: Option<String>,
max_keys: i32,
fetch_owner: bool,
start_after: Option<String>,
incl_deleted: bool,
) -> Result<ListObjectsV2Info> {
self.inner_list_objects_v2(
bucket,
prefix,
continuation_token,
delimiter,
max_keys,
fetch_owner,
start_after,
incl_deleted,
)
.await
}
#[tracing::instrument(skip(self))]
async fn list_object_versions(
self: Arc<Self>,
bucket: &str,
prefix: &str,
marker: Option<String>,
version_marker: Option<String>,
delimiter: Option<String>,
max_keys: i32,
) -> Result<ListObjectVersionsInfo> {
self.inner_list_object_versions(bucket, prefix, marker, version_marker, delimiter, max_keys)
.await
}
async fn walk(
self: Arc<Self>,
rx: CancellationToken,
bucket: &str,
prefix: &str,
result: tokio::sync::mpsc::Sender<ObjectInfoOrErr>,
opts: WalkOptions,
) -> Result<()> {
self.walk_internal(rx, bucket, prefix, result, opts).await
}
}
#[async_trait::async_trait]
impl crate::storage_api_contracts::multipart::MultipartOperations for Sets {
type Error = Error;
@@ -1555,9 +1447,7 @@ mod tests {
use crate::layout::endpoint::Endpoint;
use crate::layout::endpoints::SetupType;
use crate::storage_api_contracts::heal::HealOperations as _;
use crate::storage_api_contracts::list::ListOperations as _;
use crate::storage_api_contracts::multipart::MultipartOperations as _;
use rustfs_lock::client::local::LocalClient;
use serial_test::serial;
struct SetupTypeGuard {
@@ -2137,100 +2027,6 @@ mod tests {
assert_eq!(second.common_prefixes, expected_prefixes[1..]);
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn sets_list_objects_v2_lists_objects_within_the_pool() {
let _setup_type_guard = SetupTypeGuard::switch_to(SetupType::Erasure).await;
let format = FormatV3::new(1, 2);
let mut endpoints = Vec::new();
let mut disks = Vec::new();
for disk_idx in 0..2 {
let dir = tempfile::tempdir().expect("tempdir should be created");
let mut endpoint =
Endpoint::try_from(dir.path().to_str().expect("tempdir path should be utf8")).expect("endpoint should parse");
endpoint.set_pool_index(0);
endpoint.set_set_index(0);
endpoint.set_disk_index(disk_idx);
let disk = new_disk(
&endpoint,
&DiskOption {
cleanup: false,
health_check: false,
},
)
.await
.expect("disk should be created");
let mut disk_format = format.clone();
disk_format.erasure.this = format.erasure.sets[0][disk_idx];
save_format_file(&Some(disk.clone()), &Some(disk_format))
.await
.expect("format should be saved");
std::mem::forget(dir);
endpoints.push(endpoint);
disks.push(Some(disk));
}
let set_disks = SetDisks::new(
"test-owner".to_string(),
Arc::new(RwLock::new(disks)),
2,
1,
0,
0,
endpoints.clone(),
format.clone(),
vec![Arc::new(LocalClient::new()), Arc::new(LocalClient::new())],
)
.await;
let sets = Arc::new(Sets {
id: format.id,
disk_set: vec![set_disks],
pool_idx: 0,
endpoints: PoolEndpoints {
legacy: false,
set_count: 1,
drives_per_set: 2,
endpoints: Endpoints::from(endpoints),
cmd_line: String::new(),
platform: String::new(),
},
format,
parity_count: 1,
set_count: 1,
set_drive_count: 2,
default_parity_count: 1,
distribution_algo: DistributionAlgoVersion::V1,
exit_signal: None,
ctx: bootstrap_ctx(),
});
let bucket = format!("bucket-{}", Uuid::new_v4().simple());
let object = format!("object-{}", Uuid::new_v4().simple());
sets.make_bucket(&bucket, &MakeBucketOptions::default())
.await
.expect("bucket should be created");
let mut reader = PutObjReader::from_vec(b"hello".to_vec());
sets.put_object(&bucket, &object, &mut reader, &ObjectOptions::default())
.await
.expect("object should be written");
let result = sets
.clone()
.list_objects_v2(&bucket, "", None, None, 1000, false, None, false)
.await
.expect("pool-level listing should succeed");
assert_eq!(result.objects.len(), 1);
assert_eq!(result.objects[0].name, object);
}
#[tokio::test]
async fn sets_check_abandoned_parts_rejects_invalid_set_scope() {
let format = FormatV3::new(1, 1);
+310
View File
@@ -958,6 +958,26 @@ fn is_data_movement_rewritten_transition_metadata(object_info: &ObjectInfo, key:
})
}
fn data_movement_publication_marker_valid(object_info: &ObjectInfo) -> bool {
use crate::disk::multipart_source::{SourceConditionV1, publication_identity_from_metadata};
let publication = match publication_identity_from_metadata(&object_info.user_defined) {
Ok(Some(publication)) => publication,
Ok(None) => return true,
Err(_) => return false,
};
if publication.final_data_dir.is_nil() {
return false;
}
SourceConditionV1 {
lineage: publication.lineage,
generation: publication.source_generation,
attempt: publication.attempt,
}
.validate()
.is_ok()
}
fn is_data_movement_rewritten_metadata(object_info: &ObjectInfo, key: &str, normalize_compression_size: bool) -> bool {
[
SUFFIX_DATA_MOVED,
@@ -972,6 +992,7 @@ fn is_data_movement_rewritten_metadata(object_info: &ObjectInfo, key: &str, norm
]
.iter()
.any(|suffix| is_data_movement_internal_metadata(key, suffix))
|| crate::disk::multipart_source::is_publication_metadata_key(key)
|| is_data_movement_rewritten_transition_metadata(object_info, key)
|| key == rustfs_rio::RUSTFS_MULTIPART_CHECKSUM
|| key == rustfs_rio::RUSTFS_MULTIPART_CHECKSUM_TYPE
@@ -994,6 +1015,14 @@ pub(crate) fn is_equivalent_data_movement_metadata(
return false;
}
// Copy can retain a native publication after changing the current key,
// version or placement. Validate that historical record's structure, then
// exclude only this per-copy provenance from content equivalence. Actual
// current scope/content remain independently compared below; this grants
// no native write-quorum proof or Complete replay authority.
if !data_movement_publication_marker_valid(source) || !data_movement_publication_marker_valid(target) {
return false;
}
let normalize_compression_size = source.is_compressed() && target.is_compressed();
if normalize_compression_size
&& !is_compatible_data_movement_marker_presence(
@@ -1033,6 +1062,25 @@ pub(crate) fn is_equivalent_data_movement_object_identity(
target: &ObjectInfo,
compare_mod_time: bool,
compare_part_checksums: bool,
) -> bool {
source.bucket == target.bucket
&& source.name == target.name
&& is_equivalent_data_movement_object_fields(source, target, compare_mod_time, compare_part_checksums)
}
// Exact-delete capacity reconciliation reads a target at a fixed physical key.
// Keep its checked encoded-name contract separate from logical-key equality.
pub(crate) fn is_equivalent_data_movement_capacity_target(source: &ObjectInfo, target: &ObjectInfo) -> bool {
source.bucket == target.bucket
&& source.name == rustfs_utils::path::decode_dir_object(&target.name)
&& is_equivalent_data_movement_object_fields(source, target, true, false)
}
fn is_equivalent_data_movement_object_fields(
source: &ObjectInfo,
target: &ObjectInfo,
compare_mod_time: bool,
compare_part_checksums: bool,
) -> bool {
let (Some(source_actual_size), Some(target_actual_size)) = (effective_actual_size(source), effective_actual_size(target))
else {
@@ -3549,6 +3597,268 @@ mod tests {
assert!(!can_replace_stale_data_movement_target(&delete_marker, &opts));
}
#[test]
fn data_movement_identity_keeps_logical_directory_and_literal_suffix_distinct() {
let fi = FileInfo {
version_id: Some(Uuid::from_u128(1)),
mod_time: Some(OffsetDateTime::UNIX_EPOCH),
size: 10,
metadata: HashMap::from([("etag".to_owned(), "directory-etag".to_owned())]),
..Default::default()
};
let source = ObjectInfo::from_file_info(&fi, "bucket", "directory/", true);
let target = ObjectInfo::from_file_info(&fi, "bucket", &encode_dir_object("directory/"), true);
assert_eq!(target.name, "directory/", "real metadata construction returns the logical name");
assert!(is_equivalent_data_movement_object_identity(&source, &target, true, false));
assert!(is_equivalent_data_movement_capacity_target(&source, &target));
let literal = ObjectInfo {
name: "directory__XLDIR__".to_owned(),
..target
};
assert!(crate::bucket::utils::is_valid_object_name(&literal.name));
assert!(crate::bucket::utils::is_valid_object_name(&source.name));
assert!(!is_equivalent_data_movement_object_identity(&source, &literal, true, false));
}
#[tokio::test]
async fn data_movement_equivalence_validates_each_real_native_multipart_publication() {
use crate::disk::DiskAPI as _;
use crate::disk::multipart_source::{is_publication_metadata_key, publication_identity_from_metadata};
use crate::storage_api_contracts::multipart::MultipartOperations as _;
use crate::storage_api_contracts::object::{ObjectIO as _, ObjectOperations as _};
let (_source_dirs, source_disks, source_set) = crate::set_disk::hermetic_set_disks_isolated(4).await;
let (_target_dirs, target_disks, target_set) = crate::set_disk::hermetic_set_disks_isolated(4).await;
let bucket = "native-multipart-migration-equivalence";
let object = "native-multipart-migration-equivalence-object";
for disk in source_disks.iter().chain(&target_disks) {
disk.make_volume(bucket).await.expect("create both real bucket volumes");
}
let body = vec![0x39; 1 << 20];
let upload = source_set
.new_multipart_upload(bucket, object, &ObjectOptions::default())
.await
.expect("real source upload");
let part = source_set
.put_object_part(
bucket,
object,
&upload.upload_id,
1,
&mut PutObjReader::from_vec(body.clone()),
&ObjectOptions::default(),
)
.await
.expect("real source part");
source_set
.clone()
.complete_multipart_upload(
bucket,
object,
&upload.upload_id,
vec![CompletePart {
part_num: part.part_num,
etag: part.etag,
..Default::default()
}],
&ObjectOptions::default(),
)
.await
.expect("real native source publication");
let source = source_set
.get_object_info(bucket, object, &ObjectOptions::default())
.await
.expect("actual committed source metadata");
let create_opts = data_movement_new_multipart_opts(&source, 0);
let moved_upload = target_set
.new_multipart_upload(bucket, object, &create_opts)
.await
.expect("real temporary transfer upload");
let part_opts = ObjectOptions {
part_number: Some(source.parts[0].number),
preserve_etag: Some(source.parts[0].etag.clone()),
data_movement: true,
..Default::default()
};
let part = target_set
.put_object_part(
bucket,
object,
&moved_upload.upload_id,
1,
&mut PutObjReader::from_vec(body.clone()),
&part_opts,
)
.await
.expect("real transfer part");
let complete_opts = data_movement_complete_multipart_opts(&source, 0, false).expect("compatible transfer options");
target_set
.clone()
.complete_multipart_upload(
bucket,
object,
&moved_upload.upload_id,
vec![CompletePart {
part_num: part.part_num,
etag: part.etag,
..Default::default()
}],
&complete_opts,
)
.await
.expect("real native migration publication");
let target = target_set
.get_object_info(bucket, object, &ObjectOptions::default())
.await
.expect("actual committed target metadata");
let source_publication = publication_identity_from_metadata(&source.user_defined)
.expect("source tuple decodes")
.expect("source producer retained its native tuple");
let target_publication = publication_identity_from_metadata(&target.user_defined)
.expect("target tuple decodes")
.expect("target producer retained its own native tuple");
assert_ne!(
source_publication, target_publication,
"physical publications have distinct attempts and directories"
);
assert_ne!(source.data_dir, target.data_dir);
assert!(
are_equivalent_data_movement_parts(&source.parts, &target.parts),
"the real transport preserves each part ETag"
);
assert!(is_equivalent_data_movement_object(&source, &target));
assert_eq!(
resolve_data_movement_overwrite_resume_copy(
&Error::PreconditionFailed,
Ok(Some(target.clone())),
&source,
0,
1,
false,
)
.expect("actual equivalent target resolves the failed create-only condition"),
Some(DataMovementCopy::Committed)
);
for set in [&source_set, &target_set] {
let mut reader = set
.get_object_reader(bucket, object, None, http::HeaderMap::new(), &ObjectOptions::default())
.await
.expect("actual source and target bodies");
let mut actual = Vec::new();
reader.stream.read_to_end(&mut actual).await.expect("read full actual body");
assert_eq!(actual, body);
}
for changed in [
"data-dir",
"version",
"mtime",
"bucket",
"key",
"user-metadata",
"etag",
"checksum",
"parts",
"condition",
"status",
"unknown",
] {
let mut invalid = target.clone();
match changed {
"data-dir" => {
let mut historical = publication_identity_from_metadata(&invalid.user_defined)
.expect("real tuple decodes")
.expect("real tuple exists");
historical.final_data_dir = Uuid::nil();
let encoded = hex_simd::encode_to_string(
rmp_serde::to_vec_named(&historical).expect("encode structurally invalid historical tuple"),
hex_simd::AsciiCase::Lower,
);
for (key, value) in Arc::make_mut(&mut invalid.user_defined).iter_mut() {
if is_publication_metadata_key(key) {
value.clone_from(&encoded);
}
}
}
"version" => invalid.version_id = Some(Uuid::new_v4()),
"mtime" => invalid.mod_time = invalid.mod_time.map(|time| time + time::Duration::seconds(1)),
"bucket" => invalid.bucket.push_str("-other"),
"key" => invalid.name.push_str("-other"),
"etag" => invalid.etag = Some("different-current-etag".to_owned()),
"checksum" => invalid.checksum = Some(Bytes::from_static(b"different-current-checksum")),
"parts" => Arc::make_mut(&mut invalid.parts)[0].etag.push_str("-other"),
"user-metadata" => {
Arc::make_mut(&mut invalid.user_defined).insert("x-amz-meta-extra".to_owned(), "changed".to_owned());
}
suffix => rustfs_utils::http::insert_str(
Arc::make_mut(&mut invalid.user_defined),
&format!("multipart-source-{suffix}"),
"unexpected".to_owned(),
),
}
assert!(
!is_equivalent_data_movement_object(&source, &invalid),
"{changed} cannot bypass metadata equivalence"
);
}
let mut malformed = target.clone();
for (key, value) in Arc::make_mut(&mut malformed.user_defined).iter_mut() {
if is_publication_metadata_key(key) {
*value = "malformed".to_owned();
}
}
assert!(
!is_equivalent_data_movement_object(&source, &malformed),
"malformed publication cannot be ignored"
);
let mut conflicting = target.clone();
let aliases = conflicting
.user_defined
.keys()
.filter(|key| is_publication_metadata_key(key))
.cloned()
.collect::<Vec<_>>();
assert_eq!(aliases.len(), 2, "actual writer stores both canonical publication aliases");
Arc::make_mut(&mut conflicting.user_defined).insert(aliases[0].clone(), "conflicting".to_owned());
assert!(
!is_equivalent_data_movement_object(&source, &conflicting),
"conflicting aliases cannot be ignored"
);
let mut malformed_source = source.clone();
for (key, value) in Arc::make_mut(&mut malformed_source.user_defined).iter_mut() {
if is_publication_metadata_key(key) {
*value = "malformed-source".to_owned();
}
}
assert!(
!is_equivalent_data_movement_object(&malformed_source, &target),
"a malformed present source publication cannot be projected away"
);
let mut takeover = target.clone();
takeover.mod_time = source.mod_time.map(|time| time + time::Duration::seconds(1));
rustfs_utils::http::insert_str(
Arc::make_mut(&mut takeover.user_defined),
SUFFIX_DATA_MOVEMENT_UPLOAD,
data_movement_upload_identity(&source),
);
assert!(is_data_movement_upload_takeover_target(&source, &takeover, true));
rustfs_utils::http::insert_str(
Arc::make_mut(&mut takeover.user_defined),
SUFFIX_DATA_MOVEMENT_UPLOAD,
"different-transfer-identity".to_owned(),
);
assert!(
!is_data_movement_upload_takeover_target(&source, &takeover, true),
"historical publication cannot bypass the exact current transfer identity"
);
let mut legacy = target.clone();
Arc::make_mut(&mut legacy.user_defined).retain(|key, _| !is_publication_metadata_key(key));
assert!(
is_equivalent_data_movement_object(&source, &legacy),
"a legacy destination may lack per-copy provenance"
);
}
#[test]
fn test_is_equivalent_data_movement_object_accepts_matching_metadata() {
let version_id = Uuid::nil();
+73 -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;
@@ -4572,15 +4598,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 +4641,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 +4679,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 +4713,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");
}
}
}
}
+479 -24
View File
@@ -440,7 +440,7 @@ pub(crate) trait DiskStoreRenameDataExt {
/// Run a mutation in an owned task when a caller supplied publication guard.
/// RPC cancellation drops only the waiter; the mutation owner keeps the guard
/// until its operation has returned, including any detached blocking syscall.
async fn run_owned_mutation<T, F, Fut>(external_guard: Option<Arc<dyn Send + Sync>>, operation: F) -> Result<T>
pub(crate) async fn run_owned_mutation<T, F, Fut>(external_guard: Option<Arc<dyn Send + Sync>>, operation: F) -> Result<T>
where
T: Send + 'static,
F: FnOnce() -> Fut + Send + 'static,
@@ -803,12 +803,45 @@ fn unpack_health_state(snapshot: u64) -> (RuntimeDriveHealthState, u32) {
(RuntimeDriveHealthState::from_u32((snapshot >> 32) as u32), snapshot as u32)
}
/// One upload-scoped observer; it is never part of production disk state.
#[cfg(test)]
#[derive(Debug)]
pub(crate) struct ScopedMultipartCleanupCompletions {
path: String,
delete_paths_returns: AtomicU64,
delete_returns: AtomicU64,
}
#[cfg(test)]
impl ScopedMultipartCleanupCompletions {
fn observes(&self, operation: &str, volume: &str, path: &str) -> bool {
volume == super::RUSTFS_META_MULTIPART_BUCKET
&& match operation {
"delete" => path == self.path,
"delete_paths" => path
.strip_prefix(self.path.as_str())
.is_some_and(|suffix| suffix.starts_with('/') && suffix.len() > 1),
_ => false,
}
}
pub(crate) fn completed(&self, operation: &str) -> u64 {
match operation {
"delete_paths" => self.delete_paths_returns.load(Ordering::Acquire),
"delete" => self.delete_returns.load(Ordering::Acquire),
_ => 0,
}
}
}
#[derive(Debug)]
pub(crate) struct DiskHealthMetricEpoch {
/// Preallocated per-operation metrics for built-in disk operation names.
operation_metrics: Box<[DiskOperationMetricEntry]>,
/// Fallback for tests or future extension operations outside DISK_OPERATION_NAMES.
fallback_operation_metrics: StdRwLock<HashMap<&'static str, Arc<DiskOperationMetrics>>>,
#[cfg(test)]
multipart_cleanup_observer: std::sync::Mutex<Option<std::sync::Weak<ScopedMultipartCleanupCompletions>>>,
/// Caller API operations currently executing through the disk health wrapper.
api_waiting: AtomicU32,
/// Operations rejected because the disk was unavailable for the caller.
@@ -834,6 +867,8 @@ impl Default for DiskHealthMetricEpoch {
.collect::<Vec<_>>()
.into_boxed_slice(),
fallback_operation_metrics: StdRwLock::new(HashMap::new()),
#[cfg(test)]
multipart_cleanup_observer: std::sync::Mutex::new(None),
api_waiting: AtomicU32::new(0),
total_errors_availability: AtomicU64::new(0),
total_errors_timeout: AtomicU64::new(0),
@@ -893,6 +928,46 @@ impl DiskHealthMetricEpoch {
}
}
#[cfg(test)]
fn observe_multipart_cleanup(&self, path: &str) -> Arc<ScopedMultipartCleanupCompletions> {
assert!(!path.is_empty() && !path.ends_with('/'), "observe one exact canonical upload root");
let scope = Arc::new(ScopedMultipartCleanupCompletions {
path: path.to_owned(),
delete_paths_returns: AtomicU64::new(0),
delete_returns: AtomicU64::new(0),
});
let mut observer = self.multipart_cleanup_observer.lock().expect("test cleanup observer lock");
assert!(
observer.as_ref().and_then(std::sync::Weak::upgrade).is_none(),
"one observer per physical wrapper"
);
*observer = Some(Arc::downgrade(&scope));
scope
}
#[cfg(test)]
fn matching_multipart_cleanup<'a>(
&self,
operation: &str,
volume: &str,
paths: impl IntoIterator<Item = &'a str>,
) -> Option<Arc<ScopedMultipartCleanupCompletions>> {
let scope = self
.multipart_cleanup_observer
.lock()
.expect("test cleanup observer lock")
.as_ref()
.and_then(std::sync::Weak::upgrade)?;
let mut nonempty = false;
for path in paths {
if !scope.observes(operation, volume, path) {
return None;
}
nonempty = true;
}
nonempty.then_some(scope)
}
fn record_availability_error(&self) {
self.total_errors_availability.fetch_add(1, Ordering::Relaxed);
}
@@ -1570,6 +1645,52 @@ impl LocalDiskWrapper {
.await
}
/// Bound the waiter while the physical recovery task retains its instance owner.
pub(crate) async fn recover_multipart_source_with_namespace_owner(
&self,
request: super::multipart_source::MultipartSourceRequest,
namespace_owner: Option<Arc<dyn Send + Sync>>,
) -> Result<super::multipart_source::MultipartSourceResponse> {
self.recover_multipart_source_owned(request, namespace_owner, get_max_timeout_duration())
.await
}
async fn recover_multipart_source_owned(
&self,
request: super::multipart_source::MultipartSourceRequest,
namespace_owner: Option<Arc<dyn Send + Sync>>,
wait_timeout: Duration,
) -> Result<super::multipart_source::MultipartSourceResponse> {
request.validate()?;
let mutation = request.operation.is_mutation();
let operation = self.clone();
let owner = if mutation {
Some(namespace_owner.unwrap_or_else(|| Arc::new(()) as Arc<dyn Send + Sync>))
} else {
namespace_owner
};
let owned = run_owned_mutation(owner, move || async move {
operation
.track_disk_health_mutation(
"recover_multipart_source",
if mutation {
DiskMetricMutation::Write
} else {
DiskMetricMutation::None
},
|| async { operation.disk.recover_multipart_source(request).await },
if mutation { Duration::ZERO } else { wait_timeout },
)
.await
});
if wait_timeout.is_zero() {
return owned.await;
}
// Dropping this waiter detaches the owned JoinHandle. The task keeps
// its namespace owner and resource unit until the native tail drains.
time::timeout(wait_timeout, owned).await.map_err(|_| DiskError::Timeout)?
}
pub(crate) fn new_with_reconnect_state(
disk: Arc<LocalDisk>,
health_check: bool,
@@ -2034,6 +2155,11 @@ impl LocalDiskWrapper {
self.metrics.metrics_snapshot()
}
#[cfg(test)]
pub(crate) fn observe_multipart_cleanup_for_test(&self, path: &str) -> Arc<ScopedMultipartCleanupCompletions> {
self.metrics.observe_multipart_cleanup(path)
}
fn record_result_error_metrics<T>(&self, result: &Result<T>) {
match result {
Err(DiskError::Timeout) => self.metrics.record_timeout_error(),
@@ -2639,15 +2765,6 @@ impl DiskAPI for LocalDiskWrapper {
})
}
async fn bucket_creation_witness(&self, volume: &str) -> Result<Option<super::os::NativeBucketCreationWitness>> {
self.track_disk_health_with_op(
"bucket_creation_witness",
|| async { self.disk.bucket_creation_witness(volume).await },
Duration::ZERO,
)
.await
}
async fn make_volume(&self, volume: &str) -> Result<()> {
// Scoped heal must drain directory creation before releasing its lifecycle owner.
let timeout = if crate::store::bucket_heal_scope(volume).is_some() {
@@ -2747,13 +2864,23 @@ impl DiskAPI for LocalDiskWrapper {
}
async fn delete_paths(&self, volume: &str, paths: &[String]) -> Result<()> {
self.track_disk_health_mutation(
"delete_paths",
DiskMetricMutation::Delete,
|| async { self.disk.delete_paths(volume, paths).await },
get_max_timeout_duration(),
)
.await
#[cfg(test)]
let observed = self
.metrics
.matching_multipart_cleanup("delete_paths", volume, paths.iter().map(String::as_str));
let result = self
.track_disk_health_mutation(
"delete_paths",
DiskMetricMutation::Delete,
|| async { self.disk.delete_paths(volume, paths).await },
get_max_timeout_duration(),
)
.await;
#[cfg(test)]
if let Some(scope) = observed {
scope.delete_paths_returns.fetch_add(1, Ordering::Release);
}
result
}
async fn acquire_snapshot_lease(&self, volume: &str, path: &str) -> Result<SnapshotLeaseToken> {
@@ -2927,6 +3054,70 @@ impl DiskAPI for LocalDiskWrapper {
.await
}
async fn rename_data_with_pool_meta_cas(
&self,
src_volume: &str,
src_path: &str,
fi: FileInfo,
dst_volume: &str,
dst_path: &str,
) -> Result<RenameDataResp> {
if dst_volume != super::RUSTFS_META_BUCKET
|| dst_path != "pool.bin"
|| !rustfs_utils::http::metadata_compat::contains_key_str(&fi.metadata, super::POOL_META_NATIVE_CAS_METADATA_SUFFIX)
{
return Err(DiskError::UnsupportedDisk);
}
self.rename_data_borrowed(src_volume, src_path, &fi, dst_volume, dst_path)
.await
}
async fn undo_pool_meta_write(&self, volume: &str, path: &str, fi: FileInfo, opts: DeleteOptions) -> Result<()> {
if volume != super::RUSTFS_META_BUCKET
|| path != "pool.bin"
|| !opts.undo_write
|| !rustfs_utils::http::metadata_compat::contains_key_str(&fi.metadata, super::POOL_META_NATIVE_CAS_METADATA_SUFFIX)
{
return Err(DiskError::UnsupportedDisk);
}
self.undo_write_with_namespace_owner(volume, path, fi, opts, None).await
}
async fn pool_meta_native_recovery(
&self,
request: super::PoolMetaNativeRecoveryRequest,
) -> Result<super::PoolMetaNativeRecoveryResponse> {
let mutation = matches!(&request, super::PoolMetaNativeRecoveryRequest::Apply { .. });
let operation = self.clone();
let owner = mutation.then(|| Arc::new(()) as Arc<dyn Send + Sync>);
run_owned_mutation(owner, move || async move {
operation
.track_disk_health_mutation(
"pool_meta_native_recovery",
if mutation {
DiskMetricMutation::Write
} else {
DiskMetricMutation::None
},
|| async { operation.disk.pool_meta_native_recovery(request).await },
if mutation {
Duration::ZERO
} else {
get_max_timeout_duration()
},
)
.await
})
.await
}
async fn recover_multipart_source(
&self,
request: super::multipart_source::MultipartSourceRequest,
) -> Result<super::multipart_source::MultipartSourceResponse> {
self.recover_multipart_source_with_namespace_owner(request, None).await
}
async fn list_dir(&self, origvolume: &str, volume: &str, dir_path: &str, count: i32) -> Result<Vec<String>> {
self.track_disk_health_with_op_and_timeout_action(
"list_dir",
@@ -3096,13 +3287,23 @@ impl DiskAPI for LocalDiskWrapper {
}
async fn delete(&self, volume: &str, path: &str, opt: DeleteOptions) -> Result<()> {
self.track_cleanup_mutation(
"delete",
DiskMetricMutation::Delete,
|| async { self.cleanup_delete(volume, path, opt, None).await },
get_max_timeout_duration(),
)
.await
#[cfg(test)]
let observed = self
.metrics
.matching_multipart_cleanup("delete", volume, std::iter::once(path));
let result = self
.track_cleanup_mutation(
"delete",
DiskMetricMutation::Delete,
|| async { self.cleanup_delete(volume, path, opt, None).await },
get_max_timeout_duration(),
)
.await;
#[cfg(test)]
if let Some(scope) = observed {
scope.delete_returns.fetch_add(1, Ordering::Release);
}
result
}
async fn verify_file(&self, volume: &str, path: &str, fi: &FileInfo) -> Result<CheckPartsResp> {
@@ -3228,6 +3429,100 @@ mod tests {
}
}
#[tokio::test]
async fn multipart_cleanup_completion_receipts_are_path_volume_operation_and_return_scoped() {
let dir = tempfile::tempdir().expect("cleanup receipt disk root");
let endpoint = Endpoint::try_from(dir.path().to_str().expect("UTF-8 disk root")).expect("local endpoint");
let disk = Arc::new(LocalDisk::new(&endpoint, false).await.expect("actual local disk"));
let wrapper = LocalDiskWrapper::new(disk.clone(), false);
let path = format!("{}/{}", "a".repeat(64), Uuid::new_v4());
let scope = wrapper.observe_multipart_cleanup_for_test(&path);
let source = disk
.get_object_path_for_io(super::super::RUSTFS_META_MULTIPART_BUCKET, &path)
.expect("exact source root");
std::fs::create_dir_all(&source).expect("actual orphan upload root");
std::fs::write(source.join("part.1.meta"), b"selected part record").expect("actual selected cleanup child");
std::fs::write(source.join("sentinel"), b"retained root child").expect("actual root child");
let recursive = DeleteOptions {
recursive: true,
..Default::default()
};
let tmp = disk
.get_object_path_for_io(super::super::RUSTFS_META_TMP_BUCKET, &path)
.expect("same spelling in TMP");
std::fs::create_dir_all(&tmp).expect("actual old TMP cleanup");
wrapper
.delete(super::super::RUSTFS_META_TMP_BUCKET, &path, recursive.clone())
.await
.expect("actual unrelated TMP deletion returns");
assert_eq!(scope.completed("delete"), 0, "old TMP completion cannot credit the MPI phase");
let sibling = format!("{}/{}", "a".repeat(64), Uuid::new_v4());
let sibling_root = disk
.get_object_path_for_io(super::super::RUSTFS_META_MULTIPART_BUCKET, &sibling)
.expect("different upload");
std::fs::create_dir_all(&sibling_root).expect("actual different upload root");
wrapper
.delete(super::super::RUSTFS_META_MULTIPART_BUCKET, &sibling, recursive.clone())
.await
.expect("actual unrelated upload deletion returns");
assert_eq!(scope.completed("delete"), 0, "another upload cannot credit the observed phase");
assert_eq!(
wrapper
.read_all(super::super::RUSTFS_META_MULTIPART_BUCKET, &format!("{path}/part.1.meta"))
.await
.expect("actual same-scope read"),
Bytes::from_static(b"selected part record")
);
assert_eq!(scope.completed("read_all"), 0);
assert_eq!(scope.completed("delete_paths"), 0, "other operations do not credit cleanup");
wrapper
.delete_paths(super::super::RUSTFS_META_MULTIPART_BUCKET, &[])
.await
.expect("empty actual cleanup returns");
assert_eq!(scope.completed("delete_paths"), 0, "empty cleanup has no scoped part observation");
std::fs::write(source.join("obsolete"), b"old child").expect("actual obsolete source child");
wrapper
.delete(
super::super::RUSTFS_META_MULTIPART_BUCKET,
&format!("{path}/obsolete"),
DeleteOptions::default(),
)
.await
.expect("actual same-upload child deletion returns");
assert_eq!(scope.completed("delete"), 0, "a descendant delete cannot credit upload-root cleanup");
wrapper
.delete_paths(super::super::RUSTFS_META_MULTIPART_BUCKET, &[format!("{path}/part.1.meta")])
.await
.expect("actual selected source child deletion returns");
assert_eq!(scope.completed("delete_paths"), 1);
assert_eq!(scope.completed("delete"), 0, "part cleanup cannot credit root cleanup");
let admission = wrapper.disk_id.write().await;
{
let cancelled = wrapper.delete(super::super::RUSTFS_META_MULTIPART_BUCKET, &path, recursive.clone());
tokio::pin!(cancelled);
assert!(futures::poll!(&mut cancelled).is_pending());
assert_eq!(
wrapper.metrics_snapshot().total_waiting,
0,
"the real wrapper is still in stale-check admission"
);
assert_eq!(scope.completed("delete"), 0);
}
drop(admission);
assert_eq!(scope.completed("delete"), 0, "cancelled wrappers cannot fabricate a return receipt");
assert!(source.exists());
let admission = wrapper.disk_id.write().await;
let deletion = wrapper.delete(super::super::RUSTFS_META_MULTIPART_BUCKET, &path, recursive);
tokio::pin!(deletion);
assert!(futures::poll!(&mut deletion).is_pending());
assert_eq!(scope.completed("delete"), 0, "a pending real delete has not returned");
drop(admission);
deletion.await.expect("actual exact-source root deletion returns");
assert_eq!(scope.completed("delete"), 1);
assert_eq!(scope.completed("delete_paths"), 1);
assert!(!source.exists(), "the receipt follows the real destructive operation");
}
#[tokio::test(start_paused = true)]
async fn slow_disk_operation_warns_once_while_pending_and_preserves_result() {
let dir = tempfile::tempdir().expect("temp dir should be created");
@@ -3840,6 +4135,166 @@ mod tests {
);
}
async fn assert_multipart_recovery_waiter_preserves_physical_owner(wait_timeout: Duration) {
use crate::disk::multipart_source::{
MULTIPART_SOURCE_PROTOCOL_VERSION, MultipartRecoveryRound, MultipartSourceOperation, MultipartSourcePhase,
MultipartSourceRequest, MultipartSourceResponse, SourceGeneration,
};
use crate::disk::{RUSTFS_META_MULTIPART_BUCKET, os};
use crate::runtime::instance::InstanceContext;
use rustfs_concurrency::adaptive::AdaptiveAdmission;
use rustfs_filemeta::FileMeta;
use std::num::NonZeroU16;
runtime_sources::ensure_test_rpc_secret();
let root = tempfile::tempdir().expect("formatted MPU disk root");
let meta_dir = root.path().join(RUSTFS_META_BUCKET);
std::fs::create_dir_all(&meta_dir).expect("format directory");
let mut format = FormatV3::new(1, 4);
format.erasure.this = format.erasure.sets[0][0];
std::fs::write(meta_dir.join(FORMAT_CONFIG_FILE), format.to_json().expect("native format encoding"))
.expect("native format file");
let mut endpoint = Endpoint::try_from(root.path().to_str().expect("UTF-8 disk root")).expect("native endpoint");
endpoint.set_pool_index(0);
endpoint.set_set_index(0);
endpoint.set_disk_index(0);
let path = format!("upload/{}", Uuid::new_v4());
let directory = Uuid::new_v4();
let upload = root.path().join(RUSTFS_META_MULTIPART_BUCKET).join(&path);
std::fs::create_dir_all(upload.join(directory.to_string())).expect("real original shard directory");
std::fs::write(upload.join(directory.to_string()).join("part.1"), b"original shard").expect("real shard");
let mut original = FileInfo::new("object", 2, 2);
original.erasure.distribution = vec![1, 2, 3, 4];
original.erasure.index = 1;
original.data_dir = Some(directory);
original.mod_time = Some(::time::OffsetDateTime::now_utc());
rustfs_utils::http::metadata_compat::insert_str(&mut original.metadata, "multipart-bucket", "bucket".to_owned());
rustfs_utils::http::metadata_compat::insert_str(&mut original.metadata, "multipart-object", "object".to_owned());
let mut meta = FileMeta::new();
meta.add_version(original).expect("original native FI");
let head = upload.join("xl.meta");
let before = meta.marshal_msg().expect("original head bytes");
std::fs::write(&head, &before).expect("actual original native head");
let disk = Arc::new(LocalDisk::new(&endpoint, false).await.expect("formatted native disk"));
let wrapper = Arc::new(LocalDiskWrapper::new(Arc::clone(&disk), false));
let inspect = || MultipartSourceRequest {
protocol_version: MULTIPART_SOURCE_PROTOCOL_VERSION,
path: path.clone(),
operation: MultipartSourceOperation::Inspect { lineage: None },
};
let MultipartSourceResponse::Inspected(initial) =
wrapper.recover_multipart_source(inspect()).await.expect("initial source")
else {
panic!("native inspection response");
};
let expected = initial.snapshot.expect("initial active head");
assert_eq!(expected.phase, MultipartSourcePhase::Active);
let generation = SourceGeneration {
epoch: expected.generation.epoch + 1,
nonce: Uuid::new_v4(),
};
let request = MultipartSourceRequest {
protocol_version: MULTIPART_SOURCE_PROTOCOL_VERSION,
path: path.clone(),
operation: MultipartSourceOperation::Claim {
round: MultipartRecoveryRound {
lineage: expected.lineage.clone(),
from_generation: expected.generation,
old_attempt: Uuid::new_v4(),
generation,
round: Uuid::new_v4(),
},
expected,
},
};
let held_namespace = os::acquire_namespace_mutation_lease_with_owner(
&disk
.get_object_path(RUSTFS_META_MULTIPART_BUCKET, &path)
.expect("canonical native lease path"),
None,
)
.await;
let ctx = Arc::new(InstanceContext::new());
let owner: Arc<dyn Send + Sync> = ctx.begin_namespace_commit();
let resources = AdaptiveAdmission::new(NonZeroU16::new(1).expect("one resource unit"), 1);
let unit = resources.acquire().await;
let operation = wrapper.clone();
time::pause();
let mut waiter = tokio::spawn(crate::disk::with_background_resource_unit(unit, async move {
operation
.recover_multipart_source_owned(request, Some(owner), wait_timeout)
.await
}));
while wrapper.health.waiting_count() == 0 {
assert!(!waiter.is_finished(), "recovery must enter the real physical operation");
tokio::task::yield_now().await;
}
assert!(futures::poll!(&mut waiter).is_pending());
assert!(ctx.namespace_commits_pending());
assert_eq!(resources.snapshot().active, 1);
assert_eq!(std::fs::read(&head).expect("head before timeout"), before);
assert!(!upload.join(".mpu-source-state").exists());
time::advance(if wait_timeout.is_zero() {
Duration::from_secs(31)
} else {
wait_timeout
})
.await;
if wait_timeout.is_zero() {
assert!(futures::poll!(&mut waiter).is_pending(), "zero keeps the configured no-timeout policy");
} else {
assert!(matches!((&mut waiter).await.expect("logical waiter"), Err(DiskError::Timeout)));
}
assert!(ctx.namespace_commits_pending(), "a logical timeout cannot drain the instance owner");
assert_eq!(resources.snapshot().active, 1, "the physical task must retain its resource unit");
assert_eq!(wrapper.health.waiting_count(), 1, "the native operation must remain active");
assert_eq!(std::fs::read(&head).expect("head at logical deadline"), before);
assert!(!upload.join(".mpu-source-state").exists());
drop(held_namespace);
time::resume();
if wait_timeout.is_zero() {
assert!(matches!(
waiter.await.expect("unbounded configured waiter"),
Ok(MultipartSourceResponse::Claimed(_))
));
}
time::timeout(Duration::from_secs(10), async {
while ctx.namespace_commits_pending() || resources.snapshot().active != 0 || wrapper.health.waiting_count() != 0 {
tokio::task::yield_now().await;
}
})
.await
.expect("actual recovery tail should drain after the real namespace lease is released");
let MultipartSourceResponse::Inspected(completed) = wrapper
.recover_multipart_source(inspect())
.await
.expect("completed native source")
else {
panic!("native inspection response");
};
let completed = completed.snapshot.expect("durable recovered head");
assert_eq!(completed.phase, MultipartSourcePhase::Recovering);
assert_eq!(completed.generation, generation);
assert_ne!(std::fs::read(&head).expect("completed source head"), before);
assert!(upload.join(".mpu-source-state").is_file());
assert_eq!(
std::fs::read(upload.join(directory.to_string()).join("part.1")).expect("preserved shard"),
b"original shard"
);
}
#[tokio::test]
async fn multipart_recovery_timeout_keeps_native_claim_and_instance_owner_alive() {
assert_multipart_recovery_waiter_preserves_physical_owner(Duration::from_secs(1)).await;
}
#[tokio::test]
async fn multipart_recovery_zero_wait_budget_keeps_the_existing_no_timeout_policy() {
assert_multipart_recovery_waiter_preserves_physical_owner(Duration::ZERO).await;
}
struct PendingWriter;
#[test]
-7
View File
@@ -117,9 +117,6 @@ pub const O_APPEND: FileMode = 0x00400;
// create_new: bool,
pub async fn open_file(path: impl AsRef<Path>, mode: FileMode) -> io::Result<File> {
if mode & O_CREATE != 0 {
super::os::record_native_publication(path.as_ref());
}
let base_opts = match mode & (O_RDONLY | O_WRONLY | O_RDWR) {
O_RDONLY => get_readonly_options(),
O_WRONLY => get_writeonly_options(),
@@ -163,7 +160,6 @@ pub fn lstat_std(path: impl AsRef<Path>) -> io::Result<Metadata> {
}
pub async fn make_dir_all(path: impl AsRef<Path>) -> io::Result<()> {
super::os::record_native_publication(path.as_ref());
fs::create_dir_all(path.as_ref()).await
}
@@ -215,18 +211,15 @@ pub fn remove_all_std(path: impl AsRef<Path>) -> io::Result<()> {
}
pub async fn mkdir(path: impl AsRef<Path>) -> io::Result<()> {
super::os::record_native_publication(path.as_ref());
fs::create_dir(path.as_ref()).await
}
pub async fn rename(from: impl AsRef<Path>, to: impl AsRef<Path>) -> io::Result<()> {
super::os::record_native_publication(to.as_ref());
fs::rename(from, to).await
}
#[cfg(any(not(windows), test))]
pub fn rename_std(from: impl AsRef<Path>, to: impl AsRef<Path>) -> io::Result<()> {
super::os::record_native_publication(to.as_ref());
std::fs::rename(from, to)
}
File diff suppressed because it is too large Load Diff
+451 -93
View File
@@ -281,10 +281,12 @@ type PreparedInlineMetadata = (
Option<StagedRollbackBackup>,
bool,
os::PreparedRenameSource,
Option<Arc<os::PreparedPoolMetaBackup>>,
Option<super::pool_meta_native::DurableHeadReceipt>,
);
enum InlinePreparationOutcome {
Committed(RenameDataResp),
Committed(RenameDataResp, Option<super::pool_meta_native::DurableHeadReceipt>),
CommitErr(DiskError),
Prepared(PreparedInlineMetadata),
}
@@ -400,6 +402,14 @@ pub(super) struct RenameDataState {
preflight_rejection: Option<LocalRenamePreflightRejection>,
}
// Keep optional native MPU state machines out of ordinary rename frames.
// Construct the future here as well as pinning it, so its temporary stays off
// the caller's poll stack. Call only from the existing native MPU branches.
#[inline(never)]
fn pin_multipart_future<F: std::future::Future>(build: impl FnOnce() -> F) -> std::pin::Pin<Box<F>> {
Box::pin(build())
}
impl LocalDisk {
#[tracing::instrument(name = "rename_data", target = "rustfs_ecstore::disk::local", level = "trace", skip_all)]
pub(super) async fn rename_data_inner(
@@ -411,12 +421,54 @@ impl LocalDisk {
dst_path: &str,
state: &mut RenameDataState,
) -> Result<RenameDataResp> {
super::multipart_staging::reject_noncanonical_scope(&self.root, self.io_root(), src_volume, src_path).inspect_err(
|_| {
state.preflight_rejection = Some(LocalRenamePreflightRejection(()));
},
)?;
super::multipart_staging::reject_noncanonical_scope(&self.root, self.io_root(), dst_volume, dst_path).inspect_err(
|_| {
state.preflight_rejection = Some(LocalRenamePreflightRejection(()));
},
)?;
super::pool_meta_native::reject_raw_path(&self.root, src_volume, src_path).inspect_err(|_| {
state.preflight_rejection = Some(LocalRenamePreflightRejection(()));
})?;
super::pool_meta_native::reject_noncanonical_object(&self.root, dst_volume, dst_path).inspect_err(|_| {
state.preflight_rejection = Some(LocalRenamePreflightRejection(()));
})?;
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 mut fi = fi;
let pool_meta_condition = if super::pool_meta_native::is_object(&self.root, dst_volume, dst_path) {
Some(super::pool_meta_native::PoolMetaNativeCasCondition::take(&mut fi).inspect_err(|_| {
state.preflight_rejection = Some(LocalRenamePreflightRejection(()));
})?)
} else {
None
};
let native_timing = pool_meta_condition.is_some();
if pool_meta_condition
.as_ref()
.is_some_and(|condition| fi.data_dir == Some(condition.attempt_id))
{
state.preflight_rejection = Some(LocalRenamePreflightRejection(()));
return Err(DiskError::FileCorrupt);
}
if let Some(condition) = &pool_meta_condition {
let new_identity = super::pool_meta_native::PoolMetaNativeFileIdentity::from_file_info(&fi)?;
if condition
.parent
.as_ref()
.is_some_and(|parent| new_identity.mod_time_unix_nanos <= parent.mod_time_unix_nanos)
{
state.preflight_rejection = Some(LocalRenamePreflightRejection(()));
return Err(DiskError::ConditionalDeleteConflict);
}
}
// 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
@@ -442,8 +494,32 @@ impl LocalDisk {
Some(token) => Some(self.claim_quota_mutation_fence(dst_volume, dst_path, token).await?),
None => None,
};
// A failed fanout can undo the destination after this disk succeeded.
// Keep the original upload authoritative until coordinator quorum: only
// this private attempt's links and generated metadata may be consumed.
let multipart_staging = if src_volume == super::super::RUSTFS_META_MULTIPART_BUCKET {
Some(
pin_multipart_future(|| {
self.prepare_multipart_commit_staging(src_path, &fi, dst_volume, state.namespace_owner.clone())
})
.await?,
)
} else {
None
};
let multipart_publication = if let Some(staging) = multipart_staging.as_ref() {
Some(pin_multipart_future(|| self.prepare_native_multipart_publication(src_path, &mut fi, staging)).await?)
} else {
None
};
let (src_volume, src_path) = match multipart_staging.as_ref() {
Some(staging) => (super::super::RUSTFS_META_TMP_BUCKET, staging.path.as_str()),
None => (src_volume, src_path),
};
// Quota admission -> metadata RMW -> namespace/volume publication.
let no_inline = fi.data.is_none() && fi.size > 0;
// MPU completion remains parts-backed even for a zero-byte part; its
// version-owned retirement anchor is published with that same DD.
let no_inline = fi.data.is_none() && (fi.size > 0 || multipart_publication.is_some());
let fi_healing = fi.is_healing();
let staging_prefix = if fi_healing && !no_inline && src_volume == super::super::RUSTFS_META_TMP_BUCKET {
Some(tmp_staging_mutation_path(&self.root, src_path)?)
@@ -455,19 +531,46 @@ 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 metadata_wait = os::StageTimer::new(native_timing, "pool_meta_native_metadata_lease_wait");
let metadata_owner: Option<Arc<dyn Send + Sync>> = match multipart_staging.as_ref() {
Some(staging) => Some(Arc::new((state.namespace_owner.take(), staging.lease.clone()))),
None => state.namespace_owner.take(),
};
let metadata_lease = os::acquire_metadata_mutation_lease(&root_destination_object_path, metadata_owner).await;
drop(metadata_wait);
#[cfg(unix)]
let parent_receipt = if native_timing {
self.pool_meta_durable_head.lock().take().filter(|receipt| {
pool_meta_condition.as_ref().is_some_and(|condition| {
condition.parent_raw_sha256.is_none() && condition.parent.as_ref() == Some(&receipt.identity)
})
})
} else {
None
};
#[cfg(unix)]
let mut receipt_mutation = native_timing
.then(|| super::pool_meta_native::ReceiptMutationGuard::new(&self.pool_meta_durable_head, metadata_lease.clone()));
let namespace_wait = os::StageTimer::new(native_timing, "pool_meta_native_namespace_lease_wait");
let mutation_lease = os::acquire_rename_data_mutation_lease_with_owner(
&self.root,
dst_volume,
&destination_object_path,
&root_destination_object_path,
staging_prefix.as_deref(),
Some(metadata_lease),
)
.await;
drop(namespace_wait);
if let Some(claim) = quota_fence_claim {
mutation_lease.attach_external_guard(claim);
}
if let Some(condition) = &pool_meta_condition {
let _parent_check = os::StageTimer::new(native_timing, "pool_meta_native_parent_check");
if let Err(err) = self.check_pool_meta_parent(condition, &destination_object_path).await {
state.preflight_rejection = Some(LocalRenamePreflightRejection(()));
return Err(err);
}
}
if fi.is_legacy_indexed_delete_marker() {
fi.erasure.index = 0;
}
@@ -524,24 +627,23 @@ 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())?;
// Native publication consumes its canonical head. The legacy path
// keeps main's independent recovery authority and staging filename.
let preserve_upload_metadata =
multipart_publication.is_none() && 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 = match multipart_publication.as_ref() {
Some(publication) => publication.canonical_head(),
None => 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())?;
// Multipart restore completion has validated staged local parts,
// while its public status stays pending until finalization.
let pending_multipart_restore = src_volume == crate::disk::RUSTFS_META_MULTIPART_BUCKET
&& fi
.metadata
.get(rustfs_filemeta::metadata_keys::RESTORE)
.and_then(|value| rustfs_filemeta::parse_restore_obj_status(value).ok())
.is_some_and(|status| status.is_restore_in_progress == Some(true));
if pending_multipart_restore && fi.data_dir.is_none_or(|dir| dir.is_nil()) {
return Err(DiskError::FileCorrupt);
}
// data_dir path (only used by the non-inline commit branch)
let has_data_dir_path = if no_inline && (!fi.is_remote() || pending_multipart_restore) {
let has_data_dir_path = if no_inline && !fi.is_remote() {
if let Some(data_dir) = fi
.data_dir
.map(|dir| rustfs_utils::path::retain_slash(dir.to_string().as_str()))
@@ -563,6 +665,22 @@ impl LocalDisk {
None
};
if multipart_publication.is_none()
&& let Some((source_directory, target_directory)) = has_data_dir_path.as_ref()
{
// Reject imported provenance before any DD purge or publication.
// Same-disk heal copies only an already verified local anchor.
self.retain_owned_multipart_anchor(
&fi,
dst_volume,
dst_path,
source_directory,
target_directory,
mutation_lease.clone(),
)
.await?;
}
check_path_length(src_file_path.to_string_lossy().to_string().as_str())?;
check_path_length(dst_file_path.to_string_lossy().to_string().as_str())?;
@@ -582,8 +700,10 @@ impl LocalDisk {
.ok_or_else(|| DiskError::other("missing object metadata parent"))?;
// Acquire the common trees before reading destination metadata. On
// Windows this pins the object directory identity across publication.
// Multipart authority already created its source tree. Recreating it
// here would resurrect staging retired before a delayed request arrived.
let (rename_commit_guard, has_dst_buf) = prepare_rename_commit_and_destination_metadata(
!no_inline,
!no_inline && !preserve_upload_metadata,
src_file_parent,
dst_file_parent,
&dst_file_path,
@@ -602,6 +722,39 @@ 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 == root_destination_object_path {
commit.mutation_lease.clone()
} else {
os::acquire_metadata_mutation_lease(&source_object_path, Some(commit.mutation_lease.clone())).await
};
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())?;
os::rename_all_with_owner(
&authority,
&recovery,
src_file_parent,
commit.publication_root,
Some(authority_owner.clone()),
)
.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();
@@ -631,13 +784,17 @@ impl LocalDisk {
let (old_current_size, old_current_source_checked, old_current_source) =
observe_old_current(has_dst_buf.is_some(), dst_meta_unparsable, &xlmeta, dst_volume, dst_path, version_id);
let has_old_data_dir = xlmeta.find_unshared_data_dir_for_version(Some(version_id));
let rollback_data_dir = has_old_data_dir.or_else(|| {
if has_dst_buf.is_some() && xlmeta.find_version(Some(version_id)).is_ok() {
Some(inline_metadata_rollback_dir(version_id, &xlmeta))
} else {
None
}
});
let rollback_data_dir = if let Some(condition) = &pool_meta_condition {
condition.has_parent().then_some(condition.attempt_id)
} else {
has_old_data_dir.or_else(|| {
if has_dst_buf.is_some() && xlmeta.find_version(Some(version_id)).is_ok() {
Some(inline_metadata_rollback_dir(version_id, &xlmeta))
} else {
None
}
})
};
if let Some(old_data_dir) = has_old_data_dir.as_ref() {
let _ = xlmeta.data.remove_two(version_id, *old_data_dir);
}
@@ -651,7 +808,10 @@ impl LocalDisk {
// after the commit rename, and a crash before the rename means the
// PUT was never acknowledged. A metadata commit: relaxed tiers
// leave it to the page cache.
let tmp_meta_sync = if durability.syncs_commit_metadata() {
if let Some(publication) = multipart_publication.as_ref() {
pin_multipart_future(|| publication.prepare_metadata(&new_dst_buf, has_dst_buf.as_deref())).await?;
}
let tmp_meta_sync = if durability.syncs_commit_metadata() || multipart_publication.is_some() {
SyncMode::FileOnly
} else {
SyncMode::None
@@ -671,7 +831,9 @@ impl LocalDisk {
// rename below to report through the existing rollback path. Payload
// durability is kept by both strict and relaxed.
let tmp_meta_write = {
let src_file_path = commit.source_metadata.to_path_buf();
let src_file_path = multipart_publication
.as_ref()
.map_or_else(|| commit.source_metadata.to_path_buf(), |publication| publication.prepared_path.clone());
let dst_file_path = dst_file_path.clone();
let owned_directory_guard = commit.directory_guard.clone();
let owned_mutation_lease = commit.mutation_lease.clone();
@@ -708,6 +870,10 @@ impl LocalDisk {
// sequential version did.
let prepared_metadata_source = tmp_meta_res?;
shard_sync_res?;
let prepared_metadata_source = match multipart_publication.as_ref() {
Some(publication) => pin_multipart_future(|| publication.install_head(prepared_metadata_source)).await?,
None => prepared_metadata_source,
};
commit.directory_guard = remove_dst_base_before_commit(
dst_path,
commit.directory_guard,
@@ -746,30 +912,49 @@ impl LocalDisk {
"Healing commit could not purge the stale destination data dir"
);
}
if let Some((src_data_path, dst_data_path)) = has_data_dir_path.as_ref()
&& let Err(err) = os::rename_all_with_commit_guard(
if let Some((src_data_path, dst_data_path)) = has_data_dir_path.as_ref() {
#[cfg(target_os = "linux")]
let payload_guard = if multipart_publication.is_some() {
Some(
os::multipart_payload_commit_guard(
&commit.directory_guard,
src_data_path.parent().ok_or(DiskError::InvalidPath)?,
commit.mutation_lease.clone(),
)
.await
.map_err(to_file_error)?,
)
} else {
None
};
#[cfg(target_os = "linux")]
let directory_guard = payload_guard.as_ref().unwrap_or(&commit.directory_guard);
#[cfg(not(target_os = "linux"))]
let directory_guard = &commit.directory_guard;
if let Err(err) = os::rename_all_with_commit_guard(
src_data_path,
dst_data_path,
&skip_parent,
commit.publication_root,
&commit.directory_guard,
directory_guard,
commit.mutation_lease.clone(),
)
.await
{
info!(
target: "rustfs_ecstore::disk::local",
event = EVENT_DISK_LOCAL_RENAME_REJECTED,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_DISK_LOCAL,
reason = "rename_all_data_path_failed",
src_path = ?src_data_path,
dst_path = ?dst_data_path,
error = ?err,
"Disk local rename flow failed"
);
commit.restore_published_data_source().await?;
return Err(err);
{
info!(
target: "rustfs_ecstore::disk::local",
event = EVENT_DISK_LOCAL_RENAME_REJECTED,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_DISK_LOCAL,
reason = "rename_all_data_path_failed",
src_path = ?src_data_path,
dst_path = ?dst_data_path,
error = ?err,
"Disk local rename flow failed"
);
commit.restore_published_data_source().await?;
return Err(err);
}
}
#[cfg(test)]
if has_data_dir_path.is_some() {
@@ -807,7 +992,9 @@ impl LocalDisk {
} else {
SyncMode::None
};
if let (Some(old_data_dir), Some(dst_buf)) = (rollback_data_dir, has_dst_buf.as_ref()) {
if let (Some(old_data_dir), Some(dst_buf)) = (rollback_data_dir, has_dst_buf.as_ref())
&& multipart_publication.as_ref().is_none_or(|publication| publication.legacy)
{
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 {
@@ -899,7 +1086,11 @@ impl LocalDisk {
return Err(err);
}
if should_fail_after_metadata_commit(dst_path) {
commit.rollback_non_inline_std(rollback_data_dir).map_err(to_file_error)?;
if let Some(publication) = multipart_publication.as_ref() {
pin_multipart_future(|| publication.restore_target_after_error(commit.mutation_lease.clone())).await?;
} else {
commit.rollback_non_inline_std(rollback_data_dir).map_err(to_file_error)?;
}
return Err(DiskError::Unexpected);
}
@@ -926,7 +1117,11 @@ impl LocalDisk {
rustfs_io_metrics::PUT_STAGE_SET_DISK_RENAME_DST_DIR_FSYNC,
fsync_started,
);
commit.rollback_non_inline_std(rollback_data_dir).map_err(to_file_error)?;
if let Some(publication) = multipart_publication.as_ref() {
pin_multipart_future(|| publication.restore_target_after_error(commit.mutation_lease.clone())).await?;
} else {
commit.rollback_non_inline_std(rollback_data_dir).map_err(to_file_error)?;
}
// The commit rename changed the dst part inodes before this fsync
// failed and rolled them back; drop any fd cached during that
// window so readers re-open the restored inode (rustfs/backlog#1177).
@@ -963,7 +1158,12 @@ impl LocalDisk {
rustfs_io_metrics::PUT_STAGE_SET_DISK_RENAME_ANCESTOR_DIR_FSYNC,
fsync_started,
);
commit.rollback_non_inline_std(rollback_data_dir).map_err(to_file_error)?;
if let Some(publication) = multipart_publication.as_ref() {
pin_multipart_future(|| publication.restore_target_after_error(commit.mutation_lease.clone()))
.await?;
} else {
commit.rollback_non_inline_std(rollback_data_dir).map_err(to_file_error)?;
}
// Same post-commit rollback window as above — drop cached
// dst part fds so readers re-open the restored inode
// (rustfs/backlog#1177).
@@ -989,14 +1189,11 @@ 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 multipart_publication.is_none()
&& !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
@@ -1014,9 +1211,24 @@ impl LocalDisk {
self.io_backend.invalidate_cached_fd(dst_volume, part_path).await;
}
let multipart_source = match multipart_publication.as_ref() {
Some(publication) => match pin_multipart_future(|| publication.finish(commit.mutation_lease.clone())).await {
Ok(result) => Some(Box::new(result)),
Err(error) => {
pin_multipart_future(|| publication.restore_target_after_error(commit.mutation_lease.clone())).await?;
return Err(error);
}
},
None => None,
};
Ok(RenameDataResp {
multipart_source,
old_data_dir: has_old_data_dir,
rollback_data_dir,
rollback_data_dir: if multipart_publication.as_ref().is_some_and(|publication| !publication.legacy) {
None
} else {
rollback_data_dir
},
cleanup_data_dir: has_old_data_dir,
sign: version_signature,
old_current_size,
@@ -1029,25 +1241,47 @@ 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 = multipart_publication.is_none() && !preserve_upload_metadata;
#[cfg(windows)]
let source_parent = src_file_parent.to_path_buf();
let rename_commit_guard_for_preparation = commit.directory_guard.clone();
let sync = durability.syncs_commit_metadata();
#[cfg(unix)]
let receipt_identity = if sync
&& pool_meta_condition
.as_ref()
.is_some_and(|condition| condition.parent_raw_sha256.is_none())
{
Some(super::pool_meta_native::PoolMetaNativeFileIdentity::from_file_info(&fi)?)
} else {
None
};
#[cfg(unix)]
let owned_backup_path = pool_meta_condition
.as_ref()
.filter(|condition| condition.has_parent())
.map(|condition| {
destination_object_path
.join(condition.attempt_id.to_string())
.join(STORAGE_FORMAT_FILE_BACKUP)
});
#[cfg(unix)]
if let Some(backup) = &owned_backup_path {
let parent = backup
.parent()
.ok_or_else(|| DiskError::other("missing owned backup parent"))?;
commit.mutation_lease =
os::acquire_namespace_mutation_lease_with_owner(parent, Some(commit.mutation_lease.clone())).await;
}
#[cfg(test)]
run_inline_before_file_sync_admission(dst_path);
let mut file_sync_admission = if sync {
let _admission = os::StageTimer::new(native_timing, "pool_meta_native_file_sync_admission");
Some(
os::acquire_file_sync_admission(self.file_sync_permits.clone())
.await
@@ -1057,7 +1291,10 @@ impl LocalDisk {
} else {
None
};
let preparation_dispatch = os::StageTimer::new(native_timing, "pool_meta_native_prepare_dispatch_wait");
let prepare_inline_metadata = move |is_cancelled: &dyn Fn() -> bool| {
drop(preparation_dispatch);
let _preparation = os::StageTimer::new(native_timing, "pool_meta_native_prepare_execute");
let mut prepared_metadata_source =
os::create_prepared_rename_source_with_commit_guard(&src, &dst, &rename_commit_guard_for_preparation)?;
#[cfg(windows)]
@@ -1091,7 +1328,9 @@ impl LocalDisk {
// Healing commits never run set-level post-return rollback; keep
// their intra-commit rollback backup in the staging directory so
// `<object>/<rollback_data_dir>/xl.meta.bkp` is never stranded.
let rollback_data_dir = if fi_healing {
let rollback_data_dir = if let Some(condition) = &pool_meta_condition {
condition.has_parent().then_some(condition.attempt_id)
} else if fi_healing {
None
} else {
old_data_dir.or_else(|| {
@@ -1114,13 +1353,61 @@ impl LocalDisk {
// relaxed tiers do no per-object fsync here at all (aligned
// with MinIO's default), trading a documented power-loss
// window for latency.
prepared_metadata_source.write_all(&new_buf, sync)?;
{
let _new_file = os::StageTimer::new(native_timing, "pool_meta_native_staged_write_sync");
prepared_metadata_source.write_all(&new_buf, sync)?;
}
run_inline_preparation_before_backup(&dst_path_for_failpoint);
#[cfg(unix)]
let mut receipt_candidate = receipt_identity.and_then(|identity| {
os::DurableFileReceipt::capture(&publication_root_for_commit, &src, &new_buf)
.ok()
.map(|physical| super::pool_meta_native::DurableHeadReceipt { physical, identity })
});
#[cfg(not(unix))]
let receipt_candidate = None;
#[cfg(unix)]
let owned_backup = match (owned_backup_path.as_ref(), has_dst_buf.as_ref()) {
(Some(backup_path), Some(old_metadata)) => {
let backup = {
let _create = os::StageTimer::new(native_timing, "pool_meta_native_backup_create");
Arc::new(os::prepare_pool_meta_owned_backup(
&dst,
backup_path,
&dst_volume_dir_for_commit,
&publication_root_for_commit,
old_metadata,
should_fail_local_inline_rollback_hardlink(&dst),
)?)
};
let reuses_durability = parent_receipt.as_ref().is_some_and(|receipt| {
backup.can_reuse_durability(&receipt.physical, &publication_root_for_commit, old_metadata)
});
if !backup.is_linked() {
receipt_candidate = None;
}
if sync && !reuses_durability {
let _backup_sync = os::StageTimer::new(native_timing, "pool_meta_native_backup_file_sync");
backup.sync_data()?;
} else if sync {
rustfs_io_metrics::record_put_object_stage_count("pool_meta_native_backup_sync_reused", 1);
}
Some(backup)
}
(Some(_), None) => {
return Err(std::io::Error::new(ErrorKind::InvalidData, "pool metadata predecessor disappeared"));
}
(None, _) => None,
};
#[cfg(not(unix))]
let owned_backup = None;
if let Some(ref old_metadata) = has_dst_buf
&& owned_backup.is_none()
&& (rollback_data_dir.is_some() || fi_healing || sync || cfg!(test))
{
#[cfg(windows)]
let backup_path = {
let _backup = os::StageTimer::new(native_timing, "pool_meta_native_backup_create_sync");
let backup_path = src
.parent()
.ok_or_else(|| std::io::Error::new(ErrorKind::InvalidInput, "missing staging metadata parent"))?
@@ -1129,9 +1416,13 @@ impl LocalDisk {
backup_path
};
#[cfg(not(windows))]
let backup_path = create_local_inline_rollback_backup(&dst, &src, old_metadata)?;
let backup_path = {
let _backup = os::StageTimer::new(native_timing, "pool_meta_native_backup_create");
create_local_inline_rollback_backup(&dst, &src, old_metadata)?
};
#[cfg(not(windows))]
if sync {
let _backup_sync = os::StageTimer::new(native_timing, "pool_meta_native_backup_file_sync");
std::fs::File::open(&backup_path)?.sync_data()?;
}
staged_rollback_path = Some(StagedRollbackBackup(backup_path));
@@ -1190,15 +1481,19 @@ impl LocalDisk {
let _ = std::fs::remove_dir(parent);
}
return Ok::<_, std::io::Error>(InlinePreparationOutcome::Committed(RenameDataResp {
old_data_dir,
rollback_data_dir,
cleanup_data_dir: old_data_dir,
sign: version_signature,
old_current_size,
old_current_source_checked,
old_current_source,
}));
return Ok::<_, std::io::Error>(InlinePreparationOutcome::Committed(
RenameDataResp {
multipart_source: None,
old_data_dir,
rollback_data_dir,
cleanup_data_dir: old_data_dir,
sign: version_signature,
old_current_size,
old_current_source_checked,
old_current_source,
},
receipt_candidate,
));
}
Ok::<_, std::io::Error>(InlinePreparationOutcome::Prepared((
@@ -1211,8 +1506,11 @@ impl LocalDisk {
staged_rollback_path,
destination_was_absent,
prepared_metadata_source,
owned_backup,
receipt_candidate,
)))
};
let preparation_total = os::StageTimer::new(native_timing, "pool_meta_native_prepare_total");
let inline_preparation = if let Some(admission) = file_sync_admission.as_ref() {
os::run_cancellable_blocking_namespace_file_sync_operation(
commit.mutation_lease.clone(),
@@ -1225,6 +1523,7 @@ impl LocalDisk {
}
.map_err(to_file_error)
.map_err(DiskError::from);
drop(preparation_total);
let (
rollback_data_dir,
cleanup_data_dir,
@@ -1235,22 +1534,29 @@ impl LocalDisk {
mut local_rollback_path,
destination_was_absent,
prepared_metadata_source,
owned_backup,
receipt_candidate,
) = match inline_preparation {
Ok(InlinePreparationOutcome::Committed(resp)) => {
Ok(InlinePreparationOutcome::Committed(resp, receipt_candidate)) => {
drop(file_sync_admission.take());
#[cfg(unix)]
self.publish_pool_meta_durable_head(receipt_candidate, dst_file_path.clone(), commit.mutation_lease.clone())
.await;
#[cfg(not(unix))]
let _ = receipt_candidate;
#[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;
}
#[cfg(unix)]
if let Some(receipt_mutation) = receipt_mutation.as_mut() {
receipt_mutation.complete();
}
return Ok(resp);
}
Ok(InlinePreparationOutcome::CommitErr(err)) | Err(err) => {
@@ -1284,6 +1590,42 @@ impl LocalDisk {
return Err(DiskError::FileNotFound);
}
#[cfg(unix)]
if let Some(backup) = &owned_backup {
// The backup namespace lease owns the head transaction; retain
// its directory and inode guards in every later detached job.
commit.mutation_lease.attach_external_guard(backup.clone());
#[cfg(test)]
run_rename_data_after_first_publication(&self.root, dst_volume, dst_path);
let backup_base = dst_volume_dir.clone();
let backup_root = commit.publication_root.clone();
if let Some(admission) = file_sync_admission.as_ref() {
let _backup_dir = os::StageTimer::new(native_timing, "pool_meta_native_backup_dir_sync");
let fsync_started = rustfs_io_metrics::put_stage_timer();
let backup = backup.clone();
let result =
os::run_blocking_namespace_file_sync_operation(commit.mutation_lease.clone(), admission, move || {
backup.verify_paths(&backup_base, &backup_root)?;
backup.sync_directory()
})
.await;
rustfs_io_metrics::record_put_object_stage_duration_from(
rustfs_io_metrics::PUT_STAGE_SET_DISK_RENAME_BACKUP_DIR_FSYNC,
fsync_started,
);
result.map_err(to_file_error)?;
} else {
let backup = backup.clone();
os::run_blocking_namespace_operation(commit.mutation_lease.clone(), move || {
backup.verify_paths(&backup_base, &backup_root)
})
.await
.map_err(to_file_error)?;
}
}
#[cfg(not(unix))]
let _ = owned_backup;
if let (Some(rollback_data_dir), Some(staged_backup)) =
(rollback_data_dir, local_rollback_path.as_ref().map(StagedRollbackBackup::path))
{
@@ -1296,21 +1638,24 @@ impl LocalDisk {
// rename_all acquires the backup path's namespace lease. Do not
// hold a disk admission while acquiring another namespace lock.
drop(file_sync_admission.take());
if let Err(err) = os::rename_all_with_owner(
let backup_publish = os::StageTimer::new(native_timing, "pool_meta_native_backup_publish");
let backup_result = os::rename_all_with_owner(
staged_backup,
&backup_path,
&dst_volume_dir,
commit.publication_root,
Some(commit.mutation_lease.clone()),
)
.await
{
.await;
drop(backup_publish);
if let Err(err) = backup_result {
let _ = remove_file_if_exists(staged_backup);
return Err(err);
}
#[cfg(test)]
run_rename_data_after_first_publication(&self.root, dst_volume, dst_path);
if sync {
let _admission = os::StageTimer::new(native_timing, "pool_meta_native_file_sync_readmission");
file_sync_admission = Some(
os::acquire_file_sync_admission(self.file_sync_permits.clone())
.await
@@ -1321,6 +1666,7 @@ impl LocalDisk {
if let Some(admission) = file_sync_admission.as_ref()
&& let Some(backup_parent) = backup_path.parent()
{
let _backup_dir = os::StageTimer::new(native_timing, "pool_meta_native_backup_dir_sync");
let fsync_started = rustfs_io_metrics::put_stage_timer();
if let Err(err) =
os::fsync_dir_with_namespace_file_sync_limit(backup_parent, commit.mutation_lease.clone(), admission)
@@ -1340,6 +1686,7 @@ impl LocalDisk {
local_rollback_path = None;
}
let head_publish = os::StageTimer::new(native_timing, "pool_meta_native_head_publish");
let commit_result = if should_fail_commit_rename(dst_path) {
Err(DiskError::other("test fail during metadata commit rename"))
} else {
@@ -1354,6 +1701,7 @@ impl LocalDisk {
)
.await
};
drop(head_publish);
if let Err(err) = commit_result {
if let Some(backup_path) = local_rollback_path.as_ref().map(StagedRollbackBackup::path) {
let _ = remove_file_if_exists(backup_path);
@@ -1374,6 +1722,7 @@ impl LocalDisk {
if let Some(admission) = file_sync_admission.as_ref()
&& let Some(dst_parent) = dst_file_path.parent()
{
let _head_dir = os::StageTimer::new(native_timing, "pool_meta_native_head_dir_sync");
let fsync_started = rustfs_io_metrics::put_stage_timer();
if let Err(err) = os::fsync_commit_directory(
dst_parent,
@@ -1454,6 +1803,12 @@ impl LocalDisk {
return Err(DiskError::from(err));
}
#[cfg(unix)]
self.publish_pool_meta_durable_head(receipt_candidate, dst_file_path.clone(), commit.mutation_lease.clone())
.await;
#[cfg(not(unix))]
let _ = receipt_candidate;
// The commit no longer has a rollback path. Release the Windows
// object identity guard before best-effort staging cleanup.
#[cfg(windows)]
@@ -1464,9 +1819,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);
}
@@ -1485,7 +1838,12 @@ impl LocalDisk {
self.io_backend.invalidate_cached_fd(dst_volume, part_path).await;
}
#[cfg(unix)]
if let Some(receipt_mutation) = receipt_mutation.as_mut() {
receipt_mutation.complete();
}
Ok(RenameDataResp {
multipart_source: None,
old_data_dir: cleanup_data_dir,
rollback_data_dir,
cleanup_data_dir,
+514 -92
View File
@@ -32,6 +32,7 @@ impl DiscoverySession {
}
}
let mut transactions = BTreeSet::new();
let mut seen_candidates = HashSet::new();
let mut visited = 0usize;
while !self.frames.is_empty()
&& transactions.len() < usize::try_from(limit).map_err(|_| std::io::Error::from(ErrorKind::InvalidInput))?
@@ -91,6 +92,9 @@ impl DiscoverySession {
.find_map(|prefix| delete_transaction_marker_id(&name, prefix))
};
if let Some(transaction) = transaction {
if !seen_candidates.insert((object.to_owned(), transaction)) {
continue;
}
let physical = match self.object.as_ref() {
Some(_) => self.root.clone(),
None => self.root.join(object),
@@ -101,15 +105,20 @@ impl DiscoverySession {
continue;
}
let proof_root = physical.join(transaction.to_string());
if self.object.is_some() {
regular_file_exists(&proof_root.join(PREPARED)).map_err(std::io::Error::from)?;
regular_file_exists(&proof_root.join(DELETE_QUORUM_COMMITTED_FILE)).map_err(std::io::Error::from)?;
transactions.insert((object.to_owned(), transaction));
continue;
}
let prepared = regular_bytes(&proof_root.join(PREPARED)).map_err(std::io::Error::from)?;
let receipt =
regular_bytes(&proof_root.join(DELETE_QUORUM_COMMITTED_FILE)).map_err(std::io::Error::from)?;
// Only a fully bound journal can name a logical
// key. Other records remain discoverable by their
// physical key for canonical inspection to reject.
let record = match prepared {
Some(bytes) => serde_json::from_slice::<DeleteRecoveryJournal>(&bytes).ok(),
None => receipt
None => regular_bytes(&proof_root.join(DELETE_QUORUM_COMMITTED_FILE))
.map_err(std::io::Error::from)?
.filter(|bytes| !bytes.is_empty())
.and_then(|bytes| serde_json::from_slice::<CommitReceipt>(&bytes).ok())
.map(|receipt| receipt.journal),
@@ -280,11 +289,19 @@ fn regular_metadata_bytes(path: &Path) -> Result<Option<Vec<u8>>> {
}
}
fn regular_bytes(path: &Path) -> Result<Option<Vec<u8>>> {
if std::fs::symlink_metadata(path).is_ok_and(|meta| meta.len() > MAX_METADATA) {
return Err(DiskError::FileCorrupt);
fn regular_file_exists(path: &Path) -> Result<bool> {
match std::fs::symlink_metadata(path) {
Ok(meta) if meta.is_file() && meta.len() <= MAX_METADATA => Ok(true),
Ok(_) => Err(DiskError::FileCorrupt),
Err(err) if err.kind() == ErrorKind::NotFound => Ok(false),
Err(err) => Err(to_file_error(err).into()),
}
regular_metadata_bytes(path)
}
fn regular_bytes(path: &Path) -> Result<Option<Vec<u8>>> {
regular_file_exists(path)?
.then(|| std::fs::read(path).map_err(|err| DiskError::from(to_file_error(err))))
.transpose()
}
/// A write/heal backup is not a delete transaction when the live metadata
@@ -299,7 +316,7 @@ fn live_write_backup(root: &Path, transaction: Uuid) -> Result<bool> {
DELETE_MARKER_ROLLBACK_FILE,
DELETE_QUORUM_COMMITTED_FILE,
] {
if regular_bytes(&rollback.join(name))?.is_some() {
if regular_file_exists(&rollback.join(name))? {
return Ok(false);
}
}
@@ -309,7 +326,7 @@ fn live_write_backup(root: &Path, transaction: Uuid) -> Result<bool> {
RECOVERY_DELETE_DATA_DIR_MARKER_PREFIX,
] {
let name = format!("{prefix}{transaction}");
if regular_bytes(&root.join(&name))?.is_some() || regular_bytes(&rollback.join(name))?.is_some() {
if regular_file_exists(&root.join(&name))? || regular_file_exists(&rollback.join(name))? {
return Ok(false);
}
}
@@ -530,13 +547,11 @@ struct ExplicitDeletePlan {
proof_files: Vec<PathBuf>,
}
fn explicit_delete_plan(
root: &Path,
transaction: Uuid,
fn explicit_delete_referenced_data_dirs(
original: Option<&FileMeta>,
journal: Option<&DeleteRecoveryJournal>,
current_missing: bool,
) -> Result<Option<ExplicitDeletePlan>> {
) -> Result<Option<HashSet<Uuid>>> {
if !current_missing
|| journal
.is_some_and(|journal| journal.target_digest.is_some() || (journal.original_digest.is_some() && original.is_none()))
@@ -562,6 +577,19 @@ fn explicit_delete_plan(
}
referenced.extend(metadata.get_data_dirs()?.into_iter().flatten().filter(|id| !id.is_nil()));
}
Ok(Some(referenced))
}
fn explicit_delete_plan(
root: &Path,
transaction: Uuid,
original: Option<&FileMeta>,
journal: Option<&DeleteRecoveryJournal>,
current_missing: bool,
) -> Result<Option<ExplicitDeletePlan>> {
let Some(referenced) = explicit_delete_referenced_data_dirs(original, journal, current_missing)? else {
return Ok(None);
};
let rollback = root.join(transaction.to_string());
let marker_names = [
DELETE_DATA_DIR_MARKER_PREFIX,
@@ -669,7 +697,12 @@ fn canonical_uuid(name: &str) -> Option<Uuid> {
Uuid::parse_str(name).ok().filter(|id| name == id.hyphenated().to_string())
}
fn inspection(root: &Path, volume: &str, object: &str, transaction: Uuid) -> Result<DeleteRecoveryInspection> {
pub(super) fn inspection_with_markers(
root: &Path,
volume: &str,
object: &str,
transaction: Uuid,
) -> Result<(DeleteRecoveryInspection, bool)> {
let rollback = root.join(transaction.to_string());
let before = regular_metadata_bytes(&rollback.join(STORAGE_FORMAT_FILE_BACKUP))?;
let current = regular_metadata_bytes(&root.join(STORAGE_FORMAT_FILE))?;
@@ -739,6 +772,8 @@ fn inspection(root: &Path, volume: &str, object: &str, transaction: Uuid) -> Res
}
fingerprint.update([u8::from(committed), u8::from(finished)]);
let mut any_marker = false;
let mut has_other_uuid_dir = false;
let mut has_staged_dir = false;
let mut descriptions = Vec::new();
let entries = match std::fs::read_dir(root) {
Ok(entries) => Some(entries),
@@ -762,6 +797,7 @@ fn inspection(root: &Path, volume: &str, object: &str, transaction: Uuid) -> Res
}
descriptions.push((name.clone(), kind.is_dir(), entry.metadata().map_err(to_file_error)?.len()));
if kind.is_dir() && id.is_some_and(|id| !id.is_nil()) {
has_other_uuid_dir |= id != Some(transaction);
for child in std::fs::read_dir(entry.path()).map_err(to_file_error)? {
let child = child.map_err(to_file_error)?;
let kind = child.file_type().map_err(to_file_error)?;
@@ -807,6 +843,7 @@ fn inspection(root: &Path, volume: &str, object: &str, transaction: Uuid) -> Res
return Err(DiskError::FileCorrupt);
}
if name == transaction.to_string() && kind.is_dir() {
has_staged_dir = true;
for file in std::fs::read_dir(child.path()).map_err(to_file_error)? {
let file = file.map_err(to_file_error)?;
let kind = file.file_type().map_err(to_file_error)?;
@@ -924,9 +961,18 @@ fn inspection(root: &Path, volume: &str, object: &str, transaction: Uuid) -> Res
} else {
DeleteRecoveryState::Absent
};
let explicit_delete_safe =
explicit_delete_plan(root, transaction, before_meta.as_ref(), journal.as_ref(), current.is_none())?.is_some();
Ok(DeleteRecoveryInspection {
let has_physical_data_dirs_or_markers = any_marker || has_other_uuid_dir || has_staged_dir;
let explicit_delete_safe = match state {
DeleteRecoveryState::NewPrepared if !has_physical_data_dirs_or_markers => {
explicit_delete_referenced_data_dirs(before_meta.as_ref(), journal.as_ref(), current.is_none())?
.is_some_and(|referenced| !referenced.contains(&transaction))
}
DeleteRecoveryState::LegacyAmbiguous | DeleteRecoveryState::NewPrepared => {
explicit_delete_plan(root, transaction, before_meta.as_ref(), journal.as_ref(), current.is_none())?.is_some()
}
_ => false,
};
let inspection = DeleteRecoveryInspection {
protocol_version: DELETE_RECOVERY_PROTOCOL_VERSION,
id: DeleteTransactionId {
object: object.to_owned(),
@@ -950,10 +996,22 @@ fn inspection(root: &Path, volume: &str, object: &str, transaction: Uuid) -> Res
.transpose()?
.unwrap_or_default(),
fingerprint: fingerprint.finalize().into(),
})
};
Ok((inspection, has_physical_data_dirs_or_markers))
}
impl LocalDisk {
// Native MPU source closure has its own conditional protocol. Generic
// object recovery must never restore its head or acquire SourceRW under
// an already-owned object metadata lease.
fn reject_multipart_delete_recovery_mutation(&self, volume: &str, object: &str) -> Result<()> {
multipart_staging::reject_noncanonical_scope(&self.root, self.io_root(), volume, object)?;
if volume == RUSTFS_META_MULTIPART_BUCKET {
return Err(DiskError::ConditionalDeleteConflict);
}
Ok(())
}
async fn delete_recovery_predecessors(
&self,
volume: &str,
@@ -1196,12 +1254,25 @@ impl LocalDisk {
transaction: Uuid,
owner: Option<Arc<dyn Send + Sync>>,
) -> Result<DeleteRecoveryInspection> {
Ok(self
.delete_recovery_inspect_owned_with_markers(volume, object, transaction, owner)
.await?
.0)
}
async fn delete_recovery_inspect_owned_with_markers(
&self,
volume: &str,
object: &str,
transaction: Uuid,
owner: Option<Arc<dyn Send + Sync>>,
) -> Result<(DeleteRecoveryInspection, bool)> {
let root = self.io_get_object_path(volume, object)?;
self.io_get_object_path(volume, &format!("{object}/{transaction}"))?;
let volume = volume.to_owned();
let object = object.to_owned();
let lease = os::acquire_namespace_mutation_lease_with_owner(&root, owner).await;
os::run_blocking_namespace_operation(lease, move || Ok(inspection(&root, &volume, &object, transaction)))
os::run_blocking_namespace_operation(lease, move || Ok(inspection_with_markers(&root, &volume, &object, transaction)))
.await
.map_err(to_file_error)?
}
@@ -1219,15 +1290,16 @@ impl LocalDisk {
let journal = journal_bytes(journal)?;
self.wait_for_startup_cleanup().await;
let temporary = self.io_get_bucket_path(RUSTFS_META_TMP_BUCKET)?;
let root = self.io_get_object_path(volume, object)?;
let (bucket, root) = self.io_get_bucket_and_object_path(volume, object)?;
let destination = root.join(transaction.to_string());
let destination_was_absent = original.is_none();
let original = original.map(<[u8]>::to_vec);
let sync = effective_durability(volume).syncs_commit_metadata();
#[cfg(test)]
let record_destination = destination.clone();
let staging_root = temporary.clone();
let staging_root = temporary;
let lease = os::acquire_namespace_mutation_lease_with_owner(&destination, owner.clone()).await;
let (staged, target) = os::run_blocking_namespace_operation(lease, move || {
let (staged, target) = os::run_blocking_namespace_operation_with_sync(lease, sync, move || {
std::fs::create_dir_all(&staging_root)?;
let staged = Arc::new(
tempfile::Builder::new()
@@ -1248,8 +1320,8 @@ impl LocalDisk {
run_owned_file_write_before_open(&record_destination.join(name));
let mut file = std::fs::File::create(staged.path().join(name))?;
std::io::Write::write_all(&mut file, bytes)?;
if sync {
file.sync_all()?;
if sync && !bytes.is_empty() {
file.sync_data()?;
}
}
if sync {
@@ -1280,25 +1352,26 @@ impl LocalDisk {
// The directory owner accompanies the blocking rename, so cancelling
// its waiter cannot delete staged files while activation is running.
let publication_owner: Option<Arc<dyn Send + Sync>> = Some(Arc::new((owner.clone(), staged.clone())));
os::rename_all_with_owner(
staged.path(),
&destination,
&self.io_get_bucket_path(volume)?,
&self.publication_root,
publication_owner,
)
.await?;
os::rename_all_with_owner(staged.path(), &destination, &bucket, &self.publication_root, publication_owner).await?;
if sync {
let bucket = self.io_get_bucket_path(volume)?;
let mut dirs_to_sync = Vec::new();
let mut path = root.as_path();
loop {
os::fsync_dir_with_owner(path, owner.clone()).await.map_err(to_file_error)?;
if path == bucket {
dirs_to_sync.push(path.to_path_buf());
if !destination_was_absent || path == bucket {
break;
}
path = path.parent().ok_or(DiskError::InvalidPath)?;
}
os::fsync_dir_with_owner(&temporary, owner.clone())
let sync_owner = owner.clone();
self.blocking_queue
.run_fsync_cancellable(move |_| {
let _owner = sync_owner;
for dir in dirs_to_sync {
os::fsync_dir_std(&dir)?;
}
Ok(())
})
.await
.map_err(to_file_error)?;
}
@@ -1315,6 +1388,8 @@ impl LocalDisk {
authority: Option<&DeleteRecoveryJournal>,
context: DeletePreparationContext,
) -> Result<Option<Vec<u8>>> {
self.reject_multipart_delete_recovery_mutation(volume, object)?;
let DeletePreparationContext {
transaction,
bucket_incarnation: incarnation,
@@ -1406,6 +1481,8 @@ impl LocalDisk {
action: DeleteRecoveryAction,
namespace_owner: Option<Arc<dyn Send + Sync>>,
) -> Result<DeleteRecoveryInspection> {
self.reject_multipart_delete_recovery_mutation(volume, object)?;
if !safe_path(object, false) || transaction.is_nil() {
return Err(DiskError::InvalidPath);
}
@@ -1445,8 +1522,10 @@ impl LocalDisk {
action: DeleteRecoveryAction,
owner: Option<Arc<dyn Send + Sync>>,
) -> Result<DeleteRecoveryInspection> {
let mut observed = self
.delete_recovery_inspect_owned(volume, object, transaction, owner.clone())
self.reject_multipart_delete_recovery_mutation(volume, object)?;
let (mut observed, mut has_data_dir_work) = self
.delete_recovery_inspect_owned_with_markers(volume, object, transaction, owner.clone())
.await?;
if observed.fingerprint != expected_fingerprint {
return Err(DiskError::OutdatedXLMeta);
@@ -1572,8 +1651,8 @@ impl LocalDisk {
},
)
.await?;
observed = self
.delete_recovery_inspect_owned(volume, object, transaction, owner.clone())
(observed, has_data_dir_work) = self
.delete_recovery_inspect_owned_with_markers(volume, object, transaction, owner.clone())
.await?;
} else {
return Ok(observed);
@@ -1680,17 +1759,22 @@ impl LocalDisk {
journal: journal.clone(),
finished: false,
};
write_delete_rollback_file(
let inspect_after = (!cfg!(feature = "e2e-test-hooks")).then_some(object);
let inspected = write_delete_rollback_files(
self.io_root(),
volume,
&root,
transaction,
DELETE_QUORUM_COMMITTED_FILE,
&serde_json::to_vec(&receipt)?,
&[],
&[(DELETE_QUORUM_COMMITTED_FILE, &serde_json::to_vec(&receipt)?)],
inspect_after,
owner.clone(),
)
.await?;
self.delete_recovery_barrier(volume, object, transaction, "DECISION").await?;
if let Some(inspected) = inspected {
return Ok(inspected);
}
}
DeleteRecoveryAction::FinishCommit => {
if !matches!(observed.state, DeleteRecoveryState::QuorumCommitted | DeleteRecoveryState::CommitFinished)
@@ -1701,7 +1785,7 @@ impl LocalDisk {
if observed.state == DeleteRecoveryState::CommitFinished {
return Ok(observed);
}
for dir in &journal.data_dirs {
for dir in journal.data_dirs.iter().filter(|_| has_data_dir_work) {
self.reserve_version_delete_with_namespace_owner(volume, object, *dir, transaction, owner.clone())
.await?;
}
@@ -1731,27 +1815,26 @@ impl LocalDisk {
}
}
}
self.commit_reserved_version_delete_with_namespace_owner(volume, object, transaction, owner.clone())
.await?;
for name in [STORAGE_FORMAT_FILE_BACKUP, DELETE_MARKER_ROLLBACK_FILE] {
match os::remove_file_with_owner(rollback.join(name), owner.clone()).await {
Ok(()) => {}
Err(err) if err.kind() == ErrorKind::NotFound => {}
Err(err) => return Err(to_file_error(err).into()),
if has_data_dir_work {
self.commit_reserved_version_delete_with_namespace_owner(volume, object, transaction, owner.clone())
.await?;
os::remove_files_and_fsync_with_owner(
&rollback,
&[STORAGE_FORMAT_FILE_BACKUP, DELETE_MARKER_ROLLBACK_FILE],
effective_durability(volume).syncs_commit_metadata(),
None,
owner.clone(),
)
.await
.map_err(to_file_error)?;
let (_, complete) = self
.finish_version_delete(volume, object, transaction, true, owner.clone(), None)
.await?;
if !complete {
return Err(DiskError::DiskOngoingReq);
}
}
if effective_durability(volume).syncs_commit_metadata() {
os::fsync_dir_with_owner(&rollback, owner.clone())
.await
.map_err(to_file_error)?;
}
let (_, complete) = self
.finish_version_delete(volume, object, transaction, true, owner.clone())
.await?;
if !complete {
return Err(DiskError::DiskOngoingReq);
}
for dir in &journal.data_dirs {
for dir in journal.data_dirs.iter().filter(|_| has_data_dir_work) {
let staged = rollback.join(dir.to_string());
match fs::symlink_metadata(&staged).await {
Ok(metadata) if metadata.is_dir() => {
@@ -1798,52 +1881,97 @@ impl LocalDisk {
Err(err) => return Err(to_file_error(err).into()),
}
}
write_delete_rollback_file(self.io_root(), volume, &root, transaction, FINISHED, &[], owner.clone()).await?;
let receipt = CommitReceipt {
journal: journal.clone(),
finished: true,
};
write_delete_rollback_file(
let receipt_bytes = serde_json::to_vec(&receipt)?;
let remove_before_publish: &[&'static str] = if has_data_dir_work {
&[]
} else {
&[STORAGE_FORMAT_FILE_BACKUP, DELETE_MARKER_ROLLBACK_FILE]
};
if let Some(inspected) = write_delete_rollback_files(
self.io_root(),
volume,
&root,
transaction,
DELETE_QUORUM_COMMITTED_FILE,
&serde_json::to_vec(&receipt)?,
remove_before_publish,
&[(FINISHED, &[]), (DELETE_QUORUM_COMMITTED_FILE, &receipt_bytes)],
Some(object),
owner.clone(),
)
.await?;
.await?
{
return Ok(inspected);
}
}
DeleteRecoveryAction::FinalizeCommit => {
if observed.state != DeleteRecoveryState::CommitFinished {
return Err(DiskError::DiskOngoingReq);
}
let bucket_path = self.io_get_bucket_path(volume)?;
let mut file_leases = Vec::with_capacity(3);
for name in [TARGET, PREPARED, FINISHED] {
match os::remove_file_with_owner(rollback.join(name), owner.clone()).await {
Ok(()) => {}
Err(err) if err.kind() == ErrorKind::NotFound => {}
Err(err) => return Err(to_file_error(err).into()),
let path = rollback.join(name);
let lease = os::acquire_namespace_mutation_lease_with_owner(&path, owner.clone()).await;
file_leases.push((path, lease));
}
let receipt_path = rollback.join(DELETE_QUORUM_COMMITTED_FILE);
let receipt_lease = os::acquire_namespace_mutation_lease_with_owner(&receipt_path, owner.clone()).await;
let prune_paths: Vec<_> = rollback
.ancestors()
.take_while(|dir| *dir != bucket_path.as_path() && dir.starts_with(&bucket_path))
.map(Path::to_path_buf)
.collect();
let mut prune_dirs = Vec::with_capacity(prune_paths.len());
for dir in prune_paths {
let lease = os::acquire_namespace_mutation_lease_with_owner(&dir, owner.clone()).await;
prune_dirs.push((dir, lease));
}
let sync = effective_durability(volume).syncs_commit_metadata();
let (rollback_buf, root_buf, volume_str, object_str) =
(rollback.clone(), root.clone(), volume.to_owned(), object.to_owned());
let publication_root = self.publication_root.clone();
return os::run_blocking_namespace_operation_with_sync(receipt_lease, sync, move || {
let _publication_root = publication_root;
for (path, _) in &file_leases {
#[cfg(all(any(test, feature = "test-util"), not(windows)))]
os::prepared_publication_test_hooks::run(os::prepared_publication_test_hooks::Stage::Remove, path);
match std::fs::remove_file(path) {
Ok(()) => {}
Err(err) if err.kind() == ErrorKind::NotFound => {}
Err(err) => return Err(err),
}
}
}
if effective_durability(volume).syncs_commit_metadata() {
os::fsync_dir_with_owner(&rollback, owner.clone())
.await
.map_err(to_file_error)?;
}
os::remove_file_with_owner(rollback.join(DELETE_QUORUM_COMMITTED_FILE), owner.clone())
.await
.map_err(to_file_error)?;
self.delete_unleased_with_namespace_owner(
volume,
&format!("{object}/{transaction}"),
&DeleteOptions {
immediate: true,
..Default::default()
},
owner.clone(),
None,
)
.await?;
if sync {
os::fsync_dir_std(&rollback_buf)?;
}
#[cfg(all(any(test, feature = "test-util"), not(windows)))]
os::prepared_publication_test_hooks::run(os::prepared_publication_test_hooks::Stage::Remove, &receipt_path);
std::fs::remove_file(&receipt_path)?;
for (dir_path, _) in &prune_dirs {
#[cfg(all(any(test, feature = "test-util"), not(windows)))]
os::prepared_publication_test_hooks::run(os::prepared_publication_test_hooks::Stage::Remove, dir_path);
if let Err(err) = std::fs::remove_dir(dir_path) {
if is_dir_not_empty_error(&err) {
break;
}
if !is_benign_object_rmdir_error(&err) {
return Err(std::io::Error::other(FileAccessDeniedWithContext {
path: dir_path.clone(),
source: err,
}));
}
}
}
let inspected =
inspection_with_markers(&root_buf, &volume_str, &object_str, transaction).map(|(state, _)| state);
drop((file_leases, prune_dirs));
Ok(inspected)
})
.await
.map_err(to_file_error)?;
}
DeleteRecoveryAction::Rollback => {
if observed.state != DeleteRecoveryState::NewPrepared || !observed.rollback_data_complete {
@@ -2605,6 +2733,149 @@ mod tests {
);
}
#[tokio::test]
async fn delete_recovery_known_heal_backup_inspection_preserves_unproven_owners_after_reopen() {
for change in [
"unchanged",
"generation",
"tier-object",
"tier-version",
"missing-free-owner",
"replaced-backup",
] {
let (_temp, disk, mut info, _) = fixture().await;
info.metadata.insert("etag".to_owned(), "same-visible-etag".to_owned());
info.set_object_transaction_epoch(Uuid::new_v4());
let free_id = Uuid::new_v4();
info.set_tier_free_version_id(&free_id.to_string());
let mut remote = test_file_info("object", Uuid::nil(), Some(Uuid::new_v4()), None);
remote.mod_time = info.mod_time;
remote.transition_status = rustfs_filemeta::TRANSITION_COMPLETE.to_owned();
remote.transition_tier = "WARM".to_owned();
remote.transitioned_objname = "remote/original".to_owned();
remote.transition_version_state = rustfs_filemeta::TransitionVersionState::Exact;
remote.transition_version = Some("original-provider-version".to_owned());
rustfs_utils::http::metadata_compat::insert_str(
&mut remote.metadata,
rustfs_utils::http::metadata_compat::SUFFIX_TRANSITION_TIER_DESTINATION_ID,
"ab".repeat(32),
);
let metadata = |remote: FileInfo, current: FileInfo| {
let mut meta = FileMeta::new();
meta.add_version(remote).expect("seed transitioned predecessor");
meta.add_version(current)
.expect("commit local replacement and its free-version owner");
meta
};
let original = metadata(remote.clone(), info.clone());
let (_, free) = original.find_version(Some(free_id)).expect("original free-version owner");
assert!(free.free_version(), "fixture must retain a separate remote cleanup owner");
let backup_bytes = original.marshal_msg().expect("encode historical backup");
let transaction = inline_metadata_rollback_dir(Uuid::nil(), &original);
let root = disk.io_get_object_path("recovery", "object").expect("object path");
let backup_path = root.join(transaction.to_string()).join(STORAGE_FORMAT_FILE_BACKUP);
fs::create_dir_all(backup_path.parent().expect("backup parent"))
.await
.expect("create backup directory");
fs::write(&backup_path, &backup_bytes)
.await
.expect("retain historical backup");
let mut current = info.clone();
match change {
"generation" => current.set_object_transaction_epoch(Uuid::new_v4()),
"tier-object" => remote.transitioned_objname = "remote/successor".to_owned(),
"tier-version" => remote.transition_version = Some("successor-provider-version".to_owned()),
_ => {}
}
let mut current_meta = metadata(remote, current);
if change == "missing-free-owner" {
current_meta
.versions
.retain(|version| version.header.version_id != Some(free_id));
} else if change == "replaced-backup" {
let replacement = root.join("replacement-backup");
fs::write(&replacement, &backup_bytes).await.expect("write replacement inode");
fs::rename(replacement, &backup_path)
.await
.expect("replace backup without changing its bytes");
}
let current_bytes = current_meta.marshal_msg().expect("encode current metadata");
fs::write(root.join(STORAGE_FORMAT_FILE), &current_bytes)
.await
.expect("publish readable current object");
let visible = disk
.read_version(
"",
"recovery",
"object",
"",
&ReadOptions {
read_data: true,
..Default::default()
},
)
.await
.expect("current object remains physically readable");
assert_eq!(visible.data, info.data, "{change}: current payload");
assert_eq!(visible.mod_time, info.mod_time, "{change}: unchanged modification time");
assert_eq!(visible.metadata.get("etag"), info.metadata.get("etag"), "{change}: unchanged ETag");
let before = disk
.inspect_delete_transaction("recovery", "object", transaction)
.await
.expect("query known backup without inferring commit authority");
let endpoint = disk.endpoint();
drop(disk);
let reopened = LocalDisk::new(&endpoint, false).await.expect("reopen original disk endpoint");
// Linux I/O paths belong to the open disk's mount descriptor.
let root = reopened
.io_get_object_path("recovery", "object")
.expect("reopened object path");
let backup_path = root.join(transaction.to_string()).join(STORAGE_FORMAT_FILE_BACKUP);
let after = reopened
.inspect_delete_transaction("recovery", "object", transaction)
.await
.expect("known historical backup remains queryable after reopen");
assert_eq!(after, before, "{change}: inspection must preserve the complete evidence snapshot");
assert!(after.journal.is_none(), "{change}: no historical success or delete authority exists");
assert_eq!(after.backup_digest, Some(digest(&backup_bytes)), "{change}: retained C-class evidence");
assert!(!after.explicit_delete_safe, "{change}: current data forbids legacy discard");
let expected_state = if matches!(change, "unchanged" | "replaced-backup") {
DeleteRecoveryState::Absent
} else {
DeleteRecoveryState::LegacyAmbiguous
};
assert_eq!(
after.state, expected_state,
"{change}: delete-owner semantics remain separate from C classification"
);
let page = reopened
.discover_delete_transactions("recovery", Some("object"), None, 128)
.await
.expect("discover only delete-recovery work");
assert_eq!(
page.transactions.iter().any(|id| id.transaction == transaction),
expected_state == DeleteRecoveryState::LegacyAmbiguous,
"{change}: a live backup must not become a delete blocker"
);
assert_eq!(fs::read(&backup_path).await.expect("retained backup bytes"), backup_bytes, "{change}");
assert_eq!(
fs::read(root.join(STORAGE_FORMAT_FILE))
.await
.expect("retained current bytes"),
current_bytes,
"{change}"
);
let backup = FileMeta::load(&backup_bytes).expect("decode retained owner evidence");
assert_eq!(
backup.find_version(Some(free_id)).expect("retained free-version owner").1,
free,
"{change}: ordinary-object readability cannot release remote ownership"
);
}
}
#[tokio::test]
async fn delete_recovery_live_external_backup_rejects_partial_version_delete() {
let (_temp, disk, mut info, _) = fixture().await;
@@ -2744,6 +3015,152 @@ mod tests {
assert_eq!(disk.read_all("recovery", "object/xl.meta").await.unwrap().as_ref(), original);
}
#[cfg(target_os = "linux")]
#[tokio::test]
async fn delete_recovery_cancelled_finalize_pins_root_after_last_disk_owner_drops() {
use crate::disk::os::prepared_publication_test_hooks as hooks;
use futures::FutureExt;
struct ReleaseOnDrop(Option<std::sync::mpsc::Sender<()>>);
impl Drop for ReleaseOnDrop {
fn drop(&mut self) {
if let Some(release) = self.0.take() {
let _ = release.send(());
}
}
}
let (temp, native, _info, _original) = fixture().await;
let root_file = Arc::downgrade(native.publication_root.directory());
assert!(Arc::ptr_eq(native.publication_root.directory(), &native.mount_lease));
// The constructor's idle GC task also owns this FD. Drain that existing
// task so it cannot mask a missing pin in the Finalize callback.
let exit = native.exit_signal.as_ref().expect("fixture cleanup lifecycle");
exit.send(()).expect("stop the existing idle cleanup task");
tokio::time::timeout(Duration::from_secs(5), async {
while exit.receiver_count() != 0 || root_file.strong_count() != 2 {
tokio::task::yield_now().await;
}
})
.await
.expect("only LocalDisk's publication and mount fields may retain the initial root FD");
let transaction = Uuid::new_v4();
let applied = apply(&native, transaction).await;
let committed = native
.settle_delete_transaction(
"recovery",
"object",
transaction,
applied.fingerprint,
DeleteRecoveryAction::PublishCommit,
)
.await
.expect("publish actual delete decision");
let finished = native
.settle_delete_transaction(
"recovery",
"object",
transaction,
committed.fingerprint,
DeleteRecoveryAction::FinishCommit,
)
.await
.expect("finish actual committed deletion");
assert_eq!(finished.state, DeleteRecoveryState::CommitFinished);
let physical_object = temp.path().join("recovery/object");
let physical_rollback = physical_object.join(transaction.to_string());
assert!(physical_rollback.join(PREPARED).is_file());
assert!(physical_rollback.join(DELETE_QUORUM_COMMITTED_FILE).is_file());
let io_root = native.io_root().to_path_buf();
assert!(io_root.starts_with("/proc/self/fd"), "exercise the real borrowed Linux FD path");
let canonical_root = std::fs::canonicalize(temp.path()).expect("actual root identity");
let fd_alias = io_root.components().collect::<PathBuf>();
let io_rollback = native
.io_get_object_path("recovery", &format!("object/{transaction}"))
.expect("actual IO rollback path");
let receipt_path = io_rollback.join(DELETE_QUORUM_COMMITTED_FILE);
let (entered, reached) = tokio::sync::oneshot::channel();
let (release, resumed) = std::sync::mpsc::channel::<()>();
let release = ReleaseOnDrop(Some(release));
let hook = hooks::install_at(hooks::Stage::Remove, &io_rollback.join(PREPARED), move || {
let _ = entered.send(());
let _ = resumed.recv();
});
let mut native = Some(Arc::new(native));
let weak = Arc::downgrade(native.as_ref().expect("fixture disk owner"));
let finalizing = native.as_ref().expect("fixture disk owner").clone();
let mut task = Some(tokio::spawn(async move {
finalizing
.settle_delete_transaction(
"recovery",
"object",
transaction,
finished.fingerprint,
DeleteRecoveryAction::FinalizeCommit,
)
.await
}));
let observations = std::panic::AssertUnwindSafe(async {
tokio::time::timeout(Duration::from_secs(10), reached)
.await
.expect("actual Finalize remove callback must pause")
.expect("actual physical Remove hook");
let cancelling = task.take().expect("live finalize waiter");
cancelling.abort();
assert!(cancelling.await.expect_err("cancel the real waiter").is_cancelled());
drop(native.take());
assert!(weak.upgrade().is_none(), "no test or cancelled future may retain a LocalDisk");
assert_eq!(
root_file.strong_count(),
1,
"only the actual callback may retain the root FD after disk retirement"
);
assert_eq!(
std::fs::read_link(&fd_alias).expect("the physical callback must still own its root FD"),
canonical_root,
"the borrowed FD alias must keep the same actual disk identity"
);
assert!(
physical_rollback.join(PREPARED).is_file(),
"paused callback has not removed the actual prepared record"
);
assert!(
physical_rollback.join(DELETE_QUORUM_COMMITTED_FILE).is_file(),
"the actual final witness still exists before resume"
);
})
.catch_unwind()
.await;
if let Some(task) = task.take() {
task.abort();
let _ = task.await;
}
drop(native.take());
drop(release);
drop(hook);
// This path-only lease does not hold a root handle. Acquiring it proves
// the actual callback, including its inline inspection, has returned.
let drained = tokio::time::timeout(
Duration::from_secs(5),
os::acquire_namespace_mutation_lease_with_owner(&receipt_path, None),
)
.await
.map(drop);
if let Err(panic) = observations {
std::panic::resume_unwind(panic);
}
drained.expect("cancelled physical Finalize callback must finish");
for name in [TARGET, PREPARED, FINISHED, DELETE_QUORUM_COMMITTED_FILE] {
assert!(
!physical_rollback.join(name).exists(),
"actual original-volume proof must be reclaimed: {name}"
);
}
assert!(!physical_rollback.exists(), "the real rollback directory is pruned after proof removal");
assert!(!physical_object.exists(), "completed deletion leaves no original-volume object residue");
assert!(temp.path().join("recovery").is_dir(), "finalization must preserve its original volume");
}
#[cfg(unix)]
#[tokio::test]
async fn delete_recovery_activation_is_complete_before_metadata_mutation_even_after_cancel() {
@@ -5233,3 +5650,8 @@ mod tests {
);
}
}
#[cfg(test)]
mod multipart_scope_tests {
include!("multipart_delete_recovery_scope_tests.rs");
}
@@ -0,0 +1,93 @@
// Copyright 2026 RustFS Team
// SPDX-License-Identifier: Apache-2.0
use super::*;
use crate::disk::endpoint::Endpoint;
use futures::FutureExt;
#[inline(never)]
fn heap_scope<F: std::future::Future>(build: impl FnOnce() -> F) -> std::pin::Pin<Box<F>> {
Box::pin(build())
}
#[tokio::test]
async fn generic_delete_recovery_cannot_restore_or_prepare_any_physical_multipart_scope() {
let temporary = tempfile::tempdir().expect("recovery scope disk");
let endpoint = Endpoint::try_from(temporary.path().to_string_lossy().as_ref()).expect("local endpoint");
let disk = heap_scope(|| LocalDisk::new(&endpoint, false)).await.expect("local disk");
let hash = "a".repeat(64);
let upload = Uuid::new_v4().to_string();
let object = format!("{hash}/{upload}");
let source = disk
.io_get_object_path(RUSTFS_META_MULTIPART_BUCKET, &object)
.expect("actual source root");
std::fs::create_dir_all(&source).expect("existing source root");
// Scope rejection precedes any attempt to interpret or replace these bytes.
let head = source.join(STORAGE_FORMAT_FILE);
std::fs::write(&head, b"existing source head must not be read or replaced").expect("existing head bytes");
let original = std::fs::read(&head).expect("original physical bytes");
let logical = disk
.get_object_path(RUSTFS_META_MULTIPART_BUCKET, &object)
.expect("configured metadata key");
let owner: Option<Arc<dyn Send + Sync>> = Some(os::acquire_metadata_mutation_lease(&logical, None).await);
let transaction = Uuid::new_v4();
let aliases = [
(RUSTFS_META_MULTIPART_BUCKET.to_owned(), object.clone()),
(format!("{RUSTFS_META_MULTIPART_BUCKET}/."), object.clone()),
(format!("{RUSTFS_META_MULTIPART_BUCKET}/{hash}"), upload),
(crate::disk::RUSTFS_META_BUCKET.to_owned(), format!("multipart/{object}")),
];
for (volume, object) in aliases {
let result = heap_scope(|| {
disk.delete_recovery_settle(&volume, &object, transaction, [0; 32], DeleteRecoveryAction::Rollback, owner.clone())
})
.now_or_never()
.expect("scope is rejected before metadata lock acquisition");
assert_eq!(
result.expect_err("generic recovery cannot mutate MPI"),
DiskError::ConditionalDeleteConflict
);
let result = heap_scope(|| {
disk.delete_recovery_settle_owned(
&volume,
&object,
transaction,
[0; 32],
DeleteRecoveryAction::Rollback,
owner.clone(),
)
})
.now_or_never()
.expect("owned rollback is rejected before I/O");
assert_eq!(
result.expect_err("owned recovery cannot restore MPI head"),
DiskError::ConditionalDeleteConflict
);
let result = heap_scope(|| {
disk.prepare_delete_recovery(
&volume,
&object,
Some(&original),
&[],
None,
DeletePreparationContext {
transaction,
bucket_incarnation: None,
namespace_owner: owner.clone(),
},
)
})
.now_or_never()
.expect("preparation is rejected before decoding or writing metadata");
assert_eq!(
result.expect_err("generic recovery cannot prepare MPI"),
DiskError::ConditionalDeleteConflict
);
assert_eq!(std::fs::read(&head).expect("source remains present"), original);
assert_eq!(std::fs::read_dir(&source).expect("source inventory").count(), 1);
}
assert!(
disk.reject_multipart_delete_recovery_mutation("ordinary-user-bucket", "object")
.is_ok()
);
}
@@ -0,0 +1,350 @@
use super::*;
use crate::disk::SnapshotLeaseToken;
async fn deferred_owned_undo() -> (Fixture, PublicationIdentity, SnapshotLeaseToken) {
let fixture = Fixture::new().await;
let result = fixture
.publish_zero_on(&[0])
.await
.pop()
.expect("real first-key native publication")
.1;
let publication = result.publication.clone();
let path = format!("object/{}", publication.final_data_dir);
let token = fixture.disks[0]
.acquire_snapshot_lease("bucket", &path)
.await
.expect("retain the actual published DD");
fixture.disks[0]
.recover_multipart_source_native(fixture.request(MultipartSourceOperation::UndoAndClaim {
expected: Box::new(result.proof.snapshot),
publication: publication.clone(),
capability: result.undo_capability,
round: Fixture::round_for(&publication),
}))
.await
.expect("owned Undo restores absence and defers only its exact DD");
assert!(!fixture.disks[0].root.join("bucket/object/xl.meta").exists());
(fixture, publication, token)
}
#[tokio::test]
async fn native_multipart_owned_undo_deferred_reclaim_rechecks_successors_references_and_ownership() {
for changed in [
"successor",
"referenced",
"child",
"foreign-anchor",
"missing-anchor",
"unknown-file",
] {
let (fixture, publication, token) = Box::pin(deferred_owned_undo()).await;
let disk = &fixture.disks[0];
let path = format!("object/{}", publication.final_data_dir);
let data = disk.io_get_object_path("bucket", &path).expect("actual failed DD");
let part = std::fs::read(data.join("part.1")).expect("real selected shard before deferred callback");
let sidecar = std::fs::read(data.join("part.1.meta")).expect("real selected generation before callback");
let mut successor = None;
let mut child = None;
match changed {
"successor" | "referenced" => {
let referenced = (changed == "referenced").then_some(publication.final_data_dir);
let info = super::super::super::test::test_file_info(
"object",
Uuid::new_v4(),
referenced,
Some(bytes::Bytes::from_static(b"newer PUT body")),
);
disk.rename_data(RUSTFS_META_TMP_BUCKET, "real-newer-put", info, "bucket", "object")
.await
.expect("real ordinary PUT while old failed DD is snapshot-protected");
let raw = std::fs::read(disk.io_get_object_path("bucket", "object/xl.meta").expect("target FI"))
.expect("actual successor FI");
let refs = FileMeta::load(&raw)
.expect("successor metadata")
.get_data_dirs()
.expect("all successor versions");
assert_eq!(refs.contains(&Some(publication.final_data_dir)), changed == "referenced");
successor = Some(raw);
}
"child" => {
let child_path = format!("{path}/child");
let info = super::super::super::test::test_file_info(
"child",
Uuid::nil(),
None,
Some(bytes::Bytes::from_static(b"child PUT body")),
);
disk.rename_data(RUSTFS_META_TMP_BUCKET, "real-child-put", info, "bucket", &child_path)
.await
.expect("real child PUT under the UUID-shaped S3 prefix");
child = Some(std::fs::read(data.join("child/xl.meta")).expect("actual child metadata"));
}
"foreign-anchor" => {
let mut anchor: UntrustedAnchorBlob =
rmp_serde::from_slice(&std::fs::read(data.join(".mpu-source-retirement")).unwrap()).unwrap();
anchor.disk_id = Uuid::new_v4();
std::fs::write(data.join(".mpu-source-retirement"), rmp_serde::to_vec_named(&anchor).unwrap()).unwrap();
}
"missing-anchor" => {
std::fs::remove_file(data.join(".mpu-source-retirement")).unwrap();
}
"unknown-file" => {
std::fs::write(data.join("unknown"), b"protected").unwrap();
}
_ => unreachable!(),
}
let anchor = std::fs::read(data.join(".mpu-source-retirement")).ok();
let reclaimed = disk.release_snapshot_lease("bucket", &path, token).await;
if changed == "successor" {
reclaimed.expect("unreferenced owned failed DD is reclaimable beside a newer real PUT");
assert!(!data.exists());
} else {
assert!(reclaimed.is_err(), "{changed} must reject the actual callback before deleting payload");
assert_eq!(std::fs::read(data.join("part.1")).expect("protected actual shard"), part);
assert_eq!(std::fs::read(data.join("part.1.meta")).expect("protected actual sidecar"), sidecar);
assert_eq!(std::fs::read(data.join(".mpu-source-retirement")).ok(), anchor);
}
if let Some(raw) = successor {
assert_eq!(std::fs::read(disk.io_get_object_path("bucket", "object/xl.meta").unwrap()).unwrap(), raw);
}
if let Some(raw) = child {
assert_eq!(std::fs::read(data.join("child/xl.meta")).unwrap(), raw);
}
if changed == "unknown-file" {
assert_eq!(std::fs::read(data.join("unknown")).unwrap(), b"protected");
}
}
}
#[cfg(not(windows))]
#[tokio::test]
async fn native_multipart_owned_undo_reclaim_keeps_anchor_until_payload_barrier_and_retries_empty_dd() {
use crate::disk::os::prepared_publication_test_hooks as hooks;
for after_anchor in [false, true] {
let (fixture, publication, token) = Box::pin(deferred_owned_undo()).await;
let disk = &fixture.disks[0];
let path = format!("object/{}", publication.final_data_dir);
let data = disk.io_get_object_path("bucket", &path).expect("actual DD barrier path");
let anchor_path = data.join(".mpu-source-retirement");
let anchor = std::fs::read(&anchor_path).expect("actual owner anchor");
let _hook = if after_anchor {
let directory = data.clone();
Some(hooks::install_at(hooks::Stage::Remove, &anchor_path, move || {
os::fsync_dir_recorder::set_failure(&directory, std::io::ErrorKind::Other);
}))
} else {
os::fsync_dir_recorder::set_failure(&data, std::io::ErrorKind::Other);
None
};
assert!(disk.release_snapshot_lease("bucket", &path, token).await.is_err());
assert!(
os::fsync_dir_recorder::take_failure(&data).is_none(),
"the real selected DD barrier must fail"
);
assert!(!data.join("part.1").exists() && !data.join("part.1.meta").exists());
if after_anchor {
assert!(
std::fs::read_dir(&data).unwrap().next().is_none(),
"only an empty DD remains after the final anchor unlink"
);
} else {
assert_eq!(std::fs::read(&anchor_path).expect("failed payload barrier retains authority"), anchor);
}
let retry = disk
.acquire_snapshot_lease("bucket", &path)
.await
.expect("finished failed callback releases deleting");
disk.release_snapshot_lease("bucket", &path, retry)
.await
.expect("same privately owned pending operation retries");
assert!(!data.exists());
disk.delete_volume("bucket", false)
.await
.expect("no owned native payload residue blocks empty bucket deletion");
}
}
#[cfg(not(windows))]
#[tokio::test]
async fn native_multipart_owned_undo_cancelled_snapshot_release_keeps_physical_delete_claim() {
use crate::disk::os::prepared_publication_test_hooks as hooks;
let (fixture, publication, token) = Box::pin(deferred_owned_undo()).await;
let disk = fixture.disks[0].clone();
let path = format!("object/{}", publication.final_data_dir);
let data = disk.io_get_object_path("bucket", &path).expect("actual callback DD");
let (entered, receive_entered) = std::sync::mpsc::channel();
let (release, receive_release) = std::sync::mpsc::channel();
let _hook = hooks::install_at(hooks::Stage::Remove, &data.join("part.1"), move || {
entered.send(()).expect("actual unlink callback entered");
receive_release
.recv_timeout(std::time::Duration::from_secs(5))
.expect("release physical unlink");
});
let owned_disk = disk.clone();
let owned_path = path.clone();
let caller = tokio::spawn(async move { owned_disk.release_snapshot_lease("bucket", &owned_path, token).await });
tokio::task::spawn_blocking(move || receive_entered.recv_timeout(std::time::Duration::from_secs(5)))
.await
.expect("hook waiter joins")
.expect("real unlink is blocked before cancelling its waiter");
caller.abort();
assert!(caller.await.expect_err("cancelled waiter").is_cancelled());
let key = super::super::super::SnapshotLeaseKey {
volume: "bucket".to_owned(),
path: path.clone(),
};
assert!(
disk.snapshot_leases
.lock()
.entries
.get(&key)
.is_some_and(|entry| entry.deleting),
"only the actual physical callback may release the deleting claim"
);
release.send(()).expect("finish actual unlink after waiter cancellation");
tokio::time::timeout(std::time::Duration::from_secs(5), async {
loop {
if !data.exists() && !disk.snapshot_leases.lock().entries.contains_key(&key) {
break;
}
tokio::task::yield_now().await;
}
})
.await
.expect("physical callback finishes and clears its owned registry claim");
assert!(!data.exists());
assert!(matches!(disk.acquire_snapshot_lease("bucket", &path).await, Err(DiskError::FileNotFound)));
}
#[tokio::test]
async fn native_multipart_owned_undo_rpc_retries_a_failed_physical_reclaim() {
let fixture = Fixture::new().await;
let result = fixture
.publish_zero_on(&[0])
.await
.pop()
.expect("actual failed-owner publication")
.1;
let publication = result.publication.clone();
let round = Fixture::round_for(&publication);
let expected = result.proof.snapshot.clone();
let capability_wire = rmp_serde::to_vec_named(&result.undo_capability).expect("original opaque native cap");
let path = format!("object/{}", publication.final_data_dir);
let data = fixture.disks[0].io_get_object_path("bucket", &path).expect("actual final DD");
let anchor = std::fs::read(data.join(".mpu-source-retirement")).expect("real native anchor");
os::fsync_dir_recorder::set_failure(&data, std::io::ErrorKind::Other);
assert!(
fixture.disks[0]
.recover_multipart_source_native(fixture.request(MultipartSourceOperation::UndoAndClaim {
expected: Box::new(expected.clone()),
publication: publication.clone(),
capability: result.undo_capability,
round: round.clone(),
}))
.await
.is_err(),
"a real reclaim barrier failure cannot become a successful first RPC"
);
assert!(
os::fsync_dir_recorder::take_failure(&data).is_none(),
"the selected physical reclaim barrier was reached"
);
let after_failure = fixture.snapshot(0).await;
assert_eq!(after_failure.phase, MultipartSourcePhase::Recovering);
assert!(after_failure.head_sha256.is_some() && after_failure.capability_sha256.is_none());
assert!(!fixture.disks[0].root.join("bucket/object/xl.meta").exists());
assert_eq!(
std::fs::read(data.join(".mpu-source-retirement")).expect("failed payload barrier retains exact anchor"),
anchor
);
let capability = rmp_serde::from_slice(&capability_wire).expect("same opaque capability on RPC replay");
let response = fixture.disks[0]
.recover_multipart_source_native(fixture.request(MultipartSourceOperation::UndoAndClaim {
expected: Box::new(expected),
publication,
capability,
round,
}))
.await
.expect("same UndoAndClaim RPC resumes its qualified reclaim before returning Claimed");
let MultipartSourceResponse::Claimed(proof) = response else {
panic!("native owned RPC replay must return its durable claim");
};
assert_eq!(proof.snapshot, after_failure);
assert!(
!data.exists(),
"the early same-claim RPC branch must actually finish its interrupted reclaim"
);
fixture.disks[0]
.delete_volume("bucket", false)
.await
.expect("RPC replay leaves an actually empty bucket");
}
#[tokio::test]
async fn native_multipart_owned_undo_pending_proof_survives_both_generic_request_orders() {
for ordinary_first in [false, true] {
let fixture = Fixture::new().await;
let result = fixture.publish_zero_on(&[0]).await.pop().expect("actual native producer").1;
let publication = result.publication.clone();
let path = format!("object/{}", publication.final_data_dir);
let disk = &fixture.disks[0];
let data = disk.io_get_object_path("bucket", &path).expect("actual failed native DD");
let token = disk
.acquire_snapshot_lease("bucket", &path)
.await
.expect("real snapshot protects this DD");
let ordinary = || DeleteOptions {
orphan_data_dir: true,
..Default::default()
};
if ordinary_first {
assert_eq!(
disk.delete_data_dir("bucket", &path, ordinary())
.await
.expect("generic request parks behind actual snapshot"),
crate::disk::DataDirDeleteStatus::Deferred
);
}
disk.recover_multipart_source_native(fixture.request(MultipartSourceOperation::UndoAndClaim {
expected: Box::new(result.proof.snapshot),
publication: publication.clone(),
capability: result.undo_capability,
round: Fixture::round_for(&publication),
}))
.await
.expect("actual owned Undo qualifies missing-FI cleanup");
if !ordinary_first {
assert_eq!(
disk.delete_data_dir("bucket", &path, ordinary())
.await
.expect("later generic request remains deferred"),
crate::disk::DataDirDeleteStatus::Deferred
);
}
let key = super::super::super::SnapshotLeaseKey {
volume: "bucket".to_owned(),
path: path.clone(),
};
assert!(
matches!(
disk.snapshot_leases
.lock()
.entries
.get(&key)
.and_then(|entry| entry.pending_delete.as_ref()),
Some(super::super::super::SnapshotPendingDelete::OwnedMultipart(_))
),
"the exact native proof cannot be lost to generic options, ordinary_first={ordinary_first}"
);
assert!(data.join(".mpu-source-retirement").exists());
disk.release_snapshot_lease("bucket", &path, token)
.await
.expect("release replays the qualified native owner");
assert!(!data.exists(), "both request orders must reclaim the owned DD after its snapshot drains");
disk.delete_volume("bucket", false)
.await
.expect("no generic pending request strands an owned absent-target DD");
}
}
@@ -0,0 +1,703 @@
// Copyright 2026 RustFS Team
// SPDX-License-Identifier: Apache-2.0
//! All-participant, irreversible source closure. No operation in this module undoes a target.
use super::{
LocalDisk,
multipart_source_state::{
LoadedSource, SourceHead, SourceState, SourceStore, decode, encode, load_source, raw_sha, read_optional,
},
};
use crate::disk::{
DiskError, Error, RUSTFS_META_MULTIPART_BUCKET, RUSTFS_META_TMP_DELETED_BUCKET, Result, STORAGE_FORMAT_FILE,
multipart_source::*, os,
};
use rustfs_filemeta::FileMeta;
use serde::{Deserialize, Serialize};
use std::{
io::{self, ErrorKind},
path::{Path, PathBuf},
sync::Arc,
};
use uuid::Uuid;
const STATE: &str = ".mpu-source-state";
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(super) struct RetiredHead {
pub sha256: [u8; 32],
pub length: u64,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(super) struct AbortCertificateRef {
pub round: MultipartAbortRound,
pub sha256: [u8; 32],
pub slot: u8,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(super) struct NativeAbortState {
pub round: MultipartAbortRound,
pub pending_from: MultipartSourceSnapshot,
pub retired_head: Option<RetiredHead>,
pub committed: Option<AbortCertificateRef>,
}
impl NativeAbortState {
pub fn validate(&self, state: &SourceState) -> Result<()> {
self.round.validate()?;
if state.phase != MultipartSourcePhase::Aborting
|| state.capability_sha256.is_some()
|| state.generation != self.round.generation
|| state.lineage != self.round.lineage
|| self.pending_from.lineage != state.lineage
|| self.pending_from.disk_id != state.disk_id
|| self.pending_from.physical_slot != state.physical_slot
|| self.pending_from.generation.epoch > state.generation.epoch
|| self.committed.as_ref().is_some_and(|reference| {
reference.round.lineage != state.lineage
|| reference.slot > 1
|| reference.round.generation.epoch > state.generation.epoch
|| (reference.round.generation.epoch == state.generation.epoch
&& reference.round.generation != state.generation)
})
{
return Err(DiskError::FileCorrupt);
}
if let Some(reference) = &self.committed {
reference.round.validate()?;
}
Ok(())
}
}
fn retired_path(source: &Path, state: &SourceState) -> PathBuf {
source
.join(".mpu-completions")
.join(state.backup_owner.to_string())
.join("aborted-head.xl.meta")
}
fn abort_certificate_path(source: &Path, owner: Uuid, slot: u8) -> PathBuf {
source
.join(".mpu-completions")
.join(owner.to_string())
.join(format!("abort-{slot}.msgpack"))
}
/// The only cold local provenance rehydration token; callers cannot construct it.
pub(in crate::disk) struct ValidatedAbortCertificate {
digest: [u8; 32],
round: MultipartAbortRound,
}
impl ValidatedAbortCertificate {
pub(in crate::disk) fn validate_certificate(&self, certificate: &MultipartAbortCertificate) -> Result<()> {
if certificate.digest()? != self.digest || certificate.round != self.round {
return Err(DiskError::FileCorrupt);
}
Ok(())
}
}
pub(super) fn validate_retired_head(
source: &Path,
base: &Path,
root: &os::PublicationRoot,
state: &SourceState,
head: Option<&SourceHead>,
) -> Result<()> {
let Some(abort) = &state.abort else {
return Ok(());
};
abort.validate(state)?;
if let Some(head) = head {
if abort.pending_from.head_sha256 != Some(head.sha256)
&& abort
.retired_head
.as_ref()
.is_none_or(|receipt| receipt.sha256 != head.sha256 || receipt.length != head.length)
{
return Err(DiskError::ConditionalDeleteConflict);
}
} else if let Some(receipt) = &abort.retired_head {
let observed = os::observe_multipart_source_metadata(&retired_path(source, state), base, root, receipt.length)?;
if observed != (receipt.length, receipt.sha256) {
return Err(DiskError::FileCorrupt);
}
}
Ok(())
}
impl SourceStore {
pub async fn root_missing(&self) -> Result<bool> {
let source = self.source.clone();
let base = self.base.clone();
let root = self.publication_root.clone();
os::run_blocking_namespace_operation(self.lease.clone(), move || {
match os::pin_multipart_source_directory(&source, &base, &root) {
Ok(_) => Ok(false),
Err(error) if error.kind() == ErrorKind::NotFound => Ok(true),
Err(error) => Err(error),
}
})
.await
.map_err(DiskError::from)
}
fn verify_abort_cohort(&self, certificate: &MultipartAbortCertificate) -> Result<()> {
certificate.verify_with_authority(&self.authority)?;
if certificate.disk_ids != self.disk_ids
|| certificate.disk_ids.get(self.physical_slot) != Some(&self.disk_id)
|| certificate.round.lineage.path != self.path
{
return Err(DiskError::ConditionalDeleteConflict);
}
Ok(())
}
pub async fn read_abort_certificate(&self, loaded: &LoadedSource) -> Result<Option<MultipartAbortCertificate>> {
let Some(reference) = loaded
.state
.as_ref()
.and_then(|state| state.abort.as_ref())
.and_then(|abort| abort.committed.as_ref())
else {
return Ok(None);
};
let owner = loaded.state.as_ref().ok_or(DiskError::FileCorrupt)?.backup_owner;
let path = abort_certificate_path(&self.source, owner, reference.slot);
let base = self.base.clone();
let root = self.publication_root.clone();
let raw = os::run_blocking_namespace_operation(self.lease.clone(), move || {
os::read_multipart_source_metadata(&path, &base, &root)
})
.await?;
let mut certificate: MultipartAbortCertificate = decode(&raw)?;
let validated = ValidatedAbortCertificate {
digest: reference.sha256,
round: reference.round.clone(),
};
certificate.hydrate_owned(&self.authority, &validated)?;
self.verify_abort_cohort(&certificate)?;
Ok(Some(certificate))
}
async fn persist_abort_certificate(
&self,
state: &SourceState,
certificate: &MultipartAbortCertificate,
) -> Result<AbortCertificateRef> {
self.verify_abort_cohort(certificate)?;
let raw = encode(certificate)?;
let digest = raw_sha(&raw);
let slot = state
.abort
.as_ref()
.and_then(|abort| abort.committed.as_ref())
.map_or(0, |current| 1 - current.slot);
let path = abort_certificate_path(&self.source, state.backup_owner, slot);
let source = self.source.clone();
let base = self.base.clone();
let root = self.publication_root.clone();
os::run_blocking_namespace_operation(self.lease.clone(), move || {
let _source = os::pin_multipart_source_directory(&source, &base, &root)?;
let parent = path
.parent()
.ok_or_else(|| io::Error::other("abort certificate has no parent"))?;
let _parents = os::mkdir_all_below_existing_base_std(parent, &base, &root)?;
// Only the unreferenced slot may be replaced. The old committed
// slot remains recoverable until the subsequent state barrier.
os::replace_multipart_source_metadata(&path, &raw, &base, &root)?;
os::sync_multipart_owned_record_parents(&source, parent, true, &base, &root)
})
.await?;
Ok(AbortCertificateRef {
round: certificate.round.clone(),
sha256: digest,
slot,
})
}
async fn retire_abort_head(&self, state: &SourceState) -> Result<()> {
let abort = state.abort.as_ref().ok_or(DiskError::FileCorrupt)?;
let source = self.source.clone();
let base = self.base.clone();
let root = self.publication_root.clone();
let state = state.clone();
let receipt = abort.retired_head.clone();
os::run_blocking_namespace_operation(self.lease.clone(), move || {
let _pin = os::pin_multipart_source_directory(&source, &base, &root)?;
let canonical = source.join(STORAGE_FORMAT_FILE);
if let Some(receipt) = receipt {
let retired = retired_path(&source, &state);
os::retire_multipart_source_head_std(&canonical, &retired, &base, &root, receipt.length, receipt.sha256)?;
os::sync_multipart_owned_record_parents(
&source,
retired
.parent()
.ok_or_else(|| io::Error::other("retired head parent missing"))?,
true,
&base,
&root,
)
} else {
match os::observe_multipart_source_metadata(&canonical, &base, &root, 0) {
Err(error) if error.kind() == ErrorKind::NotFound => os::fsync_dir_std(&source),
Err(error) => Err(error),
Ok(_) => Err(io::Error::new(ErrorKind::AlreadyExists, "closed source unexpectedly has a head")),
}
}
})
.await
.map_err(DiskError::from)
}
async fn abort_proof(
&self,
kind: MultipartAbortProofKind,
round: &MultipartAbortRound,
certificate_sha256: Option<[u8; 32]>,
publication: Option<PublicationIdentity>,
challenge: Uuid,
) -> Result<MultipartAbortProof> {
let loaded = self.load().await?;
let snapshot = loaded.snapshot.ok_or(DiskError::ConditionalDeleteConflict)?;
if loaded.head.is_some() {
return Err(DiskError::ConditionalDeleteConflict);
}
MultipartAbortProof::issue(&self.authority, kind, challenge, round.clone(), snapshot, certificate_sha256, publication)
}
async fn abort_inspection(&self) -> Result<MultipartSourceInspection> {
let loaded = self.load().await?;
let certificate = self.read_abort_certificate(&loaded).await?;
Ok(MultipartSourceInspection {
disk_id: self.disk_id,
root_missing: self.root_missing().await?,
abort_round: loaded
.state
.as_ref()
.and_then(|state| state.abort.as_ref().map(|abort| abort.round.clone())),
abort_certificate: certificate,
snapshot: loaded.snapshot,
original: loaded.original,
recovery: loaded.state.as_ref().and_then(|state| state.recovery.clone()),
negative_certificate: None,
ready_certificate: None,
terminal_certificate: loaded.state.map_or_else(Vec::new, |state| state.terminal_certificate),
})
}
}
impl LocalDisk {
pub(super) async fn recover_multipart_abort_owned(
&self,
store: SourceStore,
operation: MultipartSourceOperation,
) -> Result<MultipartSourceResponse> {
match operation {
MultipartSourceOperation::UpgradeLegacy { expected } => {
let loaded = store.load().await?;
if loaded.snapshot.as_ref() != Some(&expected)
|| loaded.state.is_some()
|| expected.phase != MultipartSourcePhase::Active
|| expected.generation.epoch != 0
|| !loaded.head_matches_state
{
return Err(DiskError::ConditionalDeleteConflict);
}
let mut info = loaded.info.ok_or(DiskError::FileCorrupt)?;
if native_source_protocol(&info)? {
return Ok(MultipartSourceResponse::LegacyUpgraded(store.abort_inspection().await?));
}
if recognized_native_source_controls(&info)? {
return Err(DiskError::ConditionalDeleteConflict);
}
let source = store.source.clone();
let base = store.base.clone();
let root = store.publication_root.clone();
os::run_blocking_namespace_operation(store.lease.clone(), move || {
let _pin = os::pin_multipart_source_directory(&source, &base, &root)?;
for entry in std::fs::read_dir(&source)? {
let entry = entry?;
if entry
.file_name()
.to_str()
.is_some_and(|name| name.get(..5).is_some_and(|prefix| prefix.eq_ignore_ascii_case(".mpu-")))
{
return Err(io::Error::new(ErrorKind::InvalidData, "legacy source has native owned records"));
}
}
Ok(())
})
.await?;
mark_native_source(&mut info);
let mut metadata = FileMeta::new();
metadata.add_version(info).map_err(Error::other)?;
store.replace_head(&metadata.marshal_msg().map_err(Error::other)?).await?;
Ok(MultipartSourceResponse::LegacyUpgraded(store.abort_inspection().await?))
}
MultipartSourceOperation::PrepareAbort { expected, round } => {
round.validate()?;
let loaded = store.load().await?;
if loaded.snapshot.as_ref() != Some(&expected) || expected.lineage != round.lineage {
return Err(DiskError::ConditionalDeleteConflict);
}
let mut state = store.ensure_state(&loaded).await?;
if state
.abort
.as_ref()
.and_then(|abort| abort.committed.as_ref())
.is_some_and(|committed| committed.round == round)
{
return Err(DiskError::ConditionalDeleteConflict);
}
if state.abort.as_ref().is_none_or(|abort| abort.round != round) {
if round.generation.epoch <= state.generation.epoch {
return Err(DiskError::ConditionalDeleteConflict);
}
if let Some(head) = loaded.head.as_ref() {
let owned = head.sha256 == state.original_sha256
|| Some(head.sha256) == state.prepared_sha256
|| loaded.head_matches_state;
if !owned {
return Err(DiskError::ConditionalDeleteConflict);
}
}
let previous = state.abort.take();
let retired = previous.as_ref().and_then(|abort| abort.retired_head.clone()).or_else(|| {
loaded.head.as_ref().map(|head| RetiredHead {
sha256: head.sha256,
length: head.length,
})
});
state.abort = Some(NativeAbortState {
round: round.clone(),
pending_from: expected,
retired_head: retired,
committed: previous.and_then(|abort| abort.committed),
});
state.generation = round.generation;
state.capability_sha256 = None;
state.advance(MultipartSourcePhase::Aborting)?;
store.persist(&state).await?;
}
store.retire_abort_head(&state).await?;
Ok(MultipartSourceResponse::AbortPrepared(
store
.abort_proof(MultipartAbortProofKind::Prepared, &round, None, None, round.round)
.await?,
))
}
MultipartSourceOperation::CommitAbort { expected, certificate } => {
let loaded = store.load().await?;
if loaded.snapshot.as_ref() != Some(&expected) {
return Err(DiskError::ConditionalDeleteConflict);
}
store.verify_abort_cohort(&certificate)?;
let mut state = loaded.state.ok_or(DiskError::ConditionalDeleteConflict)?;
let abort = state.abort.as_ref().ok_or(DiskError::ConditionalDeleteConflict)?;
if state.phase != MultipartSourcePhase::Aborting
|| loaded.head.is_some()
|| certificate.round.lineage != state.lineage
|| certificate.round.generation.epoch > state.generation.epoch
{
return Err(DiskError::ConditionalDeleteConflict);
}
let digest = certificate.digest()?;
if abort.committed.as_ref().is_none_or(|reference| reference.sha256 != digest) {
let reference = store.persist_abort_certificate(&state, &certificate).await?;
state.abort.as_mut().ok_or(DiskError::FileCorrupt)?.committed = Some(reference);
state.advance(MultipartSourcePhase::Aborting)?;
store.persist(&state).await?;
} else {
store
.read_abort_certificate(&store.load().await?)
.await?
.ok_or(DiskError::FileCorrupt)?;
}
Ok(MultipartSourceResponse::AbortCommitted(
store
.abort_proof(
MultipartAbortProofKind::Committed,
&certificate.round,
Some(digest),
None,
certificate.round.round,
)
.await?,
))
}
MultipartSourceOperation::DeleteAborted { expected, certificate } => {
let loaded = store.load().await?;
if loaded.snapshot.as_ref() != Some(&expected) {
return Err(DiskError::ConditionalDeleteConflict);
}
store.verify_abort_cohort(&certificate)?;
let current = store
.read_abort_certificate(&loaded)
.await?
.ok_or(DiskError::ConditionalDeleteConflict)?;
if current != certificate || loaded.head.is_some() {
return Err(DiskError::ConditionalDeleteConflict);
}
let permit = AbortDeletePermit::new(&store, &expected, certificate.clone())?;
let destination = self.io_get_object_path(RUSTFS_META_TMP_DELETED_BUCKET, &Uuid::new_v4().to_string())?;
let source = store.source.clone();
let base = store.base.clone();
let root = store.publication_root.clone();
let lease = os::acquire_namespace_mutation_lease_with_owner(&destination, Some(store.lease.clone())).await;
os::run_blocking_namespace_operation_with_sync(lease, true, move || {
permit.validate()?;
os::rename_multipart_source_owned_std(&source, &destination, &base, &root, || permit.validate())
})
.await?;
let proof = MultipartAbortProof::issue(
&store.authority,
MultipartAbortProofKind::Deleted,
certificate.round.round,
certificate.round.clone(),
expected,
Some(certificate.digest()?),
None,
)?;
Ok(MultipartSourceResponse::AbortDeleted(proof))
}
MultipartSourceOperation::ConfirmCleanupTarget {
expected,
publication,
challenge,
} => {
let loaded = store.load().await?;
if loaded.snapshot.as_ref() != Some(&expected)
|| expected.phase != MultipartSourcePhase::Aborting
|| loaded.head.is_some()
|| expected.lineage != publication.lineage
{
return Err(DiskError::ConditionalDeleteConflict);
}
let certificate = store
.read_abort_certificate(&loaded)
.await?
.ok_or(DiskError::ConditionalDeleteConflict)?;
let target = self.get_object_path(&publication.lineage.bucket, &publication.lineage.object)?;
let _owner = os::acquire_metadata_mutation_lease(&target, Some(store.lease.clone())).await;
store.validate_cleanup_target(&publication).await?;
store
.abort_proof(
MultipartAbortProofKind::CleanupTarget,
&certificate.round,
Some(certificate.digest()?),
Some(publication),
challenge,
)
.await
.map(MultipartSourceResponse::CleanupTargetConfirmed)
}
_ => Err(DiskError::UnsupportedDisk),
}
}
}
/// This permit is neither serializable nor constructible by a generic delete RPC.
struct AbortDeletePermit {
source: PathBuf,
base: PathBuf,
root: os::PublicationRoot,
path: String,
disk_id: Uuid,
slot: usize,
disk_ids: Vec<Uuid>,
expected: MultipartSourceSnapshot,
certificate: MultipartAbortCertificate,
authority: ProofVerificationContext,
}
impl AbortDeletePermit {
fn new(store: &SourceStore, expected: &MultipartSourceSnapshot, certificate: MultipartAbortCertificate) -> Result<Self> {
store.verify_abort_cohort(&certificate)?;
Ok(Self {
source: store.source.clone(),
base: store.base.clone(),
root: store.publication_root.clone(),
path: store.path.clone(),
disk_id: store.disk_id,
slot: store.physical_slot,
disk_ids: store.disk_ids.clone(),
expected: expected.clone(),
certificate,
authority: store.authority.clone(),
})
}
fn validate(&self) -> io::Result<()> {
(|| -> Result<()> {
self.certificate.verify_with_authority(&self.authority)?;
let loaded = load_source(&self.source, &self.base, &self.root, &self.path, self.disk_id, self.slot, &self.disk_ids)?;
if loaded.snapshot.as_ref() != Some(&self.expected) || loaded.head.is_some() {
return Err(DiskError::ConditionalDeleteConflict);
}
let reference = loaded
.state
.as_ref()
.and_then(|state| state.abort.as_ref())
.and_then(|abort| abort.committed.as_ref())
.ok_or(DiskError::ConditionalDeleteConflict)?;
if reference.sha256 != self.certificate.digest()? || reference.round != self.certificate.round {
return Err(DiskError::ConditionalDeleteConflict);
}
let raw = read_optional(
&abort_certificate_path(
&self.source,
loaded.state.as_ref().ok_or(DiskError::FileCorrupt)?.backup_owner,
reference.slot,
),
&self.base,
&self.root,
)?
.ok_or(DiskError::FileCorrupt)?;
if raw_sha(&raw) != reference.sha256 {
return Err(DiskError::FileCorrupt);
}
Ok(())
})()
.map_err(io::Error::other)
}
}
/// A local ownership token can only be obtained by acquiring the upload's
/// SourceRW. Appending another owner preserves that guard in the Arc chain.
#[derive(Clone)]
pub(super) struct MultipartDeleteOwner {
owner: Option<Arc<dyn Send + Sync>>,
source_key: Option<PathBuf>,
}
impl MultipartDeleteOwner {
pub(super) fn namespace_owner(&self) -> Option<Arc<dyn Send + Sync>> {
self.owner.clone()
}
pub(super) fn chain(self, owner: Option<Arc<dyn Send + Sync>>) -> Self {
let owner = match (self.owner, owner) {
(None, owner) | (owner, None) => owner,
(Some(first), Some(second)) if Arc::ptr_eq(&first, &second) => Some(first),
(Some(first), Some(second)) => Some(Arc::new((first, second)) as Arc<dyn Send + Sync>),
};
Self {
owner,
source_key: self.source_key,
}
}
}
impl LocalDisk {
fn multipart_delete_owner_key(&self, volume: &str, path: &str) -> Result<Option<PathBuf>> {
super::multipart_staging::reject_noncanonical_scope(&self.root, self.io_root(), volume, path)?;
Ok((volume == RUSTFS_META_MULTIPART_BUCKET).then(|| {
self.root
.join(RUSTFS_META_MULTIPART_BUCKET)
.join(super::multipart_staging::upload_scope_root(path, true).to_ascii_lowercase())
}))
}
pub(super) async fn acquire_multipart_delete_owner(
&self,
volume: &str,
path: &str,
owner: Option<Arc<dyn Send + Sync>>,
) -> Result<MultipartDeleteOwner> {
let source_key = self.multipart_delete_owner_key(volume, path)?;
let owner = self.multipart_source_owner(volume, path, true, true, owner).await?;
Ok(MultipartDeleteOwner { owner, source_key })
}
pub(super) fn validate_multipart_delete_owner(
&self,
token: &MultipartDeleteOwner,
volume: &str,
path: &str,
) -> Result<Option<Arc<dyn Send + Sync>>> {
if token.source_key != self.multipart_delete_owner_key(volume, path)? {
return Err(DiskError::ConditionalDeleteConflict);
}
Ok(token.namespace_owner())
}
}
/// No prefix-wide upload lock exists. Ordinary recursive ancestor deletion is
/// refused instead of scanning children and racing a newly closed upload.
#[derive(Clone)]
pub(super) struct OrdinaryMultipartDeleteCheck {
source: PathBuf,
base: PathBuf,
root: os::PublicationRoot,
}
impl OrdinaryMultipartDeleteCheck {
pub(super) fn validate(&self) -> Result<()> {
if let Some(raw) = read_optional(&self.source.join(STATE), &self.base, &self.root)? {
let state: SourceState = decode(&raw)?;
if state.abort.is_some() || state.phase == MultipartSourcePhase::Aborting {
return Err(DiskError::ConditionalDeleteConflict);
}
} else if os::multipart_source_has_native_controls(&self.source, &self.base, &self.root)? {
return Err(DiskError::ConditionalDeleteConflict);
}
Ok(())
}
pub(super) fn callback(&self) -> Arc<dyn Fn() -> io::Result<()> + Send + Sync> {
let check = self.clone();
Arc::new(move || check.validate().map_err(io::Error::other))
}
}
impl LocalDisk {
/// Admission is read-only but may read metadata. The queued callback keeps
/// SourceRW ownership and the job's resource unit after waiter cancellation.
pub(super) async fn validate_multipart_delete_admission(
&self,
check: OrdinaryMultipartDeleteCheck,
owner: Option<Arc<dyn Send + Sync>>,
) -> Result<()> {
self.blocking_queue
.run_cancellable(move |_| {
let _owner = owner;
Ok(check.validate())
})
.await
.map_err(DiskError::from)?
}
pub(super) fn multipart_delete_check(&self, path: &Path, recursive: bool) -> Result<Option<OrdinaryMultipartDeleteCheck>> {
let Ok(relative) = path.strip_prefix(self.io_root()) else {
return Ok(None);
};
let mut components = relative.components();
if !components
.next()
.and_then(|component| component.as_os_str().to_str())
.is_some_and(|component| component.eq_ignore_ascii_case(crate::disk::RUSTFS_META_BUCKET))
{
return Ok(None);
}
match components.next().and_then(|component| component.as_os_str().to_str()) {
Some(component) if component.eq_ignore_ascii_case("multipart") => {}
None if recursive => return Err(DiskError::ConditionalDeleteConflict),
_ => return Ok(None),
}
let relative = components.as_path();
let relative = relative.to_str().ok_or(DiskError::InvalidPath)?;
let upload = super::multipart_staging::upload_scope_root(relative, true);
let source = self.io_get_object_path(RUSTFS_META_MULTIPART_BUCKET, upload)?;
// Only a production hash/upload root owns a recursive upload scope.
// Empty SHA rmdir remains nonrecursive and unchanged.
let canonical = Path::new(upload)
.file_name()
.and_then(|name| name.to_str())
.and_then(|name| name.split(['x', 'X']).next())
.and_then(|name| Uuid::parse_str(name).ok())
.is_some_and(|id| !id.is_nil());
let hashed_parent = Path::new(upload)
.parent()
.and_then(Path::file_name)
.and_then(|name| name.to_str())
.is_some_and(|name| name.len() == 64 && name.bytes().all(|byte| byte.is_ascii_hexdigit()));
if recursive && !(canonical && hashed_parent) {
return Err(DiskError::ConditionalDeleteConflict);
}
Ok(Some(OrdinaryMultipartDeleteCheck {
source,
base: self.io_root().to_path_buf(),
root: self.publication_root.clone(),
}))
}
}
#[cfg(test)]
mod lock_order_tests {
include!("multipart_source_lock_order_tests.rs");
}

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