Commit Graph

194 Commits

Author SHA1 Message Date
Zhengchao An 3674f5f56e fix(ecstore): bound remote shard writers with a progress deadline so one black-hole peer cannot pin write quorum (#4925)
A PUT that fans out erasure shards to remote peers awaited every shard writer to completion on both the per-block write and the final shutdown, and the remote HttpWriter had no progress deadline. A peer that accepts the TCP connection but never drains the request body (or never sends a response) therefore wedges the writer forever once the bounded buffers fill, pinning an otherwise-healthy write quorum indefinitely — a cluster-level write-availability hazard triggered by a single bad peer (rustfs/backlog#1319, https://github.com/rustfs/backlog/issues/1319).

MultiWriter now wraps each shard write and each shard-writer shutdown in a forward-progress deadline. The budget is re-armed on every block, so it bounds a stall rather than the total transfer time of a large object: a slow-but-honest writer that keeps completing shards is never killed, while a writer that makes no progress within the budget is failed and its disk dropped before commit. An optional absolute per-object cap (disabled by default) backstops a slow-drip peer that dribbles just enough progress to reset the per-block timer without ever converging; it is off by default so a legitimate large upload over a slow link is not killed on total time alone. Both knobs come from RUSTFS_OBJECT_DISK_WRITE_STALL_TIMEOUT (default 30s) and RUSTFS_OBJECT_DISK_WRITE_ABSOLUTE_CAP (default 0 = disabled); setting the stall timeout to 0 restores the previous wait-forever behavior for a conservative rollback.

The deadline enforcement lives in MultiWriter (writer-agnostic), so it covers local and remote writers alike and keeps the existing control-flow shape: a timed-out shard is marked failed (Error::Timeout, which is not an ignored error) and excluded from the write quorum exactly like any other shard write failure, and the unchanged nil_count/quorum check then continues on quorum or fails cleanly. This deliberately stays out of the MultiWriter lifecycle / commit-coordinator territory owned by rustfs/backlog#1312.

When a stalled writer is dropped to fail its shard, the remote HttpWriter must stop holding the connection and its buffered body. HttpWriter previously left its spawned request task running on drop; it now aborts that background task in Drop (it is no longer pin-projected, since every field is Unpin and the AsyncWrite impl already used get_mut). Bytes already handed to the transport cannot be unsent, but they land only in this upload's unique tmp path and are reclaimed by tmp GC — they never touch a committed object.

Tests, all on a paused virtual clock so they are deterministic and non-flaky:
- one black-hole writer still meets a 3/4 write quorum without hanging; two black holes fail the quorum cleanly (both for the per-block write and the shutdown paths).
- a slow-but-honest writer that keeps making progress within the stall budget is never failed across many blocks.
- the absolute cap bounds a slow-drip writer within a finite budget while the healthy writers keep quorum.
- the default policy is armed by default and honors 0 as disabled.
- HttpWriter aborts its background request task on drop against a hanging peer.

The toxiproxy/black-hole 4x4 end-to-end acceptance depends on black-box test facilities from rustfs/backlog#1325, which are not built yet; that acceptance is deferred to #1325 and intentionally not faked here.
2026-07-16 16:41:38 +00:00
houseme 1badff3923 fix: resolve moved-value diagnostics (#4905)
Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-16 18:46:56 +08:00
Zhengchao An f78f146c35 test(ecstore): extend crash-point injection to multipart complete and xl.meta update paths (#4853) 2026-07-15 16:10:14 +08:00
cxymds 7ece747eab fix(ecstore): suppress missing rollback rename warnings (#4792) 2026-07-13 19:05:09 +08:00
Zhengchao An 6096bb189d fix(ecstore): demote reliable_rename NotFound WARN to debug (#4789) 2026-07-13 12:07:49 +08:00
houseme 1553dc3f62 Address P2 follow-ups from the 2026-07-10..12 merged-PR review (backlog#1210-1220) (#4783)
* fix(obs): open cleaner compression source with O_NOFOLLOW

The compressor opened the source log via File::open, which follows a
symlink at the final path component. Between the scanner selecting a
regular file and this open, an attacker with write access to the log
directory could swap the entry for a symlink (TOCTOU) pointing at, say,
/etc/shadow, whose contents would then be copied into an archive. Open
the source with O_NOFOLLOW on Unix so such a swap fails with ELOOP; the
temp/archive path already refused symlinks, this closes the source side.

Refs rustfs/backlog#1210
Co-Authored-By: heihutu <heihutu@gmail.com>

* fix(obs): recompress instead of trusting leftover cleaner archives

archive_header_ok only checked the first 2-4 magic bytes before treating
an existing .gz/.zst as a completed prior result and letting the caller
delete the source log. A file with valid magic but a truncated or forged
body passes that check, so an attacker with write access to the log
directory (or a crashed prior run) could plant such a stub and make the
cleaner delete the real log without ever producing a usable archive —
silent audit-data loss.

Chosen fix: stop trusting cross-process leftovers entirely and always
recompress the source in this pass, rather than fully decoding every
leftover to validate it. Full-decode validation would add real CPU cost
and decode-bug surface for a rare crash-recovery case; the existing
atomic create_new+rename already overwrites whatever sits at the archive
path (a planted symlink is replaced, never followed) with a freshly
written, fsync'd archive, so a partial/forged leftover can never gate
source deletion. This is the lowest-regression option.

Refs rustfs/backlog#1211
Co-Authored-By: heihutu <heihutu@gmail.com>

* fix(object-data-cache): cap memory-gate reservation at cache growth headroom

The memory gate subtracts `admitted_since_refresh` from the snapshot's
available bytes so a burst arriving faster than the 5 s refresh cannot
over-allocate. That counter is GROSS: it only rolls over on the refresh and
never rolls back when a fill is later evicted, cancelled, or loses the
invalidation race. Under sustained high-throughput churn (net footprint flat
and far below `max_capacity`) the raw counter balloons past the memory the
cache actually holds, so `effective_available` collapses and the gate reports
false memory pressure — skipping the hottest fills with SkippedMemoryPressure
until the next 5 s refresh. This only lowers hit rate; it never returns wrong
data and self-heals each refresh.

Fix direction 1 (minimal regression): cap the reservation deduction at the
cache's own growth headroom (`max_capacity - weighted_size()`) instead of
letting the unbounded gross counter shrink the system-available budget. The
cache can never hold more than `max_capacity`, so a burst adds at most that
headroom of real memory before moka evicts to stay bounded (net-zero churn
beyond that point) — capping the deduction there keeps the reservation honest
without treating gross churn as growth. Chosen over net-accounting (direction
2, releasing bytes on every failure/cancel/eviction path) because that only
plugs the leak on failed fills and would not address the core defect: churn of
*successful* insert/evict fills over the 5 s window. It also touches only the
gate plus one call site rather than every failure path in moka_backend.

The cap only ever raises `effective_available`, so real memory pressure (a low
snapshot at refresh) still suppresses fills; when the cache is at capacity the
headroom is 0 and the deduction vanishes, correctly reflecting net-zero churn.
`MokaBackend` now stores `max_capacity` and passes the live headroom into
`allows_fill`. Adds targeted gate tests: gross churn far above headroom no
longer falsely suppresses, yet the reservation still bounds a burst while the
cache can genuinely grow.

Refs rustfs/backlog#1212
Co-Authored-By: heihutu <heihutu@gmail.com>

* test(ecstore): assert native O_DIRECT path runs in uring read test

uring_preserves_o_direct_for_eligible_reads only compared bytes through
LocalDisk::read_file_mmap_copy. On a filesystem that rejects O_DIRECT the
read silently degrades to the buffered StdBackend fallback and the byte
check still passes, so the test could go green without the native
read_at_direct path ever executing -- a vacuous pass.

Add a per-disk native_direct_reads counter on UringBackend, incremented
only when pread_uring_direct completes, and rebuild the test to drive a
real UringBackend's pread_bytes and assert the counter is non-zero (every
eligible read went through the native tier). When io_uring or O_DIRECT is
unavailable on the host filesystem (restricted CI runners, tmpfs), the
test skips loudly via eprintln instead of asserting a tautology, while
still checking byte-correctness on whatever tier served the read.

The counter also gives a gray release a positive signal that the O_DIRECT
tier is serving reads, not just a fallback count.

Refs rustfs/backlog#1213
Co-Authored-By: heihutu <heihutu@gmail.com>

* fix(ecstore): warn + count read-time EINVAL on native O_DIRECT reads

classify_direct_read_error is only reached from the read side: the
O_DIRECT open in pread_uring_direct already succeeded (an open-time
refusal is handled earlier as DirectOpenError::ODirectRefused). So an
EINVAL/EOPNOTSUPP arriving here is a read-time error on an fd the kernel
accepted for O_DIRECT -- far more likely an alignment bug in the aligned
read path than an unsupported filesystem. The old code latched the disk's
native path off with only a once-per-disk debug trace, hiding a potential
correctness regression behind a silent buffered-read downgrade.

Diagnostics only: the fallback behaviour is unchanged (the native path is
still latched off and the caller still reads via StdBackend). This adds a
rustfs_io_uring_direct_read_einval_total counter and promotes the
once-per-disk trace from debug to warn so an operator can see an alignment
regression instead of an unexplained latency/CPU shift.

Refs rustfs/backlog#1214
Co-Authored-By: heihutu <heihutu@gmail.com>

* docs(ecstore): document data-blocks-first default and its tail-latency cost

DEFAULT_RUSTFS_GET_DATA_BLOCKS_FIRST_READER_SETUP is true and must stay
true: deferred-parity is the deliberate, already-rolled-out full-object
GET default from backlog#1159/#923. Flipping it back to false in code
would silently revert that rollout for every deployment that has not set
the env var, so this commit only documents -- no behaviour change.

The added notes explain what data-blocks-first does (schedule data shards
up front, engage parity lazily on a missing/corrupt data shard), the known
trade-off (parity is engaged late, so a slow-but-not-dead data drive
raises GET p99 because the faster parity shards are not raced against it
until a data shard is declared missing), and the operational rollback
switch (RUSTFS_GET_DATA_BLOCKS_FIRST_READER_SETUP=false), which is
intentionally an env override rather than a code default change.

No metric was added: the low-risk observability hook for "slow data drive
engaged deferred parity" would live at the deferred-stripe engage point,
which is out of this file's scope; this change stays documentation-only to
avoid touching the hot GET path.

Refs rustfs/backlog#1215
Co-Authored-By: heihutu <heihutu@gmail.com>

* docs(ecstore): document wide-directory walk stall hazard and tuning

list_dir enumerates a whole directory in one os::read_dir call (count =
-1), and the walk caller bounds that entire enumeration with the per-read
stall budget (default 5s) as if it were a single read. For a wide, flat
prefix -- one directory holding millions of immediate children -- a single
readdir can exceed the budget on a healthy disk, trip DiskError::Timeout,
and surface as a ListObjects 500 quorum failure though the drive is fine
(a #2999 sub-class).

This is documented, not rewritten: turning the one-shot readdir into a
streaming/batched enumeration that refreshes the stall deadline between
chunks is an architecture-level change with high regression surface
(ordering, the count contract, quorum merge) and belongs in a separate
follow-up. The supported mitigation today is operational, so the comments
point wide-directory deployments at RUSTFS_DRIVE_WALKDIR_STALL_TIMEOUT_SECS
and the high-latency drive-timeout profile, which widen the budget with no
code change. Notes were added at list_dir, the scan_dir call site, and
get_drive_walkdir_stall_timeout. No behaviour change.

Refs rustfs/backlog#1216
Co-Authored-By: heihutu <heihutu@gmail.com>

* docs(ecstore): document consumer-peek vs producer-stall coupling

In list_path_raw the consumer's peek_timeout is drawn from the same source
and same value (walkdir_stall_timeout, default 5s) as the producer-side
walk stall budget, but the two measure different things: the producer
stall bounds a single drive read, while the consumer peek bounds the gap
between two ADJACENT entries arriving from a reader. Because they share a
value, the consumer cannot wait meaningfully longer for the next entry
than the producer is allowed to spend producing one. Walking a region
dense with non-listable internal items can make a HEALTHY drive miss the
budget between visible entries; the consumer then declares it stalled and
detaches it, dropping a good drive from the merge and capping the "large
prefix succeeds" guarantee.

Documented, not decoupled: giving the consumer peek an independent,
strictly-larger budget would cut these false detaches but equally delays
detaching a genuinely dead drive and shifts listing tail-latency
semantics, so it wants soak data before changing the default. The comment
records the invariant any such follow-up must keep -- consumer peek >=
producer stall, never stricter -- so it can never fail a drive before the
producer would. No behaviour change.

Refs rustfs/backlog#1217
Co-Authored-By: heihutu <heihutu@gmail.com>

* fix(io-metrics): add time-based trigger for low-IOPS latency percentiles

Percentiles were recomputed only every 128 IOs and seeded to 0, so a
low-traffic deployment exported p95/p99 = 0/stale for a long time after
startup. Add a 10s wall-clock trigger alongside the count throttle so the
first recompute can fire before 128 samples accrue. Hot-path per-op mean
update is unchanged.

Refs rustfs/backlog#1218
Co-Authored-By: heihutu <heihutu@gmail.com>

* test(e2e): cover codec-streaming parity under fault injection and NoSuchKey

The codec-streaming compat A/B previously ran only against a healthy
4-disk EC set with successful full GETs: the DiskFaultHarness was
constructed but never faulted, the error path was untested, and the
range assertion silently compared legacy-vs-legacy (ranges always fall
back to the duplex path), overstating what it proved.

Add two genuinely-failable scenarios reusing the existing harness and
fixtures:

- Parity reconstruction A/B: take one data disk offline and re-run the
  full object matrix on both phases while the EC 2+2 set rebuilds each
  large object from the surviving shards. Assert codec == legacy
  byte-for-byte (sha256) and header-for-header, and assert the codec
  phase served the reconstructed objects with zero duplex-pipe fallback
  (the reader gate is drive-health-independent, so the codec fast path
  is really exercised through reconstruction).
- NoSuchKey negative path: compare the HTTP status + S3 error code of a
  missing-key GET across the legacy and codec phases and require them to
  be identical (404/NoSuchKey), guarding against the codec env
  perturbing the error path.

Also clarify the range-phase comment so it is not misread as
codec-range correctness coverage: both sides are served by the same
legacy range path, so the assertion only proves ranges keep working and
keep falling back to legacy with the gates open.

Verified: cargo check/--no-run pass and the test passes locally
(1 passed; dup_codec=0 confirms the codec path ran).

Refs rustfs/backlog#1219
Co-Authored-By: heihutu <heihutu@gmail.com>

* ci(ecstore): exercise native O_DIRECT read path on an ext4 loopback

The uring-integration leg ran on the runner's default TMPDIR, which may sit
on tmpfs/overlayfs where open(O_DIRECT) fails and the native read_at_direct
path silently latches off to the aligned StdBackend fallback. Mount a
dedicated ext4 loopback and point TMPDIR at it so the real io_uring dep
(bumped git->0.1.0->0.2.0->0.2.1) and the native O_DIRECT read path are
actually covered rather than validated only by signature diffing.

Refs rustfs/backlog#1220
Co-Authored-By: heihutu <heihutu@gmail.com>

---------

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-12 16:03:28 +00:00
Zhengchao An 418f505a81 fix(ecstore): allow concurrent directory scans (#4778) 2026-07-12 13:17:50 +00:00
houseme 13bdca6762 build(toolchain): switch Rust channel to stable (#4775)
* Change Rust toolchain channel to stable

Signed-off-by: houseme <housemecn@gmail.com>

* style: apply clippy --fix and cargo fix lint suggestions

Run `cargo clippy --fix --all-targets --all-features` and
`cargo fix --lib --all-targets` across the workspace, then resolve the
remaining warnings by hand:

- collapse needless borrows in `format!` args, prefer `?` over explicit
  early returns, and use `.values()` / `.flatten()` iterator adapters
- rewrite the `Md5` scan loop via `manual_flatten` and re-indent the
  `select!` macro body (rustfmt skips macro interiors)
- annotate the intentional dead-code `Md5` inherent methods (constructed
  only by the test factory) with `#[allow(dead_code)]`

Behavior is unchanged.

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

---------

Signed-off-by: houseme <housemecn@gmail.com>
Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-12 18:59:43 +08:00
cxymds d8e69a3adf fix(logging): enforce single-writer sinks and bound tracing (#4765)
* fix(obs): prevent rolling log stdout aliasing

* fix(ecstore): bound hot-path tracing payloads

* test(logging): guard service and disk log invariants

* fix(obs): silence useless_conversion on st_dev for Linux clippy

rustix's Stat.st_dev is u64 on Linux/glibc, making u64::try_from a no-op
that trips clippy::useless_conversion under -D warnings. The conversion is
still needed on macOS/BSD where st_dev is a signed dev_t, so suppress the
lint on that line rather than dropping the portable fallible conversion.

* fix(logging): keep stdout sink validation portable (#4769)

* fix(obs): keep stdout device conversion portable

* docs(logging): update single-writer plan status

---------

Co-authored-by: overtrue <anzhengchao@gmail.com>
Co-authored-by: houseme <housemecn@gmail.com>
2026-07-12 16:03:01 +08:00
houseme 2ddafb4ed9 test(ecstore): bound file sync probe waits (#4767)
Co-authored-by: Zhengchao An <anzhengchao@gmail.com>
2026-07-12 16:01:09 +08:00
Zhengchao An a5765274fc fix: auto-repair test_rename_data_shares_file_sync_limit hang on macOS (#4758)
fix(test): use canonicalized disk root for file_sync_probe in rename_data test

On macOS, tempfile::tempdir returns /var/folders/... while LocalDisk
resolves the root to /private/var/folders/... via dunce::canonicalize.
The file_sync_probe::enter() path check uses starts_with(), so passing
the non-canonical tempdir path caused the probe to never activate,
making wait_for_active() hang indefinitely.

Use disk.root (already canonicalized) for the probe instead.
2026-07-12 06:01:39 +08:00
Ramakrishna Chilaka 028ba6a675 perf(ecstore): parallelize multipart shard syncing (#4734)
Bound large shard-directory syncs per disk and process while preserving small-directory and durability behavior.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-07-12 01:36:24 +08:00
houseme 89557a7ffe perf(ecstore): cache io_uring fallback root label (#4747)
`record_uring_fallback` is called at multiple sites in the io_uring read
path whenever a read falls back to `StdBackend`. It formatted
`self.root.display().to_string()` on every call, heap-allocating a
`String` from a `Path` that never changes after construction — pure
per-read waste when io_uring is degraded.

Cache the label once in `UringBackend::try_new` as a `String` field
(`root_label`) and clone it per emission. The metric name and the
`"root"` label value are unchanged; only the redundant `Path` formatting
is removed. The clone is a single alloc of an already-short string.

Refs: rustfs/backlog#1185

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-11 17:25:06 +00:00
houseme 264b2dd480 perf(metrics): drop needless per-emission work on hot metric paths (#4743)
* perf(metrics): drop needless per-emission work on hot metric paths

Audit of the metrics hot paths surfaced four low-risk wins where emission did
work it did not need to:

- `record_file_cache_reclaim_success/error` (disk/local.rs) called `.to_string()`
  on `kind` (already `&'static str`) and on the `"ok"`/`"err"` literals, heap-
  allocating up to four `String`s per page-cache reclaim window — which runs per
  read-stream reclaim. The `metrics` macros accept `&'static str` label values
  directly, so pass them as-is.
- `record_read_repair_dedup` (set_disk/core/io_primitives.rs) likewise
  `.to_string()`-ed an already-`&'static str` `reason`.
- `SetDisks::get_object_reader` (set_disk/ops/object.rs) captured `Instant::now()`
  and emitted the `rustfs.lock.acquire.*` counter and histogram unconditionally
  on every GET, right beside an already-gated stage timer. Gate them behind
  `get_stage_metrics_enabled()` too, so an inactive observability config pays no
  per-GET clock read or recorder lookups.
- The per-response-body-chunk counter in server/http.rs re-ran the `counter!`
  registry lookup on every chunk (a streamed GET emits many). Resolve the
  label-less handle once into a `LazyLock<metrics::Counter>`; the global recorder
  is installed at startup before any response streams, so the cached handle binds
  to the final recorder.

No metric names or label values change. The only behavior change is that the
`rustfs.lock.acquire.*` GET-path metrics now follow the GET stage-metrics flag,
consistent with the neighbouring stage timings.

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

* perf(metrics): gate page-cache reclaim metrics behind metrics_enabled()

`record_file_cache_reclaim_success/error` run per read-stream reclaim window on
large-object reads and emitted unconditionally. When general metrics are
disabled the `counter!`/`histogram!` macros still construct three metric keys
per call for nothing. Skip the emission behind `rustfs_io_metrics::metrics_enabled()`,
matching how the io-metrics free functions self-gate. The serial reclaim-metrics
test now enables the flag (save/restore) alongside the existing stage gate.

Left ungated deliberately: `record_read_repair_dedup` (rare read-repair path,
and its non-serial test would need a global-flag toggle), and the HTTP body-chunk
counter (its cached handle already makes the disabled case a no-op increment).

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

---------

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-11 15:27:15 +00:00
houseme 1ab66e124c test(ecstore): pin the remaining io_uring fd-cache invariants (#1180) (#4739)
Close the three test-completeness items in rustfs/backlog#1180 that the earlier
hardening (rustfs/rustfs#4726, #4729) left unpinned; the other two of the five
(sharded cancel routing, bailout-handle error) already landed in rustfs/uring.

- rename_data end-to-end invalidation: drive the real `LocalDisk::rename_data`
  commit (non-inline part, so `invalidate_part_paths` is non-empty) and assert
  the destination part descriptor is dropped — not merely `rename_file`/`delete`.
  Both production `rename_data` call sites share this invalidation, so a
  "fix one copy, miss the other" regression is now caught. The test is
  non-vacuous: it seeds the cache, removes the on-disk data dir out of band (the
  cached fd keeps the old inode alive and clears the path for the directory
  rename `rename_data` performs), and asserts a read still returns the OLD bytes
  before the commit — which fails outright if the cache is off, so it cannot pass
  without a live cache.
- FD_CACHE_TTL backstop: an injected short TTL proves the cache self-evicts a
  descriptor with no explicit invalidation; a static check pins the 5s value.
- zero-length read bounds parity on the cache-HIT path: a `length == 0` read
  past EOF must be rejected identically to the miss path and StdBackend, pinning
  the #1173 fix against regression.

Refactors `FdCache::new` to delegate to a private `with_ttl(ttl)` helper so the
TTL backstop can be exercised with a short TTL instead of a multi-second wait.

Verified: `cargo test -p rustfs-ecstore` on Linux with real io_uring
(seccomp=unconfined, RLIMIT_NOFILE raised, RUSTFS_URING_TESTS_MUST_RUN=1 so a
degraded skip fails rather than passing vacuously).

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-11 13:50:48 +00:00
houseme 793b2a06e2 perf(ecstore): snapshot per-IO env config on local disk read/write paths (#4736) 2026-07-11 13:26:26 +00:00
houseme 9d64d71bd7 fix(ecstore): reduce GET reader setup shard fanout (#4735) 2026-07-11 21:22:40 +08:00
houseme c79366d42c refactor(ecstore): tidy io_uring read backend (docs + fd-cache handle) (#4732)
Two behavior-preserving cleanups to the io_uring local read backend that
accumulated as later work layered onto it:

- Reunite the `UringBackend` struct doc comment. The backlog#1145 fd-cache
  constants were inserted into the middle of the struct's doc block, so its
  opening sentences were orphaned onto `ENV_RUSTFS_IO_URING_FD_CACHE` and the
  struct itself was documented by a sentence fragment starting mid-clause with
  "through rustfs-uring's...". Move the opening back onto the struct and give
  the constant its own one-line doc.

- Fold the fd-cache handle and its lookup key into a single
  `Option<(&FdCache, FdKey)>` in `pread_uring`, so the get and the
  insert_if_fresh sites stop re-deriving `self.fd_cache.as_ref()` and
  re-matching presence. Semantics are identical, including the backlog#1176
  generation guard (`gen_at_open` snapshot before open, insert only when the
  generation is unchanged).

No functional change. The borrow/move pattern was validated against a
host-compilable reduction (the cfg(linux) path cannot be built from macOS);
CI compiles the Linux backend.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-11 12:08:47 +00:00
houseme ffca98cdbf fix(ecstore): harden io_uring integration (#4726)
* fix(ecstore): close the fd-cache open-then-insert race with a generation guard (rustfs/backlog#1176)

pread_uring's miss path opened a descriptor on the blocking pool and only then
inserted it into the moka cache. moka's invalidations cover only entries present
at call time, so a heal/delete commit that invalidated between the open and the
insert could not stop the just-opened stale inode from being cached afterwards —
serving the pre-heal/pre-delete inode for up to the TTL and defeating the heal.

Add an invalidation generation to FdCache, bumped by invalidate_exact and
invalidate_under before they touch moka. The read path snapshots the generation
before opening and inserts via insert_if_fresh, which refuses the insert if the
generation moved during the open and, with a post-insert re-check, removes the
entry if an invalidation raced the insert itself. Reads that never miss are
unaffected.

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

* fix(ecstore): invalidate the fd cache on the primary object-delete paths (rustfs/backlog#1175)

The fd-cache invalidation contract was only wired into DiskAPI::delete,
rename_file and rename_data, but object deletion almost never goes through
LocalDisk::delete — DeleteObject(s) reach delete_version, delete_versions ->
delete_versions_internal, and delete_paths, all of which remove a version's data
dir (move_to_trash / rename_all staging) with no invalidation. A cached io_uring
descriptor kept the deleted part.N inode readable for up to the TTL, so a GET in
that window could still return deleted data.

Invalidate every cached fd under the removed data dir at each site: in
delete_version and delete_versions_internal the data_dir uuid and object path are
in hand (invalidate_cached_fds_under(volume, "{path}/{uuid}")); delete_paths
invalidates under each removed path. A later rollback that restores a data dir
just causes the next read to re-open it.

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

* fix(ecstore): close remaining fd-cache invalidation gaps (rustfs/backlog#1177)

Three residual paths could keep serving a stale descriptor:

- delete_volume removed the whole bucket tree (remove_dir_all/remove_dir) with no
  invalidation, and the cache-hit read path skips the volume-access check, so a
  cached fd kept a removed object readable. Add invalidate_cached_fds_for_volume
  (a per-volume moka predicate) and call it after the bucket is removed.

- A retired LocalDisk instance (renew_disk on reconnect builds a fresh one) kept
  its populated cache alive while still referenced by in-flight ops, so
  invalidations through the new instance never reached it. close() now clears the
  backend's cache via clear_cached_fds.

- rename_data's post-commit rollback (a commit-metadata fsync failure under
  strict durability) restored the old data dir without dropping fds cached during
  the committed window; the streaming branch now invalidates the dst part fds on
  those rollback paths. The inline branch's rollback runs inside spawn_blocking
  and is left to the TTL backstop.

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

* fix(ecstore): narrow the io_uring latch classes to match StdBackend (rustfs/backlog#1171)

The runtime degradation classification reused the probe-time restriction errnos,
which the driver's C7 contract explicitly warns against, so a single per-file
error could latch a whole disk off io_uring:

- is_io_uring_unsupported no longer includes EACCES: at read time on an
  already-open fd it is per-file (an LSM hooks security_file_permission on every
  read) and StdBackend hits the same denial, so falling back masks nothing and a
  full-disk latch would be wrong. ENOSYS and EPERM (seccomp/LSM applied after
  startup) remain. EOPNOTSUPP is now classified per-path by the caller.

- pread_uring_direct's read-error arm now mirrors StdBackend: an O_DIRECT-shape
  error (EINVAL/EOPNOTSUPP) latches only direct_uring.supported so eligible reads
  take StdBackend's aligned path, instead of over-latching the whole io_uring
  backend or never latching a read-side EINVAL at all.

- try_new only negative-caches genuine restriction-class probe failures in
  URING_UNSUPPORTED_DISKS; an unexpected (possibly transient) probe failure now
  falls back without latching, so the next reconnect re-probes.

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

* feat(ecstore): log when a disk latches io_uring off at runtime (rustfs/backlog#1172)

A probe-gated gray release was flying blind: the permanent per-disk `active`
latch flipped with no log and no metric, so the only message operators ever saw
was the startup "io_uring read backend enabled" line — which stayed true on
dashboards even after the very first read latched the disk back to StdBackend
forever.

Add latch_active_off, which flips the latch with `swap` and logs the true->false
transition exactly once at warn with a dedicated event constant, disk root, and
errno. Both the buffered and O_DIRECT read paths use it. A fallback/latch metric
counter and periodic export of the driver StatsSnapshot (cq_overflow,
cancel_already) remain as follow-ups that need rustfs_io_metrics plumbing.

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

* chore(audit): correct the stale rustfs-uring license-allow rationale (rustfs/backlog#1181)

The dependency-review allow said rustfs-uring is "pulled as a git dependency",
but ecstore now pins it from crates.io. Update the rationale and scope the allow
to the exact pinned version (pkg:cargo/rustfs-uring@0.1.0) so a future version
bump forces a conscious re-review of the license/provenance claim instead of
being waved through on an outdated justification.

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

* fix(ecstore): offload io_uring driver teardown off the tokio worker (rustfs/backlog#1170)

UringBackend held Arc<UringDriver> and had no Drop, so when the last LocalDisk
reference dropped in async context (disk reconnect via renew_disk, or shutdown),
UringDriver's own Drop ran on that thread — sending Shutdown and joining each
shard thread, which can block up to the bounded-drain timeout (5s) on a hung /
D-state disk, stalling a tokio worker.

Wrap the driver in ManuallyDrop (deref is transparent, so read call sites are
unchanged) and add a Drop that takes the Arc and, when a runtime is present,
drops it on a blocking thread so the potentially-blocking join never runs on a
runtime worker. Off-runtime it drops inline.

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

* fix(ecstore): restore StdBackend read parity on the uring paths (rustfs/backlog#1173)

Two byte-for-byte parity breaks against StdBackend on the io_uring read paths:

- A zero-length read on an fd-cache hit returned Ok(empty) without any bounds
  check, while StdBackend and the uring miss path return FileCorrupt for an
  offset past EOF. Fstat the cached descriptor on the length==0 path and match.

- reclaim_read_range fadvise(DONTNEED)'d the raw unaligned [offset, offset+len)
  range, but fadvise only drops fully-covered pages, so the head partial page
  stayed resident — whereas StdBackend's mmap path reclaims the page-aligned
  superset. Bitrot shards' 32-byte block headers keep offsets off page
  boundaries, so this diverged on the common case. Page-align the reclaim window
  to match the mmap path exactly.

(The third parity item from the audit — a failed reclaim fadvise failing the
read — is already parity: StdBackend's mmap path propagates the same fadvise
error with `?`, so no change is needed.)

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

* fix(ecstore): bound worst-case in-flight memory by chunking huge uring reads (rustfs/backlog#1174)

The driver's backpressure permits count operations, not bytes, and it zero-fills
a full-size buffer per op, so a single unbounded read could pin ~length bytes per
permit (128 permits x shards x up to ~2 GiB). ecstore passes a whole part's shard
range as one pread_bytes with no upstream chunking.

On the buffered path, split reads larger than URING_MAX_OP_LEN (128 MiB) into
sequential chunks, awaited one at a time, so worst-case in-flight memory is
bounded by permits x URING_MAX_OP_LEN per shard. The threshold is high enough
that ordinary shard reads keep the single-op, zero-copy fast path unchanged. The
O_DIRECT path (opt-in, alignment-constrained) is left for a follow-up.

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

* fix(ecstore): gate the io_uring fd cache on RLIMIT_NOFILE headroom (rustfs/backlog#1178)

The fd cache holds up to FD_CACHE_CAPACITY (512) descriptors per disk, but
try_new cannot know the disk count and nothing checked the process fd budget. On
a bare-metal / non-systemd run with the common 1024 soft RLIMIT_NOFILE, two disks
would already exhaust fds with EMFILE surfacing on reads and probes.

Check the soft limit at try_new: enable the cache only with ample headroom
(>= 16384), otherwise log a warning once and fall back to open-per-read. The
packaged systemd unit sets 1,048,576, so tuned deployments are unaffected.

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

* test(ecstore): make io_uring test skips visible and gate non-vacuity (rustfs/backlog#1179)

The ecstore io_uring tests degrade to a silent pass when io_uring is unavailable
(bare `return`s or plain eprintlns), so a CI leg on a restricted runner never
exercises the real UringBackend/FdCache/latch paths yet still goes green — an
integration regression could merge unseen.

Add uring_test_skip: it emits a grep-able `SKIP <name>` line and, when
RUSTFS_URING_TESTS_MUST_RUN is set (a CI leg that guarantees io_uring, e.g. a
seccomp=unconfined container), panics instead of skipping. Route the silent-skip
sites through it. Wiring a dedicated CI leg that sets that env on a capable
runner is tracked in the issue; this provides the enforcement mechanism.

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

* test(ecstore): cover delete_paths fd-cache invalidation (rustfs/backlog#1180)

Add an end-to-end test that seeds the descriptor cache with a read, removes the
part via disk.delete_paths (one of the primary object-delete entry points that
does not go through LocalDisk::delete), and asserts the next read no longer
returns the removed inode — pinning the invalidation added in #1175. The sharded
cancel-routing half of #1180 is covered in the rustfs-uring PR.

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

* io_uring audit follow-ups: O_DIRECT chunking, inline invalidation, metrics, CI leg (backlog#1160) (#4729)

* fix(ecstore): chunk large O_DIRECT reads too, bounding in-flight memory (rustfs/backlog#1174)

The buffered read path already splits reads above URING_MAX_OP_LEN into
sequential chunks; do the same for the O_DIRECT path, which was left for a
follow-up. read_at_direct aligns each chunk's sub-range internally, and chunk
sizes are a multiple of URING_MAX_OP_LEN so a boundary re-read is at most one
block. Extract classify_direct_read_error so the single-op and chunked paths
share one copy of the EINVAL/EOPNOTSUPP-vs-subsystem latch classification rather
than duplicating it.

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

* fix(ecstore): invalidate cached fds on the inline rename_data rollback (rustfs/backlog#1177)

The streaming rename_data branch invalidates cached part fds on its post-commit
rollback paths, but the inline branch runs its commit and rollback inside a
single spawn_blocking closure where the async invalidate cannot be called, so it
was left to the TTL backstop.

Capture the closure's result instead of `??`-propagating it: on error (a
commit-metadata fsync failure under strict durability rolls the committed rename
back), invalidate the dst part paths at the async level before returning. Inline
objects keep their data in xl.meta rather than separate part inodes, so this is
largely defensive, but it removes the caveat and keeps the two branches
consistent.

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

* feat(ecstore): export io_uring latch/fallback and driver stats metrics (rustfs/backlog#1172)

Complete the gray-release observability. Beyond the warn log added earlier, emit
metrics so a dashboard can answer "how much traffic is on io_uring vs falling
back, and is any disk degrading":

- rustfs_io_uring_latch_off_total — a disk latching io_uring off at runtime.
- rustfs_io_uring_read_fallback_total — each io_uring -> StdBackend read
  fallback (latched-off short-circuit, O_DIRECT error, buffered error).
- a low-frequency per-disk exporter of the driver StatsSnapshot as gauges
  (in_flight, cq_overflow, cancel_already), spawned in try_new. It holds only a
  Weak reference so it never keeps the driver alive, and drops any temporary
  strong reference on the blocking pool so a last-reference UringDriver::Drop
  join never runs on an async worker (rustfs/backlog#1170).

submit_errors is deliberately not exported yet: it is a field added in the
unreleased rustfs-uring 0.2.0, and ecstore still pins 0.1.0. It lands once the
dependency is bumped (rustfs/backlog#1181).

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

* ci: add a real-io_uring integration leg on ubuntu-latest (rustfs/backlog#1179)

The existing self-hosted sm-standard runners cannot guarantee io_uring is
available (a container seccomp filter can block io_uring_setup), so the ecstore
uring tests degrade to a silent skip and never exercise the real
UringBackend/FdCache/latch paths in CI.

Add a job on GitHub-hosted ubuntu-latest, which runs a recent kernel with no
container seccomp filter, running the uring-named ecstore tests with
RUSTFS_IO_URING_READ_ENABLE=true and RUSTFS_URING_TESTS_MUST_RUN=1 — the
non-vacuity gate makes the leg fail rather than skip if io_uring is unavailable,
so an integration regression can no longer merge green behind a vacuous pass.

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

---------

Co-authored-by: heihutu <heihutu@gmail.com>

---------

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-11 11:15:29 +00:00
Henry Guo 3f25426534 fix(ecstore): reject incomplete listing usage refreshes (#4698)
Co-authored-by: Henry Guo <marshawcoco@users.noreply.github.com>
2026-07-11 08:47:49 +08:00
Zhengchao An d0ca14d8df test(ecstore): cover post-commit hard-crash in rename_data crash harness (#4689)
The rename_data crash-consistency harness (backlog#935/#878) only armed
pre-commit crash points (AfterDataRename, AfterBackupBeforeMetaCommit),
which assert the *old* version survives. The other half of the
"partial commit -> old or new, never mixed" invariant — a hard power loss
*after* the xl.meta commit rename must leave the *new* version — had no
crash-point coverage. The existing after-metadata-commit failpoint only
exercises the graceful in-process rollback (restores old), a different
path from a hard crash (no rollback runs, commit stays on disk).

Add a RenameDataCrashPoint::AfterMetaCommit injection right after the
commit rename (cfg(test), compiles to a const-false no-op in production,
like the existing points) and overwrite/fresh x strict/relaxed tests
asserting the object reads back as the new version. The fresh case pins
that the point is genuinely post-commit: a pre-commit misplacement would
leave no readable object and fail the assertion.

cargo test -p rustfs-ecstore crash_consistency: 9 passed. clippy -D
warnings clean; fmt and arch guards clean.
2026-07-11 02:21:07 +08:00
houseme 6f05a740b3 refactor(ecstore): extract the shared io_uring read preamble (backlog#1145) (#4696)
`pread_uring` and `pread_uring_direct` accumulated across seven rounds
(#4632/#4635/#4645/#4649/#4653/#4658/#4662) and each carried its own copy of
the same open preamble: resolve the bucket path, run the volume access check,
resolve the object path, and check the path length. The only real difference
is what happens after — one opens buffered and errors as `DiskError`, the
other opens `O_DIRECT` and errors as `DirectOpenError` so it can latch an
O_DIRECT refusal.

Extract that shared sequence into `resolve_uring_object_path`, a blocking
helper called inside both `spawn_blocking` closures. Each caller keeps exactly
what differs — its open flags, its error type, its metadata/bounds/align
handling — and no longer restates the resolution. No behavior change: the same
checks run in the same order and produce the same errors (the O_DIRECT path
still maps them through `DirectOpenError::Disk`).

Net -12 lines. Verified on a real Linux host (16-core, real io_uring):
clippy -p rustfs-ecstore --all-targets -D warnings clean; disk::local tests
144 passed, 0 failed — including the io_uring, O_DIRECT, fd-cache, and
page-cache-reclaim cases that exercise both read paths.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-10 17:03:34 +00:00
Zhengchao An 80ddd8fa7e fix(ecstore): roll back delete on disks that staged then errored (#4676)
fix(ecstore): roll back delete on disks that staged then errored (backlog#1158)

#4300 rolls back a failed delete only on disks that returned Ok, skipping any
disk that staged its rollback backup, applied the delete, and then errored --
leaving that disk deleted while its peers are restored. On rollback, fan the
undo out to every online disk instead; the disk-side restore_delete_rollback
is already idempotent (Ok no-op when nothing was staged), so unstaged disks
are unaffected. The err.is_some() skip now applies only to the success/cleanup
path. Covers both single-object and batch delete.

Refs backlog#1158.
2026-07-10 20:34:19 +08:00
houseme f83f9ada13 fix(ecstore): reclaim page cache after io_uring reads (#4662)
fix(ecstore): io_uring reads must reclaim the page cache like StdBackend (backlog#1145)

`StdBackend::pread_bytes` calls `fadvise(DONTNEED)` over the range it just
read whenever `should_reclaim_file_cache_after_read(length)` holds —
`RUSTFS_OBJECT_FILE_CACHE_RECLAIM_READ_ENABLE` (on by default) above a 4 MiB
threshold. Large object reads are usually cold, and leaving them resident
evicts everything else, so this is a deliberate policy rather than a side
effect of how StdBackend happens to read.

`pread_uring` and `pread_uring_direct` never did it. Enabling io_uring on a
disk therefore turned the policy off silently: shard reads at or above the
threshold stayed resident and grew the page cache without bound. This is the
same class of mistake as the O_DIRECT loss fixed earlier — copying the read
itself while dropping the policy attached to it.

It also badly distorts any benchmark. An end-to-end warp GET A/B on a
16-core host (4 MiB objects, conc 64) showed io_uring at 8782 MiB/s against
StdBackend's 116 MiB/s. That is not a 76x speedup: with io_uring the run
issued *zero* device reads and grew the page cache, while StdBackend read
2950 MiB from the device and shrank it. The two legs were not running the
same policy. (Disabling the mmap read path changed nothing — 1.01x — so the
mmap-vs-pread difference was not the cause either.)

Both io_uring read paths now reclaim the range they read, with the same gate,
the same range, and the same error handling as StdBackend. O_DIRECT should
leave nothing resident anyway, but a filesystem that quietly buffered the read
still has to honour the policy, so `pread_uring_direct` reclaims too.

The test measures the policy, not the call: it reads an 8 MiB file through
each backend and asks `mincore(2)` how much stayed resident. Crucially it also
asserts the reclaim-disabled case leaves the range resident — without that
gate, a backend that never reclaimed would pass silently. Verified: with the
fix removed, the test fails with "uring: reclaim is on ... 2048/2048 pages
remain"; with the fix it passes.

Verified on a real Linux host (16-core, real io_uring): clippy --tests
-D warnings clean; disk::local tests 143 passed, 0 failed.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-10 18:50:47 +08:00
Henry Guo 99eef032c6 fix(ecstore): restore Windows async read trait import (#4652)
* fix(ecstore): restore Windows async read trait import

* fix(ecstore): gate async read import to non-Unix
2026-07-10 18:28:29 +08:00
houseme 15b8b13698 feat(ecstore): cache part-file descriptors for io_uring reads (#4658)
* feat(ecstore): run one io_uring ring per shard on each disk (backlog#1145)

A buffered read that hits the page cache completes inline inside
`io_uring_enter`, so the thread driving a ring performs that read's
memcpy. One ring per disk therefore capped cache-hit reads at a single
core's memory bandwidth: measured on a 16-core host, one driver thread sat
pinned at 100% CPU while throughput stayed flat at ~5 GB/s regardless of
read size, against 50 GB/s for the blocking-pool baseline.

rustfs/uring#6 taught the driver to hold N independent rings, each with its
own thread, pending table, backpressure semaphore, and eventfd. Wire it up:
`UringBackend::try_new` now calls `probe_and_start_sharded`, and
`RUSTFS_IO_URING_SHARDS` selects the count per disk.

The default is a quarter of the available parallelism clamped to `1..=4`,
because the cost is `disks × shards` driver threads (each normally blocked
in `poll(2)`). Any override is clamped to `1..=16`, so a mistyped value can
neither disable the driver (0) nor spawn threads without bound; an
unparseable value falls back to the default.

Effect (warm page cache, 16-core, rustfs/uring's concurrent_pread_bench):

  1 MiB, conc 8:    1 shard  4911 MB/s -> 8 shards 47361 MB/s (9.6x);
                    the blocking-pool baseline is 50662 MB/s
  64 KiB, conc 32:  StdBackend 153678 IOPS, p999 3030 us
                    8 shards   345402 IOPS, p999  897 us
  64 KiB, conc 128: StdBackend 135155 IOPS, p999 10716 us
                    8 shards    389047 IOPS, p999  4092 us

Sharding removes the throughput deficit *and* keeps io_uring's tail-latency
advantage, rather than trading one for the other.

Unchanged: io_uring read stays gray-off by default
(`RUSTFS_IO_URING_READ_ENABLE`), reads are byte-for-byte identical to
StdBackend, the per-disk degradation latches and probe cache (backlog#1101)
and the O_DIRECT tiered fallback (backlog#1102) all still apply. Rings stay
per-disk, so a stalled disk cannot starve another disk's rings
(backlog#1055). Bumps the rustfs-uring pin to the merged #6 commit.

Verified on a real Linux host (16-core, real io_uring): cargo clippy
--tests -D warnings clean; disk::local tests 132 passed, 0 failed —
including the existing io_uring and O_DIRECT cases now running on the
sharded driver, plus a new test covering the shard-count default, override,
and clamping.

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

* feat(ecstore): cache part-file descriptors for io_uring reads (backlog#1145)

`pread_uring` opened the file on the blocking pool for every read, so each
read paid a `spawn_blocking` round trip — the very thread hop io_uring
exists to avoid. Sharding the driver (backlog#1145) removed the previous
ceiling and left this as the binding cost. Measured on a 16-core host with
a 4-shard driver, warm page cache:

  64 KiB, conc 8:   143942 -> 263054 IOPS (+83%),  p999 240 -> 65 us
  64 KiB, conc 32:  150128 -> 204876 IOPS (+36%),  p999 2508 -> 871 us
  64 KiB, conc 128: 129172 -> 361287 IOPS (+180%), p999 15329 -> 3046 us
  1 MiB,  conc 32:   33875 ->  42301 IOPS (+25%)

At 64 KiB / conc 128 the open is what masked io_uring entirely: with it,
io_uring beat StdBackend by 3.5%; without it, by 189%.

Add a bounded per-disk descriptor cache used by the io_uring read path.
A hit takes no `open` and no `spawn_blocking`, so the read never leaves the
runtime worker.

Why caching a part-file descriptor is safe:
  * only `<object>/<data_dir>/part.N` reaches this backend's `pread_bytes`;
    `xl.meta` — the one path replaced in place — is read through `read_all`
    / `read_metadata` and never gets here;
  * part files are never rewritten in place. A replacement is always
    write-new-tmp then `rename`, which swaps the inode, so a cached
    descriptor can never observe a torn shard.

Why invalidation is nevertheless REQUIRED: heal reuses the existing
version's `data_dir` and renames a rebuilt shard onto the SAME part path.
A cached descriptor would keep serving the pre-heal (corrupt) inode,
defeating the heal and eroding read quorum. `delete` likewise unlinks a
part that a cached descriptor would keep readable. So `rename_data`,
`rename_file`, and `delete` all call the new
`LocalIoBackend::invalidate_cached_fds` after they mutate, and a 5s TTL
bounds the blast radius should a future mutation path forget to.

Two preamble checks the miss path runs are not silently lost on a hit:
  * bounds — the driver only short-reads at EOF (it resubmits otherwise),
    so `bytes.len() != length` is exactly the old `meta.len() < end_offset`
    check, and now yields the same `FileCorrupt`;
  * volume access — skipped while an entry is live. An unreachable disk
    keeps serving already-open descriptors for at most the TTL, after which
    the re-open re-runs the check. Disk health is tracked independently of
    this per-read probe.

Scope: buffered io_uring reads only. The O_DIRECT path keeps opening per
read (its reads are >= 4 MiB, so the open is a small fraction), and
StdBackend is untouched — it must take the blocking hop for the pread
regardless, so caching would buy it only 2-6% while carrying the same
invalidation risk. `RUSTFS_IO_URING_FD_CACHE=false` restores open-per-read.

Verified on a real Linux host (16-core, real io_uring): clippy --tests
-D warnings clean; disk::local tests 135 passed, 0 failed. The new
heal-staleness test first asserts a read still returns the PRE-heal bytes —
proving the cache is live and the hazard real — then that invalidation makes
the healed shard visible. A second test drives `rename_file` and `delete`
through `LocalDisk` to prove those paths actually invalidate, and a unit
test pins prefix invalidation to component boundaries (`a/b` must not drop
`a/bc`).

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

---------

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-10 08:33:50 +00:00
Zhengchao An aaffd3ede8 fix(ecstore): align Deep verifier shard geometry (#4656) 2026-07-10 08:06:38 +00:00
GatewayJ a269f8df05 fix(ecstore): require write quorum for metadata early stop (#4300) 2026-07-10 15:57:09 +08:00
houseme fe682e16e9 feat(ecstore): run one io_uring ring per shard on each disk (backlog#1145) (#4653)
A buffered read that hits the page cache completes inline inside
`io_uring_enter`, so the thread driving a ring performs that read's
memcpy. One ring per disk therefore capped cache-hit reads at a single
core's memory bandwidth: measured on a 16-core host, one driver thread sat
pinned at 100% CPU while throughput stayed flat at ~5 GB/s regardless of
read size, against 50 GB/s for the blocking-pool baseline.

rustfs/uring#6 taught the driver to hold N independent rings, each with its
own thread, pending table, backpressure semaphore, and eventfd. Wire it up:
`UringBackend::try_new` now calls `probe_and_start_sharded`, and
`RUSTFS_IO_URING_SHARDS` selects the count per disk.

The default is a quarter of the available parallelism clamped to `1..=4`,
because the cost is `disks × shards` driver threads (each normally blocked
in `poll(2)`). Any override is clamped to `1..=16`, so a mistyped value can
neither disable the driver (0) nor spawn threads without bound; an
unparseable value falls back to the default.

Effect (warm page cache, 16-core, rustfs/uring's concurrent_pread_bench):

  1 MiB, conc 8:    1 shard  4911 MB/s -> 8 shards 47361 MB/s (9.6x);
                    the blocking-pool baseline is 50662 MB/s
  64 KiB, conc 32:  StdBackend 153678 IOPS, p999 3030 us
                    8 shards   345402 IOPS, p999  897 us
  64 KiB, conc 128: StdBackend 135155 IOPS, p999 10716 us
                    8 shards    389047 IOPS, p999  4092 us

Sharding removes the throughput deficit *and* keeps io_uring's tail-latency
advantage, rather than trading one for the other.

Unchanged: io_uring read stays gray-off by default
(`RUSTFS_IO_URING_READ_ENABLE`), reads are byte-for-byte identical to
StdBackend, the per-disk degradation latches and probe cache (backlog#1101)
and the O_DIRECT tiered fallback (backlog#1102) all still apply. Rings stay
per-disk, so a stalled disk cannot starve another disk's rings
(backlog#1055). Bumps the rustfs-uring pin to the merged #6 commit.

Verified on a real Linux host (16-core, real io_uring): cargo clippy
--tests -D warnings clean; disk::local tests 132 passed, 0 failed —
including the existing io_uring and O_DIRECT cases now running on the
sharded driver, plus a new test covering the shard-count default, override,
and clamping.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-10 07:41:11 +00:00
houseme baf7e899b9 fix(ecstore): bound every walk filesystem call by the stall budget (#4650)
The listing path no longer has a wrapper-level total timeout, so any
filesystem call the walk makes without a stall bound would have no liveness
bound at all. The first pass covered list_dir and read_metadata but left four
reads unbounded: the volume access() probe and fs::metadata() in walk_dir, the
xl.meta read in its dir-object branch, and is_empty_dir() in scan_dir.

Split the helper so reads that do not yield a Result go through the same
budget, and route the four remaining calls through it. A hung drive now fails
those reads with DiskError::Timeout instead of parking the walk until the
merge consumer's own stall timeout aborts it.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-10 05:16:55 +00:00
houseme 8b233cdd94 fix(ecstore): keep O_DIRECT for eligible reads via native io_uring (backlog#1102) (#4649)
fix(ecstore): keep O_DIRECT for eligible reads via native io_uring (rustfs/backlog#1102)

Wire ecstore's io_uring read backend to rustfs-uring's native aligned
O_DIRECT read (`read_at_direct`, merged in rustfs/uring#3), so an
O_DIRECT-eligible read keeps BOTH io_uring's async submission AND
O_DIRECT's page-cache bypass instead of trading one for the other.

`UringBackend::pread_bytes` now selects the read shape by a tiered,
per-disk-latched ladder — never a blanket downgrade:

  1. io_uring latched off for this disk (backlog#1101) -> StdBackend.
  2. O_DIRECT-eligible + native path usable -> pread_uring_direct: open
     the file O_DIRECT, probe the device alignment once (cached), and
     read via read_at_direct. Best path: async + no cache pollution.
  3. O_DIRECT-eligible but the fs refused O_DIRECT earlier (latched)
     -> StdBackend aligned path (which itself degrades to buffered).
  4. non-O_DIRECT read -> buffered pread_uring.

Latching keeps a failure from being re-attempted per-read: an fs that
refuses O_DIRECT (EINVAL/EOPNOTSUPP on open) latches the native direct
path off for that disk; a restriction-class errno from the read latches
io_uring off wholesale (backlog#1101). Any other error falls back for
that one read without masking a genuine data problem as a permanent
downgrade. The alignment comes from the existing probe_direct_io_align
(statx STATX_DIOALIGN, default 4096) and is cached in a per-disk
OnceLock so the probe runs at most once.

This replaces the interim guard that routed every O_DIRECT-eligible read
to StdBackend (which disabled io_uring on the hottest shard reads); the
native path is now the default and StdBackend is only a fallback tier.
Bumps the rustfs-uring pin to the merged #3 commit.

Verified on a real Linux host (16-core, real io_uring + O_DIRECT):
cargo check --tests, clippy -D warnings, and disk::local tests all pass
(128 passed, 0 failed), including uring_preserves_o_direct_for_eligible_reads
across unaligned ranges.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-10 04:47:46 +00:00
houseme 399461c33e fix(ecstore): bound listing walks by drive stall, not total duration (#4647)
Foreground S3 listings wrapped the entire walk_dir stream in a single
wall-clock timeout (RUSTFS_DRIVE_WALKDIR_TIMEOUT_SECS, default 5s). That
budget measured how much data the walk had to produce rather than whether
the drive was still answering, so a healthy but large prefix on slow media
returned 500 InternalError / "Io error: timeout" to the client. The timer
also kept running while the walk was blocked writing to a slow consumer,
charging merge-side backpressure to the producer.

WalkDirOptions::stall_timeout_ms already carried the right semantics but was
only honored by the remote-disk RPC walk; LocalDisk::walk_dir ignored it
entirely. A single-drive deployment therefore had no way to distinguish a
hung drive from a big directory.

Teach LocalDisk::walk_dir to bound each individual drive read with the stall
budget, defaulting it from RUSTFS_DRIVE_WALKDIR_STALL_TIMEOUT_SECS when the
caller does not pin one, and let the foreground listing path skip the
wrapper-level total timeout. A walk that keeps making progress now runs to
completion; a drive that stops answering still fails with DiskError::Timeout.
Time spent blocked on the consumer stays outside the budget.

Heal and rebalance walks already skipped the total timeout and previously ran
unbounded on local drives. Give them an explicit, generous 60s stall budget so
this change does not tighten them from "no bound" to the 5s default.

Fixes #4644
Refs #2999, #3001

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-10 12:20:54 +08:00
houseme 0ad4a26056 fix(ecstore): keep O_DIRECT for eligible reads when io_uring is enabled (backlog#1102) (#4645)
fix(ecstore): keep O_DIRECT for eligible reads when io_uring is enabled (rustfs/backlog#1102)

UringBackend::pread_uring opens the file buffered, so with io_uring enabled an
O_DIRECT-eligible read (RUSTFS_OBJECT_DIRECT_IO_READ_ENABLE + length >= the
threshold) silently lost O_DIRECT and polluted the page cache — a behavior
change the moment io_uring was turned on for a disk that uses O_DIRECT. The
io_uring backend is gray-off by default, so no deployment is affected, but the
gap had to close before it can be enabled anywhere O_DIRECT is in use.

Route O_DIRECT-eligible reads back through StdBackend's aligned path (which
itself falls back to buffered when the filesystem rejects O_DIRECT). A native
aligned O_DIRECT read in the driver remains part of rustfs/backlog#1102.

Adds uring_preserves_o_direct_for_eligible_reads: with BOTH flags on and the
threshold at 1, unaligned ranges still return exactly the requested bytes.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-10 03:43:47 +00:00
houseme e6e4aef45b test(ecstore): cover the io_uring per-disk probe cache hit (backlog#1101) (#4641)
test(ecstore): cover the io_uring per-disk probe cache hit (rustfs/backlog#1101)

Follow-up to #4635. The per-disk negative probe cache was implemented but had
no dedicated test. Add uring_probe_cache_skips_known_unsupported_disk: a root
recorded in URING_UNSUPPORTED_DISKS makes try_new return None without a fresh
probe. Completes the #1101 acceptance ("cache hit does not re-probe").

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-10 02:08:56 +00:00
houseme 7dc03603da feat(ecstore): add io_uring degradation latch and per-disk probe cache (#4635)
feat(ecstore): io_uring runtime degradation latch + per-disk probe cache (rustfs/backlog#1101)

Follow-up to #1104. Add the runtime half of the io_uring degradation contract:

- Runtime latch: UringBackend gains an `active` flag (mirrors
  DirectIoReadState::supported). A read that returns a restriction-class errno
  (EPERM/EACCES/ENOSYS/EOPNOTSUPP) — io_uring became unusable on this disk —
  trips the latch, and every further read goes straight to StdBackend with no
  more per-read io_uring attempts. Data errors (EIO), missing files, and
  parameter errors do NOT latch, since StdBackend would hit them too.
- Per-disk probe cache: a process-wide negative cache of disks whose probe
  failed, so try_new skips re-probing (creating a ring + driver thread) on
  reconnect.

Tests: the errno classifier (restriction-only) and a latched-off read that
still returns correct bytes via StdBackend; the #1104 differential test still
passes under real io_uring.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-10 08:54:59 +08:00
Zhengchao An 5e80980698 test(ecstore): pin sorted scan_dir stream for dir-marker with children (backlog#1068) (#4626) 2026-07-10 03:34:21 +08:00
houseme da755eb66b feat(ecstore): runtime-probed io_uring read backend, gray-off by default (backlog#1104) (#4632)
feat(ecstore): add runtime-probed io_uring read backend, gray-off by default (rustfs/backlog#1104)

Wire the standalone rustfs-uring crate (https://github.com/rustfs/uring) into
LocalIoBackend as an opt-in read backend. When RUSTFS_IO_URING_READ_ENABLE is
set AND the per-disk io_uring probe succeeds, positioned reads go through the
cancel-safe UringDriver; otherwise — default, or on a restricted host, or on
any per-read driver error — reads fall back to StdBackend byte-for-byte, so the
default build is unchanged.

- Cargo.toml: rustfs-uring as a Linux-only git dependency (pinned rev). The
  guard scripts/check_no_tokio_io_uring.sh allows an explicit io-uring
  integration; only the tokio "io-uring" runtime feature is banned.
- UringBackend mirrors StdBackend::pread_bytes's resolution/access/bounds
  preamble and only swaps the raw byte read; the other three trait methods
  delegate to the inner StdBackend.
- Backend selection at LocalDisk construction via build_local_io_backend.
- Differential test uring_backend_reads_match_std: with the flag set, every
  positioned read returns byte-identical data (driver-served when io_uring is
  available, StdBackend fallback otherwise).

Verified: cargo check -p rustfs-ecstore on Linux; the differential test passes
under real io_uring (seccomp=unconfined). The runtime errno degradation latch,
per-disk probe cache, and productionization are tracked in
rustfs/backlog#1101/#1102.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-09 16:57:17 +00:00
Zhengchao An 4f999bb6b8 perf(ecstore): backfill rename_data old size and gate the PUT prelookup (#4598) 2026-07-09 12:08:01 +08:00
Zhengchao An 579cdf52dc fix(ecstore): harden object-prefix probe and parse markers before forward_past (#4600) 2026-07-09 12:07:37 +08:00
houseme e0eee89a3b fix(ecstore): emit CommonPrefix for object with same-named prefix in non-recursive listings (#4596)
fix(ecstore): emit CommonPrefix for object with same-named prefix in non-recursive listings (backlog#1042)

On single-disk / consistent deployments a non-recursive scan_dir that finds a/xl.meta classifies `a` as an object and does not descend, so the source never emits the prefix `a/`, dropping the CommonPrefix from delimiter listings even when `a/b` exists. This is the single-disk follow-up to backlog#880 (the multi-disk merge path was fixed in #4563).

Fix: in the scan_dir Ok branch, for a non-recursive listing of a plain object, probe whether `a/` holds a listing entry other than the object itself (new object_prefix_has_sibling_listing_entry, reusing directory_has_visible_listing_entry and skipping the object's own xl.meta and data dirs); if so, additionally schedule the prefix dir `a/`. The upstream fold in from_meta_cache_entries_sorted_infos already emits object `a` as Contents and dir `a/` as a CommonPrefix, so nothing changes there. Leaf objects (the common case) pay a single list_dir and return false immediately.

Verification: new scan_dir unit test (positive: `a` + `a/b` coexist; negative: leaf `c` produces no spurious `c/`); new end-to-end e2e (object `a` + `a/b` with delimiter "/" -> Contents `a` + CommonPrefix `a/`); existing list_objects_duplicates e2e (#1797 / #2439 regressions) stays green; set_disk::read 114 passed.

Note: lib-test compilation depends on #4573 (now merged), which added the allow_inplace_legacy_fallback argument to the codec streaming arity tests after #4560 / backlog#879.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-09 01:45:25 +00:00
Zhengchao An ce6bc30b26 test(ecstore): harden validation gate and EC coverage tests (#4590) 2026-07-09 07:18:18 +08:00
Zhengchao An ed81d2f6b8 test(ecstore): complete EC validation coverage gate
* test(ecstore): complete EC validation coverage gate

* test(ecstore): stabilize validation suite after rebase

* test(ecstore): fix rio-v2 clippy lint
2026-07-09 03:12:48 +08:00
houseme 608ab14d7d perf(ecstore): fold metadata read open+fstat+read into a single spawn_blocking (HP-12 item 1) (#4554)
Sub-change A only: the two local-disk metadata read paths
(`read_metadata_with_dmtime`, `read_all_data_with_dmtime` in
crates/ecstore/src/disk/local.rs) previously dispatched open, fstat and the
xl.meta read as three separate async fs hops (three spawn_blocking round-trips
under tokio::fs). Each is now folded into a single tokio::task::spawn_blocking
closure using std::fs, cutting per-metadata-read dispatch from 3 to 1.

This does NOT touch sub-change B (removing the entry-point access() call).

Correctness first: this is the hottest metadata read path, so the refactor
preserves byte-for-byte-equivalent Results. Every error mapping is kept
identical:
  - open failure     -> to_file_error
  - is_dir           -> Error::FileNotFound (not to_file_error(EISDIR))
  - metadata failure -> to_file_error
  - xl.meta parse    -> propagated verbatim from the parser
  - try_reserve      -> Error::other
  - read_to_end      -> to_file_error
For read_all_data_with_dmtime the async NotFound -> access(volume_dir) ->
VolumeNotFound fallback (and its warn! event) is preserved on the async side:
the closure returns the raw open error unmapped; only open() can yield ENOENT
once the fd is valid, so gating the fallback on the open error is equivalent to
the original open-arm-only fallback.

To run the parser inside a blocking closure, filemeta gains a synchronous twin
`read_xl_meta_no_data_sync` (+ `read_more_sync`) in
crates/filemeta/src/filemeta/version.rs that line-for-line mirrors the async
version, differing only in std vs tokio read_exact. A new equivalence test
(`read_xl_meta_sync_equivalence_tests`) feeds identical buffers to both the
async and sync readers and asserts equal Ok bytes / equal Err variants across:
v1.0; v1.1/v1.2/v1.3; large meta triggering read_more; header truncation ->
UnexpectedEof; CRC-trailer truncation -> FileCorrupt; unknown major/minor ->
InvalidData; and want boundaries (exact fit, inline-data drop, read_more EOF).

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-08 18:28:57 +00:00
houseme f7d2b25638 fix(ecstore): propagate disk delete/rename failures instead of swallowing them (#4546)
Two disk-layer correctness fixes in crates/ecstore/src/disk/local.rs.

move_to_trash (#948, ECA-07) previously handled only DiskFull in its
tail error block and let every other rename failure (EIO/EACCES/ENOTDIR/
cross-device) fall through to `return Ok(())`, so a delete that never
landed on a faulty disk was reported as success and the drive fault was
hidden from heal/offline logic. It now keeps the DiskFull in-place-remove
fallback, treats a missing source (FileNotFound) as benign, and
propagates every other error (already mapped by to_file_error, e.g.
I/O -> FaultyDisk, permission -> FileAccessDenied), matching MinIO's
deleteFile.

rename_file and rename_part (#960, ECA-19) directory (trailing-slash)
branch unconditionally removed the destination before rename and
propagated the resulting NotFound, so renaming a directory to a new
location always failed. Both now tolerate ErrorKind::NotFound on that
pre-rename remove and continue, matching MinIO's RenameFile.

Adds regression tests covering benign-missing-source, real-error
propagation, the unchanged move happy path, and directory rename to a
missing destination for both rename_file and rename_part.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-08 18:27:11 +00:00
houseme 20d61c73bc fix(ecstore): stop reduce_errs leaking nil placeholder as dominant error (#4551)
reduce_errs seeds best_err with a synthetic Error::other("nil") placeholder
via unwrap_or. When the error slice is empty or every error is ignored,
err_counts is empty and nil_count is 0, so both nil tie-break conditions are
false and the else branch returned (0, Some(Error::other("nil"))). That
placeholder leaked to build_write_quorum_failure_summary, where dominant_error
became Some("nil"), skipping the nil_dominated early return and falling back to
the "other_error" metric label. Quorum decisions were unaffected (max_count 0
still fails any positive quorum), but write-quorum failure metric labels and
tracing fields were misclassified.

Add an explicit short-circuit: when best_count == 0 and nil_count == 0, return
the (0, None) sentinel so the placeholder never escapes. Add regression tests
for the all-ignored slice, empty slice, and the summary label path, which the
existing test_build_write_quorum_failure_summary (nil_count > 0) did not cover.

Fixes #957

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-08 18:22:55 +00:00
houseme 2df315baf0 fix(ecstore): fsync ancestor dirs for first object writes (#4493)
* fix(ecstore): fsync new object's ancestor dirs to close a power-loss gap

reliable_mkdir_all creates an object's directory (and any missing prefix dirs)
with plain mkdir and never fsyncs the parent chain. The commit-point fsync in
rename_data persists the object dir's *contents* (its xl.meta and data dir),
but not the object dir's own entry in the bucket/prefix directory. So on the
first PUT of an object, a power loss after the write is acknowledged could drop
the whole object directory even though its contents were durable — an
acknowledged write silently lost (rustfs/backlog#922 step 4).

For a new object (no prior xl.meta) under a durability tier that syncs commit
metadata, fsync the ancestor chain from the object dir's parent up to and
including the bucket after the commit rename, so the newly created directory
entries survive power loss. A starts_with guard bounds the walk to the bucket
subtree. Overwrites already have a durable object dir and are unaffected;
relaxed/none accept the wider window like the existing commit fsync.

Durability regressions are invisible to ordinary behavior tests, so the new
tests assert directly (via the fsync_dir recorder) that a first PUT under a new
prefix fsyncs both the prefix and bucket dirs, and that relaxed does not.

Scope: the non-inline (erasure) commit path. The inline branch has the same
gap and is a separate follow-up.

Refs: rustfs/backlog#922 (HP-1 step 4), rustfs/backlog#936

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

* fix(ecstore): fsync new inline object's ancestor dirs too

Extend the backlog#922 step 4 mkdir-gap fix to the inline commit branch. Like
the non-inline path, a first PUT of an inline object creates its directory
(and any missing prefix dirs) whose entry in the parent chain reliable_mkdir_all
never fsynced; the commit fsync persists the object dir's contents, not its own
entry. For a new inline object under a commit-metadata-syncing tier, fsync the
ancestor chain up to and including the bucket after the commit rename, using the
same starts_with-bounded walk (via the synchronous os::fsync_dir_std inside the
inline spawn_blocking closure).

Adds a test asserting a new inline object under a new prefix fsyncs both the
prefix and bucket dirs. rename_data now closes the ack'd-write power-loss gap on
both the erasure and inline paths.

Refs: rustfs/backlog#922 (HP-1 step 4), rustfs/backlog#936

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

---------

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-08 16:31:00 +00:00
houseme 651ccac130 perf(ecstore): parallelize tmp xl.meta write and shard fdatasync on commit (#4487)
The non-inline rename_data commit path made the tmp xl.meta durable and then
fdatasynced the shard files in two sequential awaits, each its own blocking
round-trip. The two operations touch disjoint paths (the tmp xl.meta under the
tmp bucket vs the shard data dir) and have no ordering constraint between them:
both only need to be durable before the commit renames that follow.

Run them concurrently with tokio::join!, dropping a blocking round-trip from
the PUT commit critical path (backlog#922 step 2). The commit ordering is
unchanged — both futures complete before any rename, and a failure in either
aborts before the rename exactly as the sequential version did (tmp-meta error
is still surfaced first). Payload and metadata durability semantics are
identical under strict and relaxed tiers.

Validated by the rename_data crash-consistency harness (backlog#935): a crash
at any pre-commit point still reopens as old-or-new, never mixed. Existing
rename_data / durability-tier / disk::os tests are unchanged and pass.

Refs: rustfs/backlog#922 (HP-1 step 2), rustfs/backlog#936

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-08 22:22:33 +08:00
houseme 54872d52dc test(ecstore): deterministic rename_data crash-consistency harness (#4478)
The rename_data commit sequence is the highest-risk durability path in the
store, and the HP-1/HP-4/HP-5 work (fsync coalescing, group commit, relaxed
durability tiers) all need a standing gate that proves a power loss mid-commit
can never leave a mixed or corrupt object. There was one graceful-rollback
failpoint but no crash-consistency coverage.

Add a deterministic harness that models a hard power loss — the commit
sequence stops dead at the armed step with no in-process cleanup — and then
reopens the disk to assert the raw on-disk state is coherent:

- Two pre-commit injection points, RenameDataCrashPoint::{AfterDataRename,
  AfterBackupBeforeMetaCommit}, constructed at the real commit-path call sites
  but gated so the production build compiles them to a const-false no-op
  (mirrors the existing should_fail_before_old_metadata_backup pattern).
- A parameterized scenario over {strict, relaxed} durability x {both crash
  points} x {overwrite, fresh object}: seed, stage a replacement, inject,
  reopen, and assert the object reads back as exactly the old version (or does
  not exist when there was no old version) with its data dir intact, never the
  half-committed new one. The un-injected run asserts the commit makes the new
  version visible. Relaxed is held to the same old-or-new invariant as strict
  (only the durability window widens), which is exactly the property the
  durability-relaxation work must not break (rustfs/backlog#878 hard rule).
- Wire an explicit `ecstore-crash-consistency` gate into the destructive
  profile of run_ecstore_validation_suite.sh so it runs in the standing suite
  (coordinates with the #878 destructive profile rather than building a
  parallel one).

Production behavior is unchanged: the injection guards are no-ops outside
tests, and the existing rename_data rollback tests still pass.

Refs: rustfs/backlog#935 (HP-14 crash-consistency harness), rustfs/backlog#896
(test plan), rustfs/backlog#878 (ECStore validation suite), rustfs/backlog#936

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-08 20:53:49 +08:00
Henry Guo 506cd156bb fix(scanner): scope long walk timeouts (#4376)
* fix(scanner): scope long walk timeouts

* fix(scanner): bound IAM config walks

---------

Co-authored-by: Henry Guo <marshawcoco@users.noreply.github.com>
2026-07-08 17:06:51 +08:00
houseme 92c8c6db75 perf(ecstore): Linux O_DIRECT shard-write path off the commit critical section (#4411)
`create_file` writes erasure shard / multipart part data through the page
cache, so the commit-point `sync_dir_files` fdatasync in `rename_data` has to
flush the whole shard's dirty pages (profiling: create_file avg 67.52ms vs
1.51ms nosync; BitrotWriter::write p95 40.86ms), and concurrent PUTs on one
device stall each other's writeback inside the rename critical section.

Add an opt-in Linux O_DIRECT streaming writer that reuses the direct-io read
infrastructure (statx DIOALIGN probe, aligned bounce buffer, supported latch,
one-time fallback log). Incoming bytes are staged into an aligned buffer;
full aligned batches and the shutdown tail are flushed on the blocking pool
(same offloading posture as the buffered writer it replaces). The
sub-alignment tail is written buffered after clearing O_DIRECT via fcntl
(MinIO's recipe). Shard bytes reach the device during the write phase, so the
unchanged commit-point `sync_dir_files` fdatasync degrades to a cheap
metadata/device FLUSH instead of flushing ~2MiB of dirty pages.

Gated by `RUSTFS_OBJECT_DIRECT_IO_WRITE_ENABLE` (default false), Linux-only,
and durability-neutral: `sync_dir_files` still fdatasyncs every shard at the
commit point, so the strict/relaxed/none durability tiers keep their exact
guarantees. Filesystems that reject O_DIRECT (tmpfs, overlayfs, 9p) latch the
path off and fall back to buffered writes; an O_DIRECT open/write EINVAL is
never surfaced as `InvalidInput` (which `to_file_error` maps to `FileNotFound`
and would masquerade as a missing shard, triggering a spurious EC rebuild).

Unit tests cover the env gate, the alignment/staging-capacity and tail-split
math, an end-to-end `create_file` round trip across sizes crossing the
alignment and multi-batch boundaries (buffered fallback on macOS / O_DIRECT on
a block device), and the writer state machine over a plain file on Linux. Real
O_DIRECT throughput on Linux NVMe (ext4/xfs) is deferred to the azure 4-node
bench per the issue's GO/NO-GO gate; the development host is macOS and cannot
exercise O_DIRECT.

Refs: rustfs/backlog#927, rustfs/backlog#936

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-08 16:39:43 +08:00