mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-21 11:56:38 +00:00
72f8e90e17f1aa61b8a095890dd52c2da2ed8ab2
330 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
7048a9a3ed |
test(ecstore): run shard_read_costs empty test under a Tokio runtime (#4496)
shard_read_costs_for_empty_disk_set_are_empty was a plain sync #[test], but shard_read_costs_for_disks consults process-global topology state (local_endpoint_hosts_for_shard_costs) whose fast-lock manager lazily spawns a background cleanup task on first access. When this test was the first in a process to touch that global — as under nextest's per-test isolation — the tokio::spawn panicked with a TryCurrentError because no runtime was present, making the test order-dependent flaky in CI (it passes only when some sibling tokio test initializes the manager first). Run it under a Tokio runtime like the sibling reservation tests (#[tokio::test]), so the lazy init's spawn always has a runtime. Test-only change; no production behavior change. Verified failing before the change and passing after under both `cargo test` and `cargo nextest run` in isolation. Co-authored-by: heihutu <heihutu@gmail.com> |
||
|
|
7fb95d4fc0 | fix(multipart): lock upload metadata reads and aborts (#4428) | ||
|
|
fefa70b31e |
fix(ecstore): stop ListMultipartUploads from returning one upload past max-uploads (#4447)
fix(ecstore): stop ListMultipartUploads from returning one upload past max-uploads (backlog#954) The result-collection loop in `SetDisks::list_multipart_uploads` pushed an upload into the page and only then checked `ret_uploads.len() > max_uploads`, so each page returned up to max_uploads + 1 uploads and pointed `next_upload_id_marker` at that surplus entry, violating the S3 ListMultipartUploads max-uploads contract. Move the cap check before the push (MinIO-style) so a page never exceeds max_uploads, and derive `is_truncated` from the post-marker cursor position (`upload_idx < uploads.len()`) rather than comparing the page length against the full listing length. The old length comparison mis-reported truncation on the final page whenever a marker had skipped earlier entries, which prevented marker-based pagination from terminating. Add a regression test that starts more in-progress uploads than a page holds and asserts a single page returns exactly max_uploads with a correct next-marker, that the exact-boundary case is not marked truncated, and that paginating one upload at a time enumerates every upload once with no loss, duplication, or non-termination. Incidental: add the missing `ctx` field to the `new_multipart_lock_test_store` cfg(test) helper so the ecstore test build compiles after the #4413/#4437 merge collision. Refs: https://github.com/rustfs/backlog/issues/954 |
||
|
|
a48bc89cdc |
fix(ecstore): lock batch object deletes (#4435)
* fix(ecstore): lock batch object deletes * fix(ecstore): honor no_lock in batch deletes |
||
|
|
df9cbc4ed1 |
fix(ecstore): validate erasure distribution values to avoid shuffle index panic (#4427)
fix(ecstore): validate erasure distribution values to avoid shuffle index panic (backlog#949) The element values of `erasure.distribution` read from `xl.meta` were never range-checked. `FileInfo::is_valid()` and `MetaObjectV1::valid()` only verified `distribution.len()` and the `erasure.index` bound, not that each distribution value is a valid 1-based slot in `[1, N]`. The metadata shuffle helpers then use these values directly as `distribution[k] - 1` indices, so a corrupt or adversarial `xl.meta` carrying a `0` (usize underflow) or a value greater than N (out-of-bounds) triggers a panic in the shuffle path, turning bad-disk metadata that erasure coding is meant to tolerate into a request/task crash. Fix, two layers: - Validate distribution values at metadata acceptance: `is_valid_distribution` now requires the distribution to be a permutation of `1..=N` (correct length, every value in range, no duplicates). `FileInfo::is_valid()` and `MetaObjectV1::valid()` use it, so `find_file_info_in_quorum` rejects corrupt metadata and it surfaces as a clean `ErasureReadQuorum` error instead of an index path. - Defensive indexing in the shuffle helpers (`shuffle_disks_and_parts_metadata`, `_by_index`, `_by_index_owned`, `shuffle_parts_metadata`, `shuffle_disks`, `shuffle_check_parts`): out-of-range distribution values are skipped via `checked_sub(1)` + bounds-checked slot access instead of a bare `idx - 1` index, matching the existing pattern in `collect_inline_data_shard_fileinfos_by_index`. Regression tests: `is_valid_distribution`/`is_valid`/`valid` reject distributions containing `0`, values greater than N, duplicates, and wrong length while accepting valid permutations; the shuffle helpers no longer panic on corrupt distributions and preserve output length. Refs: https://github.com/rustfs/backlog/issues/949 |
||
|
|
91dec123d9 |
refactor(ecstore): add per-instance InstanceContext, migrate erasure setup type (#4413)
* refactor(ecstore): add per-instance InstanceContext, migrate erasure setup type Phase 5 of the global-singleton consolidation (backlog#939): begin moving runtime identity state out of process globals so multiple ECStore instances can coexist in one process. Isolation is carried by the object graph (ECStore -> Sets -> SetDisks holding an Arc<InstanceContext>), not a task-local, which does not propagate across the many internal tokio::spawn boundaries in the data/background paths. This first slice migrates the erasure setup type -- previously three independent process-global bools -- into a single per-instance RwLock<SetupType> that derives is_erasure / is_dist_erasure / is_erasure_sd, removing a triple source of truth that could drift out of sync. - New runtime::instance module: InstanceContext + process bootstrap context. - The legacy free-function facade (is_erasure/update_erasure_type/...) keeps its signatures and forwards to the current instance's context, falling back to the bootstrap context before a store is published. - ECStore gains a pub(crate) ctx field and setup_is_* accessors; its constructors adopt the bootstrap context (never mint a fresh one) so startup writes and post-construction reads share one cell -- single-instance behavior is byte-for-byte unchanged. Tests: erasure predicate derivation vs the legacy behavior, object-graph carrier isolation across two ECStore instances, and bootstrap adoption. Refs: backlog#939 (Phase 5, Slice 1), backlog#653 (item 8) * refactor(ecstore): thread InstanceContext down the object graph (Phase 5 Slice 2) (#4415) * refactor(ecstore): source the namespace lock manager per-instance (#4417) refactor(ecstore): source the namespace lock manager per-instance (Phase 5 Slice 3) Phase 5 Slice 3 (backlog#939): give each instance its own lock namespace by sourcing SetDisks' lock manager from the instance context instead of the process singleton. This removes the false cross-instance mutual exclusion (and attendant ABBA risk) that a shared GlobalLockManager would cause once multiple instances coexist. - InstanceContext gains a `lock_manager: Arc<GlobalLockManager>`. `new()` mints a fresh manager (independent per-instance); `bootstrap_ctx()` aliases the process singleton via get_global_lock_manager(), so a single-instance deployment keeps exactly one shared namespace. - SetDisks::new sources `local_lock_manager` from `ctx.lock_manager()` (the ctx it already adopts), not `runtime_sources::global_lock_manager()`. Single instance: same Arc as before, so behavior is unchanged. - Remove the now-unused `runtime_sources::global_lock_manager()` wrapper. Tests: bootstrap lock manager aliases the process singleton; two fresh contexts own distinct managers; a SetDisks' lock manager is the one from its context and aliases the global singleton in a single-instance build. Verification: cargo test -p rustfs-ecstore (10 Phase 5 + set_disk locking regressions green), cargo clippy -p rustfs-ecstore --all-targets (clean), make pre-commit (pass). Refs: backlog#939 (Phase 5, Slice 3). Stacked on #4415 (Slice 2). |
||
|
|
cda7688909 |
fix(multipart): clean temp part data on failure (#4412)
fix(multipart): clean failed part temp data |
||
|
|
1e6207c08e |
fix(lock): fence write commit on lock loss (#4406)
fix(lock): fence write commit on lock loss (backlog#899 Phase 2) Phase 0+1 (#4388) made object write locks refreshable and marks the guard lost when the heartbeat can no longer refresh a quorum, but does not act on it. Under a partition a long write's lock can expire on an unreachable node and be reclaimed, letting a third party re-acquire it; the original writer keeps going and both commit -- a double write. Expose the loss signal through NamespaceLockGuard::is_lock_lost() and ObjectLockDiagGuard::is_lock_lost(), and fence the commit in put_object and complete_multipart_upload: immediately before rename_data (the atomic commit point), abort with a retryable NamespaceLockQuorumUnavailable (503) if the lock was lost. In multipart the check precedes cleanup_multipart_path so a lost lock leaves the upload intact and retryable. A write that already reached rename_data Ok is durable and never aborted. The loss criterion is unchanged (reacts to Phase 1's signal). Heal and the long-GET read side are deferred follow-ups. |
||
|
|
a413729b16 | perf(delete): gate and parallelize DeleteObjects per-object stat fanout (#4398) | ||
|
|
afc7f1d6f9 |
fix(ecstore): make post-commit old data dir cleanup best-effort (#4386)
* fix(ecstore): make post-commit old data dir cleanup best-effort (backlog#898) A write is authoritatively committed once rename_data returns Ok (the new version is durable on >= write_quorum disks and immediately readable). The subsequent reclamation of the now-dereferenced old object/<data_dir> is pure space reclamation, yet commit_rename_data_dir propagated a below-quorum GC failure via `?` into ErasureWriteQuorum -> 503, producing a false-negative ACK for an already-persisted write. This is a deliberate divergence from MinIO (erasure-object.go:1577), which couples the two; the divergence is justified by durability semantics, not parity. Changes: - commit_rename_data_dir now returns a structured OldDataDirCleanup receipt and never returns Err. Adds an old==committed-dir anti-misdelete guard and a committed_data_dir parameter. Classification is extracted into pure functions (classify_old_data_dir_cleanup / map_cleanup_join_result / is_cleanup_not_found) so it is unit-testable. Task panic/cancel is mapped to a non-ignored DiskError::other (never DiskNotFound), and not-found is normalized to reclaimed. - object.rs / multipart.rs consume the receipt instead of `?`. The result reverts to Ok, so the invalidate_get_object_metadata_cache self-heal and the capacity/compression accounting that a `?` early-return previously skipped now run on the cleanup-failure path too. - On residue, report_old_data_dir_cleanup emits leak metrics and enqueues an object heal over the existing heal channel (disk-health signal replacing the 503). heal_object -> reclaim_orphan_data_dirs already reclaims unreferenced local data dirs, closing the loop end to end. - Adds rustfs_old_data_dir_* counters (attempted/reclaimed/leaked/below_quorum) as the operator-visible backstop for leaked residue. - Adds a test-only (#[cfg(test)]) delete fault-injection seam; in production it inlines to a no-op None and has no behavioral effect. Tests: pure-function A/C group + join-error mapping + actions decision; A5/A5b real-disk guard/reclaim integration; end-to-end overwrite returning 200 while old-data-dir cleanup fails. #864 rollback guard test remains green. * fix(ecstore): resolve merge conflicts with origin/main in io_primitives.rs --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: houseme <housemecn@gmail.com> |
||
|
|
3f13d098b4 |
feat(observability): feature-gated hotpath instrumentation for the data path (#4394)
Merge the hotpath-rs wall-time instrumentation from the backlog#936 analysis worktree behind an opt-in 'hotpath' cargo feature, keeping the default build at zero overhead and zero dependency. - hotpath is an optional dependency everywhere (dep:hotpath feature syntax); the default dependency tree contains no hotpath crate at all - 40+ measurement points across S3 handlers, ECStore/SetDisks object and multipart ops, erasure encode/decode, bitrot, LocalDisk I/O, FileMeta codec, and HashReader - attribute sites use #[cfg_attr(feature = "hotpath", hotpath::measure)]; async_trait bodies use per-crate hp_guard! macros (ecstore + rustfs bin); rio gates measure_block! behind hp_measure_block! - feature chain: rustfs -> rustfs-ecstore -> rustfs-rio / rustfs-filemeta, each crate owning its own gate - hotpath-alloc is intentionally not wired up (hotpath 0.21.x TLS panic on cross-thread guard drop under tokio, see backlog#935); mimalloc stays the unconditional global allocator - docs/development/hotpath-profiling.md documents building, HOTPATH_* env vars, SIGTERM report flow, and how to reproduce the backlog#936 timing reports Refs: https://github.com/rustfs/backlog/issues/935 (HP-14, item 2), https://github.com/rustfs/backlog/issues/936 Co-authored-by: heihutu <heihutu@gmail.com> |
||
|
|
bd5d3c5d92 |
perf(ecstore): data-shards-only lockstep GET reads with stripe-aligned deferred parity engagement (opt-in) (#4392)
* feat(ecstore): add stripe-advance handles for deferred bitrot readers Give DeferredObjectReader a shared pending state and expose a DeferredReaderStripeHandle that advances the still-unopened source by whole bitrot blocks using the same bitrot_encoded_range geometry the reader was created with (identity mapping when hash_size == 0). This lets the GET decode path open a parity shard aligned to the stripe where a data shard failed instead of reading every parity shard on every stripe (backlog#923). An already-opened (or failed) reader rejects the advance so callers retire it rather than engage it out of alignment; bitrot verification after an advance checks the advanced stripe's block against that stripe's stored hash. Co-Authored-By: heihutu <heihutu@gmail.com> * perf(ecstore): read only data shards on healthy lockstep GET behind opt-in gate PR #4289's lockstep fix made every reconstruction-verifying GET read all data+parity shards per stripe; the parity blocks are read, bitrot-hashed and then discarded, a deterministic 2x read-bytes/IOPS/hash-CPU amplification on healthy 2+2 objects (backlog#923). With the new opt-in gate RUSTFS_GET_LOCKSTEP_DATA_SHARDS_ONLY_ENABLE=true (default: false, behavior identical to main): - read_lockstep keeps only the data slots engaged while the object is healthy; parity slots stay unopened deferred readers. - When a data shard is missing or dies at stripe k, parity readers are engaged mid-object by advancing their deferred stripe handle to stripe k, preserving the lockstep alignment invariant from backlog#832. - Degraded stripes engage one parity beyond the decode quorum so reconstruction verification keeps an extra source to check against (erasure.rs only verifies when available > data shards); an engaged parity reader that errors is retired for the rest of the object like any other, and a parity reader that cannot be realigned is retired instead of being read out of position. - fill_deferred_bitrot_readers records stripe handles for deferred slots and, gate-on only, swaps eagerly opened parity readers for unopened deferred ones so they remain engageable mid-object; ready/error bookkeeping used by quorum decisions is untouched. - Both GET paths (legacy duplex via Erasure::decode_with_stripe_handles, codec streaming via ParallelReader::with_deferred_parity_handles) carry the handles from reader setup. Short-read -> UnexpectedEof -> whole-object retirement and the inconsistent-source rejection are unchanged in both gate modes; tests lock the healthy-path data-shards-only call counts, the default read-all-shards behavior, mid-object parity engagement for streaming and hash_size==0 formats, and mid-stream inconsistent-parity rejection. Co-Authored-By: heihutu <heihutu@gmail.com> --------- Co-authored-by: heihutu <heihutu@gmail.com> |
||
|
|
65953bfdb3 | fix(ecstore): reduce rename data version signatures (#4383) | ||
|
|
e7cc719c17 |
perf(ecstore): move speculative PUT-tail tmp cleanup off the hot path (#4389)
* perf(ecstore): move speculative tmp cleanup off the PUT hot path On a successful PUT, rename_data has already moved the data dir out of the tmp workspace, so the delete_all(RUSTFS_META_TMP_BUCKET) at the end of SetDisks::put_object is a speculative no-op safety net. It was awaited inline on the response path, where profiling (backlog#924 / HP-3) showed the same-disk queueing behind fsync-heavy load turns a ~49us no-op into ~9ms average (p99 77ms, macOS F_FULLFSYNC amplified) added to every PUT. Run that cleanup on a spawned task instead, keeping it as a real backstop (rename_data's remove_std only removes empty dirs and silently ignores failures). The failure path (quorum loss / rollback) keeps the cleanup inline so a failed PUT never returns with tmp shards still on disk. If the process dies before the spawned task runs, cleanup_stale_tmp_objects (24h expiry, 5-minute loop) reclaims the entry. Scope note: ops/multipart.rs delete_all on RUSTFS_META_MULTIPART_BUCKET is intentionally untouched; it removes real leftovers and deferring it would widen CompleteMultipartUpload/Abort races. Regression tests (hermetic SetDisks on formatted local disks, no global state): PUT success drains the tmp workspace (polling the spawned task), and PUT failure (missing bucket volume, rename_data quorum error after tmp shards were written) cleans the workspace inline before returning. Ref: https://github.com/rustfs/backlog/issues/924 Co-Authored-By: heihutu <heihutu@gmail.com> * fix(ecstore): do not retry NotFound in reliable_rename_inner reliable_rename_inner blindly retried the rename once on any error. A NotFound retry cannot succeed: nothing recreates the missing source or parent directory between attempts, so the second rename fails identically and speculative cleanup renames (e.g. move_to_trash on an already-removed tmp path) always paid for two syscalls. Extract the retry decision into should_retry_rename: NotFound returns immediately, any other error keeps the existing single retry. This helper is shared by the rename_data commit path via rename_all, so behavior there is covered by a new rename_all success regression test alongside the retry-predicate tests. Ref: https://github.com/rustfs/backlog/issues/924 Co-Authored-By: heihutu <heihutu@gmail.com> --------- Co-authored-by: heihutu <heihutu@gmail.com> |
||
|
|
7efacbdf95 |
fix(filemeta): validate part array lengths in into_fileinfo (#4382)
MetaObject::into_fileinfo indexed part_sizes[i]/part_actual_sizes[i] by part_numbers.len() without checking the arrays are the same length, unlike the adjacent part_etags/part_indices which are length-guarded. decode_from pushes the three arrays independently and the xl.meta CRC only covers bytes, so a CRC-valid but internally inconsistent xl.meta (foreign writer / MinIO interop) triggers an out-of-bounds panic on the GET/HEAD/LIST decode path. Guard the three arrays for equal length and return Err(FileCorrupt) so a divergent shard is skipped and quorum uses the other disks, instead of panicking the request task. Cascade into_fileinfo to Result across its callers, and fix io_primitives early-return to derive the version id from the merged header and fall into the per-disk loop (single-disk survival + heal). The 2118 merge-first path is left as a documented follow-up. Refs backlog#900 (filemeta-01). |
||
|
|
62a31e4ec4 | fix(storage): address pending metadata and health gaps (#4380) | ||
|
|
742a59884d | test(ecstore): add validation suite coverage gates (#4378) | ||
|
|
58114f49f2 |
perf(ecstore): k-way heap merge for ListObjects, drop clone-to-parse (#4347)
* perf(ecstore): replace linear merge scan with k-way heap and drop clone-to-parse (backlog#874 backlog#875) merge_entry_channels advanced the k-way merge with a linear scan over all channel heads (O(entries x channels)) and allocated two fresh Strings per pairwise comparison via path::clean. Every step also cloned MetaCacheEntry values, including entry.clone().xl_meta() clone-to-parse calls. - Introduce MergeHead with a cached cleaned name (allocated only when the raw name is not already clean) and drive the merge with a BinaryHeap of boxed heads: O(log channels) per entry, allocation-free comparisons. - Move entries through the merge instead of cloning; the winner is sent without an intermediate copy. - Remove the dead merge_file_meta_versions block: it only ran for prefix-dir groups whose entries have empty metadata, so xl_meta() always failed; cross-drive version merging happens in the resolve path. - Keep legacy same-name semantics (dir groups collapse, objects shadow prefix dirs, later object candidate wins) and add regression tests for interleaved ordering, dir/object precedence, uncleaned-name grouping, and prefix-dir collapse. Co-Authored-By: heihutu <heihutu@gmail.com> * fix(ecstore): honor ascending versions_sort in ListObjects walk (#4348) * fix(ecstore): honor ascending versions_sort in walk and document ordering invariant (backlog#876) The walk loop carried a bare `//TODO: SORT` inside the `WalkVersionsSortOrder::Ascending` branch, so the requested ascending order was silently ignored and versions streamed newest-first (the raw FileMeta order). WalkOptions defaults to Ascending, so every default walker -- notably replication resync, which replays versions and needs oldest-first to preserve the version-stack order -- received the exact opposite of the contract. FileMeta maintains versions newest-first (sort_by_mod_time is descending) and into_file_info_versions preserves that order, so ascending emission is the exact reverse of file_info_versions output. Reverse in place when ascending is requested and add a regression test locking the newest-first invariant plus the reversal contract. Key-ordering audit result (no gap found): per-disk walkers emit sorted streams, merge_entry_channels performs an ordered k-way merge, and gather_results only filters by marker/limit, so ListObjects key order is guaranteed upstream and needs no post-sort. Co-Authored-By: heihutu <heihutu@gmail.com> * perf(ecstore): enable GET metadata early-stop by default (#4349) * perf(ecstore): enable GET metadata early-stop by default with env opt-out (backlog#872) The metadata early-stop fanout (read_all_fileinfo_early_stop) has been implemented and instrumented for a while but stayed behind an opt-in flag, so default GETs always waited for every disk to answer the metadata read even after quorum agreement was reached. Flip RUSTFS_GET_METADATA_EARLY_STOP_ENABLE to default-on. The gate stays conservative: should_allow_metadata_early_stop only admits metadata-only reads (read_data=false) without version_id, healing, or free-version requirements, everything else falls back to the full-wait fanout, and setting the env var to false restores the old behavior entirely. The version-aware gate (RUSTFS_GET_METADATA_VERSION_EARLY_STOP_ENABLE) remains opt-in because versioned reads carry a higher stale-selection risk profile. Also replace the stale "optimize concurrency" TODO in get_object_fileinfo with a pointer to the early-stop implementation and add regression tests for the new default plus the explicit opt-out path. Co-Authored-By: heihutu <heihutu@gmail.com> * perf(ecstore): lazily construct codec streaming multipart readers (#4350) * perf(ecstore): lazily construct codec streaming multipart part readers (backlog#871) get_object_decode_reader_with_fileinfo opened shard readers for every part of a multipart object before returning the streaming reader, so TTFB paid for parts x disks file opens up front and an early client disconnect wasted the setup work for every unread part. Replace the eager loop with LazyMultipartCodecStreamingReader: the first part is still built eagerly so the dominant fallback conditions (missing shards / read quorum) are detected before any byte is streamed and the whole request can fall back to the legacy duplex path exactly as before. Each subsequent part is built on demand -- when the previous part hits EOF -- via a spawned task handle owned by the reader; dropping the reader aborts an in-flight build so disconnects stop all further IO. If a later part hits a fallback condition mid-stream (a shard vanished after the request started), the reader surfaces an explicit read error with a pipeline-failure metric instead of silently degrading; the client's retry then detects the condition on the eager first-part setup and takes the legacy path cleanly. Adds unit tests for in-order streaming across lazy boundaries, deferred construction (no build when the client stops within part 1), and the mid-stream fallback error path. Co-Authored-By: heihutu <heihutu@gmail.com> * perf(ecstore): prefetch next multipart part reader setup during decode (#4351) * perf(ecstore): prefetch next multipart part reader setup during decode (backlog#870) get_object_with_fileinfo processed multipart parts strictly serially: the next part's bitrot reader setup (file opens + read-quorum wait across all disks) only started after the current part finished decoding, so large multipart reads paid full setup latency between every part. Overlap the two stages with a depth-one pipeline: right after the current part's readers are obtained, the next part's setup is spawned (shared inputs behind Arc) and joined when the loop reaches that part. The shared setup_multipart_part_readers helper keeps stage-duration metrics semantics identical for both paths; a failed or stale prefetch falls back to the synchronous setup, and the PrefetchedReaderSetup guard aborts the in-flight task on error returns, early breaks, or caller drop so disconnects stop background disk IO. Gate: RUSTFS_GET_MULTIPART_READER_SETUP_PREFETCH (default on, env opt-out). Adds a three-part end-to-end read test covering the prefetch hit path and cross-part content ordering. Co-Authored-By: heihutu <heihutu@gmail.com> * perf(ecstore): move FileInfo through GET shuffle instead of cloning (#4352) perf(ecstore): move FileInfo entries through the GET shuffle instead of cloning (backlog#873) shuffle_disks_and_parts_metadata_by_index deep-cloned every valid FileInfo (parts, erasure info, metadata map) once per disk on each GET. Add an ownership-taking variant that runs the same by-index consistency check as a read-only first pass and then moves entries into their shuffled slots with mem::take, and switch get_object_with_fileinfo to it -- that call site already owned the parts metadata vector. Disk handles are Arc clones and stay cheap. Scope notes from the backlog#873 audit: - get_object_fileinfo's disks.clone() stays: DiskStore is Arc<Disk>, so the clone is per-slot refcounting and correctly avoids holding the RwLock read guard across the metadata fanout awaits. - get_object_decode_reader_with_fileinfo keeps the borrowing shuffle: its caller must retain files/disks for the legacy fallback path, so an owned variant would just shift the same clone upstream. - The metadata-cache hit path still clones parts_metadata; sharing the cached entry via Arc changes the read-path return types and is left as a follow-up. Equivalence tests cover both the by-index placement and the mod-time fallback against the borrowing variant. Co-authored-by: heihutu <heihutu@gmail.com> --------- Co-authored-by: heihutu <heihutu@gmail.com> --------- Co-authored-by: heihutu <heihutu@gmail.com> --------- Co-authored-by: heihutu <heihutu@gmail.com> --------- Co-authored-by: heihutu <heihutu@gmail.com> * fix(ecstore): gate merge emission on cleaned key and clear clippy redundant_clone Address review + CI findings on the ListObjects/GET optimization PR: - merge_entry_channels gated emission on the raw entry name while the heap orders by the cleaned key, so entries whose cleaned order and raw byte order disagree (e.g. redundant slashes) could be dropped. Gate on the same cleaned sort key the heap uses; add a regression test (`a//c` after `a/b`). - Drop three redundant `.clone()` calls in test code flagged by clippy::redundant_clone (owned-shuffle equivalence tests and the walk ascending-versions contract test) that failed the CI clippy gate. - Document the known mid-stream fallback limitation of the opt-in multipart codec streaming reader (default off) and mark the in-place per-part legacy degradation as a follow-up. Co-Authored-By: heihutu <heihutu@gmail.com> * fix(ecstore): force full metadata fanout for object tagging writes (backlog#872) put_object_tags reads the object fileinfo with read_data=false and then writes the updated tags to the online-disk set that read returned. With metadata early-stop enabled by default, that read now returns as soon as read quorum is reached, so the online-disk set is only a read-quorum subset. Writing tags to that subset fails write quorum -> ErasureWriteQuorum -> S3 SlowDown, which is exactly the s3-tests tagging failures (PutObjectTagging/DeleteObjectTagging, reached max retries). Thread a caller-controlled `allow_early_stop` gate through read_all_fileinfo_observed/_inner and add get_object_fileinfo_gated; put_object_tags calls it with allow_early_stop=false so the metadata read does the full quorum fanout and returns the complete online-disk set as the write target. Pure-read callers (GET/HEAD/tag read) keep the early-stop fast path unchanged. Extract metadata_early_stop_permitted() as the single gate and add a unit test locking the invariant: caller opt-out (and observe=false, and data reads) never early-stop even with the env flags on. Co-Authored-By: heihutu <heihutu@gmail.com> --------- Co-authored-by: heihutu <heihutu@gmail.com> |
||
|
|
c9b976ad46 |
feat(ecstore): reclaim orphaned data dirs during heal (#4356)
* feat(ecstore): reclaim orphaned data dirs during heal (backlog #3231/#3191) Pre-#3510, an unversioned overwrite leaked one UUID-named data dir per PUT. The write path now cleans up going forward, but pre-existing strays stay on disk forever: heal's dangling logic only removes whole objects whose data is missing, never surplus data dirs of an otherwise-healthy object. That leaked space is what eventually made ListObjects scan tens of GB and time out. Add SetDisks::reclaim_orphan_data_dirs, a fail-closed, quorum-safe sweep that mirrors purge_orphan_dir_object: - referenced set is the UNION of get_data_dirs() across every online replica's xl.meta, so a dir named by any replica is kept; - if a disk holds the object dir but its xl.meta is missing or unparseable the object is treated as degraded and nothing is removed; - only UUID-named subdirectories are ever considered. Wire it into heal_object's healthy tail (under the object write lock) as a best-effort step so the background heal scanner and admin heal recover the leaked space automatically; a reclaim failure never fails the heal. Covered by four unit tests: removal of an unreferenced dir, no-op when all dirs are referenced, fail-closed abort on missing metadata, and cross-replica union preservation. Co-Authored-By: heihutu <heihutu@gmail.com> * docs(ecstore): fix typo unparseable -> unparsable Satisfies the repository typos CI check. Co-Authored-By: heihutu <heihutu@gmail.com> --------- Co-authored-by: heihutu <heihutu@gmail.com> |
||
|
|
5594b18912 |
fix(ecstore): make delete_volume non-recursive by default to prevent bucket-heal wipe (backlog#799 B1) (#4339)
* fix(ecstore): make delete_volume non-recursive by default to prevent bucket-heal wipe (backlog#799 B1) `delete_volume` unconditionally `remove_dir_all`'d the whole bucket tree, and the bucket-heal "remove" branch called it fire-and-forget on every local disk. A mis-classified "dangling" bucket (or a non-force S3 DeleteBucket on a populated bucket) was therefore recursively wiped — a potential whole-bucket data loss. The `VolumeNotEmpty` -> recreate/`BucketNotEmpty` handling already present in both delete_bucket paths was dead code because the primitive never refused. Add an explicit `force_delete` flag to `DiskAPI::delete_volume` and default the non-force path to a non-recursive `remove_dir` (rmdir), which fails atomically with `VolumeNotEmpty` if the bucket still holds any object data. Only an explicit force delete (S3 force bucket delete) removes recursively. Mirrors MinIO's `xlStorage.DeleteVol` (`Remove` vs `RemoveAll`). - Trait + all impls (local behavior, dispatch, disk_store, remote RPC) take the flag; the gRPC `DeleteVolumeRequest` gains a `force` field (proto3 default false → old peers get the safe non-recursive behavior on rolling upgrade). - Heal remove branch passes `false` and no longer discards the result: a `VolumeNotEmpty` refusal is logged (the bucket is not dangling) instead of wiping data. - Both `delete_bucket` paths pass `opts.force`, activating the previously-dead `VolumeNotEmpty` -> `BucketNotEmpty`/recreate handling (correct S3 semantics). Adds a regression test: non-force delete of a non-empty bucket returns VolumeNotEmpty and preserves the data; force delete removes it. Design converged by two independent expert reviews (MinIO-fidelity + defense-in-depth) referencing MinIO xl-storage.go. Refs backlog#799 (B1), issue rustfs/backlog#850. The safety expert's deeper hardening (typed capability instead of a bool, trash-instead-of-in-place for force, quorum re-verification of dangling) is noted on #850 as follow-up. * fix(ecstore): reword 'mis-classified' -> 'misclassified' to satisfy typos (backlog#799 B1) * fix(rustfs): thread force_delete through StorageDiskRpcExt::delete_volume + test literal (backlog#799 B1) |
||
|
|
b813fc7739 |
fix(ecstore): reject zero erasure block_size in codec streaming read (#4340)
The codec streaming GET reader divides by erasure.block_size in build_codec_streaming_part_reader without validating the erasure dimensions, unlike the legacy multipart path which already rejects block_size==0 / data_shards==0. FileInfo::is_valid() does not check block_size, so corrupted on-disk metadata (block_size==0, data_blocks>0) passes validation and panics the read task with a divide-by-zero. Add Erasure::has_valid_dimensions() and reject invalid dimensions at the codec streaming entry before any disk access, mirroring the legacy guard (which now reuses the same predicate). Refs backlog#868 (868-1). |
||
|
|
3bc8d79fe5 |
fix(multipart): serialize put_object_part per uploadId (#4329)
fix(multipart): serialize the part commit per uploadId to prevent mixed-generation shards (backlog#853) Concurrently re-transmitting the same part could land two shard generations across different disks: each shard is individually bitrot-valid, so the corruption surfaces only as a silent mix at read time. The hazard is confined to rename_part, where two temp parts are moved cross-disk onto the SAME final part path — interleaving there can leave shards from two generations. Each concurrent stream already writes to its own unique temp dir, so the encode/stream phase never conflicts and must stay lock-free: holding a lock across it would serialize slow re-transmits of the same part, break the S3 'last finisher wins' semantics, and cause UploadPart lock-acquire timeouts (ServiceUnavailable). Scope a write lock to the uploadId namespace around only the rename_part commit, so each commit is atomic across disks and the last committer wins consistently. Mirrors MinIO's per-uploadID NS lock; disjoint from the object lock held by complete_multipart_upload (no lock-ordering cycle). Honors opts.no_lock. The pre-delete-before-rename half of backlog#853 was already fixed in #4316; this completes the issue. Adds an end-to-end regression test: six concurrent resends of the same part must all succeed (no lock-acquire timeout), exactly one generation stays visible, and the reassembled object equals one intact generation. Closes rustfs/backlog#853 |
||
|
|
6297d112c3 |
fix: auto-repair breaking changes from 1b3727e2 (#4324)
fix(heal): remove useless .into() conversion in heal error path Clippy flagged a useless_conversion lint at heal.rs:491 where is redundant because is already of type . |
||
|
|
31c6859965 |
chore: converge stale TODOs and apply safe fills (backlog#646) (#4322)
Second TODO-convergence round over the current tree (backlog#646). All line numbers in the old inventory had gone stale after the set_disk / diagnostics / cluster refactors, so this re-scans and reduces the marker count from 144 to 99. STALE removals (comment describes already-implemented behavior, or dead commented-out blocks) across ecstore (set_disk ops/core, store, cluster/rpc, bucket/metadata_sys, services), iam, filemeta, s3select and rustfs auth/object_usecase. No behavior change. Safe fills, each verified: - filemeta: replication_info_equals now also compares replication_state_internal (function currently has no callers; adds a regression test). - bitrot: drop the confirmed-unused `_want` parameter from bitrot_verify and the now-unused `sum` on LocalDisk::bitrot_verify, removing a Bytes::copy_from_slice allocation. Streaming verify uses the file's embedded per-shard hash, never the passed sum. - signer: rename v4_ignored_headers -> V4_IGNORED_HEADERS and drop the non_upper_case_globals allow. - admin/heal: test_decode was #[ignore]d and used serde_urlencoded on a JSON body (would panic); rewire to serde_json::from_slice to match the production decode path, add assertions, un-ignore. Verified: cargo fmt; cargo check on touched crates; tests pass (filemeta, signer, bitrot, heal::test_decode); arch guardrail scripts pass. |
||
|
|
83edafb11f |
fix(ecstore): reject renewing a mis-mounted drive into the wrong set (backlog#799 B19) (#4321)
fix(ecstore): reject renewing a misplaced drive into the wrong set (backlog#799 B19) `renew_disk` located the drive's (set, disk) position from its own format via `find_disk_index`, but never checked that the resolved `set_idx` matched this `SetDisks`' own `set_index` before inserting the drive into `self.disks`. A drive whose format places it in another set would be claimed by this set too, so two sets could manage the same drive and degrade together. Skip (with a warning) when `set_idx != self.set_index`. Refs backlog#799 (B19), tracked in rustfs/backlog#863. |
||
|
|
1b3727e2c4 |
fix(heal): clean up .rustfs/tmp healed shards on all failure paths (backlog#799 B20) (#4319)
heal_object wrote healed shards to .rustfs/tmp/<uuid>/ and only removed that tmp dir on the success path (the final delete_all). Three early exits after the tmp shards were written leaked the dir: - `erasure.heal(...)?` failing midway, - the `disks_to_heal_count == 0` early return, and - the `?` on the post-rename remote data-dir delete. Add the tmp cleanup before the first two, and downgrade the remote data-dir cleanup failure to a warning (the healed shard is already renamed into place, so that cleanup failing must not abort the heal or leak the tmp shards). Refs backlog#799 (B20), tracked in rustfs/backlog#863. |
||
|
|
8ffe230a89 |
fix(multipart): don't pre-delete the committed part before rename_part (backlog#853) (#4316)
`rename_part` unconditionally ran `cleanup_multipart_path([part.N, part.N.meta])` on every disk *before* the per-disk rename fan-out. The per-disk `rename_part` already replaces `part.N` atomically (std::fs::rename) and rewrites `part.N.meta`, so the pre-delete is redundant — and destructive: it removed an already-committed (ACKed) part on all disks before the new rename landed, so a re-upload of the same part that then failed write quorum destroyed the committed part outright. Remove the pre-rename cleanup and rely on the atomic rename to overwrite in place; the quorum-failure rollback below is unchanged. No behaviour change on the success paths (first upload / retry both end with part.N replaced), verified by the existing multipart suite. Refs backlog#799 (B4). The remaining half of B4 — serializing concurrent same-part uploads with an uploadId-scoped lock so two generations can't be mixed across disks — is a larger concurrency change tracked as a follow-up on the issue. |
||
|
|
1cc1fc0f83 |
fix(ecstore): exclude failed-shard disks from commit so write quorum isn't inflated (backlog#852) (#4309)
`MultiWriter` recorded per-writer failures only in a private `errs` vector and nulled the failed writer locally, but `put_object` never used that to prune the disk set: `rename_data` ran over the full `shuffle_disks`, so a disk that took a short/failed write still had its truncated shard renamed into place and counted as an online disk. The object then claimed N good shards while one was short/corrupt, so a single later disk failure could drop it below reconstructable quorum — silent data loss. - `write_shard`: null the writer on a generic write error too (not only on `ShortWrite`), so a failed writer is uniformly represented as `None` — matching `shutdown_writer`, which already does this. - `put_object`: after encode, drop every disk whose writer failed (`drop_failed_writer_disks`) from `shuffle_disks` before `rename_data`, and re-check write quorum over the survivors. `rename_data` already re-checks quorum and rolls back if too few disks remain, so excluded disks are neither committed nor counted (MinIO sets failed writers to nil before `renameData`). Adds unit tests for the exclusion/quorum accounting. Refs backlog#799 (B3). |
||
|
|
3e4c15da5d | fix(object-lock): prevent locked version deletes (#4297) | ||
|
|
a8d8e56478 | refactor(ecstore): relocate NamespaceLocking + finalize God-Object split (backlog#822) (#4291) | ||
|
|
f737b39cfc |
refactor(ecstore): move ObjectIO/ObjectOperations into set_disk::ops::object (backlog#821) (#4290)
P6 of the SetDisks God-Object split (tracking backlog#815, issue backlog#821; depends on P5 #4288). Relocate the core object read/write hot-path contract impls out of the set_disk/mod.rs God-Object into their own module home: - impl ObjectIO for SetDisks (~1,010 lines) -> set_disk/ops/object.rs - impl ObjectOperations for SetDisks (~1,137 lines) -> set_disk/ops/object.rs - Registered pub(crate) mod object; under set_disk/ops. Pure move — zero logic change. Both contracts stay implemented for SetDisks, so their EcstoreObjectIO / EcstoreObjectOperations associated-type bounds are unchanged and the contract-compat tests still guard them. Method bodies are moved verbatim: a whitespace-insensitive token-stream diff of the two impl blocks (with their #[async_trait::async_trait] attributes) against the pre-move mod.rs source is byte-identical (5,754 tokens each) — NO visibility widening was required, because the impls reach SetDisks helpers and the P5 io_primitives through inherent self./Self:: calls that resolve across modules unchanged. The two inherent impl SetDisks blocks that sat between the trait impls (lock batch helpers) remain in mod.rs, verified present exactly once and uncorrupted. The issue's borrow/Arc-clone-avoidance optimization is intentionally deferred: it is a perf-sensitive change requiring the #738 benchmark and would risk the 'byte-level behavior unchanged' acceptance; this PR delivers the relocation. Verification: - cargo check / clippy -D warnings -p rustfs-ecstore --all-targets: clean - cargo test -p rustfs-ecstore --lib: 1841 passed, 0 failed - all five arch guard scripts: pass - token-stream diff of moved impls vs original: identical (mod.rs diff is a pure relocation; ObjectIO/ObjectOperations each defined exactly once post-move) |
||
|
|
166679a723 |
refactor(ecstore): sink write + shared-read primitives into set_disk::core::io_primitives (backlog#820) (#4288)
* refactor(ecstore): sink write/rename/delete primitives into set_disk::core::io_primitives (backlog#820) P5 step 2 of the SetDisks God-Object split (tracking backlog#815, issue backlog#820; follows step 1 #4285). Relocate the entire set_disk/write.rs primitive family into the core/io_primitives.rs module home established by step 1: - Module items: dangling_delete_grace() + its env consts, and the OrphanDirScan scan-result enum. - impl SetDisks write/rename/delete primitives shared across mod.rs and the ops/ operation families: rename_data, commit_rename_data_dir, cleanup_multipart_path, rename_part, eval_disks, write_unique_file_info, update_object_meta(_with_opts), delete_if_dangling, delete_prefix, scan_orphan_dir, purge_orphan_dir_object, check_write_precondition, default_read_quorum, default_write_quorum. - The dangling_delete_grace unit tests move with their subject. set_disk/write.rs is removed and 'mod write;' dropped from mod.rs. Pure move + visibility adjustment, zero logic change. Method bodies are moved verbatim; pub(super) items are widened to pub(in crate::set_disk) so mod.rs / ops still reach them (write.rs's super was set_disk; the deeper module needs the explicit path to preserve identical reach). Callers use inherent self./Self:: calls, so no call sites change. Verification: - cargo check / clippy -D warnings -p rustfs-ecstore --all-targets: clean - cargo test -p rustfs-ecstore --lib: 1841 passed, 0 failed (moved dangling_delete_grace tests run as set_disk::core::io_primitives::tests::*) - all five arch guard scripts: pass - token-stream diff of moved block vs original write.rs: identical modulo visibility tokens (2713 tokens each) * refactor(ecstore): sink shared read primitives into set_disk::core::io_primitives (backlog#820) P5 step 3 (final) of the SetDisks God-Object split (tracking backlog#815, issue backlog#820; follows steps 1 #4285 and 2). Relocate the shared, low-level metadata/erasure READ PRIMITIVE methods out of set_disk/read.rs into the core/io_primitives.rs module home, leaving the object-read operation itself in read.rs. Moved into a new impl SetDisks block in io_primitives.rs (verbatim bodies): - read_parts, read_all_fileinfo (+ _observed / _inner / _full_wait / _early_stop variants), read_all_xl, load_file_info_versions_exact, read_all_raw_file_info, pick_latest_quorum_files_info, read_multiple_files. - The should_allow_metadata_early_stop free helper (called by the moved read_all_fileinfo_observed). Kept in read.rs: the object-read operation and its private helpers (read_version_optimized, get_object_fileinfo, get_object_info_and_quorum, try_get_object_direct_data_shards_with_fileinfo, get_object_with_fileinfo, get_object_decode_reader_with_fileinfo, build_codec_streaming_part_reader) and the metadata-cache helpers/tests. read.rs reaches the moved primitives through the SetDisks core (inherent self./Self:: calls; the sole cross-boundary edge, get_object_fileinfo -> read_all_fileinfo_observed, is handled by widening that one method to pub(in crate::set_disk)). Pure move + visibility adjustment, zero logic change. pub(super) primitives are widened to pub(in crate::set_disk); the three internal-only fanout variants (_inner/_full_wait/_early_stop) stay private; load_file_info_versions_exact stays pub(crate). Method bodies are moved verbatim. Verification: - cargo check / clippy -D warnings -p rustfs-ecstore --all-targets: clean - cargo test -p rustfs-ecstore --lib: 1841 passed, 0 failed - all five arch guard scripts: pass - token-stream diff of the three moved regions vs original read.rs: identical modulo visibility tokens, the new impl-block scaffolding, and rustfmt signature re-wrapping (read.rs diff is pure deletion, zero added lines) |
||
|
|
46bb7e52b6 |
refactor(ecstore): extract read IO primitives to set_disk::core::io_primitives (backlog#820) (#4285)
* refactor(ecstore): extract read IO primitives to set_disk::core::io_primitives (backlog#820) P5 step 1 of the SetDisks God-Object split (tracking backlog#815, issue backlog#820). Relocate the module-level low-level read/erasure primitives out of the 5,898-line set_disk/read.rs into a new core/io_primitives.rs module home: - Metadata-fanout quorum machinery (MetadataQuorumAccumulator, MetadataFanoutDiagnostics/Observation, MetadataEarlyStopDecision, MetadataCacheLookup). - Bitrot reader scheduling/creation (BitrotReaderSetup and the create_bitrot_readers_* / schedule / fill helpers). - Shard-cost classification, read-repair heal dedup, and codec-streaming reader helpers. Pure move + visibility adjustment, zero logic change. The moved block is byte-identical to the pre-move read.rs source modulo the module header swap (use super::* -> use super::super::*) and item visibility widening (module-private / pub(super) -> pub(in crate::set_disk) so read.rs still reaches them). read.rs's impl SetDisks body and imports are byte-identical to before; mod.rs only gains 'mod core;' and repoints two GetCodecStreamingReaderBuildOutcome references to the new path. Verification: - cargo check / clippy -D warnings -p rustfs-ecstore --all-targets: clean - cargo test -p rustfs-ecstore --lib: 1840 passed, 0 failed - normalized + token-stream diff of moved block vs original: identical modulo visibility tokens and rustfmt signature re-wrapping * fix(arch): allow io_primitives as READ_REPAIR_HEAL_CACHE owner (backlog#820) The READ_REPAIR_HEAL_CACHE owner-path guard in check_architecture_migration_rules.sh pinned the cache to set_disk/read.rs. P5 step 1 relocated the cache (and its accessor helpers) to set_disk/core/io_primitives.rs, so allow that path too. Guard intent is unchanged: the cache stays behind the ECStore set-disk read-owner helpers. --------- Co-authored-by: houseme <housemecn@gmail.com> |
||
|
|
1f51d21d33 | refactor(ecstore): move List/Bucket Operations into set_disk::ops (backlog#819) (#4282) | ||
|
|
a164645ff6 | refactor(ecstore): move MultipartOperations into set_disk::ops::multipart (backlog#818) (#4279) | ||
|
|
db277b17a4 |
fix: harden GET object performance paths (#4271)
* fix: harden GET object performance paths * fix: satisfy GET multipart layer guard * fix: keep v1 list markers S3 compatible * perf: tighten GET direct-memory decision * ci: isolate s3tests from scanner workload * refactor: simplify get object body lifecycle * fix: satisfy get object clippy |
||
|
|
26d6c06e03 |
fix: auto-repair breaking changes from 0271d7aa (#4276)
|
||
|
|
a6b3e4f5d6 | refactor: use canonical Rust module paths (#4269) | ||
|
|
55ad8df1c2 |
refactor(ecstore): move HealOperations into set_disk::ops::heal (backlog#817) (#4267)
P1 of the SetDisks split (tracking backlog#815, depends on P0 backlog#816). Give the Heal operation family its own module home: relocate set_disk/heal.rs to set_disk/ops/heal.rs and move the HealOperations storage-api contract impl beside its inherent helpers. The contract stays implemented for SetDisks so its associated-type bounds are unchanged (ecstore_contract_compat_test still covers it). Method bodies are moved unchanged. The four inherent helpers widen from pub(super) to pub(in crate::set_disk) to preserve their exact prior visibility from the deeper module. get_pool_and_set now reads topology through SetDisksCtx to keep the Heal family aligned with the P0 borrow pattern; the read is provably identical (ctx.format()/pool_index() alias the core fields). Runtime behavior is unchanged. |
||
|
|
86eafc799b |
refactor(ecstore): add SetDisks borrow context for God-Object split (backlog#816) (#4265)
P0 of the SetDisks split (tracking backlog#815). Introduce SetDisksCtx<'a>, a Copy borrow handle over the shared SetDisks core state (topology/config, disks, locker trio), and validate the pattern by routing the List family's delete_all through a ListOperations<'a> service unit. No trait impl is moved and runtime behavior is byte-for-byte unchanged; the public SetDisks::delete_all entry point is preserved and delegates to the borrow-based unit. |
||
|
|
e3a8234bc9 |
fix: 12 P1 reliability/security defects from the full-repo audit (backlog#806) (#4256)
* fix(rio): reject corrupted short compressed/encrypted blocks instead of panicking DecompressReader::poll_read and DecryptReader::poll_read sliced the block body with a fixed `[0..16]` index to read the length varint. The body length comes from an untrusted 24-bit header field, so a corrupted/truncated block shorter than 16 bytes made the slice panic and crash the request task — a read-path DoS on GET of tiered/corrupted data. Pass the whole (arbitrary-length-safe) slice to uvarint and reject a non-positive or out-of-range length prefix with InvalidData. Adds a repro test for each reader; all existing round-trip tests still pass. Refs rustfs/backlog#812 * fix(utils): close SSRF bypass via IPv4-mapped IPv6 addresses validate_outbound_ip branched on the IpAddr variant, and the V6 branch's is_loopback/is_unicast_link_local/is_unique_local checks never inspect the embedded IPv4 of an IPv4-mapped address (::ffff:a.b.c.d). The metadata guard also only matched the plain V4 169.254.169.254. So ::ffff:127.0.0.1, ::ffff:10.0.0.5 and ::ffff:169.254.169.254 all passed the outbound guard, letting an attacker reach loopback/private/metadata endpoints. Normalize IPv4-mapped IPv6 to its embedded IPv4 (via to_ipv4_mapped, which matches only the true mapped form) before classification. Adds reject tests for mapped loopback/private/metadata and an allow test for public IPv6. Refs rustfs/backlog#813 * fix(ecstore): streaming last-part loss, GCS tier Range/remove, stat_all_dirs alignment Four confirmed data-reliability defects: - put_object_multipart_stream: the CompleteMultipartUpload part-collection loop used exclusive `1..total_parts_count`, dropping the final part (and collecting zero parts for a single-part object) — silently truncating the completed object. Extracted collect_complete_parts (1..=total_parts_count) with unit tests. - GCS warm backend get() ignored the requested byte range, returning the whole object for a Range GET; now applies ReadRange::segment like the other backends. - GCS warm backend remove() was an empty stub, so deleting a tiered object left it on GCS forever; now deletes via StorageControl (added a control-plane client), and in_use() actually lists (prefix-scoped) instead of always returning false. - stat_all_dirs skipped None disk slots and dropped JoinErrors, returning a compressed, misaligned error vector; heal_object_dir then zipped it against the full disks array and could make_volume on the WRONG disk. Now returns one index-aligned entry per slot (None -> DiskNotFound), and heal no longer pre-fills the drive report (which would double it). Added an alignment test. Refs rustfs/backlog#807 * fix(kms): stop Vault backend from destroying/reviving keys on failure Two confirmed key-safety defects in the Vault KV2 backend: - get_key_material() 'self-healed' a decrypt or wrong-length failure by minting a fresh random master key and overwriting the stored value. That destroys the original key material, making every DEK ever wrapped by it permanently undecryptable. Decryption must never mutate the stored key: both branches now return a cryptographic_error instead. (The empty-material bootstrap path, which only fills a never-initialized key, is intentionally left intact.) - cancel_key_deletion() reset key_state to Enabled only in the returned response and never persisted it, so the key stayed PendingDeletion in storage and would still be reaped. It now writes the state back via update_key_metadata_in_storage and fails the request if the write fails. Adds ignored (Vault-requiring) integration tests documenting both behaviours. The third item (VaultTransit key state only in memory -> revived as Enabled after restart) is deferred: a fail-closed guard would break restart availability for all transit keys; the correct fix needs a persistent metadata store + Vault integration testing. Tracked in rustfs/backlog#808. Refs rustfs/backlog#808 * fix(admin): clamp STS AssumeRole duration; persist ImportBucketMetadata to disk Two confirmed admin-API defects: - Standard AssumeRole used the raw client-supplied DurationSeconds with no upper bound, so a caller could mint near-permanent temporary credentials. Clamp it to the AWS/MinIO STS window [900, 43200] (with 0 -> default 3600) via a shared clamp_assume_role_duration helper, and build the exp claim with saturating_add. This matches the existing AssumeRoleWithWebIdentity path. - ImportBucketMetadata only mutated an in-memory map and returned 200, silently dropping every imported config. It now persists each non-empty config via metadata_sys::update (which merges onto existing on-disk metadata) and returns InternalError if a write fails. Mapping extracted to imported_configs_to_persist with unit tests. Refs rustfs/backlog#809 * fix(heal): enqueue displacing request in release builds push_displacing_lower_priority folded the real enqueue call into debug_assert_eq!(self.push(request), Accepted). In release builds (debug_assertions off) the whole macro — including its argument — is compiled out, so after evicting a lower-priority queued item the new high-priority request was silently dropped and never healed. Hoist self.push(request) out of the assertion so the side effect runs in all builds. Adds a --release regression test. Refs rustfs/backlog#811 * fix(iam): propagate real delete_policy backend errors instead of swallowing them delete_policy's is_from_notify path had its error handling inverted: a real backend failure (disk IO / insufficient quorum) evicted the cache and returned Ok(()), reporting a phantom success while policy.json survived on disk (to be reloaded on the next full IAM reload); NoSuchPolicy — which should be idempotent success — returned Err. Propagate real errors and let NoSuchPolicy fall through to the idempotent cache-evict + Ok, matching delete_user / the notification handler in the same file. Adds a backend-error-injection regression test. Refs rustfs/backlog#810 * fix(utils): also normalize IPv4-compatible IPv6 in the SSRF guard The initial fix only unwrapped IPv4-mapped (::ffff:a.b.c.d) addresses; the deprecated IPv4-compatible form (::a.b.c.d, e.g. ::127.0.0.1 / ::169.254.169.254) still bypassed the guard. Reject pure-IPv6 specials (::, ::1, fe80::, fc00::) first, then normalize BOTH embedded-IPv4 forms before the IPv4 rules. Adds tests for compatible-form loopback/metadata and confirms ::1 / :: stay rejected. Found by adversarial review of the initial fix. Refs rustfs/backlog#813 * fix(ecstore): fix the same last-part loss in the parallel streaming path put_object_multipart_stream_parallel had the identical off-by-one (1..total_parts_count) that truncated the last part / produced zero parts for a single-part upload — reachable when concurrent stream parts are enabled. Reuse collect_complete_parts, which now returns an error instead of panicking on a gap in the parts map. Adds a missing-part error test. Found by adversarial review of the initial fix. Refs rustfs/backlog#807 * fix(kms): local backend must preserve key material on status change LocalKmsClient (the default KMS backend) regenerated the master key material on enable_key/disable_key/schedule_key_deletion/cancel_key_deletion — a pure status change. A single disable+enable cycle therefore destroyed the original key, making every DEK ever wrapped by it permanently undecryptable (silent data loss, no network needed). Preserve the existing material via get_key_material and re-save with only the status changed. Adds a hermetic regression test that wraps a DEK, cycles all four status methods, and asserts the DEK still decrypts. Found by adversarial review of the Vault fix. Refs rustfs/backlog#808 * test(rio): cover the length-prefix guard; correct its comment Add a DecompressReader test that feeds an unterminated length varint so uvarint returns 0 and the new guard (not the downstream codec) produces the InvalidData error, and reword the guard comment which overclaimed that the > len bound prevents a reachable panic (it is belt-and-suspenders). No behavior change. Found by adversarial review. Refs rustfs/backlog#812 * test(rio): build test block headers via vec! to satisfy clippy The new corrupted-block tests built the header with Vec::new() + repeated push, tripping clippy::vec_init_then_push (-D warnings in CI). Construct the fixed header bytes with vec![] instead. No behavior change. --------- Co-authored-by: houseme <housemecn@gmail.com> |
||
|
|
3d4fb532e5 | refactor(replication): own filemeta wire contracts (#4254) | ||
|
|
6a81445a96 | refactor(replication): isolate ecstore owner contracts (#4239) | ||
|
|
92402a3bde |
perf(get,heal): fix GET hot-path overhead and heal checkpoint scaling (#4237)
perf(get,heal): land verified fixes for backlog #800-#804 Five fixes from the GET-path performance audit and scanner/heal completeness audit (rustfs/backlog#800..#804), each verified locally: - backlog#800 (heal checkpoint O(N^2)): ResumeCheckpoint object sets are now HashSet (Vec::contains was O(n) per healed object; 1.5ms at n=1M vs 11ns measured), and per-object checkpoint/resume-state persistence is batched (1000 mutations / 5s) instead of rewriting the whole file per object. complete_page() prunes the sets at page boundaries so memory stays bounded; positions still persist unconditionally and legacy Vec-format checkpoints still deserialize. - backlog#801 (DiskInfo.healing never set): erasure-set heal now writes a healing marker (.rustfs.sys/healing.bin) on the disks it rebuilds (endpoints plumbed via HealRequest/HealTask.heal_endpoints from the auto disk scanner) and clears it on success. LocalDisk::disk_info surfaces the marker, so scanner heal coordination, lock selection and admin/metrics healing counts see the rebuild. - backlog#802 (cache probe after data read): new GetObjectBodyCacheHook in ecstore lets the app-layer object data cache serve the body inside get_object_reader, after metadata quorum resolution (etag known) but before the erasure shard read/decode. Previously a hit still paid the full disk read. Hook is None/no-op when the cache is disabled. - backlog#803 (GET hot-path redundant work): ObjectInfo is cloned for event notification only when an event will actually be built (GET and HEAD paths; events are currently suppressed so the clone was pure waste); get_opts/put_opts/del_opts resolve bucket versioning with one metadata-sys lookup instead of two; skip_verify_bitrot and get_lock_acquire_timeout env reads are cached via OnceLock; the io-priority metric is no longer double-counted; GetObject input fields are cloned selectively instead of cloning the whole input. - backlog#804 (disk permit starvation): the disk-read permit wait is now bounded (RUSTFS_OBJECT_DISK_PERMIT_WAIT_TIMEOUT, default 5s, 0 = previous unbounded behavior); on timeout the GET proceeds without a permit and the bypass is counted. DiskReadPermitReader also releases the permit at body EOF instead of holding it until the client drops the stream. Verification: make pre-commit; cargo clippy -D warnings on the four changed crates; full rustfs lib suite (2096 tests) green; rustfs-heal lib suite green with new unit tests for checkpoint pruning/legacy format/throttle, permit EOF release, and the cache hook (hit + SSE skip). The heal_integration_test and one set_disk listing test fail identically on unmodified main (pre-existing global-state ordering flakes, verified via git stash A/B). Co-authored-by: houseme <housemecn@gmail.com> |
||
|
|
25d80d7c60 |
feat(storage): harden internode data-path controls (#4224)
* fix(rio): propagate http writer shutdown errors * fix(ecstore): unify remote lock rpc deadlines * fix(storage): reject corrupt read multiple payloads * feat(rio): add internode http tuning profiles * feat(metrics): add internode baseline signals * feat(ecstore): observe shard locality topology * feat(ecstore): gate shard locality scheduling * feat(ecstore): gate batch read version rpc * feat(ecstore): observe batch processor adaptation * feat(ecstore): gate batch processor observation * docs: add get benchmark regression analysis * docs: add issue 797 execution plan status * fix(ecstore): require explicit batch rpc support * fix(ecstore): honor documented batch read gate * fix(ecstore): keep batch read gate stable per call * chore: update workspace dependencies * feat(ecstore): log batch read gate decisions * feat(ecstore): count batch read gate decisions * test(issue-797): add local internode A/B runner * test(rio): fix tuning profile spelling fixture * fix(protocols): adapt sftp channel open callbacks * fix(metrics): wrap batch processor observation args * chore(docs): keep issue notes local only * fix(storage): address internode review feedback * fix(storage): address internode data-path review findings - Run the BatchReadVersion auto-mode unary fallback outside the batch RPC deadline so each read_version keeps its own per-op timeout and health accounting instead of racing the whole batch against one drive timeout. - Cap adaptive batch-processor concurrency growth at a hard multiple of the configured baseline so sustained fast batches cannot ratchet past the configured limit. - Parse RUSTFS_INTERNODE_HTTP_* tuning, RUSTFS_BATCH_PROCESSOR_ADAPTIVE, and RUSTFS_METADATA_BATCH_READ once per process instead of re-reading the environment on hot paths. - Skip shard read-cost collection in observe mode when stage metrics are disabled, and cache the local endpoint host list instead of rebuilding it on every read. - Allow --warp-extra-args values starting with -- and drop the unused warp_hosts_csv helper in the issue-797 A/B runner. * fix(storage): address internode data-path review findings - Run the BatchReadVersion auto-mode unary fallback outside the batch RPC deadline so each read_version keeps its own per-op timeout and health accounting instead of racing the whole batch against one drive timeout. - Cap adaptive batch-processor concurrency growth at a hard multiple of the configured baseline so sustained fast batches cannot ratchet past the configured limit. - Parse RUSTFS_INTERNODE_HTTP_* tuning, RUSTFS_BATCH_PROCESSOR_ADAPTIVE, and RUSTFS_METADATA_BATCH_READ once per process instead of re-reading the environment on hot paths. - Skip shard read-cost collection in observe mode when stage metrics are disabled, and cache the local endpoint host list instead of rebuilding it on every read. - Allow --warp-extra-args values starting with -- and drop the unused warp_hosts_csv helper in the issue-797 A/B runner. Co-Authored-By: heihutu<heihutu@gmail.com> * fix(storage): align buffer clamp test with media cap * fix(ecstore): release optimized read locks before streaming --------- Co-authored-by: Zhengchao An <anzhengchao@gmail.com> |
||
|
|
19c21b3522 | fix(storage): make acknowledged writes durable across power loss (#4221) | ||
|
|
cf056b39e3 |
fix(core-storage): fix critical correctness defects from core-storage reliability audit (#4222)
* fix(core-storage): fix critical correctness defects from core-storage audit Fixes verified defects found in a deep audit of the core storage path (erasure coding, disk persistence, quorum, heal, replication resync): - ecstore/disk: rewrite live xl.meta atomically (temp+rename) in delete_versions_internal and write_metadata instead of in-place truncate, which exposed torn metadata to concurrent readers and crashes on the DeleteObjects hot path - ecstore/erasure: allow heal to reconstruct from exactly data_shards bitrot-verified sources; requiring data_shards+1 made objects permanently unhealable after losing parity_shards disks - ecstore/set_disk: direct-memory inline GET applied the erasure distribution permutation twice (shuffled inputs re-indexed through distribution), concatenating wrong shards into the response body in degraded reads; collect from canonical disk-ordered inputs - ecstore/set_disk: heal now preserves the committed inline layout instead of recomputing it with a hardcoded unversioned threshold, which split quorum identity of healed replicas and caused endless re-heal churn - ecstore/replication: resync results channel switched from broadcast(1) to mpsc; a lagged broadcast receiver ended the stats collector and every subsequent failure went uncounted, letting failed resyncs be marked completed - ecstore/replication: ignore an empty persisted resync checkpoint; resuming with one skipped every object and marked the resync completed without replicating anything - ecstore/replication: fix inverted not-found error classification in replicate_object/replicate_delete logging paths - ecstore/erasure: guard decode paths against zero block_size or data_shards from corrupt on-disk metadata (divide-by-zero panic) - ecstore/disk: os::read_dir no longer consumes the entry limit on entries it does not return (is_empty_dir misjudgment); create_file opens with O_TRUNC to avoid stale trailing bytes - filemeta: treat Some(nil) version id as a null version in matches_not_strict; disk-loaded headers never store None, so the mod_time quorum guard for unversioned overwrites never fired and an interrupted overwrite could displace the committed version in merge - filemeta: fix msgpack skip lengths for fixext (missed the ext type byte) and ext16/32 (over-skipped) unknown fields - filemeta: return FileCorrupt instead of usize underflow when xl.meta is truncated inside the CRC trailer - filemeta: surface delete-marker insertion failure in delete_version instead of reporting success when the data dir is shared Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(replication): drop duplicate cfg(test) etag import from boundary module The test module already imports content_matches_by_etag locally, so the top-level cfg(test) import is unused under -D warnings and fails clippy. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
e9af89b40d |
fix(ecstore): purge orphan empty-directory trees on folder delete (#4220)
fix(ecstore): purge orphan empty-directory trees on folder delete (#4189) Empty "phantom" folders — on-disk directory trees with no xl.meta anywhere — show up in listings as common prefixes but cannot be removed by any S3 call: DeleteObject on the folder key encodes it to __XLDIR__, finds no metadata, and returns NotFound, which the API layer masks as a fake 204 while the tree survives on disk. Make handle_delete_object attempt a guarded purge when a dir-object key resolves to NotFound: - sweep every erasure set of every pool (orphan fragments can sit on any set, left behind by whichever sets stored the deleted children) - per set, refuse if ANY disk holds a regular file under the prefix, so degraded/healable objects are never destroyed - remove empty directories children-first with non-recursive deletes; a racing PutObject makes the rmdir fail with DirectoryNotEmpty and the entry is skipped This state is unrepresentable on AWS S3 (CommonPrefixes derive only from real keys; DeleteObject is an idempotent 204), so the purge moves visible behavior closer to S3. It deliberately goes beyond MinIO, whose DeleteObject leaves these trees stranded and whose heal only removes dangling (minority-presence) dirs — a long-standing complaint (minio/minio#15257, #19516, #18444). Also move the test-only content_matches_by_etag import in replication_target_boundary.rs into the cfg(test) module; it failed cargo clippy -D warnings and blocked the pre-PR gate. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
dd6b5525e4 |
fix(ecstore): remove reachable panics in tiering, replication, and heal paths (#4205)
* fix(ecstore): remove reachable panics in tiering, replication, and heal paths - Parse x-amz-expiration leniently in tier PUT responses; any lifecycle rule on the remote tier bucket returns an RFC1123 date that the previous ISO8601 unwrap turned into a panic of the ILM transition worker - Skip invalid user-metadata header values (with a warning) when building tier and replication PUT headers instead of panicking on non-ASCII input - Heal: tolerate absent data_dir for delete markers and remote objects - transition_object: don't unwrap version_id on unversioned buckets when recording partial writes for offline disks - Admin server info: use port_or_known_default() so default-port (80/443) endpoints don't panic is_server_resolvable - Tier ListObjectsV2 client: decode response body with from_utf8_lossy - walk_internal: log merge_entry_channels errors instead of dropping them Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ecstore): fail heal explicitly when data_dir is missing Address review feedback: unwrap_or_default() silently substituted a nil UUID when latest metadata lacked data_dir. Delete markers and remote objects legitimately have no data_dir and skip the data-heal block, but for a regular object a missing data_dir means corrupt metadata — return FileCorrupt with a descriptive log instead of building part paths under a nil UUID directory. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
f9cf4ff2bf | fix(ecstore): surface multipart cleanup delete errors (#4198) |