Compare commits

...

108 Commits

Author SHA1 Message Date
wood 58ac19f7a4 fix(sse): optimize is_encrypted for old metadata compatibility (#3113)
Signed-off-by: w0od <dingboning02@163.com>
Co-authored-by: houseme <housemecn@gmail.com>
2026-05-29 11:59:08 +00:00
安正超 ac97ceb744 fix(config): restore default credential startup (#3114)
* fix(config): restore default credential startup

* fix: align e2e credentials with server env

* fix(config): restore default credential consistency

---------

Co-authored-by: houseme <housemecn@gmail.com>
2026-05-29 11:52:46 +00:00
houseme 5d74637968 fix(utils): tolerate bavail greater than bfree on Linux (#3119)
* fix(utils): tolerate bavail greater than bfree on Linux

Treat f_bavail > f_bfree as a compatibility edge case instead of fatal corruption during startup disk info probing.

- keep corruption checks for other invalid counter relationships
- log a warning and clamp reserved blocks to 0 when bavail exceeds bfree
- add linux unit coverage for the compatibility and overflow paths

Refs #3025

* fix(utils): cap bavail to bfree for Linux statfs edge

* fix(utils): rate-limit Linux statfs compatibility warning

* test(utils): cover Linux statfs capacity math

Expand Linux statfs capacity math tests around normal, equal, zero, and fully-free block counter combinations.

Refs #3025

* fix(utils): avoid statfs warning guard reallocations

* test(utils): align statfs reserved block expectations

* fmt

* fix(utils): avoid warning guard path allocation

Check the warn-once set before allocating a PathBuf so repeated bavail/bfree compatibility warnings stay low-overhead.

Refs #3025
2026-05-29 11:20:47 +00:00
houseme 9f78cd3896 chore(deps): bump workspace dependencies (#3118)
* chore(deps): bump workspace dependencies

- bump direct dependency versions in Cargo.toml\n- refresh Cargo.lock to resolve updated crates

* fix(deps): avoid pyroscope and pprof linker collision

- downgrade pyroscope from 2.0.6 to 2.0.5\n- keep a single pprof implementation path via pprof-pyroscope-fork\n- fix duplicate perf_signal_handler during test linking
2026-05-29 09:36:19 +00:00
houseme 2b82432f9e fix(ecstore): send valid ping body in remote locker (#3112)
* fix(ecstore): send valid ping body in remote locker

Build ping requests with a flatbuffer payload so health checks remain compatible with the ping response parser after restart.

* fix(bench): use multi-host warp target during failover

Normalize comma-separated warp host lists in run_object_batch_bench and let four-node failover bench pass BENCH_WARP_HOSTS so rolling restart does not pin load to a single restarting node.

* feat(health): add compat health probes with busy/KMS checks

  - Add /health/live liveness probe endpoint
  - Add busy protection (429) for readiness probes, gated by RUSTFS_HEALTH_COMPAT_BUSY_CHECK_ENABLE
  - Add KMS readiness check for /health/ready, gated by RUSTFS_HEALTH_COMPAT_KMS_READY_CHECK_ENABLE
  - Add lock quorum status caching with TTL to reduce RPC pressure
  - Consolidate health response building into build_health_response_parts
  - Register /health/live in console router and readiness gate
  - Remove MinIO references from newly added health code

* fix(health): decouple kms readiness from lock quorum
2026-05-29 08:02:50 +00:00
GatewayJ c257043b63 fix(iam): serialize IAM cache writes (#3105)
* fix(iam): serialize IAM cache writes

* fix(iam): timestamp rebuilt group memberships

* fix(iam): publish cache updates atomically

* fix(iam): reuse policy cache snapshots

* fix(iam): commit missing user notification cache updates atomically

* fix(iam): remove unused cache membership rebuild wrapper

---------

Co-authored-by: 季宏伟 <jihongwei@jihongweis-MacBook-Pro.local>
Co-authored-by: houseme <housemecn@gmail.com>
2026-05-29 08:02:42 +00:00
houseme ede813070f fix(site-replication): refresh TLS peer client and tls inspect alias (#3109)
* fix(site-replication): refresh peer TLS client cache

- rebuild site-replication peer reqwest client when outbound TLS generation changes\n- keep cached client/failed state per generation for low-overhead requests\n- accept --tls-path as alias of tls inspect --path for operator compatibility\n- add targeted parser/cache tests\n\nRefs #2723

* docs(issue-2723): add private-ca cert rotation retest

- add executable retest checklist for private CA site-replication flows\n- cover tls inspect compatibility (--path and --tls-path)\n- include post-rotation and post-restart pass criteria\n\nRefs #2723

* test(site-replication): isolate global TLS cache test

- serialize generation-sensitive peer TLS cache test\n- snapshot and restore global outbound TLS generation and static cache\n- avoid order-dependent interactions with parallel test execution\n\nRefs #2723

* chore: remove docs file from tracking, keep locally

The docs directory is already in .gitignore but this file was
previously committed. Remove it from git index to prevent docs
content from being included in the PR.

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

* test(site-replication): cover ready client cache hit path

Add a focused unit test for unchanged-generation Ready cache entries in site_replication_peer_client_cache_hit to validate steady-state client reuse behavior highlighted in PR #3109 review feedback.

---------

Co-authored-by: loverustfs <hello@rustfs.com>
Co-authored-by: heihutu <heihutu@gmail.com>
2026-05-29 07:17:55 +00:00
安正超 b436272642 fix: tolerate stalled listing readers after quorum (#3110)
Co-authored-by: loverustfs <hello@rustfs.com>
2026-05-29 08:57:57 +08:00
houseme 1de0ba916d fix(ecstore): reduce restart-time startup race noise (#3108)
Treat empty ping bodies as liveness probes, add a startup cleanup barrier for early walk_dir calls, and delay immediate background cleanup/capacity timers to reduce transient restart-time VolumeNotFound noise.

Also downgrade expected missing-path producer results during startup from generic errors to warnings while preserving existing storage semantics.
2026-05-28 15:14:30 +00:00
houseme 088c4bda43 fix(ecstore): harden issue3031 multipart validation path (#3106)
* fix(ecstore): harden issue3031 multipart validation path

- clear stale multipart part destinations before rename fan-out
- add repeated part overwrite regression coverage
- reduce remote disk startup false-fault escalation to suspect-first
- refine remote locker diagnostics and lower scanner leader-lock log noise
- add a dedicated 4-node issue3031 docker validation script

* refactor(admin): inline console version json macro

- drop the unused serde_json::json import in admin console
- call serde_json::json! inline in version_handler
- keep the console version response behavior unchanged

* fix(remote-disk): recover suspect health on probe success

- record probe success during remote disk health checks so suspect drives recover
- use async_with_vars for the remote disk health probe test
- make the missing-listener test assert the state transition more robustly
2026-05-28 14:26:31 +00:00
GatewayJ 8d20e89bf8 fix(iam): avoid stale cache replacement on walk errors (#3094)
* fix(iam): avoid stale cache replacement on walk errors

* fix(iam): guard full reload cache commits

---------

Co-authored-by: houseme <housemecn@gmail.com>
Co-authored-by: loverustfs <hello@rustfs.com>
Co-authored-by: 季宏伟 <jihongwei@jihongweis-MacBook-Pro.local>
2026-05-28 09:54:06 +00:00
houseme 28bac7fbd6 chore(release): prepare 1.0.0-beta.6 (#3104)
* chore(release): prepare 1.0.0-beta.6

* ci(nix): harden flaky crate fetch handling

* ci(nix): drop magic cache and force fallback

* ci(nix): set explicit user-agent for crate fetch

* ci(nix): adopt determinate nix workflow stack

* ci(nix): add nix user-agent suffix for fetches

* ci(nix): add flakehub cache and align determinate actions

* ci(nix): pin determinate actions to release tags

* ci(nix): disable flakehub auth path in CI cache

* ci(nix): restore stable magic cache baseline

* ci(nix): trust local magic cache substituter

* ci(nix): stop forcing Node24 for JS actions

* ci(nix): drop manual localhost cache config

* ci(nix): adopt latest determinate flakehub stack

* ci(nix): record latest determinate workflow state
2026-05-28 09:21:16 +00:00
cxymds 527faad575 docs(readme): add Discord community links (#3103) 2026-05-28 12:22:17 +08:00
houseme dd1ffbaee7 fix(lock): isolate retry attempt lock ids (#3102)
* fix(lock): isolate retry attempt lock ids

Generate a fresh lock id for each distributed lock retry attempt so late pending cleanup from an earlier attempt cannot release a lock acquired by a later retry.

Also add a regression test covering late-success cleanup versus retried acquisition.

* style(lock): normalize retry race regression test

Normalize the distributed lock retry race regression test formatting without changing behavior.

* test(lock): remove quorum-order assumption

Make the retry race regression test assert the safety property without assuming which delayed client enters the retry quorum first.
2026-05-28 03:56:12 +00:00
GatewayJ 247973f34c fix(lifecycle): make transition worker resize nonblocking (#3090)
Co-authored-by: 季宏伟 <jihongwei@jihongweis-MacBook-Pro.local>
Co-authored-by: houseme <housemecn@gmail.com>
2026-05-28 01:36:08 +00:00
安正超 d9c683decc fix: retry namespace lock quorum contention (#3098)
* fix: retry namespace lock quorum contention

* fix(lock): treat remote lock rpc failures as hard

* fix(lock): classify lock contention timeout and avoid hard rollback blocks

* fix(lock): handle namespace lock quorum timeouts

* fix(lock): refine quorum contention handling

* fix(lock): refine unrecoverable quorum handling logs

* fix: address namespace lock quorum review feedback

* test: stabilize batch quorum timing tests

* fix(lock): classify remote quorum failures

* fix: drop remote lock timeout grace period

* fix: satisfy clippy on lock quorum error

* fix: classify remote lock rpc timeouts as hard failures

* fix: fast fail impossible lock quorum attempts

* fix: retry on transient timeout with pending cleanup

* fix: evict remote lock timeout connections

* refactor: avoid duplicate timeout eviction warning

---------

Co-authored-by: houseme <housemecn@gmail.com>
2026-05-28 01:35:00 +00:00
overtrue f77e979e4a fix: satisfy clippy for Snowball traversal test 2026-05-28 09:16:00 +08:00
overtrue 95836a0a4d fix: reject Snowball extract path traversal 2026-05-28 08:32:13 +08:00
Henry Guo 0c52334480 fix(lock): retry transient distributed lock timeouts (#3101)
* fix(lock): retry transient distributed lock timeouts

* fix(lock): avoid retry during pending lock cleanup

* fix(lock): preserve acquire timeout budget

---------

Co-authored-by: Henry Guo <marshawcoco@users.noreply.github.com>
Co-authored-by: houseme <housemecn@gmail.com>
2026-05-27 17:55:46 +00:00
Henry Guo f8e6fc1f10 fix(ecstore): offload erasure encoding from async workers (#3099)
* fix(ecstore): offload erasure encoding from async workers

* fix(ecstore): reuse encode buffers in blocking tasks

---------

Co-authored-by: Henry Guo <marshawcoco@users.noreply.github.com>
Co-authored-by: houseme <housemecn@gmail.com>
2026-05-27 17:54:05 +00:00
houseme 5dfc1b5f07 fix(readiness): gate on lock quorum health (#3100)
* fix(readiness): gate on lock quorum health

Include distributed lock quorum in runtime readiness decisions and expose the signal in health responses.

Map namespace lock quorum failures to ServiceUnavailable instead of InternalError so clients can safely retry while keeping quorum enforcement unchanged.

* fix(ecstore): restore namespace lock error decoding

Add the missing from_u32 arm for NamespaceLockQuorumUnavailable and cover the numeric roundtrip in error tests.
2026-05-27 17:12:52 +00:00
houseme 4648de9e62 chore(deploy): refine systemd and nixos service docs (#3096)
* fix(deploy): simplify systemd service startup

* docs(deploy): add nixos systemd service example

* docs(deploy): add nixos service guide

* chore(deps): update pulsar and pyroscope

* docs(deploy): refine nixos service guidance

* fix(deploy): use explicit rustfs server entrypoint
2026-05-27 14:47:47 +00:00
Derek Ditch 11e97951fd fix(helm): add LoadBalancer service type support (#3049)
The service template only handled ClusterIP and NodePort via if/else-if
branches. When service.type=LoadBalancer was set, no branch matched and
the type field was omitted from the rendered Service, causing Kubernetes
to silently default to ClusterIP.

Add the missing LoadBalancer branch with support for:
- loadBalancerIP: request a specific IP (e.g. Cilium LB-IPAM, MetalLB)
- loadBalancerClass: select a specific LB implementation
- loadBalancerSourceRanges: restrict client source IPs

This is particularly useful for bare-metal / on-prem clusters using
software load balancers (Cilium LB-IPAM, MetalLB, kube-vip) where
a stable routable IP is preferable to an Ingress for S3 clients.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: houseme <housemecn@gmail.com>
Co-authored-by: 安正超 <anzhengchao@gmail.com>
Co-authored-by: cxymds <Cxymds@qq.com>
Co-authored-by: majinghe <42570491+majinghe@users.noreply.github.com>
2026-05-27 14:35:09 +00:00
安正超 f53eb4ad44 fix: reject invalid multipart part numbers (#3091)
Co-authored-by: houseme <housemecn@gmail.com>
2026-05-27 08:09:59 +00:00
houseme ceffe21f75 feat(tls): add inspect command for TLS layouts (#3092) 2026-05-27 07:23:58 +00:00
houseme 909f0d57fb feat: improve degraded readiness reporting and shutdown handling (#3089)
* fix(server): unify runtime readiness and shutdown flow

* refactor(server): decouple readiness shared types

* refactor(server): keep readiness types crate-private

* refactor(server): await protocol shutdown handles

* fix(server): bypass cached startup readiness

* feat(health): expose degraded readiness reasons

* fix(health): preserve raw IAM readiness in degraded reports
2026-05-27 03:25:28 +00:00
houseme 658b8dea66 fix: unify runtime readiness publication and graceful shutdown flow (#3087)
* fix(server): unify runtime readiness and shutdown flow

* refactor(server): decouple readiness shared types

* refactor(server): keep readiness types crate-private

* refactor(server): await protocol shutdown handles

* fix(server): bypass cached startup readiness
2026-05-26 18:49:42 +00:00
Henry Guo 0478505839 fix(heal): restore single disk data during deep heal (#3085)
* fix(heal): restore single disk data during deep heal

* fix(heal): clarify erasure heal step logging

* fix(heal): avoid duplicate deep object heals

* fix(ecstore): preserve volume-not-found walk errors

---------

Co-authored-by: Henry Guo <marshawcoco@users.noreply.github.com>
Co-authored-by: houseme <housemecn@gmail.com>
2026-05-26 17:36:17 +00:00
安正超 0875f09a39 fix: rebuild wiped disks during admin heal (#3084)
* fix: rebuild wiped disks during admin heal

* Preserve forceStart heal admission semantics

* Address heal regression test review comments

* fix(heal): address admin heal review comments

* fix(heal): fix force-start dedup and test polling

* fix(heal): address unresolved review comments

* fix(heal): simplify dedup key for clippy

---------

Co-authored-by: houseme <housemecn@gmail.com>
2026-05-26 21:54:29 +08:00
weisd ea74fa7a95 fix(heal): rebuild parity shards during repair (#3086)
Object repair rebuilt missing data shards but left missing parity shards unrecreated. A parity-only repair could therefore complete without restoring the shard contents.

Add a repair-specific reconstruction path that regenerates parity after data shards are available, while keeping normal read decoding data-only. Also shut down repair writers after all blocks are written.

Constraint: Preserve read-path decode behavior and limit parity regeneration to repair.

Confidence: high

Scope-risk: moderate

Directive: Do not route read decoding through parity regeneration without measuring the cost.

Tested: cargo test -p rustfs-ecstore decode_data_and_parity -- --nocapture

Tested: cargo test -p rustfs-ecstore heal_reconstructs_missing_parity_shard -- --nocapture

Tested: cargo test -p rustfs-ecstore erasure_coding -- --nocapture

Tested: cargo test -p rustfs heal_object_marks_missing_shard_disk_dirty_for_capacity_manager -- --nocapture

Tested: cargo test -p rustfs-heal -- --nocapture

Tested: cargo clippy -p rustfs-ecstore --all-targets -- -D warnings

Tested: cargo fmt --all --check

Tested: make pre-commit

Not-tested: Live multi-node disk replacement outside local test harness
2026-05-26 09:49:25 +00:00
Henry Guo 62d1f80dbf fix(data-usage): refresh admin usage after object changes (#3081)
Co-authored-by: Henry Guo <marshawcoco@users.noreply.github.com>
2026-05-25 14:24:00 +00:00
安正超 0d0a17bb36 fix: include deployment ID in admin info (#3083)
* fix: include deployment ID in admin info

* test(ecstore): avoid mutating global deployment id
2026-05-25 14:23:35 +00:00
houseme 3d2449872a refactor(credentials): derive RPC secret fallback and remove IAM keygen duplication (#3079)
* refactor(credentials): derive rpc secret and remove iam keygen

* fix(credentials): reject default access key RPC secret

* test(credentials): align RPC fallback and add keygen coverage
2026-05-25 11:05:58 +00:00
Demo Macro 5f5b1207e0 fix(ecstore): correct is_truncated logic in ListObjectsV2 pagination (#2997) 2026-05-25 14:59:19 +08:00
Sergei Z. 64feca3550 fix(user): service account expiration handling with RFC3339 (#3078)
fix(user): enhance service account expiration handling with RFC3339 support

Co-authored-by: houseme <housemecn@gmail.com>
2026-05-25 04:27:25 +00:00
houseme f1207a9e28 docs(skills): quote release bump description (#3077)
* docs(skills): quote release bump description

* chore(deps): bump lapin and rumqttc-next
2026-05-24 19:59:29 +00:00
weisd f9475e10dc fix(replication): preserve multipart pending state (#3058)
* Preserve multipart replication recovery state

Multipart uploads previously only scheduled replication after completion, leaving no persisted pending state for scanner recovery if the initial async work was lost. Persist the same pending replication metadata during multipart initialization and let completion evaluate the object metadata that was actually stored.

The scanner heal path also treated ordinary pending objects as delete-replication candidates. Restrict that path to delete markers and version purge state so pending objects remain eligible for object replication heal.

Constraint: Bucket replication recovery depends on persisted object metadata after the async queue is unavailable.
Rejected: Rely only on immediate completion-time scheduling | it cannot recover after process restart or worker loss.
Confidence: high
Scope-risk: moderate
Directive: Keep multipart upload initialization aligned with single PUT replication metadata semantics.
Tested: cargo fmt --all --check
Tested: cargo clippy --workspace --all-features --all-targets -- -D warnings
Tested: LC_ALL=en_US.UTF-8 LANG=en_US.UTF-8 make pre-commit
Tested: Runtime replication outage check confirmed multipart xl.meta stores PENDING status and timestamp.

* fix(replication): preserve version purge scanner state

Role-derived replication configs need target-scoped status strings before scanner heal can build per-target purge status. The duplicated replication-status assignment left version purge status unset, so scanner recovery could lose the target-level purge state.

Constraint: Scanner heal derives per-target purge decisions from version_purge_status_internal.
Rejected: Leave the duplicate as a harmless cleanup | it changes recovery behavior for role-only configs with version purge state.
Confidence: high
Scope-risk: narrow
Directive: Keep role-derived replication and version purge internal status mapping symmetric.
Tested: cargo fmt --all --check
Tested: cargo test -p rustfs-ecstore heal -- --nocapture

---------

Co-authored-by: wly <wlywly0735@126.com>
Co-authored-by: houseme <housemecn@gmail.com>
2026-05-24 14:24:00 +00:00
GatewayJ b0646be756 feat(s3select): improve SelectObjectContent streaming (#3072)
* feat(s3select): improve SelectObjectContent streaming

* fix(s3select): reject empty select expressions

* fix(s3select): address streaming review feedback

---------

Co-authored-by: loverustfs <hello@rustfs.com>
2026-05-24 14:23:47 +00:00
houseme d74e6eb042 refactor(tls): centralize runtime foundation (#3065)
* refactor(targets): move notify net helpers from utils

* refactor(tls): centralize runtime foundation

* refactor(targets): move notify net helpers from utils

* refactor(tls): centralize runtime foundation

* feat(tls-runtime): add TLS debug state and admin handler

* refactor(tls-runtime): unify TLS debug consumer status view

* fix(tls): address PR3065 review feedback

* refactor(tls): align debug status payload types

* refactor(targets): harden TLS hot reload paths

* fix(targets): resolve review-4348251652 findings

* fix(targets): finalize tls runtime review follow-ups

* fix(targets): harden tls reload and review follow-ups

* fix(targets): align tls reload handling across targets

* fix(targets): finalize tls reload state and metrics updates

* chore(deps): trim unused TLS deps

* style(targets): normalize TLS reload formatting

* refactor(targets): introduce tls runtime adapter path

* chore: update workspace manifests for tls refactor

* fix(tls): stabilize material reload and audit workflow

* fix(targets): refresh tls fingerprint flow across sinks

* fix(tls): align runtime coordinator and http reader updates

* fix(sftp): simplify protocol error mapping

* fix(tls): harmonize material loading behavior

* fix(server): finalize tls material wiring in startup flow

* fix(protos): tighten tls generation cache and deps
2026-05-24 06:41:15 +00:00
安正超 8be787387c test(utils): cover bracketed IPv6 zone host parsing (#3073) 2026-05-24 03:18:56 +00:00
houseme c9377161e4 chore(deps): update flake.lock (#3074)
Flake lock file updates:

• Updated input 'nixpkgs':
    'github:NixOS/nixpkgs/d233902' (2026-05-15)
  → 'github:NixOS/nixpkgs/3d8f0f3' (2026-05-23)
• Updated input 'rust-overlay':
    'github:oxalica/rust-overlay/61ec6a4' (2026-05-16)
  → 'github:oxalica/rust-overlay/d9973e2' (2026-05-23)
2026-05-24 03:18:41 +00:00
Henry Guo 522605a055 test(internode): cover adapter metrics validation (#3071)
Co-authored-by: Henry Guo <marshawcoco@users.noreply.github.com>
2026-05-23 15:23:05 +00:00
安正超 22bcfc474e perf(ecstore): use direct std writes for local disk (#3069)
* perf(ecstore): use direct std writes for local disk

* fix(ecstore): avoid blocking disk writes in local disk path

* fix(ecstore): use tokio async write in local disk path

* chore(ecstore): remove unused AsyncWriteExt import

* fix(ecstore): preserve write semantics and avoid bytes copy

* fix(ecstore): optimize write_all_internal buffer paths

* fix(ecstore): sync write path in local disk

* fix(ecstore): align sync flag docs and avoid ref copy

* chore(rust): format local write path

---------

Co-authored-by: houseme <housemecn@gmail.com>
2026-05-23 15:22:49 +00:00
Henry Guo a28cb34381 test(internode): cover RemoteDisk adapter routing (#3070)
Co-authored-by: Henry Guo <marshawcoco@users.noreply.github.com>
2026-05-23 09:41:25 +00:00
LeonWang0735 cbcfe625ef fix(replication): avoid skipping existing-object backfill for new targets (#2992)
* fix(replication): avoid skipping existing-object backfill for new targets

* optimize(replication): delete redundant line

---------

Co-authored-by: houseme <housemecn@gmail.com>
Co-authored-by: cxymds <Cxymds@qq.com>
2026-05-23 04:53:08 +00:00
安正超 02a7d3c228 fix: derive run script CORS console port (#3068) 2026-05-23 04:52:46 +00:00
Henry Guo 53b608c089 docs(internode): keep transport adapter OSS scoped (#3067)
docs(internode): keep transport adapter oss scoped

Co-authored-by: Henry Guo <marshawcoco@users.noreply.github.com>
2026-05-23 13:00:36 +08:00
Henry Guo 2786a4734a docs(internode): align transport adapter scope (#3064)
Co-authored-by: Henry Guo <marshawcoco@users.noreply.github.com>
2026-05-22 16:27:27 +00:00
GatewayJ c9f0f25f55 fix: bind run script to localhost (#3063) 2026-05-22 14:28:17 +00:00
houseme 20a4db7c9a fix(tls): resolve RUSTFS_TLS_PATH startup regression (#3059)
* fix(tls): resolve RUSTFS_TLS_PATH startup regression

* fix(tls): adopt PR review follow-ups
2026-05-22 09:09:31 +00:00
houseme 6264be437c fix(storage): add scoped timeout policy and startup fs guardrail (#3056)
* fix(storage): add RUSTFS_NETWORK_MOUNT_MODE for CIFS/NFS backends

* style: fix cargo fmt formatting in disk_store.rs

* fix(storage): add RUSTFS_NETWORK_MOUNT_MODE for CIFS/NFS backends

Extend the TimeoutHealthAction introduced in #2996 to read_metadata,
list_dir, and disk_info operations when RUSTFS_NETWORK_MOUNT_MODE=true.
Also raises all drive operation timeouts to 60s (explicit per-operation
overrides still take precedence).

Closes #2790

* feat(startup): add unsupported filesystem policy guardrail

* chore(deps): refresh lockfile and dependency pins

* feat(ecstore): add scoped timeout health-action policy

* docs(config): document drive timeout health-action policy

* refactor(ecstore): cache timeout health policy per disk wrapper

* fix(storage): add RUSTFS_NETWORK_MOUNT_MODE for CIFS/NFS backends (#2838)

* fix(storage): add RUSTFS_NETWORK_MOUNT_MODE for CIFS/NFS backends

* style: fix cargo fmt formatting in disk_store.rs

* fix(storage): add RUSTFS_NETWORK_MOUNT_MODE for CIFS/NFS backends

Extend the TimeoutHealthAction introduced in #2996 to read_metadata,
list_dir, and disk_info operations when RUSTFS_NETWORK_MOUNT_MODE=true.
Also raises all drive operation timeouts to 60s (explicit per-operation
overrides still take precedence).

Closes #2790

* fix(utils): map verified Linux filesystem magic values (#3051)

* fix(utils): cover sha256 checksum validation (#3052)

* fix(utils): cover sha256 checksum validation

* docs: clarify sha256 checksum validation

---------

Co-authored-by: houseme <housemecn@gmail.com>
Co-authored-by: 安正超 <anzhengchao@gmail.com>

* refactor(config): replace network mount mode with timeout profile preset

* fix(review): align fallback defaults and extend fs-type detection

* fix(review): cache timeout profile and restore probe timeout semantics

* refactor(ecstore): cache timeout health policy lookup

* perf(ecstore): cache active probe timeout per monitor task

---------

Co-authored-by: mistik <mistiklord4@gmail.com>
Co-authored-by: 安正超 <anzhengchao@gmail.com>
2026-05-22 06:37:30 +00:00
weisd 69345fe059 fix(scanner): preserve background heal compatibility (#3041) 2026-05-22 14:38:35 +08:00
Henry Guo b42766f1d3 feat(internode): define transport capabilities (#3047)
Co-authored-by: Henry Guo <marshawcoco@users.noreply.github.com>
Co-authored-by: houseme <housemecn@gmail.com>
2026-05-22 02:56:37 +00:00
安正超 3e3fcbf44d fix(utils): cover sha256 checksum validation (#3052)
* fix(utils): cover sha256 checksum validation

* docs: clarify sha256 checksum validation
2026-05-22 02:45:53 +00:00
安正超 0d94437788 fix(utils): map verified Linux filesystem magic values (#3051) 2026-05-22 02:37:50 +00:00
houseme ed2078e025 fix(ecstore): allow expired delete markers on locked buckets (#3048)
* fix: allow expired delete markers on locked buckets

* fix: reject zero-day del marker expiration
2026-05-22 01:39:53 +00:00
Henry Guo 2404bc1657 docs(internode): inventory transport data paths (#3040)
Co-authored-by: Henry Guo <marshawcoco@users.noreply.github.com>
2026-05-21 15:40:39 +00:00
Henry Guo 0985f0b37b feat(internode): label transport operation metrics (#3045)
Co-authored-by: Henry Guo <marshawcoco@users.noreply.github.com>
Co-authored-by: houseme <housemecn@gmail.com>
2026-05-21 15:20:39 +00:00
houseme e69e32285b fix(ecstore): harden multipart part metadata visibility (#3042)
* fix(ecstore): harden multipart part metadata visibility

* fix(ecstore): address PR3042 review follow-ups
2026-05-21 14:47:52 +00:00
Henry Guo 97858baf8e docs(internode): analyze buffer lifecycle (#3046) 2026-05-21 22:52:39 +08:00
Henry Guo 69dcf9e6cb fix(tooling): harden internode transport benchmark setup (#3037)
* refactor(config): centralize internode transport constants

* fix(bench): guard all ripgrep calls behind dry-run check

Move require_cmd rg and metrics collection inside the non-dry-run
path so that --dry-run works on hosts without rg installed.

* feat(tooling): cross-platform protoc setup for Linux and macOS

Make install-protoc.sh support Linux (x86_64, aarch64) alongside
macOS, and bump CI protoc from 29.3 to 33.1 to match the version
required by the gproto build script.

* fix(bench): record internode baseline error counts

* fix(skill): correct YAML frontmatter formatting for release-version-bump

* chore(ci): bump protoc version to 34.1

* fix(tooling): bump protoc 33.1 to 34.1 in install script, restore SKILL.md description

---------

Co-authored-by: Henry Guo <marshawcoco@users.noreply.github.com>
2026-05-21 05:47:32 +00:00
houseme 503f89bf0e chore(deps): bump aws sdk and config (#3035) 2026-05-21 01:47:34 +00:00
houseme dcbffb084f chore(deps): refresh workspace deps and linux fs_type gating (#3030)
* chore(deps): refresh workspace deps and linux fs_type gating

- refresh workspace dependency pins and lockfile updates

- remove now-unused crate dependency entries in multiple Cargo.toml files

- enable profiling export defaults in config and scripts/run.sh

- gate os::fs_type module/function/tests to Linux to avoid non-Linux dead_code warnings

* fix(utils): simplify fs_type linux gating

- keep fs_type module-level linux cfg in os::mod

- remove redundant linux cfg on get_fs_type and test module

* chore(deps): bump s3s git revision

- update workspace s3s dependency to rev 507e1312b211c3ddc214b03875d6fabd15d22ed5

- refresh Cargo.lock source entry for s3s

* chore(dev): allow mysql_async git source and env overrides

- allow mysql_async git source in deny.toml allow-git list

- make scripts/run.sh core env vars overrideable via existing shell env

* fix(utils): import get_fs_type in fs_type tests

- add explicit super::get_fs_type import in fs_type test module

- fix Linux E0425 unresolved function errors in unit tests

* chore(dev): tune run script observability defaults

- make profiling export env overrideable in scripts/run.sh

- set RUSTFS_OBS_SAMPLE_RATIO default from 2.0 to 1.0

- update allow-git review window comments in deny.toml

* test(obs): stabilize profiling env alias tests
2026-05-20 15:03:07 +00:00
houseme b2dfdf85de chore(release): prepare 1.0.0-beta.4 (#3032)
* chore(release): prepare 1.0.0-beta.4

* docs(skill): refine rustfs spec changelog rule

* docs(skill): optimize rustfs release bump workflow
2026-05-20 13:56:43 +00:00
houseme 375482a4b6 refactor: converge storage io hot paths (#3029)
* refactor(issue-633): clarify layered io control policies

* refactor(issue-633): consolidate timeout and deadlock layers

* refactor(issue-633): align storage backpressure metadata

* refactor(issue-633): unify storage backpressure transitions

* refactor(issue-633): simplify watermark transition API

* test(issue-633): add storage backpressure transition test

* refactor(issue-633): align storage pipe meta shape

* refactor(issue-633): enrich storage monitor metadata

* refactor(issue-633): finalize storage backpressure convergence

* refactor(issue-633): complete scheduler layer convergence

* refactor(issue-633): reduce concurrency facade config duplication

* refactor(issue-633): migrate storage callsites to final policy names

* chore(issue-633): apply final pre-commit normalization

* refactor(issue-633): unify timeout wrapper dynamic size path

* refactor(issue-633): make concurrency policies copyable

* refactor(issue-633): converge storage io hot paths

* fix(issue-633): honor storage timeout min bound

* fix(storage): avoid timeout calc panic on huge sizes

* refactor(storage): consolidate timeout checks and test attrs

* fix(storage): harden io scheduler core config mapping

* refactor(storage): eliminate patch-on-patch patterns and dead code

- Remove trivial accessor methods on ConcurrencyConfig that just return pub fields
- Remove dead BackpressureEvent/BackpressureEventType types from concurrency crate
- Fix io_schedule test using wrong constructor (from_core_config -> from_scheduler_config)
- Update manager.rs to use config fields directly instead of removed accessors

* fix: adopt review feedback for config guards

* test: remove needless struct update defaults

* fix: harden timeout policy and preserve api alias
2026-05-20 12:00:23 +00:00
Henry Guo 45b04316b9 refactor(ecstore): decouple transport construction from RemoteDisk (#3027)
Move build_internode_data_transport_from_env() out of RemoteDisk::new()
into new_disk(), so RemoteDisk depends only on the
Arc<dyn InternodeDataTransport> trait object. TCP/HTTP remains the
default backend; all data paths unchanged.

Co-authored-by: Henry Guo <marshawcoco@users.noreply.github.com>
2026-05-20 11:55:08 +00:00
Henry Guo 19b69abe5c feat(internode): harden p0 transport boundary and baseline tooling (#3017)
* feat(internode): p0 transport baseline and ci hardening

* fix(internode): avoid double wrapping transport errors

---------

Co-authored-by: Henry Guo <marshawcoco@users.noreply.github.com>
Co-authored-by: houseme <housemecn@gmail.com>
2026-05-20 11:06:26 +00:00
安正超 c684438625 fix(obs): add proxied PUT replication metrics (#3020)
fix(obs): add proxied put replication metrics
2026-05-20 05:30:57 +00:00
安正超 11054263c1 fix(utils): map common Linux filesystem magic values (#3023) 2026-05-20 04:00:32 +00:00
安正超 b530a9f0b2 fix(ecstore): remove stale bucket metadata parse TODO (#3021)
* fix(ecstore): remove stale bucket metadata parse TODO

* test: cover stored bucket target config parsing
2026-05-20 03:59:55 +00:00
安正超 dd35538e90 fix(ecstore): remove stale disk TODOs (#3022) 2026-05-20 03:59:34 +00:00
安正超 c727589161 fix(obs): remove stale replication metric TODOs (#3024) 2026-05-20 03:59:16 +00:00
安正超 cde53ca6ad fix(utils): handle IPv6 zones and hex ranges (#3019)
* fix(utils): handle low-risk TODO range parsing

* fix(utils): address scoped IPv6 and range review
2026-05-20 03:56:14 +00:00
Henry Guo e929814edc feat(ecstore): add internode transport boundary and TCP baseline runner (#3010)
* docs: add internode data transport RFC

* feat: add internode operation metrics

* fix feedback

* fix(ci): fallback protoc token to github.token

* feat(ecstore): add internode transport boundary and baseline runner

* feat(internode): harden data transport baseline

* Revert "feat(internode): harden data transport baseline"

This reverts commit 5b8d6b8aa4.

* fix(internode): address baseline review comments

* fix(ci): pin setup-protoc to stable release

* fix(ci): install protoc via apt on linux

* fix(ci): restore protoc install for macos and windows

---------

Co-authored-by: Henry Guo <marshawcoco@users.noreply.github.com>
Co-authored-by: houseme <housemecn@gmail.com>
2026-05-19 22:41:34 +00:00
安正超 a8a5f25af3 perf(ecstore): remove owned write sync regression (#3018) 2026-05-19 22:14:35 +00:00
安正超 54be3cab23 fix(heal): ignore missing response subscribers (#3015)
* fix(heal): ignore missing response subscribers

* fix(heal): ignore missing response subscribers
2026-05-19 15:34:22 +00:00
安正超 1c8fdfddf4 chore(ci): pin RustFS setup-protoc release (#3016)
* chore(ci): use RustFS setup-protoc fork

* chore(ci): pin RustFS setup-protoc release
2026-05-19 15:28:51 +00:00
houseme 73bde843d6 refactor(s3): consolidate semantic boundaries and remove s3-common (#3012)
* refactor(common): introduce rustfs-data-usage core crate

* refactor(concurrency): migrate workers crate into concurrency

* refactor(crypto): migrate appauth token APIs into crypto

* fix docs urls

* remove unused crate

* refactor(data-usage): switch consumers to rustfs-data-usage

* chore(fmt): apply cargo fmt and lockfile sync

* refactor(common): remove data_usage compatibility re-export

* refactor(capacity): move capacity_scope to object-capacity

* refactor(io-metrics): relocate internode metrics from common

* refactor(common): decouple scanner report from madmin

* chore(fmt): normalize import ordering after pre-commit

* refactor(s3): split s3 types and ops crates

* refactor(s3): centralize event version and safe parsing

* refactor(s3): add op-event compatibility guardrails

* refactor(s3): add runtime op-event mismatch observability

* refactor(s3): extract delete event mapping helper

* refactor(s3): extract put event mapping helper

* refactor(s3): consolidate remaining event semantic helpers

* refactor(s3): add op-event coverage checks and observability alerts

* refactor(s3-ops): consolidate op-event semantic mapping

* refactor(scanner): remove last_minute wrapper module

* refactor(scanner): consolidate duplicated data usage models
2026-05-19 12:50:25 +00:00
houseme d6fc70eb12 feat(obs): expose key S3 usecase spans at info (#3013) 2026-05-19 11:00:51 +00:00
houseme 25c6bdf490 perf(filemeta): phase-1~3 rename_data metadata optimization (#3011)
* chore(perf): harden amd64 profiling benchmark flow

* fix(profiling): isolate bench buckets and map protobuf conflict

* perf: avoid blocking owned local writes

* style: format profile admin handler

* docs: clarify observability trace validation

* perf: reduce mkdir overhead on local writes

* perf: add rename_data meta microbenchmark

* perf(filemeta): fast-path data_dir decode in version meta

* perf(filemeta): collapse data-dir lookup into one scan

* perf(filemeta): reduce scan allocs and refresh meta bench

* perf(ecstore): skip mkdir path on read-only open

* perf(filemeta): single-pass unshared data-dir scan

* perf(filemeta): add two-key inline remove fast path

* perf(filemeta): compare remove-two keys by bytes first

* bench(ecstore): add remove_two-only micro benchmark

* bench(ecstore): stabilize rename_data meta benchmark timing

* bench(ecstore): align rename_data path with remove_two

* perf(filemeta): avoid uuid string alloc in remove_two

* perf(filemeta): add fast-path for empty inline data

* perf(filemeta): streamline add_version match branch

* perf(filemeta): fast-return remove_key on miss

* perf(filemeta): speed up add_version insertion lookup

* style(ecstore): normalize formatting in perf-tuning files

* refactor(filemeta): unify inline data removal paths
2026-05-19 10:20:24 +00:00
Henry Guo f695870626 feat(internode): add transport observability (#3007)
* docs: add internode data transport RFC

* feat: add internode operation metrics

* fix feedback

* fix(ci): fallback protoc token to github.token

---------

Co-authored-by: Henry Guo <marshawcoco@users.noreply.github.com>
2026-05-19 07:16:05 +00:00
安正超 aa3f13c0d3 fix(ecstore): stop listing after reaching result limit (#3001)
* fix(ecstore): stop listing after reaching result limit

* fix(ecstore): ignore intentional list cancellation

* fix(ecstore): satisfy list cancellation clippy lint

* fix(ecstore): include entry before limit early return

* fix(ecstore): cancel on limit in gather_results

* fix(ecstore): sync timeout branch with reviewed patch

* fix(ecstore): satisfy cancellation clippy lint

---------

Co-authored-by: loverustfs <hello@rustfs.com>
2026-05-19 05:20:21 +00:00
houseme c4248078d6 chore: update dependencies (#2890)
* chore: update dependencies

* build(deps): bump the dependencies group with 5 updates

* build(deps): bump the dependencies group with 6 updates (#2908)

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

* fix(ecstore): narrow Windows URL drive path rewrite

* chore(deps): bump starshard to 1.2.0

* revert(ecstore): restore endpoint Windows path behavior

* up

* up

* up

* perf(notify): use cached snapshot mode for scans

* fix

* chore(deps): bump workspace dependency versions

* chore(deps): refresh pinned dependency references

* chore(ci): align profiling and decommission tooling updates

- enable tokio_unstable cfg in performance profiling build flags\n- bump Rust base image from 1.93 to 1.95 in source and decommission Dockerfiles\n- remove obsolete compose version key from docker-compose-simple.yml\n- add standard Apache-2.0 license header to docker-compose.decommission.yml

* chore(deps): bump the dependencies group across 1 directory with 7 updates (#2994)

Bumps the dependencies group with 7 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [sysinfo](https://github.com/GuillaumeGomez/sysinfo) | `0.39.1` | `0.39.2` |
| [dial9-tokio-telemetry](https://github.com/dial9-rs/dial9-tokio-telemetry) | `0.3.9` | `0.3.10` |
| [opentelemetry](https://github.com/open-telemetry/opentelemetry-rust) | `0.31.0` | `0.32.0` |
| [opentelemetry-otlp](https://github.com/open-telemetry/opentelemetry-rust) | `0.31.1` | `0.32.0` |
| [opentelemetry_sdk](https://github.com/open-telemetry/opentelemetry-rust) | `0.31.0` | `0.32.0` |
| [opentelemetry-semantic-conventions](https://github.com/open-telemetry/opentelemetry-rust) | `0.31.0` | `0.32.0` |
| [opentelemetry-stdout](https://github.com/open-telemetry/opentelemetry-rust) | `0.31.0` | `0.32.0` |



Updates `sysinfo` from 0.39.1 to 0.39.2
- [Changelog](https://github.com/GuillaumeGomez/sysinfo/blob/main/CHANGELOG.md)
- [Commits](https://github.com/GuillaumeGomez/sysinfo/compare/v0.39.1...v0.39.2)

Updates `dial9-tokio-telemetry` from 0.3.9 to 0.3.10
- [Release notes](https://github.com/dial9-rs/dial9-tokio-telemetry/releases)
- [Changelog](https://github.com/dial9-rs/dial9/blob/main/CHANGELOG.md)
- [Commits](https://github.com/dial9-rs/dial9-tokio-telemetry/compare/dial9-tokio-telemetry-v0.3.9...dial9-tokio-telemetry-v0.3.10)

Updates `opentelemetry` from 0.31.0 to 0.32.0
- [Release notes](https://github.com/open-telemetry/opentelemetry-rust/releases)
- [Changelog](https://github.com/open-telemetry/opentelemetry-rust/blob/main/docs/release_0.32.md)
- [Commits](https://github.com/open-telemetry/opentelemetry-rust/compare/opentelemetry-prometheus-0.31.0...opentelemetry-0.32.0)

Updates `opentelemetry-otlp` from 0.31.1 to 0.32.0
- [Release notes](https://github.com/open-telemetry/opentelemetry-rust/releases)
- [Changelog](https://github.com/open-telemetry/opentelemetry-rust/blob/main/docs/release_0.32.md)
- [Commits](https://github.com/open-telemetry/opentelemetry-rust/compare/opentelemetry-otlp-0.31.1...opentelemetry-otlp-0.32.0)

Updates `opentelemetry_sdk` from 0.31.0 to 0.32.0
- [Release notes](https://github.com/open-telemetry/opentelemetry-rust/releases)
- [Changelog](https://github.com/open-telemetry/opentelemetry-rust/blob/main/docs/release_0.32.md)
- [Commits](https://github.com/open-telemetry/opentelemetry-rust/compare/v0.31.0...opentelemetry_sdk-0.32.0)

Updates `opentelemetry-semantic-conventions` from 0.31.0 to 0.32.0
- [Release notes](https://github.com/open-telemetry/opentelemetry-rust/releases)
- [Changelog](https://github.com/open-telemetry/opentelemetry-rust/blob/main/docs/release_0.32.md)
- [Commits](https://github.com/open-telemetry/opentelemetry-rust/compare/v0.31.0...opentelemetry-semantic-conventions-0.32.0)

Updates `opentelemetry-stdout` from 0.31.0 to 0.32.0
- [Release notes](https://github.com/open-telemetry/opentelemetry-rust/releases)
- [Changelog](https://github.com/open-telemetry/opentelemetry-rust/blob/main/docs/release_0.32.md)
- [Commits](https://github.com/open-telemetry/opentelemetry-rust/compare/v0.31.0...opentelemetry-stdout-0.32.0)

---
updated-dependencies:
- dependency-name: sysinfo
  dependency-version: 0.39.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: dependencies
- dependency-name: dial9-tokio-telemetry
  dependency-version: 0.3.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: dependencies
- dependency-name: opentelemetry
  dependency-version: 0.32.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: dependencies
- dependency-name: opentelemetry-otlp
  dependency-version: 0.32.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: dependencies
- dependency-name: opentelemetry_sdk
  dependency-version: 0.32.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: dependencies
- dependency-name: opentelemetry-semantic-conventions
  dependency-version: 0.32.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: dependencies
- dependency-name: opentelemetry-stdout
  dependency-version: 0.32.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: houseme <housemecn@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: houseme <housemecn@gmail.com>

* chore(deps): bump workspace dependency versions

* chore(deps): refresh lockfile windows crate graph

* chore(bench): align snapshot mode labels with coverage

* chore(bench): clarify tested snapshot modes

* fix(review): address PR2890 Copilot comments

---------

Signed-off-by: houseme <housemecn@gmail.com>
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-19 04:58:42 +00:00
Henry Guo 94da6e34cb fix(notify): parse IPv6 hosts without ports (#3000)
Co-authored-by: Henry Guo <marshawcoco@users.noreply.github.com>
2026-05-19 02:40:21 +00:00
安正超 a9e62dc2c2 perf: avoid blocking hop for owned disk writes (#3004)
* perf: avoid blocking hop for owned disk writes

* fix(ecstore): sync owned write path
2026-05-19 02:27:14 +00:00
yihong ecb6704679 fix: make help color not right (#3005)
Signed-off-by: yihong0618 <zouzou0208@gmail.com>
2026-05-19 02:26:23 +00:00
安正超 e65aebba53 fix(ecstore): guard transition worker max (#3003)
* fix(ecstore): guard transition worker max

* test: clarify env mutation safety

* test(ecstore): document env mutation concurrency contract
2026-05-19 02:25:51 +00:00
weisd a66337bd28 fix: keep scanner walk timeouts from offlining drives (#2996)
* fix: keep scanner walk timeouts from offlining drives

Scanner walk operations can time out on large or slow directory listings without proving the backing drive is faulty. Keep the timeout error local to the scan while preserving failure marking for ordinary disk operations.

Constraint: Scanner walk_dir can include listing work that exceeds the drive timeout under slow storage.

Rejected: Disable timeout failure marking globally | real stuck disk operations must still affect drive health

Confidence: high

Scope-risk: narrow

Directive: Do not route scanner/listing timeout back into drive offline state without reproducing issue #2651

Tested: cargo test -p rustfs-ecstore timeout

Tested: cargo fmt --all --check

Tested: cargo clippy --workspace --all-features --all-targets -- -D warnings

Tested: make pre-commit with en_US.UTF-8 locale

Related: https://github.com/rustfs/rustfs/issues/2651

* fix: keep remote scanner walks from offlining drives

Remote walk_dir uses a streaming request that can hit total or stall timeouts during large scans without proving the remote drive is faulty. Route that scanner path through an explicit ignore action while preserving failure marking for ordinary remote operations.

Also treat Duration::ZERO as no operation timeout for remote health tracking, matching the existing local disk wrapper contract while still marking network-like errors on default paths.

Constraint: Scanner walk_dir streams can be slow because of directory size or consumer backpressure.

Rejected: Disable remote timeout failure marking globally | normal remote disk operations still need to evict failed connections and update drive health

Confidence: high

Scope-risk: narrow

Directive: Do not reintroduce remote scanner timeout failure marking without reproducing issue #2651 against remote disks.

Tested: cargo test -p rustfs-ecstore execute_with_timeout

Tested: cargo test -p rustfs-ecstore timeout

Tested: cargo fmt --all --check

Tested: cargo clippy --workspace --all-features --all-targets -- -D warnings

Tested: make pre-commit with en_US.UTF-8 locale

Related: https://github.com/rustfs/rustfs/issues/2651

* test: prove scanner walk backpressure keeps drives online

Add a local scanner walk regression test where the output writer never makes progress. The test confirms the walk timeout returns without marking the drive faulty, covering a stall source that is not caused by object count or network transfer speed.

Constraint: Scanner walk can block on downstream writer backpressure as well as disk or network IO.\nConfidence: high\nScope-risk: narrow\nTested: cargo test -p rustfs-ecstore walk_dir_writer_backpressure_timeout_does_not_mark_drive_failure\nTested: cargo test -p rustfs-ecstore timeout\nTested: cargo fmt --all --check\nTested: cargo clippy --workspace --all-features --all-targets -- -D warnings\nTested: make pre-commit

---------

Co-authored-by: houseme <housemecn@gmail.com>
2026-05-18 15:04:05 +00:00
houseme bd1e57293f fix: harden lifecycle transition compensation and regression coverage (#2995)
* fix(ecstore): honor transition worker configuration

* fix(ecstore): add transition queue backpressure metrics

* fix(ecstore): schedule transition compensation on enqueue pressure

* fix(ecstore): log transition compensation scheduling

* test(rustfs): add transition compensation fault-injection coverage

* test(rustfs): cover delete after transition compensation

* test(scanner): cover cleanup after transition compensation

* test(rustfs): extend compensation transition coverage

* test(scanner): cover backfill idempotency after compensation

* test(scanner): cover noncurrent expiry after compensation

* test(rustfs): cover versioned delete after compensation

* test(rustfs): cover delete marker lifecycle after compensation

* test(scanner): extend versioned lifecycle compensation coverage

* test(scanner): model versioned delete after compensation

* test(scanner): clarify modeled versioned delete helper

* refactor(ecstore): optimize transition enqueue hot path

* refactor(ecstore): centralize transition runtime constants

* style(ecstore): apply rustfmt for transition timeout helper

* fix(ilm): align queue-full metric semantics

* refactor(ecstore): unify immediate enqueue failure handling

* refactor(ecstore): reuse transition worker env constant

* ci(actions): update setup action inputs
2026-05-18 12:50:43 +00:00
wood 0e888cfef2 fix(ecstore): list_object_v2 error when scanning multipart folder (#2946)
* fix(ecstore): list_object_v2 error when scanning multipart directory with prefix

Signed-off-by: w0od <dingboning02@163.com>

* test(ecstore): cover prefix dir scan with multipart folder support

Signed-off-by: w0od <dingboning02@163.com>

* test(ecstore): harden test shape to improve regression detection

Signed-off-by: w0od <dingboning02@163.com>

* refactor(ecstore): move multipart dir filter into recursion to reduce I/O

Signed-off-by: w0od <dingboning02@163.com>

* test(ecstore): replace scan_dir test with walk_dir integration coverage

Signed-off-by: w0od <dingboning02@163.com>

* refactor(ecstore): remove unnecessary .clone() calls

Signed-off-by: w0od <dingboning02@163.com>

---------

Signed-off-by: w0od <dingboning02@163.com>
2026-05-18 11:01:20 +00:00
安正超 4c9fd789ea docs: update security advisory skill lessons (#2991) 2026-05-18 12:34:27 +08:00
Henry Guo 17ae9f34c7 fix(notify): accept case-insensitive filter rule names (#2990) 2026-05-18 03:41:48 +00:00
houseme cdfe83877b chore(deps): update flake.lock (#2986)
Flake lock file updates:

• Updated input 'nixpkgs':
    'github:NixOS/nixpkgs/b3da656' (2026-05-08)
  → 'github:NixOS/nixpkgs/d233902' (2026-05-15)
• Updated input 'rust-overlay':
    'github:oxalica/rust-overlay/1d634a9' (2026-05-09)
  → 'github:oxalica/rust-overlay/61ec6a4' (2026-05-16)
2026-05-17 03:09:08 +00:00
安正超 be37fd285e ci(nix): avoid requesting review from PR author (#2987) 2026-05-17 11:13:45 +08:00
安正超 095337100e test(policy): validate default policies (#2985) 2026-05-17 01:41:03 +00:00
GatewayJ e0729f5f4d fix(policy): align action-family validation and defaults (#2984)
* fix(policy): align action-family validation and defaults

* test(e2e): add accountinfo service-account roundtrip

* test(policy): add mixed action family cases
2026-05-16 11:19:04 +00:00
houseme 6e12289339 fix(runtime): finalize issue 2941 profiling cleanup (#2983)
* perf(runtime): narrow profiling support and upgrade starshard

* style(notify): normalize starshard imports

* perf(ecstore): reduce list_path_raw coordination overhead

* docs(scripts): add issue 2941 perf capture workflow

* fix(runtime): finalize issue 2941 profiling cleanup

* build(deps): bump quick-xml to 0.40.0

* chore(scripts): untrack local perf capture guide

* fix(scripts): honor label in perf capture output
2026-05-16 11:09:04 +00:00
weisd 9dcf8cb7ea fix: stabilize rebalance start and listing (#2961)
* fix: unblock empty chunked admin rebalance start

Some admin clients send empty POST requests with chunked framing and no explicit content length. The admin rebalance route carries no request body, so normalize that known route before downstream validation sees a missing length.

Constraint: Keep normalization scoped to known empty-body admin routes

Rejected: Relax transfer-encoding handling for S3 routes | broader protocol risk

Confidence: high

Scope-risk: narrow

Tested: cargo test -p rustfs --lib server::layer::tests::

Tested: cargo fmt --all --check

Tested: git diff --check

* fix: keep rebalance listing from stalling

Large object migration was awaited inside the listing callback, so metacache readers could stop being drained while drive walk operations were still writing listing data. Move entry migration into bounded background tasks and wait for them after each set listing completes.

Constraint: Preserve existing rebalance cancellation and first-error propagation

Rejected: Only increase drive walk timeouts | masks callback backpressure and still fails for slower migrations

Confidence: medium

Scope-risk: moderate

Tested: cargo test -p rustfs-ecstore rebalance_unit_tests

Tested: cargo test -p rustfs-ecstore cache_value::metacache_set::tests

Tested: cargo test -p rustfs --lib server::layer::tests::

Tested: cargo clippy -p rustfs-ecstore --lib --all-features -- -D warnings

Tested: cargo clippy -p rustfs-ecstore --tests --all-features -- -D warnings

Tested: cargo fmt --all --check

Tested: git diff --check

---------

Co-authored-by: loverustfs <hello@rustfs.com>
Co-authored-by: houseme <housemecn@gmail.com>
Co-authored-by: cxymds <Cxymds@qq.com>
2026-05-16 05:17:01 +00:00
安正超 6898e720dd fix(security): harden proxy auth and default credentials (#2981)
* fix(security): harden proxy auth and default credentials

* fix(security): address proxy and credential feedback
2026-05-16 04:01:50 +00:00
yihong 824c4f7673 docs: fix some dead links (#2975)
Signed-off-by: yihong0618 <zouzou0208@gmail.com>
Co-authored-by: houseme <housemecn@gmail.com>
2026-05-16 10:06:53 +08:00
安正超 c0c92cb048 ci(build): honor console asset download fallback (#2980) 2026-05-16 10:06:23 +08:00
Henry Guo bca8b08c2b fix: handle Windows paths in pre-commit tests (#2974)
Co-authored-by: houseme <housemecn@gmail.com>
2026-05-15 14:04:51 +00:00
Henry Guo 738fb86611 fix(admin): surface access key policy errors (#2970)
Co-authored-by: houseme <housemecn@gmail.com>
Co-authored-by: loverustfs <hello@rustfs.com>
2026-05-15 08:26:38 +00:00
安正超 092c6bc135 ci(build): pin macOS x86 release runner (#2971) 2026-05-15 11:56:13 +08:00
escapecode 5cda460451 fix(sftp): preserve OPEN-time client attrs as object metadata (#2913)
Co-authored-by: houseme <housemecn@gmail.com>
2026-05-15 00:32:06 +00:00
GatewayJ fc8322ed64 bucket policy notify & pba (#2968) 2026-05-14 14:31:40 +00:00
houseme cd2cd74314 ci: force Node24 in Nix workflows with pinned actions (#2966) 2026-05-14 11:13:42 +00:00
Henry Guo ced390b02b test(ecstore): cover managed sse range reads (#2962)
Co-authored-by: houseme <housemecn@gmail.com>
2026-05-14 06:56:21 +00:00
354 changed files with 29234 additions and 9716 deletions
@@ -1,22 +1,32 @@
---
name: rustfs-release-version-bump
description: Prepare a RustFS release branch like PR #2957 by bumping versioned files, aligning release assets, running required verification, and finishing with commit, push, and gh PR creation. Use when publishing a new RustFS alpha, beta, or stable release.
description: "Publish a RustFS alpha/beta/stable release with an auditable flow: confirm target version and scope, update workspace and release assets (including strict rustfs.spec changelog identity/date/version format), run required verification, and finish with commit, push, and GitHub PR creation."
---
# RustFS Release Version Bump
Use this skill when the task is to prepare a new RustFS version release branch following the pattern validated in PR `#2957`.
Use this skill to publish a RustFS release (alpha, beta, or stable) with a minimal, auditable diff and a complete ship flow (`edit -> verify -> commit -> push -> PR`).
## Read first
Validated baseline: release pattern used in PR `#2957`.
- Read `AGENTS.md`.
- Read `.github/pull_request_template.md`.
- Inspect the current branch diff against `origin/main`.
- Do not assume every release file should change in the same way every time.
## Required inputs
## Scope validated by PR #2957
- Exact target version, for example `1.0.0-beta.4`.
- Delivery scope:
- Local only (`edit/verify`).
- Local + git (`commit/push`).
- Full GitHub flow (`commit/push/PR`).
The `1.0.0-beta.3` release branch updated these files:
If target version is missing or ambiguous, stop and ask before editing.
## Read before editing
- `AGENTS.md` (root and nearest path-specific files).
- `.github/pull_request_template.md`.
- Current branch status and diff against `origin/main`.
## Default release file scope
Treat the following file list as the default checklist for each release bump:
- `Cargo.toml`
- `Cargo.lock`
@@ -26,78 +36,89 @@ The `1.0.0-beta.3` release branch updated these files:
- `helm/rustfs/Chart.yaml`
- `rustfs.spec`
Treat this file list as the default checklist for future release bumps. Only drop a file if the repository's current release pattern clearly says it is no longer part of the publish flow.
Only drop a file when the current repository release process clearly no longer requires it.
## Workflow
## Hard release policy
1. Confirm release intent
- Identify the target version string exactly, for example `1.0.0-beta.4`.
- Check whether the user wants only local commit preparation or the full `commit + push + PR` flow.
- Compare the branch against `origin/main` and isolate only release-related edits.
- If the target version is not explicit, stop and ask for it before editing.
- Docker doc tags use `<version>` (for example `rustfs/rustfs:1.0.0-beta.4`), not `v<version>`.
- Helm chart version mapping follows `beta.N -> 0.N.0`.
- `rustfs.spec` `Release` uses prerelease suffix only (for example `beta.4`).
- Do not change these rules without explicit confirmation.
2. Update core Rust workspace versions
- Bump the workspace version in `Cargo.toml`.
- Bump all internal workspace crate versions in `Cargo.toml`.
- Ensure `Cargo.lock` reflects the same release version for workspace packages.
- Re-read the diff and verify there are no partial version leftovers.
## Step-by-step workflow
3. Align release assets
- Update versioned Docker examples in `README.md` and `README_ZH.md` using the `<version>` tag form, for example `rustfs/rustfs:1.0.0-beta.4`.
- Update `flake.nix` package version.
- Update `helm/rustfs/Chart.yaml` `appVersion`.
- Update `helm/rustfs/Chart.yaml` `version` using the release-chart rule:
1. Confirm intent and isolate scope
- Confirm target version string exactly.
- Confirm whether user requested local-only or full GitHub flow.
- Inspect current branch and ensure only release-related files are touched for this task.
2. Update workspace versions
- Bump `[workspace.package].version` in `Cargo.toml`.
- Bump internal workspace crate dependency versions in `Cargo.toml`.
- Update `Cargo.lock` so workspace package versions match target version.
- Re-scan for partial leftovers.
3. Update release assets
- `README.md` and `README_ZH.md`: update versioned Docker examples to target version.
- `flake.nix`: update package version to target version.
- `helm/rustfs/Chart.yaml`:
- `appVersion` = target version.
- `version` follows chart mapping rule, for example:
- `1.0.0-beta.3` -> `0.3.0`
- `1.0.0-beta.4` -> `0.4.0`
- Follow the same pattern for later beta releases unless the repository rule changes.
- Update `rustfs.spec` release metadata and changelog entry.
- Set `rustfs.spec` `Release` to the prerelease suffix without the base version, for example `beta.3` for `1.0.0-beta.3`.
- Keep the release asset changes in a separate commit from the core Rust workspace version bump when the branch contains both.
- `rustfs.spec`:
- Set `Release` to prerelease suffix (example `beta.4`).
- Add/update top changelog entry with exact format:
- `* Thu May 20 2026 houseme <housemecn@gmail.com>`
- `- Update RPM package to RustFS 1.0.0-beta.4`
- Changelog identity and time must come from current environment:
- `git config --get user.name`
- `git config --get user.email`
- `date '+%a %b %d %Y'`
- Changelog version text must match target release version exactly.
4. Stop and discuss before changing release policy
- Ask before changing the established Docker tag style away from `<version>`.
- Ask before changing the established Helm chart version mapping away from `beta.N -> 0.N.0`.
- Ask before changing the established `rustfs.spec` `Release` rule away from the release suffix form such as `beta.N`.
- Ask before widening the release scope beyond the files already validated in PR `#2957`.
4. Verify before shipping
- Run:
- `cargo fmt --all`
- `cargo fmt --all --check`
- `make pre-commit`
- If verification passes, run `cargo clean`.
- If `make pre-commit` fails, return `BLOCKED` with root cause and do not silently widen scope to fix unrelated issues unless user asks.
5. Verify before shipping
- Run `make pre-commit`.
- If verification succeeds, run `cargo clean` to remove generated build artifacts before wrapping up.
- If `make pre-commit` fails, stop and return `BLOCKED`.
5. Commit strategy
- Preferred split when both parts changed:
- `chore(release): prepare <version>` for `Cargo.toml` and `Cargo.lock`.
- `chore(release): align release assets for <version>` for docs and packaging files.
- If user asks for one commit, use one commit.
- Stage only intended release files; do not include unrelated working tree changes.
6. Commit structure
- Prefer two commits when the change naturally splits:
- `chore(release): prepare <version>` for `Cargo.toml` and `Cargo.lock`
- `chore(release): align release assets for <version>` for docs and packaging metadata
- If the user asks for a single commit, follow that request.
6. Push and PR
- Push branch:
- `git push -u origin <branch>` (first push), or `git push` (tracking already exists).
- Create PR with template headings unchanged:
- `gh pr create --base main --head <branch> --title ... --body-file ...`
- PR title/body must be English.
- Use `N/A` for non-applicable template sections.
- Include verification commands and any `BLOCKED` reason clearly.
7. Push and PR
- Push the release branch with `git push -u origin <branch>` or `git push` if upstream already exists.
- Create the PR with `gh pr create --base main --head <branch> --title ... --body-file ...`.
- Keep the PR title and body in English.
- Keep the `.github/pull_request_template.md` headings exactly.
## Ready-to-check commands
## Recommended check commands
- `git status --short --branch`
- `git diff --name-only origin/main...HEAD`
- `git diff --stat origin/main...HEAD`
- `rg -n "<old_version>|<new_version>" Cargo.toml Cargo.lock README.md README_ZH.md flake.nix helm/rustfs/Chart.yaml rustfs.spec`
- `cargo fmt --all`
- `cargo fmt --all --check`
- `make pre-commit`
- `cargo clean`
- `git status --short --branch`
## Output expectations
## Output contract
When using this skill, return:
When using this skill, always report:
- The files changed for the release bump
- Any uncertainty that needs user confirmation before editing
- Verification status
- Commit messages used
- Push status and PR URL when the GitHub flow was requested
## Established release policy
- Docs use Docker tags in `<version>` form, not `v<version>`.
- `helm/rustfs/Chart.yaml` `version` follows `beta.N -> 0.N.0` based on the current release policy.
- `rustfs.spec` `Release` follows the release suffix form, for example `beta.3`.
- If any of these rules need to change in the future, pause and confirm before editing.
- Target version.
- Files changed.
- Any assumptions or uncertainties requiring confirmation.
- Verification result (`PASSED` or `BLOCKED`) with key evidence.
- Commit message(s) used.
- Push status and PR URL when GitHub flow is requested.
@@ -91,7 +91,7 @@ For the full pattern map, read [advisory-patterns.md](references/advisory-patter
- Do not reflect arbitrary `Origin` while also allowing credentials. Default CORS should be no CORS unless explicitly configured.
- Do not render user-controlled object content in a same-origin iframe with console credentials available to JavaScript.
- Prefer origin separation for object preview/download, `nosniff`, CSP, strict content-type handling, and avoiding durable credentials in `localStorage`.
- License/version-like metadata endpoints should expose only coarse public data unless authenticated.
- Console license/version-like metadata endpoints should expose only coarse public data unless authenticated, especially subject names and expiration timestamps.
### Profiling, debug, and health endpoints
- Profiling and debug endpoints are not health checks. They require admin auth, opt-in enablement, rate limiting, and safe responses.
@@ -20,7 +20,7 @@ Update this file only when an advisory adds or changes a reusable lesson, affect
- `GHSA-mm2q-qcmx-gw4w`: `ListServiceAccount` used `UpdateServiceAccountAdminAction`, while update lacked target ownership checks. Lesson: exact action constants and ownership checks are both required; information disclosure can chain into secret rotation and takeover.
- `GHSA-vcwh-pff9-64cc`: `ImportIam` checked `ExportIAMAction` for an import/write operation. Lesson: every admin handler must authorize the action it actually performs.
- `GHSA-jqmc-mg33-v45g` and `GHSA-8784-9m7f-c6p6`: `/profile/cpu` and `/profile/memory` were whitelisted from auth and allowed expensive diagnostics plus path disclosure. Lesson: profiling/debug endpoints need admin auth, opt-in, rate limits, and non-sensitive responses.
- `GHSA-x5xv-223c-8vm7`: console license metadata endpoint was public. Lesson: public metadata endpoints should be coarse or authenticated.
- `GHSA-xp32-gxq2-3v52`: console license metadata endpoint was public and exposed subject and expiration fields. Lesson: management metadata endpoints should require admin auth or return only coarse public status.
### IAM import, service accounts, and privilege boundaries
+1 -1
View File
@@ -4,7 +4,7 @@
.PHONY: help
help: ## Shows This Help Menu
echo -e "$$HEADER"
grep -E '(^[a-zA-Z0-9_-]+:.*?## .*$$)|(^## )' $(MAKEFILE_LIST) | sed 's/^[^:]*://g' | awk 'BEGIN {FS = ":.*?## | #"} ; {printf "${cyan}%-30s${reset} ${white}%s${reset} ${green}%s${reset}\n", $$1, $$2, $$3}' | sed -e 's/\[36m##/\n[32m##/'
grep -E '(^[a-zA-Z0-9_-]+:.*?## .*$$)|(^## )' $(MAKEFILE_LIST) | sed 's/^[^:]*://g' | awk 'BEGIN {FS = ":.*?## | #"} /^## / {printf "\n${green}%s${reset}\n", $$0; next} {printf "${cyan}%-30s${reset} ${white}%s${reset} ${green}%s${reset}\n", $$1, $$2, $$3}'
.PHONY: help-build
help-build: ## Shows RustFS build help
+40
View File
@@ -54,6 +54,46 @@ Strict mode is available when you explicitly want `/health/ready == 200` as the
WAIT_PROBE_MODE=ready ./scripts/run_four_node_cluster_failover_bench.sh
```
### Profiling + Trace Validation
The profiling-focused 4-node compose keeps profiling enabled and points RustFS
to an OTLP/HTTP collector endpoint:
```bash
docker compose -f .docker/compose/docker-compose.cluster.local-build.profiling-amd64.yml up -d
```
Important behavior notes:
- `RUSTFS_OBS_ENDPOINT` is the OTLP/HTTP base URL. RustFS automatically sends
traces to `/v1/traces`, metrics to `/v1/metrics`, and logs to `/v1/logs`.
- Startup usually produces logs and metrics first. That does not guarantee
visible traces yet.
- Trace data becomes obvious only after real HTTP/S3/gRPC requests hit RustFS.
- `RUSTFS_OBS_LOGGER_LEVEL=info` keeps the top-level request span but filters
many nested `debug` spans. If Tempo/Jaeger looks sparse, retry with
`RUSTFS_OBS_LOGGER_LEVEL=debug` before suspecting the collector.
Minimal trace verification flow:
```bash
# 1. Start the profiling compose with richer span visibility.
RUSTFS_OBS_LOGGER_LEVEL=debug \
docker compose -f .docker/compose/docker-compose.cluster.local-build.profiling-amd64.yml up -d
# 2. Generate real request traffic after startup.
curl -I http://127.0.0.1:9000/health
curl -I http://127.0.0.1:9000/health/ready
# 3. Then inspect Tempo or Jaeger.
# Grafana: http://localhost:3000
# Jaeger: http://localhost:16686
```
If logs and metrics are present but traces are sparse, the most common cause is
"no real request traffic yet" or "`info` level filtered nested spans", not an
OTLP routing failure.
### (Deprecated) Minimal Observability
```bash
@@ -0,0 +1,236 @@
# Copyright 2024 RustFS Team
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# Profiling-first 4-node local-build compose.
#
# Goals:
# - force linux/amd64 runtime/build on Apple Silicon hosts;
# - enable RustFS built-in CPU profiling;
# - keep all tuning knobs host-overridable via env.
#
# Observability notes:
# - `RUSTFS_OBS_ENDPOINT` is the OTLP/HTTP base URL. RustFS appends
# `/v1/traces`, `/v1/metrics`, and `/v1/logs` automatically.
# - Logs and metrics usually appear during startup. Traces mainly appear after
# real HTTP/S3/gRPC requests create spans.
# - `RUSTFS_OBS_LOGGER_LEVEL=info` keeps the top-level request trace span but
# filters many `debug`-level nested spans. Use `debug` when validating trace
# richness rather than collector reachability.
services:
node1:
platform: ${RUSTFS_DOCKER_PLATFORM:-linux/amd64}
image: ${RUSTFS_IMAGE:-rustfs/rustfs:local-4node}
build:
context: ../..
dockerfile: Dockerfile.source
hostname: node1
environment:
- RUSTFS_VOLUMES=http://node{1...4}:9000/data/rustfs{0...3}
- RUSTFS_ADDRESS=:9000
- RUSTFS_CONSOLE_ENABLE=true
- RUSTFS_ACCESS_KEY=${RUSTFS_ACCESS_KEY:-rustfs-cluster-admin}
- RUSTFS_SECRET_KEY=${RUSTFS_SECRET_KEY:-rustfs-cluster-secret}
- RUSTFS_OBS_ENDPOINT=${RUSTFS_OBS_ENDPOINT:-http://host.docker.internal:4318}
# `info` is enough for startup logs/metrics. Use `debug` if Tempo/Jaeger
# should show richer nested spans during request-path verification.
- RUSTFS_OBS_LOGGER_LEVEL=${RUSTFS_OBS_LOGGER_LEVEL:-info}
- RUSTFS_OBS_PROFILING_ENDPOINT=${RUSTFS_OBS_PROFILING_ENDPOINT:-http://host.docker.internal:4040}
- RUSTFS_OBS_PROFILING_EXPORT_ENABLED=${RUSTFS_OBS_PROFILING_EXPORT_ENABLED:-true}
- RUSTFS_UNSAFE_BYPASS_DISK_CHECK=${RUSTFS_UNSAFE_BYPASS_DISK_CHECK:-true}
- RUSTFS_ENABLE_PROFILING=${RUSTFS_ENABLE_PROFILING:-true}
- RUSTFS_PROF_CPU_MODE=${RUSTFS_PROF_CPU_MODE:-continuous}
- RUSTFS_PROF_CPU_FREQ=${RUSTFS_PROF_CPU_FREQ:-99}
- RUSTFS_PROF_OUTPUT_DIR=${RUSTFS_PROF_OUTPUT_DIR:-/tmp/rustfs-profiles}
- RUSTFS_OBJECT_MAX_CONCURRENT_DISK_READS=${RUSTFS_OBJECT_MAX_CONCURRENT_DISK_READS:-48}
- RUSTFS_OBJECT_IO_BUFFER_SIZE=${RUSTFS_OBJECT_IO_BUFFER_SIZE:-262144}
- RUSTFS_OBJECT_MEDIUM_CONCURRENCY_THRESHOLD=${RUSTFS_OBJECT_MEDIUM_CONCURRENCY_THRESHOLD:-6}
- RUSTFS_OBJECT_HIGH_CONCURRENCY_THRESHOLD=${RUSTFS_OBJECT_HIGH_CONCURRENCY_THRESHOLD:-12}
- RUSTFS_OBJECT_IO_RANDOM_READAHEAD_DISABLE_CONCURRENCY=${RUSTFS_OBJECT_IO_RANDOM_READAHEAD_DISABLE_CONCURRENCY:-8}
- RUSTFS_OBJECT_DUPLEX_BUFFER_SIZE=${RUSTFS_OBJECT_DUPLEX_BUFFER_SIZE:-8388608}
- RUSTFS_ERASURE_ENCODE_MAX_INFLIGHT_BYTES=${RUSTFS_ERASURE_ENCODE_MAX_INFLIGHT_BYTES:-25165824}
- RUSTFS_RUNTIME_WORKER_THREADS=${RUSTFS_RUNTIME_WORKER_THREADS:-12}
- RUSTFS_RUNTIME_MAX_BLOCKING_THREADS=${RUSTFS_RUNTIME_MAX_BLOCKING_THREADS:-512}
- RUSTFS_ALLOCATOR_RECLAIM_ENABLED=${RUSTFS_ALLOCATOR_RECLAIM_ENABLED:-false}
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
- node1_data_0:/data/rustfs0
- node1_data_1:/data/rustfs1
- node1_data_2:/data/rustfs2
- node1_data_3:/data/rustfs3
ports:
- "9000:9000"
networks:
- rustfs-cluster-net
node2:
platform: ${RUSTFS_DOCKER_PLATFORM:-linux/amd64}
image: ${RUSTFS_IMAGE:-rustfs/rustfs:local-4node}
build:
context: ../..
dockerfile: Dockerfile.source
hostname: node2
environment:
- RUSTFS_VOLUMES=http://node{1...4}:9000/data/rustfs{0...3}
- RUSTFS_ADDRESS=:9000
- RUSTFS_CONSOLE_ENABLE=true
- RUSTFS_ACCESS_KEY=${RUSTFS_ACCESS_KEY:-rustfs-cluster-admin}
- RUSTFS_SECRET_KEY=${RUSTFS_SECRET_KEY:-rustfs-cluster-secret}
- RUSTFS_OBS_ENDPOINT=${RUSTFS_OBS_ENDPOINT:-http://host.docker.internal:4318}
# `info` is enough for startup logs/metrics. Use `debug` if Tempo/Jaeger
# should show richer nested spans during request-path verification.
- RUSTFS_OBS_LOGGER_LEVEL=${RUSTFS_OBS_LOGGER_LEVEL:-info}
- RUSTFS_OBS_PROFILING_ENDPOINT=${RUSTFS_OBS_PROFILING_ENDPOINT:-http://host.docker.internal:4040}
- RUSTFS_OBS_PROFILING_EXPORT_ENABLED=${RUSTFS_OBS_PROFILING_EXPORT_ENABLED:-true}
- RUSTFS_UNSAFE_BYPASS_DISK_CHECK=${RUSTFS_UNSAFE_BYPASS_DISK_CHECK:-true}
- RUSTFS_ENABLE_PROFILING=${RUSTFS_ENABLE_PROFILING:-true}
- RUSTFS_PROF_CPU_MODE=${RUSTFS_PROF_CPU_MODE:-continuous}
- RUSTFS_PROF_CPU_FREQ=${RUSTFS_PROF_CPU_FREQ:-99}
- RUSTFS_PROF_OUTPUT_DIR=${RUSTFS_PROF_OUTPUT_DIR:-/tmp/rustfs-profiles}
- RUSTFS_OBJECT_MAX_CONCURRENT_DISK_READS=${RUSTFS_OBJECT_MAX_CONCURRENT_DISK_READS:-48}
- RUSTFS_OBJECT_IO_BUFFER_SIZE=${RUSTFS_OBJECT_IO_BUFFER_SIZE:-262144}
- RUSTFS_OBJECT_MEDIUM_CONCURRENCY_THRESHOLD=${RUSTFS_OBJECT_MEDIUM_CONCURRENCY_THRESHOLD:-6}
- RUSTFS_OBJECT_HIGH_CONCURRENCY_THRESHOLD=${RUSTFS_OBJECT_HIGH_CONCURRENCY_THRESHOLD:-12}
- RUSTFS_OBJECT_IO_RANDOM_READAHEAD_DISABLE_CONCURRENCY=${RUSTFS_OBJECT_IO_RANDOM_READAHEAD_DISABLE_CONCURRENCY:-8}
- RUSTFS_OBJECT_DUPLEX_BUFFER_SIZE=${RUSTFS_OBJECT_DUPLEX_BUFFER_SIZE:-8388608}
- RUSTFS_ERASURE_ENCODE_MAX_INFLIGHT_BYTES=${RUSTFS_ERASURE_ENCODE_MAX_INFLIGHT_BYTES:-25165824}
- RUSTFS_RUNTIME_WORKER_THREADS=${RUSTFS_RUNTIME_WORKER_THREADS:-12}
- RUSTFS_RUNTIME_MAX_BLOCKING_THREADS=${RUSTFS_RUNTIME_MAX_BLOCKING_THREADS:-512}
- RUSTFS_ALLOCATOR_RECLAIM_ENABLED=${RUSTFS_ALLOCATOR_RECLAIM_ENABLED:-false}
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
- node2_data_0:/data/rustfs0
- node2_data_1:/data/rustfs1
- node2_data_2:/data/rustfs2
- node2_data_3:/data/rustfs3
ports:
- "9001:9000"
networks:
- rustfs-cluster-net
node3:
platform: ${RUSTFS_DOCKER_PLATFORM:-linux/amd64}
image: ${RUSTFS_IMAGE:-rustfs/rustfs:local-4node}
build:
context: ../..
dockerfile: Dockerfile.source
hostname: node3
environment:
- RUSTFS_VOLUMES=http://node{1...4}:9000/data/rustfs{0...3}
- RUSTFS_ADDRESS=:9000
- RUSTFS_CONSOLE_ENABLE=true
- RUSTFS_ACCESS_KEY=${RUSTFS_ACCESS_KEY:-rustfs-cluster-admin}
- RUSTFS_SECRET_KEY=${RUSTFS_SECRET_KEY:-rustfs-cluster-secret}
- RUSTFS_OBS_ENDPOINT=${RUSTFS_OBS_ENDPOINT:-http://host.docker.internal:4318}
# `info` is enough for startup logs/metrics. Use `debug` if Tempo/Jaeger
# should show richer nested spans during request-path verification.
- RUSTFS_OBS_LOGGER_LEVEL=${RUSTFS_OBS_LOGGER_LEVEL:-info}
- RUSTFS_OBS_PROFILING_ENDPOINT=${RUSTFS_OBS_PROFILING_ENDPOINT:-http://host.docker.internal:4040}
- RUSTFS_OBS_PROFILING_EXPORT_ENABLED=${RUSTFS_OBS_PROFILING_EXPORT_ENABLED:-true}
- RUSTFS_UNSAFE_BYPASS_DISK_CHECK=${RUSTFS_UNSAFE_BYPASS_DISK_CHECK:-true}
- RUSTFS_ENABLE_PROFILING=${RUSTFS_ENABLE_PROFILING:-true}
- RUSTFS_PROF_CPU_MODE=${RUSTFS_PROF_CPU_MODE:-continuous}
- RUSTFS_PROF_CPU_FREQ=${RUSTFS_PROF_CPU_FREQ:-99}
- RUSTFS_PROF_OUTPUT_DIR=${RUSTFS_PROF_OUTPUT_DIR:-/tmp/rustfs-profiles}
- RUSTFS_OBJECT_MAX_CONCURRENT_DISK_READS=${RUSTFS_OBJECT_MAX_CONCURRENT_DISK_READS:-48}
- RUSTFS_OBJECT_IO_BUFFER_SIZE=${RUSTFS_OBJECT_IO_BUFFER_SIZE:-262144}
- RUSTFS_OBJECT_MEDIUM_CONCURRENCY_THRESHOLD=${RUSTFS_OBJECT_MEDIUM_CONCURRENCY_THRESHOLD:-6}
- RUSTFS_OBJECT_HIGH_CONCURRENCY_THRESHOLD=${RUSTFS_OBJECT_HIGH_CONCURRENCY_THRESHOLD:-12}
- RUSTFS_OBJECT_IO_RANDOM_READAHEAD_DISABLE_CONCURRENCY=${RUSTFS_OBJECT_IO_RANDOM_READAHEAD_DISABLE_CONCURRENCY:-8}
- RUSTFS_OBJECT_DUPLEX_BUFFER_SIZE=${RUSTFS_OBJECT_DUPLEX_BUFFER_SIZE:-8388608}
- RUSTFS_ERASURE_ENCODE_MAX_INFLIGHT_BYTES=${RUSTFS_ERASURE_ENCODE_MAX_INFLIGHT_BYTES:-25165824}
- RUSTFS_RUNTIME_WORKER_THREADS=${RUSTFS_RUNTIME_WORKER_THREADS:-12}
- RUSTFS_RUNTIME_MAX_BLOCKING_THREADS=${RUSTFS_RUNTIME_MAX_BLOCKING_THREADS:-512}
- RUSTFS_ALLOCATOR_RECLAIM_ENABLED=${RUSTFS_ALLOCATOR_RECLAIM_ENABLED:-false}
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
- node3_data_0:/data/rustfs0
- node3_data_1:/data/rustfs1
- node3_data_2:/data/rustfs2
- node3_data_3:/data/rustfs3
ports:
- "9002:9000"
networks:
- rustfs-cluster-net
node4:
platform: ${RUSTFS_DOCKER_PLATFORM:-linux/amd64}
image: ${RUSTFS_IMAGE:-rustfs/rustfs:local-4node}
build:
context: ../..
dockerfile: Dockerfile.source
hostname: node4
environment:
- RUSTFS_VOLUMES=http://node{1...4}:9000/data/rustfs{0...3}
- RUSTFS_ADDRESS=:9000
- RUSTFS_CONSOLE_ENABLE=true
- RUSTFS_ACCESS_KEY=${RUSTFS_ACCESS_KEY:-rustfs-cluster-admin}
- RUSTFS_SECRET_KEY=${RUSTFS_SECRET_KEY:-rustfs-cluster-secret}
- RUSTFS_OBS_ENDPOINT=${RUSTFS_OBS_ENDPOINT:-http://host.docker.internal:4318}
# `info` is enough for startup logs/metrics. Use `debug` if Tempo/Jaeger
# should show richer nested spans during request-path verification.
- RUSTFS_OBS_LOGGER_LEVEL=${RUSTFS_OBS_LOGGER_LEVEL:-info}
- RUSTFS_OBS_PROFILING_ENDPOINT=${RUSTFS_OBS_PROFILING_ENDPOINT:-http://host.docker.internal:4040}
- RUSTFS_OBS_PROFILING_EXPORT_ENABLED=${RUSTFS_OBS_PROFILING_EXPORT_ENABLED:-true}
- RUSTFS_UNSAFE_BYPASS_DISK_CHECK=${RUSTFS_UNSAFE_BYPASS_DISK_CHECK:-true}
- RUSTFS_ENABLE_PROFILING=${RUSTFS_ENABLE_PROFILING:-true}
- RUSTFS_PROF_CPU_MODE=${RUSTFS_PROF_CPU_MODE:-continuous}
- RUSTFS_PROF_CPU_FREQ=${RUSTFS_PROF_CPU_FREQ:-99}
- RUSTFS_PROF_OUTPUT_DIR=${RUSTFS_PROF_OUTPUT_DIR:-/tmp/rustfs-profiles}
- RUSTFS_OBJECT_MAX_CONCURRENT_DISK_READS=${RUSTFS_OBJECT_MAX_CONCURRENT_DISK_READS:-48}
- RUSTFS_OBJECT_IO_BUFFER_SIZE=${RUSTFS_OBJECT_IO_BUFFER_SIZE:-262144}
- RUSTFS_OBJECT_MEDIUM_CONCURRENCY_THRESHOLD=${RUSTFS_OBJECT_MEDIUM_CONCURRENCY_THRESHOLD:-6}
- RUSTFS_OBJECT_HIGH_CONCURRENCY_THRESHOLD=${RUSTFS_OBJECT_HIGH_CONCURRENCY_THRESHOLD:-12}
- RUSTFS_OBJECT_IO_RANDOM_READAHEAD_DISABLE_CONCURRENCY=${RUSTFS_OBJECT_IO_RANDOM_READAHEAD_DISABLE_CONCURRENCY:-8}
- RUSTFS_OBJECT_DUPLEX_BUFFER_SIZE=${RUSTFS_OBJECT_DUPLEX_BUFFER_SIZE:-8388608}
- RUSTFS_ERASURE_ENCODE_MAX_INFLIGHT_BYTES=${RUSTFS_ERASURE_ENCODE_MAX_INFLIGHT_BYTES:-25165824}
- RUSTFS_RUNTIME_WORKER_THREADS=${RUSTFS_RUNTIME_WORKER_THREADS:-12}
- RUSTFS_RUNTIME_MAX_BLOCKING_THREADS=${RUSTFS_RUNTIME_MAX_BLOCKING_THREADS:-512}
- RUSTFS_ALLOCATOR_RECLAIM_ENABLED=${RUSTFS_ALLOCATOR_RECLAIM_ENABLED:-false}
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
- node4_data_0:/data/rustfs0
- node4_data_1:/data/rustfs1
- node4_data_2:/data/rustfs2
- node4_data_3:/data/rustfs3
ports:
- "9003:9000"
networks:
- rustfs-cluster-net
volumes:
node1_data_0:
node1_data_1:
node1_data_2:
node1_data_3:
node2_data_0:
node2_data_1:
node2_data_2:
node2_data_3:
node3_data_0:
node3_data_1:
node3_data_2:
node3_data_3:
node4_data_0:
node4_data_1:
node4_data_2:
node4_data_3:
networks:
rustfs-cluster-net:
driver: bridge
@@ -20,13 +20,18 @@ services:
dockerfile: Dockerfile.source
hostname: node1
environment:
- RUSTFS_VOLUMES=http://node{1...4}:9000/data/rustfs{0...3}
- RUSTFS_VOLUMES=${RUSTFS_VOLUMES:-http://node{1...4}:9000/data/rustfs{0...3}}
- RUSTFS_ADDRESS=:9000
- RUSTFS_CONSOLE_ENABLE=true
- RUSTFS_ACCESS_KEY=${RUSTFS_ACCESS_KEY:-rustfsadmin}
- RUSTFS_SECRET_KEY=${RUSTFS_SECRET_KEY:-rustfsadmin}
- RUSTFS_OBS_ENDPOINT=${RUSTFS_OBS_ENDPOINT:-http://host.docker.internal:4318}
- RUSTFS_OBS_LOGGER_LEVEL=${RUSTFS_OBS_LOGGER_LEVEL:-info}
- RUSTFS_OBS_USE_STDOUT=${RUSTFS_OBS_USE_STDOUT:-false}
- RUSTFS_OBS_LOG_STDOUT_ENABLED=${RUSTFS_OBS_LOG_STDOUT_ENABLED:-false}
- RUSTFS_ISSUE3031_DIAG_ENABLE=${RUSTFS_ISSUE3031_DIAG_ENABLE:-false}
- RUSTFS_OBJECT_LOCK_ACQUIRE_TIMEOUT=${RUSTFS_OBJECT_LOCK_ACQUIRE_TIMEOUT:-5}
- RUSTFS_LOCK_ACQUIRE_TIMEOUT=${RUSTFS_LOCK_ACQUIRE_TIMEOUT:-5}
- RUSTFS_UNSAFE_BYPASS_DISK_CHECK=${RUSTFS_UNSAFE_BYPASS_DISK_CHECK:-true}
extra_hosts:
- "host.docker.internal:host-gateway"
@@ -47,13 +52,18 @@ services:
dockerfile: Dockerfile.source
hostname: node2
environment:
- RUSTFS_VOLUMES=http://node{1...4}:9000/data/rustfs{0...3}
- RUSTFS_VOLUMES=${RUSTFS_VOLUMES:-http://node{1...4}:9000/data/rustfs{0...3}}
- RUSTFS_ADDRESS=:9000
- RUSTFS_CONSOLE_ENABLE=true
- RUSTFS_ACCESS_KEY=${RUSTFS_ACCESS_KEY:-rustfsadmin}
- RUSTFS_SECRET_KEY=${RUSTFS_SECRET_KEY:-rustfsadmin}
- RUSTFS_OBS_ENDPOINT=${RUSTFS_OBS_ENDPOINT:-http://host.docker.internal:4318}
- RUSTFS_OBS_LOGGER_LEVEL=${RUSTFS_OBS_LOGGER_LEVEL:-info}
- RUSTFS_OBS_USE_STDOUT=${RUSTFS_OBS_USE_STDOUT:-false}
- RUSTFS_OBS_LOG_STDOUT_ENABLED=${RUSTFS_OBS_LOG_STDOUT_ENABLED:-false}
- RUSTFS_ISSUE3031_DIAG_ENABLE=${RUSTFS_ISSUE3031_DIAG_ENABLE:-false}
- RUSTFS_OBJECT_LOCK_ACQUIRE_TIMEOUT=${RUSTFS_OBJECT_LOCK_ACQUIRE_TIMEOUT:-5}
- RUSTFS_LOCK_ACQUIRE_TIMEOUT=${RUSTFS_LOCK_ACQUIRE_TIMEOUT:-5}
- RUSTFS_UNSAFE_BYPASS_DISK_CHECK=${RUSTFS_UNSAFE_BYPASS_DISK_CHECK:-true}
extra_hosts:
- "host.docker.internal:host-gateway"
@@ -74,13 +84,18 @@ services:
dockerfile: Dockerfile.source
hostname: node3
environment:
- RUSTFS_VOLUMES=http://node{1...4}:9000/data/rustfs{0...3}
- RUSTFS_VOLUMES=${RUSTFS_VOLUMES:-http://node{1...4}:9000/data/rustfs{0...3}}
- RUSTFS_ADDRESS=:9000
- RUSTFS_CONSOLE_ENABLE=true
- RUSTFS_ACCESS_KEY=${RUSTFS_ACCESS_KEY:-rustfsadmin}
- RUSTFS_SECRET_KEY=${RUSTFS_SECRET_KEY:-rustfsadmin}
- RUSTFS_OBS_ENDPOINT=${RUSTFS_OBS_ENDPOINT:-http://host.docker.internal:4318}
- RUSTFS_OBS_LOGGER_LEVEL=${RUSTFS_OBS_LOGGER_LEVEL:-info}
- RUSTFS_OBS_USE_STDOUT=${RUSTFS_OBS_USE_STDOUT:-false}
- RUSTFS_OBS_LOG_STDOUT_ENABLED=${RUSTFS_OBS_LOG_STDOUT_ENABLED:-false}
- RUSTFS_ISSUE3031_DIAG_ENABLE=${RUSTFS_ISSUE3031_DIAG_ENABLE:-false}
- RUSTFS_OBJECT_LOCK_ACQUIRE_TIMEOUT=${RUSTFS_OBJECT_LOCK_ACQUIRE_TIMEOUT:-5}
- RUSTFS_LOCK_ACQUIRE_TIMEOUT=${RUSTFS_LOCK_ACQUIRE_TIMEOUT:-5}
- RUSTFS_UNSAFE_BYPASS_DISK_CHECK=${RUSTFS_UNSAFE_BYPASS_DISK_CHECK:-true}
extra_hosts:
- "host.docker.internal:host-gateway"
@@ -101,13 +116,18 @@ services:
dockerfile: Dockerfile.source
hostname: node4
environment:
- RUSTFS_VOLUMES=http://node{1...4}:9000/data/rustfs{0...3}
- RUSTFS_VOLUMES=${RUSTFS_VOLUMES:-http://node{1...4}:9000/data/rustfs{0...3}}
- RUSTFS_ADDRESS=:9000
- RUSTFS_CONSOLE_ENABLE=true
- RUSTFS_ACCESS_KEY=${RUSTFS_ACCESS_KEY:-rustfsadmin}
- RUSTFS_SECRET_KEY=${RUSTFS_SECRET_KEY:-rustfsadmin}
- RUSTFS_OBS_ENDPOINT=${RUSTFS_OBS_ENDPOINT:-http://host.docker.internal:4318}
- RUSTFS_OBS_LOGGER_LEVEL=${RUSTFS_OBS_LOGGER_LEVEL:-info}
- RUSTFS_OBS_USE_STDOUT=${RUSTFS_OBS_USE_STDOUT:-false}
- RUSTFS_OBS_LOG_STDOUT_ENABLED=${RUSTFS_OBS_LOG_STDOUT_ENABLED:-false}
- RUSTFS_ISSUE3031_DIAG_ENABLE=${RUSTFS_ISSUE3031_DIAG_ENABLE:-false}
- RUSTFS_OBJECT_LOCK_ACQUIRE_TIMEOUT=${RUSTFS_OBJECT_LOCK_ACQUIRE_TIMEOUT:-5}
- RUSTFS_LOCK_ACQUIRE_TIMEOUT=${RUSTFS_LOCK_ACQUIRE_TIMEOUT:-5}
- RUSTFS_UNSAFE_BYPASS_DISK_CHECK=${RUSTFS_UNSAFE_BYPASS_DISK_CHECK:-true}
extra_hosts:
- "host.docker.internal:host-gateway"
@@ -0,0 +1,56 @@
services:
node1:
environment:
- RUSTFS_OBJECT_MAX_CONCURRENT_DISK_READS=56
- RUSTFS_OBJECT_IO_BUFFER_SIZE=131072
- RUSTFS_OBJECT_MEDIUM_CONCURRENCY_THRESHOLD=10
- RUSTFS_OBJECT_HIGH_CONCURRENCY_THRESHOLD=20
- RUSTFS_OBJECT_IO_RANDOM_READAHEAD_DISABLE_CONCURRENCY=10
- RUSTFS_OBJECT_DUPLEX_BUFFER_SIZE=8388608
- RUSTFS_ERASURE_ENCODE_MAX_INFLIGHT_BYTES=25165824
- RUSTFS_RUNTIME_WORKER_THREADS=16
- RUSTFS_RUNTIME_MAX_BLOCKING_THREADS=768
- RUSTFS_CAPACITY_SCHEDULED_INTERVAL=420
- RUSTFS_CAPACITY_WRITE_FREQUENCY_THRESHOLD=50
node2:
environment:
- RUSTFS_OBJECT_MAX_CONCURRENT_DISK_READS=56
- RUSTFS_OBJECT_IO_BUFFER_SIZE=131072
- RUSTFS_OBJECT_MEDIUM_CONCURRENCY_THRESHOLD=10
- RUSTFS_OBJECT_HIGH_CONCURRENCY_THRESHOLD=20
- RUSTFS_OBJECT_IO_RANDOM_READAHEAD_DISABLE_CONCURRENCY=10
- RUSTFS_OBJECT_DUPLEX_BUFFER_SIZE=8388608
- RUSTFS_ERASURE_ENCODE_MAX_INFLIGHT_BYTES=25165824
- RUSTFS_RUNTIME_WORKER_THREADS=16
- RUSTFS_RUNTIME_MAX_BLOCKING_THREADS=768
- RUSTFS_CAPACITY_SCHEDULED_INTERVAL=420
- RUSTFS_CAPACITY_WRITE_FREQUENCY_THRESHOLD=50
node3:
environment:
- RUSTFS_OBJECT_MAX_CONCURRENT_DISK_READS=56
- RUSTFS_OBJECT_IO_BUFFER_SIZE=131072
- RUSTFS_OBJECT_MEDIUM_CONCURRENCY_THRESHOLD=10
- RUSTFS_OBJECT_HIGH_CONCURRENCY_THRESHOLD=20
- RUSTFS_OBJECT_IO_RANDOM_READAHEAD_DISABLE_CONCURRENCY=10
- RUSTFS_OBJECT_DUPLEX_BUFFER_SIZE=8388608
- RUSTFS_ERASURE_ENCODE_MAX_INFLIGHT_BYTES=25165824
- RUSTFS_RUNTIME_WORKER_THREADS=16
- RUSTFS_RUNTIME_MAX_BLOCKING_THREADS=768
- RUSTFS_CAPACITY_SCHEDULED_INTERVAL=420
- RUSTFS_CAPACITY_WRITE_FREQUENCY_THRESHOLD=50
node4:
environment:
- RUSTFS_OBJECT_MAX_CONCURRENT_DISK_READS=56
- RUSTFS_OBJECT_IO_BUFFER_SIZE=131072
- RUSTFS_OBJECT_MEDIUM_CONCURRENCY_THRESHOLD=10
- RUSTFS_OBJECT_HIGH_CONCURRENCY_THRESHOLD=20
- RUSTFS_OBJECT_IO_RANDOM_READAHEAD_DISABLE_CONCURRENCY=10
- RUSTFS_OBJECT_DUPLEX_BUFFER_SIZE=8388608
- RUSTFS_ERASURE_ENCODE_MAX_INFLIGHT_BYTES=25165824
- RUSTFS_RUNTIME_WORKER_THREADS=16
- RUSTFS_RUNTIME_MAX_BLOCKING_THREADS=768
- RUSTFS_CAPACITY_SCHEDULED_INTERVAL=420
- RUSTFS_CAPACITY_WRITE_FREQUENCY_THRESHOLD=50
@@ -0,0 +1,56 @@
services:
node1:
environment:
- RUSTFS_OBJECT_MAX_CONCURRENT_DISK_READS=96
- RUSTFS_OBJECT_IO_BUFFER_SIZE=524288
- RUSTFS_OBJECT_MEDIUM_CONCURRENCY_THRESHOLD=16
- RUSTFS_OBJECT_HIGH_CONCURRENCY_THRESHOLD=32
- RUSTFS_OBJECT_IO_RANDOM_READAHEAD_DISABLE_CONCURRENCY=24
- RUSTFS_OBJECT_DUPLEX_BUFFER_SIZE=33554432
- RUSTFS_ERASURE_ENCODE_MAX_INFLIGHT_BYTES=50331648
- RUSTFS_RUNTIME_WORKER_THREADS=24
- RUSTFS_RUNTIME_MAX_BLOCKING_THREADS=1536
- RUSTFS_CAPACITY_SCHEDULED_INTERVAL=600
- RUSTFS_CAPACITY_WRITE_FREQUENCY_THRESHOLD=80
node2:
environment:
- RUSTFS_OBJECT_MAX_CONCURRENT_DISK_READS=96
- RUSTFS_OBJECT_IO_BUFFER_SIZE=524288
- RUSTFS_OBJECT_MEDIUM_CONCURRENCY_THRESHOLD=16
- RUSTFS_OBJECT_HIGH_CONCURRENCY_THRESHOLD=32
- RUSTFS_OBJECT_IO_RANDOM_READAHEAD_DISABLE_CONCURRENCY=24
- RUSTFS_OBJECT_DUPLEX_BUFFER_SIZE=33554432
- RUSTFS_ERASURE_ENCODE_MAX_INFLIGHT_BYTES=50331648
- RUSTFS_RUNTIME_WORKER_THREADS=24
- RUSTFS_RUNTIME_MAX_BLOCKING_THREADS=1536
- RUSTFS_CAPACITY_SCHEDULED_INTERVAL=600
- RUSTFS_CAPACITY_WRITE_FREQUENCY_THRESHOLD=80
node3:
environment:
- RUSTFS_OBJECT_MAX_CONCURRENT_DISK_READS=96
- RUSTFS_OBJECT_IO_BUFFER_SIZE=524288
- RUSTFS_OBJECT_MEDIUM_CONCURRENCY_THRESHOLD=16
- RUSTFS_OBJECT_HIGH_CONCURRENCY_THRESHOLD=32
- RUSTFS_OBJECT_IO_RANDOM_READAHEAD_DISABLE_CONCURRENCY=24
- RUSTFS_OBJECT_DUPLEX_BUFFER_SIZE=33554432
- RUSTFS_ERASURE_ENCODE_MAX_INFLIGHT_BYTES=50331648
- RUSTFS_RUNTIME_WORKER_THREADS=24
- RUSTFS_RUNTIME_MAX_BLOCKING_THREADS=1536
- RUSTFS_CAPACITY_SCHEDULED_INTERVAL=600
- RUSTFS_CAPACITY_WRITE_FREQUENCY_THRESHOLD=80
node4:
environment:
- RUSTFS_OBJECT_MAX_CONCURRENT_DISK_READS=96
- RUSTFS_OBJECT_IO_BUFFER_SIZE=524288
- RUSTFS_OBJECT_MEDIUM_CONCURRENCY_THRESHOLD=16
- RUSTFS_OBJECT_HIGH_CONCURRENCY_THRESHOLD=32
- RUSTFS_OBJECT_IO_RANDOM_READAHEAD_DISABLE_CONCURRENCY=24
- RUSTFS_OBJECT_DUPLEX_BUFFER_SIZE=33554432
- RUSTFS_ERASURE_ENCODE_MAX_INFLIGHT_BYTES=50331648
- RUSTFS_RUNTIME_WORKER_THREADS=24
- RUSTFS_RUNTIME_MAX_BLOCKING_THREADS=1536
- RUSTFS_CAPACITY_SCHEDULED_INTERVAL=600
- RUSTFS_CAPACITY_WRITE_FREQUENCY_THRESHOLD=80
@@ -0,0 +1,56 @@
services:
node1:
environment:
- RUSTFS_OBJECT_MAX_CONCURRENT_DISK_READS=64
- RUSTFS_OBJECT_IO_BUFFER_SIZE=262144
- RUSTFS_OBJECT_MEDIUM_CONCURRENCY_THRESHOLD=12
- RUSTFS_OBJECT_HIGH_CONCURRENCY_THRESHOLD=24
- RUSTFS_OBJECT_IO_RANDOM_READAHEAD_DISABLE_CONCURRENCY=16
- RUSTFS_OBJECT_DUPLEX_BUFFER_SIZE=16777216
- RUSTFS_ERASURE_ENCODE_MAX_INFLIGHT_BYTES=33554432
- RUSTFS_RUNTIME_WORKER_THREADS=20
- RUSTFS_RUNTIME_MAX_BLOCKING_THREADS=1024
- RUSTFS_CAPACITY_SCHEDULED_INTERVAL=300
- RUSTFS_CAPACITY_WRITE_FREQUENCY_THRESHOLD=60
node2:
environment:
- RUSTFS_OBJECT_MAX_CONCURRENT_DISK_READS=64
- RUSTFS_OBJECT_IO_BUFFER_SIZE=262144
- RUSTFS_OBJECT_MEDIUM_CONCURRENCY_THRESHOLD=12
- RUSTFS_OBJECT_HIGH_CONCURRENCY_THRESHOLD=24
- RUSTFS_OBJECT_IO_RANDOM_READAHEAD_DISABLE_CONCURRENCY=16
- RUSTFS_OBJECT_DUPLEX_BUFFER_SIZE=16777216
- RUSTFS_ERASURE_ENCODE_MAX_INFLIGHT_BYTES=33554432
- RUSTFS_RUNTIME_WORKER_THREADS=20
- RUSTFS_RUNTIME_MAX_BLOCKING_THREADS=1024
- RUSTFS_CAPACITY_SCHEDULED_INTERVAL=300
- RUSTFS_CAPACITY_WRITE_FREQUENCY_THRESHOLD=60
node3:
environment:
- RUSTFS_OBJECT_MAX_CONCURRENT_DISK_READS=64
- RUSTFS_OBJECT_IO_BUFFER_SIZE=262144
- RUSTFS_OBJECT_MEDIUM_CONCURRENCY_THRESHOLD=12
- RUSTFS_OBJECT_HIGH_CONCURRENCY_THRESHOLD=24
- RUSTFS_OBJECT_IO_RANDOM_READAHEAD_DISABLE_CONCURRENCY=16
- RUSTFS_OBJECT_DUPLEX_BUFFER_SIZE=16777216
- RUSTFS_ERASURE_ENCODE_MAX_INFLIGHT_BYTES=33554432
- RUSTFS_RUNTIME_WORKER_THREADS=20
- RUSTFS_RUNTIME_MAX_BLOCKING_THREADS=1024
- RUSTFS_CAPACITY_SCHEDULED_INTERVAL=300
- RUSTFS_CAPACITY_WRITE_FREQUENCY_THRESHOLD=60
node4:
environment:
- RUSTFS_OBJECT_MAX_CONCURRENT_DISK_READS=64
- RUSTFS_OBJECT_IO_BUFFER_SIZE=262144
- RUSTFS_OBJECT_MEDIUM_CONCURRENCY_THRESHOLD=12
- RUSTFS_OBJECT_HIGH_CONCURRENCY_THRESHOLD=24
- RUSTFS_OBJECT_IO_RANDOM_READAHEAD_DISABLE_CONCURRENCY=16
- RUSTFS_OBJECT_DUPLEX_BUFFER_SIZE=16777216
- RUSTFS_ERASURE_ENCODE_MAX_INFLIGHT_BYTES=33554432
- RUSTFS_RUNTIME_WORKER_THREADS=20
- RUSTFS_RUNTIME_MAX_BLOCKING_THREADS=1024
- RUSTFS_CAPACITY_SCHEDULED_INTERVAL=300
- RUSTFS_CAPACITY_WRITE_FREQUENCY_THRESHOLD=60
@@ -0,0 +1,56 @@
services:
node1:
environment:
- RUSTFS_OBJECT_MAX_CONCURRENT_DISK_READS=48
- RUSTFS_OBJECT_IO_BUFFER_SIZE=262144
- RUSTFS_OBJECT_MEDIUM_CONCURRENCY_THRESHOLD=8
- RUSTFS_OBJECT_HIGH_CONCURRENCY_THRESHOLD=16
- RUSTFS_OBJECT_IO_RANDOM_READAHEAD_DISABLE_CONCURRENCY=12
- RUSTFS_OBJECT_DUPLEX_BUFFER_SIZE=8388608
- RUSTFS_ERASURE_ENCODE_MAX_INFLIGHT_BYTES=25165824
- RUSTFS_RUNTIME_WORKER_THREADS=12
- RUSTFS_RUNTIME_MAX_BLOCKING_THREADS=512
- RUSTFS_CAPACITY_SCHEDULED_INTERVAL=300
- RUSTFS_CAPACITY_WRITE_FREQUENCY_THRESHOLD=40
node2:
environment:
- RUSTFS_OBJECT_MAX_CONCURRENT_DISK_READS=48
- RUSTFS_OBJECT_IO_BUFFER_SIZE=262144
- RUSTFS_OBJECT_MEDIUM_CONCURRENCY_THRESHOLD=8
- RUSTFS_OBJECT_HIGH_CONCURRENCY_THRESHOLD=16
- RUSTFS_OBJECT_IO_RANDOM_READAHEAD_DISABLE_CONCURRENCY=12
- RUSTFS_OBJECT_DUPLEX_BUFFER_SIZE=8388608
- RUSTFS_ERASURE_ENCODE_MAX_INFLIGHT_BYTES=25165824
- RUSTFS_RUNTIME_WORKER_THREADS=12
- RUSTFS_RUNTIME_MAX_BLOCKING_THREADS=512
- RUSTFS_CAPACITY_SCHEDULED_INTERVAL=300
- RUSTFS_CAPACITY_WRITE_FREQUENCY_THRESHOLD=40
node3:
environment:
- RUSTFS_OBJECT_MAX_CONCURRENT_DISK_READS=48
- RUSTFS_OBJECT_IO_BUFFER_SIZE=262144
- RUSTFS_OBJECT_MEDIUM_CONCURRENCY_THRESHOLD=8
- RUSTFS_OBJECT_HIGH_CONCURRENCY_THRESHOLD=16
- RUSTFS_OBJECT_IO_RANDOM_READAHEAD_DISABLE_CONCURRENCY=12
- RUSTFS_OBJECT_DUPLEX_BUFFER_SIZE=8388608
- RUSTFS_ERASURE_ENCODE_MAX_INFLIGHT_BYTES=25165824
- RUSTFS_RUNTIME_WORKER_THREADS=12
- RUSTFS_RUNTIME_MAX_BLOCKING_THREADS=512
- RUSTFS_CAPACITY_SCHEDULED_INTERVAL=300
- RUSTFS_CAPACITY_WRITE_FREQUENCY_THRESHOLD=40
node4:
environment:
- RUSTFS_OBJECT_MAX_CONCURRENT_DISK_READS=48
- RUSTFS_OBJECT_IO_BUFFER_SIZE=262144
- RUSTFS_OBJECT_MEDIUM_CONCURRENCY_THRESHOLD=8
- RUSTFS_OBJECT_HIGH_CONCURRENCY_THRESHOLD=16
- RUSTFS_OBJECT_IO_RANDOM_READAHEAD_DISABLE_CONCURRENCY=12
- RUSTFS_OBJECT_DUPLEX_BUFFER_SIZE=8388608
- RUSTFS_ERASURE_ENCODE_MAX_INFLIGHT_BYTES=25165824
- RUSTFS_RUNTIME_WORKER_THREADS=12
- RUSTFS_RUNTIME_MAX_BLOCKING_THREADS=512
- RUSTFS_CAPACITY_SCHEDULED_INTERVAL=300
- RUSTFS_CAPACITY_WRITE_FREQUENCY_THRESHOLD=40
@@ -0,0 +1,56 @@
services:
node1:
environment:
- RUSTFS_OBJECT_MAX_CONCURRENT_DISK_READS=32
- RUSTFS_OBJECT_IO_BUFFER_SIZE=262144
- RUSTFS_OBJECT_MEDIUM_CONCURRENCY_THRESHOLD=6
- RUSTFS_OBJECT_HIGH_CONCURRENCY_THRESHOLD=12
- RUSTFS_OBJECT_IO_RANDOM_READAHEAD_DISABLE_CONCURRENCY=8
- RUSTFS_OBJECT_DUPLEX_BUFFER_SIZE=8388608
- RUSTFS_ERASURE_ENCODE_MAX_INFLIGHT_BYTES=16777216
- RUSTFS_RUNTIME_WORKER_THREADS=6
- RUSTFS_RUNTIME_MAX_BLOCKING_THREADS=256
- RUSTFS_CAPACITY_SCHEDULED_INTERVAL=300
- RUSTFS_CAPACITY_WRITE_FREQUENCY_THRESHOLD=30
node2:
environment:
- RUSTFS_OBJECT_MAX_CONCURRENT_DISK_READS=32
- RUSTFS_OBJECT_IO_BUFFER_SIZE=262144
- RUSTFS_OBJECT_MEDIUM_CONCURRENCY_THRESHOLD=6
- RUSTFS_OBJECT_HIGH_CONCURRENCY_THRESHOLD=12
- RUSTFS_OBJECT_IO_RANDOM_READAHEAD_DISABLE_CONCURRENCY=8
- RUSTFS_OBJECT_DUPLEX_BUFFER_SIZE=8388608
- RUSTFS_ERASURE_ENCODE_MAX_INFLIGHT_BYTES=16777216
- RUSTFS_RUNTIME_WORKER_THREADS=6
- RUSTFS_RUNTIME_MAX_BLOCKING_THREADS=256
- RUSTFS_CAPACITY_SCHEDULED_INTERVAL=300
- RUSTFS_CAPACITY_WRITE_FREQUENCY_THRESHOLD=30
node3:
environment:
- RUSTFS_OBJECT_MAX_CONCURRENT_DISK_READS=32
- RUSTFS_OBJECT_IO_BUFFER_SIZE=262144
- RUSTFS_OBJECT_MEDIUM_CONCURRENCY_THRESHOLD=6
- RUSTFS_OBJECT_HIGH_CONCURRENCY_THRESHOLD=12
- RUSTFS_OBJECT_IO_RANDOM_READAHEAD_DISABLE_CONCURRENCY=8
- RUSTFS_OBJECT_DUPLEX_BUFFER_SIZE=8388608
- RUSTFS_ERASURE_ENCODE_MAX_INFLIGHT_BYTES=16777216
- RUSTFS_RUNTIME_WORKER_THREADS=6
- RUSTFS_RUNTIME_MAX_BLOCKING_THREADS=256
- RUSTFS_CAPACITY_SCHEDULED_INTERVAL=300
- RUSTFS_CAPACITY_WRITE_FREQUENCY_THRESHOLD=30
node4:
environment:
- RUSTFS_OBJECT_MAX_CONCURRENT_DISK_READS=32
- RUSTFS_OBJECT_IO_BUFFER_SIZE=262144
- RUSTFS_OBJECT_MEDIUM_CONCURRENCY_THRESHOLD=6
- RUSTFS_OBJECT_HIGH_CONCURRENCY_THRESHOLD=12
- RUSTFS_OBJECT_IO_RANDOM_READAHEAD_DISABLE_CONCURRENCY=8
- RUSTFS_OBJECT_DUPLEX_BUFFER_SIZE=8388608
- RUSTFS_ERASURE_ENCODE_MAX_INFLIGHT_BYTES=16777216
- RUSTFS_RUNTIME_WORKER_THREADS=6
- RUSTFS_RUNTIME_MAX_BLOCKING_THREADS=256
- RUSTFS_CAPACITY_SCHEDULED_INTERVAL=300
- RUSTFS_CAPACITY_WRITE_FREQUENCY_THRESHOLD=30
+48
View File
@@ -90,6 +90,54 @@ docker compose down -v
- **Grafana**: Dashboards and datasources are provisioned from the `grafana/` directory.
- **Collector**: Edit `otel-collector-config.yaml` to modify pipelines, processors, or exporters.
### Verifying RustFS Traces
When RustFS points `RUSTFS_OBS_ENDPOINT` at this stack, treat the value as the
OTLP/HTTP base URL, for example:
```bash
export RUSTFS_OBS_ENDPOINT=http://host.docker.internal:4318
```
RustFS automatically expands that base URL to:
- `/v1/traces`
- `/v1/metrics`
- `/v1/logs`
Important behavior notes:
- Logs and metrics usually appear during startup, so seeing those two signals
first is expected.
- Visible trace data usually requires real HTTP/S3/gRPC request traffic after
startup, because request-path spans are created on demand.
- `RUSTFS_OBS_LOGGER_LEVEL=info` keeps the top-level request span but filters
many nested `debug` spans. If Tempo or Jaeger looks sparse, retry with
`RUSTFS_OBS_LOGGER_LEVEL=debug` before suspecting collector or Tempo issues.
Minimal validation flow:
```bash
# 1. Start this observability stack.
docker compose up -d
# 2. Start RustFS with OTLP/HTTP export and richer span visibility.
export RUSTFS_OBS_ENDPOINT=http://host.docker.internal:4318
export RUSTFS_OBS_LOGGER_LEVEL=debug
# 3. Generate real request traffic.
curl -I http://127.0.0.1:9000/health
curl -I http://127.0.0.1:9000/health/ready
# 4. Inspect Grafana or Jaeger.
# Grafana: http://localhost:3000
# Jaeger: http://localhost:16686
```
If logs and metrics are present but traces are sparse, the most common cause is
"no real request traffic yet" or "`info` level filtered nested spans", not an
OTLP routing failure.
## Troubleshooting
- **Service Health**: Check the health of services using `docker compose ps`.
+44
View File
@@ -90,6 +90,50 @@ docker compose down -v
- **Grafana**: 仪表盘和数据源从 `grafana/` 目录预置。
- **Collector**: 编辑 `otel-collector-config.yaml` 以修改管道、处理器或导出器。
### 验证 RustFS Trace
当 RustFS 将 `RUSTFS_OBS_ENDPOINT` 指向这套技术栈时,应将该值视为
OTLP/HTTP 的基础 URL,例如:
```bash
export RUSTFS_OBS_ENDPOINT=http://host.docker.internal:4318
```
RustFS 会自动在该基础 URL 后补全:
- `/v1/traces`
- `/v1/metrics`
- `/v1/logs`
需要注意:
- 启动阶段通常会先看到日志和指标,因此“先有日志/指标、后有 trace”是正常现象。
- 可见的 trace 数据通常依赖启动后的真实 HTTP/S3/gRPC 请求流量,因为请求路径上的 span 是按需创建的。
- `RUSTFS_OBS_LOGGER_LEVEL=info` 会保留顶层请求 span,但会过滤掉很多 `debug` 级别的嵌套 span。
如果 Tempo 或 Jaeger 中的 trace 看起来很稀疏,建议先改成 `RUSTFS_OBS_LOGGER_LEVEL=debug`,再判断是否是 collector 或 Tempo 问题。
最小验证流程:
```bash
# 1. 启动本目录下的可观测性技术栈。
docker compose up -d
# 2. 以 OTLP/HTTP 导出方式启动 RustFS,并提高 span 可见性。
export RUSTFS_OBS_ENDPOINT=http://host.docker.internal:4318
export RUSTFS_OBS_LOGGER_LEVEL=debug
# 3. 产生真实请求流量。
curl -I http://127.0.0.1:9000/health
curl -I http://127.0.0.1:9000/health/ready
# 4. 到 Grafana 或 Jaeger 中检查。
# Grafana: http://localhost:3000
# Jaeger: http://localhost:16686
```
如果日志和指标已经正常,但 trace 仍然稀疏,最常见的原因通常是
“还没有真实请求流量”或“`info` 级别过滤了嵌套 span”,而不是 OTLP 路由失败。
## 故障排除
- **服务健康**: 使用 `docker compose ps` 检查服务健康状况。
@@ -9037,13 +9037,13 @@
"uid": "${datasource}"
},
"editorMode": "code",
"expr": "{__name__=~\"rustfs_(notification|audit)_.*\",job=~\"$job\"}",
"expr": "{__name__=~\"rustfs_(notification|audit|log_chain)_.*\",job=~\"$job\"}",
"legendFormat": "{{__name__}}",
"range": true,
"refId": "A"
}
],
"title": "Notification / Audit (All)",
"title": "Notification / Audit / LogChain (All)",
"type": "timeseries"
},
{
@@ -38,3 +38,16 @@ groups:
expr: sum by (job) (rate(rustfs_scanner_buckets_scanned_total[5m]))
- record: rustfs:scanner_cycles_success:rate5m
expr: sum by (job) (rate(rustfs_scanner_cycles_total{result="success"}[5m]))
- record: rustfs:log_chain_op_event_mismatch:rate5m
expr: sum by (job) (rate(rustfs_log_chain_op_event_mismatch_total[5m]))
- alert: RustFSLogChainOpEventMismatchDetected
expr: rustfs:log_chain_op_event_mismatch:rate5m > 0
for: 10m
labels:
severity: warning
component: s3-log-chain
annotations:
summary: "RustFS log-chain op/event mismatch detected"
description: "job={{ $labels.job }} has non-zero rustfs_log_chain_op_event_mismatch_total rate for more than 10m. Check s3 op/event mapping changes."
+7 -6
View File
@@ -53,18 +53,19 @@ runs:
musl-tools \
build-essential \
pkg-config \
libssl-dev
libssl-dev \
protobuf-compiler
- name: Install protoc
uses: arduino/setup-protoc@v3
uses: rustfs/setup-protoc@v3.0.1
with:
version: "33.1"
repo-token: ${{ inputs.github-token }}
version: "34.1"
repo-token: ${{ github.token }}
- name: Install flatc
uses: Nugine/setup-flatc@v1.2.4
uses: Nugine/setup-flatc@v1
with:
version: "25.9.23"
version: "25.12.19"
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
+2 -2
View File
@@ -75,7 +75,7 @@ jobs:
uses: actions/checkout@v6
- name: Dependency Review
uses: actions/dependency-review-action@v4
uses: actions/dependency-review-action@v5
with:
fail-on-severity: moderate
comment-summary-in-pr: true
comment-summary-in-pr: always
+4 -6
View File
@@ -169,7 +169,7 @@ jobs:
{"target_id":"linux-x86_64-gnu","os":"ubicloud-standard-2","target":"x86_64-unknown-linux-gnu","cross":false,"platform":"linux","rustflags":""},
{"target_id":"linux-aarch64-gnu","os":"ubicloud-standard-2","target":"aarch64-unknown-linux-gnu","cross":true,"platform":"linux","rustflags":""},
{"target_id":"macos-aarch64","os":"macos-latest","target":"aarch64-apple-darwin","cross":false,"platform":"macos","rustflags":""},
{"target_id":"macos-x86_64","os":"macos-latest","target":"x86_64-apple-darwin","cross":false,"platform":"macos","rustflags":""},
{"target_id":"macos-x86_64","os":"macos-15-intel","target":"x86_64-apple-darwin","cross":false,"platform":"macos","rustflags":""},
{"target_id":"windows-x86_64","os":"windows-latest","target":"x86_64-pc-windows-msvc","cross":false,"platform":"windows","rustflags":""}
]}'
@@ -234,8 +234,7 @@ jobs:
run: |
mkdir -p ./rustfs/static
if [[ "${{ matrix.platform }}" == "windows" ]]; then
curl.exe -L "https://dl.rustfs.com/artifacts/console/rustfs-console-latest.zip" -o console.zip --retry 3 --retry-delay 5 --max-time 300
if [[ $? -eq 0 ]]; then
if curl.exe --fail -L "https://dl.rustfs.com/artifacts/console/rustfs-console-latest.zip" -o console.zip --retry 3 --retry-delay 5 --max-time 300; then
unzip -o console.zip -d ./rustfs/static
rm console.zip
else
@@ -244,9 +243,8 @@ jobs:
fi
else
chmod +w ./rustfs/static/LICENSE || true
curl -L "https://dl.rustfs.com/artifacts/console/rustfs-console-latest.zip" \
-o console.zip --retry 3 --retry-delay 5 --max-time 300
if [[ $? -eq 0 ]]; then
if curl --fail -L "https://dl.rustfs.com/artifacts/console/rustfs-console-latest.zip" \
-o console.zip --retry 3 --retry-delay 5 --max-time 300; then
unzip -o console.zip -d ./rustfs/static
rm console.zip
else
+1 -1
View File
@@ -204,7 +204,7 @@ jobs:
with:
tool: s3s-e2e
git: https://github.com/s3s-project/s3s.git
rev: 4a04a670cf41274d9be9ab65dc36f4aa3f92fbad
rev: 62cb4a71dd759a6ec56b64c4c42fcc183a2c6a52
- name: Run end-to-end tests
run: |
+7 -2
View File
@@ -31,7 +31,9 @@ jobs:
update-flake:
name: Update flake.lock
runs-on: ubuntu-latest
timeout-minutes: 45
timeout-minutes: 90
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
steps:
- name: Checkout repository
uses: actions/checkout@v6
@@ -39,6 +41,9 @@ jobs:
- name: Install Nix
uses: DeterminateSystems/determinate-nix-action@v3
- name: Cache Nix
uses: DeterminateSystems/flakehub-cache-action@v3.20.0
- name: Check Nix flake inputs
uses: DeterminateSystems/flake-checker-action@v12
@@ -56,7 +61,7 @@ jobs:
nix
automated
commit-msg: "chore(deps): update flake.lock"
pr-reviewers: houseme, overtrue, majinghe
pr-reviewers: overtrue, majinghe
token: ${{ secrets.FLAKE_UPDATE_TOKEN }}
- name: Log PR details
+35 -9
View File
@@ -38,39 +38,65 @@ concurrency:
cancel-in-progress: true
permissions:
contents: read
id-token: write
jobs:
nix-validation:
name: Nix Build & Check
runs-on: ubuntu-latest
timeout-minutes: 45
timeout-minutes: 60
permissions:
contents: read
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Install Nix
uses: cachix/install-nix-action@v31
uses: DeterminateSystems/determinate-nix-action@v3.21.0
with:
github_access_token: ${{ secrets.GITHUB_TOKEN }}
extra_nix_config: |
github-token: ${{ secrets.GITHUB_TOKEN }}
extra-conf: |
experimental-features = nix-command flakes
max-jobs = 1
- name: Setup Magic Nix Cache
uses: DeterminateSystems/magic-nix-cache-action@v13
- name: Cache Nix
uses: DeterminateSystems/flakehub-cache-action@v3.21.0
- name: Setup Flake Checker
- name: Check Nix Flake Inputs
uses: DeterminateSystems/flake-checker-action@v12
with:
fail-mode: true
ignore-missing-flake-lock: false
- name: Verify Flake
run: |
echo "Checking flake structure and evaluation..."
nix flake show
nix flake check --print-build-logs
for attempt in 1 2 3; do
if nix flake check --print-build-logs --fallback --option fallback true; then
break
fi
if [ "$attempt" -eq 3 ]; then
echo "nix flake check failed after 3 attempts."
exit 1
fi
sleep $((attempt * 15))
done
- name: Build RustFS
run: |
echo "Building the default package..."
nix build .#default --print-build-logs
for attempt in 1 2 3; do
if nix build .#default --print-build-logs --fallback --option fallback true; then
break
fi
if [ "$attempt" -eq 3 ]; then
echo "nix build failed after 3 attempts."
exit 1
fi
sleep $((attempt * 15))
done
- name: Test Binary
run: |
+1 -1
View File
@@ -86,7 +86,7 @@ jobs:
- name: Build with profiling optimizations
run: |
RUSTFLAGS="-C force-frame-pointers=yes -C debug-assertions=off" \
RUSTFLAGS="-C force-frame-pointers=yes -C debug-assertions=off --cfg tokio_unstable" \
cargo +nightly build --profile profiling -p rustfs --bins
- name: Run performance profiling
+1 -1
View File
@@ -2,7 +2,7 @@
## 📋 Code Quality Requirements
For instructions on setting up and running the local development environment, please see [Development Guide](docs/DEVELOPMENT.md).
This guide covers the local development environment and the checks expected before contributing.
### 🔧 Code Formatting Rules
Generated
+787 -958
View File
File diff suppressed because it is too large Load Diff
+84 -89
View File
@@ -15,10 +15,10 @@
[workspace]
members = [
"rustfs", # Core file system implementation
"crates/appauth", # Application authentication and authorization
"crates/audit", # Audit target management system with multi-target fan-out
"crates/checksums", # client checksums
"crates/common", # Shared utilities and data structures
"crates/data-usage", # Shared data usage models and algorithms
"crates/config", # Configuration management
"crates/credentials", # Credential management system
"crates/crypto", # Cryptography and security features
@@ -39,15 +39,16 @@ members = [
"crates/protos", # Protocol buffer definitions
"crates/rio", # Rust I/O utilities and abstractions
"crates/concurrency", # Concurrency management for RustFS - timeout, locking, backpressure, and I/O scheduling
"crates/s3-common", # Common utilities and data structures for S3 compatibility
"crates/s3-types", # S3 event type definitions
"crates/s3-ops", # S3 operation definitions and mapping
"crates/s3select-api", # S3 Select API interface
"crates/s3select-query", # S3 Select query engine
"crates/scanner", # Scanner for data integrity checks and health monitoring
"crates/signer", # client signer
"crates/targets", # Target-specific configurations and utilities
"crates/trusted-proxies", # Trusted proxies management
"crates/tls-runtime", # Project-wide TLS runtime foundation
"crates/utils", # Utility functions and helpers
"crates/workers", # Worker thread pools and task scheduling
"crates/io-metrics", # Zero-copy metrics collection for performance analysis
"crates/io-core", # Zero-copy core reader and writer implementations
"crates/zip", # ZIP file handling and compression
@@ -59,7 +60,7 @@ edition = "2024"
license = "Apache-2.0"
repository = "https://github.com/rustfs/rustfs"
rust-version = "1.95.0"
version = "1.0.0-beta.3"
version = "1.0.0-beta.6"
homepage = "https://rustfs.com"
description = "RustFS is a high-performance distributed object storage software built using Rust, one of the most popular languages worldwide. "
keywords = ["RustFS", "Minio", "object-storage", "filesystem", "s3"]
@@ -76,76 +77,77 @@ redundant_clone = "warn"
[workspace.dependencies]
# RustFS Internal Crates
rustfs = { path = "./rustfs", version = "1.0.0-beta.3" }
rustfs-heal = { path = "crates/heal", version = "1.0.0-beta.3" }
rustfs-appauth = { path = "crates/appauth", version = "1.0.0-beta.3" }
rustfs-audit = { path = "crates/audit", version = "1.0.0-beta.3" }
rustfs-checksums = { path = "crates/checksums", version = "1.0.0-beta.3" }
rustfs-common = { path = "crates/common", version = "1.0.0-beta.3" }
rustfs-config = { path = "./crates/config", version = "1.0.0-beta.3" }
rustfs-concurrency = { path = "./crates/concurrency", version = "1.0.0-beta.3" }
rustfs-credentials = { path = "crates/credentials", version = "1.0.0-beta.3" }
rustfs-crypto = { path = "crates/crypto", version = "1.0.0-beta.3" }
rustfs-ecstore = { path = "crates/ecstore", version = "1.0.0-beta.3" }
rustfs-filemeta = { path = "crates/filemeta", version = "1.0.0-beta.3" }
rustfs-iam = { path = "crates/iam", version = "1.0.0-beta.3" }
rustfs-keystone = { path = "crates/keystone", version = "1.0.0-beta.3" }
rustfs-kms = { path = "crates/kms", version = "1.0.0-beta.3" }
rustfs-lock = { path = "crates/lock", version = "1.0.0-beta.3" }
rustfs-madmin = { path = "crates/madmin", version = "1.0.0-beta.3" }
rustfs-notify = { path = "crates/notify", version = "1.0.0-beta.3" }
rustfs-io-metrics = { path = "crates/io-metrics", version = "1.0.0-beta.3" }
rustfs-io-core = { path = "crates/io-core", version = "1.0.0-beta.3" }
rustfs-object-capacity = { path = "crates/object-capacity", version = "1.0.0-beta.3" }
rustfs-obs = { path = "crates/obs", version = "1.0.0-beta.3" }
rustfs-policy = { path = "crates/policy", version = "1.0.0-beta.3" }
rustfs-protos = { path = "crates/protos", version = "1.0.0-beta.3" }
rustfs-protocols = { path = "crates/protocols", version = "1.0.0-beta.3" }
rustfs-rio = { path = "crates/rio", version = "1.0.0-beta.3" }
rustfs-s3-common = { path = "crates/s3-common", version = "1.0.0-beta.3" }
rustfs-s3select-api = { path = "crates/s3select-api", version = "1.0.0-beta.3" }
rustfs-s3select-query = { path = "crates/s3select-query", version = "1.0.0-beta.3" }
rustfs-scanner = { path = "crates/scanner", version = "1.0.0-beta.3" }
rustfs-signer = { path = "crates/signer", version = "1.0.0-beta.3" }
rustfs-trusted-proxies = { path = "crates/trusted-proxies", version = "1.0.0-beta.3" }
rustfs-targets = { path = "crates/targets", version = "1.0.0-beta.3" }
rustfs-utils = { path = "crates/utils", version = "1.0.0-beta.3" }
rustfs-workers = { path = "crates/workers", version = "1.0.0-beta.3" }
rustfs-zip = { path = "./crates/zip", version = "1.0.0-beta.3" }
rustfs = { path = "./rustfs", version = "1.0.0-beta.6" }
rustfs-heal = { path = "crates/heal", version = "1.0.0-beta.6" }
rustfs-audit = { path = "crates/audit", version = "1.0.0-beta.6" }
rustfs-checksums = { path = "crates/checksums", version = "1.0.0-beta.6" }
rustfs-common = { path = "crates/common", version = "1.0.0-beta.6" }
rustfs-data-usage = { path = "crates/data-usage", version = "1.0.0-beta.6" }
rustfs-config = { path = "./crates/config", version = "1.0.0-beta.6" }
rustfs-concurrency = { path = "./crates/concurrency", version = "1.0.0-beta.6" }
rustfs-credentials = { path = "crates/credentials", version = "1.0.0-beta.6" }
rustfs-crypto = { path = "crates/crypto", version = "1.0.0-beta.6" }
rustfs-ecstore = { path = "crates/ecstore", version = "1.0.0-beta.6" }
rustfs-filemeta = { path = "crates/filemeta", version = "1.0.0-beta.6" }
rustfs-iam = { path = "crates/iam", version = "1.0.0-beta.6" }
rustfs-keystone = { path = "crates/keystone", version = "1.0.0-beta.6" }
rustfs-kms = { path = "crates/kms", version = "1.0.0-beta.6" }
rustfs-lock = { path = "crates/lock", version = "1.0.0-beta.6" }
rustfs-madmin = { path = "crates/madmin", version = "1.0.0-beta.6" }
rustfs-notify = { path = "crates/notify", version = "1.0.0-beta.6" }
rustfs-io-metrics = { path = "crates/io-metrics", version = "1.0.0-beta.6" }
rustfs-io-core = { path = "crates/io-core", version = "1.0.0-beta.6" }
rustfs-object-capacity = { path = "crates/object-capacity", version = "1.0.0-beta.6" }
rustfs-obs = { path = "crates/obs", version = "1.0.0-beta.6" }
rustfs-policy = { path = "crates/policy", version = "1.0.0-beta.6" }
rustfs-protos = { path = "crates/protos", version = "1.0.0-beta.6" }
rustfs-protocols = { path = "crates/protocols", version = "1.0.0-beta.6" }
rustfs-rio = { path = "crates/rio", version = "1.0.0-beta.6" }
rustfs-s3-types = { path = "crates/s3-types", version = "1.0.0-beta.6" }
rustfs-s3-ops = { path = "crates/s3-ops", version = "1.0.0-beta.6" }
rustfs-s3select-api = { path = "crates/s3select-api", version = "1.0.0-beta.6" }
rustfs-s3select-query = { path = "crates/s3select-query", version = "1.0.0-beta.6" }
rustfs-scanner = { path = "crates/scanner", version = "1.0.0-beta.6" }
rustfs-signer = { path = "crates/signer", version = "1.0.0-beta.6" }
rustfs-trusted-proxies = { path = "crates/trusted-proxies", version = "1.0.0-beta.6" }
rustfs-targets = { path = "crates/targets", version = "1.0.0-beta.6" }
rustfs-tls-runtime = { path = "crates/tls-runtime", version = "1.0.0-beta.6" }
rustfs-utils = { path = "crates/utils", version = "1.0.0-beta.6" }
rustfs-zip = { path = "./crates/zip", version = "1.0.0-beta.6" }
# Async Runtime and Networking
async-channel = "2.5.0"
mysql_async = { version = "0.36.1", default-features = false, features = ["default-rustls", "tracing"], git = "https://github.com/blackbeam/mysql_async", rev = "98d3d8067efdf97d3e93cdca7b9231753c904aca" }
mysql_async = { version = "0.37", default-features = false, features = ["default-rustls", "tracing"] }
async-compression = { version = "0.4.42" }
async-recursion = "1.1.1"
async-trait = "0.1.89"
async-nats = "0.48.0"
async-nats = "0.49.0"
axum = "0.8.9"
futures = "0.3.32"
futures-core = "0.3.32"
futures-util = "0.3.32"
pollster = "0.4.0"
pulsar = { version = "6.7.2", default-features = false, features = ["tokio-rustls-runtime"] }
lapin = { version = "4.7.2", default-features = false, features = ["tokio", "rustls", "rustls--aws_lc_rs"] }
hyper = { version = "1.9.0", features = ["http2", "http1", "server"] }
pulsar = { version = "6.8.0", default-features = false, features = ["tokio-rustls-runtime"] }
lapin = { version = "4.10.0", default-features = false, features = ["tokio", "rustls", "rustls--aws_lc_rs"] }
hyper = { version = "1.10.0", features = ["http2", "http1", "server"] }
hyper-rustls = { version = "0.27.9", default-features = false, features = ["native-tokio", "http1", "tls12", "logging", "http2", "aws-lc-rs", "webpki-roots"] }
hyper-util = { version = "0.1.20", features = ["tokio", "server-auto", "server-graceful", "tracing"] }
http = "1.4.0"
http = "1.4.1"
http-body = "1.0.1"
http-body-util = "0.1.3"
reqwest = { version = "0.13.3", default-features = false, features = ["rustls", "charset", "http2", "system-proxy", "stream", "json", "blocking", "query", "form"] }
reqwest = { version = "0.13.4", default-features = false, features = ["rustls", "charset", "http2", "system-proxy", "stream", "json", "blocking", "query", "form"] }
rustfs-kafka-async = { version = "1.2.0" }
socket2 = { version = "0.6.3", features = ["all"] }
socket2 = { version = "0.6.4", features = ["all"] }
tokio = { version = "1.52.3", features = ["fs", "rt-multi-thread"] }
tokio-rustls = { version = "0.26.4", default-features = false, features = ["logging", "tls12", "aws-lc-rs"] }
tokio-stream = { version = "0.1.18" }
tokio-test = "0.4.5"
tokio-util = { version = "0.7.18", features = ["io", "compat"] }
tonic = { version = "0.14.6", features = ["gzip"] }
tonic = { version = "0.14.6", features = ["gzip", "deflate"] }
tonic-prost = { version = "0.14.6" }
tonic-prost-build = { version = "0.14.6" }
tower = { version = "0.5.3", features = ["timeout"] }
tower-http = { version = "0.6.10", features = ["cors"] }
tower-http = { version = "0.6.11", features = ["cors"] }
# Serialization and Data Formats
bytes = { version = "1.11.1", features = ["serde"] }
@@ -154,21 +156,21 @@ byteorder = "1.5.0"
flatbuffers = "25.12.19"
form_urlencoded = "1.2.2"
prost = "0.14.3"
quick-xml = "0.39.4"
quick-xml = "0.40.1"
rmp = { version = "0.8.15" }
rmp-serde = { version = "1.3.1" }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = { version = "1.0.149", features = ["raw_value"] }
serde_json = { version = "1.0.150", features = ["raw_value"] }
serde_urlencoded = "0.7.1"
# Cryptography and Security
aes-gcm = { version = "0.11.0-rc.3", features = ["rand_core"] }
aes-gcm = { version = "0.11.0-rc.4", features = ["rand_core"] }
argon2 = { version = "0.6.0-rc.8" }
blake2 = "0.11.0-rc.6"
chacha20poly1305 = { version = "0.11.0-rc.3" }
crc-fast = "1.9.0"
crc-fast = "1.10.0"
hmac = { version = "0.13.0" }
jsonwebtoken = { version = "10.3.0", features = ["aws_lc_rs"] }
jsonwebtoken = { version = "10.4.0", features = ["aws_lc_rs"] }
openidconnect = { version = "4.0", default-features = false }
pbkdf2 = "0.13.0"
rsa = { version = "0.10.0-rc.18" }
@@ -183,29 +185,28 @@ zeroize = { version = "1.8.2", features = ["derive"] }
# Time and Date
chrono = { version = "0.4.44", features = ["serde"] }
humantime = "2.3.0"
jiff = { version = "0.2.24", features = ["serde"] }
jiff = { version = "0.2.28", features = ["serde"] }
time = { version = "0.3.47", features = ["std", "parsing", "formatting", "macros", "serde"] }
# Database
deadpool-postgres = { version = "0.14", features = ["rt_tokio_1"] }
tokio-postgres = { version = "0.7", default-features = false, features = ["runtime", "with-serde_json-1"] }
tokio-postgres-rustls = "0.13"
tokio-postgres-rustls = "0.14.0"
# Utilities and Tools
anyhow = "1.0.102"
arc-swap = "1.9.1"
astral-tokio-tar = "0.6.1"
astral-tokio-tar = "0.6.2"
atoi = "2.0.0"
atomic_enum = "0.3.0"
aws-config = { version = "1.8.16" }
aws-config = { version = "1.8.17" }
aws-credential-types = { version = "1.2.14" }
aws-sdk-s3 = { version = "1.132.0", default-features = false, features = ["sigv4a", "default-https-client", "rt-tokio"] }
aws-sdk-s3 = { version = "1.134.0", default-features = false, features = ["sigv4a", "default-https-client", "rt-tokio"] }
aws-smithy-http-client = { version = "1.1.12", default-features = false, features = ["default-client", "rustls-aws-lc"] }
aws-smithy-types = { version = "1.4.7" }
backtrace = "0.3.76"
aws-smithy-types = { version = "1.4.8" }
base64 = "0.22.1"
base64-simd = "0.8.0"
brotli = "8.0.2"
brotli = "8.0.3"
clap = { version = "4.6.1", features = ["derive", "env"] }
const-str = { version = "1.1.0", features = ["std", "proc"] }
convert_case = "0.11.0"
@@ -216,7 +217,7 @@ crossbeam-deque = "0.8.6"
crossbeam-utils = "0.8.21"
datafusion = "53.1.0"
derive_builder = "0.20.2"
enumset = "1.1.12"
enumset = "1.1.13"
faster-hex = "0.10.0"
flate2 = "1.1.9"
glob = "0.3.3"
@@ -230,7 +231,7 @@ ipnetwork = { version = "0.21.1", features = ["serde"] }
lazy_static = "1.5.0"
libc = "0.2.186"
libsystemd = "0.7.2"
local-ip-address = "0.6.12"
local-ip-address = "0.6.13"
memmap2 = "0.9.10"
lz4 = "1.28.1"
matchit = "0.9.2"
@@ -254,12 +255,12 @@ rayon = "1.12.0"
reed-solomon-erasure = { version = "6.0", default-features = false, features = ["std", "simd-accel"] }
reed-solomon-simd = "3.1.0"
regex = { version = "1.12.3" }
rumqttc = { package = "rumqttc-next", version = "0.33.0", features = ["websocket"] }
redis = { version = "1.2.1", features = ["connection-manager", "tokio-rustls-comp", "tls-rustls-insecure"] }
rumqttc = { package = "rumqttc-next", version = "0.33.2", features = ["websocket"] }
redis = { version = "1.2.2", features = ["connection-manager", "tokio-rustls-comp", "tls-rustls-insecure"] }
rustix = { version = "1.1.4", features = ["fs"] }
rust-embed = { version = "8.11.0" }
rustc-hash = { version = "2.1.2" }
s3s = { git = "https://github.com/rustfs/s3s", rev = "a3b16608df35aaeed8fff08b4988d03f4ca9445b", features = ["minio"] }
s3s = { git = "https://github.com/rustfs/s3s", rev = "507e1312b211c3ddc214b03875d6fabd15d22ed5", features = ["minio"] }
serial_test = "3.4.0"
shadow-rs = { version = "2.0.0", default-features = false }
siphasher = "1.0.3"
@@ -267,9 +268,9 @@ smallvec = { version = "1.15.1", features = ["serde"] }
smartstring = "1.0.1"
snafu = "0.9.0"
snap = "1.1.1"
starshard = { version = "1.1.0", features = ["rayon", "async", "serde"] }
starshard = { version = "2.2.0", features = ["rayon", "async", "serde"] }
strum = { version = "0.28.0", features = ["derive"] }
sysinfo = "0.39.0"
sysinfo = "0.39.3"
temp-env = "0.3.6"
tempfile = "3.27.0"
test-case = "3.3.1"
@@ -277,7 +278,7 @@ thiserror = "2.0.18"
tracing = { version = "0.1.44" }
tracing-appender = "0.2.5"
tracing-error = "0.2.1"
tracing-opentelemetry = "0.32.1"
tracing-opentelemetry = { version = "0.33" }
tracing-subscriber = { version = "0.3.23", features = ["env-filter", "time"] }
transform-stream = "0.3.1"
url = "2.5.8"
@@ -292,23 +293,23 @@ zip = "8.6.0"
zstd = "0.13.3"
# Observability and Metrics
metrics = "0.24.5"
metrics = "0.24.6"
dial9-tokio-telemetry = "0.3"
opentelemetry = { version = "0.31.0" }
opentelemetry-appender-tracing = { version = "0.31.1", features = ["experimental_use_tracing_span_context", "experimental_metadata_attributes", "spec_unstable_logs_enabled"] }
opentelemetry-otlp = { version = "0.31.1", features = ["gzip-http", "reqwest-rustls"] }
opentelemetry_sdk = { version = "0.31.0" }
opentelemetry-semantic-conventions = { version = "0.31.0", features = ["semconv_experimental"] }
opentelemetry-stdout = { version = "0.31.0" }
pyroscope = { version = "2.0.3", features = ["backend-pprof-rs"] }
opentelemetry = { version = "0.32.0" }
opentelemetry-appender-tracing = { version = "0.32.0", features = ["experimental_span_attributes", "experimental_metadata_attributes"] }
opentelemetry-otlp = { version = "0.32.0", features = ["gzip-http", "reqwest-rustls"] }
opentelemetry_sdk = { version = "0.32.1", features = ["rt-tokio"] }
opentelemetry-semantic-conventions = { version = "0.32.0", features = ["semconv_experimental"] }
opentelemetry-stdout = { version = "0.32.0" }
pyroscope = { version = "2.0.5", features = ["backend-pprof-rs"] }
# FTP and SFTP
libunftp = { version = "0.23.0", features = ["experimental"] }
unftp-core = "0.1.0"
suppaftp = { version = "8.0.3", features = ["tokio", "tokio-rustls-aws-lc-rs"] }
rcgen = "0.14.7"
russh = "0.60.2"
russh-sftp = "2.1.2"
rcgen = "0.14.8"
russh = { version = "0.61.1",features = ["serde"] }
russh-sftp = "2.3.0"
# WebDAV
dav-server = "0.11.0"
@@ -324,17 +325,11 @@ jemalloc_pprof = { version = "0.8.2", features = ["symbolize", "flamegraph"] }
# Used to generate CPU performance analysis data and flame diagrams
# pprof = { version = "0.15.0", features = ["flamegraph", "protobuf-codec"] }
# Pyroscope uses a patched pprof, until they merge back upstream, replace all references. Otherwise, two pprof libs with symbol collision.
pprof = { package = "pprof-pyroscope-fork", version = "0.1500.3", features = ["flamegraph", "protobuf-codec"] }
pprof = { package = "pprof-pyroscope-fork", version = "0.1500.4", features = ["flamegraph", "protobuf-codec"] }
[workspace.metadata.cargo-shear]
ignored = ["rustfs"]
[patch.crates-io]
# Pinned to the simon-escapecode/russh fork carrying the upstream fix at
# https://github.com/Eugeny/russh/pull/702. Drops out when a russh release
# containing the fix is published.
russh = { git = "https://github.com/simon-escapecode/russh", rev = "5cac2ed84945f9b80a52b673e058f2032bbe98ec" }
[profile.release]
opt-level = 3
+1 -1
View File
@@ -12,7 +12,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
FROM rust:1.93-trixie
FROM rust:1.95-trixie
RUN set -eux; \
export DEBIAN_FRONTEND=noninteractive; \
+1 -1
View File
@@ -31,7 +31,7 @@ ARG TARGETARCH
# -----------------------------
# Build stage
# -----------------------------
FROM rust:1.93-trixie AS builder
FROM rust:1.95-trixie AS builder
# Re-declare args after FROM
ARG TARGETPLATFORM
+3 -1
View File
@@ -7,6 +7,7 @@
<a href="https://github.com/rustfs/rustfs/actions/workflows/docker.yml"><img alt="Build and Push Docker Images" src="https://github.com/rustfs/rustfs/actions/workflows/docker.yml/badge.svg" /></a>
<img alt="GitHub commit activity" src="https://img.shields.io/github/commit-activity/m/rustfs/rustfs"/>
<img alt="Github Last Commit" src="https://img.shields.io/github/last-commit/rustfs/rustfs"/>
<a href="https://discord.gg/NcKBCEJp6P"><img alt="Discord" src="https://img.shields.io/badge/Discord-Join%20Chat-5865F2?logo=discord&logoColor=white" /></a>
<a href="https://hellogithub.com/repository/rustfs/rustfs" target="_blank"><img src="https://abroad.hellogithub.com/v1/widgets/recommend.svg?rid=b95bcb72bdc340b68f16fdf6790b7d5b&claim_uid=MsbvjYeLDKAH457&theme=small" alt="FeaturedHelloGitHub" /></a>
</p>
@@ -115,7 +116,7 @@ chown -R 10001:10001 data logs
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:latest
# Using specific version
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.0-beta.3
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.0-beta.6
```
If you use [podman](https://github.com/containers/podman) instead of docker, you can install the RustFS with the below command
@@ -295,6 +296,7 @@ If you have any questions or need assistance:
- [Documentation](https://docs.rustfs.com) - The manual you should read
- [Changelog](https://github.com/rustfs/rustfs/releases) - What we broke and fixed
- [GitHub Discussions](https://github.com/rustfs/rustfs/discussions) - Where the community lives
- [Discord](https://discord.gg/NcKBCEJp6P) - Chat with the RustFS community
## Contact
+2 -2
View File
@@ -1,4 +1,4 @@
[![RustFS](https://github.com/user-attachments/assets/1b5afcd6-a2c3-47ff-8bc3-ce882b0ddca7)](https://rustfs.com.cn)
[![RustFS](https://repository-images.githubusercontent.com/722597620/0fa936a2-8164-4f53-867f-def4beb64b21)](https://rustfs.com.cn)
<p align="center">RustFS 是一个基于 Rust 构建的高性能分布式对象存储系统。</p>
@@ -113,7 +113,7 @@ RustFS 容器以非 root 用户 `rustfs` (UID `10001`) 运行。如果您使用
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:latest
# 使用指定版本运行
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.0-beta.3
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.0-beta.6
```
您也可以使用 Docker Compose。使用根目录下的 `docker-compose.yml` 文件:
+1 -1
View File
@@ -217,7 +217,7 @@ setup_rust_environment() {
# Set up environment variables for musl targets
if [[ "$PLATFORM" == *"musl"* ]]; then
print_message $YELLOW "Setting up environment for musl target..."
export RUSTFLAGS="'--cfg tokio_unstable -C target-feature=-crt-static'"
export RUSTFLAGS="--cfg tokio_unstable -C target-feature=-crt-static"
# For cargo-zigbuild, set up additional environment variables
if command -v cargo-zigbuild &> /dev/null; then
-37
View File
@@ -1,37 +0,0 @@
[![RustFS](https://rustfs.com/images/rustfs-github.png)](https://rustfs.com)
# RustFS AppAuth - Application Authentication
<p align="center">
<strong>Application-level authentication and authorization module for RustFS distributed object storage</strong>
</p>
<p align="center">
<a href="https://github.com/rustfs/rustfs/actions/workflows/ci.yml"><img alt="CI" src="https://github.com/rustfs/rustfs/actions/workflows/ci.yml/badge.svg" /></a>
<a href="https://docs.rustfs.com/">📖 Documentation</a>
· <a href="https://github.com/rustfs/rustfs/issues">🐛 Bug Reports</a>
· <a href="https://github.com/rustfs/rustfs/discussions">💬 Discussions</a>
</p>
---
## 📖 Overview
**RustFS AppAuth** provides application-level authentication and authorization capabilities for the [RustFS](https://rustfs.com) distributed object storage system. For the complete RustFS experience, please visit the [main RustFS repository](https://github.com/rustfs/rustfs).
## ✨ Features
- JWT-based authentication with secure token management
- RBAC (Role-Based Access Control) for fine-grained permissions
- Multi-tenant application isolation and management
- OAuth 2.0 and OpenID Connect integration
- API key management and rotation
- Session management with configurable expiration
## 📚 Documentation
For comprehensive documentation, examples, and usage guides, please visit the main [RustFS repository](https://github.com/rustfs/rustfs).
## 📄 License
This project is licensed under the Apache License 2.0 - see the [LICENSE](../../LICENSE) file for details.
+1 -1
View File
@@ -29,7 +29,7 @@ categories = ["web-programming", "development-tools", "asynchronous", "api-bindi
rustfs-targets = { workspace = true }
rustfs-config = { workspace = true, features = ["audit", "constants"] }
rustfs-ecstore = { workspace = true }
rustfs-s3-common = { workspace = true }
rustfs-s3-types = { workspace = true }
chrono = { workspace = true }
const-str = { workspace = true }
futures = { workspace = true }
+1 -1
View File
@@ -14,7 +14,7 @@
use chrono::{DateTime, Utc};
use hashbrown::HashMap;
use rustfs_s3_common::EventName;
use rustfs_s3_types::EventName;
use serde::{Deserialize, Serialize};
use serde_json::Value;
+1 -1
View File
@@ -23,7 +23,7 @@ homepage.workspace = true
description = "Checksum calculation and verification callbacks for HTTP request and response bodies sent by service clients generated by RustFS, ensuring data integrity and authenticity."
keywords = ["checksum-calculation", "verification", "integrity", "authenticity", "rustfs"]
categories = ["web-programming", "development-tools", "network-programming"]
documentation = "https://docs.rs/rustfs-signer/latest/rustfs_checksum/"
documentation = "https://docs.rs/rustfs-checksums/latest/rustfs_checksum/"
[dependencies]
bytes = { workspace = true }
-4
View File
@@ -33,12 +33,8 @@ tonic = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true }
metrics = { workspace = true }
rustfs-madmin = { workspace = true }
rustfs-filemeta = { workspace = true }
serde = { workspace = true }
path-clean = { workspace = true }
rmp-serde = { workspace = true }
async-trait = { workspace = true }
s3s = { workspace = true }
tracing = { workspace = true }
+12
View File
@@ -17,6 +17,7 @@
use chrono::{DateTime, Utc};
use std::collections::HashMap;
use std::sync::LazyLock;
use std::sync::atomic::{AtomicU64, Ordering};
use tokio::sync::RwLock;
use tonic::transport::Channel;
@@ -27,6 +28,7 @@ pub static GLOBAL_RUSTFS_ADDR: LazyLock<RwLock<String>> = LazyLock::new(|| RwLoc
pub static GLOBAL_CONN_MAP: LazyLock<RwLock<HashMap<String, Channel>>> = LazyLock::new(|| RwLock::new(HashMap::new()));
pub static GLOBAL_ROOT_CERT: LazyLock<RwLock<Option<Vec<u8>>>> = LazyLock::new(|| RwLock::new(None));
pub static GLOBAL_MTLS_IDENTITY: LazyLock<RwLock<Option<MtlsIdentityPem>>> = LazyLock::new(|| RwLock::new(None));
pub static GLOBAL_OUTBOUND_TLS_GENERATION: LazyLock<AtomicU64> = LazyLock::new(|| AtomicU64::new(0));
/// Global initialization time of the RustFS node.
pub static GLOBAL_INIT_TIME: LazyLock<RwLock<Option<DateTime<Utc>>>> = LazyLock::new(|| RwLock::new(None));
@@ -88,6 +90,16 @@ pub async fn set_global_mtls_identity(identity: Option<MtlsIdentityPem>) {
*GLOBAL_MTLS_IDENTITY.write().await = identity;
}
/// Set the global outbound TLS generation.
pub fn set_global_outbound_tls_generation(generation: u64) {
GLOBAL_OUTBOUND_TLS_GENERATION.store(generation, Ordering::Relaxed);
}
/// Get the global outbound TLS generation.
pub fn get_global_outbound_tls_generation() -> u64 {
GLOBAL_OUTBOUND_TLS_GENERATION.load(Ordering::Relaxed)
}
/// Evict a stale/dead connection from the global connection cache.
/// This is critical for cluster recovery when a node dies unexpectedly (e.g., power-off).
/// By removing the cached connection, subsequent requests will establish a fresh connection.
+44 -7
View File
@@ -260,9 +260,17 @@ pub enum HealChannelCommand {
response_tx: oneshot::Sender<Result<HealAdmissionResult, String>>,
},
/// Query heal task status
Query { heal_path: String, client_token: String },
Query {
heal_path: String,
client_token: String,
response_tx: oneshot::Sender<Result<HealChannelResponse, String>>,
},
/// Cancel heal task
Cancel { heal_path: String },
Cancel {
heal_path: String,
client_token: String,
response_tx: oneshot::Sender<Result<HealChannelResponse, String>>,
},
}
/// Heal request from admin to ahm
@@ -379,7 +387,9 @@ fn heal_response_sender() -> &'static HealResponseSender {
/// Publish a heal response to subscribers.
pub fn publish_heal_response(response: HealChannelResponse) -> Result<(), broadcast::error::SendError<HealChannelResponse>> {
heal_response_sender().send(response).map(|_| ())
let sender = heal_response_sender();
let _ = sender.send(response);
Ok(())
}
/// Subscribe to heal responses.
@@ -405,14 +415,36 @@ pub async fn send_heal_request(request: HealChannelRequest) -> Result<(), String
}
}
async fn receive_heal_channel_response(
response_rx: oneshot::Receiver<Result<HealChannelResponse, String>>,
) -> Result<HealChannelResponse, String> {
response_rx
.await
.map_err(|e| format!("Failed to receive heal channel response: {e}"))?
}
/// Send heal query request
pub async fn query_heal_status(heal_path: String, client_token: String) -> Result<(), String> {
send_heal_command(HealChannelCommand::Query { heal_path, client_token }).await
pub async fn query_heal_status(heal_path: String, client_token: String) -> Result<HealChannelResponse, String> {
let (response_tx, response_rx) = oneshot::channel();
send_heal_command(HealChannelCommand::Query {
heal_path,
client_token,
response_tx,
})
.await?;
receive_heal_channel_response(response_rx).await
}
/// Send heal cancel request
pub async fn cancel_heal_task(heal_path: String) -> Result<(), String> {
send_heal_command(HealChannelCommand::Cancel { heal_path }).await
pub async fn cancel_heal_task(heal_path: String, client_token: String) -> Result<HealChannelResponse, String> {
let (response_tx, response_rx) = oneshot::channel();
send_heal_command(HealChannelCommand::Cancel {
heal_path,
client_token,
response_tx,
})
.await?;
receive_heal_channel_response(response_rx).await
}
/// Create a new heal request
@@ -626,5 +658,10 @@ mod tests {
let received = receiver.recv().await.expect("should receive heal response");
assert_eq!(received.request_id, response.request_id);
assert!(received.success);
drop(receiver);
let response = create_heal_response("req-no-subscriber".to_string(), true, None, None);
publish_heal_response(response).expect("publish without subscribers should be ignored");
}
}
-166
View File
@@ -1,166 +0,0 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use metrics::{counter, gauge};
use std::sync::{
Arc, LazyLock,
atomic::{AtomicU64, Ordering},
};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct InternodeMetricsSnapshot {
pub sent_bytes_total: u64,
pub recv_bytes_total: u64,
pub outgoing_requests_total: u64,
pub incoming_requests_total: u64,
pub errors_total: u64,
pub dial_errors_total: u64,
pub dial_avg_time_nanos: u64,
pub last_dial_unix_millis: u64,
}
#[derive(Debug, Default)]
pub struct InternodeMetrics {
sent_bytes_total: AtomicU64,
recv_bytes_total: AtomicU64,
outgoing_requests_total: AtomicU64,
incoming_requests_total: AtomicU64,
errors_total: AtomicU64,
dial_errors_total: AtomicU64,
dial_total_time_nanos: AtomicU64,
dial_samples_total: AtomicU64,
last_dial_unix_millis: AtomicU64,
}
impl InternodeMetrics {
pub fn record_sent_bytes(&self, bytes: usize) {
let bytes = bytes as u64;
if bytes == 0 {
return;
}
self.sent_bytes_total.fetch_add(bytes, Ordering::Relaxed);
counter!("rustfs_system_network_internode_sent_bytes_total").increment(bytes);
}
pub fn record_recv_bytes(&self, bytes: usize) {
let bytes = bytes as u64;
if bytes == 0 {
return;
}
self.recv_bytes_total.fetch_add(bytes, Ordering::Relaxed);
counter!("rustfs_system_network_internode_recv_bytes_total").increment(bytes);
}
pub fn record_outgoing_request(&self) {
self.outgoing_requests_total.fetch_add(1, Ordering::Relaxed);
counter!("rustfs_system_network_internode_requests_outgoing_total").increment(1);
}
pub fn record_incoming_request(&self) {
self.incoming_requests_total.fetch_add(1, Ordering::Relaxed);
counter!("rustfs_system_network_internode_requests_incoming_total").increment(1);
}
pub fn record_error(&self) {
self.errors_total.fetch_add(1, Ordering::Relaxed);
counter!("rustfs_system_network_internode_errors_total").increment(1);
}
pub fn record_dial_result(&self, duration: Duration, success: bool) {
let elapsed_nanos = duration.as_nanos().min(u128::from(u64::MAX)) as u64;
self.dial_total_time_nanos.fetch_add(elapsed_nanos, Ordering::Relaxed);
let samples = self.dial_samples_total.fetch_add(1, Ordering::Relaxed) + 1;
let total = self.dial_total_time_nanos.load(Ordering::Relaxed);
gauge!("rustfs_system_network_internode_dial_avg_time_nanos").set(total as f64 / samples as f64);
if !success {
self.dial_errors_total.fetch_add(1, Ordering::Relaxed);
counter!("rustfs_system_network_internode_dial_errors_total").increment(1);
}
let now_ms = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis()
.min(u128::from(u64::MAX)) as u64;
self.last_dial_unix_millis.store(now_ms, Ordering::Relaxed);
}
pub fn snapshot(&self) -> InternodeMetricsSnapshot {
let dial_samples_total = self.dial_samples_total.load(Ordering::Relaxed);
let dial_total_time_nanos = self.dial_total_time_nanos.load(Ordering::Relaxed);
let dial_avg_time_nanos = dial_total_time_nanos.checked_div(dial_samples_total).unwrap_or(0);
InternodeMetricsSnapshot {
sent_bytes_total: self.sent_bytes_total.load(Ordering::Relaxed),
recv_bytes_total: self.recv_bytes_total.load(Ordering::Relaxed),
outgoing_requests_total: self.outgoing_requests_total.load(Ordering::Relaxed),
incoming_requests_total: self.incoming_requests_total.load(Ordering::Relaxed),
errors_total: self.errors_total.load(Ordering::Relaxed),
dial_errors_total: self.dial_errors_total.load(Ordering::Relaxed),
dial_avg_time_nanos,
last_dial_unix_millis: self.last_dial_unix_millis.load(Ordering::Relaxed),
}
}
#[doc(hidden)]
pub fn reset_for_test(&self) {
self.sent_bytes_total.store(0, Ordering::Relaxed);
self.recv_bytes_total.store(0, Ordering::Relaxed);
self.outgoing_requests_total.store(0, Ordering::Relaxed);
self.incoming_requests_total.store(0, Ordering::Relaxed);
self.errors_total.store(0, Ordering::Relaxed);
self.dial_errors_total.store(0, Ordering::Relaxed);
self.dial_total_time_nanos.store(0, Ordering::Relaxed);
self.dial_samples_total.store(0, Ordering::Relaxed);
self.last_dial_unix_millis.store(0, Ordering::Relaxed);
}
}
pub fn global_internode_metrics() -> &'static Arc<InternodeMetrics> {
static GLOBAL_INTERNODE_METRICS: LazyLock<Arc<InternodeMetrics>> = LazyLock::new(|| Arc::new(InternodeMetrics::default()));
&GLOBAL_INTERNODE_METRICS
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn snapshot_reports_recorded_values() {
let metrics = global_internode_metrics();
metrics.reset_for_test();
metrics.record_sent_bytes(64);
metrics.record_recv_bytes(32);
metrics.record_outgoing_request();
metrics.record_incoming_request();
metrics.record_error();
metrics.record_dial_result(Duration::from_millis(9), true);
metrics.record_dial_result(Duration::from_millis(3), false);
let snapshot = metrics.snapshot();
assert_eq!(snapshot.sent_bytes_total, 64);
assert_eq!(snapshot.recv_bytes_total, 32);
assert_eq!(snapshot.outgoing_requests_total, 1);
assert_eq!(snapshot.incoming_requests_total, 1);
assert_eq!(snapshot.errors_total, 1);
assert_eq!(snapshot.dial_errors_total, 1);
assert_eq!(snapshot.dial_avg_time_nanos, 6_000_000);
assert!(snapshot.last_dial_unix_millis > 0);
metrics.reset_for_test();
}
}
-3
View File
@@ -13,12 +13,9 @@
// limitations under the License.
pub mod bucket_stats;
pub mod capacity_scope;
// pub mod error;
pub mod data_usage;
pub mod globals;
pub mod heal_channel;
pub mod internode_metrics;
pub mod last_minute;
pub mod metrics;
mod readiness;
+30 -5
View File
@@ -14,7 +14,6 @@
use crate::last_minute::{AccElem, LastMinuteLatency};
use chrono::{DateTime, Utc};
use rustfs_madmin::metrics::{ScannerMetrics as M_ScannerMetrics, TimedAction};
use serde::{Deserialize, Serialize};
use std::{
collections::HashMap,
@@ -351,6 +350,32 @@ pub struct CurrentCycle {
pub started: DateTime<Utc>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct ScannerTimedAction {
pub count: u64,
pub acc_time: u64,
pub bytes: u64,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct ScannerLastMinute {
pub actions: HashMap<String, ScannerTimedAction>,
pub ilm: HashMap<String, ScannerTimedAction>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct ScannerMetricsReport {
pub collected_at: DateTime<Utc>,
pub current_cycle: u64,
pub current_started: DateTime<Utc>,
pub cycles_completed_at: Vec<DateTime<Utc>>,
pub ongoing_buckets: usize,
pub life_time_ops: HashMap<String, u64>,
pub life_time_ilm: HashMap<String, u64>,
pub last_minute: ScannerLastMinute,
pub active_paths: Vec<String>,
}
impl CurrentCycle {
pub fn unmarshal(&mut self, buf: &[u8]) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
*self = rmp_serde::from_slice(buf)?;
@@ -572,8 +597,8 @@ impl Metrics {
}
/// Build a full metrics report snapshot.
pub async fn report(&self) -> M_ScannerMetrics {
let mut m = M_ScannerMetrics::default();
pub async fn report(&self) -> ScannerMetricsReport {
let mut m = ScannerMetricsReport::default();
if let Some(cycle) = self.get_cycle().await {
m.current_cycle = cycle.current;
@@ -606,7 +631,7 @@ impl Metrics {
{
m.last_minute.actions.insert(
metric.as_str().to_string(),
TimedAction {
ScannerTimedAction {
count: last_min.n,
acc_time: last_min.total,
bytes: last_min.size,
@@ -633,7 +658,7 @@ impl Metrics {
{
m.last_minute.ilm.insert(
action.as_str().to_string(),
TimedAction {
ScannerTimedAction {
count: last_min.n,
acc_time: last_min.total,
bytes: last_min.size,
+72 -51
View File
@@ -14,15 +14,17 @@
//! Backpressure management
use rustfs_io_core::{BackpressureMonitor as CoreBackpressureMonitor, BackpressureState};
use rustfs_io_core::{
BackpressureConfig as CoreBackpressureConfig, BackpressureMonitor as CoreBackpressureMonitor, BackpressureState,
};
use rustfs_io_metrics::backpressure_metrics;
use std::sync::Arc;
use std::time::Instant;
use tokio::io::{DuplexStream, duplex};
/// Backpressure configuration
#[derive(Debug, Clone)]
pub struct BackpressureConfig {
/// Facade policy for duplex-pipe watermark backpressure.
#[derive(Debug, Clone, Copy)]
pub struct PipeBackpressurePolicy {
/// Buffer size in bytes
pub buffer_size: usize,
/// High watermark percentage
@@ -31,7 +33,7 @@ pub struct BackpressureConfig {
pub low_watermark: u32,
}
impl Default for BackpressureConfig {
impl Default for PipeBackpressurePolicy {
fn default() -> Self {
Self {
buffer_size: 4 * 1024 * 1024, // 4MB
@@ -41,7 +43,7 @@ impl Default for BackpressureConfig {
}
}
impl BackpressureConfig {
impl PipeBackpressurePolicy {
/// Calculate high watermark threshold in bytes
pub fn high_watermark_bytes(&self) -> usize {
(self.buffer_size as u64 * self.high_watermark as u64 / 100) as usize
@@ -51,42 +53,59 @@ impl BackpressureConfig {
pub fn low_watermark_bytes(&self) -> usize {
(self.buffer_size as u64 * self.low_watermark as u64 / 100) as usize
}
/// Convert the facade policy into the reusable io-core admission-pressure config.
///
/// The concurrency layer still owns duplex buffer sizing, but the shared
/// overload/admission primitive lives in `io-core`.
pub fn to_core_config(&self) -> CoreBackpressureConfig {
CoreBackpressureConfig {
max_concurrent: 32,
high_water_mark: self.high_watermark as f64 / 100.0,
low_water_mark: self.low_watermark as f64 / 100.0,
cooldown: std::time::Duration::from_millis(100),
enabled: true,
}
}
}
/// Backpressure manager
pub struct BackpressureManager {
config: BackpressureConfig,
config: PipeBackpressurePolicy,
core_config: CoreBackpressureConfig,
monitor: Arc<CoreBackpressureMonitor>,
}
impl BackpressureManager {
/// Create a new backpressure manager
pub fn new(buffer_size: usize, high_watermark: u32, low_watermark: u32) -> Self {
let config = BackpressureConfig {
Self::from_policy(PipeBackpressurePolicy {
buffer_size,
high_watermark,
low_watermark,
};
let core_config = rustfs_io_core::BackpressureConfig {
max_concurrent: 32,
high_water_mark: high_watermark as f64 / 100.0,
low_water_mark: low_watermark as f64 / 100.0,
cooldown: std::time::Duration::from_millis(100),
enabled: true,
};
})
}
/// Create a new backpressure manager from the facade policy type.
pub fn from_policy(config: PipeBackpressurePolicy) -> Self {
let core_config = config.to_core_config();
Self {
config,
core_config: core_config.clone(),
monitor: Arc::new(CoreBackpressureMonitor::new(core_config)),
}
}
/// Get the configuration
pub fn config(&self) -> &BackpressureConfig {
pub fn config(&self) -> &PipeBackpressurePolicy {
&self.config
}
/// Get the derived io-core admission-pressure configuration.
pub fn core_config(&self) -> &CoreBackpressureConfig {
&self.core_config
}
/// Get the monitor
pub fn monitor(&self) -> Arc<CoreBackpressureMonitor> {
self.monitor.clone()
@@ -94,7 +113,7 @@ impl BackpressureManager {
/// Create a backpressure pipe
pub fn create_pipe(&self) -> BackpressurePipe {
BackpressurePipe::new(self.config.clone(), self.monitor.clone())
BackpressurePipe::new(self.config, self.monitor.clone())
}
/// Get current state
@@ -112,13 +131,24 @@ impl BackpressureManager {
pub struct BackpressurePipe {
reader: DuplexStream,
writer: DuplexStream,
config: BackpressureConfig,
config: PipeBackpressurePolicy,
monitor: Arc<CoreBackpressureMonitor>,
created_at: Instant,
}
/// Shared pipe metadata snapshot for facade-level backpressure pipes.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BackpressurePipeMeta {
/// Configured duplex buffer capacity in bytes.
pub buffer_capacity: usize,
/// Current backpressure state reported by the shared core monitor.
pub state: BackpressureState,
/// Age of the pipe since creation.
pub age: std::time::Duration,
}
impl BackpressurePipe {
fn new(config: BackpressureConfig, monitor: Arc<CoreBackpressureMonitor>) -> Self {
fn new(config: PipeBackpressurePolicy, monitor: Arc<CoreBackpressureMonitor>) -> Self {
let (reader, writer) = duplex(config.buffer_size);
Self {
@@ -146,7 +176,7 @@ impl BackpressurePipe {
}
/// Get the configuration
pub fn config(&self) -> &BackpressureConfig {
pub fn config(&self) -> &PipeBackpressurePolicy {
&self.config
}
@@ -160,6 +190,15 @@ impl BackpressurePipe {
self.created_at.elapsed()
}
/// Get a compact metadata snapshot for the pipe.
pub fn meta(&self) -> BackpressurePipeMeta {
BackpressurePipeMeta {
buffer_capacity: self.config.buffer_size,
state: self.state(),
age: self.age(),
}
}
/// Check if should apply backpressure
pub fn should_apply_backpressure(&self) -> bool {
let should = self.monitor.should_apply_backpressure();
@@ -170,45 +209,26 @@ impl BackpressurePipe {
}
}
/// Backpressure event
#[allow(dead_code)]
#[derive(Debug, Clone)]
pub struct BackpressureEvent {
/// Event timestamp
pub timestamp: Instant,
/// Event type
pub event_type: BackpressureEventType,
/// Buffer usage
pub buffer_usage: usize,
/// Buffer capacity
pub buffer_capacity: usize,
}
/// Backpressure event type
#[allow(dead_code)]
#[derive(Debug, Clone, Copy)]
pub enum BackpressureEventType {
/// High watermark reached
HighWatermarkReached,
/// High watermark exited
HighWatermarkExited,
/// Backpressure applied
BackpressureApplied,
/// Backpressure released
BackpressureReleased,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_backpressure_config() {
let config = BackpressureConfig::default();
let config = PipeBackpressurePolicy::default();
assert_eq!(config.buffer_size, 4 * 1024 * 1024);
assert!(config.high_watermark > config.low_watermark);
}
#[test]
fn test_backpressure_policy_to_core_config() {
let policy = PipeBackpressurePolicy::default();
let core = policy.to_core_config();
assert_eq!(core.high_water_mark, policy.high_watermark as f64 / 100.0);
assert_eq!(core.low_water_mark, policy.low_watermark as f64 / 100.0);
assert!(core.enabled);
}
#[test]
fn test_backpressure_manager() {
let manager = BackpressureManager::new(1024, 80, 50);
@@ -220,5 +240,6 @@ mod tests {
let manager = BackpressureManager::new(1024, 80, 50);
let pipe = manager.create_pipe();
assert_eq!(pipe.state(), BackpressureState::Normal);
assert_eq!(pipe.meta().buffer_capacity, 1024);
}
}
+70 -84
View File
@@ -14,6 +14,10 @@
//! Configuration for concurrency management
use crate::{
backpressure::PipeBackpressurePolicy, deadlock::DeadlockMonitorPolicy, scheduler::SchedulerPolicy,
timeout::TimeoutManagerPolicy,
};
use std::time::Duration;
/// Feature flags for concurrency modules
@@ -72,84 +76,39 @@ impl ConcurrencyFeatures {
}
}
/// Facade policy for lock manager behavior.
#[derive(Debug, Clone, Copy)]
pub struct LockManagerPolicy {
/// Enable lock optimization.
pub enabled: bool,
/// Lock acquisition timeout.
pub acquire_timeout: Duration,
}
impl Default for LockManagerPolicy {
fn default() -> Self {
Self {
enabled: true,
acquire_timeout: Duration::from_secs(5),
}
}
}
/// Main configuration for concurrency management
#[derive(Debug, Clone)]
#[derive(Debug, Clone, Default)]
pub struct ConcurrencyConfig {
/// Feature flags
pub features: ConcurrencyFeatures,
// Timeout configuration
/// Default timeout duration
pub default_timeout: Duration,
/// Maximum timeout duration
pub max_timeout: Duration,
/// Enable dynamic timeout
pub enable_dynamic_timeout: bool,
// Lock configuration
/// Enable lock optimization
pub enable_lock_optimization: bool,
/// Lock acquisition timeout
pub lock_acquire_timeout: Duration,
// Deadlock configuration
/// Enable deadlock detection
pub enable_deadlock_detection: bool,
/// Deadlock check interval
pub deadlock_check_interval: Duration,
/// Hang threshold
pub hang_threshold: Duration,
// Backpressure configuration
/// Buffer size for backpressure
pub backpressure_buffer_size: usize,
/// High watermark percentage
pub high_watermark: u32,
/// Low watermark percentage
pub low_watermark: u32,
// Scheduler configuration
/// Base buffer size for I/O
pub io_buffer_size: usize,
/// Maximum buffer size
pub max_buffer_size: usize,
/// High priority size threshold
pub high_priority_threshold: usize,
/// Low priority size threshold
pub low_priority_threshold: usize,
}
impl Default for ConcurrencyConfig {
fn default() -> Self {
Self {
features: ConcurrencyFeatures::default(),
// Timeout defaults
default_timeout: Duration::from_secs(30),
max_timeout: Duration::from_secs(300),
enable_dynamic_timeout: true,
// Lock defaults
enable_lock_optimization: true,
lock_acquire_timeout: Duration::from_secs(5),
// Deadlock defaults
enable_deadlock_detection: false,
deadlock_check_interval: Duration::from_secs(10),
hang_threshold: Duration::from_secs(60),
// Backpressure defaults
backpressure_buffer_size: 4 * 1024 * 1024, // 4MB
high_watermark: 80,
low_watermark: 50,
// Scheduler defaults
io_buffer_size: 64 * 1024, // 64KB
max_buffer_size: 4 * 1024 * 1024, // 4MB
high_priority_threshold: 1024 * 1024, // 1MB
low_priority_threshold: 10 * 1024 * 1024, // 10MB
}
}
/// Timeout facade policy.
pub timeout_policy: TimeoutManagerPolicy,
/// Lock facade policy.
pub lock_policy: LockManagerPolicy,
/// Deadlock facade policy.
pub deadlock_policy: DeadlockMonitorPolicy,
/// Backpressure facade policy.
pub backpressure_policy: PipeBackpressurePolicy,
/// Scheduler facade policy.
pub scheduler_policy: SchedulerPolicy,
}
impl ConcurrencyConfig {
@@ -161,25 +120,25 @@ impl ConcurrencyConfig {
if let Ok(val) = std::env::var("RUSTFS_TIMEOUT_DEFAULT")
&& let Ok(secs) = val.parse::<u64>()
{
config.default_timeout = Duration::from_secs(secs);
config.timeout_policy.default_timeout = Duration::from_secs(secs);
}
if let Ok(val) = std::env::var("RUSTFS_TIMEOUT_MAX")
&& let Ok(secs) = val.parse::<u64>()
{
config.max_timeout = Duration::from_secs(secs);
config.timeout_policy.max_timeout = Duration::from_secs(secs);
}
if let Ok(val) = std::env::var("RUSTFS_BACKPRESSURE_BUFFER_SIZE")
&& let Ok(size) = val.parse::<usize>()
{
config.backpressure_buffer_size = size;
config.backpressure_policy.buffer_size = size;
}
if let Ok(val) = std::env::var("RUSTFS_IO_BUFFER_SIZE")
&& let Ok(size) = val.parse::<usize>()
{
config.io_buffer_size = size;
config.scheduler_policy.base_buffer_size = size;
}
config
@@ -187,18 +146,25 @@ impl ConcurrencyConfig {
/// Validate configuration
pub fn validate(&self) -> Result<(), ConfigError> {
if self.default_timeout > self.max_timeout {
if self.timeout_policy.default_timeout > self.timeout_policy.max_timeout {
return Err(ConfigError::InvalidTimeout("default_timeout cannot exceed max_timeout".to_string()));
}
if self.timeout_policy.min_timeout > self.timeout_policy.max_timeout {
return Err(ConfigError::InvalidTimeout("min_timeout cannot exceed max_timeout".to_string()));
}
if self.high_watermark <= self.low_watermark || self.high_watermark > 100 {
if self.backpressure_policy.high_watermark <= self.backpressure_policy.low_watermark
|| self.backpressure_policy.high_watermark > 100
{
return Err(ConfigError::InvalidBackpressure(
"high_watermark must be > low_watermark and <= 100".to_string(),
));
}
if self.io_buffer_size > self.max_buffer_size {
return Err(ConfigError::InvalidScheduler("io_buffer_size cannot exceed max_buffer_size".to_string()));
if self.scheduler_policy.base_buffer_size > self.scheduler_policy.max_buffer_size {
return Err(ConfigError::InvalidScheduler(
"base_buffer_size cannot exceed max_buffer_size".to_string(),
));
}
Ok(())
@@ -235,8 +201,12 @@ mod tests {
#[test]
fn test_invalid_timeout() {
let config = ConcurrencyConfig {
default_timeout: Duration::from_secs(100),
max_timeout: Duration::from_secs(50),
timeout_policy: TimeoutManagerPolicy {
default_timeout: Duration::from_secs(100),
max_timeout: Duration::from_secs(50),
enable_dynamic: true,
..Default::default()
},
..Default::default()
};
assert!(
@@ -245,6 +215,22 @@ mod tests {
);
}
#[test]
fn test_invalid_min_timeout() {
let config = ConcurrencyConfig {
timeout_policy: TimeoutManagerPolicy {
min_timeout: Duration::from_secs(100),
max_timeout: Duration::from_secs(50),
..Default::default()
},
..Default::default()
};
assert!(
config.validate().is_err(),
"validate() should return an error when min_timeout > max_timeout"
);
}
#[test]
fn test_features() {
let features = ConcurrencyFeatures::all();
+47 -17
View File
@@ -14,15 +14,15 @@
//! Deadlock detection management
use rustfs_io_core::{DeadlockDetector as CoreDeadlockDetector, LockType};
use rustfs_io_core::{DeadlockDetector as CoreDeadlockDetector, DeadlockDetectorConfig as CoreDeadlockConfig, LockType};
use rustfs_io_metrics::deadlock_metrics;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::{Duration, Instant};
/// Deadlock configuration
#[derive(Debug, Clone)]
pub struct DeadlockConfig {
/// Facade policy for the concurrency-layer deadlock monitor.
#[derive(Debug, Clone, Copy)]
pub struct DeadlockMonitorPolicy {
/// Enable deadlock detection
pub enabled: bool,
/// Check interval
@@ -31,7 +31,7 @@ pub struct DeadlockConfig {
pub hang_threshold: Duration,
}
impl Default for DeadlockConfig {
impl Default for DeadlockMonitorPolicy {
fn default() -> Self {
Self {
enabled: false,
@@ -41,9 +41,20 @@ impl Default for DeadlockConfig {
}
}
impl DeadlockMonitorPolicy {
/// Convert the facade policy into the reusable io-core deadlock config.
pub fn to_core_config(&self) -> CoreDeadlockConfig {
CoreDeadlockConfig {
enabled: self.enabled,
detection_interval: self.check_interval,
max_hold_time: self.hang_threshold,
}
}
}
/// Deadlock manager
pub struct DeadlockManager {
config: DeadlockConfig,
config: DeadlockMonitorPolicy,
detector: Arc<CoreDeadlockDetector>,
running: Arc<tokio::sync::Mutex<bool>>,
}
@@ -51,18 +62,16 @@ pub struct DeadlockManager {
impl DeadlockManager {
/// Create a new deadlock manager
pub fn new(enabled: bool, check_interval: Duration, hang_threshold: Duration) -> Self {
let config = DeadlockConfig {
Self::from_policy(DeadlockMonitorPolicy {
enabled,
check_interval,
hang_threshold,
};
let core_config = rustfs_io_core::DeadlockDetectorConfig {
enabled,
detection_interval: check_interval,
max_hold_time: hang_threshold,
};
})
}
/// Create a new deadlock manager from the facade policy type.
pub fn from_policy(config: DeadlockMonitorPolicy) -> Self {
let core_config = config.to_core_config();
Self {
config,
detector: Arc::new(CoreDeadlockDetector::new(core_config)),
@@ -71,7 +80,7 @@ impl DeadlockManager {
}
/// Get the configuration
pub fn config(&self) -> &DeadlockConfig {
pub fn config(&self) -> &DeadlockMonitorPolicy {
&self.config
}
@@ -129,7 +138,11 @@ impl DeadlockManager {
}
}
/// Request tracker for tracking resources
/// Lightweight compatibility wrapper for request-scoped deadlock bookkeeping.
///
/// This type intentionally stays minimal in the concurrency layer. Rich
/// request-level lock/resource diagnostics belong to
/// `rustfs::storage::deadlock_detector::RequestResourceTracker`.
pub struct RequestTracker {
request_id: String,
description: String,
@@ -174,6 +187,11 @@ impl RequestTracker {
deadlock_metrics::record_lock_acquisition("read");
}
/// Return a read-only view of tracked resource names.
pub fn resources(&self) -> &HashMap<String, Vec<String>> {
&self.resources
}
/// Record a lock release
pub fn record_lock_release(&mut self, lock_id: u64) {
self.detector.record_release(lock_id);
@@ -196,12 +214,24 @@ mod tests {
assert!(!manager.config().enabled);
}
#[test]
fn test_deadlock_policy_to_core_config() {
let policy = DeadlockMonitorPolicy::default();
let core = policy.to_core_config();
assert_eq!(core.enabled, policy.enabled);
assert_eq!(core.detection_interval, policy.check_interval);
assert_eq!(core.max_hold_time, policy.hang_threshold);
}
#[tokio::test]
async fn test_request_tracker() {
let manager = DeadlockManager::new(true, Duration::from_secs(10), Duration::from_secs(60));
let tracker = manager.track_request("req-1".to_string(), "test request".to_string());
let mut tracker = manager.track_request("req-1".to_string(), "test request".to_string());
let lock_id = manager.register_lock(LockType::Mutex);
tracker.record_lock_acquire(lock_id, "bucket/key".to_string());
assert_eq!(tracker.request_id(), "req-1");
assert_eq!(tracker.description(), "test request");
assert_eq!(tracker.resources().get("locks").map(Vec::len), Some(1));
}
}
+10 -8
View File
@@ -124,21 +124,23 @@ mod backpressure;
#[cfg(feature = "scheduler")]
mod scheduler;
pub mod workers;
// Public module exports with feature gates
#[cfg(feature = "timeout")]
pub use timeout::{TimeoutConfig, TimeoutGuard, TimeoutManager};
pub use timeout::{TimeoutGuard, TimeoutManager, TimeoutManagerPolicy};
#[cfg(feature = "lock")]
pub use lock::{LockConfig, LockManager, LockScopeGuard, OptimizedLockGuard};
#[cfg(feature = "deadlock")]
pub use deadlock::{DeadlockConfig, DeadlockManager, RequestTracker};
pub use deadlock::{DeadlockManager, DeadlockMonitorPolicy, RequestTracker};
#[cfg(feature = "backpressure")]
pub use backpressure::{BackpressureConfig, BackpressureManager, BackpressurePipe};
pub use backpressure::{BackpressureManager, BackpressurePipe, PipeBackpressurePolicy};
#[cfg(feature = "scheduler")]
pub use scheduler::{IoStrategy, SchedulerConfig, SchedulerManager};
pub use scheduler::{IoStrategy, SchedulerManager, SchedulerPolicy};
// Configuration
mod config;
@@ -153,19 +155,19 @@ pub mod prelude {
//! Prelude module for convenient imports
#[cfg(feature = "timeout")]
pub use crate::timeout::{TimeoutConfig, TimeoutGuard, TimeoutManager};
pub use crate::timeout::{TimeoutGuard, TimeoutManager, TimeoutManagerPolicy};
#[cfg(feature = "lock")]
pub use crate::lock::{LockConfig, LockManager, LockScopeGuard, OptimizedLockGuard};
#[cfg(feature = "deadlock")]
pub use crate::deadlock::{DeadlockConfig, DeadlockManager, RequestTracker};
pub use crate::deadlock::{DeadlockManager, DeadlockMonitorPolicy, RequestTracker};
#[cfg(feature = "backpressure")]
pub use crate::backpressure::{BackpressureConfig, BackpressureManager, BackpressurePipe};
pub use crate::backpressure::{BackpressureManager, BackpressurePipe, PipeBackpressurePolicy};
#[cfg(feature = "scheduler")]
pub use crate::scheduler::{IoStrategy, SchedulerConfig, SchedulerManager};
pub use crate::scheduler::{IoStrategy, SchedulerManager, SchedulerPolicy};
pub use crate::{ConcurrencyConfig, ConcurrencyFeatures, ConcurrencyManager};
}
+7 -24
View File
@@ -85,39 +85,22 @@ impl ConcurrencyManager {
Self {
#[cfg(feature = "timeout")]
timeout: Arc::new(crate::timeout::TimeoutManager::new(
config.default_timeout,
config.max_timeout,
config.enable_dynamic_timeout,
)),
timeout: Arc::new(crate::timeout::TimeoutManager::from_policy(config.timeout_policy)),
#[cfg(feature = "lock")]
lock: Arc::new(crate::lock::LockManager::new(
config.enable_lock_optimization,
config.lock_acquire_timeout,
config.lock_policy.enabled,
config.lock_policy.acquire_timeout,
)),
#[cfg(feature = "deadlock")]
deadlock: Arc::new(crate::deadlock::DeadlockManager::new(
config.enable_deadlock_detection,
config.deadlock_check_interval,
config.hang_threshold,
)),
deadlock: Arc::new(crate::deadlock::DeadlockManager::from_policy(config.deadlock_policy)),
#[cfg(feature = "backpressure")]
backpressure: Arc::new(crate::backpressure::BackpressureManager::new(
config.backpressure_buffer_size,
config.high_watermark,
config.low_watermark,
)),
backpressure: Arc::new(crate::backpressure::BackpressureManager::from_policy(config.backpressure_policy)),
#[cfg(feature = "scheduler")]
scheduler: Arc::new(crate::scheduler::SchedulerManager::new(
config.io_buffer_size,
config.max_buffer_size,
config.high_priority_threshold,
config.low_priority_threshold,
)),
scheduler: Arc::new(crate::scheduler::SchedulerManager::from_policy(config.scheduler_policy)),
config,
}
@@ -244,7 +227,7 @@ impl ConcurrencyManager {
pub async fn start(&self) {
#[cfg(feature = "deadlock")]
{
if self.config.enable_deadlock_detection {
if self.config.deadlock_policy.enabled {
self.deadlock.start().await;
}
}
+47 -14
View File
@@ -22,9 +22,9 @@ use rustfs_io_metrics::io_metrics;
use std::sync::Arc;
use std::time::Duration;
/// Scheduler configuration
#[derive(Debug, Clone)]
pub struct SchedulerConfig {
/// Facade policy for the concurrency-layer scheduler manager.
#[derive(Debug, Clone, Copy)]
pub struct SchedulerPolicy {
/// Base buffer size
pub base_buffer_size: usize,
/// Maximum buffer size
@@ -35,7 +35,7 @@ pub struct SchedulerConfig {
pub low_priority_threshold: usize,
}
impl Default for SchedulerConfig {
impl Default for SchedulerPolicy {
fn default() -> Self {
Self {
base_buffer_size: 64 * 1024, // 64KB
@@ -46,9 +46,23 @@ impl Default for SchedulerConfig {
}
}
impl SchedulerPolicy {
/// Convert facade policy to io-core scheduler config.
pub fn to_core_config(&self) -> rustfs_io_core::IoSchedulerConfig {
rustfs_io_core::IoSchedulerConfig {
base_buffer_size: self.base_buffer_size,
max_buffer_size: self.max_buffer_size,
high_priority_size_threshold: self.high_priority_threshold,
low_priority_size_threshold: self.low_priority_threshold,
..rustfs_io_core::IoSchedulerConfig::default()
}
}
}
/// Scheduler manager
pub struct SchedulerManager {
config: SchedulerConfig,
config: SchedulerPolicy,
core_config: rustfs_io_core::IoSchedulerConfig,
scheduler: Arc<CoreIoScheduler>,
}
@@ -60,26 +74,35 @@ impl SchedulerManager {
high_priority_threshold: usize,
low_priority_threshold: usize,
) -> Self {
let config = SchedulerConfig {
Self::from_policy(SchedulerPolicy {
base_buffer_size,
max_buffer_size,
high_priority_threshold,
low_priority_threshold,
};
})
}
let core_config = rustfs_io_core::IoSchedulerConfig::default();
/// Create a scheduler manager from facade policy.
pub fn from_policy(config: SchedulerPolicy) -> Self {
let core_config = config.to_core_config();
Self {
config,
core_config: core_config.clone(),
scheduler: Arc::new(CoreIoScheduler::new(core_config)),
}
}
/// Get the configuration
pub fn config(&self) -> &SchedulerConfig {
pub fn config(&self) -> &SchedulerPolicy {
&self.config
}
/// Get the derived io-core scheduler config.
pub fn core_config(&self) -> &rustfs_io_core::IoSchedulerConfig {
&self.core_config
}
/// Get the scheduler
pub fn scheduler(&self) -> Arc<CoreIoScheduler> {
self.scheduler.clone()
@@ -87,7 +110,7 @@ impl SchedulerManager {
/// Create an I/O strategy
pub fn create_strategy(&self) -> IoStrategy {
IoStrategy::new(self.config.clone(), self.scheduler.clone())
IoStrategy::new(self.config, self.scheduler.clone())
}
/// Calculate buffer size
@@ -111,12 +134,12 @@ impl SchedulerManager {
/// I/O strategy
pub struct IoStrategy {
config: SchedulerConfig,
config: SchedulerPolicy,
scheduler: Arc<CoreIoScheduler>,
}
impl IoStrategy {
fn new(config: SchedulerConfig, scheduler: Arc<CoreIoScheduler>) -> Self {
fn new(config: SchedulerPolicy, scheduler: Arc<CoreIoScheduler>) -> Self {
Self { config, scheduler }
}
@@ -191,7 +214,7 @@ impl IoStrategy {
}
/// Get the configuration
pub fn config(&self) -> &SchedulerConfig {
pub fn config(&self) -> &SchedulerPolicy {
&self.config
}
}
@@ -202,10 +225,20 @@ mod tests {
#[test]
fn test_scheduler_config() {
let config = SchedulerConfig::default();
let config = SchedulerPolicy::default();
assert!(config.base_buffer_size < config.max_buffer_size);
}
#[test]
fn test_scheduler_policy_to_core_config() {
let policy = SchedulerPolicy::default();
let core = policy.to_core_config();
assert_eq!(core.base_buffer_size, policy.base_buffer_size);
assert_eq!(core.max_buffer_size, policy.max_buffer_size);
assert_eq!(core.high_priority_size_threshold, policy.high_priority_threshold);
assert_eq!(core.low_priority_size_threshold, policy.low_priority_threshold);
}
#[test]
fn test_scheduler_manager() {
let manager = SchedulerManager::new(1024, 4096, 512, 2048);
+89 -17
View File
@@ -14,60 +14,101 @@
//! Timeout management for operations
use rustfs_io_core::{TimeoutError, calculate_adaptive_timeout};
use rustfs_io_core::{TimeoutConfig as CoreTimeoutConfig, TimeoutError, calculate_adaptive_timeout};
use std::time::{Duration, Instant};
use tokio_util::sync::CancellationToken;
/// Timeout configuration
#[derive(Debug, Clone)]
pub struct TimeoutConfig {
/// Facade policy for the concurrency-layer timeout manager.
#[derive(Debug, Clone, Copy)]
pub struct TimeoutManagerPolicy {
/// Default timeout duration
pub default_timeout: Duration,
/// Maximum timeout duration
pub max_timeout: Duration,
/// Minimum timeout floor (prevents dynamic calculation from going too low).
pub min_timeout: Duration,
/// Enable dynamic timeout calculation
pub enable_dynamic: bool,
}
impl Default for TimeoutConfig {
impl Default for TimeoutManagerPolicy {
fn default() -> Self {
Self {
default_timeout: Duration::from_secs(30),
max_timeout: Duration::from_secs(300),
min_timeout: Duration::from_secs(5),
enable_dynamic: true,
}
}
}
impl TimeoutManagerPolicy {
/// Convert the facade policy into the reusable io-core timeout configuration.
///
/// This keeps the concurrency layer explicitly wired to the shared core
/// timeout primitives without changing the facade's public behavior.
pub fn to_core_config(&self) -> CoreTimeoutConfig {
CoreTimeoutConfig {
base_timeout: self.default_timeout,
timeout_per_mb: Duration::ZERO,
max_timeout: self.max_timeout,
min_timeout: self.min_timeout,
get_object_timeout: self.default_timeout,
put_object_timeout: self.max_timeout,
list_objects_timeout: self.default_timeout,
enable_dynamic_timeout: self.enable_dynamic,
}
}
}
/// Timeout manager
pub struct TimeoutManager {
config: TimeoutConfig,
config: TimeoutManagerPolicy,
core_config: CoreTimeoutConfig,
}
impl TimeoutManager {
/// Create a new timeout manager
pub fn new(default_timeout: Duration, max_timeout: Duration, enable_dynamic: bool) -> Self {
Self {
config: TimeoutConfig {
default_timeout,
max_timeout,
enable_dynamic,
},
}
let min_timeout = default_timeout.min(max_timeout);
Self::from_policy(TimeoutManagerPolicy {
default_timeout,
max_timeout,
min_timeout,
enable_dynamic,
})
}
/// Create a new timeout manager from the facade policy type.
pub fn from_policy(config: TimeoutManagerPolicy) -> Self {
let config = TimeoutManagerPolicy {
// Guard clamp(min, max) from panic when callers provide an
// out-of-order policy (or very small max_timeout).
min_timeout: config.min_timeout.min(config.max_timeout),
..config
};
let core_config = config.to_core_config();
Self { config, core_config }
}
/// Get the configuration
pub fn config(&self) -> &TimeoutConfig {
pub fn config(&self) -> &TimeoutManagerPolicy {
&self.config
}
/// Get the derived io-core timeout configuration.
pub fn core_config(&self) -> &CoreTimeoutConfig {
&self.core_config
}
/// Calculate timeout for a given size
pub fn calculate_timeout(&self, size: u64, _history: &[Duration]) -> Duration {
if !self.config.enable_dynamic {
return self.config.default_timeout;
}
calculate_adaptive_timeout(self.config.default_timeout, None, 0, size).min(self.config.max_timeout)
calculate_adaptive_timeout(self.core_config.base_timeout, None, 0, size)
.clamp(self.core_config.min_timeout, self.core_config.max_timeout)
}
/// Wrap an operation with timeout control
@@ -87,7 +128,7 @@ impl TimeoutManager {
/// Create a timeout guard for manual timeout control
pub fn create_guard(&self, timeout: Option<Duration>) -> TimeoutGuard {
TimeoutGuard::new(timeout.unwrap_or(self.config.default_timeout))
TimeoutGuard::new(timeout.unwrap_or(self.core_config.base_timeout))
}
}
@@ -134,10 +175,41 @@ mod tests {
#[test]
fn test_timeout_config() {
let config = TimeoutConfig::default();
let config = TimeoutManagerPolicy::default();
assert!(config.default_timeout < config.max_timeout);
}
#[test]
fn test_timeout_policy_to_core_config() {
let policy = TimeoutManagerPolicy::default();
let core = policy.to_core_config();
assert_eq!(core.base_timeout, policy.default_timeout);
assert_eq!(core.max_timeout, policy.max_timeout);
assert_eq!(core.min_timeout, policy.min_timeout);
assert_eq!(core.get_object_timeout, policy.default_timeout);
assert!(core.enable_dynamic_timeout);
}
#[test]
fn test_timeout_manager_new_sanitizes_min_timeout_with_small_max_timeout() {
let manager = TimeoutManager::new(Duration::from_secs(1), Duration::from_secs(1), true);
let timeout = manager.calculate_timeout(1024, &[]);
assert_eq!(timeout, Duration::from_secs(1));
}
#[test]
fn test_timeout_manager_from_policy_sanitizes_min_timeout() {
let manager = TimeoutManager::from_policy(TimeoutManagerPolicy {
default_timeout: Duration::from_secs(30),
max_timeout: Duration::from_secs(1),
min_timeout: Duration::from_secs(5),
enable_dynamic: true,
});
assert_eq!(manager.config().min_timeout, Duration::from_secs(1));
assert_eq!(manager.core_config().min_timeout, Duration::from_secs(1));
}
#[tokio::test]
async fn test_wrap_operation_success() {
let manager = TimeoutManager::new(Duration::from_secs(5), Duration::from_secs(10), true);
@@ -12,10 +12,13 @@
// See the License for the specific language governing permissions and
// limitations under the License.
//! Worker slot limiter used by long-running background workflows.
use std::sync::Arc;
use tokio::sync::{Mutex, Notify};
use tracing::info;
/// Cooperative worker-slot controller for async tasks.
pub struct Workers {
available: Mutex<usize>, // Available working slots
notify: Notify, // Used to notify waiting tasks
@@ -23,20 +26,20 @@ pub struct Workers {
}
impl Workers {
// Create a Workers object that allows up to n jobs to execute concurrently
pub fn new(n: usize) -> Result<Arc<Workers>, &'static str> {
/// Create a [`Workers`] object that allows up to `n` jobs to execute concurrently.
pub fn new(n: usize) -> Result<Arc<Self>, &'static str> {
if n == 0 {
return Err("n must be > 0");
}
Ok(Arc::new(Workers {
Ok(Arc::new(Self {
available: Mutex::new(n),
notify: Notify::new(),
limit: n,
}))
}
// Give a job a chance to be executed
/// Acquire a worker slot, waiting until one becomes available.
pub async fn take(&self) {
loop {
let mut available = self.available.lock().await;
@@ -51,7 +54,7 @@ impl Workers {
}
}
// Release a job's slot
/// Release a worker slot.
pub async fn give(&self) {
let mut available = self.available.lock().await;
info!("worker give, {}", *available);
@@ -59,7 +62,7 @@ impl Workers {
self.notify.notify_one(); // Notify a waiting task
}
// Wait for all concurrent jobs to complete
/// Wait until all worker slots are released.
pub async fn wait(&self) {
loop {
{
@@ -74,6 +77,7 @@ impl Workers {
info!("worker wait end");
}
/// Return the current number of available worker slots.
pub async fn available(&self) -> usize {
*self.available.lock().await
}
+40
View File
@@ -71,6 +71,24 @@ Current guidance:
- `RUSTFS_SCANNER_IDLE_MODE` (canonical)
- `RUSTFS_SCANNER_CACHE_SAVE_TIMEOUT_SECS` (canonical)
## Health compatibility switches
- `RUSTFS_HEALTH_ENDPOINT_ENABLE`
- controls canonical `/health`, `/health/live`, and `/health/ready` endpoint exposure.
- `RUSTFS_HEALTH_MINIMAL_RESPONSE_ENABLE`
- enables minimal payload mode for GET health responses (`status`, `ready` only).
- `RUSTFS_HEALTH_READINESS_CACHE_TTL_MS`
- TTL for readiness cache evaluation.
- `RUSTFS_HEALTH_COMPAT_BUSY_CHECK_ENABLE`
- enables busy protection behavior for health probes.
- default is `false`.
- `RUSTFS_HEALTH_COMPAT_BUSY_MAX_ACTIVE_REQUESTS`
- max active HTTP requests; health probes return `429` when active requests reach or exceed this value.
- `0` disables thresholding even if busy protection is enabled.
- `RUSTFS_HEALTH_COMPAT_KMS_READY_CHECK_ENABLE`
- enables KMS readiness enforcement for `/health/ready`.
- default is `false`.
## Drive timeout environment variables
- `RUSTFS_DRIVE_METADATA_TIMEOUT_SECS`
@@ -83,6 +101,28 @@ Legacy compatibility fallback:
- `RUSTFS_DRIVE_MAX_TIMEOUT_DURATION`
This legacy variable is treated as a deprecated fallback for the operation-specific drive timeout variables above when a canonical variable is unset.
Drive timeout health-action policy:
- `RUSTFS_DRIVE_TIMEOUT_HEALTH_ACTION`
- `mark_failure` (default): timeout marks failure and may transition drive runtime state.
- `ignore_scanner`: timeout does not mark failure for scanner-sensitive operations (`walk_dir`, `read_metadata`, `list_dir`, `disk_info`).
Drive timeout profile preset:
- `RUSTFS_DRIVE_TIMEOUT_PROFILE`
- `default` (default): keep current timeout defaults.
- `high_latency`: use 60s default timeout for scanner-sensitive operations when no per-operation timeout override is set (`read_metadata`, `disk_info`, `list_dir`, `walk_dir`, `walk_dir_stall`).
- Precedence:
- Explicit per-operation timeout env (`RUSTFS_DRIVE_*_TIMEOUT_SECS`) takes highest precedence.
- Then `RUSTFS_DRIVE_MAX_TIMEOUT_DURATION` legacy fallback.
- Then the profile-derived default (`default` or `high_latency`).
## Startup filesystem boundary policy
- `RUSTFS_UNSUPPORTED_FS_POLICY` controls startup behavior when RustFS detects local endpoint filesystems that are outside the supported production boundary.
- `warn` (default): log warning and continue startup.
- `fail`: abort startup with an error.
RustFS production guidance remains direct-attached local POSIX filesystems. Network-mounted filesystems (for example `nfs`, `cifs`, `smb2`, and `fuse.*`) are treated as unsupported by this startup guard.
## 📄 License
This project is licensed under the Apache License 2.0 - see the [LICENSE](../../LICENSE) file for details.
+12 -2
View File
@@ -155,6 +155,16 @@ pub const ENV_RUSTFS_SECRET_KEY: &str = "RUSTFS_SECRET_KEY";
/// Environment variable for server secret key file.
pub const ENV_RUSTFS_SECRET_KEY_FILE: &str = "RUSTFS_SECRET_KEY_FILE";
/// Environment variable controlling startup behavior when unsupported filesystem types are detected.
///
/// Accepted values:
/// - "warn" (default): log a warning and continue startup
/// - "fail": abort startup with an error
pub const ENV_RUSTFS_UNSUPPORTED_FS_POLICY: &str = "RUSTFS_UNSUPPORTED_FS_POLICY";
pub const RUSTFS_UNSUPPORTED_FS_POLICY_WARN: &str = "warn";
pub const RUSTFS_UNSUPPORTED_FS_POLICY_FAIL: &str = "fail";
pub const DEFAULT_RUSTFS_UNSUPPORTED_FS_POLICY: &str = RUSTFS_UNSUPPORTED_FS_POLICY_WARN;
/// Environment variable for server OBS endpoint.
pub const ENV_RUSTFS_OBS_ENDPOINT: &str = "RUSTFS_OBS_ENDPOINT";
@@ -293,9 +303,9 @@ pub const DEFAULT_OBS_LOGS_EXPORT_ENABLED: bool = true;
/// Default profiling export enabled
/// It is used to enable or disable exporting profiles
/// Default value: false
/// Default value: true
/// Environment variable: RUSTFS_OBS_PROFILING_EXPORT_ENABLED
pub const DEFAULT_OBS_PROFILING_EXPORT_ENABLED: bool = false;
pub const DEFAULT_OBS_PROFILING_EXPORT_ENABLED: bool = true;
/// Default log local logging enabled for rustfs
/// This is the default log local logging enabled for rustfs.
+26
View File
@@ -47,6 +47,16 @@ pub const DEFAULT_DRIVE_ACTIVE_CHECK_INTERVAL_SECS: u64 = 15;
pub const ENV_DRIVE_ACTIVE_CHECK_TIMEOUT_SECS: &str = "RUSTFS_DRIVE_ACTIVE_CHECK_TIMEOUT_SECS";
pub const DEFAULT_DRIVE_ACTIVE_CHECK_TIMEOUT_SECS: u64 = 5;
/// Timeout-to-health transition policy for drive operations.
///
/// Accepted values:
/// - "mark_failure" (default): timeout marks failure and may transition runtime state.
/// - "ignore_scanner": timeout does not mark failure for scanner-sensitive operations.
pub const ENV_DRIVE_TIMEOUT_HEALTH_ACTION: &str = "RUSTFS_DRIVE_TIMEOUT_HEALTH_ACTION";
pub const DRIVE_TIMEOUT_HEALTH_ACTION_MARK_FAILURE: &str = "mark_failure";
pub const DRIVE_TIMEOUT_HEALTH_ACTION_IGNORE_SCANNER: &str = "ignore_scanner";
pub const DEFAULT_DRIVE_TIMEOUT_HEALTH_ACTION: &str = DRIVE_TIMEOUT_HEALTH_ACTION_MARK_FAILURE;
/// Number of consecutive failures before a suspect drive is classified as offline.
pub const ENV_DRIVE_SUSPECT_FAILURE_THRESHOLD: &str = "RUSTFS_DRIVE_SUSPECT_FAILURE_THRESHOLD";
pub const DEFAULT_DRIVE_SUSPECT_FAILURE_THRESHOLD: u64 = 2;
@@ -66,3 +76,19 @@ pub const DEFAULT_DRIVE_OFFLINE_GRACE_PERIOD_SECS: u64 = 30;
/// Duration in seconds after which a recovered drive is classified as long offline.
pub const ENV_DRIVE_LONG_OFFLINE_THRESHOLD_SECS: &str = "RUSTFS_DRIVE_LONG_OFFLINE_THRESHOLD_SECS";
pub const DEFAULT_DRIVE_LONG_OFFLINE_THRESHOLD_SECS: u64 = 172_800;
/// Drive timeout profile preset.
///
/// Accepted values:
/// - "default": keep current timeout defaults.
/// - "high_latency": use a higher default timeout preset for scanner-sensitive and metadata operations.
///
/// Explicit per-operation overrides (`RUSTFS_DRIVE_*_TIMEOUT_SECS`) still take precedence.
pub const ENV_DRIVE_TIMEOUT_PROFILE: &str = "RUSTFS_DRIVE_TIMEOUT_PROFILE";
pub const DRIVE_TIMEOUT_PROFILE_DEFAULT: &str = "default";
pub const DRIVE_TIMEOUT_PROFILE_HIGH_LATENCY: &str = "high_latency";
pub const DEFAULT_DRIVE_TIMEOUT_PROFILE: &str = DRIVE_TIMEOUT_PROFILE_DEFAULT;
/// Timeout preset (seconds) used when `RUSTFS_DRIVE_TIMEOUT_PROFILE=high_latency`
/// and no per-operation timeout override is provided.
pub const DRIVE_TIMEOUT_PROFILE_HIGH_LATENCY_SECS: u64 = 60;
+17
View File
@@ -26,3 +26,20 @@ pub const DEFAULT_HEALTH_READINESS_CACHE_TTL_MS: u64 = 1000;
/// When enabled, only `status` and `ready` fields are returned.
pub const ENV_HEALTH_MINIMAL_RESPONSE_ENABLE: &str = "RUSTFS_HEALTH_MINIMAL_RESPONSE_ENABLE";
pub const DEFAULT_HEALTH_MINIMAL_RESPONSE_ENABLE: bool = false;
/// Enable busy protection for health probes.
/// When enabled with a positive request threshold, alias health probes may
/// return 429 when active HTTP requests exceed the threshold.
pub const ENV_HEALTH_COMPAT_BUSY_CHECK_ENABLE: &str = "RUSTFS_HEALTH_COMPAT_BUSY_CHECK_ENABLE";
pub const DEFAULT_HEALTH_COMPAT_BUSY_CHECK_ENABLE: bool = false;
/// Max active HTTP requests; alias health probes report busy (429) when active requests reach or exceed this value.
/// Set to 0 to disable thresholding even when busy protection is enabled.
pub const ENV_HEALTH_COMPAT_BUSY_MAX_ACTIVE_REQUESTS: &str = "RUSTFS_HEALTH_COMPAT_BUSY_MAX_ACTIVE_REQUESTS";
pub const DEFAULT_HEALTH_COMPAT_BUSY_MAX_ACTIVE_REQUESTS: usize = 0;
/// Enable KMS readiness check for alias readiness probes.
/// When enabled, `/health/ready` additionally requires KMS service to be
/// in running state if a global KMS manager exists.
pub const ENV_HEALTH_COMPAT_KMS_READY_CHECK_ENABLE: &str = "RUSTFS_HEALTH_COMPAT_KMS_READY_CHECK_ENABLE";
pub const DEFAULT_HEALTH_COMPAT_KMS_READY_CHECK_ENABLE: bool = false;
+14
View File
@@ -32,6 +32,16 @@ pub const DEFAULT_INTERNODE_HTTP2_KEEPALIVE_TIMEOUT_SECS: u64 = 3;
pub const ENV_INTERNODE_RPC_TIMEOUT_SECS: &str = "RUSTFS_INTERNODE_RPC_TIMEOUT_SECS";
pub const DEFAULT_INTERNODE_RPC_TIMEOUT_SECS: u64 = 10;
/// Environment variable for selecting the internode data-plane transport backend.
pub const ENV_RUSTFS_INTERNODE_DATA_TRANSPORT: &str = "RUSTFS_INTERNODE_DATA_TRANSPORT";
pub const DEFAULT_INTERNODE_DATA_TRANSPORT: &str = "tcp-http";
/// Legacy alias for "tcp-http". Both values select the TCP/HTTP transport backend.
pub const INTERNODE_DATA_TRANSPORT_TCP: &str = "tcp";
/// Known internode transport backend names accepted by the config parser.
pub const KNOWN_INTERNODE_DATA_TRANSPORT_BACKENDS: &[&str] = &[DEFAULT_INTERNODE_DATA_TRANSPORT, INTERNODE_DATA_TRANSPORT_TCP];
#[cfg(test)]
mod tests {
use super::*;
@@ -58,5 +68,9 @@ mod tests {
"RUSTFS_INTERNODE_HTTP2_KEEPALIVE_TIMEOUT_SECS"
);
assert_eq!(ENV_INTERNODE_RPC_TIMEOUT_SECS, "RUSTFS_INTERNODE_RPC_TIMEOUT_SECS");
assert_eq!(ENV_RUSTFS_INTERNODE_DATA_TRANSPORT, "RUSTFS_INTERNODE_DATA_TRANSPORT");
assert_eq!(DEFAULT_INTERNODE_DATA_TRANSPORT, "tcp-http");
assert_eq!(INTERNODE_DATA_TRANSPORT_TCP, "tcp");
assert_eq!(KNOWN_INTERNODE_DATA_TRANSPORT_BACKENDS, &["tcp-http", "tcp"]);
}
}
+19
View File
@@ -59,6 +59,25 @@ pub const DEFAULT_RUNTIME_DIAL9_ROTATION_COUNT: usize = 10;
pub const DEFAULT_RUNTIME_DIAL9_SAMPLING_RATE: f64 = 1.0; // 100% sampling
// Note: S3 bucket/prefix have no default; absence means upload is disabled (modeled as Option<String>)
/// Maximum transition workers used as a local fallback when runtime env is unset.
pub const DEFAULT_TRANSITION_WORKERS_CAP: i64 = 16;
/// Absolute upper bound for transition workers accepted from runtime env.
pub const DEFAULT_TRANSITION_WORKERS_ABSOLUTE_MAX: i64 = 32;
/// Default capacity for the transition queue.
pub const DEFAULT_TRANSITION_QUEUE_CAPACITY: usize = 1000;
/// Default send timeout for transition queue enqueue attempts, in milliseconds.
pub const DEFAULT_TRANSITION_QUEUE_SEND_TIMEOUT_MS: usize = 100;
/// Test-only fault injection env var that forces the immediate transition enqueue timeout path.
pub const ENV_TEST_FORCE_IMMEDIATE_TRANSITION_ENQUEUE_TIMEOUT: &str = "RUSTFS_TEST_FORCE_IMMEDIATE_TRANSITION_ENQUEUE_TIMEOUT";
/// Runtime env var controlling the transition worker count.
pub const ENV_TRANSITION_WORKERS: &str = "RUSTFS_MAX_TRANSITION_WORKERS";
/// Runtime env var controlling the absolute maximum transition workers.
pub const ENV_TRANSITION_WORKERS_ABSOLUTE_MAX: &str = "RUSTFS_ABSOLUTE_MAX_WORKERS";
/// Runtime env var controlling the transition queue capacity.
pub const ENV_TRANSITION_QUEUE_CAPACITY: &str = "RUSTFS_TRANSITION_QUEUE_CAPACITY";
/// Runtime env var controlling the transition queue send timeout in milliseconds.
pub const ENV_TRANSITION_QUEUE_SEND_TIMEOUT_MS: &str = "RUSTFS_TRANSITION_QUEUE_SEND_TIMEOUT_MS";
// Allocator reclaim configuration
pub const ENV_ALLOCATOR_RECLAIM_ENABLED: &str = "RUSTFS_ALLOCATOR_RECLAIM_ENABLED";
pub const ENV_ALLOCATOR_RECLAIM_INTERVAL_SECS: &str = "RUSTFS_ALLOCATOR_RECLAIM_INTERVAL_SECS";
+31
View File
@@ -40,6 +40,37 @@ pub const ENV_SCANNER_SPEED: &str = "RUSTFS_SCANNER_SPEED";
/// Default scanner speed preset.
pub const DEFAULT_SCANNER_SPEED: &str = "default";
/// Environment variable that specifies the periodic bitrot scan cycle in seconds.
/// When set to `0`, `true`, `on`, or `yes`, every scanner cycle runs in deep mode.
/// When set to `false`, `off`, `no`, or `disabled`, periodic deep scans are disabled.
/// - Unit: seconds (u64).
/// - Example: `export RUSTFS_SCANNER_BITROT_CYCLE_SECS=2592000` (30 days)
pub const ENV_SCANNER_BITROT_CYCLE_SECS: &str = "RUSTFS_SCANNER_BITROT_CYCLE_SECS";
/// Default bitrot scan cycle used by the scanner.
pub const DEFAULT_SCANNER_BITROT_CYCLE_SECS: u64 = 30 * 24 * 60 * 60;
/// Environment variable that controls how many object versions trigger scanner alerts.
/// - Example: `export RUSTFS_SCANNER_ALERT_EXCESS_VERSIONS=100`
pub const ENV_SCANNER_ALERT_EXCESS_VERSIONS: &str = "RUSTFS_SCANNER_ALERT_EXCESS_VERSIONS";
/// Default object version count that triggers scanner alerts.
pub const DEFAULT_SCANNER_ALERT_EXCESS_VERSIONS: u64 = 100;
/// Environment variable that controls how many cumulative bytes across object versions trigger scanner alerts.
/// - Example: `export RUSTFS_SCANNER_ALERT_EXCESS_VERSION_SIZE=1099511627776`
pub const ENV_SCANNER_ALERT_EXCESS_VERSION_SIZE: &str = "RUSTFS_SCANNER_ALERT_EXCESS_VERSION_SIZE";
/// Default cumulative object version bytes that trigger scanner alerts.
pub const DEFAULT_SCANNER_ALERT_EXCESS_VERSION_SIZE: u64 = 1024 * 1024 * 1024 * 1024;
/// Environment variable that controls how many subfolders trigger scanner alerts.
/// - Example: `export RUSTFS_SCANNER_ALERT_EXCESS_FOLDERS=50000`
pub const ENV_SCANNER_ALERT_EXCESS_FOLDERS: &str = "RUSTFS_SCANNER_ALERT_EXCESS_FOLDERS";
/// Default subfolder count that triggers scanner alerts.
pub const DEFAULT_SCANNER_ALERT_EXCESS_FOLDERS: u64 = 50_000;
/// Environment variable that controls whether the scanner sleeps between operations.
/// When `true` (default), the scanner throttles itself. When `false`, it runs at full speed.
/// - Example: `export RUSTFS_SCANNER_IDLE_MODE=false`
+16
View File
@@ -1,3 +1,17 @@
# Copyright 2024 RustFS Team
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
[package]
name = "rustfs-credentials"
edition.workspace = true
@@ -12,9 +26,11 @@ categories = ["web-programming", "development-tools", "data-structures", "securi
[dependencies]
base64-simd = { workspace = true }
hmac = { workspace = true }
rand = { workspace = true }
serde = { workspace = true }
serde_json.workspace = true
sha2 = { workspace = true }
time = { workspace = true, features = ["serde", "parsing", "formatting", "macros"] }
[lints]
+107 -33
View File
@@ -12,10 +12,13 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use crate::{DEFAULT_SECRET_KEY, ENV_RPC_SECRET, IAM_POLICY_CLAIM_NAME_SA, INHERITED_POLICY_TYPE};
use crate::{DEFAULT_ACCESS_KEY, DEFAULT_SECRET_KEY, ENV_RPC_SECRET, IAM_POLICY_CLAIM_NAME_SA, INHERITED_POLICY_TYPE};
use base64_simd::URL_SAFE_NO_PAD;
use hmac::{Hmac, KeyInit, Mac};
use rand::{Rng, RngExt};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sha2::Sha256;
use std::collections::HashMap;
use std::env;
use std::fmt;
@@ -33,7 +36,12 @@ pub static GLOBAL_RUSTFS_RPC_SECRET: OnceLock<String> = OnceLock::new();
pub const RPC_SECRET_REQUIRED_MESSAGE: &str = "RPC authentication secret is not configured";
/// Operator-facing guidance for configuring RPC authentication safely.
pub const RPC_SECRET_REQUIRED_OPERATOR_MESSAGE: &str = "RUSTFS_RPC_SECRET must be set to a non-default value or RUSTFS_SECRET_KEY must be changed from the default for RPC authentication";
pub const RPC_SECRET_REQUIRED_OPERATOR_MESSAGE: &str =
"RUSTFS_RPC_SECRET can be set explicitly; otherwise the RPC secret is derived from the active access/secret key pair";
type HmacSha256 = Hmac<Sha256>;
const RPC_SECRET_DERIVATION_CONTEXT: &[u8] = b"rustfs-rpc-secret:v1";
/// Error type for credentials operations
#[derive(Debug)]
@@ -217,37 +225,59 @@ pub fn gen_secret_key(length: usize) -> std::io::Result<String> {
Ok(encoded)
}
/// Get the RPC authentication token from environment variable
/// Get the RPC authentication token from the environment or derive it from the active credentials.
///
/// # Returns
/// * `String` - The RPC authentication token
///
fn resolve_rpc_secret(env_secret: Option<&str>, global_secret: Option<&str>) -> Option<String> {
if let Some(secret) = env_secret.map(str::trim).filter(|secret| !secret.is_empty()) {
return (secret != DEFAULT_SECRET_KEY).then(|| secret.to_string());
fn normalize_rpc_secret(secret: &str) -> Option<String> {
let secret = secret.trim();
(!secret.is_empty() && secret != DEFAULT_SECRET_KEY && secret != DEFAULT_ACCESS_KEY).then(|| secret.to_string())
}
fn derive_rpc_secret(access_key: &str, secret_key: &str) -> Option<String> {
let access_key = access_key.trim();
let secret_key = secret_key.trim();
if access_key.is_empty() || secret_key.is_empty() {
return None;
}
global_secret
.map(str::trim)
.filter(|secret| !secret.is_empty() && *secret != DEFAULT_SECRET_KEY)
.map(ToOwned::to_owned)
let mut mac = <HmacSha256 as KeyInit>::new_from_slice(secret_key.as_bytes()).expect("HMAC can take key of any size");
mac.update(RPC_SECRET_DERIVATION_CONTEXT);
mac.update(&[0]);
mac.update(access_key.as_bytes());
Some(URL_SAFE_NO_PAD.encode_to_string(mac.finalize().into_bytes()))
}
fn resolve_rpc_secret(env_secret: Option<&str>, global_access: Option<&str>, global_secret: Option<&str>) -> Option<String> {
if let Some(secret) = env_secret.map(str::trim).filter(|secret| !secret.is_empty()) {
return normalize_rpc_secret(secret);
}
match (global_access, global_secret) {
(Some(access_key), Some(secret_key)) => derive_rpc_secret(access_key, secret_key),
_ => None,
}
}
pub fn try_get_rpc_token() -> std::io::Result<String> {
if let Some(secret) = GLOBAL_RUSTFS_RPC_SECRET.get() {
return resolve_rpc_secret(None, Some(secret)).ok_or_else(|| Error::other(RPC_SECRET_REQUIRED_MESSAGE));
return normalize_rpc_secret(secret).ok_or_else(|| Error::other(RPC_SECRET_REQUIRED_MESSAGE));
}
let env_secret = env::var(ENV_RPC_SECRET).ok();
let global_access = get_global_access_key_opt();
let global_secret = get_global_secret_key_opt();
let secret = resolve_rpc_secret(env_secret.as_deref(), global_secret.as_deref())
let secret = resolve_rpc_secret(env_secret.as_deref(), global_access.as_deref(), global_secret.as_deref())
.ok_or_else(|| Error::other(RPC_SECRET_REQUIRED_MESSAGE))?;
match GLOBAL_RUSTFS_RPC_SECRET.set(secret.clone()) {
Ok(()) => Ok(secret),
Err(_) => GLOBAL_RUSTFS_RPC_SECRET
.get()
.and_then(|stored| resolve_rpc_secret(None, Some(stored)))
.and_then(|stored| normalize_rpc_secret(stored))
.ok_or_else(|| Error::other(RPC_SECRET_REQUIRED_MESSAGE)),
}
}
@@ -407,7 +437,7 @@ impl Credentials {
#[cfg(test)]
mod tests {
use super::*;
use crate::{IAM_POLICY_CLAIM_NAME_SA, INHERITED_POLICY_TYPE};
use crate::{DEFAULT_ACCESS_KEY, IAM_POLICY_CLAIM_NAME_SA, INHERITED_POLICY_TYPE};
use time::Duration;
#[test]
@@ -510,11 +540,12 @@ mod tests {
// If it hasn't already been initialized, the test automatically generates logic
if get_global_action_cred().is_none() {
init_global_action_credentials(None, None).ok();
let ak = get_global_access_key();
let sk = get_global_secret_key();
assert_eq!(ak.len(), 20);
assert_eq!(sk.len(), 32);
}
let ak = get_global_access_key();
let sk = get_global_secret_key();
assert!(ak.len() >= 3);
assert!(sk.len() >= 8);
}
#[test]
@@ -528,10 +559,26 @@ mod tests {
}
#[test]
fn test_resolve_rpc_secret_rejects_default_fallback() {
assert!(resolve_rpc_secret(None, None).is_none());
assert!(resolve_rpc_secret(None, Some(DEFAULT_SECRET_KEY)).is_none());
assert!(resolve_rpc_secret(Some(DEFAULT_SECRET_KEY), Some("custom-global-secret")).is_none());
fn test_gen_access_key_length_and_charset() {
let err = gen_access_key(2).expect_err("length below 3 should fail");
assert_eq!(err.to_string(), "access key length is too short");
let key = gen_access_key(20).expect("access key should generate");
assert_eq!(key.len(), 20);
assert!(key.chars().all(|ch| ch.is_ascii_uppercase() || ch.is_ascii_digit()));
}
#[test]
fn test_resolve_rpc_secret_allows_default_credentials_for_derivation() {
assert!(resolve_rpc_secret(None, None, None).is_none());
let expected = derive_rpc_secret(DEFAULT_ACCESS_KEY, DEFAULT_SECRET_KEY).expect("secret should derive");
assert_eq!(
resolve_rpc_secret(None, Some(DEFAULT_ACCESS_KEY), Some(DEFAULT_SECRET_KEY)).as_deref(),
Some(expected.as_str())
);
assert!(resolve_rpc_secret(Some(DEFAULT_SECRET_KEY), Some("custom-access"), Some("custom-global-secret")).is_none());
}
#[test]
@@ -551,37 +598,64 @@ mod tests {
#[test]
fn test_resolve_rpc_secret_accepts_non_default_secret() {
assert_eq!(resolve_rpc_secret(Some("custom-rpc-secret"), None).as_deref(), Some("custom-rpc-secret"));
assert_eq!(
resolve_rpc_secret(None, Some("custom-global-secret")).as_deref(),
Some("custom-global-secret")
resolve_rpc_secret(Some("custom-rpc-secret"), None, None).as_deref(),
Some("custom-rpc-secret")
);
let expected = derive_rpc_secret("custom-access", "custom-global-secret").expect("secret should derive");
assert_eq!(
resolve_rpc_secret(None, Some("custom-access"), Some("custom-global-secret")).as_deref(),
Some(expected.as_str())
);
}
#[test]
fn test_resolve_rpc_secret_trims_and_falls_back_from_blank_env() {
let expected = derive_rpc_secret("custom-access", "custom-global-secret").expect("secret should derive");
assert_eq!(
resolve_rpc_secret(Some(" custom-rpc-secret "), None).as_deref(),
resolve_rpc_secret(Some(" custom-rpc-secret "), None, None).as_deref(),
Some("custom-rpc-secret")
);
assert_eq!(
resolve_rpc_secret(Some(""), Some("custom-global-secret")).as_deref(),
Some("custom-global-secret")
resolve_rpc_secret(Some(""), Some("custom-access"), Some("custom-global-secret")).as_deref(),
Some(expected.as_str())
);
assert_eq!(
resolve_rpc_secret(Some(" "), Some(" custom-global-secret ")).as_deref(),
Some("custom-global-secret")
resolve_rpc_secret(Some(" "), Some(" custom-access "), Some(" custom-global-secret ")).as_deref(),
Some(expected.as_str())
);
assert_eq!(
resolve_rpc_secret(Some(" "), Some("custom-global-secret")).as_deref(),
Some("custom-global-secret")
resolve_rpc_secret(Some(" "), Some("custom-access"), Some("custom-global-secret")).as_deref(),
Some(expected.as_str())
);
}
#[test]
fn test_resolve_rpc_secret_returns_none_for_trimmed_default_secret() {
let padded_default_secret = format!(" {} ", DEFAULT_SECRET_KEY);
assert!(resolve_rpc_secret(Some(padded_default_secret.as_str()), Some("custom-global-secret")).is_none());
assert!(
resolve_rpc_secret(Some(padded_default_secret.as_str()), Some("custom-access"), Some("custom-global-secret"))
.is_none()
);
let padded_default_access = format!(" {} ", DEFAULT_ACCESS_KEY);
assert!(
resolve_rpc_secret(Some(padded_default_access.as_str()), Some("custom-access"), Some("custom-global-secret"))
.is_none()
);
}
#[test]
fn test_derive_rpc_secret_is_stable_and_not_plaintext() {
let first = derive_rpc_secret("custom-access", "custom-secret").expect("secret should derive");
let second = derive_rpc_secret("custom-access", "custom-secret").expect("secret should derive");
assert_eq!(first, second);
assert_ne!(first, "custom-access");
assert_ne!(first, "custom-secret");
assert_ne!(first, format!("{}{}", "custom-access", "custom-secret"));
assert!(!first.contains("custom-access"));
assert!(!first.contains("custom-secret"));
}
#[test]
+3
View File
@@ -33,8 +33,11 @@ aes-gcm = { workspace = true, optional = true }
argon2 = { workspace = true, optional = true }
chacha20poly1305 = { workspace = true, optional = true }
jsonwebtoken = { workspace = true }
base64-simd = { workspace = true }
pbkdf2 = { workspace = true, optional = true }
rand = { workspace = true, optional = true }
rsa = { workspace = true, features = ["sha2"] }
serde = { workspace = true, features = ["derive"] }
sha2 = { workspace = true, optional = true }
thiserror.workspace = true
serde_json.workspace = true
+2
View File
@@ -16,6 +16,7 @@
mod encdec;
mod error;
mod jwt;
pub mod license_token;
pub use encdec::decrypt::decrypt_data;
pub use encdec::encrypt::encrypt_data;
@@ -25,3 +26,4 @@ pub use encdec::stream_io::{decrypt_stream_io, encrypt_stream_io};
pub use error::Error;
pub use jwt::decode::decode as jwt_decode;
pub use jwt::encode::encode as jwt_encode;
pub use license_token::{Token, parse_license_with_public_key, parse_signed_license_token, sign_license_token};
@@ -115,12 +115,20 @@ mod tests {
let private_key = RsaPrivateKey::new(&mut rng, bits).expect("Failed to generate private key");
let public_key = RsaPublicKey::from(&private_key);
let private_key_pem = private_key.to_pkcs8_pem(LineEnding::LF).unwrap();
let public_key_pem = public_key.to_public_key_pem(LineEnding::LF).unwrap();
let private_key_pem = private_key
.to_pkcs8_pem(LineEnding::LF)
.expect("failed to encode private key pem");
let public_key_pem = public_key
.to_public_key_pem(LineEnding::LF)
.expect("failed to encode public key pem");
let token = Token {
name: "test_app".to_string(),
expired: SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() + 3600, // 1 hour from now
expired: SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system clock before unix epoch")
.as_secs()
+ 3600, // 1 hour from now
};
let encoded = sign_license_token(&token, &private_key_pem).expect("Failed to encode token");
@@ -139,12 +147,20 @@ mod tests {
let private_key = RsaPrivateKey::new(&mut rng, bits).expect("Failed to generate private key");
let public_key = RsaPublicKey::from(&private_key);
let private_key_pem = private_key.to_pkcs8_pem(LineEnding::LF).unwrap();
let public_key_pem = public_key.to_public_key_pem(LineEnding::LF).unwrap();
let private_key_pem = private_key
.to_pkcs8_pem(LineEnding::LF)
.expect("failed to encode private key pem");
let public_key_pem = public_key
.to_public_key_pem(LineEnding::LF)
.expect("failed to encode public key pem");
let token = Token {
name: "test_app".to_string(),
expired: SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() + 3600,
expired: SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system clock before unix epoch")
.as_secs()
+ 3600,
};
let encoded = gencode(&token, &public_key_pem).expect("Failed to encode token");
@@ -159,11 +175,19 @@ mod tests {
let mut rng = rand::rng();
let private_key = RsaPrivateKey::new(&mut rng, 2048).expect("Failed to generate private key");
let public_key = RsaPublicKey::from(&private_key);
let private_key_pem = private_key.to_pkcs8_pem(LineEnding::LF).unwrap();
let public_key_pem = public_key.to_public_key_pem(LineEnding::LF).unwrap();
let private_key_pem = private_key
.to_pkcs8_pem(LineEnding::LF)
.expect("failed to encode private key pem");
let public_key_pem = public_key
.to_public_key_pem(LineEnding::LF)
.expect("failed to encode public key pem");
let token = Token {
name: "test_app".to_string(),
expired: SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() + 3600,
expired: SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system clock before unix epoch")
.as_secs()
+ 3600,
};
let encoded = sign_license_token(&token, &private_key_pem).expect("Failed to encode token");
@@ -181,7 +205,7 @@ mod tests {
#[test]
fn test_source_does_not_embed_private_key() {
let source = include_str!("token.rs");
let source = include_str!("license_token.rs");
let forbidden = ["BEGIN", "PRIVATE KEY"].join(" ");
assert!(!source.contains(&forbidden));
@@ -192,7 +216,9 @@ mod tests {
let mut rng = rand::rng();
let private_key = RsaPrivateKey::new(&mut rng, 2048).expect("Failed to generate private key");
let public_key = RsaPublicKey::from(&private_key);
let public_key_pem = public_key.to_public_key_pem(LineEnding::LF).unwrap();
let public_key_pem = public_key
.to_public_key_pem(LineEnding::LF)
.expect("failed to encode public key pem");
let invalid_token = "invalid_base64_token";
let result = parse_signed_license_token(invalid_token, &public_key_pem);
@@ -204,7 +230,11 @@ mod tests {
fn test_sign_license_token_with_invalid_signing_key() {
let token = Token {
name: "test_app".to_string(),
expired: SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() + 3600, // 1 hour from now
expired: SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system clock before unix epoch")
.as_secs()
+ 3600, // 1 hour from now
};
let invalid_key = "invalid_private_key";
@@ -13,24 +13,26 @@
# limitations under the License.
[package]
name = "rustfs-workers"
name = "rustfs-data-usage"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
rust-version.workspace = true
version.workspace = true
homepage.workspace = true
description = "Workers for RustFS, providing background processing capabilities for tasks such as data synchronization, indexing, and more."
keywords = ["workers", "tasks", "rustfs", "Minio"]
categories = ["web-programming", "development-tools"]
documentation = "https://docs.rs/rustfs-workers/latest/rustfs_workers/"
description = "Shared data usage models and algorithms for RustFS"
keywords = ["rustfs", "data-usage", "cache", "histogram"]
categories = ["data-structures", "filesystem"]
[lints]
workspace = true
[dependencies]
tokio = { workspace = true, features = ["sync", "time", "macros"] }
tracing.workspace = true
serde = { workspace = true }
path-clean = { workspace = true }
rmp-serde = { workspace = true }
async-trait = { workspace = true }
rustfs-filemeta = { workspace = true }
[lib]
doctest = false
@@ -312,6 +312,12 @@ impl SizeHistogram {
}
res
}
pub fn merge_from(&mut self, other: &Self) {
for (dst, src) in self.0.iter_mut().zip(other.0.iter()) {
*dst += src;
}
}
}
/// Versions histogram for version count distribution
@@ -361,6 +367,12 @@ impl VersionsHistogram {
}
res
}
pub fn merge_from(&mut self, other: &Self) {
for (dst, src) in self.0.iter_mut().zip(other.0.iter()) {
*dst += src;
}
}
}
/// Replication statistics for a single target
@@ -419,6 +431,9 @@ pub struct DataUsageEntry {
pub obj_versions: VersionsHistogram,
pub replication_stats: Option<ReplicationAllStats>,
pub compacted: bool,
/// Number of objects that failed to scan (e.g., IO errors)
#[serde(default)]
pub failed_objects: usize,
}
impl DataUsageEntry {
@@ -456,6 +471,7 @@ impl DataUsageEntry {
self.versions += other.versions;
self.delete_markers += other.delete_markers;
self.size += other.size;
self.failed_objects += other.failed_objects;
if let Some(o_rep) = &other.replication_stats {
let s_rep = self.replication_stats.get_or_insert_with(ReplicationAllStats::default);
@@ -490,9 +506,11 @@ impl DataUsageEntry {
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct DataUsageCacheInfo {
pub name: String,
pub next_cycle: u32,
pub next_cycle: u64,
pub last_update: Option<SystemTime>,
pub skip_healing: bool,
#[serde(default)]
pub failed_objects: HashMap<String, u64>,
}
/// Data usage cache
@@ -12,4 +12,6 @@
// See the License for the specific language governing permissions and
// limitations under the License.
pub mod workers;
pub mod data_usage;
pub use data_usage::*;
+1 -1
View File
@@ -31,7 +31,7 @@ sftp = []
[dependencies]
rustfs-config = { workspace = true, features = ["constants"] }
rustfs-ecstore.workspace = true
rustfs-common.workspace = true
rustfs-data-usage.workspace = true
rustfs-rio.workspace = true
flatbuffers.workspace = true
futures.workspace = true
+1 -1
View File
@@ -13,7 +13,7 @@
// limitations under the License.
use aws_sdk_s3::primitives::ByteStream;
use rustfs_common::data_usage::DataUsageInfo;
use rustfs_data_usage::DataUsageInfo;
use serial_test::serial;
use crate::common::{RustFSTestEnvironment, TEST_BUCKET, awscurl_get, init_logging};
@@ -0,0 +1,212 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Erasure-set healing regression tests.
#[cfg(test)]
mod tests {
use crate::common::{RustFSTestEnvironment, execute_awscurl, init_logging};
use aws_sdk_s3::primitives::ByteStream;
use serial_test::serial;
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use tokio::time::{Duration, sleep};
use tracing::info;
fn has_file_under(path: &Path) -> bool {
let Ok(entries) = std::fs::read_dir(path) else {
return false;
};
for entry in entries.filter_map(Result::ok) {
let path = entry.path();
if path.is_dir() {
if has_file_under(&path) {
return true;
}
} else {
return true;
}
}
false
}
fn object_metadata_exists_on_disk(disk: &Path, bucket: &str, key: &str) -> bool {
disk.join(bucket).join(key).join("xl.meta").is_file()
}
async fn assert_object_body(env: &RustFSTestEnvironment, bucket: &str, key: &str, expected: &[u8]) {
let client = env.create_s3_client();
let response = client
.get_object()
.bucket(bucket)
.key(key)
.send()
.await
.expect("GET should succeed during/after heal");
let body = response.body.collect().await.expect("GET body should collect").into_bytes();
assert_eq!(body.as_ref(), expected, "object body changed for {key}");
}
#[tokio::test]
#[serial]
async fn test_admin_deep_heal_rebuilds_cleared_disk_in_single_node_erasure_set() {
init_logging();
info!("Discussion #2964: admin deep heal should rebuild a wiped disk in a 4-disk single-node erasure set");
let mut env = RustFSTestEnvironment::new().await.expect("Failed to create test environment");
let root = PathBuf::from(env.temp_dir.clone());
let disk0 = root.join("disk0");
let disk1 = root.join("disk1");
let disk2 = root.join("disk2");
let disk3 = root.join("disk3");
for disk in [&disk0, &disk1, &disk2, &disk3] {
std::fs::create_dir_all(disk).expect("disk directory should be created");
}
// The test helper always appends env.temp_dir as the final storage path.
// Point it at disk3 and pass the other three disks explicitly.
env.temp_dir = disk3.to_string_lossy().to_string();
let disk0_arg = disk0.to_string_lossy().to_string();
let disk1_arg = disk1.to_string_lossy().to_string();
let disk2_arg = disk2.to_string_lossy().to_string();
env.start_rustfs_server_with_env(
vec![disk0_arg.as_str(), disk1_arg.as_str(), disk2_arg.as_str()],
&[("RUSTFS_UNSAFE_BYPASS_DISK_CHECK", "true")],
)
.await
.expect("Failed to start 4-disk RustFS");
let client = env.create_s3_client();
let bucket = "heal-cleared-disk-regression";
let target_object_count = std::env::var("RUSTFS_HEAL_REBUILD_OBJECT_COUNT")
.ok()
.and_then(|value| value.parse::<usize>().ok())
.unwrap_or(4)
.max(4);
let heal_timeout_secs = std::env::var("RUSTFS_HEAL_REBUILD_TIMEOUT_SECS")
.ok()
.and_then(|value| value.parse::<u64>().ok())
.unwrap_or(60);
let mut objects: Vec<(String, Vec<u8>, &'static str)> = vec![
(
"中文/报告-0001.json".to_string(),
"{\"message\":\"hello 中文\"}".as_bytes().to_vec(),
"application/json",
),
(
"english/images/photo-0002.jpg".to_string(),
vec![0xff, 0xd8, 0xff, 0x00, 0x42, 0x24],
"image/jpeg",
),
(
"mixed/空 格 + symbols @#%.txt".to_string(),
b"text object with spaces and symbols".to_vec(),
"text/plain; charset=utf-8",
),
(
"bin/archive-0004.bin".to_string(),
(0..=255).collect::<Vec<u8>>(),
"application/octet-stream",
),
];
for index in objects.len()..target_object_count {
objects.push((
format!("bulk/prefix-{}/object-{index:04}.txt", index % 17),
format!("bulk object {index}: heal regression payload").into_bytes(),
"text/plain; charset=utf-8",
));
}
let object_keys = objects.iter().map(|(key, _, _)| key.clone()).collect::<Vec<_>>();
let mut remaining_rebuild_keys: HashSet<String> = object_keys.iter().cloned().collect();
client
.create_bucket()
.bucket(bucket)
.send()
.await
.expect("bucket create should succeed");
for (key, body, content_type) in &objects {
client
.put_object()
.bucket(bucket)
.key(key)
.content_type(*content_type)
.body(ByteStream::from(body.clone()))
.send()
.await
.expect("PUT should succeed");
}
assert!(has_file_under(&disk0), "disk0 should contain object shards before wipe");
env.stop_server();
std::fs::remove_dir_all(&disk0).expect("disk0 wipe should succeed");
std::fs::create_dir_all(&disk0).expect("disk0 should be recreated empty");
assert!(!has_file_under(&disk0), "disk0 must be empty before restart");
env.start_rustfs_server_with_env(
vec![disk0_arg.as_str(), disk1_arg.as_str(), disk2_arg.as_str()],
&[("RUSTFS_UNSAFE_BYPASS_DISK_CHECK", "true")],
)
.await
.expect("Failed to restart 4-disk RustFS after disk wipe");
// The helper's Drop cleanup removes env.temp_dir. Reset it to the parent
// directory after server startup so all four disk directories are cleaned
// without manually deleting a path Drop will also try to remove.
env.temp_dir = root.to_string_lossy().to_string();
let heal_body = r#"{"recursive":true,"dryRun":false,"remove":false,"recreate":true,"scanMode":2,"updateParity":false,"nolock":false}"#;
let heal_url = format!("{}/rustfs/admin/v3/heal/{}?forceStart=true", env.url, bucket);
execute_awscurl(&heal_url, "POST", Some(heal_body), &env.access_key, &env.secret_key)
.await
.expect("admin deep heal should be accepted");
for _ in 0..heal_timeout_secs {
if !remaining_rebuild_keys.is_empty() {
let mut rebuilt = Vec::new();
for key in &remaining_rebuild_keys {
if object_metadata_exists_on_disk(&disk0, bucket, key) {
rebuilt.push(key.clone());
}
}
for key in rebuilt {
let _ = remaining_rebuild_keys.remove(&key);
}
}
if remaining_rebuild_keys.is_empty() {
for (key, body, _) in &objects {
assert_object_body(&env, bucket, key, body).await;
}
env.stop_server();
for key in &object_keys {
assert!(
object_metadata_exists_on_disk(&disk0, bucket, key),
"wiped disk should contain rebuilt xl.meta for {key}"
);
}
return;
}
sleep(Duration::from_secs(1)).await;
}
panic!("admin deep heal did not rebuild all files on the wiped disk within timeout");
}
}
+4 -4
View File
@@ -191,7 +191,7 @@ RUST_LOG=debug cargo test test_local_kms_end_to_end -- --nocapture
4. **Monitor ports**
```bash
netstat -an | grep 9050
curl http://127.0.0.1:9050/minio/health/ready
curl http://127.0.0.1:9050/health/ready
```
## 📊 Coverage
@@ -244,9 +244,9 @@ Designed to run inside CI/CD pipelines:
## 📚 References
- [KMS configuration guide](../../../../docs/kms/README.md)
- [Dynamic configuration API](../../../../docs/kms/http-api.md)
- [Troubleshooting](../../../../docs/kms/troubleshooting.md)
- [KMS configuration types](../../../kms/src/config.rs)
- [Dynamic configuration API handlers](../../../../rustfs/src/admin/handlers/kms_dynamic.rs)
- [KMS management API handlers](../../../../rustfs/src/admin/handlers/kms_management.rs)
---
+10
View File
@@ -63,6 +63,10 @@ mod archive_download_integrity_test;
#[cfg(test)]
mod list_objects_v2_pagination_test;
// Regression test for Issue #3107: mc mirror small-bucket listing must not time out.
#[cfg(test)]
mod mc_mirror_small_bucket_test;
// Policy variables tests
#[cfg(test)]
mod policy;
@@ -114,6 +118,9 @@ mod head_object_range_test;
#[cfg(test)]
mod head_object_consistency_test;
#[cfg(test)]
mod heal_erasure_disk_rebuild_test;
#[cfg(test)]
mod copy_object_metadata_test;
@@ -139,4 +146,7 @@ mod replication_extension_test;
#[cfg(test)]
mod snowball_auto_extract_test;
#[cfg(test)]
mod namespace_lock_quorum_test;
pub mod tls_gen;
@@ -612,4 +612,421 @@ mod tests {
env.stop_server();
}
/// Regression test: delimiter listing must not produce false-positive truncation
/// when many raw keys collapse into a small number of CommonPrefixes.
///
/// Scenario: 30 objects across 3 directories + 2 direct files under prefix.
/// With max_keys=1000, all 5 visible results (3 prefixes + 2 objects) fit in one
/// page, so IsTruncated must be false even though raw entry count is much larger.
#[tokio::test]
#[serial]
async fn test_list_objects_v2_delimiter_collapsed_prefix_no_false_truncation() {
init_logging();
info!("Starting test: ListObjectsV2 delimiter collapsed-prefix no false truncation");
let mut env = RustFSTestEnvironment::new().await.expect("Failed to create test environment");
env.start_rustfs_server(vec![]).await.expect("Failed to start RustFS");
let client = create_s3_client(&env);
let bucket = "test-collapsed-prefix";
let prefix = "data/";
create_bucket(&client, bucket).await.expect("Failed to create bucket");
// Create objects under 3 subdirectories (10 each = 30 raw keys)
let dirs = ["alpha/", "beta/", "gamma/"];
let mut expected_keys = Vec::new();
for dir in &dirs {
for i in 0..10 {
let key = format!("{prefix}{dir}file{i:02}.txt");
client
.put_object()
.bucket(bucket)
.key(&key)
.body(ByteStream::from_static(b"x"))
.send()
.await
.expect("Failed to put object");
expected_keys.push(key);
}
}
// Add 2 direct objects under prefix
for name in ["readme.txt", "config.json"] {
let key = format!("{prefix}{name}");
client
.put_object()
.bucket(bucket)
.key(&key)
.body(ByteStream::from_static(b"x"))
.send()
.await
.expect("Failed to put object");
expected_keys.push(key);
}
// List with delimiter="/", max_keys large enough to cover all visible results
// Visible: 3 common prefixes + 2 direct objects = 5
let output = client
.list_objects_v2()
.bucket(bucket)
.prefix(prefix)
.delimiter("/")
.max_keys(1000)
.send()
.await
.expect("Failed to list objects");
let listed_keys: Vec<String> = output
.contents()
.iter()
.filter_map(|obj| obj.key())
.map(|k| k.to_string())
.collect();
let listed_prefixes: Vec<String> = output
.common_prefixes()
.iter()
.filter_map(|cp| cp.prefix())
.map(|p| p.to_string())
.collect();
// KEY ASSERTION: IsTruncated must be false because all visible results fit within max_keys
let is_truncated = output.is_truncated().unwrap_or(false);
assert!(
!is_truncated,
"BUG: IsTruncated should be false when all visible results ({} objects + {} prefixes = {}) fit within max_keys (1000)",
listed_keys.len(),
listed_prefixes.len(),
listed_keys.len() + listed_prefixes.len()
);
assert!(
output.next_continuation_token().is_none(),
"NextContinuationToken should be None when IsTruncated is false"
);
// All 32 objects must be covered (listed_keys + keys under listed_prefixes)
let total_raw_count = expected_keys.len();
let keys_under_prefixes: usize = listed_prefixes
.iter()
.map(|p| {
let dir = p.trim_end_matches('/');
expected_keys.iter().filter(|k| k.starts_with(&format!("{dir}/"))).count()
})
.sum();
let covered = listed_keys.len() + keys_under_prefixes;
assert_eq!(
covered,
total_raw_count,
"Collapsed-prefix listing must cover all {} objects, got {} (keys={}, under_prefixes={})",
total_raw_count,
covered,
listed_keys.len(),
keys_under_prefixes
);
info!(
"Collapsed-prefix test passed: {} objects covered ({} keys + {} prefixes), IsTruncated=false",
covered,
listed_keys.len(),
listed_prefixes.len()
);
env.stop_server();
}
/// Regression test: delimiter listing pagination must traverse all keys
/// even when the visible result count per page is much smaller than the
/// raw entry count (due to CommonPrefix collapse).
///
/// Scenario: 1000 objects across 100 directories, paginated with max_keys=50.
/// Each page returns up to 50 CommonPrefixes. The server must correctly set
/// IsTruncated and provide a valid continuation token across all pages.
#[tokio::test]
#[serial]
async fn test_list_objects_v2_delimiter_small_page_traverses_all() {
init_logging();
info!("Starting test: ListObjectsV2 delimiter small page traverses all keys");
let mut env = RustFSTestEnvironment::new().await.expect("Failed to create test environment");
env.start_rustfs_server(vec![]).await.expect("Failed to start RustFS");
let client = create_s3_client(&env);
let bucket = "test-delimiter-small-page";
create_bucket(&client, bucket).await.expect("Failed to create bucket");
// Create 1000 objects: 100 directories with 10 files each
let mut all_keys = Vec::new();
let dirs: Vec<String> = (0..100).map(|i| format!("dir-{:03}/", i)).collect();
for dir in &dirs {
for i in 0..10 {
let key = format!("{dir}file{:02}.txt", i);
client
.put_object()
.bucket(bucket)
.key(&key)
.body(ByteStream::from_static(b"x"))
.send()
.await
.expect("Failed to put object");
all_keys.push(key);
}
}
// Paginate with delimiter="/" and max_keys=50
// Visible per page: up to 50 CommonPrefixes
let mut listed_keys = Vec::new();
let mut listed_prefixes = Vec::new();
let mut continuation_token: Option<String> = None;
let mut page_count = 0;
let mut last_page_is_truncated: bool;
loop {
let mut request = client.list_objects_v2().bucket(bucket).delimiter("/").max_keys(50);
if let Some(token) = continuation_token.take() {
request = request.continuation_token(token);
}
let output = request.send().await.expect("Failed to list objects");
last_page_is_truncated = output.is_truncated().unwrap_or(false);
for obj in output.contents() {
if let Some(key) = obj.key() {
listed_keys.push(key.to_string());
}
}
for cp in output.common_prefixes() {
if let Some(p) = cp.prefix() {
listed_prefixes.push(p.to_string());
}
}
page_count += 1;
if last_page_is_truncated {
continuation_token = output.next_continuation_token().map(|s| s.to_string());
assert!(
continuation_token.is_some(),
"BUG: NextContinuationToken must be present when IsTruncated is true"
);
} else {
break;
}
if page_count > 20 {
panic!("Too many pages, possible infinite loop in delimiter pagination");
}
}
// Last page must have IsTruncated=false
assert!(
!last_page_is_truncated,
"BUG: Last page must have IsTruncated=false after all results returned"
);
// Verify all objects are covered via listed prefixes
let keys_under_prefixes: HashSet<String> = all_keys
.iter()
.filter(|k| {
listed_prefixes
.iter()
.any(|p| k.starts_with(&format!("{}/", p.trim_end_matches('/'))))
})
.cloned()
.collect();
let listed_set: HashSet<String> = listed_keys.iter().cloned().collect();
let covered: HashSet<String> = listed_set.union(&keys_under_prefixes).cloned().collect();
let expected_set: HashSet<String> = all_keys.iter().cloned().collect();
assert_eq!(
covered,
expected_set,
"Delimiter pagination must cover all {} objects, missing: {:?}",
expected_set.difference(&covered).count(),
expected_set.difference(&covered)
);
info!(
"Delimiter small-page test passed: {} objects covered in {} pages",
covered.len(),
page_count
);
env.stop_server();
}
/// Regression test: raw entries exceed MaxKeys but all collapse into fewer
/// visible CommonPrefixes — IsTruncated must be false because there are no
/// more visible results beyond the collapsed prefixes.
///
/// Scenario: 10 directories with 200 files each = 2000 raw keys.
/// With MaxKeys=1000, the raw entry count exceeds MaxKeys (disk_has_more=true),
/// but after delimiter collapse only 10 CommonPrefixes are visible (10 < 1000).
/// IsTruncated must be false since there are no additional visible results.
#[tokio::test]
#[serial]
async fn test_list_objects_v2_raw_exceeds_maxkeys_but_visible_below() {
init_logging();
info!("Starting test: ListObjectsV2 raw > MaxKeys but visible < MaxKeys after collapse");
let mut env = RustFSTestEnvironment::new().await.expect("Failed to create test environment");
env.start_rustfs_server(vec![]).await.expect("Failed to start RustFS");
let client = create_s3_client(&env);
let bucket = "test-raw-exceeds-visible-below";
create_bucket(&client, bucket).await.expect("Failed to create bucket");
// Create 10 directories × 200 files = 2000 raw keys
let dir_count = 10;
let files_per_dir = 200;
let mut all_keys = Vec::new();
for d in 0..dir_count {
let dir = format!("dir-{:02}/", d);
for f in 0..files_per_dir {
let key = format!("{}file{:03}.txt", dir, f);
client
.put_object()
.bucket(bucket)
.key(&key)
.body(ByteStream::from_static(b"x"))
.send()
.await
.expect("Failed to put object");
all_keys.push(key);
}
}
// List with delimiter="/", max_keys=1000
// Visible: 10 CommonPrefixes (dir-00/ .. dir-09/) = 10 visible << 1000 MaxKeys
// But raw entry count (2000+1001 requested) exceeds MaxKeys
let output = client
.list_objects_v2()
.bucket(bucket)
.delimiter("/")
.max_keys(1000)
.send()
.await
.expect("Failed to list objects");
let listed_keys: Vec<String> = output
.contents()
.iter()
.filter_map(|obj| obj.key())
.map(|k| k.to_string())
.collect();
let listed_prefixes: Vec<String> = output
.common_prefixes()
.iter()
.filter_map(|cp| cp.prefix())
.map(|p| p.to_string())
.collect();
// KEY ASSERTION: IsTruncated must be false because visible results (10)
// are far below MaxKeys (1000), even though raw entries exceeded MaxKeys.
let is_truncated = output.is_truncated().unwrap_or(false);
assert!(
!is_truncated,
"BUG: IsTruncated should be false when visible results ({} objects + {} prefixes = {}) < MaxKeys (1000), even if raw entries exceeded MaxKeys",
listed_keys.len(),
listed_prefixes.len(),
listed_keys.len() + listed_prefixes.len()
);
assert!(
output.next_continuation_token().is_none(),
"NextContinuationToken should be None when IsTruncated is false"
);
// All objects must be covered by listed prefixes
assert_eq!(
listed_prefixes.len(),
dir_count,
"Expected {} prefixes, got {}",
dir_count,
listed_prefixes.len()
);
for d in 0..dir_count {
let prefix = format!("dir-{:02}/", d);
assert!(listed_prefixes.contains(&prefix), "Missing prefix: {}", prefix);
}
info!(
"Raw-exceeds-visible test passed: {} raw keys → {} prefixes, IsTruncated=false",
all_keys.len(),
listed_prefixes.len()
);
env.stop_server();
}
/// Regression test: pagination with MaxKeys exceeding S3 limit (1000) and delimiter.
/// The server caps MaxKeys to 1000. With delimiter="/", many raw keys collapse into
/// few CommonPrefixes. Visible results (prefixes) < capped MaxKeys → IsTruncated=false.
///
/// This complements test_list_objects_v2_max_keys_above_limit_returns_token which
/// tests the non-delimiter case.
#[tokio::test]
#[serial]
async fn test_list_objects_v2_maxkeys_above_limit_with_delimiter() {
init_logging();
info!("Starting test: ListObjectsV2 MaxKeys above limit with delimiter");
let mut env = RustFSTestEnvironment::new().await.expect("Failed to create test environment");
env.start_rustfs_server(vec![]).await.expect("Failed to start RustFS");
let client = create_s3_client(&env);
let bucket = "test-maxkeys-limit-delimiter";
create_bucket(&client, bucket).await.expect("Failed to create bucket");
// 12 dirs × 100 files = 1200 raw keys
let dir_count = 12;
let files_per_dir = 100;
for d in 0..dir_count {
for f in 0..files_per_dir {
let key = format!("dir-{:02}/file{:03}.txt", d, f);
client
.put_object()
.bucket(bucket)
.key(&key)
.body(ByteStream::from_static(b"x"))
.send()
.await
.expect("Failed to put object");
}
}
// With delimiter: 12 CommonPrefixes visible, all fit within capped 1000
let output = client
.list_objects_v2()
.bucket(bucket)
.delimiter("/")
.max_keys(2000)
.send()
.await
.expect("Failed to list objects");
assert_eq!(
output.common_prefixes().len(),
dir_count,
"Expected {} prefixes, got {}",
dir_count,
output.common_prefixes().len()
);
assert_eq!(output.max_keys(), Some(1000));
// 12 visible < 1000 capped MaxKeys → not truncated
assert!(
!output.is_truncated().unwrap_or(false),
"BUG: IsTruncated should be false when visible ({}) < capped MaxKeys (1000)",
output.common_prefixes().len()
);
info!("MaxKeys above limit with delimiter test passed");
env.stop_server();
}
}
@@ -0,0 +1,124 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use crate::common::{DEFAULT_ACCESS_KEY, DEFAULT_SECRET_KEY, RustFSTestEnvironment};
use serial_test::serial;
use std::path::Path;
use std::process::Command;
use std::time::Duration;
use tokio::fs;
use tokio::time::timeout;
use tracing::info;
const BUCKET: &str = "ddd";
const OBJECT_COUNT: usize = 484;
type TestResult = Result<(), Box<dyn std::error::Error + Send + Sync>>;
async fn create_issue_3107_fixture(root: &Path) -> TestResult {
for idx in 0..OBJECT_COUNT {
let object_path = root.join("ddd").join("requirements").join(format!("file-{idx:04}.txt"));
fs::create_dir_all(object_path.parent().expect("object parent should exist")).await?;
fs::write(object_path, format!("issue-3107 payload {idx}\n").repeat(128)).await?;
}
for dir in ["aaa", "ccc", "ccc-package"] {
let object_path = root.join(dir).join("marker.txt");
fs::create_dir_all(object_path.parent().expect("object parent should exist")).await?;
fs::write(object_path, dir.as_bytes()).await?;
}
Ok(())
}
fn mc_available() -> bool {
Command::new("mc")
.arg("--version")
.output()
.is_ok_and(|output| output.status.success())
}
fn run_mc(args: &[&str]) -> TestResult {
let output = Command::new("mc").args(args).output()?;
if !output.status.success() {
return Err(format!(
"mc {:?} failed: status={:?}, stdout={}, stderr={}",
args,
output.status.code(),
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
)
.into());
}
Ok(())
}
fn count_files(root: &Path) -> usize {
walkdir::WalkDir::new(root)
.into_iter()
.filter_map(Result::ok)
.filter(|entry| entry.file_type().is_file())
.count()
}
#[tokio::test]
#[serial]
async fn test_mc_mirror_small_bucket_completes_without_list_timeout() -> TestResult {
crate::common::init_logging();
info!("Starting issue #3107 mc mirror regression test");
if !mc_available() {
info!("Skipping issue #3107 mc mirror regression test because mc is not installed");
return Ok(());
}
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
env.create_test_bucket(BUCKET).await?;
let alias = format!("rustfs-issue-3107-{}", uuid::Uuid::new_v4());
run_mc(&["alias", "set", &alias, &env.url, DEFAULT_ACCESS_KEY, DEFAULT_SECRET_KEY])?;
let fixture = Path::new(&env.temp_dir).join("issue-3107-fixture");
let backup = Path::new(&env.temp_dir).join("issue-3107-backup");
fs::create_dir_all(&fixture).await?;
fs::create_dir_all(&backup).await?;
create_issue_3107_fixture(&fixture).await?;
let bucket_target = format!("{alias}/{BUCKET}");
let source_path = fixture.to_string_lossy().to_string();
run_mc(&["mirror", "--overwrite", &source_path, &bucket_target])?;
let backup_path = backup.join("ddd-backup");
let backup_path_string = backup_path.to_string_lossy().to_string();
timeout(
Duration::from_secs(20),
tokio::task::spawn_blocking({
let bucket_target = bucket_target.clone();
let backup_path_string = backup_path_string.clone();
move || run_mc(&["mirror", "--overwrite", &bucket_target, &backup_path_string])
}),
)
.await
.map_err(|_| "mc mirror timed out while listing/comparing a small bucket")???;
let mirrored_count = count_files(&backup_path);
assert_eq!(
mirrored_count,
OBJECT_COUNT + 3,
"mc mirror should copy every object from the issue #3107 small bucket fixture"
);
run_mc(&["alias", "remove", &alias])?;
Ok(())
}
@@ -0,0 +1,122 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use crate::common::RustFSTestClusterEnvironment;
use aws_sdk_s3::Client;
use aws_sdk_s3::error::SdkError;
use bytes::Bytes;
use serial_test::serial;
use std::sync::Arc;
use tokio::sync::Barrier;
use tracing::{info, warn};
const BUCKET: &str = "namespace-lock-quorum-bucket";
const KEY: &str = "thumb/79/concurrent-overwrite.jpg";
type TestResult = Result<(), Box<dyn std::error::Error + Send + Sync>>;
async fn put_object(client: Client, payload: Vec<u8>, writer_id: usize) -> Result<(), String> {
client
.put_object()
.bucket(BUCKET)
.key(KEY)
.body(Bytes::from(payload).into())
.send()
.await
.map(|_| ())
.map_err(|err| format_s3_error(err, writer_id))
}
fn format_s3_error(err: SdkError<aws_sdk_s3::operation::put_object::PutObjectError>, writer_id: usize) -> String {
match err {
SdkError::ServiceError(service_err) => {
let err = service_err.err();
let code = err.meta().code().unwrap_or("<unknown>");
let message = err.meta().message().unwrap_or("<empty>");
format!("writer {writer_id} returned service error {code}: {message}")
}
other => format!("writer {writer_id} returned SDK error: {other:?}"),
}
}
#[tokio::test]
#[serial]
async fn test_concurrent_cluster_overwrites_do_not_fail_namespace_lock_quorum() -> TestResult {
crate::common::init_logging();
info!("Starting namespace lock quorum regression test with auto cluster");
let mut cluster = RustFSTestClusterEnvironment::new(4).await?;
// Keep the regression focused on false quorum-loss errors, not ordinary lock
// wait exhaustion under a heavily contended same-key overwrite workload.
cluster.set_env("RUSTFS_OBJECT_LOCK_ACQUIRE_TIMEOUT", "20");
cluster.start().await?;
cluster.create_test_bucket(BUCKET).await?;
let clients = cluster.create_all_clients()?;
let first_payload = b"initial object contents".to_vec();
put_object(clients[0].clone(), first_payload, 0).await?;
let writer_count = clients.len() * 2;
let barrier = Arc::new(Barrier::new(writer_count));
let mut handles = Vec::with_capacity(writer_count);
for writer_id in 0..writer_count {
let client = clients[writer_id % clients.len()].clone();
let barrier = barrier.clone();
let payload = format!("replacement payload from writer {writer_id:02}").into_bytes();
handles.push(tokio::spawn(async move {
barrier.wait().await;
put_object(client, payload, writer_id).await
}));
}
let mut failures = Vec::new();
for handle in handles {
match handle.await {
Ok(Ok(())) => {}
Ok(Err(err)) => failures.push(err),
Err(err) => failures.push(format!("writer task join failed: {err}")),
}
}
if !failures.is_empty() {
for failure in &failures {
warn!("concurrent overwrite failure: {}", failure);
}
}
assert!(
failures.is_empty(),
"concurrent overwrites must not surface namespace lock quorum failures: {failures:#?}"
);
let body = clients[0]
.get_object()
.bucket(BUCKET)
.key(KEY)
.send()
.await?
.body
.collect()
.await?
.into_bytes();
let body = std::str::from_utf8(&body)?;
assert!(
body.starts_with("replacement payload from writer "),
"final object body should be one of the successful overwrite payloads, got {body:?}"
);
clients[0].delete_object().bucket(BUCKET).key(KEY).send().await?;
Ok(())
}
@@ -39,7 +39,7 @@
use crate::protocols::sftp_compliance_tests::{
cmptst_01, cmptst_02, cmptst_03, cmptst_04, cmptst_05, cmptst_06, cmptst_07, cmptst_08, cmptst_09, cmptst_10, cmptst_11,
cmptst_12, cmptst_13, cmptst_14, cmptst_15, cmptst_16, cmptst_17, cmptst_18, cmptst_19, cmptst_20, cmptst_21, cmptst_22,
cmptst_23, cmptst_27, cmptst_28, cmptst_29, cmptst_32, cmptst_33, spawn_compliance_rustfs,
cmptst_23, cmptst_27, cmptst_28, cmptst_29, cmptst_32, cmptst_33, cmptst_34, spawn_compliance_rustfs,
};
#[cfg(target_os = "linux")]
use crate::protocols::sftp_compliance_tests::{cmptst_24, cmptst_25, cmptst_26};
@@ -96,6 +96,15 @@ pub async fn test_sftp_compliance_suite() -> Result<()> {
cmptst_13::run_implicit_dir_round_trip(&sftp).await?;
cmptst_14::run_winscp_setstat_shape_on_handle(&sftp).await?;
// CMPTST-34 cross-checks the SFTP streaming-multipart write
// path against the S3 layer. The OPEN-time FileAttributes must
// reach the finalised object as x-amz-meta-* user metadata
// through the CreateMultipartUpload input field. The S3 client
// connects to the same rustfs process this suite already drives.
let s3 = build_test_s3_client(&format!("http://{COMPLIANCE_RW_S3_ADDRESS}"));
wait_for_s3_ready(&s3, 30).await?;
cmptst_34::run_open_attrs_round_trip_multipart(&sftp, &s3).await?;
drop(sftp);
session.disconnect(russh::Disconnect::ByApplication, "", "en").await?;
info!("SFTP compliance suite passed");
@@ -104,6 +104,11 @@
//! byte-exact with the production cache window.
//! - CMPTST-33: read-cache disabled regression, 8 MiB download
//! byte-exact with RUSTFS_SFTP_READ_CACHE_WINDOW_BYTES=0.
//! - CMPTST-34: OPEN with non-default FileAttributes followed by a
//! payload that crosses the 5 MiB multipart boundary preserves the
//! client-supplied mtime and permissions through the streaming
//! CreateMultipartUpload path. HeadObject through aws-sdk-s3
//! confirms the metadata reached the finalised S3 object.
use crate::common::rustfs_binary_path_with_features;
use crate::protocols::sftp_helpers::{
@@ -3276,6 +3281,80 @@ pub(crate) mod cmptst_33 {
}
}
// CMPTST-34: OPEN-time client attrs preservation across the streaming
// multipart write path. The payload crosses the 5 MiB part-size
// boundary so the driver transitions Buffering -> Streaming and
// finalises via CompleteMultipartUpload. The OPEN-supplied mtime and
// permissions must reach the resulting object as x-amz-meta-mtime and
// x-amz-meta-mode. The S3 client connects to the same rustfs process
// the shared-server suite already drives.
pub(crate) mod cmptst_34 {
use super::*;
const COMPLIANCE_TEST_OUTPUT_ID: &str = "CMPTST-34";
const REQUESTED_MTIME: u32 = 1_715_000_010;
const REQUESTED_MODE: u32 = 0o600;
pub(crate) async fn run_open_attrs_round_trip_multipart(sftp: &SftpSession, s3: &S3Client) -> Result<()> {
info!("{COMPLIANCE_TEST_OUTPUT_ID}: OPEN with mtime + mode, multi-part payload, streaming path");
let bucket = "complopenattrsmpbucket";
let bucket_path = format!("/{bucket}");
sftp.create_dir(&bucket_path).await?;
let path = format!("/{bucket}/attr-mp.bin");
// 6 MiB exceeds the 5 MiB part-size boundary so the streaming
// path runs at least one full UploadPart before the CLOSE-time
// CompleteMultipartUpload finalises the object.
let payload = vec![0xA5u8; 6 * 1024 * 1024];
let client_attrs = FileAttributes {
mtime: Some(REQUESTED_MTIME),
atime: Some(REQUESTED_MTIME),
permissions: Some(REQUESTED_MODE),
..FileAttributes::default()
};
let mut writer = sftp
.open_with_flags_and_attributes(&path, OpenFlags::CREATE | OpenFlags::TRUNCATE | OpenFlags::WRITE, client_attrs)
.await?;
writer.write_all(&payload).await?;
writer.flush().await?;
writer.shutdown().await?;
let head = s3
.head_object()
.bucket(bucket)
.key("attr-mp.bin")
.send()
.await
.map_err(|e| anyhow!("S3 HeadObject failed: {e:?}"))?;
let content_length = head.content_length().unwrap_or(0);
if content_length != payload.len() as i64 {
return Err(anyhow!("{COMPLIANCE_TEST_OUTPUT_ID} unexpected size: got {content_length} bytes"));
}
let metadata = head
.metadata()
.ok_or_else(|| anyhow!("{COMPLIANCE_TEST_OUTPUT_ID} HeadObject returned no metadata map"))?;
let mtime_value = metadata
.get("mtime")
.ok_or_else(|| anyhow!("{COMPLIANCE_TEST_OUTPUT_ID} mtime key missing on the object"))?;
if mtime_value != &REQUESTED_MTIME.to_string() {
return Err(anyhow!("{COMPLIANCE_TEST_OUTPUT_ID} mtime mismatch: got {mtime_value}"));
}
let mode_value = metadata
.get("mode")
.ok_or_else(|| anyhow!("{COMPLIANCE_TEST_OUTPUT_ID} mode key missing on the object"))?;
if mode_value != &REQUESTED_MODE.to_string() {
return Err(anyhow!("{COMPLIANCE_TEST_OUTPUT_ID} mode mismatch: got {mode_value}"));
}
sftp.remove_file(&path).await?;
sftp.remove_dir(&bucket_path).await?;
info!("PASS {COMPLIANCE_TEST_OUTPUT_ID}: multipart upload preserved mtime + mode end to end");
Ok(())
}
}
// Shared parameters for CMPTST-32 (cache enabled) and CMPTST-33 (cache
// disabled). Both cases seed the same fixture and download it
// end-to-end, then assert byte-count and SHA256 against the
@@ -651,6 +651,22 @@ async fn list_service_accounts(
Ok(response.json().await?)
}
async fn get_account_info(
env: &RustFSTestEnvironment,
signer_access_key: &str,
signer_secret_key: &str,
) -> Result<serde_json::Value, Box<dyn Error + Send + Sync>> {
let url = format!("{}/rustfs/admin/v3/accountinfo", env.url);
let response = signed_request(http::Method::GET, &url, signer_access_key, signer_secret_key, None, None).await?;
if response.status() != StatusCode::OK {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(format!("account info failed: {status} {body}").into());
}
Ok(response.json().await?)
}
async fn wait_for_service_accounts(
env: &RustFSTestEnvironment,
signer_access_key: &str,
@@ -2379,6 +2395,57 @@ async fn test_site_replication_replicates_group_policy_backed_access_real_dual_n
Ok(())
}
#[tokio::test]
#[serial]
async fn test_service_account_policy_from_accountinfo_round_trips_real_single_node() -> TestResult {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let account_info = get_account_info(&env, &env.access_key, &env.secret_key).await?;
let policy_str = account_info
.get("policy")
.and_then(|value| value.as_str())
.ok_or("account info policy should be a JSON string")?;
let policy: serde_json::Value = serde_json::from_str(policy_str)?;
let statements = policy
.get("Statement")
.and_then(|value| value.as_array())
.ok_or("account info policy should include Statement array")?;
assert!(!statements.is_empty(), "account info policy Statement should not be empty: {policy}");
let req = AddServiceAccountReq {
policy: Some(policy),
target_user: None,
access_key: "svcacct-info-sample".to_string(),
secret_key: "svcacct-info-sample-secret-key-123456".to_string(),
name: Some("svcacct-info-sample".to_string()),
description: Some("service account created from accountinfo sample policy".to_string()),
expiration: None,
comment: None,
};
let created = add_service_account(&env, &env.access_key, &env.secret_key, &req).await?;
assert_eq!(created.0, "svcacct-info-sample");
let listed =
wait_for_service_accounts(&env, &env.access_key, &env.secret_key, Some(&env.access_key), &["svcacct-info-sample"])
.await?;
assert!(
listed
.accounts
.iter()
.any(|account| account.access_key == "svcacct-info-sample"),
"created service account should be listed for parent user: {:?}",
listed.accounts
);
Ok(())
}
#[tokio::test]
#[serial]
async fn test_site_replication_replicates_multiple_service_accounts_real_dual_node() -> Result<(), Box<dyn Error + Send + Sync>> {
@@ -70,6 +70,36 @@ mod tests {
Ok(builder.into_inner().await?.into_inner())
}
fn build_archive_with_parent_dir_entry(victim_bucket: &str) -> Vec<u8> {
let path = format!("../{victim_bucket}/evil-injected.txt");
let data = b"injected-body";
let mut header = [0u8; 512];
header[..path.len()].copy_from_slice(path.as_bytes());
header[100..108].copy_from_slice(b"0000644\0");
header[108..116].copy_from_slice(b"0000000\0");
header[116..124].copy_from_slice(b"0000000\0");
let size = format!("{:011o}\0", data.len());
header[124..136].copy_from_slice(size.as_bytes());
header[136..148].copy_from_slice(b"00000000000\0");
header[148..156].fill(b' ');
header[156] = b'0';
header[257..263].copy_from_slice(b"ustar\0");
header[263..265].copy_from_slice(b"00");
let checksum: u32 = header.iter().map(|byte| *byte as u32).sum();
let checksum = format!("{:06o}\0 ", checksum);
header[148..156].copy_from_slice(checksum.as_bytes());
let mut archive = Vec::new();
archive.extend_from_slice(&header);
archive.extend_from_slice(data);
let padding = (512 - (data.len() % 512)) % 512;
archive.extend(std::iter::repeat_n(0, padding));
archive.extend_from_slice(&[0u8; 1024]);
archive
}
#[tokio::test]
#[serial]
async fn snowball_auto_extract_supports_minio_prefix_and_directory_markers() -> Result<(), Box<dyn Error + Send + Sync>> {
@@ -273,6 +303,49 @@ mod tests {
Ok(())
}
#[tokio::test]
#[serial]
async fn snowball_auto_extract_rejects_parent_dir_entry_without_cross_bucket_write()
-> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let client = env.create_s3_client();
let attacker_bucket = "snowball-traversal-source";
let victim_bucket = "snowball-traversal-victim";
let archive = build_archive_with_parent_dir_entry(victim_bucket);
client.create_bucket().bucket(attacker_bucket).send().await?;
client.create_bucket().bucket(victim_bucket).send().await?;
let err = client
.put_object()
.bucket(attacker_bucket)
.key("fixture.tar")
.metadata("Snowball-Auto-Extract", "true")
.body(ByteStream::from(archive))
.send()
.await
.expect_err("parent directory archive entry should be rejected");
let service_err = err.into_service_error();
assert_eq!(service_err.code(), Some("InvalidArgument"));
let victim_err = client
.head_object()
.bucket(victim_bucket)
.key("evil-injected.txt")
.send()
.await
.expect_err("rejected archive entry must not write into the victim bucket");
let victim_service_err = victim_err.into_service_error();
assert_eq!(victim_service_err.code(), Some("NotFound"));
env.stop_server();
Ok(())
}
#[tokio::test]
#[serial]
async fn snowball_auto_extract_prefers_exact_minio_prefix_over_suffix_fallback() -> Result<(), Box<dyn Error + Send + Sync>> {
+11 -3
View File
@@ -38,6 +38,7 @@ rustfs-filemeta.workspace = true
rustfs-utils = { workspace = true, features = ["full"] }
rustfs-rio.workspace = true
rustfs-signer.workspace = true
rustfs-tls-runtime.workspace = true
rustfs-checksums.workspace = true
rustfs-config = { workspace = true, features = ["constants", "notify", "audit"] }
rustfs-credentials = { workspace = true }
@@ -45,7 +46,9 @@ rustfs-common.workspace = true
rustfs-policy.workspace = true
rustfs-protos.workspace = true
rustfs-kms.workspace = true
rustfs-s3-common = { workspace = true }
rustfs-s3-types = { workspace = true }
rustfs-data-usage.workspace = true
rustfs-object-capacity.workspace = true
async-trait.workspace = true
bytes.workspace = true
byteorder = { workspace = true }
@@ -89,6 +92,7 @@ hyper.workspace = true
hyper-util.workspace = true
hyper-rustls.workspace = true
rustls.workspace = true
rustls-pki-types.workspace = true
tokio = { workspace = true, features = ["io-util", "sync", "signal","io-uring"] }
tonic.workspace = true
xxhash-rust = { workspace = true, features = ["xxh64", "xxh3"] }
@@ -103,7 +107,7 @@ memmap2 = { workspace = true }
libc.workspace = true
rustix = { workspace = true }
rustfs-madmin.workspace = true
rustfs-workers.workspace = true
rustfs-concurrency.workspace = true
reqwest = { workspace = true }
aes-gcm.workspace = true
aws-sdk-s3 = { workspace = true }
@@ -127,7 +131,7 @@ aws-smithy-http-client.workspace = true
metrics = { workspace = true }
[dev-dependencies]
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "test-util"] }
criterion = { workspace = true, features = ["html_reports"] }
temp-env = { workspace = true, features = ["async_closure"] }
tracing-subscriber = { workspace = true }
@@ -145,5 +149,9 @@ harness = false
name = "comparison_benchmark"
harness = false
[[bench]]
name = "rename_data_meta_benchmark"
harness = false
[lib]
doctest = false
@@ -0,0 +1,118 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use criterion::{BatchSize, BenchmarkId, Criterion, criterion_group, criterion_main};
use rustfs_filemeta::{ErasureAlgo, FileInfo, FileMeta};
use std::hint::black_box;
use std::time::Duration;
use time::OffsetDateTime;
use uuid::Uuid;
const VERSION_COUNT_CASES: &[usize] = &[1, 8, 32, 64];
const BENCH_BASE_TIME_UNIX_SECS: i64 = 1_700_000_000;
fn make_file_info(version_id: Uuid, data_dir: Uuid, size: i64, mod_time: OffsetDateTime) -> FileInfo {
FileInfo {
version_id: Some(version_id),
data_dir: Some(data_dir),
size,
mod_time: Some(mod_time),
metadata: [("etag".to_string(), format!("etag-{version_id}"))].into_iter().collect(),
erasure: rustfs_filemeta::ErasureInfo {
algorithm: ErasureAlgo::ReedSolomon.to_string(),
data_blocks: 4,
parity_blocks: 2,
block_size: 1024 * 1024,
index: 1,
distribution: vec![1, 2, 3, 4, 5, 6],
..Default::default()
},
..Default::default()
}
}
fn build_meta_with_versions(version_count: usize) -> FileMeta {
let mut meta = FileMeta::new();
let base_time = OffsetDateTime::from_unix_timestamp(BENCH_BASE_TIME_UNIX_SECS).expect("valid bench base timestamp");
for i in 0..version_count {
let fi = make_file_info(Uuid::new_v4(), Uuid::new_v4(), 64 * 1024, base_time - Duration::from_secs(i as u64));
meta.add_version(fi).expect("seed add_version should succeed");
}
meta
}
fn bench_rename_data_meta_path(c: &mut Criterion) {
let mut group = c.benchmark_group("rename_data_meta");
group.sample_size(20);
group.measurement_time(Duration::from_secs(10));
for &version_count in VERSION_COUNT_CASES {
let seeded = build_meta_with_versions(version_count);
let dst_buf = seeded.marshal_msg().expect("marshal seeded meta");
let base_time = OffsetDateTime::from_unix_timestamp(BENCH_BASE_TIME_UNIX_SECS).expect("valid bench base timestamp");
let replace_version_id = seeded
.versions
.first()
.and_then(|v| v.header.version_id)
.unwrap_or(Uuid::nil());
group.bench_with_input(BenchmarkId::new("read_modify_write", version_count), &version_count, |b, _| {
b.iter(|| {
let mut xlmeta = FileMeta::load(black_box(&dst_buf)).expect("load dst meta");
let search_version_id = Some(replace_version_id);
let has_old_data_dir = xlmeta.find_unshared_data_dir_for_version(search_version_id);
if let Some(old_data_dir) = has_old_data_dir {
let _ = xlmeta.data.remove_two(replace_version_id, old_data_dir);
}
let fi = make_file_info(replace_version_id, Uuid::new_v4(), 64 * 1024, base_time + Duration::from_millis(1));
xlmeta.add_version(fi).expect("add new version");
let out = xlmeta.marshal_msg().expect("marshal updated meta");
black_box(out);
});
});
let mut prepared = FileMeta::load(&dst_buf).expect("load prepared meta");
if let Some(old_data_dir) = prepared.find_unshared_data_dir_for_version(Some(replace_version_id)) {
let _ = prepared.data.remove_two(replace_version_id, old_data_dir);
}
group.bench_with_input(BenchmarkId::new("add_version_marshal_only", version_count), &version_count, |b, _| {
b.iter_batched(
|| prepared.clone(),
|mut xlmeta| {
let fi = make_file_info(replace_version_id, Uuid::new_v4(), 64 * 1024, base_time + Duration::from_millis(1));
xlmeta.add_version(fi).expect("add new version");
let out = xlmeta.marshal_msg().expect("marshal updated meta");
black_box(out);
},
BatchSize::SmallInput,
);
});
group.bench_with_input(BenchmarkId::new("remove_two_only", version_count), &version_count, |b, _| {
b.iter(|| {
let mut xlmeta = FileMeta::load(black_box(&dst_buf)).expect("load dst meta");
let removed = if let Some(old_data_dir) = xlmeta.find_unshared_data_dir_for_version(Some(replace_version_id)) {
xlmeta.data.remove_two(replace_version_id, old_data_dir).expect("remove two")
} else {
false
};
black_box(removed);
black_box(xlmeta);
});
});
}
}
criterion_group!(benches, bench_rename_data_meta_path);
criterion_main!(benches);
@@ -0,0 +1,484 @@
# RFC: Internode Transport Adapter Boundary
> Status: draft
> Last updated: 2026-05-22
> Scope: OSS internode data-plane adapter analysis, benchmark baseline, and
> transport boundary
## Summary
The current distributed internode paths use TCP-based HTTP/gRPC transports:
- `tonic` gRPC `NodeService` for most control, metadata, lock, health, and
peer operations.
- HTTP streaming routes under `/rustfs/rpc/` for remote disk file streams.
This document frames the existing work as an OSS `InternodeDataTransport`
adapter boundary. The adapter keeps RustFS data-plane logic separate from the
concrete transport backend while preserving the current TCP/HTTP behavior as
the default implementation.
Current implementation status:
- `InternodeDataTransport` exists in
`crates/ecstore/src/rpc/internode_data_transport.rs`.
- The default and only production backend is `tcp-http`; `tcp` is accepted as
an alias.
- `RUSTFS_INTERNODE_DATA_TRANSPORT` selects the backend. Blank or unset values
use `tcp-http`; invalid values fail closed.
- `RemoteDisk::read_file_stream`, `RemoteDisk::create_file`,
`RemoteDisk::append_file`, and `RemoteDisk::walk_dir` delegate to the
transport.
- `NodeService` gRPC remains the internode control plane and continues to carry
metadata/control operations.
Related design notes in this directory:
- `transport-capabilities.md`
- `transport-buffer-lifecycle.md`
- `transport-buffer-contract.md`
- `transport-fallback-and-selection.md`
- `transport-metrics-and-baseline.md`
## Open-source Scope
The OSS scope is:
- define a clear `InternodeDataTransport` adapter boundary;
- keep `tcp-http` as the default backend;
- keep existing TCP/HTTP behavior unchanged;
- keep internode data-plane behavior observable through metrics and baseline
tooling;
- document buffer ownership, fallback, and capability expectations for
maintainable transport code;
- avoid adding dependencies or backend implementations.
The OSS scope is not:
- adding another transport backend;
- replacing the gRPC control plane;
- adding benchmark plans for another transport;
- adding runtime plugin loading;
- changing object correctness semantics.
## Goals
- Document the current internode control plane and data plane.
- Identify the existing transfer paths covered by the
`InternodeDataTransport` adapter and the paths that remain on gRPC.
- Define the minimum benchmark baseline required before transport changes.
- Sketch a pluggable transport boundary that preserves the current TCP/HTTP
behavior as the default backend.
- Document backend-neutral capability, fallback, buffer ownership, and
observability expectations.
## Non-Goals
- Implement another transport backend.
- Replace `tonic` gRPC for control-plane RPCs.
- Redesign erasure coding, quorum handling, disk health tracking, or object
correctness semantics.
- Change default development, CI, or ordinary RustFS deployment requirements.
## Current Internode Architecture
### Server-side entry points
The main HTTP server builds a hybrid service per connection:
- `rustfs/src/server/http.rs` wires a `NodeServiceServer` for gRPC.
- `rustfs/src/storage/rpc/InternodeRpcService` intercepts HTTP paths under
`/rustfs/rpc/`.
- Other HTTP/S3 traffic continues through the normal S3 service.
Compression logic already treats `/rustfs/rpc/` and `/rustfs/peer/` as internode
RPC paths and skips normal response compression for them.
### gRPC channel management
`crates/protos/src/lib.rs` creates internode gRPC channels with `tonic`
`Endpoint`:
- connect timeout
- TCP keepalive
- HTTP/2 keepalive interval and timeout
- request timeout
- optional TLS configuration
- global channel caching and failed-connection eviction
This confirms the current gRPC transport is TCP/HTTP2-based.
### NodeService layout
`crates/protos/src/node.proto` defines one `NodeService` that mixes several
classes of RPCs:
- meta service: bucket and metadata operations
- disk service: local/remote disk operations
- lock service: distributed lock operations
- peer rest service: node health, metrics, IAM/policy reload, rebalance,
profiling, events, and admin-style operations
The service layout is practical today, but it is too broad to become the
transport adapter surface. A pluggable data transport should target only disk
data streams and keep this gRPC service as the control plane.
## Control Plane vs Data Plane
### Control plane
These paths carry coordination, metadata, health, and administrative state.
They should remain on gRPC/TCP:
| Area | Client/server code | Examples | Notes |
| --- | --- | --- | --- |
| Bucket peer ops | `crates/ecstore/src/rpc/peer_s3_client.rs`, `rustfs/src/storage/rpc/bucket.rs` | `MakeBucket`, `ListBucket`, `DeleteBucket`, `GetBucketInfo`, `HealBucket` | Small metadata/control payloads. |
| Locking | `crates/ecstore/src/rpc/remote_locker.rs`, `rustfs/src/storage/rpc/lock.rs` | `Lock`, `UnLock`, `Refresh`, batch lock/unlock | Latency-sensitive but not bulk data; correctness and timeout semantics matter more than transport bandwidth. |
| Peer/admin state | `crates/ecstore/src/rpc/peer_rest_client.rs`, `rustfs/src/storage/rpc/health.rs`, `metrics.rs`, `event.rs` | `LocalStorageInfo`, `ServerInfo`, `GetMetrics`, `GetLiveEvents`, reload APIs, rebalance APIs | Operational control plane. |
| Disk metadata/control | `crates/ecstore/src/rpc/remote_disk.rs`, `rustfs/src/storage/rpc/disk.rs` | `DiskInfo`, `ReadXL`, `ReadVersion`, `ReadMetadata`, `WriteMetadata`, `RenameFile`, `RenamePart`, `Delete*`, `VerifyFile`, `CheckParts` | Usually metadata, integrity checks, or namespace mutations. |
| Connection health | `RemoteDisk`, `RemotePeerS3Client`, `PeerRestClient` | TCP connectivity probes and fault/recovery state | Must remain available even if an optional data backend is unavailable. |
### Data plane candidates
These paths move object shard bytes or stream potentially large disk data and
are the only reasonable first candidates for a pluggable transport.
| Priority | Path | Current client | Current server | Current transport | Why it matters |
| --- | --- | --- | --- | --- | --- |
| P0 | `read_file_stream` | `RemoteDisk::read_file_stream` | `handle_read_file` in `http_service.rs` | HTTP `GET /rustfs/rpc/read_file_stream` with a streaming response body | Main remote disk read stream used by bitrot readers and erasure reads. |
| P0 | `put_file_stream` | `RemoteDisk::create_file` and `RemoteDisk::append_file` | `handle_put_file` in `http_service.rs` | HTTP `PUT /rustfs/rpc/put_file_stream` with a streaming request body | Main remote disk write stream used by bitrot writers and erasure writes. |
| P1 | `walk_dir` | `RemoteDisk::walk_dir` | `handle_walk_dir` in `http_service.rs` | HTTP `GET /rustfs/rpc/walk_dir` with a streamed metadata listing | Can be high-volume during scans/healing, but it is metadata-oriented rather than object byte data. |
| P1 | `ReadAll` / `WriteAll` | `RemoteDisk::read_all` / `write_all` | gRPC unary disk handlers | gRPC unary `bytes` payload | Moves bytes today, but should be measured before treating it as a high-throughput data path. |
| P2 | proto `WriteStream` / `ReadAt` | currently not used | currently returns unimplemented | gRPC streaming definitions exist but are not implemented | Declared proto shape, not a current production path. |
## P1 Data Path Inventory
Classification:
- Covered by `InternodeDataTransport`: `RemoteDisk` opens the transfer through
the transport abstraction.
- Still direct TCP/HTTP/gRPC: bytes move over a fixed internode protocol
outside the transport abstraction.
- Metadata/control-plane only: payloads are expected to be small metadata,
namespace, lock, health, or admin messages.
- Not relevant: declared or test-only paths that are not current production
data paths.
### Covered by `InternodeDataTransport`
| Path | Owner references | Server references | Classification | Notes |
| --- | --- | --- | --- | --- |
| Remote shard read stream | `crates/ecstore/src/rpc/remote_disk.rs::RemoteDisk::read_file_stream`; `crates/ecstore/src/rpc/internode_data_transport.rs::InternodeDataTransport::open_read`; `crates/ecstore/src/bitrot.rs::create_bitrot_reader` | `rustfs/src/storage/rpc/http_service.rs::handle_read_file` | Covered by `InternodeDataTransport` | Object GET, repair reads, and erasure decode use this path for remote shard bytes. |
| Remote shard write stream | `RemoteDisk::create_file`; `RemoteDisk::append_file`; `InternodeDataTransport::open_write`; `crates/ecstore/src/bitrot.rs::create_bitrot_writer` | `rustfs/src/storage/rpc/http_service.rs::handle_put_file` | Covered by `InternodeDataTransport` | Object PUT and multipart part upload use this path for remote shard bytes. |
| Remote namespace walk stream | `RemoteDisk::walk_dir`; `InternodeDataTransport::open_walk_dir`; `crates/ecstore/src/cache_value/metacache_set.rs` walk producers | `rustfs/src/storage/rpc/http_service.rs::handle_walk_dir` | Covered by `InternodeDataTransport` | High-volume listing/scanner/heal metadata stream. It is not object byte data, but it is a large internode stream. |
| Remote zero-copy read fallback | `RemoteDisk::read_file_zero_copy` | same as remote shard read stream | Covered by `InternodeDataTransport` through `read_file_stream` | The remote path buffers the stream into `Bytes`; true zero-copy is not guaranteed for remote disks. |
### Still Direct TCP/HTTP/gRPC
| Path | Owner references | Server references | Classification | Notes |
| --- | --- | --- | --- | --- |
| `ReadAll` | `RemoteDisk::read_all`; `crates/ecstore/src/store_init.rs`; heal resume metadata readers | `rustfs/src/storage/rpc/disk.rs::handle_read_all` | Still direct gRPC | Unary `bytes` response. Currently used mostly for metadata/config files; measure before moving. |
| `WriteAll` | `RemoteDisk::write_all`; `crates/ecstore/src/store_init.rs`; heal resume metadata writers | `rustfs/src/storage/rpc/disk.rs::handle_write_all` | Still direct gRPC | Unary `bytes` request. Currently used mostly for metadata/config/checkpoint writes. |
| `ReadMultiple` | `RemoteDisk::read_multiple`; `crates/ecstore/src/set_disk/read.rs::read_multiple_files` | `rustfs/src/storage/rpc/disk.rs::handle_read_multiple` | Still direct gRPC | Returns multiple small file payloads, usually metadata/listing support. Could become large with many entries. |
| `ReadParts` | `RemoteDisk::read_parts`; `crates/ecstore/src/set_disk/read.rs::read_parts`; multipart list/complete paths | `rustfs/src/storage/rpc/disk.rs::handle_read_parts` | Still direct gRPC | Encoded `ObjectPartInfo` metadata, not object data. |
| `RenamePart` | `RemoteDisk::rename_part`; `crates/ecstore/src/set_disk/write.rs::rename_part` | `rustfs/src/storage/rpc/disk.rs::handle_rename_part` | Still direct gRPC | Carries part metadata while committing multipart data already written through stream writers. |
| `ListDir` | `RemoteDisk::list_dir`; multipart/lifecycle metadata listing callers | `rustfs/src/storage/rpc/disk.rs::handle_list_dir` | Still direct gRPC | Directory name listing, metadata/control-plane unless measured otherwise. |
| Legacy gRPC `WalkDir` | `rustfs/src/storage/rpc/node_service.rs::NodeService::walk_dir` | same file | Still direct gRPC | Server implementation remains, but current `RemoteDisk::walk_dir` uses HTTP through the transport. Keep until callers are audited or compatibility policy is set. |
### Metadata/control-plane only
| Area | Owner references | Classification | Notes |
| --- | --- | --- | --- |
| Disk metadata and namespace mutations | `RemoteDisk::{read_metadata,write_metadata,update_metadata,read_version,read_xl,rename_data,rename_file,delete*,verify_file,check_parts,disk_info}` | Metadata/control-plane only | These remain on gRPC by design. |
| Peer/bucket/admin operations | `crates/ecstore/src/rpc/{peer_s3_client.rs,peer_rest_client.rs,remote_locker.rs}` and matching `rustfs/src/storage/rpc/*` handlers | Metadata/control-plane only | Not candidates for a data-plane backend without separate measurements. |
| Store init and format operations | `crates/ecstore/src/store_init.rs` | Metadata/control-plane only | Uses `ReadAll`/`WriteAll` for small format/config objects. |
| Heal orchestration | `crates/heal/src/heal/storage.rs` and `crates/ecstore/src/set_disk.rs::heal_object` | Metadata/control-plane plus covered data reads | Heal object data reads go through `get_object_reader` and then covered shard streams; resume/checkpoint metadata uses direct gRPC disk metadata calls. |
### Not Relevant Current Paths
| Path | Owner references | Classification | Notes |
| --- | --- | --- | --- |
| Proto `Write` | `crates/protos/src/node.proto`; `rustfs/src/storage/rpc/disk.rs::handle_write` | Not relevant | Handler is unimplemented. |
| Proto `WriteStream` | `crates/protos/src/node.proto`; `rustfs/src/storage/rpc/node_service.rs::write_stream` | Not relevant | Returns `unimplemented`. |
| Proto `ReadAt` | `crates/protos/src/node.proto`; `rustfs/src/storage/rpc/node_service.rs::read_at` | Not relevant | Returns `unimplemented`. |
| E2E reliant gRPC helpers | `crates/e2e_test/src/reliant/*` | Not relevant | Test harnesses, not production internode data-path callers. |
### Current Limitations
| Risk | Limitation |
| --- | --- |
| Medium | `ReadAll` and `WriteAll` still carry unary `bytes` over gRPC. They appear metadata-oriented today, but there is no size threshold or routing policy. |
| Medium | `ReadMultiple` can aggregate many metadata files into one gRPC response. |
| Low | Legacy gRPC `WalkDir` remains implemented while `RemoteDisk::walk_dir` uses HTTP through the transport. |
| Medium | Remote `read_file_zero_copy` is a buffered read over the transport, not a remote zero-copy contract. |
| Medium | Server-side TCP HTTP route handling is outside the client-side trait. |
## Current Object Write Path
For object PUTs in distributed erasure mode, the relevant flow is:
1. Upper storage layers prepare object data and erasure metadata.
2. `SetDisks` selects local and remote disks.
3. `create_bitrot_writer` calls `disk.create_file(...)` for each shard writer.
4. For a remote disk, `RemoteDisk::create_file` delegates to
`InternodeDataTransport::open_write`.
5. `HttpWriter` sends an HTTP `PUT` to `/rustfs/rpc/put_file_stream`.
6. The remote node's `handle_put_file` opens the local file writer and copies
incoming body chunks into it.
7. `Erasure::encode` writes shards through `MultiWriter` to all selected
writers while enforcing write quorum.
This is the primary write data-plane candidate.
## Current Object Read Path
For object GETs and repair reads in distributed erasure mode, the relevant flow is:
1. `SetDisks` prepares shard readers for the selected disks.
2. `create_bitrot_reader` uses local zero-copy only when `disk.is_local()`.
3. For a remote disk, it calls `disk.read_file_stream(...)`.
4. `RemoteDisk::read_file_stream` delegates to
`InternodeDataTransport::open_read`.
5. `HttpReader` sends an HTTP `GET` to `/rustfs/rpc/read_file_stream`.
6. The remote node's `handle_read_file` opens the local disk stream and returns
it as an HTTP streaming body.
7. The erasure decoder reads from the shard streams and reconstructs the object.
This is the primary read data-plane candidate.
## Existing Metrics and Benchmark Surface
RustFS already has coarse internode metrics in `crates/io-metrics/src/internode_metrics.rs`:
- sent bytes
- received bytes
- outgoing requests
- incoming requests
- errors
- dial errors
- average dial time
These metrics are useful as a starting point. For backend comparisons, the
relevant route-level and operation-level dimensions are:
- `read_file_stream`
- `put_file_stream`
- `walk_dir`
- gRPC `ReadAll` / `WriteAll`
- gRPC control-plane request volume
Existing benchmark assets:
- `scripts/run_object_batch_bench.sh`
- `scripts/run_object_batch_bench_enhanced.sh`
- `scripts/run_object_batch_bench_abc.sh`
- `scripts/run_four_node_cluster_failover_bench.sh`
- `scripts/run_internode_transport_baseline.sh` (scenario matrix wrapper for local vs distributed TCP baseline artifacts)
- Criterion benches under `crates/ecstore/benches/`
These mostly cover S3/object workload or erasure coding performance. They do
not yet isolate internode transport cost.
## Required TCP Baseline
Before changing internode data transport behavior or comparing a non-default
backend, collect a baseline for the current TCP/HTTP/gRPC implementation.
### Topology
Minimum:
- 1-node local erasure deployment, to measure local disk and erasure overhead.
- 4-node distributed erasure deployment, to measure internode overhead.
Preferred:
- Same host count and disk layout for every run.
- Dedicated network interface or isolated VLAN.
- Fixed CPU governor and no unrelated background load.
- Recorded kernel version, NIC model, MTU, RustFS commit, Rust toolchain, and
benchmark tool versions.
### Workloads
| Workload | Sizes | Concurrency | Main signal |
| --- | --- | --- | --- |
| S3 PUT | 4 KiB, 1 MiB, 16 MiB, 128 MiB, 1 GiB | 1, 16, 64, 128 | End-to-end write throughput and tail latency. |
| S3 GET | 4 KiB, 1 MiB, 16 MiB, 128 MiB, 1 GiB | 1, 16, 64, 128 | End-to-end read throughput and tail latency. |
| Remote disk stream read | shard-sized ranges from `read_file_stream` | 1, 16, 64 | Isolated internode read path. |
| Remote disk stream write | shard-sized writes through `put_file_stream` | 1, 16, 64 | Isolated internode write path. |
| Healing / repair | missing disk or missing shard scenario | controlled | Rebuild throughput and read/write amplification. |
| Scanner walk | large bucket/object namespace | controlled | Metadata streaming pressure, not the primary object-byte transport path. |
### Measurements
Collect:
- throughput in bytes/s and objects/s
- p50, p95, p99, and max latency
- CPU utilization per process and per core
- memory RSS and allocation pressure where available
- `rustfs_system_network_internode_*` metrics
- TCP retransmits, socket errors, and NIC throughput
- disk throughput and utilization
- failure/retry/fallback counts
The baseline should produce a machine-readable artifact, for example
`target/bench/internode-transport/<timestamp>/summary.csv`, plus the exact
commands and configuration used.
### Baseline runner entry point
Use `scripts/run_internode_transport_baseline.sh` to execute a reproducible
S3 PUT/GET matrix against `local` and `distributed` scenarios and export:
- `summary.csv` (throughput/latency summary per workload and object size)
- `internode_metric_deltas.csv` (operation-level internode metric deltas when
`--metrics-url` is provided)
See `transport-metrics-and-baseline.md` for current metric names, labels,
operation values, baseline inputs, and baseline artifact fields.
## Transport Abstraction Proposal
### Design principle
Keep `NodeService` as the control plane. Introduce a separate data transport
only below `RemoteDisk`, where remote disk byte streams are opened today.
The first implementation should be a no-behavior-change TCP/HTTP backend that
wraps the current `HttpReader`, `HttpWriter`, and `/rustfs/rpc/*` handlers.
Non-default backend work should not proceed until the default wrapper is
measured and adapter gaps are documented.
### Candidate boundary
The current boundary is remote disk stream transfer:
```rust
#[async_trait::async_trait]
pub trait InternodeDataTransport: Send + Sync + std::fmt::Debug {
async fn open_read(&self, request: ReadStreamRequest) -> Result<FileReader>;
async fn open_write(&self, request: WriteStreamRequest) -> Result<FileWriter>;
async fn open_walk_dir(&self, request: WalkDirStreamRequest) -> Result<FileReader>;
fn name(&self) -> &'static str;
fn capabilities(&self) -> InternodeDataTransportCapabilities;
}
```
Initial request fields should mirror the current HTTP query parameters:
- peer endpoint
- disk reference
- volume
- path
- offset
- length
- append/create mode
- expected size
- optional stall timeout for long-running listing streams
The initial TCP backend can keep the current signed HTTP URLs internally.
### Integration point
`RemoteDisk` delegates only these methods to the data transport:
- `read_file_stream`
- `read_file_zero_copy` as the current wrapper over `read_file_stream`
- `append_file`
- `create_file`
- `walk_dir`
All other `RemoteDisk` methods continue using the current gRPC client
in this adapter scope.
### Capability model
Avoid hard-coding transport-specific assumptions into the generic interface.
The current conservative capability fields are:
- streaming read
- streaming write
- streaming walk-dir
- ordered delivery
- maximum transfer size
- fallback support
The TCP/HTTP backend should report only capabilities that it actually provides.
## TCP Fallback Requirements
TCP/HTTP/gRPC must remain the default and required backend.
Fallback rules:
- If no explicit data transport is configured, use the current TCP/HTTP
implementation.
- The current accepted values for `RUSTFS_INTERNODE_DATA_TRANSPORT` are
`tcp-http` and the `tcp` alias. Empty and unset values use `tcp-http`.
- Invalid configured values fail closed with an error that includes the env var
name and invalid value.
- Unsupported configured backends fail closed during transport construction.
- Runtime fallback must preserve object correctness and quorum semantics.
- Fallback events must be logged and counted in metrics.
Do not add fallback settings until there is an implementation PR that uses them.
## Baseline Validation Commands
Dry-run command:
```bash
scripts/run_internode_transport_baseline.sh \
--access-key minioadmin \
--secret-key minioadmin \
--scenarios local=http://127.0.0.1:9000,distributed=http://127.0.0.1:9001 \
--sizes 4KiB,1MiB \
--concurrencies 1 \
--duration 10s \
--dry-run
```
Real TCP baseline command with metrics:
```bash
RUSTFS_INTERNODE_DATA_TRANSPORT=tcp-http \
scripts/run_internode_transport_baseline.sh \
--access-key "$RUSTFS_ACCESS_KEY" \
--secret-key "$RUSTFS_SECRET_KEY" \
--scenarios local=http://127.0.0.1:9000,distributed=http://127.0.0.1:9001 \
--metrics-url http://127.0.0.1:9000/metrics \
--out-dir target/bench/internode-transport/manual-run
```
Expected artifacts:
- `run_manifest.txt`
- `summary.csv`
- `internode_metric_deltas.csv` when `--metrics-url` is provided
The baseline validates the default TCP/HTTP path only. It must not be used to
claim support or performance for any other transport.
## Adapter Constraints
The current adapter boundary has these constraints:
- `tcp-http` is the default and only OSS backend.
- Backend selection is explicit and fail-closed.
- The gRPC control plane remains responsible for metadata, health, locks, and
coordination.
- Transport errors must preserve existing disk health, quorum, timeout, and
integrity semantics.
- Metrics must identify the selected backend and operation without high-cardinality
labels.
The current `RemoteDisk::walk_dir` stream is routed through the adapter.
Metadata RPCs, locks, admin RPCs, bucket coordination, and the legacy gRPC
`WalkDir` handler remain outside the current data-plane boundary.
## Out of Scope
This RFC does not add a plugin system, split the adapter into a separate crate,
add accepted backend values, or implement a new transport backend.
@@ -0,0 +1,91 @@
# Internode Transport Buffer Contract
Status: design note only. This document defines a backend-neutral buffer
ownership and lifecycle contract for the `InternodeDataTransport` adapter. It
does not implement a new backend and does not change production behavior.
## Open-source Scope
The open-source RustFS path keeps `tcp-http` as the default internode data
transport. This document defines adapter contracts only:
- no additional production backend is introduced;
- no dependency is added;
- no new accepted production backend value is added;
- RustFS core data-plane logic remains independent of the concrete transport
implementation.
## Current Adapter Surface
The current data-plane surface is byte-stream based:
| Current path | Current API shape | Current ownership |
| --- | --- | --- |
| Remote read stream | `InternodeDataTransport::open_read(...) -> FileReader` | Backend returns boxed `AsyncRead`; callers provide temporary `ReadBuf` storage per poll. |
| Remote write stream | `InternodeDataTransport::open_write(...) -> FileWriter` | Callers pass borrowed `&[u8]` slices into boxed `AsyncWrite`; the backend owns any async body staging. |
| Walk-dir stream | `InternodeDataTransport::open_walk_dir(...) -> FileReader` | Same boxed stream model as read, with a small serialized request body. |
This API is correct for the current TCP/HTTP backend. The adapter contract
describes current ownership boundaries without assuming implementation details
outside `TcpHttpInternodeDataTransport`.
## Buffer Ownership Model
| Buffer role | Allocator | Lifetime owner | Transport state | TCP/HTTP behavior |
| --- | --- | --- | --- | --- |
| Send buffer | Caller or RustFS-owned pool | Caller until the writer copies or accepts bytes for the HTTP body | Existing HTTP body staging | Copy into the existing `AsyncWrite` path when the writer cannot use the borrowed slice directly. |
| Receive buffer | Caller-provided storage | Reader while filling; caller after `poll_read` returns | Existing HTTP response body chunks | Copy from `AsyncRead` into caller storage as today. |
| Control metadata | RustFS caller | Caller/request object | Not buffer-managed by the data-plane backend | Serialize into HTTP/gRPC/control-plane messages. |
| Fallback staging | TCP/HTTP backend | TCP/HTTP backend | Existing `HttpReader`/`HttpWriter` buffers | Existing buffering semantics. |
The current writer must not retain borrowed caller slices beyond the write call.
When bytes must outlive the call, they are copied into owned HTTP body chunks.
This contract does not claim zero-copy behavior. The current TCP/HTTP path
documents where copies occur.
## Compatibility Contract
The current stream API remains the OSS compatibility contract:
```rust
#[async_trait::async_trait]
pub trait InternodeDataTransport {
async fn open_read(&self, request: ReadStreamRequest) -> Result<FileReader>;
async fn open_write(&self, request: WriteStreamRequest) -> Result<FileWriter>;
async fn open_walk_dir(&self, request: WalkDirStreamRequest) -> Result<FileReader>;
}
```
## Current Adapter Contract
| Area | Required contract |
| --- | --- |
| Ownership | Define when caller-owned bytes are copied or accepted by the transport. |
| Completion | Return from stream operations only when bytes are accepted or an error is reported. |
| Staging | Keep staging behavior inside the TCP/HTTP implementation. |
| Size limits | Report any RustFS-visible `max_transfer_size`; TCP/HTTP currently reports none. |
| Ordering | Preserve ordered byte-stream semantics. |
| Copy accounting | Document known copy boundaries and avoid unmeasured zero-copy claims. |
## Current API Limitations
| Current API | Current limitation |
| --- | --- |
| `FileReader = Box<dyn AsyncRead + Send + Sync + Unpin>` | `AsyncRead` exposes temporary caller `ReadBuf` storage. |
| `FileWriter = Box<dyn AsyncWrite + Send + Sync + Unpin>` | `AsyncWrite::poll_write` receives borrowed `&[u8]` that cannot outlive the poll. |
| `HttpWriter` | The async HTTP body must own `Bytes`, so borrowed write buffers are copied into `BytesMut` or `Bytes`. |
| `write_body_chunks_to_writer` | Server-side HTTP body chunks are copied into `BytesMut` before local disk write. |
| Erasure encode output | Encoded shards are represented as `Vec<Bytes>` and written through `AsyncWrite`. |
| Erasure decode input | Shard reads allocate `Vec<u8>` buffers before decode. |
These limitations do not block the current `tcp-http` backend.
## Adapter Stability
`InternodeDataTransport` should keep RustFS core data-plane logic separate from
the concrete transport implementation. The trait and `tcp-http` backend remain
inside `ecstore`.
This PR does not perform a crate split, add runtime loading, introduce a plugin
system, add a backend value, or implement a new transport backend.
@@ -0,0 +1,122 @@
# Internode Buffer Lifecycle and Copy Count
Status: P1-D analysis only. This document records the current TCP/HTTP
internode data path and the ownership boundaries that matter for the
backend-neutral `InternodeDataTransport` adapter. It does not implement a new
backend or change production behavior.
## Open-source Scope
The OSS scope is:
- define buffer ownership and copy-count behavior for the current
`InternodeDataTransport` adapter;
- keep `tcp-http` as the default backend;
- keep existing TCP/HTTP behavior unchanged;
- document copy hotspots and ownership gaps for maintainable transport code;
- avoid adding dependencies or backend implementations.
The OSS scope is not:
- adding another transport backend;
- replacing the current TCP/HTTP path;
- adding benchmark plans for another transport;
- changing object correctness semantics.
## Scope
The covered paths are the large internode data-plane calls currently routed
through `InternodeDataTransport`:
| Path | Entry | Transport owner | Server owner |
| --- | --- | --- | --- |
| Read stream | `RemoteDisk::read_file_stream` in `crates/ecstore/src/rpc/remote_disk.rs` | `TcpHttpInternodeDataTransport::open_read` in `crates/ecstore/src/rpc/internode_data_transport.rs`, `HttpReader` in `crates/rio/src/http_reader.rs` | `handle_read_file` in `rustfs/src/storage/rpc/http_service.rs` |
| Write stream | `RemoteDisk::create_file`, `RemoteDisk::append_file` in `crates/ecstore/src/rpc/remote_disk.rs` | `TcpHttpInternodeDataTransport::open_write` in `crates/ecstore/src/rpc/internode_data_transport.rs`, `HttpWriter` in `crates/rio/src/http_reader.rs` | `handle_put_file` in `rustfs/src/storage/rpc/http_service.rs` |
| Walk dir stream | `RemoteDisk::walk_dir` in `crates/ecstore/src/rpc/remote_disk.rs` | `TcpHttpInternodeDataTransport::open_walk_dir`, `HttpReader` | `handle_walk_dir` in `rustfs/src/storage/rpc/http_service.rs` |
Object read/write/heal callers enter these streams through
`create_bitrot_reader` and `create_bitrot_writer` in
`crates/ecstore/src/bitrot.rs`. Erasure decode and encode then move data
through `ParallelReader` in `crates/ecstore/src/erasure_coding/decode.rs` and
`MultiWriter` in `crates/ecstore/src/erasure_coding/encode.rs`.
## Read Stream
| Step | Owner | Buffer type | Copy? | Reason |
| --- | --- | --- | --- | --- |
| Build request | `RemoteDisk::read_file_stream` | `String` fields in `ReadStreamRequest` | Yes | Volume, path, endpoint, and disk references are copied into an owned request before async transport dispatch. This is metadata, not payload. |
| Select transport | `TcpHttpInternodeDataTransport::open_read` | URL `String`, `HeaderMap` | Yes | URL and auth headers are HTTP control data. No object bytes are copied here. |
| Open local file on server | `handle_read_file`, `LocalDisk::read_file_stream` | `FileCacheReclaimReader` boxed as `FileReader` | No payload copy | The server owns an async file reader positioned at the requested offset. |
| File to HTTP body | `read_file_body_stream` | `ReaderStream<AsyncRead>` yielding `Bytes` | Yes | `ReaderStream::with_capacity` reads from the file into chunk buffers. This is the file-to-network buffer materialization point. |
| Length limiting | `rustfs_utils::net::bytes_stream` | `Bytes` | Usually no | `Bytes::truncate` adjusts the chunk view when the last chunk exceeds the requested length. It does not copy the retained prefix. |
| HTTP receive | `HttpReader::with_capacity_and_stall_timeout` | `reqwest::Response::bytes_stream()` yielding `Bytes` | Network stack dependent | The user-level object is `Bytes`; any kernel/TLS/hyper copy is below the current RustFS abstraction. |
| Stream to caller buffer | `HttpReader::poll_read` | `StreamReader<Stream<Item = Bytes>>`, caller `ReadBuf` | Yes | `StreamReader` exposes `AsyncRead`, so it copies bytes from each `Bytes` chunk into the caller-provided `ReadBuf`. |
| Bitrot verification | `BitrotReader::read` | caller `&mut [u8]`, `hash_buf: Vec<u8>` | No additional payload copy | The bitrot reader reads hash bytes into `hash_buf` and payload bytes directly into the supplied output slice. Hash calculation reads the slice. |
| Erasure shard read | `ParallelReader::read` | `Vec<u8>` per shard | Yes | Each shard read allocates `vec![0u8; shard_size]`; data is filled there before decode/reconstruction. |
| Object response write | `write_data_blocks` | slices of shard `Vec<u8>` | No extra staging copy | Decoded data block slices are written to the target writer with `write_all`; the target may copy internally. |
| Remote zero-copy helper | `RemoteDisk::read_file_zero_copy` | `Vec<u8>` then `Bytes` | Yes | The remote implementation reads the full stream into a `Vec` and converts it into `Bytes`. It is a convenience fallback, not network zero-copy. |
## Write Stream
| Step | Owner | Buffer type | Copy? | Reason |
| --- | --- | --- | --- | --- |
| Build writer request | `RemoteDisk::create_file`, `RemoteDisk::append_file` | `String` fields in `WriteStreamRequest` | Yes | Volume, path, endpoint, and disk references are copied into an owned request. This is metadata. |
| Select transport | `TcpHttpInternodeDataTransport::open_write` | URL `String`, `HeaderMap` | Yes | URL and auth headers are HTTP control data. No object bytes are copied here. |
| Erasure encode input | `Erasure::encode` in `encode.rs` | reusable `Vec<u8>` sized to `block_size` | Yes | `rustfs_utils::read_full` fills a block buffer from the source reader before encoding. |
| Erasure encode output | `Erasure::encode_data` caller in `encode.rs` | `Vec<Bytes>` per encoded block | Yes | Encoding creates shard `Bytes` for data and parity blocks before queueing them to writers. |
| Multi-writer fanout | `MultiWriter::write` | borrowed `Bytes` shards | No additional fanout copy | The writer fanout passes borrowed `Bytes` references to each `BitrotWriterWrapper`. |
| Bitrot write | `BitrotWriter::write` | shard `&[u8]`, checksum bytes | Yes for checksum bytes | The payload slice is passed to the inner writer, while checksum bytes are generated and written before payload when enabled. |
| Client HTTP writer buffer | `HttpWriter::poll_write` and `poll_write_vectored` | `BytesMut` pending chunk or `Bytes::copy_from_slice` | Yes | Small writes are coalesced with `BytesMut::extend_from_slice`; large single writes still copy into an owned `Bytes` because the async body must outlive the caller's borrowed buffer. |
| Client channel to reqwest | `HttpWriter::poll_send_pending_chunk`, `ReceiverStream` | `Bytes` | No | `BytesMut::split().freeze()` transfers owned chunk storage to `Bytes`; the mpsc channel and stream move the `Bytes` handle. |
| HTTP receive body on server | `handle_put_file` | `Incoming::into_data_stream()` yielding `Bytes` | Network stack dependent | The server receives owned `Bytes` chunks from hyper. |
| Server body coalescing | `write_body_chunks_to_writer` | `BytesMut` sized to `DEFAULT_READ_BUFFER_SIZE` | Yes | Each incoming `Bytes` chunk is copied into `pending` before writing to the local file writer. This normalizes chunk size but adds a full payload copy. |
| Local file write | `LocalDisk::create_file`, `LocalDisk::append_file`, `FileCacheReclaimWriter` | `&[u8]` into `tokio::fs::File` | Kernel dependent | RustFS passes slices to Tokio file writes. Kernel page-cache copies are below the RustFS abstraction. |
## Request and Serialization Boundaries
| Boundary | Owner | Buffer type | Copy? | Notes |
| --- | --- | --- | --- | --- |
| Read/write query parameters | `build_read_file_stream_url`, `build_put_file_stream_url` | URL-encoded `String` | Yes | Metadata only. It includes disk, volume, path, offset, length, append, and size. |
| Auth headers | `build_auth_headers` callers | `HeaderMap` | Yes | Metadata only. This is currently tied to HTTP request construction. |
| Walk dir request | `RemoteDisk::walk_dir`, `open_walk_dir`, `handle_walk_dir` | JSON `Vec<u8>` body, collected `Bytes` on server | Yes | Walk dir is a streamed response but its request body is serialized JSON control data. |
| gRPC read/write-all | `RemoteDisk::read_all`, `RemoteDisk::write_all`, `NodeService::{handle_read_all,handle_write_all}` | Prost `Bytes`/message bodies | Yes | These paths are still gRPC byte paths, not `InternodeDataTransport`; they matter for metrics and inventory but are outside this P1-D stream-copy count. |
## Hotspots
| Rank | Hotspot | Impact | Reason |
| --- | --- | --- | --- |
| 1 | `HttpWriter::poll_write` and `poll_write_vectored` | High on write path | Every borrowed caller buffer is copied into owned `BytesMut` or `Bytes` before it can be sent by an async HTTP body. |
| 2 | `write_body_chunks_to_writer` | High on write path | The server copies every received `Bytes` chunk into a coalescing `BytesMut` before local disk write. |
| 3 | `ParallelReader::read` shard buffers | High on read path | Each shard read allocates and fills a `Vec<u8>` before decode can proceed. This is also where degraded reads wait on quorum. |
| 4 | `ReaderStream::with_capacity` plus `StreamReader` | Medium on read path | Server file reads create `Bytes` chunks, then client `AsyncRead` copies those chunks into the caller's `ReadBuf`. |
| 5 | `Erasure::encode` block and shard materialization | Medium on write path | Source data is first read into a block `Vec<u8>`, then encoded into per-shard `Bytes`. This is necessary for the current erasure API. |
| 6 | `RemoteDisk::read_file_zero_copy` | Medium when used | Remote zero-copy reads buffer the whole stream into memory. The name does not mean zero-copy over the network. |
| 7 | URL/query/header/JSON serialization | Low | Metadata copies are small and not on the large payload hot path. |
## Adapter Ownership Gaps
1. `FileReader` and `FileWriter` are boxed `AsyncRead`/`AsyncWrite` trait
objects. They expose borrowed buffers per poll, not stable backend-owned
regions, transfer handles, or explicit completion ownership.
2. `InternodeDataTransport` currently returns stream traits only. Its
capabilities advertise that TCP/HTTP does not require backend-specific
buffer registration and is not a zero-copy candidate, but there is no
backend API to pass stable backend-managed buffers.
3. `HttpWriter` must own outgoing chunks because the async request body outlives
the caller's borrowed `&[u8]`. A lower-copy backend would need a different
lifetime contract or an owned buffer pool.
4. Server write handling normalizes all incoming body chunks into a new
`BytesMut`. Avoiding that copy would require passing incoming `Bytes` or
backend-owned receive buffers directly into the disk/bitrot writer contract.
5. Erasure decode owns shard `Vec<u8>` buffers and write-back happens through
`AsyncWrite`. A lower-copy backend would need explicit ownership of shard
buffers across decode, reconstruction, and network completion.
6. Erasure encode materializes `Vec<Bytes>` blocks before fanout. A
backend that can send multiple stable slices would need an encode output
representation that can be transferred without repacking.
7. The HTTP auth and URL construction boundary is part of the current TCP/HTTP
backend. A non-HTTP backend would need equivalent peer authentication and
disk addressing without assuming URL query parameters.
8. Local disk zero-copy exists only for local reads via `read_file_zero_copy`.
Remote disks deliberately fall back to network streaming and full-buffer
collection for the zero-copy helper.
@@ -0,0 +1,67 @@
# Internode Transport Capabilities
Status: design note for backend-neutral capability reporting. This document
does not add a backend or transport crate.
## Open-source Scope
The OSS scope is:
- define honest capability reporting for the `InternodeDataTransport` adapter;
- keep `tcp-http` as the default backend;
- keep existing TCP/HTTP behavior unchanged;
- document the capability fields needed for maintainable transport code;
- avoid adding dependencies or backend implementations.
The OSS scope is not:
- adding another transport backend;
- replacing the current TCP/HTTP path;
- adding benchmark plans for another transport;
- changing object correctness semantics.
## Purpose
`InternodeDataTransportCapabilities` describes what a backend can honestly do
for RustFS internode data-plane transfers. The fields describe observable
adapter behavior without naming a specific transport implementation.
The capability report is descriptive. It does not select a backend, negotiate
with peers, or weaken object correctness semantics.
## Capability Fields
| Field | Meaning |
| --- | --- |
| `streaming_read` | The backend can open a remote disk reader for `read_file_stream`. |
| `streaming_write` | The backend can open a remote disk writer for `create_file` or `append_file`. |
| `streaming_walk_dir` | The backend can stream `walk_dir` responses. |
| `ordered_delivery` | Bytes for each opened transfer are delivered in order. |
| `max_transfer_size` | Optional RustFS-level cap for a single transfer. `None` means no additional cap beyond the backend/protocol/runtime limits. |
| `fallback_supported` | The backend can participate in the behavior-preserving TCP fallback path. |
## TCP/HTTP Backend
The default TCP/HTTP backend reports only capabilities it actually provides:
| Field | TCP/HTTP value | Reason |
| --- | --- | --- |
| `streaming_read` | `true` | `HttpReader` streams `/rustfs/rpc/read_file_stream` responses. |
| `streaming_write` | `true` | `HttpWriter` streams `/rustfs/rpc/put_file_stream` request bodies. |
| `streaming_walk_dir` | `true` | `HttpReader` streams `/rustfs/rpc/walk_dir` responses. |
| `ordered_delivery` | `true` | Each HTTP request body or response body is consumed as an ordered byte stream. |
| `max_transfer_size` | `None` | RustFS does not impose an extra per-transfer cap at the capability layer. |
| `fallback_supported` | `true` | TCP/HTTP is the behavior-preserving default and fallback path. |
## Capability Compatibility
Any new capability field should describe observable RustFS behavior without
assuming a specific transport implementation:
| Capability shape | Interpretation |
| --- | --- |
| `max_transfer_size=Some(n)` | The backend has a RustFS-visible transfer size ceiling and callers must split larger transfers or use fallback behavior. |
| `ordered_delivery=false` | The backend cannot be used behind the current stream API without an ordering or reassembly layer. |
Unsupported or mismatched capabilities must not silently change quorum,
integrity verification, retry, or timeout semantics.
@@ -0,0 +1,135 @@
# Internode Transport Fallback and Backend Selection Model
Status: design note only. This document defines backend-neutral selection,
fallback, failure handling, negotiation, security, and observability rules for
the `InternodeDataTransport` adapter. It does not implement a new backend and
does not change production behavior.
## Open-source Scope
The open-source RustFS path keeps `tcp-http` as the default internode data
transport. This document defines adapter contracts only:
- no additional production backend is introduced;
- no dependency is added;
- no new accepted production backend value is added;
- RustFS core data-plane logic remains independent of the concrete transport
implementation.
## Static Backend Selection
Static config is the first selection model. Existing accepted values remain:
| Config value | Meaning |
| --- | --- |
| unset | Use default TCP/HTTP backend. |
| `tcp-http` | Use default TCP/HTTP backend. |
| `tcp` | Alias for `tcp-http`. |
| any unsupported value | Fail closed with a diagnostic naming `RUSTFS_INTERNODE_DATA_TRANSPORT` and the invalid value. |
Unknown backend values must fail closed. Unsupported backend values must fail
closed. Any additional backend value must be explicitly added and must not
silently replace `tcp-http`.
Backend selection must expose an observable backend identity for metrics, logs,
and benchmark interpretation. The default and fallback path remains `tcp-http`.
## Fallback Contract
Fallback must be explicit and observable. Silent fallback is not allowed for
benchmark or production interpretation because it hides which backend moved the
payload.
| Condition | Default behavior | Explicit fallback behavior | Observability |
| --- | --- | --- | --- |
| Unsupported configured backend | Fail closed during transport construction. | Fall back only when a separately configured policy explicitly allows unsupported-backend fallback. | Error includes config key and invalid value; fallback event is counted when fallback is enabled. |
| Peer does not support selected backend | Fail before payload transfer. | Use TCP/HTTP only when both local policy and peer policy allow it. | Count peer mismatch and selected fallback backend. |
| Capability mismatch | Fail before payload transfer. | Use TCP/HTTP only if it satisfies the operation and policy allows fallback. | Record missing capability names or a low-cardinality reason. |
| Connection setup failure | Fail the operation. | Retry on TCP/HTTP only when fallback is allowed and no payload bytes have transferred. | Count setup failure, retry, fallback backend, and fallback result. |
| Partial transfer failure | Fail the operation and let existing object/quorum logic decide retry behavior. | Do not silently resume on another backend unless the transfer protocol can prove byte range, checksum, and idempotency boundaries. | Count partial failure with bytes completed. |
| Max transfer size exceeded | Fail before payload transfer or split at a higher layer. | Use TCP/HTTP if policy allows and TCP has no RustFS-level cap. | Record rejected size and selected backend. |
| Auth or encryption mismatch | Fail closed. | No fallback unless the fallback path satisfies the same or stronger security requirements. | Security failure metric and audit log entry. |
Fallback settings should not be added until there is an implementation that
uses them. A backend must define failure behavior before production use.
## Dynamic Negotiation Boundary
Dynamic negotiation is not implemented by this PR. If it is added later, it
belongs on the existing gRPC control plane. Data transfer must start only after
both peers agree on:
| Negotiated item | Required property |
| --- | --- |
| Backend name | Both peers know the backend and have it enabled. |
| Capability set | Required capabilities match the operation. |
| Max transfer size | The selected operation fits or is split before transfer starts. |
| Buffer rules | Both peers agree on staging and ownership rules. |
| Completion semantics | Both peers agree when a transfer is considered complete and when buffers may be reused. |
| Security mode | Authentication and encryption requirements are satisfied before any out-of-band transfer. |
| Fallback policy | Both peers agree whether TCP/HTTP fallback is allowed for this operation. |
Negotiation must not silently downgrade security or bypass existing disk
health, quorum, timeout, and integrity semantics.
## Failure Handling Requirements
| Failure mode | Requirement |
| --- | --- |
| Invalid config | Fail closed with `RUSTFS_INTERNODE_DATA_TRANSPORT` and the invalid value. |
| Backend disabled | Fail closed with the selected backend name and the missing enablement condition. |
| Backend unavailable | Fail closed with an actionable diagnostic; do not silently use TCP/HTTP. |
| Peer mismatch | Fail before payload transfer unless explicit fallback is configured. |
| Connection failure | Fail the operation and record setup failure; fallback only if policy allows and no payload bytes moved. |
| Completion failure | Return an operation error and release backend-owned resources. |
| Timeout | Return an operation error and preserve existing disk health and quorum semantics. |
| Partial transfer | Do not silently resume on another backend without a safe byte-range/checksum/idempotency proof. |
| Unsupported operation | Return a clear unsupported-operation error. |
## Security Requirements
- Backend selection must preserve peer authentication.
- Fallback must not weaken encryption or authorization.
- Partial transfers must not bypass bitrot verification or erasure quorum
handling.
- Any adapter implementation must preserve the same request authority, disk,
volume, path, and operation binding as the current TCP/HTTP path.
## Metrics and Observability Requirements
Metrics and logs must use low-cardinality labels or metadata:
- selected backend;
- requested backend;
- fallback backend, when used;
- operation name;
- success/failure;
- transferred bytes;
- setup failure count;
- partial transfer failure count;
- capability mismatch count;
- fallback decision count.
Adapter implementations must not add high-cardinality labels such as object
names, full paths, full URLs, peer-specific dynamic strings, memory addresses,
or buffer identifiers.
## TCP/HTTP Compatibility
The `tcp-http` backend remains the default and behavior-preserving path. It
uses ordinary byte streams, does not require backend-specific buffer
registration, and remains suitable as the fallback path when an explicit
fallback policy exists.
Any adapter implementation must not change the correctness semantics of
object writes, object reads, healing, bitrot verification, erasure quorum,
timeouts, or disk health handling.
## Adapter Stability
`InternodeDataTransport` should keep RustFS core data-plane logic separate from
the concrete transport implementation. The trait and `tcp-http` backend remain
inside `ecstore`.
This PR does not perform a crate split, add runtime loading, introduce a plugin
system, add a backend value, or implement a new transport backend.
@@ -0,0 +1,143 @@
# Internode Transport Metrics and Baseline
Status: current OSS behavior. This document describes the metrics and baseline
runner used to observe the existing `tcp-http` internode transport adapter. It
does not add a backend, change runtime behavior, or make performance claims.
## Scope
The current OSS adapter scope is:
- `tcp-http` is the default and only supported internode data transport backend;
- `tcp` is a legacy alias for `tcp-http`;
- unsupported backend values fail closed during transport construction;
- metrics and baseline artifacts are used to understand the current data-plane
behavior.
## Operation Metrics
Operation-level internode metrics are defined in
`crates/io-metrics/src/internode_metrics.rs`.
| Metric | Labels | Meaning |
| --- | --- | --- |
| `rustfs_system_network_internode_operation_sent_bytes_total` | `operation`, `backend` | Bytes sent for a known internode operation. |
| `rustfs_system_network_internode_operation_recv_bytes_total` | `operation`, `backend` | Bytes received for a known internode operation. |
| `rustfs_system_network_internode_operation_requests_outgoing_total` | `operation`, `backend` | Outgoing requests opened for a known internode operation. |
| `rustfs_system_network_internode_operation_requests_incoming_total` | `operation`, `backend` | Incoming requests handled for a known internode operation. |
| `rustfs_system_network_internode_operation_errors_total` | `operation`, `backend` | Errors observed for a known internode operation. |
Current operation label values are:
| Operation | Transport path | Notes |
| --- | --- | --- |
| `read_file_stream` | HTTP `/rustfs/rpc/read_file_stream` | Remote disk read stream opened by `InternodeDataTransport::open_read`. |
| `put_file_stream` | HTTP `/rustfs/rpc/put_file_stream` | Remote disk write stream opened by `InternodeDataTransport::open_write`. |
| `walk_dir` | HTTP `/rustfs/rpc/walk_dir` | Remote walk-dir stream opened by `InternodeDataTransport::open_walk_dir`. |
| `grpc_read_all` | gRPC `ReadAll` | Unary bytes response outside the adapter. |
| `grpc_write_all` | gRPC `WriteAll` | Unary bytes request outside the adapter. |
Current backend label values are:
| Backend | Meaning |
| --- | --- |
| `tcp-http` | Current HTTP stream backend for adapter-routed operations. |
| `grpc` | Current gRPC path for `ReadAll` and `WriteAll`. |
| `unknown` | Aggregate internode metric path when an operation-specific backend is not available. |
The `backend` label reflects the current instrumentation source, for example
the `tcp-http` stream path, gRPC path, or aggregate unknown path. It does not
imply additional supported transport backends.
Metric labels must stay low cardinality. Do not add object names, full paths,
full URLs, peer-specific dynamic strings, request IDs, or other per-request
values as labels.
## Current Instrumentation Points
| Area | File | Current signal |
| --- | --- | --- |
| Outgoing HTTP stream requests | `crates/rio/src/http_reader.rs` | Outgoing request count, sent bytes for writer bodies, received bytes for reader bodies, and errors for known `/rustfs/rpc/` operations. |
| Incoming HTTP stream requests | `rustfs/src/storage/rpc/http_service.rs` | Incoming request count, sent bytes for read and walk streams, received bytes for put streams, and route errors. |
| gRPC `ReadAll` / `WriteAll` | `rustfs/src/storage/rpc/disk.rs` | Incoming request count, sent/received bytes, and errors with the `grpc` backend label. |
Known gaps:
- Operation-level metrics do not currently expose latency histograms.
- The baseline runner reads Prometheus text output only when `--metrics-url` is
provided.
- Metrics are useful for attribution, but they do not replace end-to-end
correctness tests or object benchmark output.
## Baseline Runner
Use `scripts/run_internode_transport_baseline.sh` to run the current S3 PUT/GET
matrix against one or more configured endpoints.
Required inputs:
- `--access-key`
- `--secret-key`
Common optional inputs:
- `--tool warp|s3bench`
- `--scenarios name=url,...`
- `--sizes 4KiB,1MiB,...`
- `--concurrencies 1,16,...`
- `--duration 90s`
- `--metrics-url http://host:port/metrics`
- `--out-dir target/bench/internode-transport/manual-run`
- `--dry-run`
Dry-run example:
```bash
scripts/run_internode_transport_baseline.sh \
--access-key minioadmin \
--secret-key minioadmin \
--scenarios local=http://127.0.0.1:9000,distributed=http://127.0.0.1:9001 \
--sizes 4KiB,1MiB \
--concurrencies 1 \
--duration 10s \
--dry-run
```
Real baseline example with metrics:
```bash
RUSTFS_INTERNODE_DATA_TRANSPORT=tcp-http \
scripts/run_internode_transport_baseline.sh \
--access-key "$RUSTFS_ACCESS_KEY" \
--secret-key "$RUSTFS_SECRET_KEY" \
--scenarios local=http://127.0.0.1:9000,distributed=http://127.0.0.1:9001 \
--metrics-url http://127.0.0.1:9000/metrics \
--out-dir target/bench/internode-transport/manual-run
```
The real baseline requires a running RustFS deployment and working benchmark
tool. Do not invent or copy synthetic benchmark numbers into PR descriptions.
## Output Artifacts
The baseline runner writes:
| File | Created when | Contents |
| --- | --- | --- |
| `run_manifest.txt` | Always | Timestamp, git commit, dirty flag, Rust compiler version, kernel string, scenario matrix, tool, workload settings, metrics URL, and redacted credentials. |
| `summary.csv` | Always | `scenario`, `endpoint`, `workload`, `concurrency`, `size`, `status`, `throughput`, `requests_per_sec`, `avg_latency`, `error_count`, `log_file`, and `run_dir`. |
| `internode_metric_deltas.csv` | When `--metrics-url` is set | `scenario`, `workload`, `concurrency`, `size`, metric name, operation, backend, before value, after value, and delta. |
In dry-run mode, the runner still creates the manifest and CSV headers, and the
underlying object benchmark command is printed with credentials redacted.
## Interpretation
Use baseline artifacts to record and inspect the current `tcp-http` adapter
behavior across commits, environments, and scenario matrices. A baseline run is
valid only for the exact environment and command recorded in
`run_manifest.txt`.
Baseline artifacts should state whether metrics were collected. When
`internode_metric_deltas.csv` is absent, benchmark output cannot attribute
internode operation deltas from Prometheus metrics.
+20 -2
View File
@@ -17,7 +17,7 @@ use crate::error::{Error, Result};
use crate::rpc::{TonicInterceptor, gen_tonic_signature_interceptor, node_service_time_out_client};
use crate::{
disk::endpoint::Endpoint,
global::{GLOBAL_BOOT_TIME, GLOBAL_Endpoints},
global::{GLOBAL_BOOT_TIME, GLOBAL_Endpoints, get_global_deployment_id},
new_object_layer_fn,
notification_sys::get_global_notification_sys,
store_api::StorageAPI,
@@ -291,7 +291,7 @@ pub async fn get_server_info(get_pools: bool) -> InfoMessage {
domain: None,
region: None,
sqs_arn: None,
deployment_id: None,
deployment_id: get_global_deployment_id(),
buckets: Some(buckets),
objects: Some(objects),
versions: Some(versions),
@@ -393,3 +393,21 @@ pub fn get_commit_id() -> String {
format!("{}@{}", build::COMMIT_DATE_3339, ver)
}
#[cfg(test)]
mod tests {
use serial_test::serial;
use crate::global::get_global_deployment_id;
use super::get_server_info;
#[serial]
#[tokio::test]
async fn server_info_includes_global_deployment_id() {
let expected_deployment_id = get_global_deployment_id();
let info = get_server_info(false).await;
assert_eq!(info.deployment_id, expected_deployment_id);
}
}
+4 -4
View File
@@ -274,10 +274,10 @@ mod tests {
assert!(successes.len() >= 2);
}
#[tokio::test]
#[tokio::test(start_paused = true)]
async fn test_batch_processor_quorum_returns_before_slow_tail() {
let processor = AsyncBatchProcessor::new(4);
let started = std::time::Instant::now();
let started = tokio::time::Instant::now();
let tasks: Vec<_> = [(10_u64, Ok(1_i32)), (15, Ok(2)), (250, Ok(3))]
.into_iter()
@@ -295,10 +295,10 @@ mod tests {
assert!(started.elapsed() < Duration::from_millis(100));
}
#[tokio::test]
#[tokio::test(start_paused = true)]
async fn test_batch_processor_quorum_fails_once_quorum_becomes_impossible() {
let processor = AsyncBatchProcessor::new(4);
let started = std::time::Instant::now();
let started = tokio::time::Instant::now();
let tasks: Vec<_> = vec![
(10_u64, Ok(1_i32)),
@@ -44,14 +44,19 @@ use async_channel::{Receiver as A_Receiver, Sender as A_Sender, bounded};
use futures::Future;
use http::HeaderMap;
use lazy_static::lazy_static;
use rustfs_common::data_usage::TierStats;
use rustfs_common::heal_channel::rep_has_active_rules;
use rustfs_common::metrics::{IlmAction, Metrics};
use rustfs_config::{
DEFAULT_TRANSITION_QUEUE_CAPACITY, DEFAULT_TRANSITION_QUEUE_SEND_TIMEOUT_MS, DEFAULT_TRANSITION_WORKERS_ABSOLUTE_MAX,
DEFAULT_TRANSITION_WORKERS_CAP, ENV_TRANSITION_QUEUE_CAPACITY, ENV_TRANSITION_QUEUE_SEND_TIMEOUT_MS, ENV_TRANSITION_WORKERS,
ENV_TRANSITION_WORKERS_ABSOLUTE_MAX,
};
use rustfs_data_usage::TierStats;
use rustfs_filemeta::{
FileInfo, FileInfoOpts, NULL_VERSION_ID, REPLICATE_INCOMING_DELETE, ReplicateDecision, ReplicationState, RestoreStatusOps,
VersionPurgeStatusType, get_file_info, is_restored_object_on_disk,
};
use rustfs_s3_common::EventName;
use rustfs_s3_types::EventName;
use rustfs_utils::{get_env_i64, get_env_usize, path::encode_dir_object, string::strings_has_prefix_fold};
use s3s::dto::{
BucketLifecycleConfiguration, DefaultRetention, ExpirationStatus, ReplicationConfiguration, RestoreRequest,
@@ -60,7 +65,7 @@ use s3s::dto::{
use s3s::header::{X_AMZ_RESTORE, X_AMZ_SERVER_SIDE_ENCRYPTION};
use sha2::{Digest, Sha256};
use std::any::Any;
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::env;
use std::pin::Pin;
use std::sync::atomic::{AtomicI64, Ordering};
@@ -70,6 +75,8 @@ use time::OffsetDateTime;
use tokio::select;
use tokio::sync::mpsc::{Receiver, Sender};
use tokio::sync::{RwLock, mpsc};
use tokio::task::JoinHandle;
use tokio_util::sync::CancellationToken;
use tracing::{debug, error, info, warn};
use uuid::Uuid;
use xxhash_rust::xxh64;
@@ -99,6 +106,57 @@ lazy_static! {
pub static ref GLOBAL_TransitionState: Arc<TransitionState> = TransitionState::new();
}
fn resolve_transition_worker_count() -> (i64, i64, i64) {
let fallback = std::cmp::min(num_cpus::get() as i64, DEFAULT_TRANSITION_WORKERS_CAP);
let configured = env::var(ENV_TRANSITION_WORKERS)
.ok()
.and_then(|value| value.parse::<i64>().ok())
.filter(|value| *value > 0)
.unwrap_or(fallback);
let mut effective = configured;
let absolute_max = resolve_transition_workers_absolute_max();
effective = std::cmp::min(effective, absolute_max);
(configured, absolute_max, effective)
}
fn resolve_transition_workers_absolute_max() -> i64 {
let absolute_max = get_env_i64(ENV_TRANSITION_WORKERS_ABSOLUTE_MAX, DEFAULT_TRANSITION_WORKERS_ABSOLUTE_MAX);
if absolute_max > 0 {
absolute_max
} else {
DEFAULT_TRANSITION_WORKERS_ABSOLUTE_MAX
}
}
fn resolve_transition_queue_capacity() -> usize {
get_env_usize(ENV_TRANSITION_QUEUE_CAPACITY, DEFAULT_TRANSITION_QUEUE_CAPACITY).max(1)
}
fn resolve_transition_queue_send_timeout() -> StdDuration {
StdDuration::from_millis(
get_env_usize(ENV_TRANSITION_QUEUE_SEND_TIMEOUT_MS, DEFAULT_TRANSITION_QUEUE_SEND_TIMEOUT_MS).max(1) as u64,
)
}
fn is_immediate_transition_source(src: &LcEventSrc) -> bool {
matches!(
src,
LcEventSrc::S3PutObject | LcEventSrc::S3CopyObject | LcEventSrc::S3CompleteMultipartUpload
)
}
#[cfg(any(test, debug_assertions))]
fn should_force_immediate_transition_enqueue_timeout() -> bool {
env::var(rustfs_config::ENV_TEST_FORCE_IMMEDIATE_TRANSITION_ENQUEUE_TIMEOUT)
.ok()
.is_some_and(|value| value == "1")
}
#[cfg(not(any(test, debug_assertions)))]
fn should_force_immediate_transition_enqueue_timeout() -> bool {
false
}
pub struct LifecycleSys;
impl LifecycleSys {
@@ -531,61 +589,234 @@ impl ExpiryOp for TransitionTask {
}
}
struct TransitionWorker {
cancel: CancellationToken,
handle: JoinHandle<()>,
}
pub struct TransitionState {
transition_tx: A_Sender<Option<TransitionTask>>,
transition_rx: A_Receiver<Option<TransitionTask>>,
pub num_workers: AtomicI64,
kill_tx: A_Sender<()>,
kill_rx: A_Receiver<()>,
workers: Mutex<Vec<TransitionWorker>>,
transition_queue_capacity: usize,
transition_queue_send_timeout: StdDuration,
active_tasks: AtomicI64,
missed_immediate_tasks: AtomicI64,
queue_full_tasks: AtomicI64,
queue_send_timeout_tasks: AtomicI64,
compensation_scheduled_tasks: AtomicI64,
compensation_running_tasks: AtomicI64,
compensation_buckets: Arc<Mutex<HashSet<String>>>,
last_day_stats: Arc<Mutex<HashMap<String, LastDayTierStats>>>,
}
enum ImmediateEnqueueFailure {
ForcedTimeout,
QueueClosed { timeout_ms: Option<u64> },
QueueSendTimedOut { timeout_ms: u64 },
}
impl TransitionState {
#[allow(clippy::new_ret_no_self)]
pub fn new() -> Arc<Self> {
let (tx1, rx1) = bounded(1000);
let (tx2, rx2) = bounded(1);
Self::new_with_capacity(resolve_transition_queue_capacity())
}
fn new_with_capacity(capacity: usize) -> Arc<Self> {
let capacity = capacity.max(1);
let queue_send_timeout = resolve_transition_queue_send_timeout();
let (tx1, rx1) = bounded(capacity);
Arc::new(Self {
transition_tx: tx1,
transition_rx: rx1,
num_workers: AtomicI64::new(0),
kill_tx: tx2,
kill_rx: rx2,
workers: Mutex::new(Vec::new()),
transition_queue_capacity: capacity,
transition_queue_send_timeout: queue_send_timeout,
active_tasks: AtomicI64::new(0),
missed_immediate_tasks: AtomicI64::new(0),
queue_full_tasks: AtomicI64::new(0),
queue_send_timeout_tasks: AtomicI64::new(0),
compensation_scheduled_tasks: AtomicI64::new(0),
compensation_running_tasks: AtomicI64::new(0),
compensation_buckets: Arc::new(Mutex::new(HashSet::new())),
last_day_stats: Arc::new(Mutex::new(HashMap::new())),
})
}
pub async fn queue_transition_task(&self, oi: &ObjectInfo, event: &lifecycle::Event, src: &LcEventSrc) {
fn schedule_bucket_compensation(self: &Arc<Self>, bucket: &str) -> bool {
let mut scheduled = self.compensation_buckets.lock().unwrap();
if !scheduled.insert(bucket.to_string()) {
return false;
}
Self::inc_counter(&self.compensation_scheduled_tasks);
let bucket = bucket.to_string();
let scheduled = Arc::clone(&self.compensation_buckets);
let state = Arc::clone(self);
tokio::spawn(async move {
Self::inc_counter(&state.compensation_running_tasks);
let Some(api) = crate::new_object_layer_fn() else {
scheduled.lock().unwrap().remove(&bucket);
Self::add_counter(&state.compensation_running_tasks, -1);
warn!(bucket = %bucket, "transition compensation skipped because object layer is unavailable");
return;
};
if let Err(err) = enqueue_transition_for_existing_objects(api, &bucket).await {
warn!(bucket = %bucket, error = ?err, "transition compensation backfill failed");
} else {
info!(bucket = %bucket, "transition compensation backfill completed");
}
scheduled.lock().unwrap().remove(&bucket);
Self::add_counter(&state.compensation_running_tasks, -1);
});
true
}
#[inline]
fn inc_counter(counter: &AtomicI64) {
Self::add_counter(counter, 1);
}
#[inline]
fn add_counter(counter: &AtomicI64, delta: i64) {
counter.fetch_add(delta, Ordering::Relaxed);
}
#[inline]
fn counter_value(counter: &AtomicI64) -> i64 {
counter.load(Ordering::Relaxed)
}
fn handle_immediate_enqueue_failure(self: &Arc<Self>, oi: &ObjectInfo, src: &LcEventSrc, failure: ImmediateEnqueueFailure) {
Self::inc_counter(&self.missed_immediate_tasks);
let scheduled = self.schedule_bucket_compensation(&oi.bucket);
match failure {
ImmediateEnqueueFailure::ForcedTimeout => {
Self::inc_counter(&self.queue_send_timeout_tasks);
warn!(
bucket = %oi.bucket,
object = %oi.name,
source = ?src,
compensation_scheduled = scheduled,
"transition enqueue forced into timeout path for test fault injection"
);
}
ImmediateEnqueueFailure::QueueClosed { timeout_ms } => match timeout_ms {
Some(timeout_ms) => {
warn!(
bucket = %oi.bucket,
object = %oi.name,
source = ?src,
timeout_ms,
compensation_scheduled = scheduled,
"transition enqueue failed because the queue is closed"
);
}
None => {
warn!(
bucket = %oi.bucket,
object = %oi.name,
source = ?src,
compensation_scheduled = scheduled,
"transition enqueue failed because the queue is closed"
);
}
},
ImmediateEnqueueFailure::QueueSendTimedOut { timeout_ms } => {
Self::inc_counter(&self.queue_send_timeout_tasks);
warn!(
bucket = %oi.bucket,
object = %oi.name,
source = ?src,
timeout_ms,
compensation_scheduled = scheduled,
"transition enqueue timed out under backpressure"
);
}
}
}
pub async fn queue_transition_task(self: &Arc<Self>, oi: &ObjectInfo, event: &lifecycle::Event, src: &LcEventSrc) {
if is_immediate_transition_source(src) && should_force_immediate_transition_enqueue_timeout() {
self.handle_immediate_enqueue_failure(oi, src, ImmediateEnqueueFailure::ForcedTimeout);
return;
}
let task = TransitionTask {
obj_info: oi.clone(),
src: src.clone(),
event: event.clone(),
};
select! {
//_ -> t.ctx.Done() => (),
_ = self.transition_tx.send(Some(task)) => (),
else => {
match src {
LcEventSrc::S3PutObject | LcEventSrc::S3CopyObject | LcEventSrc::S3CompleteMultipartUpload => {
self.missed_immediate_tasks.fetch_add(1, Ordering::SeqCst);
if is_immediate_transition_source(src) {
match self.transition_tx.try_send(Some(task)) {
Ok(()) => {}
Err(async_channel::TrySendError::Full(task)) => {
Self::inc_counter(&self.queue_full_tasks);
let send_timeout = self.transition_queue_send_timeout;
match tokio::time::timeout(send_timeout, self.transition_tx.send(task)).await {
Ok(Ok(())) => {}
Ok(Err(_)) => {
self.handle_immediate_enqueue_failure(
oi,
src,
ImmediateEnqueueFailure::QueueClosed {
timeout_ms: Some(send_timeout.as_millis() as u64),
},
);
}
Err(_) => {
self.handle_immediate_enqueue_failure(
oi,
src,
ImmediateEnqueueFailure::QueueSendTimedOut {
timeout_ms: send_timeout.as_millis() as u64,
},
);
}
}
_ => ()
}
},
Err(async_channel::TrySendError::Closed(_task)) => {
self.handle_immediate_enqueue_failure(oi, src, ImmediateEnqueueFailure::QueueClosed { timeout_ms: None });
}
}
return;
}
if let Err(err) = self.transition_tx.try_send(Some(task)) {
match err {
async_channel::TrySendError::Full(_) => {
debug!(
bucket = %oi.bucket,
object = %oi.name,
source = ?src,
"transition queue is full; deferring to scanner/backfill"
);
}
async_channel::TrySendError::Closed(_) => {
warn!(
bucket = %oi.bucket,
object = %oi.name,
source = ?src,
"transition enqueue failed because the queue is closed"
);
}
}
}
}
pub async fn init(api: Arc<ECStore>) {
let max_workers = get_env_i64("RUSTFS_MAX_TRANSITION_WORKERS", std::cmp::min(num_cpus::get() as i64, 16));
let mut n = max_workers;
let tw = 8; //globalILMConfig.getTransitionWorkers();
if tw > 0 {
n = tw;
}
let (configured, absolute_max, n) = resolve_transition_worker_count();
info!(
configured_transition_workers = configured,
absolute_max_workers = absolute_max,
effective_transition_workers = n,
transition_queue_capacity = GLOBAL_TransitionState.transition_queue_capacity,
transition_queue_send_timeout_ms = GLOBAL_TransitionState.transition_queue_send_timeout.as_millis() as u64,
"transition worker count resolved"
);
//let mut transition_state = GLOBAL_TransitionState.write().await;
//self.objAPI = objAPI
@@ -599,17 +830,35 @@ impl TransitionState {
}
pub fn active_tasks(&self) -> i64 {
self.active_tasks.load(Ordering::SeqCst)
Self::counter_value(&self.active_tasks)
}
pub fn missed_immediate_tasks(&self) -> i64 {
self.missed_immediate_tasks.load(Ordering::SeqCst)
Self::counter_value(&self.missed_immediate_tasks)
}
pub async fn worker(api: Arc<ECStore>) {
pub fn queue_full_tasks(&self) -> i64 {
Self::counter_value(&self.queue_full_tasks)
}
pub fn queue_send_timeout_tasks(&self) -> i64 {
Self::counter_value(&self.queue_send_timeout_tasks)
}
pub fn compensation_scheduled_tasks(&self) -> i64 {
Self::counter_value(&self.compensation_scheduled_tasks)
}
pub fn compensation_running_tasks(&self) -> i64 {
Self::counter_value(&self.compensation_running_tasks)
}
async fn worker_with_cancel(api: Arc<ECStore>, cancel_token: CancellationToken) {
loop {
select! {
_ = GLOBAL_TransitionState.kill_rx.recv() => {
biased;
_ = cancel_token.cancelled() => {
return;
}
task = GLOBAL_TransitionState.transition_rx.recv() => {
@@ -626,7 +875,7 @@ impl TransitionState {
if task.as_any().is::<TransitionTask>() {
let task = task.as_any().downcast_ref::<TransitionTask>().expect("TransitionTask downcast failed");
GLOBAL_TransitionState.active_tasks.fetch_add(1, Ordering::SeqCst);
TransitionState::inc_counter(&GLOBAL_TransitionState.active_tasks);
let obj_info_for_event = ObjectInfo {
bucket: task.obj_info.bucket.clone(),
@@ -671,7 +920,7 @@ impl TransitionState {
..Default::default()
});
}
GLOBAL_TransitionState.active_tasks.fetch_add(-1, Ordering::SeqCst);
TransitionState::add_counter(&GLOBAL_TransitionState.active_tasks, -1);
}
}
else => ()
@@ -702,31 +951,54 @@ impl TransitionState {
pub async fn update_workers_inner(api: Arc<ECStore>, n: i64) {
let mut n = n;
let requested = n;
if n == 0 {
let max_workers = get_env_i64("RUSTFS_MAX_TRANSITION_WORKERS", std::cmp::min(num_cpus::get() as i64, 16));
n = max_workers;
let (_, _, effective) = resolve_transition_worker_count();
n = effective;
}
// Allow environment override of maximum workers
let absolute_max = get_env_i64("RUSTFS_ABSOLUTE_MAX_WORKERS", 32);
n = std::cmp::min(n, absolute_max);
let absolute_max = resolve_transition_workers_absolute_max();
n = n.clamp(0, absolute_max);
let mut num_workers = GLOBAL_TransitionState.num_workers.load(Ordering::SeqCst);
while num_workers < n {
Self::resize_workers_to(api, n, requested, absolute_max);
}
fn resize_workers_to(api: Arc<ECStore>, n: i64, requested: i64, absolute_max: i64) {
let target = n as usize;
let mut workers = GLOBAL_TransitionState.workers.lock().unwrap();
let tracked_workers = workers.len();
workers.retain(|worker| !worker.handle.is_finished());
let pruned_finished_workers = tracked_workers.saturating_sub(workers.len());
let previous_num_workers = workers.len() as i64;
while workers.len() < target {
let clone_api = api.clone();
tokio::spawn(async move {
TransitionState::worker(clone_api).await;
let cancel = CancellationToken::new();
let worker_cancel = cancel.clone();
let handle = tokio::spawn(async move {
TransitionState::worker_with_cancel(clone_api, worker_cancel).await;
});
num_workers += 1;
GLOBAL_TransitionState.num_workers.fetch_add(1, Ordering::SeqCst);
workers.push(TransitionWorker { cancel, handle });
}
let mut num_workers = GLOBAL_TransitionState.num_workers.load(Ordering::SeqCst);
while num_workers > n {
let worker = GLOBAL_TransitionState.kill_tx.clone();
let _ = worker.send(()).await;
num_workers -= 1;
GLOBAL_TransitionState.num_workers.fetch_add(-1, Ordering::SeqCst);
while workers.len() > target {
if let Some(worker) = workers.pop() {
worker.cancel.cancel();
}
}
let current_workers = workers.len() as i64;
GLOBAL_TransitionState.num_workers.store(current_workers, Ordering::SeqCst);
info!(
requested_transition_workers = requested,
effective_transition_workers = n,
absolute_max_workers = absolute_max,
previous_transition_workers = previous_num_workers,
current_transition_workers = current_workers,
pruned_finished_transition_workers = pruned_finished_workers,
"transition workers updated"
);
}
}
@@ -2004,10 +2276,13 @@ pub async fn apply_lifecycle_action(event: &lifecycle::Event, src: &LcEventSrc,
#[cfg(test)]
mod tests {
use super::{
DATE_EXPIRY_EXISTING_OBJECTS_GRACE_SECS, StaleMultipartUploadCandidate, cleanup_empty_multipart_sha_dirs_on_local_disks,
cleanup_stale_multipart_uploads_once_at, lifecycle_deleted_object, lifecycle_rule_has_date_expiration,
lifecycle_version_purge_state_from_completed_targets, mark_delete_opts_skip_decommissioned_on_remote_success,
merge_stale_multipart_candidate, replication_state_for_delete, should_defer_date_expiry_for_recent_config_update,
DATE_EXPIRY_EXISTING_OBJECTS_GRACE_SECS, DEFAULT_TRANSITION_QUEUE_CAPACITY, DEFAULT_TRANSITION_WORKERS_ABSOLUTE_MAX,
DEFAULT_TRANSITION_WORKERS_CAP, GLOBAL_TransitionState, StaleMultipartUploadCandidate, TransitionState,
cleanup_empty_multipart_sha_dirs_on_local_disks, cleanup_stale_multipart_uploads_once_at, lifecycle_deleted_object,
lifecycle_rule_has_date_expiration, lifecycle_version_purge_state_from_completed_targets,
mark_delete_opts_skip_decommissioned_on_remote_success, merge_stale_multipart_candidate, replication_state_for_delete,
resolve_transition_queue_capacity, resolve_transition_queue_send_timeout, resolve_transition_worker_count,
resolve_transition_workers_absolute_max, should_defer_date_expiry_for_recent_config_update,
should_reuse_lifecycle_delete_replication_state,
};
use crate::bucket::metadata::BUCKET_LIFECYCLE_CONFIG;
@@ -2021,12 +2296,16 @@ mod tests {
use crate::store_api::{
BucketOperations, BucketOptions, MakeBucketOptions, MultipartOperations, ObjectInfo, ObjectOptions, PutObjReader,
};
use futures::FutureExt;
use rustfs_config::ENV_TRANSITION_WORKERS_ABSOLUTE_MAX;
use rustfs_filemeta::{ReplicateDecision, VersionPurgeStatusType};
use s3s::dto::{BucketLifecycleConfiguration, ExpirationStatus, LifecycleExpiration, LifecycleRule, Timestamp};
use serial_test::serial;
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::env;
use std::path::PathBuf;
use std::sync::atomic::Ordering;
use std::sync::{Arc, OnceLock};
use std::time::Duration as StdDuration;
use time::OffsetDateTime;
@@ -2043,6 +2322,216 @@ mod tests {
assert!(opts.skip_decommissioned);
}
// SAFETY: this helper is only used from `#[serial]` tests and those tests run under a
// single-thread runtime (`worker_threads = 1`), so no concurrent reader/writer can access
// process environment while `env::set_var`/`env::remove_var` is active.
#[allow(unsafe_code)]
fn with_transition_worker_env<F>(transition: Option<&str>, absolute: Option<&str>, test_fn: F)
where
F: FnOnce(),
{
let original_transition = env::var_os("RUSTFS_MAX_TRANSITION_WORKERS");
let original_absolute = env::var_os(ENV_TRANSITION_WORKERS_ABSOLUTE_MAX);
match transition {
Some(value) => unsafe {
env::set_var("RUSTFS_MAX_TRANSITION_WORKERS", value);
},
None => unsafe {
env::remove_var("RUSTFS_MAX_TRANSITION_WORKERS");
},
}
match absolute {
Some(value) => unsafe {
env::set_var(ENV_TRANSITION_WORKERS_ABSOLUTE_MAX, value);
},
None => unsafe {
env::remove_var(ENV_TRANSITION_WORKERS_ABSOLUTE_MAX);
},
}
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(test_fn));
match original_transition {
Some(value) => unsafe {
env::set_var("RUSTFS_MAX_TRANSITION_WORKERS", value);
},
None => unsafe {
env::remove_var("RUSTFS_MAX_TRANSITION_WORKERS");
},
}
match original_absolute {
Some(value) => unsafe {
env::set_var(ENV_TRANSITION_WORKERS_ABSOLUTE_MAX, value);
},
None => unsafe {
env::remove_var(ENV_TRANSITION_WORKERS_ABSOLUTE_MAX);
},
}
if let Err(e) = result {
std::panic::resume_unwind(e);
}
}
// SAFETY: this helper is only used from `#[serial]` tests and those tests run under a
// single-thread runtime (`worker_threads = 1`), so no concurrent reader/writer can access
// process environment while `env::set_var`/`env::remove_var` is active.
#[allow(unsafe_code)]
async fn with_transition_worker_env_async<F, Fut>(transition: Option<&str>, absolute: Option<&str>, test_fn: F)
where
F: FnOnce() -> Fut,
Fut: std::future::Future<Output = ()>,
{
let original_transition = env::var_os("RUSTFS_MAX_TRANSITION_WORKERS");
let original_absolute = env::var_os(ENV_TRANSITION_WORKERS_ABSOLUTE_MAX);
match transition {
Some(value) => unsafe {
env::set_var("RUSTFS_MAX_TRANSITION_WORKERS", value);
},
None => unsafe {
env::remove_var("RUSTFS_MAX_TRANSITION_WORKERS");
},
}
match absolute {
Some(value) => unsafe {
env::set_var(ENV_TRANSITION_WORKERS_ABSOLUTE_MAX, value);
},
None => unsafe {
env::remove_var(ENV_TRANSITION_WORKERS_ABSOLUTE_MAX);
},
}
let result = std::panic::AssertUnwindSafe(test_fn()).catch_unwind().await;
match original_transition {
Some(value) => unsafe {
env::set_var("RUSTFS_MAX_TRANSITION_WORKERS", value);
},
None => unsafe {
env::remove_var("RUSTFS_MAX_TRANSITION_WORKERS");
},
}
match original_absolute {
Some(value) => unsafe {
env::set_var(ENV_TRANSITION_WORKERS_ABSOLUTE_MAX, value);
},
None => unsafe {
env::remove_var(ENV_TRANSITION_WORKERS_ABSOLUTE_MAX);
},
}
if let Err(e) = result {
std::panic::resume_unwind(e);
}
}
// SAFETY: this helper is only used from `#[serial]` tests and those tests run under a
// single-thread runtime (`worker_threads = 1`), so no concurrent reader/writer can access
// process environment while `env::set_var`/`env::remove_var` is active.
#[allow(unsafe_code)]
fn with_transition_queue_env<F>(capacity: Option<&str>, timeout_ms: Option<&str>, test_fn: F)
where
F: FnOnce(),
{
let original_capacity = env::var_os("RUSTFS_TRANSITION_QUEUE_CAPACITY");
let original_timeout = env::var_os("RUSTFS_TRANSITION_QUEUE_SEND_TIMEOUT_MS");
match capacity {
Some(value) => unsafe {
env::set_var("RUSTFS_TRANSITION_QUEUE_CAPACITY", value);
},
None => unsafe {
env::remove_var("RUSTFS_TRANSITION_QUEUE_CAPACITY");
},
}
match timeout_ms {
Some(value) => unsafe {
env::set_var("RUSTFS_TRANSITION_QUEUE_SEND_TIMEOUT_MS", value);
},
None => unsafe {
env::remove_var("RUSTFS_TRANSITION_QUEUE_SEND_TIMEOUT_MS");
},
}
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(test_fn));
match original_capacity {
Some(value) => unsafe {
env::set_var("RUSTFS_TRANSITION_QUEUE_CAPACITY", value);
},
None => unsafe {
env::remove_var("RUSTFS_TRANSITION_QUEUE_CAPACITY");
},
}
match original_timeout {
Some(value) => unsafe {
env::set_var("RUSTFS_TRANSITION_QUEUE_SEND_TIMEOUT_MS", value);
},
None => unsafe {
env::remove_var("RUSTFS_TRANSITION_QUEUE_SEND_TIMEOUT_MS");
},
}
if let Err(e) = result {
std::panic::resume_unwind(e);
}
}
// SAFETY: this helper is only used from `#[serial]` tests and those tests run under a
// single-thread runtime (`worker_threads = 1`), so no concurrent reader/writer can access
// process environment while `env::set_var`/`env::remove_var` is active.
#[allow(unsafe_code)]
async fn with_transition_queue_env_async<F, Fut>(capacity: Option<&str>, timeout_ms: Option<&str>, test_fn: F)
where
F: FnOnce() -> Fut,
Fut: std::future::Future<Output = ()>,
{
let original_capacity = env::var_os("RUSTFS_TRANSITION_QUEUE_CAPACITY");
let original_timeout = env::var_os("RUSTFS_TRANSITION_QUEUE_SEND_TIMEOUT_MS");
match capacity {
Some(value) => unsafe {
env::set_var("RUSTFS_TRANSITION_QUEUE_CAPACITY", value);
},
None => unsafe {
env::remove_var("RUSTFS_TRANSITION_QUEUE_CAPACITY");
},
}
match timeout_ms {
Some(value) => unsafe {
env::set_var("RUSTFS_TRANSITION_QUEUE_SEND_TIMEOUT_MS", value);
},
None => unsafe {
env::remove_var("RUSTFS_TRANSITION_QUEUE_SEND_TIMEOUT_MS");
},
}
let result = std::panic::AssertUnwindSafe(test_fn()).catch_unwind().await;
match original_capacity {
Some(value) => unsafe {
env::set_var("RUSTFS_TRANSITION_QUEUE_CAPACITY", value);
},
None => unsafe {
env::remove_var("RUSTFS_TRANSITION_QUEUE_CAPACITY");
},
}
match original_timeout {
Some(value) => unsafe {
env::set_var("RUSTFS_TRANSITION_QUEUE_SEND_TIMEOUT_MS", value);
},
None => unsafe {
env::remove_var("RUSTFS_TRANSITION_QUEUE_SEND_TIMEOUT_MS");
},
}
if let Err(e) = result {
std::panic::resume_unwind(e);
}
}
#[test]
fn lifecycle_rule_has_date_expiration_detects_enabled_date_rule() {
let lc = BucketLifecycleConfiguration {
@@ -2068,6 +2557,164 @@ mod tests {
assert!(!lifecycle_rule_has_date_expiration(&lc, "missing-rule"));
}
#[test]
#[serial]
fn resolve_transition_worker_count_uses_fallback_when_env_missing() {
with_transition_worker_env(None, None, || {
let (configured, absolute_max, effective) = resolve_transition_worker_count();
let fallback = std::cmp::min(num_cpus::get() as i64, DEFAULT_TRANSITION_WORKERS_CAP);
assert_eq!(configured, fallback);
assert_eq!(absolute_max, DEFAULT_TRANSITION_WORKERS_ABSOLUTE_MAX);
assert_eq!(effective, fallback);
});
}
#[test]
#[serial]
fn resolve_transition_worker_count_honors_positive_env_value() {
with_transition_worker_env(Some("4"), Some("32"), || {
let (configured, absolute_max, effective) = resolve_transition_worker_count();
assert_eq!(configured, 4);
assert_eq!(absolute_max, 32);
assert_eq!(effective, 4);
});
}
#[test]
#[serial]
fn resolve_transition_worker_count_clamps_to_absolute_max() {
with_transition_worker_env(Some("64"), Some("16"), || {
let (configured, absolute_max, effective) = resolve_transition_worker_count();
assert_eq!(configured, 64);
assert_eq!(absolute_max, 16);
assert_eq!(effective, 16);
});
}
#[test]
#[serial]
fn resolve_transition_worker_count_ignores_non_positive_absolute_max() {
with_transition_worker_env(Some("4"), Some("0"), || {
let (configured, absolute_max, effective) = resolve_transition_worker_count();
assert_eq!(configured, 4);
assert_eq!(absolute_max, DEFAULT_TRANSITION_WORKERS_ABSOLUTE_MAX);
assert_eq!(effective, 4);
});
with_transition_worker_env(Some("4"), Some("-1"), || {
let (configured, absolute_max, effective) = resolve_transition_worker_count();
assert_eq!(configured, 4);
assert_eq!(absolute_max, DEFAULT_TRANSITION_WORKERS_ABSOLUTE_MAX);
assert_eq!(effective, 4);
});
}
#[test]
#[serial]
fn resolve_transition_worker_count_falls_back_for_zero_value() {
with_transition_worker_env(Some("0"), Some("32"), || {
let (configured, absolute_max, effective) = resolve_transition_worker_count();
let fallback = std::cmp::min(num_cpus::get() as i64, DEFAULT_TRANSITION_WORKERS_CAP);
assert_eq!(configured, fallback);
assert_eq!(absolute_max, 32);
assert_eq!(effective, fallback);
});
}
#[test]
#[serial]
fn resolve_transition_queue_capacity_uses_default_when_env_missing() {
with_transition_queue_env(None, None, || {
assert_eq!(resolve_transition_queue_capacity(), DEFAULT_TRANSITION_QUEUE_CAPACITY);
});
}
#[test]
#[serial]
fn resolve_transition_queue_capacity_honors_positive_env_value() {
with_transition_queue_env(Some("128"), None, || {
assert_eq!(resolve_transition_queue_capacity(), 128);
});
}
#[test]
#[serial]
fn resolve_transition_queue_send_timeout_honors_positive_env_value() {
with_transition_queue_env(None, Some("250"), || {
assert_eq!(resolve_transition_queue_send_timeout(), StdDuration::from_millis(250));
});
}
#[tokio::test(flavor = "current_thread")]
async fn schedule_bucket_compensation_deduplicates_same_bucket() {
let state = TransitionState::new_with_capacity(1);
let first = state.schedule_bucket_compensation("bucket-a");
let second = state.schedule_bucket_compensation("bucket-a");
assert!(first);
assert!(!second);
assert_eq!(state.compensation_scheduled_tasks(), 1);
}
#[tokio::test]
#[serial]
async fn transition_state_init_honors_runtime_configured_worker_count() {
let (_paths, ecstore) = setup_test_env().await;
let original_workers = GLOBAL_TransitionState.num_workers.load(Ordering::SeqCst);
with_transition_worker_env_async(Some("3"), Some("8"), || async {
TransitionState::update_workers(ecstore.clone(), 0).await;
assert_eq!(GLOBAL_TransitionState.num_workers.load(Ordering::SeqCst), 3);
})
.await;
let absolute_max = resolve_transition_workers_absolute_max();
TransitionState::resize_workers_to(ecstore, original_workers, original_workers, absolute_max);
}
#[tokio::test]
#[serial]
async fn transition_worker_resize_cancels_removed_workers_directly() {
let (_paths, ecstore) = setup_test_env().await;
let original_workers = GLOBAL_TransitionState.num_workers.load(Ordering::SeqCst);
let absolute_max = resolve_transition_workers_absolute_max();
TransitionState::resize_workers_to(ecstore.clone(), 0, 0, absolute_max);
assert_eq!(GLOBAL_TransitionState.num_workers.load(Ordering::SeqCst), 0);
TransitionState::resize_workers_to(ecstore.clone(), 2, 2, absolute_max);
let worker_tokens = {
let workers = GLOBAL_TransitionState.workers.lock().unwrap();
assert_eq!(workers.len(), 2);
workers.iter().map(|worker| worker.cancel.clone()).collect::<Vec<_>>()
};
TransitionState::resize_workers_to(ecstore.clone(), 1, 1, absolute_max);
assert_eq!(GLOBAL_TransitionState.num_workers.load(Ordering::SeqCst), 1);
assert_eq!(worker_tokens.iter().filter(|token| token.is_cancelled()).count(), 1);
let remaining_token = {
let workers = GLOBAL_TransitionState.workers.lock().unwrap();
assert_eq!(workers.len(), 1);
let token = workers[0].cancel.clone();
assert!(!token.is_cancelled());
token
};
TransitionState::resize_workers_to(ecstore.clone(), 0, 0, absolute_max);
assert_eq!(GLOBAL_TransitionState.num_workers.load(Ordering::SeqCst), 0);
assert!(remaining_token.is_cancelled());
TransitionState::resize_workers_to(ecstore, original_workers, original_workers, absolute_max);
}
#[test]
fn should_defer_date_expiry_for_recent_config_update_respects_grace_window() {
let now = OffsetDateTime::now_utc();
@@ -2540,6 +3187,76 @@ mod tests {
assert!(!parts.user_defined.contains_key(RUSTFS_MULTIPART_OBJECT_KEY));
}
#[tokio::test]
#[serial]
async fn repeated_upload_part_overwrites_previous_part_state() {
let (_paths, ecstore) = setup_test_env().await;
let bucket = format!("multipart-overwrite-{}", Uuid::new_v4().simple());
let object = "overwrite/object.txt";
create_test_bucket(&ecstore, &bucket).await;
let upload = ecstore
.new_multipart_upload(&bucket, object, &ObjectOptions::default())
.await
.expect("multipart upload should be created");
let mut first = PutObjReader::from_vec(vec![1, 2, 3]);
let first_part = ecstore
.put_object_part(&bucket, object, &upload.upload_id, 1, &mut first, &ObjectOptions::default())
.await
.expect("first multipart part should be uploaded");
let mut second = PutObjReader::from_vec(vec![4, 5, 6, 7]);
let second_part = ecstore
.put_object_part(&bucket, object, &upload.upload_id, 1, &mut second, &ObjectOptions::default())
.await
.expect("second multipart part should overwrite the previous part");
assert_ne!(
first_part.etag, second_part.etag,
"the overwrite path should persist the latest part metadata rather than reusing stale state"
);
let parts = ecstore
.list_object_parts(
&bucket,
object,
&upload.upload_id,
None,
crate::set_disk::MAX_PARTS_COUNT,
&ObjectOptions::default(),
)
.await
.expect("multipart parts should be readable after overwrite");
assert_eq!(parts.parts.len(), 1, "only the latest version of part 1 should remain visible");
assert_eq!(parts.parts[0].part_num, 1);
assert_eq!(parts.parts[0].etag, second_part.etag);
assert_eq!(parts.parts[0].size, second_part.size);
assert_eq!(parts.parts[0].actual_size, second_part.actual_size);
let completed = ecstore
.complete_multipart_upload(
&bucket,
object,
&upload.upload_id,
vec![crate::store_api::CompletePart {
part_num: 1,
etag: second_part.etag.clone(),
checksum_crc32: None,
checksum_crc32c: None,
checksum_sha1: None,
checksum_sha256: None,
checksum_crc64nvme: None,
}],
&ObjectOptions::default(),
)
.await
.expect("complete multipart upload should succeed with the latest overwritten part");
assert_eq!(completed.size, second_part.size as i64);
}
#[tokio::test]
#[serial]
async fn cleanup_removes_empty_multipart_sha_dirs() {
+76 -11
View File
@@ -34,7 +34,8 @@ const ERR_LIFECYCLE_NO_RULE: &str = "Lifecycle configuration should have at leas
const ERR_LIFECYCLE_DUPLICATE_ID: &str = "Rule ID must be unique. Found same ID for more than one rule";
const _ERR_XML_NOT_WELL_FORMED: &str =
"The XML you provided was not well-formed or did not validate against our published schema";
const ERR_LIFECYCLE_BUCKET_LOCKED: &str = "ExpiredObjectDeleteMarker is not allowed on a bucket with Object Lock enabled";
const ERR_LIFECYCLE_BUCKET_LOCKED: &str =
"ExpiredObjectAllVersions element and DelMarkerExpiration action cannot be used on an object locked bucket";
const ERR_LIFECYCLE_TOO_MANY_RULES: &str = "Lifecycle configuration should have at most 1000 rules";
const ERR_LIFECYCLE_INVALID_EXPIRATION_DAYS: &str = "Lifecycle expiration days must not be negative";
const ERR_LIFECYCLE_INVALID_NONCURRENT_EXPIRATION_DAYS: &str = "Lifecycle noncurrent expiration days must not be negative";
@@ -320,14 +321,15 @@ impl Lifecycle for BucketLifecycleConfiguration {
r.validate()?;
if let Some(object_lock_enabled) = lr.object_lock_enabled.as_ref()
&& object_lock_enabled.as_str() == ObjectLockEnabled::ENABLED
&& let Some(expiration) = r.expiration.as_ref()
{
// Object Lock + ExpiredObjectDeleteMarker conflict
if expiration.expired_object_delete_marker.is_some_and(|v| v) {
return Err(std::io::Error::other(ERR_LIFECYCLE_BUCKET_LOCKED));
if let Some(expiration) = r.expiration.as_ref() {
// Object Lock + ExpiredObjectAllVersions conflict (MinIO extension)
if expiration.expired_object_all_versions.is_some_and(|v| v) {
return Err(std::io::Error::other(ERR_LIFECYCLE_BUCKET_LOCKED));
}
}
// Object Lock + ExpiredObjectAllVersions conflict (MinIO extension)
if expiration.expired_object_all_versions.is_some_and(|v| v) {
// Object Lock + DelMarkerExpiration conflict
if r.del_marker_expiration.is_some() {
return Err(std::io::Error::other(ERR_LIFECYCLE_BUCKET_LOCKED));
}
}
@@ -2060,11 +2062,11 @@ mod tests {
assert_eq!(event_after.due, Some(future_date));
}
// --- TASK-002 tests: Object Lock + ExpiredObjectDeleteMarker conflict ---
// --- TASK-002 tests: Object Lock + ExpiredObjectDeleteMarker compatibility ---
#[tokio::test]
#[serial]
async fn validate_rejects_expired_object_delete_marker_on_locked_bucket() {
async fn validate_allows_expired_object_delete_marker_on_locked_bucket() {
let lc = BucketLifecycleConfiguration {
expiry_updated_at: None,
rules: vec![LifecycleRule {
@@ -2089,8 +2091,9 @@ mod tests {
..Default::default()
};
let err = lc.validate(&locked_config).await.unwrap_err();
assert_eq!(err.to_string(), ERR_LIFECYCLE_BUCKET_LOCKED);
lc.validate(&locked_config)
.await
.expect("expected validation to pass for ExpiredObjectDeleteMarker on locked bucket");
}
#[tokio::test]
@@ -2154,6 +2157,68 @@ mod tests {
.expect("expected days-based expiration to pass on locked bucket");
}
#[tokio::test]
#[serial]
async fn validate_rejects_del_marker_expiration_on_locked_bucket() {
let lc = BucketLifecycleConfiguration {
expiry_updated_at: None,
rules: vec![LifecycleRule {
status: ExpirationStatus::from_static(ExpirationStatus::ENABLED),
expiration: Some(LifecycleExpiration {
days: Some(30),
..Default::default()
}),
abort_incomplete_multipart_upload: None,
del_marker_expiration: Some(s3s::dto::DelMarkerExpiration { days: Some(1) }),
filter: None,
id: Some("test-rule".to_string()),
noncurrent_version_expiration: None,
noncurrent_version_transitions: None,
prefix: None,
transitions: None,
}],
};
let locked_config = ObjectLockConfiguration {
object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)),
..Default::default()
};
let err = lc.validate(&locked_config).await.unwrap_err();
assert_eq!(err.to_string(), ERR_LIFECYCLE_BUCKET_LOCKED);
}
#[tokio::test]
#[serial]
async fn validate_rejects_zero_day_del_marker_expiration_on_locked_bucket() {
let lc = BucketLifecycleConfiguration {
expiry_updated_at: None,
rules: vec![LifecycleRule {
status: ExpirationStatus::from_static(ExpirationStatus::ENABLED),
expiration: Some(LifecycleExpiration {
days: Some(30),
..Default::default()
}),
abort_incomplete_multipart_upload: None,
del_marker_expiration: Some(s3s::dto::DelMarkerExpiration { days: Some(0) }),
filter: None,
id: Some("test-rule".to_string()),
noncurrent_version_expiration: None,
noncurrent_version_transitions: None,
prefix: None,
transitions: None,
}],
};
let locked_config = ObjectLockConfiguration {
object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)),
..Default::default()
};
let err = lc.validate(&locked_config).await.unwrap_err();
assert_eq!(err.to_string(), ERR_LIFECYCLE_BUCKET_LOCKED);
}
// --- TASK-003 tests: Round up to next UTC processing boundary ---
#[test]
@@ -18,7 +18,7 @@
#![allow(unused_must_use)]
#![allow(clippy::all)]
use rustfs_common::data_usage::TierStats;
use rustfs_data_usage::TierStats;
use sha2::Sha256;
use std::collections::HashMap;
use std::ops::Sub;
+20 -5
View File
@@ -724,7 +724,7 @@ impl BucketMetadata {
return Err(Error::other("errServerNotInitialized"));
};
self.parse_all_configs(store.clone())?;
self.parse_all_configs()?;
let mut buf: Vec<u8> = vec![0; 4];
@@ -752,7 +752,7 @@ impl BucketMetadata {
Ok(())
}
fn parse_all_configs(&mut self, _api: Arc<ECStore>) -> Result<()> {
fn parse_all_configs(&mut self) -> Result<()> {
if let Err(e) = self.parse_policy_config() {
tracing::warn!(bucket = %self.name, config = "policy", error = %e, "parse_all_configs: failed to parse");
}
@@ -881,11 +881,9 @@ pub async fn load_bucket_metadata_parse(api: Arc<ECStore>, bucket: &str, parse:
bm.default_timestamps();
if parse {
bm.parse_all_configs(api)?;
bm.parse_all_configs()?;
}
// TODO: parse_all_configs
Ok(bm)
}
@@ -939,6 +937,23 @@ mod test {
assert_eq!(bm.name, new.name);
}
#[test]
fn parse_all_configs_parses_stored_configs_without_store_dependency() {
let mut bm = BucketMetadata::new("test-bucket");
bm.policy_config_json = br#"{"Version":"2012-10-17","Statement":[]}"#.to_vec();
bm.bucket_targets_config_json =
br#"{"targets":[{"endpoint":"s3.amazonaws.com","targetbucket":"target-bucket","arn":"arn:aws:s3:::target-bucket"}]}"#
.to_vec();
bm.parse_all_configs().unwrap();
assert!(bm.policy_config.is_some());
let bucket_targets = bm.bucket_target_config.unwrap();
assert_eq!(bucket_targets.targets.len(), 1);
assert_eq!(bucket_targets.targets[0].endpoint, "s3.amazonaws.com");
assert_eq!(bucket_targets.targets[0].target_bucket, "target-bucket");
}
#[tokio::test]
async fn marshal_msg_complete_example() {
// Create a complete BucketMetadata with various configurations
@@ -56,7 +56,7 @@ use rustfs_filemeta::{
ReplicationType, ReplicationWorkerOperation, ResyncDecision, ResyncTargetDecision, VersionPurgeStatusType,
get_replication_state, parse_replicate_decision, replication_statuses_map, target_reset_header, version_purge_statuses_map,
};
use rustfs_s3_common::EventName;
use rustfs_s3_types::EventName;
use rustfs_utils::http::{
AMZ_BUCKET_REPLICATION_STATUS, AMZ_OBJECT_TAGGING, AMZ_TAGGING_DIRECTIVE, CONTENT_ENCODING, HeaderExt as _,
SSEC_ALGORITHM_HEADER, SSEC_KEY_HEADER, SSEC_KEY_MD5_HEADER, SUFFIX_OBJECTLOCK_LEGALHOLD_TIMESTAMP,
@@ -929,7 +929,7 @@ impl ReplicationResyncer {
}
fn heal_should_use_check_replicate_delete(oi: &ObjectInfo) -> bool {
oi.delete_marker || (!oi.replication_status.is_empty() && oi.replication_status != ReplicationStatusType::Failed)
oi.delete_marker || !oi.version_purge_status.is_empty()
}
pub async fn get_heal_replicate_object_info(oi: &ObjectInfo, rcfg: &ReplicationConfig) -> ReplicateObjectInfo {
@@ -939,8 +939,8 @@ pub async fn get_heal_replicate_object_info(oi: &ObjectInfo, rcfg: &ReplicationC
if let Some(rc) = rcfg.config.as_ref()
&& !rc.role.is_empty()
{
if !oi.replication_status.is_empty() {
oi.replication_status_internal = Some(format!("{}={};", rc.role, oi.replication_status.as_str()));
if !oi.version_purge_status.is_empty() {
oi.version_purge_status_internal = Some(format!("{}={};", rc.role, oi.version_purge_status.as_str()));
}
if !oi.replication_status.is_empty() {
@@ -3742,7 +3742,7 @@ mod tests {
}
#[test]
fn test_heal_should_use_check_replicate_delete_pending_uses_delete_path() {
fn test_heal_should_use_check_replicate_delete_pending_non_delete_marker_uses_must_replicate_path() {
let oi = ObjectInfo {
bucket: "b".to_string(),
name: "obj".to_string(),
@@ -3751,8 +3751,8 @@ mod tests {
..Default::default()
};
assert!(
heal_should_use_check_replicate_delete(&oi),
"Pending (non-Failed) status with non-empty replication uses check_replicate_delete path"
!heal_should_use_check_replicate_delete(&oi),
"Pending non-delete-marker object must use must_replicate path to evaluate current target set"
);
}
@@ -3771,6 +3771,36 @@ mod tests {
);
}
#[test]
fn test_heal_should_use_check_replicate_delete_version_purge_status_uses_delete_path() {
let oi = ObjectInfo {
bucket: "b".to_string(),
name: "obj".to_string(),
delete_marker: false,
version_purge_status: VersionPurgeStatusType::Pending,
..Default::default()
};
assert!(
heal_should_use_check_replicate_delete(&oi),
"Version purge entries must use check_replicate_delete path"
);
}
#[test]
fn test_heal_should_use_check_replicate_delete_completed_non_delete_marker_uses_must_replicate_path() {
let oi = ObjectInfo {
bucket: "b".to_string(),
name: "obj".to_string(),
delete_marker: false,
replication_status: ReplicationStatusType::Completed,
..Default::default()
};
assert!(
!heal_should_use_check_replicate_delete(&oi),
"Completed non-delete-marker object must use must_replicate path so new targets can be evaluated"
);
}
#[test]
fn test_delete_replication_object_opts_marks_replica_deletes() {
let dobj = ObjectToDelete {
@@ -4051,6 +4081,32 @@ mod tests {
);
}
#[tokio::test]
async fn test_get_heal_replicate_object_info_maps_version_purge_status_for_role() {
let role = "arn:rustfs:replication::target:bucket";
let oi = ObjectInfo {
bucket: "test-bucket".to_string(),
name: "key".to_string(),
delete_marker: false,
version_purge_status: VersionPurgeStatusType::Pending,
version_id: Some(Uuid::nil()),
mod_time: Some(OffsetDateTime::now_utc()),
..Default::default()
};
let rcfg = ReplicationConfig::new(
Some(ReplicationConfiguration {
role: role.to_string(),
rules: vec![],
}),
None,
);
let roi = get_heal_replicate_object_info(&oi, &rcfg).await;
assert_eq!(roi.replication_status_internal, None);
assert_eq!(roi.version_purge_status_internal.as_deref(), Some(format!("{role}=PENDING;").as_str()));
assert_eq!(roi.target_purge_statuses.get(role), Some(&VersionPurgeStatusType::Pending));
}
#[tokio::test]
async fn test_cancel_marks_only_matching_bucket_target_token() {
let resyncer = ReplicationResyncer::new().await;
+57 -17
View File
@@ -19,9 +19,10 @@ use futures::future::join_all;
use metrics::counter;
use rustfs_filemeta::{MetaCacheEntries, MetaCacheEntry, MetacacheReader, is_io_eof};
use std::{
collections::VecDeque,
future::Future,
pin::Pin,
sync::{Arc, Mutex},
sync::{Arc, OnceLock},
time::Duration,
};
use tokio::io::AsyncRead;
@@ -112,9 +113,9 @@ pub async fn list_path_raw(rx: CancellationToken, opts: ListPathRawOptions) -> d
let mut jobs: Vec<tokio::task::JoinHandle<std::result::Result<(), DiskError>>> = Vec::new();
let mut readers = Vec::with_capacity(opts.disks.len());
let fds = opts.fallback_disks.iter().flatten().cloned().collect::<Vec<_>>();
let fds = opts.fallback_disks.iter().flatten().cloned().collect::<VecDeque<_>>();
let max_disk_failures = opts.disks.len().saturating_sub(opts.min_disks);
let producer_errs = Arc::new(Mutex::new(vec![None; opts.disks.len()]));
let producer_errs: Arc<[OnceLock<DiskError>]> = (0..opts.disks.len()).map(|_| OnceLock::new()).collect::<Vec<_>>().into();
let cancel_rx = CancellationToken::new();
@@ -137,14 +138,14 @@ pub async fn list_path_raw(rx: CancellationToken, opts: ListPathRawOptions) -> d
return Ok(());
}
TestReaderBehavior::ProducerError(err) => {
producer_errs_clone.lock().expect("producer error mutex poisoned")[disk_idx] = Some(err.clone());
record_producer_error(&producer_errs_clone, disk_idx, &err);
return Err(err);
}
TestReaderBehavior::PartialThenTimeout(entries) => {
let mut wr = wr;
let mut out = rustfs_filemeta::MetacacheWriter::new(&mut wr);
let err = DiskError::Timeout;
producer_errs_clone.lock().expect("producer error mutex poisoned")[disk_idx] = Some(err.clone());
record_producer_error(&producer_errs_clone, disk_idx, &err);
let _ = out.write(&entries).await;
drop(out);
return Err(err);
@@ -187,8 +188,7 @@ pub async fn list_path_raw(rx: CancellationToken, opts: ListPathRawOptions) -> d
while need_fallback {
let mut disk_op = None;
while !fds_clone.is_empty() {
let disk = fds_clone.remove(0);
while let Some(disk) = fds_clone.pop_front() {
if disk.is_online().await {
disk_op = Some(disk);
break;
@@ -198,7 +198,7 @@ pub async fn list_path_raw(rx: CancellationToken, opts: ListPathRawOptions) -> d
let Some(disk) = disk_op else {
warn!("list_path_raw: fallback disk is none");
let err = last_err.unwrap_or(DiskError::DiskNotFound);
producer_errs_clone.lock().expect("producer error mutex poisoned")[disk_idx] = Some(err.clone());
record_producer_error(&producer_errs_clone, disk_idx, &err);
return Err(err);
};
@@ -281,7 +281,7 @@ pub async fn list_path_raw(rx: CancellationToken, opts: ListPathRawOptions) -> d
// info!("read entry disk: {}, name: {}", i, entry.name);
entry
} else {
if let Some(err) = producer_errs.lock().expect("producer error mutex poisoned")[i].clone() {
if let Some(err) = producer_error(&producer_errs, i) {
has_err += 1;
errs[i] = Some(err);
continue;
@@ -293,7 +293,7 @@ pub async fn list_path_raw(rx: CancellationToken, opts: ListPathRawOptions) -> d
}
}
PeekOutcome::Error(err) => {
if let Some(err) = producer_errs.lock().expect("producer error mutex poisoned")[i].clone() {
if let Some(err) = producer_error(&producer_errs, i) {
has_err += 1;
errs[i] = Some(err);
continue;
@@ -442,10 +442,13 @@ pub async fn list_path_raw(rx: CancellationToken, opts: ListPathRawOptions) -> d
{
finished_fn(&errs).await;
}
if errs.iter().flatten().any(|err| *err == DiskError::Timeout) {
return Err(DiskError::Timeout);
}
// All remaining readers are either at EOF or failed. Earlier logic
// returned Timeout here for even a single stalled drive, despite
// `has_err` being within the tolerated drive-failure budget. That
// makes small distributed listings fail once healthy quorum readers
// reach EOF but one remote walk stream is slow/stalled. Only the
// `has_err > opts.disks.len() - opts.min_disks` branch above should
// turn tolerated reader failures into request failures.
// error!("list_path_raw: at_eof + has_err == readers.len() break {:?}", &errs);
break;
}
@@ -486,13 +489,23 @@ pub async fn list_path_raw(rx: CancellationToken, opts: ListPathRawOptions) -> d
return Err(err);
}
// The merge consumer can finish successfully before every producer finishes
// (for example after reaching EOF quorum while a tolerated drive is stalled,
// or after the requested listing limit is satisfied). Cancel remaining walk
// jobs before joining them so list calls do not wait for slow remote streams.
cancel_rx.cancel();
let results = join_all(jobs).await;
let mut job_errs = Vec::new();
for result in results {
match result {
Ok(Ok(())) => {}
Ok(Err(err)) => {
error!("list_path_raw producer err {:?}", err);
if matches!(err, DiskError::FileNotFound | DiskError::VolumeNotFound) {
warn!("list_path_raw producer missing path {:?}", err);
} else {
error!("list_path_raw producer err {:?}", err);
}
job_errs.push(err);
}
Err(err) => {
@@ -510,10 +523,21 @@ pub async fn list_path_raw(rx: CancellationToken, opts: ListPathRawOptions) -> d
Ok(())
}
#[inline]
fn record_producer_error(producer_errs: &[OnceLock<DiskError>], idx: usize, err: &DiskError) {
let _ = producer_errs[idx].set(err.clone());
}
#[inline]
fn producer_error(producer_errs: &[OnceLock<DiskError>], idx: usize) -> Option<DiskError> {
producer_errs[idx].get().cloned()
}
#[cfg(test)]
mod tests {
use super::*;
use rustfs_filemeta::MetacacheWriter;
use std::sync::Mutex;
#[tokio::test]
async fn list_path_raw_empty_disks_returns_read_quorum() {
@@ -530,18 +554,34 @@ mod tests {
CancellationToken::new(),
ListPathRawOptions {
disks: vec![None, None],
min_disks: 1,
min_disks: 2,
test_reader_behaviors: vec![TestReaderBehavior::Stall, TestReaderBehavior::Eof],
peek_timeout: Some(Duration::from_millis(20)),
..Default::default()
},
)
.await
.expect_err("stalled reader should make listing fail explicitly");
.expect_err("stalled reader should fail when read quorum cannot be met");
assert_eq!(err, DiskError::Timeout);
}
#[tokio::test]
async fn list_path_raw_tolerates_stalled_reader_after_quorum_eof() {
list_path_raw(
CancellationToken::new(),
ListPathRawOptions {
disks: vec![None, None, None],
min_disks: 2,
test_reader_behaviors: vec![TestReaderBehavior::Eof, TestReaderBehavior::Eof, TestReaderBehavior::Stall],
peek_timeout: Some(Duration::from_millis(20)),
..Default::default()
},
)
.await
.expect("listing should complete when healthy quorum reached EOF and only a tolerated drive stalled");
}
#[tokio::test]
async fn list_path_raw_returns_timeout_when_producer_fails_after_partial_entry() {
let seen = Arc::new(Mutex::new(Vec::new()));
+38 -46
View File
@@ -51,6 +51,7 @@ use md5::Md5;
use rand::{Rng, RngExt};
use rustfs_config::MAX_S3_CLIENT_RESPONSE_SIZE;
use rustfs_rio::HashReader;
use rustfs_tls_runtime::{load_global_outbound_tls_state, record_tls_generation};
use rustfs_utils::HashAlgorithm;
use rustfs_utils::{
net::get_endpoint_url,
@@ -58,6 +59,8 @@ use rustfs_utils::{
DEFAULT_RETRY_CAP, DEFAULT_RETRY_UNIT, MAX_JITTER, MAX_RETRY, RetryTimer, is_http_status_retryable, is_s3code_retryable,
},
};
use rustls_pki_types::PrivateKeyDer;
use rustls_pki_types::pem::PemObject;
use s3s::S3ErrorCode;
use s3s::dto::Owner;
use s3s::dto::ReplicationStatus;
@@ -152,24 +155,11 @@ pub enum BucketLookupType {
BucketLookupPath,
}
fn load_root_store_from_tls_path() -> Option<rustls::RootCertStore> {
// Load the root certificate bundle from the path specified by the
// RUSTFS_TLS_PATH environment variable.
let tp = rustfs_utils::get_env_str(rustfs_config::ENV_RUSTFS_TLS_PATH, rustfs_config::DEFAULT_RUSTFS_TLS_PATH);
// If no TLS path is configured, do not fall back to a CA bundle in the current directory.
if tp.is_empty() {
return None;
}
let ca = std::path::Path::new(&tp).join(rustfs_config::RUSTFS_CA_CERT);
if !ca.exists() {
return None;
}
let der_list = rustfs_utils::load_cert_bundle_der_bytes(ca.to_str().unwrap_or_default()).ok()?;
fn build_root_store_from_der_list(der_list: Vec<Vec<u8>>) -> Option<rustls::RootCertStore> {
let mut store = rustls::RootCertStore::empty();
for der in der_list {
if let Err(e) = store.add(der.into()) {
warn!("Warning: failed to add certificate from '{}' to root store: {e}", ca.display());
warn!("Warning: failed to add certificate to root store: {e}");
}
}
Some(store)
@@ -199,18 +189,38 @@ where
})
}
fn build_tls_config() -> Result<rustls::ClientConfig, std::io::Error> {
with_rustls_init_guard(|| {
let config = if let Some(store) = load_root_store_from_tls_path() {
rustls::ClientConfig::builder()
.with_root_certificates(store)
.with_no_client_auth()
} else {
rustls::ClientConfig::builder().with_native_roots()?.with_no_client_auth()
};
async fn build_tls_config() -> Result<rustls::ClientConfig, std::io::Error> {
with_rustls_init_guard(|| Ok(()))?;
Ok(config)
})
let outbound_tls = load_global_outbound_tls_state().await;
record_tls_generation("ecstore_transition_client", outbound_tls.generation.0);
let builder = if let Some(root_ca_pem) = outbound_tls.root_ca_pem.as_ref() {
let mut reader = std::io::BufReader::new(root_ca_pem.as_slice());
let certs_der = rustls_pki_types::CertificateDer::pem_reader_iter(&mut reader)
.collect::<Result<Vec<_>, _>>()
.map_err(|e| std::io::Error::other(format!("failed to parse published root CA PEM: {e}")))?;
let root_store = build_root_store_from_der_list(certs_der.into_iter().map(|cert| cert.to_vec()).collect::<Vec<_>>())
.ok_or_else(|| std::io::Error::other("published outbound root CA material could not build root store"))?;
rustls::ClientConfig::builder().with_root_certificates(root_store)
} else {
rustls::ClientConfig::builder().with_native_roots()?
};
let config = if let Some(identity) = outbound_tls.mtls_identity.as_ref() {
let certs = rustls_pki_types::CertificateDer::pem_reader_iter(&mut std::io::BufReader::new(identity.cert_pem.as_slice()))
.collect::<Result<Vec<_>, _>>()
.map_err(|e| std::io::Error::other(format!("failed to parse published client cert PEM: {e}")))?;
let key = PrivateKeyDer::from_pem_reader(&mut std::io::BufReader::new(identity.key_pem.as_slice()))
.map_err(|e| std::io::Error::other(format!("failed to parse published client key PEM: {e}")))?;
builder
.with_client_auth_cert(certs, key)
.map_err(|e| std::io::Error::other(format!("failed to build client mTLS identity: {e}")))?
} else {
builder.with_no_client_auth()
};
Ok(config)
}
impl TransitionClient {
@@ -234,7 +244,7 @@ impl TransitionClient {
let endpoint_url = get_endpoint_url(endpoint, opts.secure)?;
let tls = build_tls_config()?;
let tls = build_tls_config().await?;
let https = hyper_rustls::HttpsConnectorBuilder::new()
.with_tls_config(tls)
@@ -1374,9 +1384,7 @@ pub struct CreateBucketConfiguration {
#[cfg(test)]
mod tests {
use super::{
build_tls_config, load_root_store_from_tls_path, signer_error_to_io_error, validate_header_values, with_rustls_init_guard,
};
use super::{build_tls_config, signer_error_to_io_error, validate_header_values, with_rustls_init_guard};
use http::{HeaderMap, HeaderValue};
#[test]
@@ -1395,22 +1403,6 @@ mod tests {
assert!(outcome.is_ok(), "TLS config creation should not panic");
}
/// When RUSTFS_TLS_PATH is not set, `load_root_store_from_tls_path` must return `None`
/// (i.e. it must not silently look for a CA bundle in the current working directory).
#[test]
fn tls_path_unset_returns_none() {
let result = temp_env::with_var_unset(rustfs_config::ENV_RUSTFS_TLS_PATH, || load_root_store_from_tls_path());
assert!(result.is_none(), "expected None when RUSTFS_TLS_PATH is unset, but got a root store");
}
/// When RUSTFS_TLS_PATH is set to an empty string, `load_root_store_from_tls_path` must
/// return `None` to avoid accidentally trusting a CA bundle in the current directory.
#[test]
fn tls_path_empty_returns_none() {
let result = temp_env::with_var(rustfs_config::ENV_RUSTFS_TLS_PATH, Some(""), || load_root_store_from_tls_path());
assert!(result.is_none(), "expected None when RUSTFS_TLS_PATH is empty, but got a root store");
}
/// Installing the rustls crypto provider when one is already set must not panic or return
/// an error that surfaces to callers (the race-safe `get_default` check guards the install).
#[test]
+275 -21
View File
@@ -27,7 +27,7 @@ pub use local_snapshot::{
data_usage_dir, data_usage_state_dir, ensure_data_usage_layout, read_snapshot as read_local_snapshot, snapshot_file_name,
snapshot_object_path, snapshot_path, write_snapshot as write_local_snapshot,
};
use rustfs_common::data_usage::{
use rustfs_data_usage::{
BucketTargetUsageInfo, BucketUsageInfo, DataUsageCache, DataUsageEntry, DataUsageInfo, DiskUsageStatus, SizeSummary,
};
use rustfs_io_metrics::record_system_path_failure;
@@ -48,7 +48,14 @@ const DATA_USAGE_BLOOM_NAME: &str = ".bloomcycle.bin";
pub const DATA_USAGE_CACHE_NAME: &str = ".usage-cache.bin";
const DATA_USAGE_CACHE_TTL_SECS: u64 = 30;
type UsageMemoryCache = Arc<RwLock<HashMap<String, (u64, SystemTime)>>>;
#[derive(Debug, Clone)]
struct CachedBucketUsage {
usage: BucketUsageInfo,
refreshed_at: SystemTime,
usage_updated_at: SystemTime,
}
type UsageMemoryCache = Arc<RwLock<HashMap<String, CachedBucketUsage>>>;
type CacheUpdating = Arc<RwLock<bool>>;
static USAGE_MEMORY_CACHE: OnceLock<UsageMemoryCache> = OnceLock::new();
@@ -403,21 +410,90 @@ pub async fn compute_bucket_usage(store: Arc<ECStore>, bucket_name: &str) -> Res
Ok(usage)
}
/// Fast in-memory increment for immediate quota consistency
pub async fn increment_bucket_usage_memory(bucket: &str, size_increment: u64) {
async fn ensure_bucket_usage_cached(bucket: &str) {
let cache = memory_cache().read().await;
if cache.contains_key(bucket) {
return;
}
drop(cache);
update_usage_cache_if_needed().await;
}
fn cached_bucket_usage_from_backend(usage: BucketUsageInfo, updated_at: SystemTime) -> CachedBucketUsage {
CachedBucketUsage {
usage,
refreshed_at: SystemTime::now(),
usage_updated_at: updated_at,
}
}
fn cached_bucket_usage_now(usage: BucketUsageInfo) -> CachedBucketUsage {
let now = SystemTime::now();
CachedBucketUsage {
usage,
refreshed_at: now,
usage_updated_at: now,
}
}
fn data_usage_info_updated_at(data_usage_info: &DataUsageInfo) -> SystemTime {
data_usage_info.last_update.unwrap_or(SystemTime::UNIX_EPOCH)
}
/// Fast in-memory update for immediate quota and admin usage consistency.
pub async fn record_bucket_object_write_memory(bucket: &str, previous_current_size: Option<u64>, new_size: u64) {
ensure_bucket_usage_cached(bucket).await;
let mut cache = memory_cache().write().await;
let current = cache.entry(bucket.to_string()).or_insert_with(|| (0, SystemTime::now()));
current.0 += size_increment;
current.1 = SystemTime::now();
let entry = cache
.entry(bucket.to_string())
.or_insert_with(|| cached_bucket_usage_now(BucketUsageInfo::default()));
match previous_current_size {
Some(previous_size) => {
entry.usage.size = entry.usage.size.saturating_sub(previous_size).saturating_add(new_size);
}
None => {
entry.usage.size = entry.usage.size.saturating_add(new_size);
entry.usage.objects_count = entry.usage.objects_count.saturating_add(1);
entry.usage.versions_count = entry.usage.versions_count.saturating_add(1);
}
}
let now = SystemTime::now();
entry.refreshed_at = now;
entry.usage_updated_at = now;
}
/// Fast in-memory increment for immediate quota consistency.
pub async fn increment_bucket_usage_memory(bucket: &str, size_increment: u64) {
record_bucket_object_write_memory(bucket, None, size_increment).await;
}
/// Fast in-memory update for successful object deletes.
pub async fn record_bucket_object_delete_memory(bucket: &str, deleted_size: u64, removed_current_object: bool) {
ensure_bucket_usage_cached(bucket).await;
let mut cache = memory_cache().write().await;
let entry = cache
.entry(bucket.to_string())
.or_insert_with(|| cached_bucket_usage_now(BucketUsageInfo::default()));
entry.usage.size = entry.usage.size.saturating_sub(deleted_size);
if removed_current_object {
entry.usage.objects_count = entry.usage.objects_count.saturating_sub(1);
entry.usage.versions_count = entry.usage.versions_count.saturating_sub(1);
}
let now = SystemTime::now();
entry.refreshed_at = now;
entry.usage_updated_at = now;
}
/// Fast in-memory decrement for immediate quota consistency
pub async fn decrement_bucket_usage_memory(bucket: &str, size_decrement: u64) {
let mut cache = memory_cache().write().await;
if let Some(current) = cache.get_mut(bucket) {
current.0 = current.0.saturating_sub(size_decrement);
current.1 = SystemTime::now();
}
record_bucket_object_delete_memory(bucket, size_decrement, size_decrement > 0).await;
}
/// Get bucket usage from in-memory cache
@@ -425,7 +501,7 @@ pub async fn get_bucket_usage_memory(bucket: &str) -> Option<u64> {
update_usage_cache_if_needed().await;
let cache = memory_cache().read().await;
cache.get(bucket).map(|(usage, _)| *usage)
cache.get(bucket).map(|cached| cached.usage.size)
}
async fn update_usage_cache_if_needed() {
@@ -434,7 +510,7 @@ async fn update_usage_cache_if_needed() {
let now = SystemTime::now();
let cache = memory_cache().read().await;
let earliest_timestamp = cache.values().map(|(_, ts)| *ts).min();
let earliest_timestamp = cache.values().map(|cached| cached.refreshed_at).min();
drop(cache);
let age = match earliest_timestamp {
@@ -461,8 +537,12 @@ async fn update_usage_cache_if_needed() {
&& let Ok(data_usage_info) = load_data_usage_from_backend(store.clone()).await
{
let mut cache = cache_clone.write().await;
let usage_updated_at = data_usage_info_updated_at(&data_usage_info);
for (bucket_name, bucket_usage) in data_usage_info.buckets_usage.iter() {
cache.insert(bucket_name.clone(), (bucket_usage.size, SystemTime::now()));
cache.insert(
bucket_name.clone(),
cached_bucket_usage_from_backend(bucket_usage.clone(), usage_updated_at),
);
}
}
let mut updating = updating_clone.write().await;
@@ -488,8 +568,12 @@ async fn update_usage_cache_if_needed() {
&& let Ok(data_usage_info) = load_data_usage_from_backend(store.clone()).await
{
let mut cache = memory_cache().write().await;
let usage_updated_at = data_usage_info_updated_at(&data_usage_info);
for (bucket_name, bucket_usage) in data_usage_info.buckets_usage.iter() {
cache.insert(bucket_name.clone(), (bucket_usage.size, SystemTime::now()));
cache.insert(
bucket_name.clone(),
cached_bucket_usage_from_backend(bucket_usage.clone(), usage_updated_at),
);
}
}
@@ -497,15 +581,62 @@ async fn update_usage_cache_if_needed() {
*updating = false;
}
pub async fn replace_bucket_usage_memory_from_info(data_usage_info: &DataUsageInfo) {
let usage_updated_at = data_usage_info_updated_at(data_usage_info);
let mut cache = memory_cache().write().await;
let mut next_cache = HashMap::new();
for (bucket, bucket_usage) in data_usage_info.buckets_usage.iter() {
if let Some(existing) = cache.get(bucket)
&& existing.usage_updated_at > usage_updated_at
{
next_cache.insert(bucket.clone(), existing.clone());
continue;
}
next_cache.insert(bucket.clone(), cached_bucket_usage_from_backend(bucket_usage.clone(), usage_updated_at));
}
for (bucket, existing) in cache.iter() {
if !data_usage_info.buckets_usage.contains_key(bucket) && existing.usage_updated_at > usage_updated_at {
next_cache.insert(bucket.clone(), existing.clone());
}
}
*cache = next_cache;
}
pub async fn apply_bucket_usage_memory_overlay(data_usage_info: &mut DataUsageInfo) {
let cache = memory_cache().read().await;
if cache.is_empty() {
return;
}
let persisted_update = data_usage_info.last_update;
let mut changed = false;
for (bucket, cached) in cache.iter() {
if persisted_update.is_some_and(|persisted| cached.usage_updated_at <= persisted) {
continue;
}
data_usage_info.buckets_usage.insert(bucket.clone(), cached.usage.clone());
data_usage_info.bucket_sizes.insert(bucket.clone(), cached.usage.size);
changed = true;
}
if changed {
data_usage_info.buckets_count = data_usage_info.buckets_usage.len() as u64;
data_usage_info.calculate_totals();
}
}
/// Sync memory cache with backend data (called by scanner)
pub async fn sync_memory_cache_with_backend() -> Result<(), Error> {
if let Some(store) = crate::global::GLOBAL_OBJECT_API.get() {
match load_data_usage_from_backend(store.clone()).await {
Ok(data_usage_info) => {
let mut cache = memory_cache().write().await;
for (bucket, bucket_usage) in data_usage_info.buckets_usage.iter() {
cache.insert(bucket.clone(), (bucket_usage.size, SystemTime::now()));
}
replace_bucket_usage_memory_from_info(&data_usage_info).await;
}
Err(e) => {
debug!("Failed to sync memory cache with backend: {}", e);
@@ -686,7 +817,33 @@ pub async fn save_data_usage_cache(cache: &DataUsageCache, name: &str) -> crate:
#[cfg(test)]
mod tests {
use super::*;
use rustfs_common::data_usage::BucketUsageInfo;
use rustfs_data_usage::BucketUsageInfo;
use serial_test::serial;
async fn clear_usage_memory_cache_for_test() {
memory_cache().write().await.clear();
*cache_updating().write().await = false;
}
fn data_usage_info_for_test(bucket: &str, objects_count: u64, size: u64, last_update: SystemTime) -> DataUsageInfo {
let mut info = DataUsageInfo {
last_update: Some(last_update),
..Default::default()
};
info.buckets_usage.insert(
bucket.to_string(),
BucketUsageInfo {
objects_count,
versions_count: objects_count,
size,
..Default::default()
},
);
info.bucket_sizes.insert(bucket.to_string(), size);
info.buckets_count = info.buckets_usage.len() as u64;
info.calculate_totals();
info
}
fn aggregate_for_test(
inputs: Vec<(DiskUsageStatus, Result<Option<LocalUsageSnapshot>, Error>)>,
@@ -772,4 +929,101 @@ mod tests {
assert_eq!(aggregated.buckets_count, 1);
assert_eq!(aggregated.buckets_usage.get("bucket-a").map(|b| (b.objects_count, b.size)), Some((3, 42)));
}
#[tokio::test]
#[serial]
async fn memory_overlay_reflects_recent_delete_before_scanner_persists() {
clear_usage_memory_cache_for_test().await;
let persisted = data_usage_info_for_test("bucket-a", 1, 42, SystemTime::now() - Duration::from_secs(10));
replace_bucket_usage_memory_from_info(&persisted).await;
record_bucket_object_delete_memory("bucket-a", 42, true).await;
let mut response = persisted.clone();
apply_bucket_usage_memory_overlay(&mut response).await;
assert_eq!(response.objects_total_count, 0);
assert_eq!(response.objects_total_size, 0);
assert_eq!(
response
.buckets_usage
.get("bucket-a")
.map(|usage| (usage.objects_count, usage.size)),
Some((0, 0))
);
}
#[tokio::test]
#[serial]
async fn memory_overlay_preserves_object_count_for_overwrite() {
clear_usage_memory_cache_for_test().await;
let persisted = data_usage_info_for_test("bucket-a", 1, 10, SystemTime::now() - Duration::from_secs(10));
replace_bucket_usage_memory_from_info(&persisted).await;
record_bucket_object_write_memory("bucket-a", Some(10), 20).await;
let mut response = persisted.clone();
apply_bucket_usage_memory_overlay(&mut response).await;
assert_eq!(response.objects_total_count, 1);
assert_eq!(response.objects_total_size, 20);
assert_eq!(
response
.buckets_usage
.get("bucket-a")
.map(|usage| (usage.objects_count, usage.size)),
Some((1, 20))
);
}
#[tokio::test]
#[serial]
async fn memory_overlay_does_not_replace_newer_persisted_usage() {
clear_usage_memory_cache_for_test().await;
let now = SystemTime::now();
let old_persisted = data_usage_info_for_test("bucket-a", 1, 42, now - Duration::from_secs(10));
replace_bucket_usage_memory_from_info(&old_persisted).await;
record_bucket_object_delete_memory("bucket-a", 42, true).await;
let mut newer_persisted = data_usage_info_for_test("bucket-a", 2, 84, now + Duration::from_secs(10));
apply_bucket_usage_memory_overlay(&mut newer_persisted).await;
assert_eq!(newer_persisted.objects_total_count, 2);
assert_eq!(newer_persisted.objects_total_size, 84);
assert_eq!(
newer_persisted
.buckets_usage
.get("bucket-a")
.map(|usage| (usage.objects_count, usage.size)),
Some((2, 84))
);
}
#[tokio::test]
#[serial]
async fn scanner_sync_preserves_newer_memory_update() {
clear_usage_memory_cache_for_test().await;
let now = SystemTime::now();
let old_persisted = data_usage_info_for_test("bucket-a", 1, 42, now - Duration::from_secs(10));
replace_bucket_usage_memory_from_info(&old_persisted).await;
record_bucket_object_delete_memory("bucket-a", 42, true).await;
let scanner_snapshot = data_usage_info_for_test("bucket-a", 1, 42, now - Duration::from_secs(5));
replace_bucket_usage_memory_from_info(&scanner_snapshot).await;
let mut response = scanner_snapshot.clone();
apply_bucket_usage_memory_overlay(&mut response).await;
assert_eq!(response.objects_total_count, 0);
assert_eq!(response.objects_total_size, 0);
assert_eq!(
response
.buckets_usage
.get("bucket-a")
.map(|usage| (usage.objects_count, usage.size)),
Some((0, 0))
);
}
}
+393 -15
View File
@@ -27,6 +27,8 @@ use crate::global::GLOBAL_LOCAL_DISK_ID_MAP;
use bytes::Bytes;
use metrics::counter;
use rustfs_filemeta::{FileInfo, ObjectPartInfo, RawFileInfo};
#[cfg(not(test))]
use std::sync::OnceLock;
use std::{
path::PathBuf,
sync::{
@@ -44,10 +46,60 @@ use uuid::Uuid;
const DISK_HEALTH_OK: u32 = 0;
const DISK_HEALTH_FAULTY: u32 = 1;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum TimeoutHealthAction {
MarkFailure,
IgnoreFailure,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum TimeoutHealthPolicy {
MarkFailure,
IgnoreScanner,
}
impl TimeoutHealthPolicy {
fn parse(raw: &str) -> Option<Self> {
match raw.trim().to_ascii_lowercase().as_str() {
rustfs_config::DRIVE_TIMEOUT_HEALTH_ACTION_MARK_FAILURE => Some(Self::MarkFailure),
rustfs_config::DRIVE_TIMEOUT_HEALTH_ACTION_IGNORE_SCANNER => Some(Self::IgnoreScanner),
_ => None,
}
}
fn scanner_timeout_health_action(self) -> TimeoutHealthAction {
match self {
Self::MarkFailure => TimeoutHealthAction::MarkFailure,
Self::IgnoreScanner => TimeoutHealthAction::IgnoreFailure,
}
}
}
pub const ENV_RUSTFS_DRIVE_ACTIVE_MONITORING: &str = "RUSTFS_DRIVE_ACTIVE_MONITORING";
pub const DEFAULT_RUSTFS_DRIVE_ACTIVE_MONITORING: bool = true;
pub const SKIP_IF_SUCCESS_BEFORE: Duration = Duration::from_secs(5);
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum DriveTimeoutProfile {
Default,
HighLatency,
}
impl DriveTimeoutProfile {
fn parse(raw: &str) -> Option<Self> {
match raw.trim().to_ascii_lowercase().as_str() {
rustfs_config::DRIVE_TIMEOUT_PROFILE_DEFAULT => Some(Self::Default),
rustfs_config::DRIVE_TIMEOUT_PROFILE_HIGH_LATENCY => Some(Self::HighLatency),
_ => None,
}
}
}
#[cfg(not(test))]
static DRIVE_TIMEOUT_PROFILE_CACHE: OnceLock<DriveTimeoutProfile> = OnceLock::new();
#[cfg(not(test))]
static DRIVE_TIMEOUT_HEALTH_POLICY_CACHE: OnceLock<TimeoutHealthPolicy> = OnceLock::new();
lazy_static::lazy_static! {
static ref TEST_DATA: Bytes = Bytes::from(vec![42u8; 2048]);
static ref TEST_BUCKET: String = ".rustfs.sys/tmp".to_string();
@@ -60,10 +112,39 @@ pub fn get_max_timeout_duration() -> Duration {
))
}
fn get_drive_timeout_duration(env_key: &str, default_secs: u64) -> Duration {
fn resolve_drive_timeout_profile_from_env() -> DriveTimeoutProfile {
let raw = rustfs_utils::get_env_str(rustfs_config::ENV_DRIVE_TIMEOUT_PROFILE, rustfs_config::DEFAULT_DRIVE_TIMEOUT_PROFILE);
if let Some(profile) = DriveTimeoutProfile::parse(&raw) {
return profile;
}
warn!(
env = rustfs_config::ENV_DRIVE_TIMEOUT_PROFILE,
value = %raw,
default = rustfs_config::DEFAULT_DRIVE_TIMEOUT_PROFILE,
"Invalid drive timeout profile; falling back to default"
);
DriveTimeoutProfile::parse(rustfs_config::DEFAULT_DRIVE_TIMEOUT_PROFILE).unwrap_or(DriveTimeoutProfile::Default)
}
fn get_drive_timeout_profile() -> DriveTimeoutProfile {
#[cfg(test)]
{
resolve_drive_timeout_profile_from_env()
}
#[cfg(not(test))]
{
*DRIVE_TIMEOUT_PROFILE_CACHE.get_or_init(resolve_drive_timeout_profile_from_env)
}
}
fn get_drive_timeout_duration(env_key: &str, default_secs: u64, high_latency_secs: Option<u64>) -> Duration {
let fallback_default = match (get_drive_timeout_profile(), high_latency_secs) {
(DriveTimeoutProfile::HighLatency, Some(secs)) => secs,
_ => default_secs,
};
Duration::from_secs(
rustfs_utils::get_env_opt_u64_with_aliases(env_key, &[rustfs_config::ENV_DRIVE_MAX_TIMEOUT_DURATION])
.unwrap_or(default_secs),
.unwrap_or(fallback_default),
)
}
@@ -71,6 +152,7 @@ pub fn get_drive_metadata_timeout() -> Duration {
get_drive_timeout_duration(
rustfs_config::ENV_DRIVE_METADATA_TIMEOUT_SECS,
rustfs_config::DEFAULT_DRIVE_METADATA_TIMEOUT_SECS,
Some(rustfs_config::DRIVE_TIMEOUT_PROFILE_HIGH_LATENCY_SECS),
)
}
@@ -78,6 +160,7 @@ pub fn get_drive_disk_info_timeout() -> Duration {
get_drive_timeout_duration(
rustfs_config::ENV_DRIVE_DISK_INFO_TIMEOUT_SECS,
rustfs_config::DEFAULT_DRIVE_DISK_INFO_TIMEOUT_SECS,
Some(rustfs_config::DRIVE_TIMEOUT_PROFILE_HIGH_LATENCY_SECS),
)
}
@@ -85,6 +168,7 @@ pub fn get_drive_list_dir_timeout() -> Duration {
get_drive_timeout_duration(
rustfs_config::ENV_DRIVE_LIST_DIR_TIMEOUT_SECS,
rustfs_config::DEFAULT_DRIVE_LIST_DIR_TIMEOUT_SECS,
Some(rustfs_config::DRIVE_TIMEOUT_PROFILE_HIGH_LATENCY_SECS),
)
}
@@ -92,6 +176,7 @@ pub fn get_drive_walkdir_timeout() -> Duration {
get_drive_timeout_duration(
rustfs_config::ENV_DRIVE_WALKDIR_TIMEOUT_SECS,
rustfs_config::DEFAULT_DRIVE_WALKDIR_TIMEOUT_SECS,
Some(rustfs_config::DRIVE_TIMEOUT_PROFILE_HIGH_LATENCY_SECS),
)
}
@@ -99,6 +184,7 @@ pub fn get_drive_walkdir_stall_timeout() -> Duration {
get_drive_timeout_duration(
rustfs_config::ENV_DRIVE_WALKDIR_STALL_TIMEOUT_SECS,
rustfs_config::DEFAULT_DRIVE_WALKDIR_STALL_TIMEOUT_SECS,
Some(rustfs_config::DRIVE_TIMEOUT_PROFILE_HIGH_LATENCY_SECS),
)
}
@@ -116,6 +202,34 @@ pub fn get_drive_active_check_timeout() -> Duration {
))
}
fn resolve_drive_timeout_health_policy_from_env() -> TimeoutHealthPolicy {
let raw = rustfs_utils::get_env_str(
rustfs_config::ENV_DRIVE_TIMEOUT_HEALTH_ACTION,
rustfs_config::DEFAULT_DRIVE_TIMEOUT_HEALTH_ACTION,
);
if let Some(policy) = TimeoutHealthPolicy::parse(&raw) {
return policy;
}
warn!(
env = rustfs_config::ENV_DRIVE_TIMEOUT_HEALTH_ACTION,
value = %raw,
default = rustfs_config::DEFAULT_DRIVE_TIMEOUT_HEALTH_ACTION,
"Invalid drive timeout health action policy; falling back to default"
);
TimeoutHealthPolicy::parse(rustfs_config::DEFAULT_DRIVE_TIMEOUT_HEALTH_ACTION).unwrap_or(TimeoutHealthPolicy::MarkFailure)
}
fn get_drive_timeout_health_policy() -> TimeoutHealthPolicy {
#[cfg(test)]
{
resolve_drive_timeout_health_policy_from_env()
}
#[cfg(not(test))]
{
*DRIVE_TIMEOUT_HEALTH_POLICY_CACHE.get_or_init(resolve_drive_timeout_health_policy_from_env)
}
}
/// DiskHealthTracker tracks the health status of a disk.
/// Similar to Go's diskHealthTracker.
#[derive(Debug)]
@@ -464,6 +578,8 @@ pub struct LocalDiskWrapper {
cancel_token: CancellationToken,
/// Disk ID for stale checking
disk_id: Arc<RwLock<Option<Uuid>>>,
/// Timeout policy for scanner-sensitive operations, loaded once on wrapper initialization.
timeout_health_policy: TimeoutHealthPolicy,
}
impl LocalDiskWrapper {
@@ -480,6 +596,7 @@ impl LocalDiskWrapper {
health_check: health_check && env_health_check,
cancel_token: CancellationToken::new(),
disk_id: Arc::new(RwLock::new(None)),
timeout_health_policy: get_drive_timeout_health_policy(),
};
record_drive_runtime_state(&wrapper.disk.endpoint(), RuntimeDriveHealthState::Online);
wrapper
@@ -505,6 +622,10 @@ impl LocalDiskWrapper {
self.health.record_capacity_probe(total, used, free);
}
fn scanner_timeout_health_action(&self) -> TimeoutHealthAction {
self.timeout_health_policy.scanner_timeout_health_action()
}
#[cfg(test)]
pub fn force_runtime_state_for_test(&self, state: RuntimeDriveHealthState) {
self.health.force_runtime_state_for_test(state);
@@ -551,9 +672,8 @@ impl LocalDiskWrapper {
/// Monitor disk writability periodically
async fn monitor_disk_writable(disk: Arc<LocalDisk>, health: Arc<DiskHealthTracker>, cancel_token: CancellationToken) {
// TODO: config interval
let mut interval = time::interval(get_drive_active_check_interval());
let active_check_timeout = get_drive_active_check_timeout();
loop {
tokio::select! {
@@ -592,7 +712,7 @@ impl LocalDiskWrapper {
&test_obj,
&TEST_DATA,
true,
get_drive_active_check_timeout(),
active_check_timeout,
)
.await
.is_err()
@@ -687,6 +807,7 @@ impl LocalDiskWrapper {
/// Monitor disk status and try to bring it back online
async fn monitor_disk_status(disk: Arc<LocalDisk>, health: Arc<DiskHealthTracker>, cancel_token: CancellationToken) {
let check_every = get_drive_returning_probe_interval();
let active_check_timeout = get_drive_active_check_timeout();
let mut interval = time::interval(check_every);
@@ -707,7 +828,7 @@ impl LocalDiskWrapper {
&test_obj,
&TEST_DATA,
false,
get_drive_active_check_timeout(),
active_check_timeout,
)
.await
{
@@ -804,6 +925,21 @@ impl LocalDiskWrapper {
operation: F,
timeout_duration: Duration,
) -> Result<T>
where
F: FnOnce() -> Fut,
Fut: std::future::Future<Output = Result<T>>,
{
self.track_disk_health_with_op_and_timeout_action(op, operation, timeout_duration, TimeoutHealthAction::MarkFailure)
.await
}
async fn track_disk_health_with_op_and_timeout_action<T, F, Fut>(
&self,
op: &'static str,
operation: F,
timeout_duration: Duration,
timeout_health_action: TimeoutHealthAction,
) -> Result<T>
where
F: FnOnce() -> Fut,
Fut: std::future::Future<Output = Result<T>>,
@@ -848,7 +984,9 @@ impl LocalDiskWrapper {
Err(_) => {
// Timeout occurred, mark disk as potentially faulty and decrement waiting counter
self.health.decrement_waiting();
if self.health.mark_failure(&self.endpoint(), "operation_timeout") {
if timeout_health_action == TimeoutHealthAction::MarkFailure
&& self.health.mark_failure(&self.endpoint(), "operation_timeout")
{
self.spawn_recovery_monitor_if_needed();
}
counter!(
@@ -872,10 +1010,11 @@ impl LocalDiskWrapper {
#[async_trait::async_trait]
impl DiskAPI for LocalDiskWrapper {
async fn read_metadata(&self, volume: &str, path: &str) -> Result<Bytes> {
self.track_disk_health_with_op(
self.track_disk_health_with_op_and_timeout_action(
"read_metadata",
|| async { self.disk.read_metadata(volume, path).await },
get_drive_metadata_timeout(),
self.scanner_timeout_health_action(),
)
.await
}
@@ -952,7 +1091,7 @@ impl DiskAPI for LocalDiskWrapper {
return Err(DiskError::FaultyDisk);
}
self.track_disk_health_with_op(
self.track_disk_health_with_op_and_timeout_action(
"disk_info",
|| async {
let result = self.disk.disk_info(opts).await?;
@@ -966,6 +1105,7 @@ impl DiskAPI for LocalDiskWrapper {
Ok(result)
},
get_drive_disk_info_timeout(),
self.scanner_timeout_health_action(),
)
.await
}
@@ -996,8 +1136,13 @@ impl DiskAPI for LocalDiskWrapper {
}
async fn walk_dir<W: tokio::io::AsyncWrite + Unpin + Send>(&self, opts: WalkDirOptions, wr: &mut W) -> Result<()> {
self.track_disk_health_with_op("walk_dir", || async { self.disk.walk_dir(opts, wr).await }, get_drive_walkdir_timeout())
.await
self.track_disk_health_with_op_and_timeout_action(
"walk_dir",
|| async { self.disk.walk_dir(opts, wr).await },
get_drive_walkdir_timeout(),
self.scanner_timeout_health_action(),
)
.await
}
async fn delete_version(
@@ -1104,10 +1249,11 @@ impl DiskAPI for LocalDiskWrapper {
}
async fn list_dir(&self, origvolume: &str, volume: &str, dir_path: &str, count: i32) -> Result<Vec<String>> {
self.track_disk_health_with_op(
self.track_disk_health_with_op_and_timeout_action(
"list_dir",
|| async { self.disk.list_dir(origvolume, volume, dir_path, count).await },
get_drive_list_dir_timeout(),
self.scanner_timeout_health_action(),
)
.await
}
@@ -1203,19 +1349,75 @@ mod tests {
use super::*;
use crate::disk::endpoint::Endpoint;
use crate::disk::health_state::RuntimeDriveHealthState;
use std::{
io,
pin::Pin,
task::{Context, Poll},
};
use tokio::io::AsyncWrite;
struct PendingWriter;
impl AsyncWrite for PendingWriter {
fn poll_write(self: Pin<&mut Self>, _cx: &mut Context<'_>, _buf: &[u8]) -> Poll<io::Result<usize>> {
Poll::Pending
}
fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<io::Result<()>> {
Poll::Ready(Ok(()))
}
fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<io::Result<()>> {
Poll::Ready(Ok(()))
}
}
#[test]
fn drive_metadata_timeout_uses_default_when_unset() {
temp_env::with_var_unset(rustfs_config::ENV_DRIVE_METADATA_TIMEOUT_SECS, || {
temp_env::with_var_unset(rustfs_config::ENV_DRIVE_MAX_TIMEOUT_DURATION, || {
assert_eq!(
get_drive_metadata_timeout(),
Duration::from_secs(rustfs_config::DEFAULT_DRIVE_METADATA_TIMEOUT_SECS)
temp_env::with_var_unset(rustfs_config::ENV_DRIVE_TIMEOUT_PROFILE, || {
assert_eq!(
get_drive_metadata_timeout(),
Duration::from_secs(rustfs_config::DEFAULT_DRIVE_METADATA_TIMEOUT_SECS)
);
});
});
});
}
#[test]
fn drive_metadata_timeout_uses_high_latency_profile_when_unset() {
temp_env::with_var_unset(rustfs_config::ENV_DRIVE_METADATA_TIMEOUT_SECS, || {
temp_env::with_var_unset(rustfs_config::ENV_DRIVE_MAX_TIMEOUT_DURATION, || {
temp_env::with_var(
rustfs_config::ENV_DRIVE_TIMEOUT_PROFILE,
Some(rustfs_config::DRIVE_TIMEOUT_PROFILE_HIGH_LATENCY),
|| {
assert_eq!(
get_drive_metadata_timeout(),
Duration::from_secs(rustfs_config::DRIVE_TIMEOUT_PROFILE_HIGH_LATENCY_SECS)
);
},
);
});
});
}
#[test]
fn drive_metadata_timeout_invalid_profile_falls_back_to_default() {
temp_env::with_var_unset(rustfs_config::ENV_DRIVE_METADATA_TIMEOUT_SECS, || {
temp_env::with_var_unset(rustfs_config::ENV_DRIVE_MAX_TIMEOUT_DURATION, || {
temp_env::with_var(rustfs_config::ENV_DRIVE_TIMEOUT_PROFILE, Some("invalid"), || {
assert_eq!(
get_drive_metadata_timeout(),
Duration::from_secs(rustfs_config::DEFAULT_DRIVE_METADATA_TIMEOUT_SECS)
);
});
});
});
}
#[test]
fn drive_metadata_timeout_uses_legacy_fallback_when_canonical_unset() {
temp_env::with_var_unset(rustfs_config::ENV_DRIVE_METADATA_TIMEOUT_SECS, || {
@@ -1323,6 +1525,182 @@ mod tests {
assert!(health.offline_duration().is_none());
}
#[tokio::test]
async fn ignored_timeout_does_not_mark_drive_failure() {
let dir = tempfile::tempdir().expect("temp dir should be created");
let endpoint =
Endpoint::try_from(dir.path().to_str().expect("temp dir should be valid UTF-8")).expect("endpoint should parse");
let disk = Arc::new(LocalDisk::new(&endpoint, false).await.expect("local disk should be created"));
let wrapper = LocalDiskWrapper::new(disk, false);
let result = wrapper
.track_disk_health_with_op_and_timeout_action(
"walk_dir",
|| async {
tokio::time::sleep(Duration::from_millis(20)).await;
Ok(())
},
Duration::from_millis(1),
TimeoutHealthAction::IgnoreFailure,
)
.await;
assert_eq!(result.expect_err("operation should time out"), DiskError::Timeout);
assert_eq!(wrapper.runtime_state(), RuntimeDriveHealthState::Online);
assert!(!wrapper.health.is_faulty());
}
#[tokio::test]
async fn walk_dir_writer_backpressure_timeout_does_not_mark_drive_failure() {
temp_env::async_with_vars(
[
(rustfs_config::ENV_DRIVE_WALKDIR_TIMEOUT_SECS, Some("1")),
(
rustfs_config::ENV_DRIVE_TIMEOUT_HEALTH_ACTION,
Some(rustfs_config::DRIVE_TIMEOUT_HEALTH_ACTION_IGNORE_SCANNER),
),
],
async {
let dir = tempfile::tempdir().expect("temp dir should be created");
let endpoint = Endpoint::try_from(dir.path().to_str().expect("temp dir should be valid UTF-8"))
.expect("endpoint should parse");
let disk = Arc::new(LocalDisk::new(&endpoint, false).await.expect("local disk should be created"));
let wrapper = LocalDiskWrapper::new(disk, false);
let bucket = "test-bucket";
let object = "test-object";
wrapper.make_volume(bucket).await.expect("bucket should be created");
let mut file_info = FileInfo::new(&format!("{bucket}/{object}"), 1, 0);
file_info.volume = bucket.to_string();
file_info.name = object.to_string();
file_info.mod_time = Some(::time::OffsetDateTime::now_utc());
file_info.erasure.index = 1;
wrapper
.write_metadata("", bucket, object, file_info)
.await
.expect("object metadata should be written");
let mut writer = PendingWriter;
let result = wrapper
.walk_dir(
WalkDirOptions {
bucket: bucket.to_string(),
recursive: true,
..Default::default()
},
&mut writer,
)
.await;
assert_eq!(result.expect_err("walk_dir should time out"), DiskError::Timeout);
assert_eq!(wrapper.runtime_state(), RuntimeDriveHealthState::Online);
assert!(!wrapper.health.is_faulty());
},
)
.await;
}
#[tokio::test]
async fn walk_dir_writer_backpressure_timeout_marks_drive_failure_by_default() {
temp_env::async_with_vars([(rustfs_config::ENV_DRIVE_WALKDIR_TIMEOUT_SECS, Some("1"))], async {
let dir = tempfile::tempdir().expect("temp dir should be created");
let endpoint =
Endpoint::try_from(dir.path().to_str().expect("temp dir should be valid UTF-8")).expect("endpoint should parse");
let disk = Arc::new(LocalDisk::new(&endpoint, false).await.expect("local disk should be created"));
let wrapper = LocalDiskWrapper::new(disk, false);
let bucket = "test-bucket";
let object = "test-object";
wrapper.make_volume(bucket).await.expect("bucket should be created");
let mut file_info = FileInfo::new(&format!("{bucket}/{object}"), 1, 0);
file_info.volume = bucket.to_string();
file_info.name = object.to_string();
file_info.mod_time = Some(::time::OffsetDateTime::now_utc());
file_info.erasure.index = 1;
wrapper
.write_metadata("", bucket, object, file_info)
.await
.expect("object metadata should be written");
let mut writer = PendingWriter;
let result = wrapper
.walk_dir(
WalkDirOptions {
bucket: bucket.to_string(),
recursive: true,
..Default::default()
},
&mut writer,
)
.await;
assert_eq!(result.expect_err("walk_dir should time out"), DiskError::Timeout);
assert_eq!(wrapper.runtime_state(), RuntimeDriveHealthState::Suspect);
})
.await;
}
#[tokio::test]
async fn default_timeout_marks_drive_failure() {
let dir = tempfile::tempdir().expect("temp dir should be created");
let endpoint =
Endpoint::try_from(dir.path().to_str().expect("temp dir should be valid UTF-8")).expect("endpoint should parse");
let disk = Arc::new(LocalDisk::new(&endpoint, false).await.expect("local disk should be created"));
let wrapper = LocalDiskWrapper::new(disk, false);
let result = wrapper
.track_disk_health_with_op(
"read_metadata",
|| async {
tokio::time::sleep(Duration::from_millis(20)).await;
Ok(())
},
Duration::from_millis(1),
)
.await;
assert_eq!(result.expect_err("operation should time out"), DiskError::Timeout);
assert_eq!(wrapper.runtime_state(), RuntimeDriveHealthState::Suspect);
}
#[test]
#[serial_test::serial]
fn drive_timeout_health_policy_defaults_to_mark_failure() {
temp_env::with_var_unset(rustfs_config::ENV_DRIVE_TIMEOUT_HEALTH_ACTION, || {
let policy = get_drive_timeout_health_policy();
assert_eq!(policy, TimeoutHealthPolicy::MarkFailure);
assert_eq!(policy.scanner_timeout_health_action(), TimeoutHealthAction::MarkFailure);
});
}
#[test]
#[serial_test::serial]
fn drive_timeout_health_policy_respects_ignore_scanner() {
temp_env::with_var(
rustfs_config::ENV_DRIVE_TIMEOUT_HEALTH_ACTION,
Some(rustfs_config::DRIVE_TIMEOUT_HEALTH_ACTION_IGNORE_SCANNER),
|| {
let policy = get_drive_timeout_health_policy();
assert_eq!(policy, TimeoutHealthPolicy::IgnoreScanner);
assert_eq!(policy.scanner_timeout_health_action(), TimeoutHealthAction::IgnoreFailure);
},
);
}
#[test]
#[serial_test::serial]
fn drive_timeout_health_policy_invalid_value_falls_back_to_default() {
temp_env::with_var(rustfs_config::ENV_DRIVE_TIMEOUT_HEALTH_ACTION, Some("invalid"), || {
let policy = get_drive_timeout_health_policy();
assert_eq!(policy, TimeoutHealthPolicy::MarkFailure);
assert_eq!(policy.scanner_timeout_health_action(), TimeoutHealthAction::MarkFailure);
});
}
#[test]
fn reset_for_store_init_retry_clears_faulty_and_back_online() {
let endpoint = Endpoint::try_from("/tmp/reset-store-init-retry").expect("endpoint should parse");
+46 -14
View File
@@ -82,17 +82,22 @@ impl TryFrom<&str> for Endpoint {
#[cfg(not(windows))]
let path = Path::new(&path).absolutize()?;
// On windows having a preceding SlashSeparator will cause problems, if the
// command line already has C:/<export-folder/ in it. Final resulting
// path on windows might become C:/C:/ this will cause problems
// of starting rustfs server properly in distributed mode on windows.
// As a special case make sure to trim the separator.
#[cfg(windows)]
let path = Path::new(&path[1..]).absolutize()?;
let path = if has_leading_slash_windows_drive(&path) {
// Url::path() exposes file-like Windows paths as `/C:/...`.
// Strip only that synthetic leading slash; plain URL paths
// such as `/export1` must stay URL paths, not become
// relative paths under the current drive.
Path::new(&path[1..]).absolutize()?.to_string_lossy().into_owned()
} else {
path
};
#[cfg(windows)]
let path = Path::new(&path);
debug!("endpoint try_from: path={}", path.display());
if path.parent().is_none() || Path::new("").eq(&path) {
if path.parent().is_none() || path.as_os_str().is_empty() {
return Err(Error::other("empty or root path is not supported in URL endpoint"));
}
@@ -217,6 +222,12 @@ impl Endpoint {
}
}
#[cfg(windows)]
fn has_leading_slash_windows_drive(path: &str) -> bool {
let bytes = path.as_bytes();
bytes.len() >= 4 && bytes[0] == b'/' && bytes[1].is_ascii_alphabetic() && bytes[2] == b':' && bytes[3] == b'/'
}
/// parse a file path into a URL.
fn url_parse_from_file_path(value: &str) -> Result<Url> {
// Only check if the arg is an ip address and ask for scheme since its absent.
@@ -242,6 +253,14 @@ fn url_parse_from_file_path(value: &str) -> Result<Url> {
mod test {
use super::*;
fn expected_file_path(path: &str) -> String {
Path::new(path).absolutize().unwrap().to_string_lossy().replace('\\', "/")
}
fn expected_file_url(path: &str) -> Url {
url_parse_from_file_path(path).unwrap()
}
#[test]
fn test_new_endpoint() {
#[derive(Default)]
@@ -255,7 +274,7 @@ mod test {
let u2 = Url::parse("https://example.org/path").unwrap();
let u4 = Url::parse("http://192.168.253.200/path").unwrap();
let u6 = Url::parse("http://server:/path").unwrap();
let root_slash_foo = Url::from_file_path("/foo").unwrap();
let root_slash_foo = expected_file_url("/foo");
let test_cases = [
TestCase {
@@ -416,7 +435,7 @@ mod test {
// Test file path display
let file_endpoint = Endpoint::try_from("/tmp/data").unwrap();
let display_str = format!("{file_endpoint}");
assert_eq!(display_str, "/tmp/data");
assert_eq!(display_str, expected_file_path("/tmp/data"));
// Test URL display
let url_endpoint = Endpoint::try_from("http://example.com:9000/path").unwrap();
@@ -479,12 +498,25 @@ mod test {
#[test]
fn test_endpoint_get_file_path() {
let file_endpoint = Endpoint::try_from("/tmp/data").unwrap();
assert_eq!(file_endpoint.get_file_path(), "/tmp/data");
assert_eq!(file_endpoint.get_file_path(), expected_file_path("/tmp/data"));
let url_endpoint = Endpoint::try_from("http://example.com:9000/path/to/data").unwrap();
assert_eq!(url_endpoint.get_file_path(), "/path/to/data");
}
#[cfg(windows)]
#[test]
fn test_windows_url_drive_path_requires_separator_after_colon() {
let drive_path_endpoint = Endpoint::try_from("http://host/C:/data").unwrap();
assert_eq!(drive_path_endpoint.get_type(), EndpointType::Url);
assert!(has_leading_slash_windows_drive(Url::parse("http://host/C:/data").unwrap().path()));
let url_path_endpoint = Endpoint::try_from("http://host/C:foo").unwrap();
assert_eq!(url_path_endpoint.get_type(), EndpointType::Url);
assert!(!has_leading_slash_windows_drive(Url::parse("http://host/C:foo").unwrap().path()));
assert_eq!(url_path_endpoint.get_file_path(), "/C:foo");
}
#[test]
fn test_endpoint_clone_and_equality() {
let endpoint1 = Endpoint::try_from("/tmp/data").unwrap();
@@ -503,7 +535,7 @@ mod test {
// Test with complex paths
let complex_path = "/var/lib/rustfs/data/bucket1";
let endpoint = Endpoint::try_from(complex_path).unwrap();
assert_eq!(endpoint.get_file_path(), complex_path);
assert_eq!(endpoint.get_file_path(), expected_file_path(complex_path));
assert!(endpoint.is_local);
assert_eq!(endpoint.get_type(), EndpointType::Path);
}
@@ -512,7 +544,7 @@ mod test {
fn test_endpoint_with_spaces_in_path() {
let path_with_spaces = "/Users/test/Library/Application Support/rustfs/data";
let endpoint = Endpoint::try_from(path_with_spaces).unwrap();
assert_eq!(endpoint.get_file_path(), path_with_spaces);
assert_eq!(endpoint.get_file_path(), expected_file_path(path_with_spaces));
assert!(endpoint.is_local);
assert_eq!(endpoint.get_type(), EndpointType::Path);
}
@@ -532,7 +564,7 @@ mod test {
// Verify that get_file_path() decodes the percent-encoded path correctly
assert_eq!(
endpoint.get_file_path(),
"/Users/test/Library/Application Support/rustfs/data",
expected_file_path("/Users/test/Library/Application Support/rustfs/data"),
"get_file_path() should decode percent-encoded spaces"
);
}
@@ -544,7 +576,7 @@ mod test {
let endpoint = Endpoint::try_from(path_with_special).unwrap();
// get_file_path() should return the original path with decoded characters
assert_eq!(endpoint.get_file_path(), path_with_special);
assert_eq!(endpoint.get_file_path(), expected_file_path(path_with_special));
}
#[test]
+1 -1
View File
@@ -545,7 +545,7 @@ mod tests {
// Create two different files
tokio::fs::write(&file1_path, b"content1").await.unwrap();
tokio::fs::write(&file2_path, b"content2").await.unwrap();
tokio::fs::write(&file2_path, b"different content").await.unwrap();
// Get metadata
let metadata1 = tokio::fs::metadata(&file1_path).await.unwrap();
+434 -71
View File
@@ -57,14 +57,15 @@ use std::{
use time::OffsetDateTime;
use tokio::fs::{self, File};
use tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWrite, AsyncWriteExt, ErrorKind};
use tokio::sync::RwLock;
use tokio::time::interval;
use tokio::sync::{Notify, RwLock};
use tokio::time::{Instant, interval_at, timeout};
use tracing::{debug, error, info, warn};
use uuid::Uuid;
const DELETED_OBJECTS_CLEANUP_INTERVAL: Duration = Duration::from_secs(60 * 5);
const STALE_TMP_OBJECT_EXPIRY: Duration = Duration::from_secs(24 * 60 * 60);
const RUSTFS_META_TMP_OLD_BUCKET: &str = ".rustfs.sys/tmp-old";
const STARTUP_CLEANUP_WAIT_TIMEOUT: Duration = Duration::from_secs(2);
#[derive(Debug, Clone)]
pub struct FormatInfo {
@@ -331,6 +332,8 @@ pub struct LocalDisk {
// pub format_data: Mutex<Vec<u8>>,
// pub format_file_info: Mutex<Option<Metadata>>,
// pub format_last_check: Mutex<Option<OffsetDateTime>>,
startup_cleanup_ready: Arc<AtomicU32>,
startup_cleanup_notify: Arc<Notify>,
exit_signal: Option<tokio::sync::broadcast::Sender<()>>,
}
@@ -370,7 +373,15 @@ impl LocalDisk {
ensure_data_usage_layout(&root).await.map_err(DiskError::from)?;
if cleanup && let Err(err) = Self::cleanup_tmp_on_startup(&root).await {
let startup_cleanup_ready = Arc::new(AtomicU32::new(u32::from(!cleanup)));
let startup_cleanup_notify = Arc::new(Notify::new());
if cleanup
&& let Err(err) =
Self::cleanup_tmp_on_startup(&root, startup_cleanup_ready.clone(), startup_cleanup_notify.clone()).await
{
startup_cleanup_ready.store(1, Ordering::Release);
startup_cleanup_notify.notify_waiters();
warn!(root = ?root, error = ?err, "failed to cleanup temporary data during disk startup");
}
@@ -466,6 +477,8 @@ impl LocalDisk {
// format_last_check: Mutex::new(format_last_check),
path_cache: Arc::new(ParkingLotRwLock::new(HashMap::with_capacity(2048))),
current_dir: Arc::new(OnceLock::new()),
startup_cleanup_ready,
startup_cleanup_notify,
exit_signal: None,
};
let (info, _root) = get_disk_info(root).await?;
@@ -497,7 +510,8 @@ impl LocalDisk {
}
async fn cleanup_deleted_objects_loop(root: PathBuf, mut exit_rx: tokio::sync::broadcast::Receiver<()>) {
let mut interval = interval(DELETED_OBJECTS_CLEANUP_INTERVAL);
let start_at = Instant::now() + DELETED_OBJECTS_CLEANUP_INTERVAL;
let mut interval = interval_at(start_at, DELETED_OBJECTS_CLEANUP_INTERVAL);
loop {
tokio::select! {
_ = interval.tick() => {
@@ -525,12 +539,18 @@ impl LocalDisk {
root.join(meta_path)
}
async fn cleanup_tmp_on_startup(root: &Path) -> Result<()> {
async fn cleanup_tmp_on_startup(
root: &Path,
startup_cleanup_ready: Arc<AtomicU32>,
startup_cleanup_notify: Arc<Notify>,
) -> Result<()> {
let tmp_path = Self::meta_path(root, RUSTFS_META_TMP_BUCKET);
let tmp_old_path = Self::meta_path(root, RUSTFS_META_TMP_OLD_BUCKET).join(Uuid::new_v4().to_string());
rename_all(&tmp_path, &tmp_old_path, root).await?;
tokio::fs::create_dir_all(Self::meta_path(root, RUSTFS_META_TMP_DELETED_BUCKET)).await?;
let tmp_old_root = Self::meta_path(root, RUSTFS_META_TMP_OLD_BUCKET);
tokio::spawn(async move {
if let Err(err) = tokio::fs::remove_dir_all(&tmp_old_root).await
@@ -538,12 +558,35 @@ impl LocalDisk {
{
warn!(path = ?tmp_old_root, error = ?err, "failed to remove old temporary data");
}
startup_cleanup_ready.store(1, Ordering::Release);
startup_cleanup_notify.notify_waiters();
});
tokio::fs::create_dir_all(Self::meta_path(root, RUSTFS_META_TMP_DELETED_BUCKET)).await?;
Ok(())
}
async fn wait_for_startup_cleanup(&self) {
if self.startup_cleanup_ready.load(Ordering::Acquire) != 0 {
return;
}
if wait_for_startup_cleanup_signal(
self.startup_cleanup_ready.as_ref(),
self.startup_cleanup_notify.as_ref(),
STARTUP_CLEANUP_WAIT_TIMEOUT,
)
.await
{
debug!(disk = %self.endpoint, "startup cleanup barrier released before walk_dir");
} else {
warn!(
disk = %self.endpoint,
timeout_ms = STARTUP_CLEANUP_WAIT_TIMEOUT.as_millis(),
"startup cleanup barrier timed out; continuing walk_dir"
);
}
}
async fn cleanup_stale_tmp_objects(root: PathBuf) -> Result<()> {
Self::cleanup_stale_tmp_objects_with_expiry(root, STALE_TMP_OBJECT_EXPIRY).await
}
@@ -1163,33 +1206,45 @@ impl LocalDisk {
}
// write_all_internal do write file
async fn write_all_internal(&self, file_path: &Path, data: InternalBuf<'_>, sync: bool, skip_parent: &Path) -> Result<()> {
let flags = O_CREATE | O_WRONLY | O_TRUNC;
let mut f = {
if sync {
// TODO: support sync
self.open_file(file_path, flags, skip_parent).await?
} else {
self.open_file(file_path, flags, skip_parent).await?
}
let skip_parent = if skip_parent.as_os_str().is_empty() {
self.root.as_path()
} else {
skip_parent
};
match data {
InternalBuf::Ref(buf) => {
let mut f = self.open_file(file_path, O_CREATE | O_WRONLY | O_TRUNC, skip_parent).await?;
f.write_all(buf).await.map_err(to_file_error)?;
}
InternalBuf::Owned(buf) => {
// Reduce one copy by using the owned buffer directly.
// It may be more efficient for larger writes.
let mut f = f.into_std().await;
let task = tokio::task::spawn_blocking(move || {
use std::io::Write as _;
f.write_all(buf.as_ref()).map_err(to_file_error)
});
task.await??;
let path = file_path.to_path_buf();
if let Some(parent) = path.parent()
&& parent != skip_parent
{
os::make_dir_all(parent, skip_parent).await?;
}
tokio::task::spawn_blocking(move || {
let mut f = std::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(path)
.map_err(to_file_error)?;
std::io::Write::write_all(&mut f, buf.as_ref()).map_err(to_file_error)?;
Ok::<(), std::io::Error>(())
})
.await
.map_err(DiskError::from)??;
}
}
// Keep existing durability contract: this path intentionally ignores `sync`.
let _ = sync;
Ok(())
}
@@ -1199,7 +1254,9 @@ impl LocalDisk {
skip_parent = self.root.as_path();
}
if let Some(parent) = path.as_ref().parent() {
if let Some(parent) = path.as_ref().parent()
&& parent != skip_parent
{
os::make_dir_all(parent, skip_parent).await?;
}
@@ -1208,6 +1265,11 @@ impl LocalDisk {
Ok(f)
}
async fn open_file_read_only(&self, path: impl AsRef<Path>) -> Result<File> {
let f = super::fs::open_file(path.as_ref(), O_RDONLY).await.map_err(to_file_error)?;
Ok(f)
}
#[allow(dead_code)]
fn get_metrics(&self) -> DiskMetrics {
DiskMetrics::default()
@@ -1234,6 +1296,7 @@ impl LocalDisk {
}
#[async_recursion::async_recursion]
#[allow(clippy::too_many_arguments)]
async fn scan_dir<W>(
&self,
mut current: String,
@@ -1242,6 +1305,7 @@ impl LocalDisk {
out: &mut MetacacheWriter<W>,
objs_returned: &mut i32,
skip_current_dir_object: bool,
multipart_dir_to_skip: Option<HashSet<String>>,
) -> Result<()>
where
W: AsyncWrite + Unpin + Send,
@@ -1298,6 +1362,14 @@ impl LocalDisk {
if opts.limit > 0 && *objs_returned >= opts.limit {
return Ok(());
}
// check multipart dir
if skip_current_dir_object
&& let Some(ref dir_to_skip) = multipart_dir_to_skip
&& dir_to_skip.contains(entry.trim_end_matches(SLASH_SEPARATOR))
{
*item = "".to_owned();
continue;
}
// check prefix
if !prefix.is_empty() && !entry.starts_with(prefix.as_str()) {
*item = "".to_owned();
@@ -1367,15 +1439,25 @@ impl LocalDisk {
}
}
let mut dir_stack: Vec<(String, bool)> = Vec::with_capacity(5);
let mut dir_stack: Vec<(String, bool, Option<HashSet<String>>)> = Vec::with_capacity(5);
// Explicit directory markers and real directories can resolve to the same logical path.
let schedule_dir = |dir_stack: &mut Vec<(String, bool)>, dir_name: String, skip_object: bool| {
if let Some((last_dir_name, existing_skip_object)) = dir_stack.last_mut()
let schedule_dir = |dir_stack: &mut Vec<(String, bool, Option<HashSet<String>>)>,
dir_name: String,
skip_object: bool,
dir_to_skip: Option<HashSet<String>>| {
if let Some((last_dir_name, existing_skip_object, existing_dir_to_skip)) = dir_stack.last_mut()
&& *last_dir_name == dir_name
{
*existing_skip_object |= skip_object;
if let Some(existing_dir_to_skip) = existing_dir_to_skip {
if let Some(new_dir_to_skip) = &dir_to_skip {
existing_dir_to_skip.extend(new_dir_to_skip.iter().cloned());
}
} else {
*existing_dir_to_skip = dir_to_skip;
}
} else {
dir_stack.push((dir_name, skip_object));
dir_stack.push((dir_name, skip_object, dir_to_skip));
}
};
prefix = "".to_owned();
@@ -1391,9 +1473,10 @@ impl LocalDisk {
let name = path_join_buf(&[current.as_str(), entry.as_str()]);
while let Some((pop, skip_object)) = dir_stack.last().cloned()
&& pop < name
while let Some((last_name, _, _)) = dir_stack.last()
&& *last_name < name
{
let (pop, skip_object, dir_to_skip) = dir_stack.pop().unwrap();
out.write_obj(&MetaCacheEntry {
name: pop.clone(),
..Default::default()
@@ -1401,11 +1484,11 @@ impl LocalDisk {
.await?;
if opts.recursive
&& let Err(er) = Box::pin(self.scan_dir(pop, prefix.clone(), opts, out, objs_returned, skip_object)).await
&& let Err(er) =
Box::pin(self.scan_dir(pop, prefix.clone(), opts, out, objs_returned, skip_object, dir_to_skip)).await
{
error!("scan_dir err {:?}", er);
}
dir_stack.pop();
}
let mut meta = MetaCacheEntry {
@@ -1442,11 +1525,24 @@ impl LocalDisk {
// }
if opts.recursive {
let mut dir_to_skip = HashSet::new();
if let Ok(file_meta) = FileMeta::load(&res)
&& let Ok(data_dirs) = file_meta.get_data_dirs()
{
for data_dir in data_dirs.iter().flatten() {
dir_to_skip.insert(data_dir.to_string());
}
}
let mut dir_name = meta.name.clone();
if !dir_name.ends_with(SLASH_SEPARATOR) {
dir_name.push_str(SLASH_SEPARATOR);
}
schedule_dir(&mut dir_stack, dir_name, true);
schedule_dir(
&mut dir_stack,
dir_name,
true,
if dir_to_skip.is_empty() { None } else { Some(dir_to_skip) },
);
}
}
Err(err) => {
@@ -1455,7 +1551,7 @@ impl LocalDisk {
// If dirObject, but no metadata (which is unexpected) we skip it.
if !is_dir_obj && !is_empty_dir(self.get_object_path(&opts.bucket, &meta.name)?).await {
meta.name.push_str(SLASH_SEPARATOR);
schedule_dir(&mut dir_stack, meta.name, false);
schedule_dir(&mut dir_stack, meta.name, false, None);
}
}
@@ -1464,7 +1560,7 @@ impl LocalDisk {
};
}
while let Some((dir, skip_object)) = dir_stack.pop() {
while let Some((dir, skip_object, dir_to_skip)) = dir_stack.pop() {
if opts.limit > 0 && *objs_returned >= opts.limit {
return Ok(());
}
@@ -1476,7 +1572,8 @@ impl LocalDisk {
.await?;
if opts.recursive
&& let Err(er) = Box::pin(self.scan_dir(dir, prefix.clone(), opts, out, objs_returned, skip_object)).await
&& let Err(er) =
Box::pin(self.scan_dir(dir, prefix.clone(), opts, out, objs_returned, skip_object, dir_to_skip)).await
{
warn!("scan_dir err {:?}", &er);
}
@@ -1958,11 +2055,7 @@ impl DiskAPI for LocalDisk {
let meta_op = match lstat_std(&src_file_path).map_err(|e| to_file_error(e).into()) {
Ok(meta) => Some(meta),
Err(e) => {
if e != DiskError::FileNotFound {
return Err(e);
}
None
return Err(e);
}
};
@@ -1974,10 +2067,22 @@ impl DiskAPI for LocalDisk {
}
remove_std(&dst_file_path).map_err(to_file_error)?;
} else {
let meta = lstat_std(&src_file_path).map_err(|e| -> DiskError { to_file_error(e).into() })?;
if meta.is_dir() {
warn!("rename_part src is dir {:?}", &src_file_path);
return Err(DiskError::FileAccessDenied);
}
}
rename_all(&src_file_path, &dst_file_path, &dst_volume_dir).await?;
let dst_meta = lstat_std(&dst_file_path).map_err(|e| -> DiskError { to_file_error(e).into() })?;
if src_is_dir != dst_meta.is_dir() {
warn!("rename_part dst type changed after rename {:?}", &dst_file_path);
return Err(DiskError::FileAccessDenied);
}
self.write_all(dst_volume, format!("{dst_path}.meta").as_str(), meta).await?;
if let Some(parent) = src_file_path.parent() {
@@ -2107,7 +2212,7 @@ impl DiskAPI for LocalDisk {
let file_path = self.get_object_path(volume, path)?;
check_path_length(file_path.to_string_lossy().as_ref())?;
let f = self.open_file(file_path, O_RDONLY, volume_dir).await?;
let f = self.open_file_read_only(file_path).await?;
Ok(Box::new(f))
}
@@ -2124,7 +2229,7 @@ impl DiskAPI for LocalDisk {
let file_path = self.get_object_path(volume, path)?;
check_path_length(file_path.to_string_lossy().as_ref())?;
let mut f = self.open_file(file_path, O_RDONLY, volume_dir).await?;
let mut f = self.open_file_read_only(file_path).await?;
let meta = f.metadata().await?;
let end_offset = offset.checked_add(length).ok_or(DiskError::FileCorrupt)?;
@@ -2153,9 +2258,6 @@ impl DiskAPI for LocalDisk {
#[allow(unsafe_code)]
#[tracing::instrument(level = "debug", skip(self))]
async fn read_file_zero_copy(&self, volume: &str, path: &str, offset: usize, length: usize) -> Result<Bytes> {
use std::time::Instant;
let start = Instant::now();
let volume_dir = self.get_bucket_path(volume)?;
if !skip_access_checks(volume) {
access(&volume_dir)
@@ -2188,6 +2290,9 @@ impl DiskAPI for LocalDisk {
#[cfg(unix)]
{
use memmap2::MmapOptions;
use std::time::Instant;
let start = Instant::now();
let file_path_clone = file_path.clone();
let should_reclaim_after_read = should_reclaim_file_cache_after_read(length);
@@ -2272,8 +2377,7 @@ impl DiskAPI for LocalDisk {
f.seek(SeekFrom::Start(offset as u64)).await?;
}
let mut buffer = Vec::with_capacity(length);
buffer.resize(length, 0);
let mut buffer = vec![0; length];
f.read_exact(&mut buffer).await?;
Ok(Bytes::from(buffer))
@@ -2314,6 +2418,8 @@ impl DiskAPI for LocalDisk {
// FIXME: TODO: io.writer TODO cancel
#[tracing::instrument(level = "debug", skip(self, wr))]
async fn walk_dir<W: AsyncWrite + Unpin + Send>(&self, opts: WalkDirOptions, wr: &mut W) -> Result<()> {
self.wait_for_startup_cleanup().await;
let volume_dir = self.get_bucket_path(&opts.bucket)?;
if !skip_access_checks(&opts.bucket)
@@ -2329,6 +2435,7 @@ impl DiskAPI for LocalDisk {
let mut objs_returned = 0;
let mut skip_current_dir_object = false;
let mut multipart_dir_to_skip: HashSet<String> = HashSet::new();
if opts.base_dir.ends_with(SLASH_SEPARATOR) {
if let Ok(data) = self
.read_metadata(
@@ -2352,10 +2459,23 @@ impl DiskAPI for LocalDisk {
let fpath =
self.get_object_path(&opts.bucket, path_join_buf(&[opts.base_dir.as_str(), STORAGE_FORMAT_FILE]).as_str())?;
if let Ok(meta) = tokio::fs::metadata(fpath).await
if let Ok(meta) = tokio::fs::metadata(&fpath).await
&& meta.is_file()
{
skip_current_dir_object = true;
if let Ok(meta_bytes) = self
.read_metadata(
opts.bucket.as_str(),
path_join_buf(&[opts.base_dir.as_str(), STORAGE_FORMAT_FILE]).as_str(),
)
.await
&& let Ok(file_meta) = FileMeta::load(&meta_bytes)
&& let Ok(data_dirs) = file_meta.get_data_dirs()
{
for data_dir in data_dirs.iter().flatten() {
multipart_dir_to_skip.insert(data_dir.to_string());
}
}
}
}
}
@@ -2367,6 +2487,11 @@ impl DiskAPI for LocalDisk {
&mut out,
&mut objs_returned,
skip_current_dir_object,
if multipart_dir_to_skip.is_empty() {
None
} else {
Some(multipart_dir_to_skip)
},
)
.await?;
@@ -2465,32 +2590,41 @@ impl DiskAPI for LocalDisk {
// TODO: Healing
let search_version_id = fi.version_id.or(Some(Uuid::nil()));
let version_id = fi.version_id.unwrap_or_default();
let search_version_id = Some(version_id);
let no_inline = fi.data.is_none() && fi.size > 0;
// Check if there's an existing version with the same version_id that has a data_dir to clean up
let has_old_data_dir = {
xlmeta.find_version(search_version_id).ok().and_then(|(_, ver)| {
// shard_count == 0 means no other version shares this data_dir
ver.get_data_dir()
.filter(|&data_dir| xlmeta.shard_data_dir_count(&search_version_id, &Some(data_dir)) == 0)
})
};
// Reuse one metadata scan to find the version data_dir and determine whether it is shared.
let has_old_data_dir = xlmeta.find_unshared_data_dir_for_version(search_version_id);
if let Some(old_data_dir) = has_old_data_dir.as_ref() {
let _ = xlmeta.data.remove(vec![search_version_id.unwrap_or_default(), *old_data_dir]);
let _ = xlmeta.data.remove_two(version_id, *old_data_dir);
}
xlmeta.add_version(fi.clone())?;
xlmeta.add_version(fi)?;
if xlmeta.versions.len() <= 10 {
// TODO: Sign
}
let new_dst_buf = xlmeta.marshal_msg()?;
self.write_all(src_volume, format!("{}/{}", &src_path, STORAGE_FORMAT_FILE).as_str(), new_dst_buf.into())
.await?;
if let Some((src_data_path, dst_data_path)) = has_data_dir_path.as_ref() {
let no_inline = fi.data.is_none() && fi.size > 0;
let src_file_parent = src_file_path.parent().unwrap_or(src_volume_dir.as_path());
let meta_skip_parent = if no_inline {
src_file_parent
} else {
src_volume_dir.as_path()
};
let new_dst_buf = xlmeta.marshal_msg()?;
self.write_all_private(
src_volume,
format!("{}/{}", &src_path, STORAGE_FORMAT_FILE).as_str(),
new_dst_buf.into(),
true,
meta_skip_parent,
)
.await?;
if no_inline && let Err(err) = rename_all(&src_data_path, &dst_data_path, &skip_parent).await {
let _ = self.delete_file(&dst_volume_dir, dst_data_path, false, false).await;
info!(
@@ -2499,6 +2633,10 @@ impl DiskAPI for LocalDisk {
);
return Err(err);
}
} else {
let new_dst_buf = xlmeta.marshal_msg()?;
self.write_all(src_volume, format!("{}/{}", &src_path, STORAGE_FORMAT_FILE).as_str(), new_dst_buf.into())
.await?;
}
if let Some(old_data_dir) = has_old_data_dir {
@@ -2985,7 +3123,6 @@ impl DiskAPI for LocalDisk {
#[tracing::instrument(skip(self))]
async fn disk_info(&self, _: &DiskInfoOptions) -> Result<DiskInfo> {
let mut info = Cache::get(self.disk_info_cache.clone()).await?;
// TODO: nr_requests, rotational
info.nr_requests = self.nrrequests;
info.rotational = self.rotational;
info.mount_path = self.path().to_str().unwrap().to_string();
@@ -3013,6 +3150,31 @@ impl DiskAPI for LocalDisk {
}
}
async fn wait_for_startup_cleanup_signal(
startup_cleanup_ready: &AtomicU32,
startup_cleanup_notify: &Notify,
wait_timeout: Duration,
) -> bool {
if startup_cleanup_ready.load(Ordering::Acquire) != 0 {
return true;
}
timeout(wait_timeout, async {
loop {
if startup_cleanup_ready.load(Ordering::Acquire) != 0 {
return;
}
let notified = startup_cleanup_notify.notified();
if startup_cleanup_ready.load(Ordering::Acquire) != 0 {
return;
}
notified.await;
}
})
.await
.is_ok()
}
#[tracing::instrument]
async fn get_disk_info(drive_path: PathBuf) -> Result<(rustfs_utils::os::DiskInfo, bool)> {
let drive_path = drive_path.to_string_lossy().to_string();
@@ -3065,7 +3227,9 @@ mod test {
fs::create_dir_all(leftover.parent().unwrap()).await.unwrap();
fs::write(&leftover, b"temporary").await.unwrap();
LocalDisk::cleanup_tmp_on_startup(dir.path()).await.unwrap();
LocalDisk::cleanup_tmp_on_startup(dir.path(), Arc::new(AtomicU32::new(0)), Arc::new(Notify::new()))
.await
.unwrap();
assert!(!tmp.join("leftover").exists());
assert!(LocalDisk::meta_path(dir.path(), RUSTFS_META_TMP_DELETED_BUCKET).exists());
@@ -3121,6 +3285,54 @@ mod test {
assert!(entries.next_entry().await.unwrap().is_none());
}
#[tokio::test(start_paused = true)]
async fn cleanup_loop_interval_does_not_tick_immediately() {
let start_at = tokio::time::Instant::now() + DELETED_OBJECTS_CLEANUP_INTERVAL;
let mut interval = interval_at(start_at, DELETED_OBJECTS_CLEANUP_INTERVAL);
assert!(tokio::time::timeout(Duration::from_secs(1), interval.tick()).await.is_err());
tokio::time::advance(DELETED_OBJECTS_CLEANUP_INTERVAL).await;
interval.tick().await;
}
#[tokio::test(start_paused = true)]
async fn startup_cleanup_barrier_waits_for_notification() {
let ready = Arc::new(AtomicU32::new(0));
let notify = Arc::new(Notify::new());
let wait = tokio::spawn({
let ready = ready.clone();
let notify = notify.clone();
async move { wait_for_startup_cleanup_signal(ready.as_ref(), notify.as_ref(), Duration::from_secs(2)).await }
});
tokio::task::yield_now().await;
assert!(!wait.is_finished());
ready.store(1, Ordering::Release);
notify.notify_waiters();
assert!(wait.await.unwrap());
}
#[tokio::test(start_paused = true)]
async fn startup_cleanup_barrier_times_out() {
let ready = Arc::new(AtomicU32::new(0));
let notify = Arc::new(Notify::new());
let wait = tokio::spawn({
let ready = ready.clone();
let notify = notify.clone();
async move { wait_for_startup_cleanup_signal(ready.as_ref(), notify.as_ref(), Duration::from_secs(2)).await }
});
tokio::task::yield_now().await;
tokio::time::advance(Duration::from_secs(2)).await;
assert!(!wait.await.unwrap());
}
#[tokio::test]
async fn test_scan_dir_includes_nested_object_dirs() {
use rustfs_filemeta::MetacacheReader;
@@ -3152,7 +3364,7 @@ mod test {
};
let mut objs_returned = 0;
disk.scan_dir("".to_string(), "".to_string(), &opts, &mut out, &mut objs_returned, false)
disk.scan_dir("".to_string(), "".to_string(), &opts, &mut out, &mut objs_returned, false, None)
.await
.unwrap();
out.close().await.unwrap();
@@ -3207,7 +3419,7 @@ mod test {
};
let mut objs_returned = 0;
disk.scan_dir("marker/".to_string(), "".to_string(), &opts, &mut out, &mut objs_returned, false)
disk.scan_dir("marker/".to_string(), "".to_string(), &opts, &mut out, &mut objs_returned, false, None)
.await
.unwrap();
out.close().await.unwrap();
@@ -3267,7 +3479,7 @@ mod test {
};
let mut objs_returned = 0;
disk.scan_dir(base_dir.to_string(), "".to_string(), &opts, &mut out, &mut objs_returned, false)
disk.scan_dir(base_dir.to_string(), "".to_string(), &opts, &mut out, &mut objs_returned, false, None)
.await
.unwrap();
out.close().await.unwrap();
@@ -3328,6 +3540,155 @@ mod test {
assert_eq!(double_count as usize, double_names.len());
}
#[tokio::test]
async fn test_walk_dir_ignore_multipart_dirs() {
use rustfs_filemeta::MetacacheReader;
use tempfile::tempdir;
const UUID_MULTIPART_1: &str = "8b262d24-fcf9-473d-a4cd-f9b27f24f60e";
const UUID_MULTIPART_2: &str = "fbf3183c-63be-45cc-b3bf-424ddb7f95f8";
const UUID_OBJ: &str = "db8b9b74-9016-4f9e-83e9-82a772947d28";
const VER_ID_1: &str = "c683f9f8-c0a1-4bc5-8a67-0faafa839a1a";
const VER_ID_2: &str = "a4b84f6e-c8ba-461b-8f9d-43feb0893efb";
const VER_ID_3: &str = "892c9ae7-2bb3-44ee-9a71-bc7ddf08d765";
const BASE_DIR: &str = "dir1/obj/";
const MULTIPART_DIR: &str = "multipart-file";
const DIR_IN_MULTIPART_DIR: &str = "dir-in-multipart";
const EMPTY_STR: &str = "";
let parse_uuid = |s: &str| Uuid::parse_str(s).unwrap();
let create_file_info = |version_id: &str, data_dir: &str| FileInfo {
version_id: Some(parse_uuid(version_id)),
data_dir: Some(parse_uuid(data_dir)),
mod_time: Some(OffsetDateTime::now_utc()),
..Default::default()
};
let dir = tempdir().unwrap();
let obj_base = dir.path().join("test-bucket").join(BASE_DIR);
let multipart_base = obj_base.join(MULTIPART_DIR);
let dir_in_multipart_base = multipart_base.join(DIR_IN_MULTIPART_DIR);
fs::create_dir_all(&multipart_base).await.unwrap();
for uuid in &[UUID_MULTIPART_1, UUID_MULTIPART_2] {
fs::create_dir_all(multipart_base.join(uuid)).await.unwrap();
fs::write(multipart_base.join(uuid).join("part.1"), b"part").await.unwrap();
}
fs::create_dir_all(obj_base.join(UUID_OBJ)).await.unwrap();
fs::write(obj_base.join(UUID_OBJ).join("part.1"), b"part").await.unwrap();
fs::create_dir_all(&dir_in_multipart_base).await.unwrap();
fs::write(dir_in_multipart_base.join(STORAGE_FORMAT_FILE), b"meta")
.await
.unwrap();
let mut fm = FileMeta::default();
fm.add_version(create_file_info(VER_ID_1, UUID_MULTIPART_1)).unwrap();
fm.add_version(create_file_info(VER_ID_2, UUID_MULTIPART_2)).unwrap();
fs::write(multipart_base.join(STORAGE_FORMAT_FILE), fm.marshal_msg().unwrap())
.await
.unwrap();
let mut fm = FileMeta::default();
fm.add_version(create_file_info(VER_ID_3, UUID_OBJ)).unwrap();
fs::write(obj_base.join(STORAGE_FORMAT_FILE), fm.marshal_msg().unwrap())
.await
.unwrap();
let endpoint = Endpoint::try_from(dir.path().to_str().unwrap()).unwrap();
let disk = LocalDisk::new(&endpoint, false).await.unwrap();
let (reader, mut writer) = tokio::io::duplex(4096);
disk.walk_dir(
WalkDirOptions {
bucket: "test-bucket".to_string(),
base_dir: BASE_DIR.to_string(),
recursive: true,
filter_prefix: Some(EMPTY_STR.to_string()),
..Default::default()
},
&mut writer,
)
.await
.unwrap();
MetacacheWriter::new(&mut writer).close().await.unwrap();
let mut reader = MetacacheReader::new(reader);
let entries = reader.read_all().await.unwrap();
let names: Vec<String> = entries.into_iter().map(|entry| entry.name).collect();
assert_eq!(
names
.iter()
.filter(|name| *name == &format!("{}{}", BASE_DIR, MULTIPART_DIR))
.count(),
1
);
assert_eq!(
names
.iter()
.filter(|name| *name == &format!("{}{}/", BASE_DIR, MULTIPART_DIR))
.count(),
1
);
assert_eq!(
names
.iter()
.filter(|name| *name == &format!("{}{}/{}", BASE_DIR, MULTIPART_DIR, DIR_IN_MULTIPART_DIR))
.count(),
1
);
assert_eq!(
names
.iter()
.filter(|name| *name == &format!("{}{}/{}/", BASE_DIR, MULTIPART_DIR, DIR_IN_MULTIPART_DIR))
.count(),
1
);
assert_eq!(
names
.iter()
.filter(|name| *name == &format!("{}{}/{}", BASE_DIR, MULTIPART_DIR, UUID_MULTIPART_1))
.count(),
0
);
assert_eq!(
names
.iter()
.filter(|name| *name == &format!("{}{}/{}", BASE_DIR, MULTIPART_DIR, UUID_MULTIPART_2))
.count(),
0
);
assert_eq!(
names
.iter()
.filter(|name| *name == &format!("{}{}", BASE_DIR, UUID_OBJ))
.count(),
0
);
assert_eq!(
names
.iter()
.filter(|name| *name == &format!("{}{}/{}/", BASE_DIR, MULTIPART_DIR, UUID_MULTIPART_1))
.count(),
0
);
assert_eq!(
names
.iter()
.filter(|name| *name == &format!("{}{}/{}/", BASE_DIR, MULTIPART_DIR, UUID_MULTIPART_2))
.count(),
0
);
assert_eq!(
names
.iter()
.filter(|name| *name == &format!("{}{}/", BASE_DIR, UUID_OBJ))
.count(),
0
);
}
#[tokio::test]
async fn test_make_volume() {
let p = "./testv0";
@@ -3497,13 +3858,15 @@ mod test {
let disk_info = disk.disk_info(&disk_info_opts).await.unwrap();
// Basic checks on disk info
// Note: On macOS and some other Unix systems, fs_type may be empty
// Note: On macOS, Windows, and some other systems, fs_type may be empty
// because statvfs does not provide filesystem type information.
// This is a platform limitation, not a bug.
#[cfg(not(target_os = "macos"))]
#[cfg(not(any(target_os = "macos", windows)))]
assert!(!disk_info.fs_type.is_empty(), "fs_type should not be empty on this platform");
assert!(disk_info.total > 0);
assert!(disk_info.free <= disk_info.total);
assert_eq!(disk_info.nr_requests, disk.nrrequests);
assert_eq!(disk_info.rotational, disk.rotational);
assert!(!disk_info.mount_path.is_empty());
assert!(!disk_info.endpoint.is_empty());
+5 -3
View File
@@ -37,6 +37,7 @@ use crate::disk::disk_store::LocalDiskWrapper;
use crate::disk::health_state::RuntimeDriveHealthState;
use crate::disk::local::ScanGuard;
use crate::rpc::RemoteDisk;
use crate::rpc::build_internode_data_transport_from_env;
use bytes::Bytes;
use endpoint::Endpoint;
use error::DiskError;
@@ -476,7 +477,8 @@ pub async fn new_disk(ep: &Endpoint, opt: &DiskOption) -> Result<DiskStore> {
let s = LocalDisk::new(ep, opt.cleanup).await?;
Ok(Arc::new(Disk::Local(Box::new(LocalDiskWrapper::new(Arc::new(s), opt.health_check)))))
} else {
let remote_disk = RemoteDisk::new(ep, opt).await?;
let data_transport = build_internode_data_transport_from_env();
let remote_disk = RemoteDisk::new(ep, opt, data_transport?).await?;
Ok(Arc::new(Disk::Remote(Box::new(remote_disk))))
}
}
@@ -789,7 +791,6 @@ mod tests {
use super::*;
use endpoint::Endpoint;
use local::LocalDisk;
use std::path::PathBuf;
use tokio::fs;
use uuid::Uuid;
@@ -1094,7 +1095,7 @@ mod tests {
assert!(disk.is_ok());
let disk = disk.unwrap();
assert_eq!(disk.path(), PathBuf::from(test_dir).canonicalize().unwrap());
assert_eq!(disk.path(), rustfs_utils::canonicalize(test_dir).unwrap());
assert!(disk.is_local());
// Note: is_online() might return false for local disks without proper initialization
// This is expected behavior for test environments
@@ -1155,6 +1156,7 @@ mod tests {
cleanup: false,
health_check: false,
},
Arc::new(crate::rpc::TcpHttpInternodeDataTransport),
)
.await
.unwrap();
+18 -13
View File
@@ -215,24 +215,29 @@ pub async fn os_mkdir_all(dir_path: impl AsRef<Path>, base_dir: impl AsRef<Path>
return Ok(());
}
if let Some(parent) = dir_path.as_ref().parent() {
// Without recursion support, fall back to create_dir_all
if let Err(e) = super::fs::make_dir_all(&parent).await {
if e.kind() == io::ErrorKind::AlreadyExists {
return Ok(());
}
return Err(e);
}
// Box::pin(os_mkdir_all(&parent, &base_dir)).await?;
}
if let Err(e) = super::fs::mkdir(dir_path.as_ref()).await {
if e.kind() == io::ErrorKind::AlreadyExists {
return Ok(());
}
return Err(e);
if e.kind() != io::ErrorKind::NotFound {
return Err(e);
}
if let Some(parent) = dir_path.as_ref().parent() {
// Fall back to creating the missing parent chain only when the direct mkdir proves it is required.
if let Err(parent_err) = super::fs::make_dir_all(parent).await
&& parent_err.kind() != io::ErrorKind::AlreadyExists
{
return Err(parent_err);
}
}
if let Err(retry_err) = super::fs::mkdir(dir_path.as_ref()).await
&& retry_err.kind() != io::ErrorKind::AlreadyExists
{
return Err(retry_err);
}
}
Ok(())

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