Commit Graph

1842 Commits

Author SHA1 Message Date
Zhengchao An 476352106e refactor(app): per-context server-config handle and Arc-owned notification system (#4621)
* refactor(app): give each context's server-config handle its own copy

backlog#1052 S3, first slice: establish the per-context state pattern on
the smallest subsystem. ServerConfigHandle was a unit struct forwarding
both get and set to the process-global GLOBAL_SERVER_CONFIG, so every
AppContext shared one config regardless of which context a caller held.

The handle now keeps its own copy: a set lands on the owning context and
on the process global (ambient readers — the config loader path, the
scanner — keep working), and a get prefers the owned copy, falling back
to the global while the initial load still publishes there. Single
instance: the owned copy and the global are written together, so reads
are unchanged. Two contexts that both published stay isolated even though
the shared global only remembers the last write (covered by a new test).

The global write in set() is the transitional bridge for ambient readers;
it goes away when those readers migrate and multi-instance flips on
(backlog#1052 S5).

* refactor(notify): hand out the notification system as an owned Arc

backlog#1052 S3, second slice. GLOBAL_NOTIFICATION_SYS stored the
NotificationSys by value and every accessor returned
Option<&'static NotificationSys> — a process-lifetime borrow that a
per-server AppContext can never hold. The global now stores an Arc and
the accessor chain (ecstore runtime sources, ECStore::notification_system,
the iam forwarders, the rustfs shims, NotificationSystemInterface and the
AppContext resolvers) hands out owned Arcs instead.

Call sites are unchanged apart from two borrow adjustments in the
rebalance admin handler (pass &Arc where a reference is expected; borrow
with as_ref() so the handle survives to the second use). Behavior is
identical — same single global instance, first init still wins — but the
type now permits a future per-server context to own its notification
system (backlog#1052 S3 follow-up).
2026-07-09 20:07:09 +08:00
houseme 071a4600bc fix(admin): make server_info retry re-dial and match peer disks by resolved host (#4618)
Two follow-up fixes to #4607 (both verified real bugs, each with a regression
test):

1. The in-call server_info retry did not re-dial on network errors. A
   network-like first failure runs through `finalize_result`, which sets the
   client's `offline` gate; the retry then called `evict_connection` and
   re-invoked `server_info`, but `get_client` short-circuits on that gate and
   returns "temporarily offline" without dialing. Only the async recovery
   monitor would clear it. So the retry re-dialed only in the timeout branch and
   was a no-op in the transport/half-open branch it was meant to cover. Add
   `PeerRestClient::prepare_retry` (evict + clear the offline gate) and use it
   before the retry.

2. Synthesized/degraded drive lists went empty on hostname deployments.
   `PeerRestClient::host` is an `XHost` that `hosts_sorted` builds via
   `XHost::try_from` -> `to_socket_addrs`, so it is the resolved `IP:port`; but
   `synthesized_disks`/`peer_disk_health` compared it against
   `Endpoint::host_port()`, which is the raw `hostname:port`. On hostname
   clusters the compare missed, the drive list came back empty, and
   `unknownDisks` stayed 0 — reproducing the "drives vanish from the summary"
   regression #4607 fixed (also affected the pre-existing offline path). Compare
   through a shared `endpoint_host_matches` helper that canonicalizes the
   endpoint side through the same `XHost` resolution.

Tests: `peer_rest_client_prepare_retry_clears_offline_gate`,
`endpoint_host_matches_direct_and_canonicalized` (uses localhost, no external
DNS).

Follow-up to rustfs/rustfs#4607; tracked in rustfs/backlog#1049.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-09 10:50:00 +00:00
GatewayJ d238b2d24e fix(admin): support external OIDC browser redirects (#4280) 2026-07-09 18:07:49 +08:00
Zhengchao An c8348f8b6f fix(ecstore): merge listing channels in canonical byte order (#4610)
fix(ecstore): merge listing channels in canonical byte order (backlog#1046)
2026-07-09 17:14:57 +08:00
Zhengchao An d7b19131d8 fix(ecstore): read the persisted SSE-C nonce in the GET decrypt resolver (unbreaks S3 CI) (#4612)
fix(ecstore): read the persisted SSE-C nonce in the GET decrypt resolver

#4576 moved SSE-C encryption to a random per-encryption nonce persisted
in object metadata and taught the rustfs-layer decrypt resolver to read
it back — but the byte-level GET decryption resolves its material in
crates/ecstore object_api/readers.rs, a duplicated twin that still
recomputed the deterministic legacy nonce. Encrypt with the random
nonce, decrypt with the deterministic one: the first AEAD block fails
and every SSE-C GET aborts its body after the response headers
(IncompleteRead(0 bytes) on clients; all 8 SSE-C cases in the
implemented s3-tests whitelist), which is what has been driving the
"S3 Implemented Tests" CI job into its 60-minute timeout since
2026-07-09.

Resolve the base nonce like the encrypt side: prefer the persisted IV
(x-rustfs-encryption-iv, then the case-insensitive MinIO interop key),
fall back to the deterministic nonce only for legacy objects with no
stored IV or an undecodable value. Full implemented s3-tests whitelist
is back to 451 passed / 0 failed against the fixed binary.
2026-07-09 17:07:43 +08:00
Zhengchao An 91a5c87132 refactor(startup): thread explicit InstanceContext through the storage startup path (#4611) 2026-07-09 16:53:41 +08:00
Zhengchao An 10ba2273c4 test(e2e): align object-lock overwrite tests with AWS semantics (#4605)
test(e2e): align object-lock overwrite tests with AWS new-version semantics

PR #3179 made put-like writes (PUT, CopyObject, multipart create/complete)
on versioned object-lock buckets create a new version instead of being
blocked by the current version's legal hold or COMPLIANCE retention, which
is the correct AWS behavior. Six e2e assertions in object_lock_test.rs
still encoded the old blocking semantics and, because e2e_test is excluded
from the CI test run, stayed red unnoticed since 2026-06-03.

Verified against a real server built from current main: all six failed at
their "should be blocked" assertions. Rewritten to assert the AWS
contract: the write succeeds with a distinct new version id, the new
content is current, the protected version keeps its content and lock
metadata, and version-targeted deletion of the protected version stays
blocked (for COMPLIANCE even with bypass_governance). Full object_lock
module is green again (33/33).
2026-07-09 06:52:51 +00:00
houseme 9152da5206 chore: drop unused wildmatch workspace dependency (#4609)
PR #4468 removed the last use of wildmatch (crates/notify swapped it for a
hand-rolled star-only glob matcher), but the workspace dependency declaration
in the root Cargo.toml was left behind as an orphan. cargo shear flags it as
not used by any workspace member. Drop the stray line.

Also refresh the comments in crates/notify/src/rules/pattern.rs that still
referred to the removed wildmatch crate, so they describe the current
hand-rolled matcher instead of a dependency that no longer exists.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-09 06:08:49 +00:00
houseme cb977c4c5a fix(admin): report unreachable info members as unknown with balanced drive counts (#4607)
fix(admin): report unreachable info members as `unknown` with balanced drive counts

`GET /rustfs/admin/v3/info` synthesizes an entry for every peer, but a peer
whose properties RPC failed below the offline threshold with no cached snapshot
was reported as `initializing` with an empty drive list. That drive list being
empty meant its drives disappeared from the backend summary entirely, so
`onlineDisks + offlineDisks` no longer equaled `totalDrivesPerSet` (an operator
sees `onlineDisks: 3, offlineDisks: 0` for a 4-drive set), and `initializing` is
a misleading state for a member that has been running for hours — it reads like
a node was ejected when nothing is wrong (upstream rustfs/rustfs#4566).

Changes:
- madmin: add `ItemState::Unknown` ("unknown") and an `unknownDisks` bucket on
  `ErasureBackend` (serde `default`, so older payloads still deserialize).
- ecstore diagnostics: `get_online_offline_disks_stats` now returns a third
  `unknown` bucket instead of folding those drives into `offline`, keeping
  `online + offline + unknown == totalDrivesPerSet`.
- ecstore notification_sys: a probe miss below the failure threshold with no
  cache is now reported as `unknown` (not `initializing`) and carries the host's
  drives from the pool topology (tagged `unknown`) so the summary stays balanced;
  drive synthesis is factored into `synthesized_disks(host, endpoints, state)`,
  shared by the offline and unknown paths.

Tracked in rustfs/backlog#1049 (P1-A + P0-A).

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-09 04:50:20 +00:00
Zhengchao An d8c32d3754 fix(ecstore): retry idempotent reads to survive read-after-write races (#4597) 2026-07-09 04:16:43 +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
Zhengchao An 79d6de860c test(e2e): avoid console port 9001 clash and fail fast on dead server (#4603) 2026-07-09 12:07:15 +08:00
houseme 073628e8be feat(utils): make DNS resolver cache TTL configurable via RUSTFS_DNS_CACHE_TTL_SECS (#4604)
* feat(utils): make DNS resolver cache TTL configurable via RUSTFS_DNS_CACHE_TTL_SECS

The resolver cache in crates/utils/src/net.rs had a hardcoded 300s TTL with
no way to tune or disable it. On orchestrated networks (Docker Swarm,
Kubernetes) a peer's DNS name is its only durable identity while its container
IP changes on reschedule, and the platform DNS always serves the current
answer. A fixed 300s application-side cache can keep a member dialing a dead IP
for up to five minutes after a peer restarts. Rust has no runtime DNS cache, so
on musl images this was the only cache in the stack and the only one that could
not be turned off.

Read the TTL from RUSTFS_DNS_CACHE_TTL_SECS (u64, default 300), matching the
existing RUSTFS_INTERNODE_* transport knobs. `0` disables caching entirely so
every get_host_ip consults the system resolver. The resolved value is logged
once at first use, removing an invisible cache from the triage surface. Change
is backward-compatible and confined to net.rs.

Also drop the two per-lookup info! logs on the resolve path: they would flood
the log when caching is disabled and added hot-path noise otherwise.

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

* fix(helm): derive mTLS secret names from chart fullname (#4601)

* fix(helm): mtls certificate for server and client hardcode

* fix hardcode issue in deployment

* update typo yaml file

---------

Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: majinghe <42570491+majinghe@users.noreply.github.com>
2026-07-09 03:26:06 +00: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
houseme cb9edd59f5 test(ecstore): serialize bucket_delete tests to fix cargo-test global race (#4595)
The three store::bucket::tests::bucket_delete_* tests drive make_bucket /
delete_bucket through the process-global local-disk registry
(GLOBAL_LOCAL_DISK_MAP) and lock client, and through Sets::new which reads the
process-global erasure mode. Under `cargo test` (single process, many threads)
they race other global-touching tests and fail deterministically with
InsufficientWriteQuorum.

serial_test's #[serial] with the crate default key serializes them across the
in-process suite. Measured at --test-threads=16: baseline fails all three every
round; with #[serial] all three pass every round. nextest's per-test processes
are covered separately by the ecstore-serial-flaky test-group in
.config/nextest.toml (#4558). Full instance-level isolation remains a backlog
#939 (InstanceContext) concern and is not attempted here.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-09 01:37:53 +00:00
Zhengchao An e0f04f4621 fix(ecstore): only evict remote lock channel on transport failures (#4591)
fix(ecstore): only evict remote lock channel on transport failures (#4567)

The remote lock RPC path evicted the cached gRPC channel on *any*
`tonic::Status`. Genuine transport failures (connect refused/reset,
keepalive/deadline) surface that way, but so do server-produced
application statuses on a perfectly healthy channel: `Unauthenticated`
/`PermissionDenied` from the signature interceptor, `Internal`
/`FailedPrecondition` when the peer lock service is not ready yet,
`InvalidArgument`, `ResourceExhausted`, `Unimplemented`, and so on.

Evicting the channel for those cannot help — the channel is fine — and
each spurious eviction re-dials the connection and advances the peer
toward the offline threshold via `record_peer_unreachable`, producing
background channel churn on an otherwise healthy cluster.

Classify the tonic status with `is_transport_failure` (underlying
transport source present, or one of `Unavailable`/`DeadlineExceeded`
/`Unknown`/`Cancelled`) and only evict the cached channel when it is a
real transport failure. The client-side timeout arm still evicts. This
mirrors the transport-vs-application distinction already used on the
remote-disk RPC path.
2026-07-09 09:35:48 +08:00
Zhengchao An ce6bc30b26 test(ecstore): harden validation gate and EC coverage tests (#4590) 2026-07-09 07:18:18 +08:00
Zhengchao An 359bdc0f1f refactor(ecstore): migrate the background-services cancel token into InstanceContext (Phase 5 Slice 13) (#4586)
* refactor(ecstore): migrate the background-services cancel token into InstanceContext (Phase 5 Slice 13)

Phase 5 Slice 13 (backlog#939): move the background-services cancellation token
out of the process static into the per-instance InstanceContext, so cancelling
one instance's background workers (scanner/heal/tier/lifecycle) no longer
touches another instance.

- InstanceContext gains `background_cancel_token: OnceLock<CancellationToken>`
  with `init_background_cancel_token` (set-once) and `background_cancel_token()`
  returning an owned clone.
- global.rs `init_/get_/create_/shutdown_` helpers keep their signatures and
  route through the current instance's context; the static is removed. The
  getter now returns an owned `Option<CancellationToken>` instead of a
  `Option<&'static _>`, which is what lets the token live in the context.
- Callers adapt to the owned token: the metadata-refresh loop drops `.cloned()`;
  the lifecycle worker/loops take the owned token (a shared fallback is cloned
  when the token is somehow uninitialized). No Arc cycle is introduced —
  workers hold a token clone, not the instance context.

Single-instance behavior is unchanged: startup creates one token in the
bootstrap context the ECStore adopts, and shutdown cancels that same token.

Tests: the token is set-once and cancelling one instance's token leaves a
distinct instance uninitialized.

Verification: cargo test -p rustfs-ecstore (23 instance-context tests green),
cargo clippy -p rustfs-ecstore --all-targets (clean), make pre-commit (pass).

Refs: backlog#939 (Phase 5, Slice 13)

* test(ecstore): prove multi-instance isolation; document embedded guard retention (Phase 5 Slice 14) (#4588)

Phase 5 (backlog#939) capstone. The prior 13 slices moved every piece of
per-instance runtime state out of process globals into ECStore's
InstanceContext. This slice proves the result and records the remaining work.

- Add `two_instances_isolate_all_migrated_state`: an end-to-end acceptance test
  that constructs two independent InstanceContexts and verifies NONE of the
  migrated state is shared — erasure setup, lock manager, region, deployment id,
  the four service handles (tier/notifier/expiry/transition), the local disk
  registry, the bucket monitor, and the background cancel token. This is the
  object-graph isolation carrier working end to end.
- Document why the embedded single-instance guard (EMBEDDED_SERVER_STARTED) is
  intentionally retained: storage startup still publishes into the process-level
  bootstrap context (write-once region/endpoints/deployment id) and the single
  GLOBAL_OBJECT_API handle, so a second startup would fail-fast on that shared
  state. Lifting the guard requires threading a per-instance context through
  startup — a follow-up beyond migrating the globals. The guard is NOT removed:
  rejecting the second start is safer than the panic it would otherwise become.

Verification: cargo test -p rustfs-ecstore (acceptance test + all instance-context
tests green), cargo clippy -p rustfs-ecstore --all-targets (clean), make
pre-commit (pass).

Refs: backlog#939 (Phase 5, Slice 14). Stacked on Slice 13 (#4586).
2026-07-08 22:06:31 +00:00
Zhengchao An c0e2c02e51 fix(rio): warn once when internode HTTP/2 tuning is inert on plaintext (backlog#805-C3) (#4587)
The internode HTTP/2 window/keepalive tuning (apply_http_client_tuning +
http2_keep_alive_* in build_http_client) only takes effect when the connection
negotiates HTTP/2, which happens via TLS-ALPN. Over plaintext internode
transport the connection is HTTP/1.1 and all the tuning is silently inert,
with no signal to the operator.

Make this honest without changing protocol behavior (no h2c, no
prior_knowledge, no opt-in flag):
- InternodeHttpClientTuning gains h2_tuning_explicit, true when the operator
  set a window size, a non-default tuning profile, or the keepalive env.
- Add pure predicate should_warn_h2_inert(negotiated_is_http2,
  h2_tuning_explicit, already_warned) and maybe_warn_h2_inert, gated by a
  process-lifetime AtomicBool so the warn fires at most once.
- Call maybe_warn_h2_inert at both record_internode_http_version sites using
  the actual negotiated resp.version().
- Document the TLS-ALPN requirement at apply_http_client_tuning and near the
  ENV_INTERNODE_HTTP2_* constants.

Tests cover the should_warn_h2_inert truth table and that h2_tuning_explicit
reflects explicit windows and a non-default profile.
2026-07-09 05:51:07 +08:00
Zhengchao An b06cbd335e fix(rio-v2): detect DARE stream truncation at package boundary (#4585) 2026-07-09 05:44:32 +08:00
Zhengchao An 37c1153c1e fix(ecstore): age LastMinuteLatency samples individually to fix the 1-minute window (backlog#806) (#4581)
fix(ecstore): age LastMinuteLatency samples individually (backlog#806-16)

The rolling one-minute latency window stored bare Duration samples plus a
single, never-updated start_time. Its retain predicate ignored the element
and evaluated the constant now.duration_since(start_time) < 60s, so once the
window had existed for 60s every add() dropped ALL samples and the average
degenerated to the latest single sample.

Each sample now carries its own Instant and is retained by its individual
age. The type backs only in-memory EpHealth + admin display (the wire shape
is target::LatencyStat with curr/avg/max), so Serialize/Deserialize is kept
only to satisfy the enclosing LatencyStat derive; the sample Instant is
serde(skip) and restarts aging on reload.
2026-07-09 05:42:51 +08:00
Zhengchao An ed36d1ed97 fix(io-metrics): drop client-controlled bucket label from s3 ops counter (backlog#806) (#4580)
fix(io-metrics): drop client-controlled bucket label from rustfs_s3_operations_total (backlog#806-22)

The bucket dimension on the rustfs_s3_operations_total counter is
client-controlled and unbounded, letting any client explode metric
cardinality (a Prometheus/OTEL cardinality DoS that grows the in-process
OTEL aggregation store even without a scrape). Drop the bucket label so the
series is keyed by the bounded op dimension only (<=122 variants), matching
MinIO which never labels its default operation counters with bucket.

BREAKING: rustfs_s3_operations_total no longer carries a "bucket" label.

record_s3_op(op, bucket) -> record_s3_op(op); all call sites in
rustfs/src/storage/{ecfs.rs,helper.rs} updated (bucket bindings retained
where still used by audit/notify paths). Add metrics-util debugging recorder
dev-dep and tests asserting the series carries only the op label and that
series count equals distinct-op count.
2026-07-09 05:42:27 +08:00
Zhengchao An da67d6d695 fix(rio): use typed-first internode HTTP error classification (#4578)
fix(rio): typed-first internode HTTP error classification (backlog#805)

classify_reqwest_error matched reqwest/hyper error text by English
substrings, brittle to library/OS-locale wording. Refactor to a pure,
testable classify_transport_error(err, is_timeout, is_connect, is_body)
that trusts structured signals first: caller timeout, then the io::Error
kind found anywhere in the source chain (typed wins over any string/body
signal so a real ConnectionRefused is never mislabeled DnsResolutionFailed),
then a DNS-only string heuristic gated behind is_connect, then body, then
Unknown.

Flip DnsResolutionFailed to retryable, mirroring MinIO IsNetworkOrHostDown
(*net.DNSError => network-or-host-down => retryable); RustFS already retries
transient DNS at endpoints.rs, and bounded retries cost at most one extra
attempt on permanent NXDOMAIN.

Swap the ecstore remote_disk non-retryable exemplar from DnsResolutionFailed
to Unknown accordingly.
2026-07-09 05:41:25 +08:00
Zhengchao An ddb9d8ca3e fix(object-capacity): harden write timing, interval bounds, and scope expiry (#4575)
fix(object-capacity): harden clock handling in write window, background intervals and scope registry

Three low-severity robustness gaps (S32+S33+#35):

- WriteRecord keyed its 60-bucket write window by wall-clock unix
  seconds while the debounce used Instant. An NTP step backwards marked
  recent buckets as future and silently suppressed write-triggered
  refreshes until the wall clock caught up; a forward step aged the
  whole window at once. Bucket keys now derive from a monotonic
  per-record epoch, immune to clock steps.
- Background refresh/metrics intervals were only clamped at zero;
  RUSTFS_CAPACITY_SCHEDULED_INTERVAL=u64::MAX overflowed
  Instant + Duration and panicked the spawned task, silently killing
  scheduled refreshes. Intervals now clamp into [1s, 30 days] via one
  helper with a structured warn.
- record_capacity_scope merged new disks into an expired entry and
  refreshed its recorded_at, resurrecting a scope take_capacity_scope
  would have discarded (and the merge path never pruned). Expired
  entries are now replaced, not merged into.

Ref: rustfs/backlog#1022 (S32+S33+#35 from audit rustfs/backlog#1010)
2026-07-09 05:27:36 +08:00
houseme 506ded59ad fix(metrics): servers_offline_total counts each peer once (normalize peer-health key) (#4572)
fix(metrics): normalize peer-health key so servers_offline_total counts each peer once

rustfs_cluster_servers_offline_total is the count of distinct keys in the
addr-keyed CLUSTER_PEER_HEALTH map. The data path keys a peer by
endpoint.grid_host() (scheme://host:port) while the lock path keys it by
url::Url::to_string() (scheme://host:port/, trailing slash), so one physical
peer becomes two health entries and a single downed node reports 2 (2N for N).

Normalize the address (trim trailing slashes) at the map boundary in
record_peer_reachable/record_peer_unreachable/cluster_peer_is_offline/
cluster_peer_should_bypass so both forms collapse to one canonical key.
Pure observability fix; peer selection and quorum are unchanged.

Fixes rustfs/backlog#1043

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-08 20:56:08 +00:00
Zhengchao An cf2da0c44d refactor(object-capacity): remove the decorative SymlinkTracker and its no-op depth knob (#4571)
The tracker never influenced traversal: walkdir's follow behavior is
fixed up front by follow_links(), and should_follow() only gated the
tracker's own bookkeeping. Its 'depth limit' compared tree depth (not
symlink chain depth), so RUSTFS_CAPACITY_MAX_SYMLINK_DEPTH was a
complete no-op while its telemetry claimed symlinks were skipped that
walkdir had in fact followed and counted; record_symlink was always
called with size 0, so tracked_bytes never left zero (S12).

Remove the tracker, its skipped/summary events, the symlink metric and
the depth env knob end to end (the env's 'as u8' truncation goes with
it), and document the real semantics at the walker: follow_links(true)
counts targets with walkdir's ancestor-loop detection breaking cycles,
follow_links(false) — the default — counts no symlink targets. The scan
root itself is pre-resolved since backlog#1015.

Ref: rustfs/backlog#1018 (S12 from audit rustfs/backlog#1010)
2026-07-09 04:55:09 +08:00
Zhengchao An 2dfad181a0 fix(admin): include accountinfo bucket quota
Expose accountinfo bucket quota only when a bucket quota is configured while preserving the existing response shape for buckets without quotas.
2026-07-09 04:52:52 +08:00
Zhengchao An e468648f0f fix(utils): map remaining Linux uapi filesystem magic values
Map the remaining stable Linux uapi filesystem magic constants and keep values without a stable uapi source explicitly unknown.
2026-07-09 04:52:43 +08:00
Zhengchao An 90d769167b test(ecstore): pass codec fallback flag in tests
Pass the explicit allow_inplace_legacy_fallback flag in codec streaming reader tests so CI builds compile after the signature change.
2026-07-09 04:22:19 +08:00
Henry Guo 91a23361ee fix(ecstore): tolerate completed metacache producers (#4531)
Co-authored-by: Henry Guo <marshawcoco@users.noreply.github.com>
2026-07-09 04:10:57 +08:00
Henry Guo c6d054245f fix(admin): refresh datausage live bucket usage (#4490)
* fix(admin): refresh datausage live bucket usage

* fix(admin): reapply datausage memory overlay

* fix(ecstore): avoid runtime lookup for empty shard costs

---------

Co-authored-by: Henry Guo <marshawcoco@users.noreply.github.com>
2026-07-09 04:09:40 +08:00
Zhengchao An 32b1094ec3 fix(object-capacity): resolve a symlinked scan root instead of silently counting zero (#4564)
walkdir defaults to follow_root_links=true precisely so a root that is
itself a symlink still descends, but the scanner overrode it with the
general follow_symlinks flag (default false). For the common indirection
layout (/data/rustfs0 -> /mnt/vol0) the walk yielded a single skipped
symlink entry and returned used_bytes=0, file_count=0, is_estimated=false
with no error and no log — an exact 0 committed straight into the
per-disk baseline (S05).

Canonicalize the scan root before walking (also covering nested
indirection and keeping the dedicated-mount statvfs check on the real
path); resolution failures surface as scan errors instead of a silent
zero. Inner-symlink policy is unchanged.

Ref: rustfs/backlog#1015 (S05 from audit rustfs/backlog#1010)
2026-07-09 03:43:05 +08:00
houseme 7e1f7f242e fix(ecstore): preserve CommonPrefixes when an object and same-named prefix dir coexist in merge (backlog#880) (#4563)
Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-08 19:40:13 +00:00
houseme 8fc75e88c8 test(ecstore): multi-disk regression for read-before-write tagging under early-stop (backlog#881) (#4561)
test(ecstore): multi-disk regression for read-before-write tagging under early-stop

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-08 19:22:06 +00:00
houseme 9636989fef perf(ecstore): in-place per-part legacy degradation for lazy multipart codec reader (backlog#879) (#4560)
perf(ecstore):  in-place per-part legacy degradation for lazy multipart codec reader

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-08 19:16:32 +00:00
Zhengchao An 787cc77a7e fix(object-capacity): clamp zero capacity env values to safe defaults (#4559)
RUSTFS_CAPACITY_MAX_FILES_THRESHOLD=0 made every file count as
overflow with an empty exact prefix, so disks with fewer files than the
sample rate committed 0 bytes as the cluster total; STAT_TIMEOUT=0 with
dynamic timeout disabled made every scan fail at the first entry. Both
were accepted unvalidated, unlike the already-clamped sample_rate and
interval values.

Zero values for the threshold and the stat/min/max timeouts now fall
back to their defaults with a single structured warn at config load
(S11). The symlink-depth knob is left untouched here: it is removed
entirely by the backlog#1018 fix.

Ref: rustfs/backlog#1019 (S11 from audit rustfs/backlog#1010)
2026-07-09 03:14:11 +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
Zhengchao An 68c3278efd test(utils): unignore drive stats platform test (#4549) 2026-07-09 03:02:13 +08:00
Zhengchao An 276a4f24c0 test(utils): extend Linux fs type mappings (#4556)
test(utils): extend linux fs type mappings
2026-07-09 03:01:57 +08:00
Zhengchao An 0ebe00b383 fix(object-capacity): drive scan progress checks by visited entries and remove unreachable stall detector (#4557)
Progress/timeout checks only ran when file_count advanced, so trees of
directory-only or error-dense entries (dirs, traversal errors, symlinks
never increment file_count) bypassed the entire cooperative time budget
and held the blocking thread for unbounded time, while each error entry
logged its own warn — permission-dense trees flooded the log (S13).
The stall detector was structurally unreachable: it fired on 'no file
progress between checks', but checks themselves only ran when file
progress happened, so RUSTFS_CAPACITY_STALL_TIMEOUT never did anything
since its introduction (S09).

- Progress checks (timeout + early-sampling entry) are now driven by a
  visited-entry counter that also advances on directories and errors, so
  every tree shape reaches the budget checks.
- Remove the unreachable stall detector and its plumbing end to end
  (ProgressMonitor fields, ScanLimits, config getter, env const, stall
  metric). Genuine walker wedges are handled by the hard outer
  wall-clock budget from backlog#1017; no decorative protection is left.
- Cap per-entry error warns at 10 per scan with an explicit suppression
  notice; had_partial_errors still records the condition.

Ref: rustfs/backlog#1016 (S09+S13 from audit rustfs/backlog#1010)
2026-07-09 02:47:17 +08:00
houseme d232a46b4d perf(ecstore): wire legacy decode stripe prefetch behind default-off gate (HP-9 step 2) (#4542)
perf(ecstore): wire legacy decode stripe prefetch / bitrot-decode overlap (backlog#930 HP-9 step 2)

The legacy GET decode duplex loop (default GET path + every Range request)
was strictly serial: read a stripe, reconstruct it, emit it, and only then
begin reading the next stripe. Two switches introduced by PR#3972 to overlap
this work — RUSTFS_GET_DECODE_STRIPE_PREFETCH_COUNT and
RUSTFS_GET_BITROT_DECODE_OVERLAP_ENABLE — were config-only with zero call
sites since introduction.

Wire both into the loop as a depth-1 stripe prefetch: while the current
stripe is reconstructed and emitted, the next stripe's shard reads (including
bitrot verification) run concurrently, hiding read latency under the emit /
duplex-backpressure stage. ParallelReader::read is inherently serial (it takes
&mut self and advances a shared stripe cursor), so at most one read can be in
flight; a prefetch count above 1 therefore collapses to the same
single-stripe-ahead pipeline rather than reading several stripes ahead. Both
switches feed one gate, legacy_stripe_prefetch_enabled().

Default (count == 1, overlap == false) keeps the loop on the pre-existing
strictly-serial path, byte for byte: the prefetch pipeline is a separate branch
and the serial branch is unchanged. The reconstruct/emit body is factored into
a shared emit_decoded_stripe helper used by both branches so error attribution,
read-quorum handling, reconstruction verification and stage metrics cannot
drift between them.

Correctness guarantees preserved under prefetch:
- A speculatively prefetched read for stripe N+1 is only consumed when the loop
  reaches N+1; if stripe N stops the loop, the in-flight read is awaited and
  dropped, so its errors never surface against stripe N.
- Bitrot (HighwayHash) verification runs inside each stripe read and is not
  bypassed or reordered; a corrupt shard is still rejected and, when
  unrecoverable, the read fails without emitting garbage.
- Shard buffers are recycled only after the overlapping next read has claimed
  its own — one extra stripe of memory (double buffering) with buffer reuse
  preserved at a one-stripe lag.
- Per-stripe exact length advance (backlog#799 B2), lockstep reconstruction
  verification (backlog#832) and the hash_size == 0 pass-through are unchanged.

Adds regression tests exercising serial-default, count>1 and overlap configs:
full/range reads byte-exact, degraded (missing-shard) reconstruction, corrupt
shard rejected-but-recovered, unrecoverable corruption erroring with no output,
late-stripe failure attributed correctly with stripe 0 still emitted,
hash_size == 0 pass-through, and the gate defaulting off.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-08 18:35:11 +00:00
houseme e3e5693e60 fix(ecstore): correct heal result drive-record reporting (#4555)
Two report-only defects on the heal result-reporting surface (they do not
affect the data path or heal decisions):

- default_heal_result (set_disk/ops/heal.rs): the offline-disk branch pushed
  an Offline record but fell through into the unconditional push, emitting a
  second (Corrupt) record for the same disk. This grew before/after.drives to
  disk_count + offline_count and misaligned every entry after the first offline
  slot. Add `continue` after the offline push, drive `disk_len` and the loop
  from a single `self.disks` snapshot, and assert `errs.len() == disk_len`.

- Sets::heal_format (core/sets.rs): the before/after drive lists were
  pre-filled with N default placeholders and then N real entries were pushed,
  yielding a 2N list whose healed status updates (indexed 0..N) landed on the
  blank placeholder half. Assign the lists directly from formats_to_drives_info
  (mirroring the set-level heal_format) so the healed updates hit the real
  entries.

Add regression tests covering offline/online record alignment and the
NoHealRequired and heal paths of the pool-level heal_format.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-08 18:29:54 +00: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 726f3dc185 fix(ecstore): accept empty remote version_id in tier recovery paths (#4552)
An object transitioned to an unversioned remote tier legally records an
empty remote version_id (per CLAUDE.md: a tier version of `None`/`""`
means the tier bucket is unversioned, so remote GET/DELETE must omit the
versionId). Two recovery paths wrongly treated that empty sentinel as an
incomplete/unrecoverable record while the persist/encode and worker
paths accept it, producing permanent leaks in the crash-recovery window.

- tier_delete_journal::into_jentry rejected entries with an empty
  version_id as "incomplete", so unversioned-tier WAL entries could never
  be decoded during recovery: the remote object was orphaned and the
  journal file leaked forever. Drop the version_id emptiness check; keep
  the obj_name/tier_name checks. Truncated payloads are still rejected at
  JSON deserialization (all fields are required, non-Option, no default,
  with deny_unknown_fields).

- tier_free_version_recovery::is_recoverable_tier_free_version required a
  non-empty version_id, silently filtering out free versions of
  unversioned-tier objects so a first enqueue failure leaked the remote
  object and local free version permanently. Drop the version_id check;
  keep free_version + name + tier checks.

Both downstream paths already issue a versionless remote delete for an
empty version_id, so no further changes are needed. Adds regression
tests covering the empty-version_id case and the preserved guards.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-08 18:26:29 +00:00
houseme f96314a1d5 docs(ecstore): pin streaming-only bitrot layout invariant (ECA-18) (#4553)
bitrot_shard_file_size only counts per-block checksum bytes for the two
streaming Highway variants, while BitrotWriter::write interleaves a hash
for any hash_algo.size() > 0 and bitrot_verify's read loop assumes an
interleaved hash per block. The three disagree for non-streaming
algorithms (SHA256/HighwayHash256/BLAKE2b512/Md5), but the divergence is
unreachable in production: every write path hardcodes HighwayHash256S and
ErasureInfo::get_checksum_info defaults to HighwayHash256S.

Per the audit decision (backlog#959), do NOT change the size formula:
it is a byte-for-byte port of MinIO's bitrotShardFileSize and its bare
return for non-streaming algorithms is correct for MinIO whole-file
bitrot; changing it would break legacy interop. Instead, document the
per-algorithm layout contract at bitrot_shard_file_size, BitrotWriter,
and bitrot_verify, and add regression tests that pin the invariants:
get_checksum_info defaults to HighwayHash256S, and the size formula
counts per-block hash bytes for streaming variants only while returning
the bare size for non-streaming ones. No disk layout or formula change.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-08 18:25:05 +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
Zhengchao An 72d76dc9ca fix(object-capacity): make dirty-disk lifecycle race-free and prune ghost entries (#4550)
Two dirty-mark lifecycle gaps (S19+S30):

- A commit cleared every dirty mark for the disks it scanned, even marks
  recorded while the walker was already past the written prefix, so the
  next dirty-subset refresh served stale cached bytes as exact until the
  next full scan. Dirty marks now carry the instant they were last
  recorded and a commit only clears marks that predate its scan start
  (CapacityUpdate::scan_started_at, set by refresh_capacity_with_scope).
- The write side marks every disk of an EC set dirty, including remote
  peers, while scans and clearing only cover local disks — remote or
  removed disks stayed marked forever, keeping the dirty-disk gauge
  permanently non-zero with bounded memory stuck behind it. The refresh
  disk selection now prunes dirty entries outside the current local
  topology before consulting them.

Ref: rustfs/backlog#1020 (S19+S30 from audit rustfs/backlog#1010)
2026-07-09 02:22:09 +08:00
houseme c77c5f047a fix(ecstore): defer multipart part.N.meta cleanup until after commit (#4548)
complete_multipart_upload deleted every part.N.meta via
cleanup_multipart_path *before* the authoritative rename_data commit.
If rename_data then failed write quorum, the upload directory was left
with data files but no part metadata, so a retried CompleteMultipartUpload
could never read the parts again and the upload became permanently
uncompletable (client must abort and re-upload all parts).

Move cleanup_multipart_path to run only after rename_data returns Ok,
matching the existing "clean up only after commit" pattern already used
for the old data-dir GC and the upload-dir delete_all. On a failed commit
the staging part.N.meta now survive so the retry can complete.

Add a hermetic regression test that forces rename_data to fail on every
disk (destination bucket dir made read-only) and asserts the staging
part.N.meta are preserved for retry.

Fixes #946.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-08 18:06:42 +00:00