Compare commits

...

13 Commits

Author SHA1 Message Date
overtrue 731cf35683 Merge remote-tracking branch 'origin/main' into fix/clippy-rust-1.100 2026-11-13 05:01:37 +08:00
overtrue afb5cf4ebb fix(clippy): satisfy lints introduced in Rust 1.100 2026-11-13 05:01:33 +08:00
GatewayJ b753f69300 fix(sts): accept Id in inline session policies (#9253)
* fix(sts): accept Id in inline session policies

* test(sts): register the session policy regression in e2e profiles

---------

Co-authored-by: Hauser Zhi <housemecn@gmail.com>
2026-11-13 04:02:38 +08:00
Hauser Zhi 3d2664152f test(rpc): verify TLS peer identity across DNS and IP channels (#9360)
* fix(rpc): derive TLS peer identity from the parsed endpoint

Use Tonic URI hostname handling for custom-root TLS connections. Manual colon splitting turns an IPv6 literal into an invalid DNS name and prevents internode channels from connecting. Preserve CA and mTLS identity configuration and cover DNS, IPv4, IPv6 and mapped addresses through the production TLS configuration boundary.

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

* fix(rpc): reconcile TLS merge with the main channel builder

Restore the upstream TLS configuration path after the conflicting merge removed its tls binding. Expand the existing real-handshake regression to DNS, IPv4 and IPv6 control/bulk channels, require an RPC response for positive cases, and verify restoration of TLS material and generation.

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

---------

Signed-off-by: Hauser Zhi <housemecn@gmail.com>
Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-11-13 04:01:12 +08:00
Chris An 4a34e512cd ci: skip the push run for a tree the merge queue already proved (#9369)
A merge-queue landing fast-forwards main to the group's commit, so the
push run re-validates a tree a successful merge_group run of ci.yml has
just passed. Select CI scope now looks that run up with the job's
read-only Actions token (scripts/ci_merge_group_proof.py) and plans
mode `proven`: Select CI scope, Typos and Quick Checks run, every
validation job is skipped, and Test and Lint requires exactly that.
The plan names the proving run, its not-applicable reasons and the
aggregate's summary say the jobs were skipped as proven.

Any doubt runs everything: no token, an API or network error, a rate
limit, an unexpected payload, no successful queue run of the same tree,
or a proof on another event. A direct push has no such run. The plan
contract moves to 5.
2026-11-13 03:58:53 +08:00
zjuncal 78325ce491 fix(s3-client): retry transient remote S3 requests (#9361)
Signed-off-by: zjuncal <naclzju@outlook.com>
2026-11-12 13:10:28 -05:00
Chris An 755cca3336 test(audit): assert event-name expansion by count, not wall clock (#9363) 2026-11-12 13:08:46 -05:00
Ramakrishna Chilaka 24b667e738 perf(ecstore): use pinned directory FDs for delete and part txns (#9366)
- Stream canonical version identity JSON directly into Sha256 via a stack-allocated Sha256Writer in delete_recovery::generation(), avoiding per-version Vec<u8> allocations during Stage 1 preparation.
- Open and cache pinned directory FDs (O_RDONLY | O_DIRECTORY | O_CLOEXEC) for the object directory and rollback transaction directory across fused recoverable DELETE Stage 1/2/3, replacing repeated full-path lookups with FD-relative openat, statat, linkat, renameat, unlinkat, fsync, and unlinkat(AT_REMOVEDIR).
- Open and cache pinned directory FDs for the part transaction parent and staging directories across prepare_part_transaction, rename_part, and settle_part_transaction, replacing repeated path walks with FD-relative statat, linkat, openat, renameat, fsync, and unlinkat.
- Purge cached delete and part transaction receipts under both io_root and root in LocalDisk::close and Drop for LocalDisk, and insert receipts in the async callers after .await so cancelled futures cannot insert FDs after disk teardown.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 21d8304f-0f49-48eb-8c28-26f3689b081b
2026-11-12 13:06:29 -05:00
Hauser Zhi 5f460c2383 perf(grpc): optimize peer pools and unary RPC observability (#9355)
* perf(protos): balance peer channels and synchronize TLS eviction

Use bounded per-peer bulk cursors and shared TLS generation reads. Keep eviction and generation removal coherent with concurrent publication, and expose opt-in bulk transport tuning with checked capacities.

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

* feat(metrics): observe internode unary request lifetimes

Record client attempts through errors and cancellation, preserve the published server-duration family, and add gated activity and completion metrics. Cache enabled instruments after recorder publication while retaining startup duration recording.

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

* test(perf): make internode gRPC experiments reproducible

Reset phase-owned settings before applying overrides, separate lifecycle experiments, and provide a bounded loopback workload with independently reported control and bulk latency.

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

* test(perf): isolate transport knobs and redact dry runs

Add single-parameter P0 presets with separate artifacts while preserving the legacy combined default. Mask credential arguments in dry-run output without changing actual dispatch, and document fleet stop barriers and measurement boundaries.

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

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

* feat(observability): qualify gRPC windows and trace shutdown

Reject stale, incomplete or maintenance-active measurement evidence using
producer timestamps and independent process identities. Preserve raw inputs,
bind evidence hashes, and exercise failure paths through the script entry point.

Record HTTP/2 graceful, child-join and TLS reloader retirement phases without
changing shared deadlines, join ownership or unclean-shutdown marker policy.
Document RPC coverage, fixed-window behavior and separate maintenance workloads.

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

* test(perf): add authenticated ReadAll and fixed-rate Ping probe

Exercise production remote-disk and signed control clients against task-owned temporary fixtures. Preserve all offered control arrivals, dispatch delay, payload validation and bounded raw request samples, with optional external measurement markers.

Register probe regressions with the default package test discovery and document its telemetry, retry and storage-acceptance boundaries.

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

* fix(perf): resolve RPC probe disks by explicit UUID

Allow a complete non-nil disk UUID mapping to be installed through the existing remote-disk client API before fixture preflight. This supports test services that register local paths instead of the probe's transport URLs, without modifying server metadata.

Validate mapping shape and assert the actual wire target in the loopback regression. Keep preflight diagnostics limited to the numeric disk-error classification.

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

* docs(perf): reseed temporary RPC fixtures after restart

Separate startup-cleaned RPC temporary files from confirmed S3 durability fixtures. Revalidate readiness and disk identity before reseeding the probe after recovery.

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

* fix(server): cancel unfinished TLS handshakes during shutdown

Cancel pre-request TLS acceptance when the listener retires so silent
peers cannot retain graceful watchers beyond the shared drain budget.
Keep established TLS/H2 response draining and original child joins intact.

Add real TCP/TLS shutdown regressions and document fresh native OTLP
observation fences and exec-confirmed diagnostic test units.

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

* perf(grpc): reuse bulk keys and fix IPv6 TLS verification

Reuse bounded per-peer slot keys and retain the existing cache identities for larger pools. Delegate custom-CA certificate names to tonic's URI parser and cover real IPv6 control/bulk TLS connections with a wrong-name rejection regression.

Classify ReadVersion failure boundaries while preserving error totals, raise the bounded raw probe budget, and align the Linux-only rename test helper with its caller's platform condition.

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

* test(grpc): keep eviction race behind cache helpers

Pause eviction on the owned TLS-generation lock and inspect the channel through the common cache API. Assert lock ordering while both mutations are queued, then retain the replacement-generation assertion after they complete.

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

* fix(grpc): load native trust and preserve mTLS client identity

Select explicit private trust or platform roots, then attach the published client identity for either mode. Build the TLS connector on the blocking pool under the existing acquisition deadline.

Cover real control/bulk TLS connections, required client authentication, private-root scope and queued configuration timeout without late connection establishment.

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

---------

Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-11-12 11:21:40 -05:00
Hauser Zhi 078fc47e9c perf(lock): avoid scanning live leases on every acquisition (#9356)
* fix(lock): allow more than 255 concurrent shared lock holders

The fast-lock state packed the reader count into 8 bits, so the 256th concurrent shared holder of one key was refused. Bucket-scoped fences are read-locked by every in-flight request into the bucket, so busy buckets hit the cap. The count now has 16 bits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* test(lock): cover shared-reader capacity through namespace quorum

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

* perf(lock): skip lease scans until a shard deadline is due

Keep a conservative earliest-expiry hint with the guard table. Healthy acquisitions and reaper passes avoid walking live leases, while due sweeps recompute the hint and release expired guards outside the registry lock. Preserve resource-specific retry counts and cover refresh, replacement, removal and cross-resource expiration.

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

---------

Co-authored-by: Rain Jiang <rain-jiang@outlook.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-11-12 10:21:21 -05:00
Chris An 55d9bf08a2 ci: run workspace clippy beside the test binary build (#9345)
Clippy ran in front of the test-binary archive inside Workspace Lint and Build, so every workspace test waited 13 to 17 minutes for a lint that shares no compiler output with the archive.

Move the unchanged Clippy step into its own workspace-clippy job with the same condition, needs, runner and cache key. Workspace Lint and Build keeps its ID and name as the archive producer. The aggregate and the scheduled alert depend on the new job, and the plan contract moves to 4 so a base planner that predates the job set falls back to the full matrix.

ci_gate.py now rejects a workflow where the lint is conditional, duplicated, outside the aggregate, scoped differently from the archive producer, or back on the archive consumers' dependency chain, and where the workflow's plan-contract marker differs from the planner's.
2026-11-12 10:09:05 -05:00
Chris An cf3e359794 refactor(storage): split impl S3 for FS bodies into legacy_edge files (#9319)
* feat(server): add the RUSTFS_S3_STACK selector and dual-stack e2e lane (#8411)

* refactor(server): group the s3s-compat tower layers under legacy_compat (#8410)

Move the ten tower layers that patch s3s behaviour out of
rustfs/src/server/layer.rs into rustfs/src/server/legacy_compat/, one file
per layer with its tests. Each file header names the s3s behaviour it
patches, the gateway mechanism that replaces it (Replaced by:), and the task
that removes it (Removed by: T2.12, rustfs/backlog#2771). The layers keep
their code, their order in external_service_stack!, and their behaviour; no
layer is deleted.

Add server::http::tests::stack_census, which renders the ordered service
chain of the real external_service_stack! expansion and compares it with a
committed constant, so a reorder, insertion or removal is a visible diff.

Route the two s3s error types the moved layers use through a
storage_api::server::legacy_compat shim so the split adds no s3s-importing
file, and lower the s3s footprint baselines to the measured values (files 209
to 208, s3_error! lines 1586 to 1585). Keep the server runtime-source bypass
guard covering the moved code.

Refs rustfs/backlog#2787, rustfs/backlog#2734

* feat(s3-types): add the RustFS-owned S3 error type and s3_error! macro (#8406)

`rustfs-s3-types` now provides `S3ErrorCode` (generated by build.rs from
src/codes.txt, the grep-derived call surface, plus `Custom`), `S3Error`,
`S3Result`, `StdError` and the `s3_error` macro with the same call syntax
as s3s, so the T1.2 codemod is a `use` swap. Statuses are hand-written and
pinned to the s3s behaviour RustFS ships today by an oracle test that uses
s3s as a dev-dependency only. The `From` conversions for the legacy edge
live behind the off-by-default `compat-s3s` feature (the orphan rule
forbids them in rustfs/src/error.rs); `rustfs` enables it.

Refs rustfs/backlog#2742, rustfs/backlog#2734

* ci: add the per-crate test-count ratchet (#8404)

Quick Checks now fails any PR that lowers the number of test attributes
in a workspace crate or adds #[ignore], measured per crate against the
exact rows of .config/test-count-baseline.txt (rustfs/backlog#2734
section 1 rule 3). The counter widens the spec's literal
#[(tokio::)?test] to the tokio attributes that carry arguments and both
spellings of #[test_case(...)], and anchors at line start so
commented-out attributes do not count. The initial baseline covers 52
crates, 21077 tests and 164 ignored.

Refs rustfs/backlog#2738
Refs rustfs/backlog#2734

* test(ecstore): pin the persisted bucket-config XML bytes for 13 families (#8408)

Before any DTO or codec is replaced, pin the exact bytes that
deserialize followed by serialize produces for every persisted
bucket-configuration XML family in its three source shapes (AWS
documentation, MinIO-written, RustFS-written), and the bytes RustFS
writes today for a typed value. Byte equality only: a structural
comparison would let a codec replacement change the persisted encoding
unnoticed.

The generator script refuses to overwrite existing goldens without
--force, refuses on dirty codec paths, cannot report success from its
write mode, and records the generating commit in the fixtures README.

Refs rustfs/backlog#2744, rustfs/backlog#2734

* ci: raise the test-count baseline to the integration tree (#8426)

* ci: make the s3s footprint baseline exact and lower-only (#8403)

* ci: make the s3s footprint baseline exact and lower-only

The s3s footprint guard compared three counters against ceilings held in
the script itself, so a ceiling could sit above the tree and a drift was
fixable by raising it (rustfs/rustfs#5964 raised the s3_error! ceiling
to go green). The three values now live in
.config/s3s-footprint-baseline.txt as files, s3_error_lines and
ecstore_files, the script reads only that file, and a measurement that
differs from its baseline in either direction fails: growth prints
"s3s footprint grew: <measured> > <baseline>", shrinkage prints
"lower the baseline to <measured> in this PR". The counting method is
unchanged; the baseline is the current measurement of
gateway/integration (209 / 1585 / 36). The header records the end state:
once all three reach 0, T4.2 switches the script to failing on any
occurrence.

Refs rustfs/backlog#2739, rustfs/backlog#2734

* ci: add the s3s edge allowlist mode to the footprint guard (#8405)

`scripts/check_s3s_footprint.sh --mode allowlist` fails on every Rust
file that references s3s (`s3s::` or `s3s_sigv4`) and is not matched by
an entry of the new `.config/s3s-edge-allowlist.txt`; `--dry-run` lists
those files and exits 0. The allowlist carries the initial edge from the
spec, and the script rejects an entry that names or matches anything
under crates/ other than crates/e2e_test. The baseline mode and the
argument-less CI invocation are unchanged.

Refs rustfs/backlog#2741, rustfs/backlog#2734

* test(ecstore): reach the bucket-config codec through the test facade (#8428)

* refactor(s3-types): own the HTTP Body and drop s3s from three crates (#8431)

Add rustfs_s3_types::Body, a BoxBody<Bytes, StdError> wrapper with
empty() and From<Bytes | Vec<u8> | String | &'static str>, and the
legacy From conversions to and from s3s::Body behind compat-s3s.

The signer is now generic over the request body: it only reads and
writes the request head, so it needs no body type and its callers keep
theirs. s3-client sends its requests with the owned Body, madmin signs a
bodiless head, and targets drops its two S3Request/S3Response header
wrappers in favour of extract_params_header.

rustfs-signer, rustfs-madmin and rustfs-targets no longer depend on
s3s; the s3s footprint baseline drops from 209 to 202 files.

Refs rustfs/backlog#2747, rustfs/backlog#2734

* refactor(ecstore): move S3ErrorCode to rustfs-s3-types by codemod (#8432)

* test(ecstore): pin the InvalidRange responses of new_getobjectreader

Both error branches of new_getobjectreader build an ErrorResponse with S3ErrorCode::InvalidRange, and no test observed either code. Pin the code, its wire name and the location fields of each branch before the code type moves to rustfs-s3-types (rustfs/backlog#2743), so a changed code on either branch fails a test. The ecstore test-count row rises by the two tests.

* chore(scripts): add the s3s-to-rustfs-s3-types error codemod

Task T1.2 of rustfs/backlog#2734 (rustfs/backlog#2743) moves S3Error, S3ErrorCode, S3Result and s3_error! from s3s to rustfs-s3-types by script, one crate closure per PR. The codemod rewrites use trees (splitting mixed s3s imports) and qualified code paths, never comments or literals, adds the rustfs-s3-types dependency, drops s3s only from a crate that no longer names it, refuses every file of the s3s edge allowlist, takes --hold for a file whose type is fixed outside the closure, and prints a per-crate census. A second run changes nothing. Its self-test runs under make script-tests.

* refactor(ecstore): take S3ErrorCode from rustfs-s3-types

Output of scripts/codemods/s3_error_to_s3_types.py over crates/ecstore, crates/lifecycle, crates/scanner and crates/replication (rustfs/backlog#2743 batches 1 and 2). The ErrorResponse code field, the two InvalidRange constructions in new_getobjectreader and an unused tier import now use rustfs_s3_types::S3ErrorCode; the variant names and wire names are unchanged. lifecycle, scanner and replication reference none of the four error names. warm_backend.rs and warm_backend_s3.rs are held: they compare against rustfs_s3_client::ErrorResponse.code, which stays the s3s type until s3-client moves.

* ci(footprint): count only s3_error! lines still on s3s's macro

s3_error_lines counted every line holding the text `s3_error!`, which
also matches the RustFS-owned `rustfs_s3_types::s3_error!`. A codemod
that moves call sites onto rustfs-s3-types therefore could not lower it
(rustfs/backlog#2743, decision 3; rustfs/rustfs#8432, gotcha 2).

The counter now applies a textual, per-file and per-line rule that errs
on the side of counting (full rule in the script header):
- occurrences spelled `rustfs_s3_types::s3_error!` never count;
- a line holding `s3s::s3_error!` always counts;
- any other `s3_error!` line counts unless its file is cleared, that is,
  it imports the macro from rustfs_s3_types by name on a line starting
  with the use item (or defines it with `macro_rules! s3_error`) and does
  not also bring s3s's macro in (`use s3s::s3_error`, a `use s3s::{..}`
  tree naming it or a crate-root `*`, `use s3s::*`, or
  `#[macro_use] extern crate s3s`);
- fail closed: a file matching neither form (`use super::*`, a
  crate-local re-export, a renamed or glob rustfs_s3_types import, a
  commented-out import, a comment-only mention) counts.

Census of the 1585 lines on 96fc06776: 1478 in 64 files that import the
macro from s3s; 107 in 15 files matching neither form (105 through
`use super::*`, 1 qualified `s3s::s3_error!`, 1 comment line); none in
files that import it from rustfs_s3_types or define it. Every line stays
counted, so the baseline remains s3_error_lines=1585 (files=202 and
ecstore_files=36 unchanged). Rewriting rustfs/src/auth.rs's import to
rustfs_s3_types lowers the new counter to 1567; the old one stayed 1585.

`--self-test` (wired into `make script-tests`) copies the script into
22 fixture trees: 6 positive cases and 16 negative ones (lines that must
not count, forms that must not clear a file, and a missing baseline,
missing key or failing classification search that must fail). Each tree
has a control line that must count, and each run gets s3s text on stdin.
Seven cases were red against the old counter. 19 mutants each turn their
case red: old definition, flipped import test, dropped fail-closed
branch, dropped e2e_test exclusion, dropped s3s veto, dropped qualified
s3s rule, dropped rustfs-qualified strip, unanchored rustfs import,
renamed import accepted, nested glob taken as root glob, single-line
s3s trees only, dropped s3s glob forms, dropped macro_use form, dropped
definition clearing, ignored classification rg errors, error-line search
without its path, classification searches without their path, missing
baseline skipped, missing key skipped.

* test(e2e): compare two RUSTFS_S3_STACK servers over every S3 operation (#8435)

Add dual_stack_differential to e2e_test. It starts two rustfs processes
from the same binary, stack A on RUSTFS_S3_STACK=legacy and stack B on
RUSTFS_E2E_STACK_B (default legacy until rustfs/backlog#2734 T2.5),
sends both the same aws-sdk-s3 script covering the 73 methods of
impl S3 for FS (a normal request and a 4xx negative each), and compares
status, error code, an allowlist of response headers and a few typed
body fields. Error message text is never compared. Each run writes
<target>/dual-stack/<run>.json with compared_ops and the (op, field,
a, b) differences; any difference fails the test.

differential_detects_an_injected_difference starts B with
RUSTFS_REGION=eu-west-1 and requires GetBucketLocation to be reported.
The comparator is pure and unit-tested without a server.

Wire the module into the e2e-smoke filter and update the e2e-smoke and
e2e-full selection digests and the e2e_test test-count row.

Refs rustfs/backlog#2740

* feat(app): add the RustFS-owned RequestEnvelope and its s3s constructor (#8437)

* feat(app): add the RustFS-owned RequestEnvelope and its s3s constructor

* ci: classify the s3s edge adapters in storage/s3_compat as interface

* feat(s3-types): add Body::from_stream and move Swift off s3s::Body (#8438)

rustfs/backlog#2747 batch 3. `Body::from_stream` wraps a chunk stream of
unknown length over `StreamBody`/`BoxBody`: one data frame per chunk, the
first error ends the body, the size hint stays unknown, and dropping the
body drops the stream. Parity tests pin it to the s3s `StreamingBlob::wrap`
it replaces, at the `http_body` level and byte for byte through hyper.

The Swift handlers in rustfs-protocols build their responses as the owned
`Body`. `SwiftService` converts them into the wrapped S3 service's response
body type through `From<Body>`, so rustfs keeps serving `s3s::Body` at the
legacy edge unchanged. Request bodies stay Send-only (`UnsyncBoxBody`); the
two storage writers and the DLO/SLO segment streams get `Sync` from
`sync_wrapper::SyncStream`, which is what s3s's `http_body_unsync` mutex
provided before. The DTO and error uses of s3s in protocols are untouched
(T1.5 and T1.2).

* refactor(protocols): move error types to rustfs-s3-types by codemod (#8439)

T1.2 batch 3 (rustfs/backlog#2743): s3select-api, s3select-query and
the error items of protocols move off s3s by scripts/codemods, run with
--hold crates/protocols/src/sftp/errors.rs. That file stays on s3s: it
downcasts the backend error, whose runtime type is still s3s::S3Error.

- rustfs/src/protocols/client.rs: the two webdav session methods now
  return rustfs_s3_types::S3Result and convert the s3s error with From.
- codemod: a child module that calls s3_error! through `use super::*`
  gets its own `use rustfs_s3_types::s3_error;` once its parent imports
  that macro by name; otherwise it is listed as GLOB.
- s3-types: a table test pins code, status and the s3s error the legacy
  edge renders for every variant a moved call site names.
- s3_error_lines 1585 -> 1584 (a lifecycle comment reworded).

* test(connect): restore one-second drive request budgets

Apply the exact test-only repair already landed on main in 5972a09cc3. The filesystem probe and production CLI success test request one second and keep their existing one-second duration and parameter checks. All outcome, I/O, error, cleanup and resource-limit assertions remain.

Scope: rustfs/tests/connect_perf_drive.rs only. Patch-id: cb0a1dfc8f68a67727525adea7458e2cee7d09f8.

Validation environment: LANG=en_US.UTF-8, CARGO_BUILD_JOBS=1, CARGO_INCREMENTAL=0, exclusive APFS-cloned CARGO_TARGET_DIR.

Quick Checks: every command from .github/actions/quick-checks/action.yml, with captured output and exit status below.

$ shellcheck --version && actionlint
ShellCheck - shell script analysis tool
version: 0.11.0
license: GNU General Public License, version 3
website: https://www.shellcheck.net
exit_code=0

$ cargo fmt --all --check
exit_code=0

$ ./scripts/check_unsafe_code_allowances.sh
exit_code=0

$ ./scripts/check_layer_dependencies.sh
Layer dependency guard passed (no new reverse dependencies/cycles).
exit_code=0

$ ./scripts/check_architecture_migration_rules.sh
Architecture migration rules passed.
exit_code=0

$ ./scripts/check_logging_guardrails.sh
✅ Logging guardrails check passed
exit_code=0

$ ./scripts/check_error_other_format_ratchet.sh
error(format!) ratchet passed (total call sites: 582).
exit_code=0

$ ./scripts/check_no_tokio_io_uring.sh
exit_code=0

$ ./scripts/check_extension_schema_boundaries.sh
Extension schema boundaries passed.
exit_code=0

$ ./scripts/check_body_cache_whitelist.sh
Body-cache whitelist guard passed.
exit_code=0

$ ./scripts/check_s3s_footprint.sh
s3s footprint OK: files importing s3s is 195 (baseline files=195)
s3s footprint OK: s3s s3_error! lines is 1584 (baseline s3_error_lines=1584)
s3s footprint OK: ecstore files referencing s3s is 36 (baseline ecstore_files=36)
✅ s3s footprint baseline check passed
exit_code=0

$ ./scripts/check_test_count_ratchet.sh
test-count ratchet OK: 52 crates, 21222 tests, 164 ignored match .config/test-count-baseline.txt
exit_code=0

$ ./scripts/check_fips_wording.sh
FIPS wording guard passed (README.md and CHANGELOG.md contain no forbidden claims).
KMS wording guard passed (crates/kms claims no Vault KV2 Transit wrapping).
exit_code=0

$ ./scripts/check_embedded_secrets.sh
Embedded secret guard passed (no private key material or provider credential literal in the tree).
exit_code=0

$ make script-tests
Running script tests...
entrypoint credential policy tests passed
internode grpc A/B bench env tests passed
object batch benchmark enhanced tests passed
PASS package service script contract
hotpath warp A/B gate tests passed
hotpath warp ABBA tests passed
Scanner validation artifacts written to /var/folders/0w/s4wxsn2n4w9b6f3cd4xrz53w0000gn/T/tmp.1pFe6Kl1Yu/out
Scanner validation artifacts written to /var/folders/0w/s4wxsn2n4w9b6f3cd4xrz53w0000gn/T/tmp.1pFe6Kl1Yu/out-missing-pid
.............................
----------------------------------------------------------------------
Ran 29 tests in 181.286s

OK
{"decision":"verified","pending_gates":["G01","G03","G04","G05","G06","G07","G08","G09","G10","G11","G12","G13","G14","P1","P2","P3","P4","R-D","R-E","R-L"],"pending_lanes":["authority-coverage","checkpoint-and-crash","ec8-4-multiset","maintenance-producers","mixed-version-rollback","mrf-responsibility","scheduler-pressure","status-and-outcome"],"rejected_errors":[],"rejected_gate":null,"release_approved":false,"release_schema_capable":true,"schema":1,"verified_gate":"G02"}
{"decision":"verified","pending_gates":["G01","G02","G03","G04","G05","G06","G07","G08","G09","G10","G11","G12","G13","G14","P1","P2","P3","P4","R-D","R-L"],"pending_lanes":["authority-coverage","checkpoint-and-crash","ec8-4-multiset","maintenance-producers","mixed-version-rollback","mrf-responsibility","scheduler-pressure","status-and-outcome"],"rejected_errors":[],"rejected_gate":null,"release_approved":false,"release_schema_capable":true,"schema":1,"verified_gate":"R-E"}
{"decision":"verified","pending_gates":["G01","G03","G04","G05","G06","G07","G08","G09","G10","G11","G12","G13","G14","P1","P2","P3","P4","R-D","R-E","R-L"],"pending_lanes":["authority-coverage","checkpoint-and-crash","ec8-4-multiset","maintenance-producers","mixed-version-rollback","mrf-responsibility","scheduler-pressure","status-and-outcome"],"rejected_errors":[],"rejected_gate":null,"release_approved":false,"release_schema_capable":true,"schema":1,"verified_gate":"G02"}
{"decision":"verified","pending_gates":["G01","G02","G03","G04","G05","G06","G07","G08","G09","G10","G11","G12","G13","G14","P1","P2","P3","P4","R-D","R-L"],"pending_lanes":["authority-coverage","checkpoint-and-crash","ec8-4-multiset","maintenance-producers","mixed-version-rollback","mrf-responsibility","scheduler-pressure","status-and-outcome"],"rejected_errors":[],"rejected_gate":null,"release_approved":false,"release_schema_capable":true,"schema":1,"verified_gate":"R-E"}
{"decision":"verified","pending_gates":["G02","G03","G04","G05","G06","G07","G08","G09","G10","G11","G12","G13","G14","P1","P2","P3","P4","R-D","R-E","R-L"],"pending_lanes":["authority-coverage","checkpoint-and-crash","ec8-4-multiset","maintenance-producers","mixed-version-rollback","mrf-responsibility","scheduler-pressure","status-and-outcome"],"rejected_errors":[],"rejected_gate":null,"release_approved":false,"release_schema_capable":true,"schema":1,"verified_gate":"G01"}
{"decision":"verified","pending_gates":["G01","G02","G04","G05","G06","G07","G08","G09","G10","G11","G12","G13","G14","P1","P2","P3","P4","R-D","R-E","R-L"],"pending_lanes":["authority-coverage","checkpoint-and-crash","ec8-4-multiset","maintenance-producers","mixed-version-rollback","mrf-responsibility","scheduler-pressure","status-and-outcome"],"rejected_errors":[],"rejected_gate":null,"release_approved":false,"release_schema_capable":true,"schema":1,"verified_gate":"G03"}
{"decision":"verified","pending_gates":["G01","G02","G03","G04","G05","G06","G07","G08","G09","G10","G11","G12","G13","G14","P1","P2","P3","P4","R-D","R-E"],"pending_lanes":["authority-coverage","checkpoint-and-crash","ec8-4-multiset","maintenance-producers","mixed-version-rollback","mrf-responsibility","scheduler-pressure","status-and-outcome"],"rejected_errors":[],"rejected_gate":null,"release_approved":false,"release_schema_capable":true,"schema":1,"verified_gate":"R-L"}
{"decision":"verified","pending_gates":["G01","G02","G03","G04","G05","G06","G07","G08","G09","G10","G11","G12","G13","P1","P2","P3","P4","R-D","R-E","R-L"],"pending_lanes":["authority-coverage","checkpoint-and-crash","ec8-4-multiset","maintenance-producers","mixed-version-rollback","mrf-responsibility","scheduler-pressure","status-and-outcome"],"rejected_errors":[],"rejected_gate":null,"release_approved":false,"release_schema_capable":true,"schema":1,"verified_gate":"G14"}
{"decision":"verified","pending_gates":["G01","G02","G03","G04","G05","G06","G07","G08","G09","G10","G11","G12","G13","P1","P2","P3","P4","R-D","R-E","R-L"],"pending_lanes":["authority-coverage","checkpoint-and-crash","ec8-4-multiset","maintenance-producers","mixed-version-rollback","mrf-responsibility","scheduler-pressure","status-and-outcome"],"rejected_errors":[],"rejected_gate":null,"release_approved":false,"release_schema_capable":true,"schema":1,"verified_gate":"G14"}
{"decision":"verified","pending_gates":["G01","G02","G03","G04","G05","G06","G07","G08","G09","G11","G12","G13","G14","P1","P2","P3","P4","R-D","R-E","R-L"],"pending_lanes":["authority-coverage","checkpoint-and-crash","ec8-4-multiset","maintenance-producers","mixed-version-rollback","mrf-responsibility","scheduler-pressure","status-and-outcome"],"rejected_errors":[],"rejected_gate":null,"release_approved":false,"release_schema_capable":true,"schema":1,"verified_gate":"G10"}
{"decision":"verified","pending_gates":["G01","G02","G03","G04","G05","G06","G07","G08","G09","G10","G11","G12","G13","G14","P2","P3","P4","R-D","R-E","R-L"],"pending_lanes":["authority-coverage","checkpoint-and-crash","ec8-4-multiset","maintenance-producers","mixed-version-rollback","mrf-responsibility","scheduler-pressure","status-and-outcome"],"rejected_errors":[],"rejected_gate":null,"release_approved":false,"release_schema_capable":true,"schema":1,"verified_gate":"P1"}
{"decision":"verified","pending_gates":["G01","G02","G03","G04","G05","G06","G07","G08","G09","G10","G11","G12","G13","G14","P1","P3","P4","R-D","R-E","R-L"],"pending_lanes":["authority-coverage","checkpoint-and-crash","ec8-4-multiset","maintenance-producers","mixed-version-rollback","mrf-responsibility","scheduler-pressure","status-and-outcome"],"rejected_errors":[],"rejected_gate":null,"release_approved":false,"release_schema_capable":true,"schema":1,"verified_gate":"P2"}
{"decision":"verified","pending_gates":["G01","G02","G03","G04","G05","G06","G07","G08","G09","G10","G11","G12","G13","G14","P1","P2","P4","R-D","R-E","R-L"],"pending_lanes":["authority-coverage","checkpoint-and-crash","ec8-4-multiset","maintenance-producers","mixed-version-rollback","mrf-responsibility","scheduler-pressure","status-and-outcome"],"rejected_errors":[],"rejected_gate":null,"release_approved":false,"release_schema_capable":true,"schema":1,"verified_gate":"P3"}
{"decision":"verified","pending_gates":["G01","G02","G03","G04","G06","G07","G08","G09","G10","G11","G12","G13","G14","P1","P2","P3","P4","R-D","R-E","R-L"],"pending_lanes":["authority-coverage","checkpoint-and-crash","ec8-4-multiset","maintenance-producers","mixed-version-rollback","mrf-responsibility","scheduler-pressure","status-and-outcome"],"rejected_errors":[],"rejected_gate":null,"release_approved":false,"release_schema_capable":true,"schema":1,"verified_gate":"G05"}
{"decision":"verified","pending_gates":["G01","G02","G03","G04","G05","G07","G08","G09","G10","G11","G12","G13","G14","P1","P2","P3","P4","R-D","R-E","R-L"],"pending_lanes":["authority-coverage","checkpoint-and-crash","ec8-4-multiset","maintenance-producers","mixed-version-rollback","mrf-responsibility","scheduler-pressure","status-and-outcome"],"rejected_errors":[],"rejected_gate":null,"release_approved":false,"release_schema_capable":true,"schema":1,"verified_gate":"G06"}
{"decision":"verified","pending_gates":["G01","G02","G03","G04","G05","G06","G07","G08","G09","G10","G11","G12","G13","G14","P1","P2","P3","P4","R-E","R-L"],"pending_lanes":["authority-coverage","checkpoint-and-crash","ec8-4-multiset","maintenance-producers","mixed-version-rollback","mrf-responsibility","scheduler-pressure","status-and-outcome"],"rejected_errors":[],"rejected_gate":null,"release_approved":false,"release_schema_capable":true,"schema":1,"verified_gate":"R-D"}
Status-and-outcome release descriptor verified: /private/var/folders/0w/s4wxsn2n4w9b6f3cd4xrz53w0000gn/T/tmp8rwn1pe0/descriptor/release-bundle-status-outcome.json
{"decision":"verified","pending_gates":["G01","G02","G03","G04","G06","G07","G08","G09","G10","G11","G12","G13","G14","P1","P2","P3","P4","R-D","R-E","R-L"],"pending_lanes":["authority-coverage","checkpoint-and-crash","ec8-4-multiset","maintenance-producers","mixed-version-rollback","mrf-responsibility","scheduler-pressure","status-and-outcome"],"rejected_errors":[],"rejected_gate":null,"release_approved":false,"release_schema_capable":true,"schema":1,"verified_gate":"G05"}
{"decision":"verified","pending_gates":["G01","G02","G03","G04","G05","G07","G08","G09","G10","G11","G12","G13","G14","P1","P2","P3","P4","R-D","R-E","R-L"],"pending_lanes":["authority-coverage","checkpoint-and-crash","ec8-4-multiset","maintenance-producers","mixed-version-rollback","mrf-responsibility","scheduler-pressure","status-and-outcome"],"rejected_errors":[],"rejected_gate":null,"release_approved":false,"release_schema_capable":true,"schema":1,"verified_gate":"G06"}
{"decision":"verified","pending_gates":["G01","G02","G03","G04","G05","G06","G07","G08","G09","G10","G11","G12","G13","G14","P1","P2","P3","P4","R-E","R-L"],"pending_lanes":["authority-coverage","checkpoint-and-crash","ec8-4-multiset","maintenance-producers","mixed-version-rollback","mrf-responsibility","scheduler-pressure","status-and-outcome"],"rejected_errors":[],"rejected_gate":null,"release_approved":false,"release_schema_capable":true,"schema":1,"verified_gate":"R-D"}
{"decision":"verified","pending_gates":["G01","G02","G03","G04","G05","G06","G08","G09","G10","G11","G12","G13","G14","P1","P2","P3","P4","R-D","R-E","R-L"],"pending_lanes":["authority-coverage","checkpoint-and-crash","ec8-4-multiset","maintenance-producers","mixed-version-rollback","mrf-responsibility","scheduler-pressure","status-and-outcome"],"rejected_errors":[],"rejected_gate":null,"release_approved":false,"release_schema_capable":true,"schema":1,"verified_gate":"G07"}
{"decision":"verified","pending_gates":["G01","G02","G03","G04","G05","G06","G07","G09","G10","G11","G12","G13","G14","P1","P2","P3","P4","R-D","R-E","R-L"],"pending_lanes":["authority-coverage","checkpoint-and-crash","ec8-4-multiset","maintenance-producers","mixed-version-rollback","mrf-responsibility","scheduler-pressure","status-and-outcome"],"rejected_errors":[],"rejected_gate":null,"release_approved":false,"release_schema_capable":true,"schema":1,"verified_gate":"G08"}
{"decision":"verified","pending_gates":["G01","G02","G03","G04","G05","G06","G07","G08","G09","G10","G11","G12","G13","G14","P1","P2","P3","R-D","R-E","R-L"],"pending_lanes":["authority-coverage","checkpoint-and-crash","ec8-4-multiset","maintenance-producers","mixed-version-rollback","mrf-responsibility","scheduler-pressure","status-and-outcome"],"rejected_errors":[],"rejected_gate":null,"release_approved":false,"release_schema_capable":true,"schema":1,"verified_gate":"P4"}
exact 1MiB handoff ABBA tests passed
pinned paired ABBA bench tests passed
==> /var/folders/0w/s4wxsn2n4w9b6f3cd4xrz53w0000gn/T/tmp.mYmuI01hNq/bin/path_containment (-max_total_time=7, -seed=123456789, -artifact_prefix=/var/folders/0w/s4wxsn2n4w9b6f3cd4xrz53w0000gn/T/tmp.mYmuI01hNq/explicit-artifacts/path_containment/, corpus=/private/tmp/gw-orchestrator-2734/ci-drive/fuzz/corpus/path_containment)
-seed=123456789
target=path_containment
seed=123456789
max_total_time=7
git_revision=4d15b816236630b2c42f321ffc9ed3654edc94e3
git_dirty=true
runner_mode=prebuilt
==> /var/folders/0w/s4wxsn2n4w9b6f3cd4xrz53w0000gn/T/tmp.mYmuI01hNq/bin/path_containment (-max_total_time=1, -seed=2322617706, -artifact_prefix=/var/folders/0w/s4wxsn2n4w9b6f3cd4xrz53w0000gn/T/tmp.mYmuI01hNq/auto-artifacts/path_containment/, corpus=/private/tmp/gw-orchestrator-2734/ci-drive/fuzz/corpus/path_containment)
-seed=2322617706
==> cargo +nightly fuzz run path_containment (-max_total_time=9, -seed=987654321, -artifact_prefix=/var/folders/0w/s4wxsn2n4w9b6f3cd4xrz53w0000gn/T/tmp.mYmuI01hNq/cargo-artifacts/path_containment/)
+nightly
fuzz
run
path_containment
-seed=987654321
seed=987654321
runner_mode=cargo-fuzz
fuzz runner seed manifest tests passed
✅ scripts/python_bin.sh resolver checks passed
self-test ok: clean fixture with every exemption present passes
self-test ok: all 11 pattern families fire
self-test ok: moving a pinned source is reported
Embedded secret guard self-test passed.
self-test ok: p1-s3s-import: 'use s3s::s3_error;' counts every line, the rustfs_s3_types module's too (s3_error_lines=3)
self-test ok: p2-s3s-mixed-tree: a multi-line s3s use tree naming s3_error counts (s3_error_lines=3)
self-test ok: p3-s3s-glob: a crate-root glob of s3s, bare or inside a tree, counts (s3_error_lines=5)
self-test ok: p4-macro-use: '#[macro_use] extern crate s3s' counts (s3_error_lines=3)
self-test ok: p5-qualified-s3s: a qualified s3s::s3_error! line counts in a cleared file (s3_error_lines=2)
self-test ok: p6-exact-pass: a baseline equal to the tree passes (exit 0)
self-test ok: n1-rustfs-import: 'use rustfs_s3_types::s3_error;' alone counts nothing (s3_error_lines=1)
self-test ok: n2-rustfs-mixed-tree: a rustfs_s3_types tree plus an s3s tree without the macro counts nothing (s3_error_lines=1)
self-test ok: n3-defines: the file defining the macro counts nothing (s3_error_lines=1)
self-test ok: n4-qualified-rustfs: a qualified rustfs_s3_types::s3_error! line counts nothing (s3_error_lines=1)
self-test ok: n5-renamed-call: a call through a renamed macro is not text-matched (out of scope) (s3_error_lines=1)
self-test ok: n6-comment-string: a comment or string mentioning s3_error! in a cleared file counts nothing (s3_error_lines=1)
self-test ok: n7-e2e-excluded: crates/e2e_test is excluded (s3_error_lines=1)
self-test ok: n8-super-glob: an unrecognised import form ('use super::*;') counts (s3_error_lines=3)
self-test ok: n9-facade: a crate-local re-export counts (s3_error_lines=2)
self-test ok: n10-renamed-import: a renamed rustfs_s3_types import does not clear s3_error! lines (s3_error_lines=2)
self-test ok: n11-commented-import: a commented-out rustfs_s3_types import does not clear (s3_error_lines=2)
self-test ok: n12-rustfs-glob: a glob import of rustfs_s3_types does not clear (s3_error_lines=2)
self-test ok: n13-comment-only: a comment mentioning s3_error! in a file with no import counts (s3_error_lines=2)
self-test ok: f1-baseline-missing: a missing baseline file fails (exit 1)
self-test ok: f2-key-missing: a baseline without s3_error_lines fails (exit 1)
self-test ok: f3-rg-fails: a failing classification search fails the guard (exit 1)
✅ s3s footprint self-test passed
test_core_gate_rejects_missing_ignored_filtered_and_corrupt_inputs (__main__.SelfTests.test_core_gate_rejects_missing_ignored_filtered_and_corrupt_inputs) ... ok
test_e2e_requires_registration (__main__.SelfTests.test_e2e_requires_registration) ... ok
test_fuzz_runtime_uses_matrix_target (__main__.SelfTests.test_fuzz_runtime_uses_matrix_target) ... ok
test_ilm_lane_keeps_the_measured_cargo_build_budget (__main__.SelfTests.test_ilm_lane_keeps_the_measured_cargo_build_budget) ... ok
test_profile_listing_binds_platform_digest (__main__.SelfTests.test_profile_listing_binds_platform_digest) ... ok
test_profile_listing_enforces_selection (__main__.SelfTests.test_profile_listing_enforces_selection) ... ok
test_quick_checks_commands_propagate_failure (__main__.SelfTests.test_quick_checks_commands_propagate_failure) ... ok
test_quick_checks_rejects_caller_and_execution_bypasses (__main__.SelfTests.test_quick_checks_rejects_caller_and_execution_bypasses) ... ok
test_runner_readiness_fails_closed (__main__.SelfTests.test_runner_readiness_fails_closed) ... ok
test_s3_runner_rejects_unbounded_failure_locals (__main__.SelfTests.test_s3_runner_rejects_unbounded_failure_locals) ... ok
test_scanner_heal_begin_requires_embedded_source_provenance (__main__.SelfTests.test_scanner_heal_begin_requires_embedded_source_provenance) ... ok
test_scanner_heal_case_does_not_approve_pending_release (__main__.SelfTests.test_scanner_heal_case_does_not_approve_pending_release) ... ok
test_scanner_heal_case_lane_is_required_for_runner_profile (__main__.SelfTests.test_scanner_heal_case_lane_is_required_for_runner_profile) ... ok
test_scanner_heal_case_only_schema_cannot_approve_release (__main__.SelfTests.test_scanner_heal_case_only_schema_cannot_approve_release) ... ok
test_scanner_heal_crash_case_rejects_restart_oracle (__main__.SelfTests.test_scanner_heal_crash_case_rejects_restart_oracle) ... ok
test_scanner_heal_ec84_case_rejects_wrong_erasure_geometry (__main__.SelfTests.test_scanner_heal_ec84_case_rejects_wrong_erasure_geometry) ... ok
test_scanner_heal_finish_collects_oracles_from_registry (__main__.SelfTests.test_scanner_heal_finish_collects_oracles_from_registry) ... ok
test_scanner_heal_multipool_case_accepts_deferred_optional_outage_write (__main__.SelfTests.test_scanner_heal_multipool_case_accepts_deferred_optional_outage_write) ... ok
test_scanner_heal_pending_gate_cannot_map_to_implemented_lane (__main__.SelfTests.test_scanner_heal_pending_gate_cannot_map_to_implemented_lane) ... ok
test_scanner_heal_receipts_reject_reuse_and_missing_builds (__main__.SelfTests.test_scanner_heal_receipts_reject_reuse_and_missing_builds) ... ok
test_scanner_heal_rejects_boolean_fractional_and_out_of_geometry_integers (__main__.SelfTests.test_scanner_heal_rejects_boolean_fractional_and_out_of_geometry_integers) ... ok
test_scanner_heal_rejects_broken_execution_and_artifacts (__main__.SelfTests.test_scanner_heal_rejects_broken_execution_and_artifacts) ... ok
test_scanner_heal_rejects_copied_junit_and_wrong_suite_build (__main__.SelfTests.test_scanner_heal_rejects_copied_junit_and_wrong_suite_build) ... ok
test_scanner_heal_release_bundle_accepts_complete_measured_evidence (__main__.SelfTests.test_scanner_heal_release_bundle_accepts_complete_measured_evidence) ... ok
test_scanner_heal_release_bundle_assembler_copies_measured_artifacts (__main__.SelfTests.test_scanner_heal_release_bundle_assembler_copies_measured_artifacts) ... ok
test_scanner_heal_release_bundle_assembler_merges_measured_descriptors (__main__.SelfTests.test_scanner_heal_release_bundle_assembler_merges_measured_descriptors) ... ok
test_scanner_heal_release_bundle_assembler_rejects_duplicate_descriptor_gate (__main__.SelfTests.test_scanner_heal_release_bundle_assembler_rejects_duplicate_descriptor_gate) ... ok
test_scanner_heal_release_bundle_assembler_rejects_fixture_descriptor (__main__.SelfTests.test_scanner_heal_release_bundle_assembler_rejects_fixture_descriptor) ... ok
test_scanner_heal_release_bundle_assembler_rejects_hash_mismatch (__main__.SelfTests.test_scanner_heal_release_bundle_assembler_rejects_hash_mismatch) ... ok
test_scanner_heal_release_bundle_assembler_rejects_mixed_descriptor_revisions (__main__.SelfTests.test_scanner_heal_release_bundle_assembler_rejects_mixed_descriptor_revisions) ... ok
test_scanner_heal_release_bundle_enforces_topology_duration_profile_and_versions (__main__.SelfTests.test_scanner_heal_release_bundle_enforces_topology_duration_profile_and_versions) ... ok
test_scanner_heal_release_bundle_fixture_writer_is_not_release_evidence (__main__.SelfTests.test_scanner_heal_release_bundle_fixture_writer_is_not_release_evidence) ... ok
test_scanner_heal_release_bundle_gate_accepts_single_measured_gate (__main__.SelfTests.test_scanner_heal_release_bundle_gate_accepts_single_measured_gate) ... ok
test_scanner_heal_release_bundle_gate_rejects_missing_field (__main__.SelfTests.test_scanner_heal_release_bundle_gate_rejects_missing_field) ... ok
test_scanner_heal_release_bundle_gate_rejects_unknown_gate (__main__.SelfTests.test_scanner_heal_release_bundle_gate_rejects_unknown_gate) ... ok
test_scanner_heal_release_bundle_rejects_synthetic_or_missing_fields (__main__.SelfTests.test_scanner_heal_release_bundle_rejects_synthetic_or_missing_fields) ... ok
test_scanner_heal_release_bundle_requires_domain_evidence (__main__.SelfTests.test_scanner_heal_release_bundle_requires_domain_evidence) ... ok
test_scanner_heal_release_bundle_requires_field_provenance (__main__.SelfTests.test_scanner_heal_release_bundle_requires_field_provenance) ... ok
test_scanner_heal_release_bundle_requires_json_artifact_provenance (__main__.SelfTests.test_scanner_heal_release_bundle_requires_json_artifact_provenance) ... ok
test_scanner_heal_release_bundle_requires_mixed_version_field_roles (__main__.SelfTests.test_scanner_heal_release_bundle_requires_mixed_version_field_roles) ... ok
test_scanner_heal_release_bundle_requires_same_gate_measurement_window (__main__.SelfTests.test_scanner_heal_release_bundle_requires_same_gate_measurement_window) ... ok
test_scanner_heal_release_consumes_multiple_registry_oracles (__main__.SelfTests.test_scanner_heal_release_consumes_multiple_registry_oracles) ... ok
test_scanner_heal_release_matrix_lanes_remain_pending_without_real_evidence (__main__.SelfTests.test_scanner_heal_release_matrix_lanes_remain_pending_without_real_evidence) ... ok
test_scanner_heal_release_requirements_cannot_be_cleared (__main__.SelfTests.test_scanner_heal_release_requirements_cannot_be_cleared) ... ok
test_scanner_heal_release_status_rejects_focused_case_run (__main__.SelfTests.test_scanner_heal_release_status_rejects_focused_case_run) ... ok
test_scanner_heal_release_status_rejects_synthetic_case (__main__.SelfTests.test_scanner_heal_release_status_rejects_synthetic_case) ... ok
test_scanner_heal_required_evidence_fields_cannot_be_removed (__main__.SelfTests.test_scanner_heal_required_evidence_fields_cannot_be_removed) ... ok
test_scheduled_alerts_require_completion_watchdog (__main__.SelfTests.test_scheduled_alerts_require_completion_watchdog) ... ok
test_update_profile_selection_changes_only_requested_platform (__main__.SelfTests.test_update_profile_selection_changes_only_requested_platform) ... ok
test_validate_includes_quick_checks (__main__.SelfTests.test_validate_includes_quick_checks) ... ok
test_vault_tests_require_cross_process_serialization (__main__.SelfTests.test_vault_tests_require_cross_process_serialization) ... ok
test_workflow_readiness_requires_dependency_probe (__main__.SelfTests.test_workflow_readiness_requires_dependency_probe) ... ok

----------------------------------------------------------------------
Ran 52 tests in 14.301s

OK
e2e-full selection OK: 1 tests, sha256=29e30e6ed2ed38d9e5b0a7cdd9536abf5c719f7021a02b578ae1381563a3c273
...................................
----------------------------------------------------------------------
Ran 35 tests in 1.161s

OK
...................
----------------------------------------------------------------------
Ran 19 tests in 17.397s

OK
..........
----------------------------------------------------------------------
Ran 10 tests in 0.183s

OK
test_actual_selector_bootstrap_uses_base_policy_and_fails_closed (__main__.SelfTests.test_actual_selector_bootstrap_uses_base_policy_and_fails_closed) ... ok
test_documentation_paths_do_not_hide_build_or_fixture_changes (__main__.SelfTests.test_documentation_paths_do_not_hide_build_or_fixture_changes) ... ok
test_e2e_scope_keeps_production_and_shared_configuration_full (__main__.SelfTests.test_e2e_scope_keeps_production_and_shared_configuration_full) ... ok
test_e2e_shortcut_rejects_new_direct_renamed_and_target_dependencies (__main__.SelfTests.test_e2e_shortcut_rejects_new_direct_renamed_and_target_dependencies) ... ok
test_e2e_shortcut_traverses_implicit_path_dependencies (__main__.SelfTests.test_e2e_shortcut_traverses_implicit_path_dependencies) ... ok
test_event_contract_requires_complete_candidate_and_optional_lanes (__main__.SelfTests.test_event_contract_requires_complete_candidate_and_optional_lanes) ... ok
test_every_wrong_result_missing_job_or_selection_fails_closed (__main__.SelfTests.test_every_wrong_result_missing_job_or_selection_fails_closed) ... ok
test_full_e2e_gate_preserves_workflow_branch_and_event_scope (__main__.SelfTests.test_full_e2e_gate_preserves_workflow_branch_and_event_scope) ... ok
test_git_range_includes_deleted_source_and_rename_origins (__main__.SelfTests.test_git_range_includes_deleted_source_and_rename_origins) ... ok
test_job_ids_and_display_names_cannot_hide_validation (__main__.SelfTests.test_job_ids_and_display_names_cannot_hide_validation) ... ok
test_report_only_stack_lane_mirrors_e2e_smoke_outside_the_aggregate (__main__.SelfTests.test_report_only_stack_lane_mirrors_e2e_smoke_outside_the_aggregate) ... ok
test_repository_wiring_and_missing_dependency_regression (__main__.SelfTests.test_repository_wiring_and_missing_dependency_regression) ... ok
test_verify_command_preserves_failures (__main__.SelfTests.test_verify_command_preserves_failures) ... ok

----------------------------------------------------------------------
Ran 13 tests in 3.280s

OK
security coverage self-test passed
test_api_failures_preserve_other_evidence_and_never_enter_grace (__main__.SelfTests.test_api_failures_preserve_other_evidence_and_never_enter_grace) ... ok
test_attempt_outcome_does_not_replace_recent_success (__main__.SelfTests.test_attempt_outcome_does_not_replace_recent_success) ... ok
test_cancelled_attempt_cannot_refresh_expired_success (__main__.SelfTests.test_cancelled_attempt_cannot_refresh_expired_success) ... ok
test_cli_requires_the_repository_default_branch (__main__.SelfTests.test_cli_requires_the_repository_default_branch) ... ok
test_config_rejects_duplicate_and_invalid_entries (__main__.SelfTests.test_config_rejects_duplicate_and_invalid_entries) ... ok
test_freshness_boundaries (__main__.SelfTests.test_freshness_boundaries) ... ok
test_grace_requires_two_successful_queries_with_no_history (__main__.SelfTests.test_grace_requires_two_successful_queries_with_no_history) ... ok
test_invalid_api_evidence_fails_closed (__main__.SelfTests.test_invalid_api_evidence_fails_closed) ... ok
test_report_retains_every_workflow (__main__.SelfTests.test_report_retains_every_workflow) ... ok
test_requests_filter_schedule_default_branch_and_success_on_server (__main__.SelfTests.test_requests_filter_schedule_default_branch_and_success_on_server) ... ok

----------------------------------------------------------------------
Ran 10 tests in 0.051s

OK
..............................
----------------------------------------------------------------------
Ran 30 tests in 22.354s

OK
..........
----------------------------------------------------------------------
Ran 10 tests in 2.969s

OK
.................................
----------------------------------------------------------------------
Ran 33 tests in 0.160s

OK
Online functional runner labels: pf-testing
.......
----------------------------------------------------------------------
Ran 7 tests in 0.002s

OK
.......
----------------------------------------------------------------------
Ran 7 tests in 0.001s

OK
....
----------------------------------------------------------------------
Ran 4 tests in 0.128s

OK
...
----------------------------------------------------------------------
Ran 3 tests in 0.547s

OK
PASS: cold stampede script self-test
exit_code=0

$ python3 ./scripts/test_object_generation_protocol_model.py
test_a_recovery_barrier_cannot_choose_an_empty_value (__main__.ObjectGenerationProtocolModel.test_a_recovery_barrier_cannot_choose_an_empty_value) ... ok
test_all_bounded_two_proposer_interleavings_choose_at_most_one_value (__main__.ObjectGenerationProtocolModel.test_all_bounded_two_proposer_interleavings_choose_at_most_one_value) ... ok
test_an_unavailable_decision_quorum_cannot_choose_a_candidate (__main__.ObjectGenerationProtocolModel.test_an_unavailable_decision_quorum_cannot_choose_a_candidate) ... ok
test_counter_allocation_requires_persistence_and_is_consumed_once (__main__.ObjectGenerationProtocolModel.test_counter_allocation_requires_persistence_and_is_consumed_once) ... ok
test_counter_restart_burns_persisted_allocations_before_or_after_issue (__main__.ObjectGenerationProtocolModel.test_counter_restart_burns_persisted_allocations_before_or_after_issue) ... ok
test_crash_after_promise_before_reply_does_not_restore_an_older_ballot (__main__.ObjectGenerationProtocolModel.test_crash_after_promise_before_reply_does_not_restore_an_older_ballot) ... ok
test_duplicate_or_unconfigured_voters_cannot_form_a_quorum (__main__.ObjectGenerationProtocolModel.test_duplicate_or_unconfigured_voters_cannot_form_a_quorum) ... ok
test_empty_or_unobserved_minority_slot_can_retry_without_allocating_another_revision (__main__.ObjectGenerationProtocolModel.test_empty_or_unobserved_minority_slot_can_retry_without_allocating_another_revision) ... ok
test_equal_ballot_cannot_accept_different_bytes_and_lower_ballot_is_rejected (__main__.ObjectGenerationProtocolModel.test_equal_ballot_cannot_accept_different_bytes_and_lower_ballot_is_rejected) ... ok
test_forgetting_tombstone_authority_allows_an_aba_create (__main__.ObjectGenerationProtocolModel.test_forgetting_tombstone_authority_allows_an_aba_create) ... ok
test_issuing_before_persistence_reuses_a_ballot_after_restart (__main__.ObjectGenerationProtocolModel.test_issuing_before_persistence_reuses_a_ballot_after_restart) ... ok
test_lost_ack_and_restart_preserve_the_chosen_operation (__main__.ObjectGenerationProtocolModel.test_lost_ack_and_restart_preserve_the_chosen_operation) ... ok
test_minority_accepted_work_is_adopted_and_finished (__main__.ObjectGenerationProtocolModel.test_minority_accepted_work_is_adopted_and_finished) ... ok
test_omitting_highest_accepted_adoption_has_a_double_choice_counterexample (__main__.ObjectGenerationProtocolModel.test_omitting_highest_accepted_adoption_has_a_double_choice_counterexample) ... ok
test_quorum_choice_does_not_require_an_uncontacted_disk_to_learn_it (__main__.ObjectGenerationProtocolModel.test_quorum_choice_does_not_require_an_uncontacted_disk_to_learn_it) ... ok
test_recovery_failure_or_stale_expected_head_does_not_allocate_a_successor (__main__.ObjectGenerationProtocolModel.test_recovery_failure_or_stale_expected_head_does_not_allocate_a_successor) ... ok
test_replacing_voters_with_empty_state_can_choose_a_conflicting_value (__main__.ObjectGenerationProtocolModel.test_replacing_voters_with_empty_state_can_choose_a_conflicting_value) ... ok
test_skipping_predecessor_recovery_chooses_a_broken_revision_chain (__main__.ObjectGenerationProtocolModel.test_skipping_predecessor_recovery_chooses_a_broken_revision_chain) ... ok
test_stale_publication_rollback_and_gc_cannot_change_the_winner (__main__.ObjectGenerationProtocolModel.test_stale_publication_rollback_and_gc_cannot_change_the_winner) ... ok
test_superseded_operation_identity_cannot_be_reused_for_different_input (__main__.ObjectGenerationProtocolModel.test_superseded_operation_identity_cannot_be_reused_for_different_input) ... ok
test_superseded_operation_retry_returns_its_original_successor (__main__.ObjectGenerationProtocolModel.test_superseded_operation_retry_returns_its_original_successor) ... ok
test_tombstone_and_null_marker_payloads_do_not_reset_authority_revision (__main__.ObjectGenerationProtocolModel.test_tombstone_and_null_marker_payloads_do_not_reset_authority_revision) ... ok
test_tombstone_rejects_a_delayed_never_present_create (__main__.ObjectGenerationProtocolModel.test_tombstone_rejects_a_delayed_never_present_create) ... ok
test_unlearned_predecessor_is_recovered_before_the_second_slot (__main__.ObjectGenerationProtocolModel.test_unlearned_predecessor_is_recovered_before_the_second_slot) ... ok

----------------------------------------------------------------------
Ran 24 tests in 0.958s

OK
model: states=51023, transitions=167520, terminal=732, chosen_candidates=2
negative control: double-choice trace=((0, 'prepare', 0), (0, 'prepare', 1), (0, 'prepare', 2), (0, 'accept', 0), (0, 'accept', 1), (0, 'accept', 2), (1, 'prepare', 0), (1, 'prepare', 1), (1, 'prepare', 2), (1, 'accept', 0), (1, 'accept', 1), (1, 'accept', 2))
multi-slot round schedules: 640
exit_code=0

$ python3 ./scripts/check_test_wiring.py
OK: e2e modules, runner selection, fuzz matrices, profiles, scheduled alerts, and Quick Checks are wired
exit_code=0

$ ./scripts/check_no_planning_docs.sh
exit_code=0

$ ./scripts/check_uring_lane_lib_only.sh
OK: no 'uring_'-matching test functions under crates/ecstore/tests; --lib is safe
exit_code=0

Initial complete gate
$ cargo nextest run --profile ci -p rustfs --test connect_perf_drive --no-fail-fast
 Nextest run ID e07fc5b9-c6c8-4cdd-ae61-54839bf24437 with nextest profile: ci
    Starting 10 tests across 1 binary
        PASS [   0.016s] rustfs::connect_perf_drive cancellation_and_single_collector_leave_no_scratch
        PASS [   0.011s] rustfs::connect_perf_drive cleanup_failure_overrides_cancellation_and_remains_attributed
        PASS [   0.009s] rustfs::connect_perf_drive consent_and_resource_budgets_fail_before_scratch_io
        PASS [   0.009s] rustfs::connect_perf_drive invalid_roots_and_existing_scratch_fail_without_clobbering
        PASS [  16.950s] rustfs::connect_perf_drive production_cli_measures_and_signs_exact_binary_provenance
        PASS [   4.025s] rustfs::connect_perf_drive production_cli_rejects_missing_l1_consent_and_limits_before_artifacts
        PASS [   0.008s] rustfs::connect_perf_drive read_only_root_and_full_drive_errors_are_classified
        PASS [   0.015s] rustfs::connect_perf_drive real_local_write_read_is_measured_and_scratch_is_removed
        PASS [   0.007s] rustfs::connect_perf_drive runtime_timeout_and_full_drive_use_schema_valid_failed_reason
        PASS [   0.035s] rustfs::connect_perf_drive successful_result_has_signed_bounded_private_offline_export
────────────
     Summary [  21.087s] 10 tests run: 10 passed, 0 skipped
exit_code=0

Restored complete gate
$ cargo nextest run --profile ci -p rustfs --test connect_perf_drive --no-fail-fast
 Nextest run ID 233b7307-5361-4eb7-94d5-b384f17d2d6a with nextest profile: ci
    Starting 10 tests across 1 binary
        PASS [   0.009s] rustfs::connect_perf_drive cancellation_and_single_collector_leave_no_scratch
        PASS [   0.008s] rustfs::connect_perf_drive cleanup_failure_overrides_cancellation_and_remains_attributed
        PASS [   0.008s] rustfs::connect_perf_drive consent_and_resource_budgets_fail_before_scratch_io
        PASS [   0.008s] rustfs::connect_perf_drive invalid_roots_and_existing_scratch_fail_without_clobbering
        PASS [  19.518s] rustfs::connect_perf_drive production_cli_measures_and_signs_exact_binary_provenance
        PASS [   4.146s] rustfs::connect_perf_drive production_cli_rejects_missing_l1_consent_and_limits_before_artifacts
        PASS [   0.009s] rustfs::connect_perf_drive read_only_root_and_full_drive_errors_are_classified
        PASS [   0.018s] rustfs::connect_perf_drive real_local_write_read_is_measured_and_scratch_is_removed
        PASS [   0.009s] rustfs::connect_perf_drive runtime_timeout_and_full_drive_use_schema_valid_failed_reason
        PASS [   0.054s] rustfs::connect_perf_drive successful_result_has_signed_bounded_private_offline_export
────────────
     Summary [  23.787s] 10 tests run: 10 passed, 0 skipped
exit_code=0

Mutation M1: success_measurement reports max(actual elapsed, requested budget + 1), forcing 1001 ms for the one-second request. The retained duration assertion at rustfs/tests/connect_perf_drive.rs:105 fails.
$ cargo nextest run --profile ci -p rustfs --test connect_perf_drive --no-fail-fast -E 'test(real_local_write_read_is_measured_and_scratch_is_removed)'
info: experimental features enabled: setup-scripts
   Compiling rustfs v1.0.1 (/private/tmp/gw-orchestrator-2734/ci-drive/rustfs)
warning: linker stderr: ld: __eh_frame section too large (max 16MB) to encode dwarf unwind offsets in compact unwind table, performance of exception handling might be affected
  |
  = note: `#[warn(linker_messages)]` on by default

warning: `rustfs` (bin "rustfs") generated 1 warning
warning: `rustfs` (bin "rustfs-cli") generated 1 warning (1 duplicate)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 7m 06s
────────────
 Nextest run ID 9f618777-7bf6-4cbc-8803-dd7f50f449ed with nextest profile: ci
    Starting 1 test across 1 binary (9 tests skipped)
        FAIL [   0.016s] rustfs::connect_perf_drive real_local_write_read_is_measured_and_scratch_is_removed
  stdout ───

    running 1 test
    test real_local_write_read_is_measured_and_scratch_is_removed ... FAILED

    failures:

    failures:
        real_local_write_read_is_measured_and_scratch_is_removed

    test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 9 filtered out; finished in 0.01s

  stderr ───

    thread 'real_local_write_read_is_measured_and_scratch_is_removed' (106986846) panicked at rustfs/tests/connect_perf_drive.rs:105:5:
    assertion failed: (1..=1_000).contains(&data.duration_millis)
    note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

────────────
     Summary [   0.017s] 1 test run: 0 passed, 1 failed, 9 skipped
        FAIL [   0.016s] rustfs::connect_perf_drive real_local_write_read_is_measured_and_scratch_is_removed
error: test run failed
exit_code=100

Mutation M2: target_parameters reports requested duration + 1. The in-process assertion at rustfs/tests/connect_perf_drive.rs:111 and CLI JSON assertion at rustfs/tests/connect_perf_drive.rs:527 both reject 1001 against 1000.
$ cargo nextest run --profile ci -p rustfs --test connect_perf_drive --no-fail-fast -E 'test(real_local_write_read_is_measured_and_scratch_is_removed) | test(production_cli_measures_and_signs_exact_binary_provenance)'
info: experimental features enabled: setup-scripts
   Compiling rustfs v1.0.1 (/private/tmp/gw-orchestrator-2734/ci-drive/rustfs)
warning: linker stderr: ld: __eh_frame section too large (max 16MB) to encode dwarf unwind offsets in compact unwind table, performance of exception handling might be affected
  |
  = note: `#[warn(linker_messages)]` on by default

warning: `rustfs` (bin "rustfs") generated 1 warning
warning: `rustfs` (bin "rustfs-cli") generated 1 warning (1 duplicate)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 7m 20s
────────────
 Nextest run ID b73d46ab-c910-4e1e-8063-7fc7e332ec15 with nextest profile: ci
    Starting 2 tests across 1 binary (8 tests skipped)
        FAIL [  13.968s] rustfs::connect_perf_drive production_cli_measures_and_signs_exact_binary_provenance
  stdout ───

    running 1 test
    test production_cli_measures_and_signs_exact_binary_provenance ... FAILED

    failures:

    failures:
        production_cli_measures_and_signs_exact_binary_provenance

    test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 9 filtered out; finished in 13.96s

  stderr ───

    thread 'production_cli_measures_and_signs_exact_binary_provenance' (107015462) panicked at rustfs/tests/connect_perf_drive.rs:527:5:
    assertion `left == right` failed
      left: Number(1001)
     right: 1000
    note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

        FAIL [   0.015s] rustfs::connect_perf_drive real_local_write_read_is_measured_and_scratch_is_removed
  stdout ───

    running 1 test
    test real_local_write_read_is_measured_and_scratch_is_removed ... FAILED

    failures:

    failures:
        real_local_write_read_is_measured_and_scratch_is_removed

    test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 9 filtered out; finished in 0.01s

  stderr ───

    thread 'real_local_write_read_is_measured_and_scratch_is_removed' (107016667) panicked at rustfs/tests/connect_perf_drive.rs:111:5:
    assertion `left == right` failed
      left: 1001
     right: 1000
    note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

────────────
     Summary [  13.985s] 2 tests run: 0 passed, 2 failed, 8 skipped
        FAIL [  13.968s] rustfs::connect_perf_drive production_cli_measures_and_signs_exact_binary_provenance
        FAIL [   0.015s] rustfs::connect_perf_drive real_local_write_read_is_measured_and_scratch_is_removed
error: test run failed
exit_code=100

Transient production mutations were restored byte-for-byte before the complete green rerun. Original/restored SHA256: 8d389e6d8836c041aab8c6a93e56addd65ba3f70b33264e1c74f639d0572a2da.

$ git diff --check

exit_code=0

$ git diff --cached --check

exit_code=0

Independent orchestrator acceptance
Tested code tree: e67c72fb01

Tested tree: 16fbbd2ea4
shellcheck --version && actionlint -> exit 0
cargo fmt --all --check -> exit 0
./scripts/check_unsafe_code_allowances.sh -> exit 0
./scripts/check_layer_dependencies.sh -> exit 0
./scripts/check_architecture_migration_rules.sh -> exit 0
./scripts/check_logging_guardrails.sh -> exit 0
./scripts/check_error_other_format_ratchet.sh -> exit 0
./scripts/check_no_tokio_io_uring.sh -> exit 0
./scripts/check_extension_schema_boundaries.sh -> exit 0
./scripts/check_body_cache_whitelist.sh -> exit 0
./scripts/check_s3s_footprint.sh -> exit 0
./scripts/check_test_count_ratchet.sh -> exit 0
./scripts/check_fips_wording.sh -> exit 0
./scripts/check_embedded_secrets.sh -> exit 0
make script-tests -> exit 0
python3 ./scripts/test_object_generation_protocol_model.py -> exit 0
python3 ./scripts/check_test_wiring.py -> exit 0
./scripts/check_no_planning_docs.sh -> exit 0
./scripts/check_uring_lane_lib_only.sh -> exit 0

$ LANG=en_US.UTF-8 CARGO_BUILD_JOBS=1 CARGO_INCREMENTAL=0 cargo nextest run --profile ci -p rustfs --test connect_perf_drive --no-fail-fast
info: experimental features enabled: setup-scripts
warning: linker stderr: ld: __eh_frame section too large (max 16MB) to encode dwarf unwind offsets in compact unwind table, performance of exception handling might be affected
  |
  = note: `#[warn(linker_messages)]` on by default

warning: `rustfs` (bin "rustfs-cli") generated 1 warning
warning: `rustfs` (bin "rustfs") generated 1 warning (1 duplicate)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.69s
────────────
 Nextest run ID cd1edf4c-f1c0-456d-ad1a-76cc00ce05c9 with nextest profile: ci
    Starting 10 tests across 1 binary
        PASS [   0.012s] rustfs::connect_perf_drive cancellation_and_single_collector_leave_no_scratch
        PASS [   0.010s] rustfs::connect_perf_drive cleanup_failure_overrides_cancellation_and_remains_attributed
        PASS [   0.010s] rustfs::connect_perf_drive consent_and_resource_budgets_fail_before_scratch_io
        PASS [   0.009s] rustfs::connect_perf_drive invalid_roots_and_existing_scratch_fail_without_clobbering
        PASS [  20.328s] rustfs::connect_perf_drive production_cli_measures_and_signs_exact_binary_provenance
        PASS [   5.527s] rustfs::connect_perf_drive production_cli_rejects_missing_l1_consent_and_limits_before_artifacts
        PASS [   0.013s] rustfs::connect_perf_drive read_only_root_and_full_drive_errors_are_classified
        PASS [   0.019s] rustfs::connect_perf_drive real_local_write_read_is_measured_and_scratch_is_removed
        PASS [   0.011s] rustfs::connect_perf_drive runtime_timeout_and_full_drive_use_schema_valid_failed_reason
        PASS [   0.049s] rustfs::connect_perf_drive successful_result_has_signed_bounded_private_offline_export
────────────
     Summary [  25.991s] 10 tests run: 10 passed, 0 skipped

exit_code=0

All 54 tracked Cargo.toml files parse without duplicate keys. git diff --check passed. The production source remains unchanged; the two deliberate implementation mutations above were restored before this complete green run.

* fix(test): compare legacy body wire parity as raw bytes

Lossy UTF-8 comparison concealed a changed binary response byte.
Compare the already captured Vec<u8> values directly.

Validation:
- Binary mutant: migrated payload 0x84 vs legacy 0x83; old comparator
  passed 1/1, raw comparator failed 0/1 (exit 100); restored green.
- Non-Swift body-drain mutant: untouched-body delegation test failed
  with empty vs payload (exit 100); restored handler without a diff.
- cargo nextest run --profile ci -p rustfs-s3-types -p rustfs-protocols
  --features rustfs-protocols/swift --no-fail-fast:
  543 tests run, 543 passed, 0 skipped after both mutants restored.
- cargo fmt --all --check and git diff --check passed.

Orchestrator acceptance (LANG=en_US.UTF-8):
cargo nextest run --profile ci -p rustfs-s3-types -p rustfs-protocols --features rustfs-protocols/swift --no-fail-fast
Summary [2.445s] 543 tests run: 543 passed, 0 skipped.

All shared quick checks on the final code tree:
shellcheck --version && actionlint -> exit 0
cargo fmt --all --check -> exit 0
./scripts/check_unsafe_code_allowances.sh -> exit 0
./scripts/check_layer_dependencies.sh -> exit 0
./scripts/check_architecture_migration_rules.sh -> exit 0
./scripts/check_logging_guardrails.sh -> exit 0
./scripts/check_error_other_format_ratchet.sh -> exit 0
./scripts/check_no_tokio_io_uring.sh -> exit 0
./scripts/check_extension_schema_boundaries.sh -> exit 0
./scripts/check_body_cache_whitelist.sh -> exit 0
./scripts/check_s3s_footprint.sh -> exit 0
./scripts/check_test_count_ratchet.sh -> exit 0
./scripts/check_fips_wording.sh -> exit 0
./scripts/check_embedded_secrets.sh -> exit 0
make script-tests -> exit 0
python3 ./scripts/test_object_generation_protocol_model.py -> exit 0
python3 ./scripts/check_test_wiring.py -> exit 0
./scripts/check_no_planning_docs.sh -> exit 0
./scripts/check_uring_lane_lib_only.sh -> exit 0

Final independent acceptance after the drive-test CI repair
Parent: a99d8f4f89
Tested code tree: 93cbd96bf0

Tested tree: 075ae5161c2b62a96812bc1e3c904a7663b4f2f8
shellcheck --version && actionlint -> exit 0
cargo fmt --all --check -> exit 0
./scripts/check_unsafe_code_allowances.sh -> exit 0
./scripts/check_layer_dependencies.sh -> exit 0
./scripts/check_architecture_migration_rules.sh -> exit 0
./scripts/check_logging_guardrails.sh -> exit 0
./scripts/check_error_other_format_ratchet.sh -> exit 0
./scripts/check_no_tokio_io_uring.sh -> exit 0
./scripts/check_extension_schema_boundaries.sh -> exit 0
./scripts/check_body_cache_whitelist.sh -> exit 0
./scripts/check_s3s_footprint.sh -> exit 0
./scripts/check_test_count_ratchet.sh -> exit 0
./scripts/check_fips_wording.sh -> exit 0
./scripts/check_embedded_secrets.sh -> exit 0
make script-tests -> exit 0
python3 ./scripts/test_object_generation_protocol_model.py -> exit 0
python3 ./scripts/check_test_wiring.py -> exit 0
./scripts/check_no_planning_docs.sh -> exit 0
./scripts/check_uring_lane_lib_only.sh -> exit 0

$ LANG=en_US.UTF-8 CARGO_BUILD_JOBS=1 CARGO_INCREMENTAL=0 cargo nextest run --profile ci -p rustfs-s3-types -p rustfs-protocols --features rustfs-protocols/swift --no-fail-fast
    Finished `test` profile [unoptimized + debuginfo] target(s) in 10m 08s
 Nextest run ID 5c7ff102-ec6d-479a-86e0-810859b28bbf with nextest profile: ci
    Starting 543 tests across 8 binaries
     Summary [   6.729s] 543 tests run: 543 passed, 0 skipped
exit_code=0

The only source change is the raw-byte wire comparator. Cargo manifests and the ratchet baselines remain unchanged from the independently validated parent. The invalid-byte and consumed-request mutations remain recorded above; their implementation changes were restored.

* fix: repair integration checks and IPC test readiness

Wait for the RPC/locks test sockets to pass the existing owner and mode
checks inside the original 5s readiness window and 10ms poll interval.

Use the existing admin S3 carrier for the new recovery and quota error
calls. Regenerate the test-count baseline for main's 118 existing tests
across seven crates. Pair the private pagination helper's raw-scan inputs
and remove the redundant Copy clone from the retirement event.

Keep the original assertions, responses, authorization, locks and storage
operations. The pagination body and raw-scan expressions at all five calls
are byte-identical; reinserting only the Copy clone restores the entire
base heal file. All ignored baseline values and s3s counts stay unchanged.

Validation base: c47432e353
Validation tree: f081084bc5
Patch SHA-256: 2dcf0028691bfb2772edfb38e262829b04f4151832dcce823c9dd9f7434b52de
Trace production-prefix SHA-256: fd994f218a21429d62866f4efb8eee1ee57b58f4a51865aa35d05a54ef1ccb45

All commands used LANG=en_US.UTF-8, CARGO_BUILD_JOBS=1,
CARGO_INCREMENTAL=0 and CARGO_NET_OFFLINE=true in one exclusive target.
Every gate retained the same tree and six source hashes before and after.
54 Cargo.toml files parse without duplicate keys.

cargo clippy -p rustfs --all-targets -- -D warnings
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 12m 50s
Exit: 0. Wall time: 770.855s.

The original 19 quick-check steps:
shellcheck --version && actionlint -> exit 0
cargo fmt --all --check -> exit 0
./scripts/check_unsafe_code_allowances.sh -> exit 0
./scripts/check_layer_dependencies.sh -> exit 0
./scripts/check_architecture_migration_rules.sh -> exit 0
./scripts/check_logging_guardrails.sh -> exit 0
./scripts/check_error_other_format_ratchet.sh -> exit 0
./scripts/check_no_tokio_io_uring.sh -> exit 0
./scripts/check_extension_schema_boundaries.sh -> exit 0
./scripts/check_body_cache_whitelist.sh -> exit 0
./scripts/check_s3s_footprint.sh -> exit 0
./scripts/check_test_count_ratchet.sh -> exit 0
./scripts/check_fips_wording.sh -> exit 0
./scripts/check_embedded_secrets.sh -> exit 0
make script-tests -> exit 0
python3 ./scripts/test_object_generation_protocol_model.py -> exit 0
python3 ./scripts/check_test_wiring.py -> exit 0
./scripts/check_no_planning_docs.sh -> exit 0
./scripts/check_uring_lane_lib_only.sh -> exit 0
Wall time: 286.337s.

cargo nextest run --profile ci -p rustfs --no-fail-fast
     Summary [ 217.017s] 5382 tests run: 5382 passed (4 leaky), 40 skipped
Exit: 0. Wall time: 1324.414s.

cargo nextest run --profile ci -p rustfs-s3-types -p rustfs-protocols --features rustfs-protocols/swift --no-fail-fast
     Summary [   2.189s] 543 tests run: 543 passed, 0 skipped
Exit: 0. Wall time: 123.192s.

cargo nextest run --profile ci -p rustfs-ecstore list_objects_paginate --no-fail-fast
     Summary [   0.170s] 8 tests run: 8 passed, 5921 skipped
Exit: 0. Wall time: 261.324s.

cargo nextest run --profile ci -p rustfs-heal durable_mrf_anchor_retires_only_after_exact_bucket_generation_is_deleted --no-fail-fast
     Summary [   0.805s] 1 test run: 1 passed, 835 skipped
Exit: 0. Wall time: 199.801s.

Baseline regeneration:
./scripts/check_test_count_ratchet.sh --update
wrote .config/test-count-baseline.txt: 52 crates, 21340 tests, 164 ignored
Test-only increases: ecstore48, heal3, iam14, io-metrics2, obs3, policy14,
rustfs34. Existing ignored values and all other rows are byte-identical.
s3s footprint OK: files importing s3s is 195 (baseline files=195)
s3s footprint OK: s3s s3_error! lines is 1584 (baseline s3_error_lines=1584)
s3s footprint OK: ecstore files referencing s3s is 36 (baseline ecstore_files=36)

Historical runtime red controls on f622 (same retained two assertions):
cargo nextest run --profile ci -p rustfs --lib -E 'test(=connect::diagnostics::trace_runtime::tests::local_top_rpc_reads_service_http_completions_and_signs_offline) | test(=connect::diagnostics::trace_runtime::tests::local_top_locks_reads_service_manager_and_signs_offline)' --no-fail-fast
Original path readiness with a temporary 500ms bind/seal gap:
     Summary [   0.863s] 2 tests run: 0 passed, 2 failed, 5113 skipped
Exit: 100.
Sealed readiness in that same gap:
     Summary [   5.621s] 2 tests run: 2 passed, 5113 skipped
Exit: 0.
Compiled M1: replace the helper predicate with path.exists():
     Summary [   0.870s] 2 tests run: 0 passed, 2 failed, 5113 skipped
Exit: 100.
Restore the sealed predicate byte-for-byte:
     Summary [   5.640s] 2 tests run: 2 passed, 5113 skipped
Exit: 0.
Both reds observe mode755 with owner_matches=true and fail at the original
RPC/locks unwraps with StateSecurity. M1 failure locations: trace_runtime.rs
2547:29 and 2713:29. No new assertions were introduced.

cargo nextest run --profile ci -p rustfs --lib -E 'test(connect::diagnostics::trace_runtime::tests::)' --no-fail-fast
     Summary [  20.748s] 28 tests run: 28 passed (1 leaky), 5087 skipped
Exit: 0. This security sweep is historical; the full final unit gate above
covers the actual c474 candidate.
The first disk-stopped compile ran no tests and is excluded from red credit.
All temporary seal instrumentation is removed from the final source.

Final unit cleanup markers: 4 leaky, 40 original skips (exit0). The ld
__eh_frame linker warning also appears verbatim in the original f622 run
and the earlier c474 run; there are no new source warnings.

* fix: remove redundant pending closure in disk test

Pass the capture-free pending future factory as a function item in the existing slow-operation test. This fixes the redundant_closure error at disk_store.rs:2914 that stopped strict Clippy before the feature tests could run.

The single-line test edit preserves the factory's result, warning and timeout behavior. Reversing the replacement reproduces the parent file byte for byte. All 220 assertion lines and the production prefix are unchanged; no tests, assertions, guards, profiles or baselines were added or relaxed.

Original Linux CI evidence (run 37888552268 at 53245be9f):
- RIO job113685483992: strict Clippy exit101, redundant closure at disk_store.rs:2914; feature tests skipped.
- Workspace Test and Lint job113685484058: same strict Clippy exit101; nextest and doc tests skipped.
These are lint failures, not runtime mutation kills or known flakes.

Native macOS arm64 validation on tree7a5bf47e7533b29479bc85207416102f1337c566, LANG=en_US.UTF-8, one build job, CARGO_INCREMENTAL=0 and offline dependencies:

shellcheck --version && actionlint -> exit 0
cargo fmt --all --check -> exit 0
./scripts/check_unsafe_code_allowances.sh -> exit 0
./scripts/check_layer_dependencies.sh -> exit 0
./scripts/check_architecture_migration_rules.sh -> exit 0
./scripts/check_logging_guardrails.sh -> exit 0
./scripts/check_error_other_format_ratchet.sh -> exit 0
./scripts/check_no_tokio_io_uring.sh -> exit 0
./scripts/check_extension_schema_boundaries.sh -> exit 0
./scripts/check_body_cache_whitelist.sh -> exit 0
./scripts/check_s3s_footprint.sh -> exit 0
./scripts/check_test_count_ratchet.sh -> exit 0
./scripts/check_fips_wording.sh -> exit 0
./scripts/check_embedded_secrets.sh -> exit 0
make script-tests -> exit 0
python3 ./scripts/test_object_generation_protocol_model.py -> exit 0
python3 ./scripts/check_test_wiring.py -> exit 0
./scripts/check_no_planning_docs.sh -> exit 0
./scripts/check_uring_lane_lib_only.sh -> exit 0
All 19 checks: exit0, 312.161s.

$ cargo clippy -p rustfs -p rustfs-ecstore --all-targets --features rio-v2 -- -D warnings
Finished `dev` profile [unoptimized + debuginfo] target(s) in 20m 11s
exit0, 1213.210s total.

$ cargo nextest run --profile ci -p rustfs-ecstore --features rio-v2 slow_disk_operation_warns_once_while_pending_and_preserves_result --no-fail-fast
Finished `test` profile [unoptimized + debuginfo] target(s) in 4m 34s
PASS [0.026s] rustfs-ecstore disk::disk_store::tests::slow_disk_operation_warns_once_while_pending_and_preserves_result
Summary [   0.170s] 1 test run: 1 passed, 5943 skipped
exit0, 281.920s total; 5943 existing filter skips.

warning: linker stderr: ld: __eh_frame section too large (max 16MB) to encode dwarf unwind offsets in compact unwind table, performance of exception handling might be affected
This linker warning is retained; flags, profiles and allowances are unchanged.

54 tracked Cargo.toml files parsed without duplicate keys. git diff --check passed. No new assertions were introduced, so no new runtime mutation is claimed. Native compilation and the existing helper test do not substitute for actual Linux RIO execution; the new integration CI run still needs to recover before the RPC merge.

Preservation and output hashes:
parent file SHA256: 8e59ee745e3aaaa22848b943d3b10f6353b515956282cde1f3af2c394200c9ac
final file SHA256: b69ba3237061b161448ae8b62b615885d5770b98a5b4a2479bcfbd70f1bd1f17
unchanged production prefix SHA256: 7903a6e288252c420231242d7b58a25fa2a7140ea3dd430cbe7f0dde11369dcb
RIO Linux failure log SHA256:421addb9868fd88e4109a922311e54f91f0dfd70862f8ba7fcaba193482ed90b
Workspace Linux failure log SHA256:e0aea8d0f014c022252a9534968e083c98cfc1fd760da445ecd04ec3f522e26a
19 output SHA256:35a54f28d707956ed1edb29d492c4dfe8a9fe753d103b111b5a2f1b5c41c2b98
RIO Clippy output SHA256:d6046e3868f2b3992fe11cdd04e9e98eb50a87d1234d9a70dbfeb154ff61638d
slow-operation output SHA256:605b7b258d2fd09c6ef2ab2ffc90d433d0509480f1e45125d20c823759e83608

Refs rustfs/backlog#2734.

* test(ecstore): isolate tier-free recovery fixture startup

The fixture constructor can take the production recovery lock and wait for 60 seconds before this test installs its queue and seeds metadata. Disable its background recovery only while creating this fixture, then restore the environment before calling the existing production recovery entrypoint.

Only the existing test setup changes. Its production entrypoint, default lock parameters, original assertions, 30-second timeout, cancellation and cleanup remain intact. No tests or assertions were added. Production and store/init.rs are byte-identical to the parent.

Independent acceptance ran on merge tree 8ec358cb42ba9ebb3bcfeafcef048a3f0366355b; the runner header reports parent HEAD, while before/after index and source hashes confirm this candidate. Worker task branch d8055766b8 has the same tree.

Every command used LANG=en_US.UTF-8. Task compilation also used CARGO_BUILD_JOBS=1, CARGO_INCREMENTAL=0, CARGO_NET_OFFLINE=true, an independent target, and empty RUSTC_WRAPPER/RUSTC_WORKSPACE_WRAPPER to keep compilers in its owned process group without changing repository configuration.

Command:
shellcheck --version && actionlint
Exit 0; observed output excerpt:
version: 0.11.0
Log SHA-256: fc9eec16b4191d058a295813ca9afbe9d2b37b59975da860170726be162a3924

Command:
cargo fmt --all --check
Exit 0; observed output excerpt:
(no stdout or stderr)
Log SHA-256: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855

Command:
./scripts/check_unsafe_code_allowances.sh
Exit 0; observed output excerpt:
(no stdout or stderr)
Log SHA-256: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855

Command:
./scripts/check_layer_dependencies.sh
Exit 0; observed output excerpt:
Layer dependency guard passed (no new reverse dependencies/cycles).
Log SHA-256: 69c6cb1896c0586b28d1167380d3260d99d8017a4265c8df5a2348f289e93a12

Command:
./scripts/check_architecture_migration_rules.sh
Exit 0; observed output excerpt:
Architecture migration rules passed.
Log SHA-256: 28790e70d070607ed4de0704f292a65030be92ec224ef106d4b72fd8296f896e

Command:
./scripts/check_logging_guardrails.sh
Exit 0; observed output excerpt:
✅ Logging guardrails check passed
Log SHA-256: 7adf41abff8ed8f7617decf86498888ddde78937099dd9ce47f6d359dfc2d317

Command:
./scripts/check_error_other_format_ratchet.sh
Exit 0; observed output excerpt:
error(format!) ratchet passed (total call sites: 579).
Log SHA-256: 66172aef18f150ba0605af122e26ffcf91f6dd86f43e38b05e2e982ce8d783ee

Command:
./scripts/check_no_tokio_io_uring.sh
Exit 0; observed output excerpt:
(no stdout or stderr)
Log SHA-256: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855

Command:
./scripts/check_extension_schema_boundaries.sh
Exit 0; observed output excerpt:
Extension schema boundaries passed.
Log SHA-256: f13d0acd0e2ce5d9815241b12b4dab7d01cc11b9b65bcf6dfb7812c878532a51

Command:
./scripts/check_body_cache_whitelist.sh
Exit 0; observed output excerpt:
Body-cache whitelist guard passed.
Log SHA-256: 6b828847b0383950ee6eef9cd846d0c322c945e47efd4c7d1ad881178810d6bb

Command:
./scripts/check_s3s_footprint.sh
Exit 0; observed output excerpt:
s3s footprint OK: files importing s3s is 195 (baseline files=195)
s3s footprint OK: s3s s3_error! lines is 1584 (baseline s3_error_lines=1584)
s3s footprint OK: ecstore files referencing s3s is 36 (baseline ecstore_files=36)
✅ s3s footprint baseline check passed
Log SHA-256: 47f53d04ef645618563fe1f22abc64667f19078f1974feedcca46e4f2c016844

Command:
./scripts/check_test_count_ratchet.sh
Exit 0; observed output excerpt:
test-count ratchet OK: 52 crates, 21340 tests, 164 ignored match .config/test-count-baseline.txt
Log SHA-256: 618a5e330e933de0c6ff484546df401457024a88bb5986d11d588794e33dab60

Command:
./scripts/check_fips_wording.sh
Exit 0; observed output excerpt:
FIPS wording guard passed (README.md and CHANGELOG.md contain no forbidden claims).
KMS wording guard passed (crates/kms claims no Vault KV2 Transit wrapping).
Log SHA-256: 99221cc9979c480ec4f46f50d76c52b18c72fe499f12e15e4f4a22a1b8bfb140

Command:
./scripts/check_embedded_secrets.sh
Exit 0; observed output excerpt:
Embedded secret guard passed (no private key material or provider credential literal in the tree).
Log SHA-256: 437a3ceba32094d2901889965ad191e411aad32cdb18bf0b8659e818011c406b

Command:
make script-tests
Exit 0; observed output excerpt:
test_cli_requires_the_repository_default_branch (__main__.SelfTests.test_cli_requires_the_repository_default_branch) ... ok
test_config_rejects_duplicate_and_invalid_entries (__main__.SelfTests.test_config_rejects_duplicate_and_invalid_entries) ... ok
test_freshness_boundaries (__main__.SelfTests.test_freshness_boundaries) ... ok
test_grace_requires_two_successful_queries_with_no_history (__main__.SelfTests.test_grace_requires_two_successful_queries_with_no_history) ... ok
test_invalid_api_evidence_fails_closed (__main__.SelfTests.test_invalid_api_evidence_fails_closed) ... ok
test_report_retains_every_workflow (__main__.SelfTests.test_report_retains_every_workflow) ... ok
test_requests_filter_schedule_default_branch_and_success_on_server (__main__.SelfTests.test_requests_filter_schedule_default_branch_and_success_on_server) ... ok
Ran 10 tests in 0.042s
OK
Ran 30 tests in 20.627s
OK
Ran 10 tests in 2.268s
OK
Ran 33 tests in 0.127s
OK
Ran 7 tests in 0.002s
OK
Ran 7 tests in 0.001s
OK
Ran 4 tests in 0.108s
OK
Ran 3 tests in 0.564s
OK
PASS: cold stampede script self-test
Log SHA-256: 75fda2d069a373c63667b908baaf99e6591aab9099a739b0c4c22ceb6da8d68c

Command:
python3 ./scripts/test_object_generation_protocol_model.py
Exit 0; observed output excerpt:
test_an_unavailable_decision_quorum_cannot_choose_a_candidate (__main__.ObjectGenerationProtocolModel.test_an_unavailable_decision_quorum_cannot_choose_a_candidate) ... ok
test_counter_allocation_requires_persistence_and_is_consumed_once (__main__.ObjectGenerationProtocolModel.test_counter_allocation_requires_persistence_and_is_consumed_once) ... ok
test_counter_restart_burns_persisted_allocations_before_or_after_issue (__main__.ObjectGenerationProtocolModel.test_counter_restart_burns_persisted_allocations_before_or_after_issue) ... ok
test_crash_after_promise_before_reply_does_not_restore_an_older_ballot (__main__.ObjectGenerationProtocolModel.test_crash_after_promise_before_reply_does_not_restore_an_older_ballot) ... ok
test_duplicate_or_unconfigured_voters_cannot_form_a_quorum (__main__.ObjectGenerationProtocolModel.test_duplicate_or_unconfigured_voters_cannot_form_a_quorum) ... ok
test_empty_or_unobserved_minority_slot_can_retry_without_allocating_another_revision (__main__.ObjectGenerationProtocolModel.test_empty_or_unobserved_minority_slot_can_retry_without_allocating_another_revision) ... ok
test_equal_ballot_cannot_accept_different_bytes_and_lower_ballot_is_rejected (__main__.ObjectGenerationProtocolModel.test_equal_ballot_cannot_accept_different_bytes_and_lower_ballot_is_rejected) ... ok
test_forgetting_tombstone_authority_allows_an_aba_create (__main__.ObjectGenerationProtocolModel.test_forgetting_tombstone_authority_allows_an_aba_create) ... ok
test_issuing_before_persistence_reuses_a_ballot_after_restart (__main__.ObjectGenerationProtocolModel.test_issuing_before_persistence_reuses_a_ballot_after_restart) ... ok
test_lost_ack_and_restart_preserve_the_chosen_operation (__main__.ObjectGenerationProtocolModel.test_lost_ack_and_restart_preserve_the_chosen_operation) ... ok
test_minority_accepted_work_is_adopted_and_finished (__main__.ObjectGenerationProtocolModel.test_minority_accepted_work_is_adopted_and_finished) ... ok
test_omitting_highest_accepted_adoption_has_a_double_choice_counterexample (__main__.ObjectGenerationProtocolModel.test_omitting_highest_accepted_adoption_has_a_double_choice_counterexample) ... ok
test_quorum_choice_does_not_require_an_uncontacted_disk_to_learn_it (__main__.ObjectGenerationProtocolModel.test_quorum_choice_does_not_require_an_uncontacted_disk_to_learn_it) ... ok
test_recovery_failure_or_stale_expected_head_does_not_allocate_a_successor (__main__.ObjectGenerationProtocolModel.test_recovery_failure_or_stale_expected_head_does_not_allocate_a_successor) ... ok
test_replacing_voters_with_empty_state_can_choose_a_conflicting_value (__main__.ObjectGenerationProtocolModel.test_replacing_voters_with_empty_state_can_choose_a_conflicting_value) ... ok
test_skipping_predecessor_recovery_chooses_a_broken_revision_chain (__main__.ObjectGenerationProtocolModel.test_skipping_predecessor_recovery_chooses_a_broken_revision_chain) ... ok
test_stale_publication_rollback_and_gc_cannot_change_the_winner (__main__.ObjectGenerationProtocolModel.test_stale_publication_rollback_and_gc_cannot_change_the_winner) ... ok
test_superseded_operation_identity_cannot_be_reused_for_different_input (__main__.ObjectGenerationProtocolModel.test_superseded_operation_identity_cannot_be_reused_for_different_input) ... ok
test_superseded_operation_retry_returns_its_original_successor (__main__.ObjectGenerationProtocolModel.test_superseded_operation_retry_returns_its_original_successor) ... ok
test_tombstone_and_null_marker_payloads_do_not_reset_authority_revision (__main__.ObjectGenerationProtocolModel.test_tombstone_and_null_marker_payloads_do_not_reset_authority_revision) ... ok
test_tombstone_rejects_a_delayed_never_present_create (__main__.ObjectGenerationProtocolModel.test_tombstone_rejects_a_delayed_never_present_create) ... ok
test_unlearned_predecessor_is_recovered_before_the_second_slot (__main__.ObjectGenerationProtocolModel.test_unlearned_predecessor_is_recovered_before_the_second_slot) ... ok
Ran 24 tests in 0.929s
OK
Log SHA-256: 0b4be926efb0609d497ae58930340153f01957b606206cbbdb3e0d47eb29d3a2

Command:
python3 ./scripts/check_test_wiring.py
Exit 0; observed output excerpt:
OK: e2e modules, runner selection, fuzz matrices, profiles, scheduled alerts, and Quick Checks are wired
Log SHA-256: 21f676f458451525535ab0ef73d6a9c0d9774e16d76a4d19e396ad34936dd39c

Command:
./scripts/check_no_planning_docs.sh
Exit 0; observed output excerpt:
(no stdout or stderr)
Log SHA-256: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855

Command:
./scripts/check_uring_lane_lib_only.sh
Exit 0; observed output excerpt:
OK: no 'uring_'-matching test functions under crates/ecstore/tests; --lib is safe
Log SHA-256: 43f3f851e27ec2ab9e34d6b05fefdede38af992699c473904bfe6f53a46d52e3

Command:
cargo nextest run --profile ci -p rustfs-ecstore --lib --features rio-v2 -E 'test(=bucket::lifecycle::bucket_lifecycle_ops::tests::tier_free_version_recovery_production_entrypoint_enqueues_seeded_item)' --no-fail-fast
Exit 0; wall 234.399s.
Observed output:
    Finished `test` profile [unoptimized + debuginfo] target(s) in 3m 49s
    Starting 1 test across 1 binary (5871 tests skipped)
  SETUP PASS [   0.006s] ecstore-base-stack: sh -c 'echo RUST_MIN_STACK=4194304 >> "$NEXTEST_ENV"'
        PASS [   0.440s] rustfs-ecstore bucket::lifecycle::bucket_lifecycle_ops::tests::tier_free_version_recovery_production_entrypoint_enqueues_seeded_item
     Summary [   0.586s] 1 test run: 1 passed, 5871 skipped
Log SHA-256: 34e973f86e8dc2102c1a0e483e0ac802ce7df3d1b40a603932f62c0805c4de27

Command:
cargo nextest run --profile ci -p rustfs-ecstore --lib --features rio-v2 -E 'test(=bucket::lifecycle::bucket_lifecycle_ops::tests::tier_free_version_recovery_eight_coordinators_hold_one_leader_and_take_over) | test(=store::bucket::tests::creation_commit_backfill_runs_under_the_config_write_fence) | test(=store::bucket::tests::orphaned_bucket_recovery_requires_every_volume_absent_and_is_retryable)' --no-fail-fast
Exit 0; wall 4.010s.
Observed output:
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.43s
    Starting 3 tests across 1 binary (5869 tests skipped)
  SETUP PASS [   0.005s] ecstore-base-stack: sh -c 'echo RUST_MIN_STACK=4194304 >> "$NEXTEST_ENV"'
        PASS [   0.068s] rustfs-ecstore bucket::lifecycle::bucket_lifecycle_ops::tests::tier_free_version_recovery_eight_coordinators_hold_one_leader_and_take_over
        PASS [   0.637s] rustfs-ecstore store::bucket::tests::creation_commit_backfill_runs_under_the_config_write_fence
        PASS [   0.846s] rustfs-ecstore store::bucket::tests::orphaned_bucket_recovery_requires_every_volume_absent_and_is_retryable
     Summary [   0.987s] 3 tests run: 3 passed, 5869 skipped
Log SHA-256: b5da2a927ff18ca9aa217762f7186a6d4371bd9e8feedd7b77940e10b64dffac

Command:
cargo clippy -p rustfs -p rustfs-ecstore --all-targets --features rio-v2 -- -D warnings
Exit 0; wall 1084.508s.
Observed output:
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 18m 03s
Log SHA-256: 28f98be7970faaa71cd118da1da4e6e2e3d23b9dda7e6449eaa7a37e741260b5

Compiled mutation command (unchanged for each red and restored run):
cargo nextest run --profile ci -p rustfs-ecstore --lib --features rio-v2 -E 'test(=bucket::lifecycle::bucket_lifecycle_ops::tests::tier_free_version_recovery_production_entrypoint_enqueues_seeded_item)' --no-fail-fast

M1: reverse only this test-local constructor flag from false to true. The mutant compiled, then failed the original recovered-item queue assertion after 30.481s. Restoring the entire observed candidate passed in 0.460s. Red log 55bf3b1535529ac0bcfb8071f9b56f9f40631007a85c315367e7e93786d65293; restored log 204d5fbe4574a7917f5e740a795d864fe28a7e7fb5e33d27b0a9cf68e2f17f52.

M2: replace only the actual awaited enqueue with false, consuming its original inputs. The mutant compiled, then failed the same original queue assertion after 30.516s. Restoring the entire observed candidate passed in 0.457s. Red log 89186330413215e231e2b06bf013cc31097ea8f4a106d91421a36d6c8986a7fd; restored log e848b42d2302401afa5dbcd9e37bf76d2e56a96ab6de5a488513b997f53c5e9b.

All temporary observation and mutation code was removed before final acceptance. Historical baseline and diagnostic failures remain recorded; they are not mutation credit. The separate Object Lock intent-retry diagnostic still fails, and these native macOS checks do not establish Linux CI recovery.

* refactor(rpc): serve internode rpc bodies with the owned Body (#8441)

* test(rpc): pin the wire bytes of every internode rpc response body

rustfs/backlog#2747 batch 4, step 1 of 2, on the s3s bodies the rpc paths
serve today. Each response body builder in storage/rpc/http_service.rs is
served through hyper's HTTP/1.1 server and the raw bytes the peer reads are
compared with goldens captured from this commit: status, content-length or
chunked framing, chunk boundaries, the terminating chunk, HEAD, and the cut
connection a failure after data produces. The capability responses carry a
per-process proof, so their length header and payload are checked against
the body they encode instead.

The read_file_stream response builder moves into read_file_stream_response
so the test reaches the same code the handler runs; behaviour is unchanged.

* refactor(rpc): serve internode rpc bodies with the owned Body

rustfs/backlog#2747 batch 4, step 2 of 2. The internode rpc HTTP paths
build their responses with rustfs_s3_types::Body: Body::from_stream
replaces Body::from(StreamingBlob::wrap(..)) at the six stream sites, and
the in-memory and empty bodies keep their constructors. InternodeRpcService
converts its responses into the wrapped service's response body through
From<Body>, so the server keeps composing it with the s3s service as
before. The wire-byte goldens from the previous commit pass unchanged;
storage/rpc no longer names s3s.

* fix(rpc): simplify wire test response builder type

Name the boxed response builder used by the capability wire test so
all-target Clippy can check it without a type-complexity warning.

Validation (LANG=en_US.UTF-8, CARGO_INCREMENTAL=0):
- cargo clippy -p rustfs --all-targets -- -D warnings: exit 0.
- cargo nextest run --profile ci -p rustfs --lib -E 'test(/storage::rpc::/)': 279 tests run, 279 passed, 4842 skipped.
- All 19 quick-check commands, including script-tests, both Python scripts and cargo fmt --all --check: exit 0.
- Footprint baseline regenerated: files=194, s3_error_lines=1584, ecstore_files=36; test-count baseline regenerated: 52 crates, 21228 tests, 164 ignored.
- All 54 Cargo.toml files parse without duplicate keys.

No assertion was added or changed by this lint fix. R1-R8 are retained
in the existing PR verification record.

* fix(ecstore): resume fenced Object Lock creation intents

* fix(ecstore): bound creation retry futures on the stack

* fix(ecstore): require native proof for creation retries

* fix(ecstore): bound bucket creation stack usage (#8466)

* fix(ecstore): retain creation fences through rollback

Keep lifecycle, metadata-transaction, and namespace guards around rollback and metadata cleanup. Preserve the original publication error when a guard is lost. Build the timestamp retry fixture through the real interrupted producer so it checks a valid pending creation.

Validated source tree: 4e8e207999.
Two independent source reviews found no blocking regression.

Validation (LANG=en_US.UTF-8):
- bash -c 'shellcheck --version && actionlint' — exit 0
- cargo fmt --all --check — exit 0
- ./scripts/check_unsafe_code_allowances.sh — exit 0
- ./scripts/check_layer_dependencies.sh — exit 0
- ./scripts/check_architecture_migration_rules.sh — exit 0
- ./scripts/check_logging_guardrails.sh — exit 0
- ./scripts/check_error_other_format_ratchet.sh — exit 0
- ./scripts/check_no_tokio_io_uring.sh — exit 0
- ./scripts/check_extension_schema_boundaries.sh — exit 0
- ./scripts/check_body_cache_whitelist.sh — exit 0
- ./scripts/check_s3s_footprint.sh — exit 0
- ./scripts/check_test_count_ratchet.sh — exit 0
- ./scripts/check_fips_wording.sh — exit 0
- ./scripts/check_embedded_secrets.sh — exit 0
- make script-tests — exit 0
- python3 ./scripts/test_object_generation_protocol_model.py — exit 0
- python3 ./scripts/check_test_wiring.py — exit 0
- ./scripts/check_no_planning_docs.sh — exit 0
- ./scripts/check_uring_lane_lib_only.sh — exit 0
- cargo nextest run --profile ci -p rustfs-ecstore --lib --locked --no-fail-fast — 5894 passed, 0 skipped
- cargo check -p rustfs-ecstore --all-targets --locked — exit 0
- cargo nextest run --profile ci -p rustfs --lib --locked --no-fail-fast -E 'test(=admin::handlers::table_catalog::tests::cold_strong_registration_completes_while_other_bucket_backup_waits) | test(=admin::handlers::table_catalog::tests::commit_backend_borrowed_put_preserves_owner_authorization_and_precondition) | test(=admin::handlers::table_catalog::tests::disable_table_bucket_preserves_objects_and_can_be_reenabled) | test(=admin::handlers::table_catalog::tests::load_table_handler_negotiates_vended_credentials_without_breaking_metadata_only_callers) | test(=admin::handlers::table_catalog::tests::namespace_property_handler_updates_object_backed_catalog_and_maps_errors) | test(=app::object::get::tests::execute_get_object_resumes_from_relocated_pool_without_splicing_body) | test(=table_catalog::store::tests::catalog_writer_loss_prevents_recovery_storage_commit)' — 7 passed
- cargo nextest run --profile ci -p rustfs --lib --locked --no-fail-fast -E 'test(unsupported_remote_disk_)' — 2 passed

Additional fault-injection evidence remains incomplete. Timestamp M1 reached its original assertion; its restore run is being collected. M2–M5 and the three planned rollback faults have not run. An automated risk check interrupted the timestamp proof task; no assertions or validation settings were weakened.

* fix(ecstore): box nested bucket rollback state

Keep the guarded rollback future behind a heap allocation so bucket creation does not store its nested state inline. The rollback body, all three fence references and pending-proof revocation order are unchanged.

The same three original Linux tests overflowed on both e611 CI attempts. Local format, ecstore test compilation and all 19 quick-check steps pass; the original Linux feature lanes still need to validate this candidate.

* test(ecstore): isolate rollback guard loss fixtures

Run the three rollback metadata-cleanup cases in separate test processes so reused /proc/self/fd aliases from earlier cases cannot invalidate their creation witnesses. Keep the original fixture, guards, assertions and io_uring test selection, and verify that each child runs the intended case successfully.

Update the generated test-count baseline for the added macro arm.

Validation: cargo check -p rustfs-ecstore --tests; cargo fmt --all --check; all 19 quick-check commands, including make script-tests and the two Python checks. Linux io_uring acceptance and fault controls remain pending.

* refactor(replication): move the replication config onto gateway types (#8443)

* wip: T1.4 replication family

* refactor(replication): move the replication config onto gateway types

The replication configuration family leaves s3s: rustfs-replication no longer depends on s3s, and ECStore bucket metadata, the scanner and the rustfs app/admin layers hold rustfs_gateway_types::persistence::PersistedReplicationConfiguration. The persisted XML goes through the gateway persistence codec behind a new BucketConfigXml trait (the 39 byte goldens pass with unchanged fixtures); the legacy s3s edge converts once in storage/ecfs.rs through storage/s3_compat/replication.rs; the scanner data usage cache keeps its MessagePack bytes through a serde mirror pinned by bytes recorded from s3s 5761ddfe. rustfs-gateway-types 0.32.0 is pinned once (rev 772d3385). Refs rustfs/backlog#2745.

* test: use S3 serializer for list response tests

* fix(ci): restore integration test compilation

Gate the diagnostic test import with its feature, initialize the conditional
MetadataWriteStore fields explicitly, and decode the scanner replication fixture
through its existing cache boundary. Preserve the original fixture and assertions.

Validation was run on tree 544acb80c0 at 6003d4b8.
LANG=en_US.UTF-8; no source changes follow validation.

Quick checks: 19/19 passed.
- shellcheck --version && actionlint: exit 0
- cargo fmt --all --check: exit 0
- ./scripts/check_unsafe_code_allowances.sh: exit 0
- ./scripts/check_layer_dependencies.sh: exit 0
- ./scripts/check_architecture_migration_rules.sh: exit 0
- ./scripts/check_logging_guardrails.sh: exit 0
- ./scripts/check_error_other_format_ratchet.sh: exit 0
- ./scripts/check_no_tokio_io_uring.sh: exit 0
- ./scripts/check_extension_schema_boundaries.sh: exit 0
- ./scripts/check_body_cache_whitelist.sh: exit 0
- ./scripts/check_s3s_footprint.sh: exit 0
- ./scripts/check_test_count_ratchet.sh: exit 0
- ./scripts/check_fips_wording.sh: exit 0
- ./scripts/check_embedded_secrets.sh: exit 0
- make script-tests: exit 0
- python3 ./scripts/test_object_generation_protocol_model.py: exit 0
- python3 ./scripts/check_test_wiring.py: exit 0
- ./scripts/check_no_planning_docs.sh: exit 0
- ./scripts/check_uring_lane_lib_only.sh: exit 0

Darwin validation:
- cargo clippy --locked -p rustfs-ecstore -p rustfs-scanner --all-targets -- -D warnings: exit 0
- cargo nextest run --locked -p rustfs-ecstore -p rustfs-scanner --lib -E 'test(config::com::tests::save_config_preserves_retryable_system_volume_errors) | test(scanner_io::tests::replication_maintenance_refreshes_compacted_stats_across_cycles_and_retries)' --test-threads 1 --no-fail-fast: exit 0, 2/2 passed
- cargo clippy --locked -p rustfs-ecstore -p rustfs-scanner --all-targets --features rustfs-ecstore/diagnostic-read-version-caller,rustfs-scanner/diagnostic-read-version-caller -- -D warnings: exit 101
- cargo nextest run --locked -p rustfs-ecstore -p rustfs-scanner --lib --features rustfs-ecstore/diagnostic-read-version-caller,rustfs-scanner/diagnostic-read-version-caller -E 'test(config::com::tests::save_config_preserves_retryable_system_volume_errors) | test(scanner_io::tests::replication_maintenance_refreshes_compacted_stats_across_cycles_and_retries) | test(cluster::rpc::runtime_sources::tests::) | test(config::com::tests::diagnostic_config_write_retains_origin_and_api_for_results_and_cancellation)' --test-threads 1 --no-fail-fast: exit 0, 14/14 passed

Diagnostic strict Clippy remains red on four unchanged production lints:
runtime_sources.rs if_same_then_else and three redundant_locals reports in
set_disk/core/metadata_read.rs and set_disk/read.rs. Their source ranges and
feature wiring match the base; the base was not compiled separately.

Linux aarch64 validation (Rust/Clippy 1.99.0, UID 501, jobs=1):
- cargo clippy --all-targets -- -D warnings: exit 0; Finished dev profile in 68m 12s
- cargo clippy -p rustfs -p rustfs-ecstore --all-targets --features rio-v2 -- -D warnings: exit 0; Finished dev profile in 49m 45s
Source HEAD/tree and clean status matched before and after both commands.
No compiler or stack overrides were used. protoc 35.1 was installed from its
verified ARM64 release; flatc was absent and not invoked by the workspace build scripts.
The local environment differs from hosted CI.

* fix(ecstore): recover delete batches with missing versions

Treat an explicit non-null version as a no-op when it is absent from both recovery sources. Keep rejecting missing null versions, delete-marker mismatches and conflicting local generations.

Add returning-disk and partial-receipt regressions, extend journal boundary checks, and regenerate the ecstore test-count baseline. Production faults exercise the 24 added assertion sites and 7 semantic checks: 33 faults failed at the expected sites and passed after full restoration.

Validation (LANG=en_US.UTF-8, one build job):

cargo nextest run --locked -p rustfs-ecstore --lib -E 'test(delete_recovery) | test(quorum_delete_cleanup_coordinator_binds_single_and_batch) | test(crash_delete_retry_batch_reclaims_inline_and_reserved_external)' --test-threads 1 --no-fail-fast
Summary [  34.580s] 69 tests run: 69 passed, 6058 skipped; exit 0

cargo clippy --locked -p rustfs-ecstore --all-targets -- -D warnings
Finished `dev` profile [unoptimized + debuginfo] target(s) in 5m 37s; exit 0

All 19 final Quick Checks completed with exit 0:

- shellcheck --version && actionlint
- cargo fmt --all --check
- ./scripts/check_unsafe_code_allowances.sh
- ./scripts/check_layer_dependencies.sh
- ./scripts/check_architecture_migration_rules.sh
- ./scripts/check_logging_guardrails.sh
- ./scripts/check_error_other_format_ratchet.sh
- ./scripts/check_no_tokio_io_uring.sh
- ./scripts/check_extension_schema_boundaries.sh
- ./scripts/check_body_cache_whitelist.sh
- ./scripts/check_s3s_footprint.sh
- ./scripts/check_test_count_ratchet.sh
- ./scripts/check_fips_wording.sh
- ./scripts/check_embedded_secrets.sh
- make script-tests
- python3 ./scripts/test_object_generation_protocol_model.py
- python3 ./scripts/check_test_wiring.py
- ./scripts/check_no_planning_docs.sh
- ./scripts/check_uring_lane_lib_only.sh

test-count ratchet OK: 52 crates, 22009 tests, 165 ignored match .config/test-count-baseline.txt
PASS: cold stampede script self-test
model: states=51023, transitions=167520, terminal=732, chosen_candidates=2
multi-slot round schedules: 640
OK: e2e modules, runner selection, fuzz matrices, profiles, scheduled alerts, and Quick Checks are wired

* fix(ecstore): retry tier commit intent lock contention

Retry transient namespace lock timeouts within the existing three-attempt
CAS budget. Reload and verify the original mutation identity after
contention, and preserve the final typed error when attempts are exhausted.
Cover save/read contention, exhausted retries, replacement identities and
CAS reconciliation without changing the existing timeout or lock policy.

Validation:
- cargo nextest run --locked -p rustfs-ecstore --features test-util --lib -E 'test(tier_mutation)' --test-threads 1 --no-fail-fast: 52 passed.
- cargo nextest run --locked -p rustfs-ecstore --features test-util --lib -E 'test(intent_advance_) | test(=services::tier::tier::tests::caller_cancellation_after_durable_prepare_does_not_hide_the_mutation)' --test-threads 1 --no-fail-fast: 5 passed.
- cargo clippy --locked -p rustfs-ecstore --all-targets -- -D warnings: passed.
- cargo clippy --locked -p rustfs-ecstore --all-targets --features test-util -- -D warnings: passed.
- 27 mutation pairs failed at the intended assertions and passed after restoring the production code.
- bash scripts/check_test_count_ratchet.sh --update, then bash scripts/check_test_count_ratchet.sh: 52 crates, 22013 tests, 165 ignored; ecstore increases from 6581 to 6585.

* refactor(lifecycle): replace s3s domain types with Gateway DTOs

Keep lifecycle rules, restore requests, Object Lock, and scanner cached tags in domain types. Convert at the existing HTTP boundary while preserving legacy XML, scanner cache bytes, date ordering, and tombstones.

Linux test validation before the final import-path-only adjustment:

cargo nextest run -p rustfs-ecstore bucket_config_goldens
39 passed; 6211 skipped by selector.

cargo nextest run --lib -p rustfs -E 'test(/test_zero_rule_lifecycle_tombstone_recognition/) | test(/test_local_lifecycle_staleness_axis_selection/) | test(/test_lifecycle_staleness_preserves_legacy_submillisecond_axis/) | test(/test_merge_incoming_lifecycle/) | test(/test_lifecycle_expiry_statement_matrix/) | test(/test_lifecycle_expiry_subset_xml/) | test(/put_bucket_lifecycle_validation_errors_keep_their_s3_code/) | test(/execute_put_bucket_lifecycle_configuration_rejects_missing_configuration/) | test(/execute_restore_object_rejects_missing_restore_request/) | test(/execute_restore_object_rejects_select_type/) | test(/execute_restore_object_maps_failures_to_typed_s3_errors/) | test(/execute_restore_object_returns_internal_error_when_store_uninitialized/) | test(/restore_request_conversion_preserves_legacy_output_values/) | test(/restore_request_conversion_preserves_presence_and_raw_enums/) | test(/restore_request_conversion_distinguishes_absent_and_empty_containers/)'
25 passed; 5318 skipped by selector.

cargo nextest run --profile ci -p rustfs-ecstore -p rustfs-lifecycle -p rustfs-replication -p rustfs-scanner
7872 passed; 21 skipped under the existing CI profile.

Previously verified mutation controls: 39 production faults, 41 RED/GREEN pairs.

cargo clippy --locked -p rustfs -p rustfs-ecstore -p rustfs-lifecycle -p rustfs-scanner --all-targets -- -D warnings
Passed in 40m 45s before the import-path adjustment, then in 1m 59s on
the final source with warnings denied. The final adjustment imports the
existing helper through the object domain facade; fmt and the architecture
check also passed.

Part of rustfs/backlog#2745. The remaining ecstore configuration families follow separately.

* refactor(admin): move error types to rustfs-s3-types (#8440)

* refactor(protocols): move error types to rustfs-s3-types by codemod

T1.2 batch 3 (rustfs/backlog#2743): s3select-api, s3select-query and
the error items of protocols move off s3s by scripts/codemods, run with
--hold crates/protocols/src/sftp/errors.rs. That file stays on s3s: it
downcasts the backend error, whose runtime type is still s3s::S3Error.

- rustfs/src/protocols/client.rs: the two webdav session methods now
  return rustfs_s3_types::S3Result and convert the s3s error with From.
- codemod: a child module that calls s3_error! through `use super::*`
  gets its own `use rustfs_s3_types::s3_error;` once its parent imports
  that macro by name; otherwise it is listed as GLOB.
- s3-types: a table test pins code, status and the s3s error the legacy
  edge renders for every variant a moved call site names.
- s3_error_lines 1585 -> 1584 (a lifecycle comment reworded).

* refactor(admin): move error types to rustfs-s3-types (WIP, no build)

T1.2 batch 4 (rustfs/backlog#2743), work in progress: rustfs check
still fails with 16 errors (listed in the PR body).

- codemod: --facade PATH treats a crate-local re-export of s3s's error
  items like s3s for the four names (9 new self-tests, 9 mutants red).
- codemod output for rustfs/src/admin and rustfs/src/site_replication
  (--facade crate::storage_api::site_replication::s3); 6 glob children
  get an explicit rustfs_s3_types::s3_error import.
- admin/router.rs: the Operation trait returns rustfs_s3_types::S3Result.
- error.rs: From<ApiError> builds the rustfs-s3-types error; the s3s
  conversion is derived from it.

* fix(admin): convert error types at the s3s boundary

Batch 4 of T1.2 left the admin and site_replication modules returning
rustfs_s3_types::S3Error while their callers and callees on the storage
and auth side still speak s3s::S3Error. Convert at each edge with the
existing From impls in rustfs-s3-types (compat_s3s.rs) instead of
changing any code, message or status:

- admin/auth.rs, admin/handlers/object_zip_download.rs,
  admin/handlers/table_catalog/mod.rs: map the s3s result returned by
  check_key_valid / authorize_request / authorize_internal_object_request
  into the admin error type with .map_err(Into::into).
- storage/ecfs.rs: the s3s-facing side maps the admin site_replication
  gate results the other way.
- admin/handlers/pools.rs: ApiError.code stays s3s::S3ErrorCode, so the
  rustfs code is converted with .into() when the struct is built, in
  production and in the two tests that build or compare it.
- admin/handlers/heal.rs, admin/runtime_sources.rs: the router returns
  s3s errors (S3Route impl); the expected rustfs code is converted with
  .into() before the comparison.
- admin/handlers/table_catalog/mod.rs: iceberg_rest_error takes
  &'static str so the Cow<'static, str> custom code borrows it; every
  caller already passes an ICEBERG_ERROR_* constant or a &'static str.
- admin/handlers/quota.rs: drop a now same-type .into() after s3_error!.
- storage_api.rs, site_replication/mod.rs: drop re-exports and an import
  that no longer have users (clippy -D warnings).

cargo check -p rustfs --all-targets and cargo clippy -p rustfs
--all-targets -- -D warnings are clean; the s3s footprint baseline is
unchanged at files=172, s3_error_lines=303, ecstore_files=28.

Refs rustfs/backlog#2743, rustfs/backlog#2734.

* refactor(ecstore): move bucket configs onto gateway persisted types (#8709)

* refactor(ecstore): move bucket configs onto gateway persisted types

Finish the ECStore batch of T1.4 (rustfs/backlog#2745). The remaining
eleven bucket configuration families (Website, CORS, Tagging, Versioning,
ObjectLock, Encryption, Notification, Logging, Accelerate, RequestPayment,
PublicAccessBlock) now read and write their stored XML through the
rustfs-gateway-types persistence and cors_tagging codecs instead of the
s3s codec, so the legacy s3s bucket-config codec and its
s3gate-metadata-xml compat marker are removed.

- ecstore: BucketConfigXml is implemented by the gateway codec for every
  family; metadata, metadata_sys, versioning, object lock, replication and
  lifecycle consumers take the persisted types. Region moves to
  rustfs_s3_types::Region, and the ETag conversion to s3s moves to the
  rustfs edge with its tests. Only warm_backend.rs and warm_backend_s3.rs
  still reference s3s, held for T1.5 (rustfs/backlog#2746).
- scanner: object lock cache, item actions and data usage use the
  persisted types.
- rustfs: the legacy S3 edge converts each family between s3s DTOs and the
  persisted types in storage/storage_api.rs by writing one codec's XML and
  reading it with the other; Tagging converts field by field.
- Bump the rustfs-gateway-types pin to 21283e87.

The 39 persisted-byte goldens are unchanged and pass, so a document the
old codec wrote reads back and re-encodes to the same bytes. s3s footprint:
files 181 -> 146, ecstore_files 28 -> 2, s3_error_lines unchanged.

* test(app): prepare the PutBucketEncryption config from the request

The uninitialized-store test passed an empty persisted configuration, which the use case now validates before it reaches the store check, so it answered MalformedXML instead of InternalError. Convert the request's configuration the way the S3 edge does.

* style(app): format the PutBucketEncryption test

* refactor(s3-client): move remote error codes and DTO enums off s3s (#8783)

Part of T1.5 (rustfs/backlog#2746), batch 1.

* refactor(s3select): run queries on gateway DTO request shapes (#8794)

* refactor(s3select): run queries on an engine request built from gateway DTO shapes

Part of T1.5 (rustfs/backlog#2746), batch 2.

* fix(s3select): keep the select request conversion in the app layer

The layer guard forbids app -> storage; the conversion moves from storage/s3_compat/select.rs to app/select_request.rs unchanged.

* fix(heal): retry transient metadata conflicts (#8812)

* fix(heal): retry transient metadata conflicts

* chore: update heal test count baseline

* test: reserve nextest capacity for progress probes (#8818)

* test(e2e): use a non-default region for the differential negative control

Since main's region-aware CreateBucket and GetBucketLocation (#8739, #8743), us-east-1 answers an empty LocationConstraint and 200 for an owned re-create, so the control no longer reported its own region. Also refresh the e2e-full linux selection from the CI listing (682 tests).

* refactor(protocols): move StorageBackend to gateway types (#8825)

* refactor(protocols): move storage backend to gateway types

* test: update e2e test count baseline

* fix: preserve copy source keys and unknown lengths

* test: update protocols test count baseline

* fix(protocols): preserve typed SFTP backend errors

* test(protocols): use gateway list input in policy tests

* fix(protocols): clear clippy failures after T1.5 merge (#8831)

fix(protocols): clear clippy failures after merge

* fix(ecstore): finalize restore metadata after local copy-back (#8836)

* fix(ecstore): finalize restore metadata after local copy-back

* test(ecstore): ratchet added restore finalization cases

* fix(ecstore): keep restore copyback compatible with older peers

* fix(ecstore): gate restore copyback on fleet compatibility

* fix(ecstore): retain transition identity during restore copyback

* fix(ecstore): route committed restore copyback locally

* fix restore copyback cleanup

* fix(ecstore): honor restore probe test fallback

* refactor(storage): migrate option errors to domain types (#8839)

* refactor(s3): migrate private multipart validation errors (#8844)

* refactor(auth): derive policy conditions from AuthzFacts (#8442)

* refactor(auth): extract authorization facts

* test(e2e): update smoke selection baseline

* test(e2e): update full selection baseline

* refactor(admin): migrate health and prefix usage to owned requests (#8887)

* refactor(admin): add owned health router boundary

* refactor(admin): migrate prefix usage to owned request

* refactor(admin): complete the owned admin request boundary (#8949)

* refactor(admin): complete the owned admin request boundary

Every type implementing the owned admin `Operation` now serves the legacy
router through one blanket impl that converts the s3s request and response
at that single edge, so handlers register with `AdminOperation(&Handler {})`
and need no adapter statics; `LegacyAdminAdapter` is removed.

`AdminRequest` gains the accessors handlers read today (`remote_addr`,
`client_info`, `request_context`, `req_info`, `req_info_mut`,
`install_req_info`, `envelope_mut`, `take_body`), the request helpers gain
envelope forms (`authenticate_envelope_credentials`,
`object_store_from_envelope`, the table-catalog `*_from_envelope` helpers,
`KmsAdminAudit::from_envelope`, `AccountAuditContext::from_envelope`,
`CallerIdentity::resolve_envelope`), `read_compatible_admin_body` accepts
either body type, and the owned `Body` gains `store_all_limited` and
`Default`. `admin::operation::test_support::TestRequest` builds owned
requests for handler tests. Unused accessors carry a per-method dead-code
expectation that each migrating handler batch removes.

No handler behavior changes; the remaining migrations follow in batches.

* chore(admin): add the T1.12 admin handler codemod

The script rewrites an admin handler file from the s3s request and response
types to the owned boundary (imports, signatures, request field and helper
calls, body access, response construction, and test request literals) and
leaves a TODO comment at every site it cannot rewrite mechanically. It is
idempotent and is removed once T1.12 completes.

* refactor(admin): migrate replication and KMS handlers to owned requests (#8951)

Move the T1.12 batch 2 admin handlers (site_replication, kms_keys, event,
replication, diagnostics, on_demand_migration, audit, account, kms_rekey,
kms_key_lifecycle, object_data_cache, tls_debug, integrity) from the s3s
request/response types to the owned AdminRequest/AdminResponse boundary.
Handler logic, authorization order, error codes and response bytes are
unchanged; the request/response plumbing now goes through the envelope
accessors and helpers introduced by the owned boundary.

The shared response helpers (utils::json_response, utils::empty_response,
handlers::admin_json_response, target_descriptor::build_json_response,
profile::authorize_profile_request) and the site replication repair
executor still answer with the s3s types, so the migrated files carry
owned-boundary forms of those helpers and convert the repair response at
its call edge. The streaming console-log body and the two devnull drains
move from the s3s byte stream to the owned Body stream/frame API.

Drop the dead_code expectations for the owned accessors and envelope
helpers this batch starts using, and lower the s3s footprint baseline to
the measured 99 files.

Part of rustfs/backlog#2788 (rustfs/backlog#2734 T1.12).

* refactor(admin): add owned forms of the shared admin response helpers

Handler batches were copying `json_response`, `empty_response`,
`admin_json_response`, and `build_json_response` into each migrated file
because the shared helpers still return the s3s response. Provide owned
forms next to each legacy helper (`owned_json_response`,
`owned_empty_response`, `owned_admin_json_response`,
`owned_build_json_response`, `authorize_profile_envelope`) with parity
tests against the legacy shape, derive `Debug` for `AdminResponse` so
tests can use `expect_err`, and fix three codemod rules: strip owned
string conversions in test credentials, annotate non-literal URI parses,
and leave a rebound request parameter immutable.

* refactor(admin): use the shared owned response helpers in batch 2 (#8954)

Replace the per-file owned-boundary copies of json_response,
empty_response, admin_json_response, build_json_response and
authorize_profile_request in the batch 2 admin handlers with the shared
owned_* helpers and authorize_profile_envelope that the boundary branch
now provides, and restore expect_err in the handler tests now that
AdminResponse implements Debug; the `.err().expect(..)` stand-ins were
failing the base branch's clippy step (clippy::err_expect under
-D warnings). The site replication repair call edge
keeps its conversion because the repair executor still answers with the
s3s response type.

Drop the dead_code expectations on the shared owned helpers this batch
now uses in non-test code.

Part of rustfs/backlog#2788 (rustfs/backlog#2734 T1.12).

* fix(admin): preserve legacy body limit error responses

* refactor(admin): move policy, ILM and batch 3 handlers to owned requests (#8956)

Batch 3 of T1.12 (rustfs/backlog#2788, parent rustfs/backlog#2734) moves
14 admin handler files to AdminRequest/AdminResponse and the owned
Operation trait: ilm_transition, policies, idp_compat, config_admin,
plugins_instances, profile_admin, group, heal, object_zip_download,
kms_key_metadata, durability, inspect_archive, sts and
gateway_key_inventory. Handler logic, authorization order, error codes
and response bytes are unchanged; test request literals use
test_support::TestRequest.

The handlers use the shared owned helpers (owned_json_response,
owned_admin_json_response, owned_build_json_response,
authorize_profile_envelope). The dead_code expectations on the
AdminRequest accessors and RequestEnvelope::uri_mut they now exercise
are removed. The legacy target_descriptor::build_json_response keeps
only its equivalence test caller and carries the matching expectation
until it retires.

object_zip_download builds the s3s request the S3 gate still reads from
the owned request facts; heal authenticates through
authenticate_envelope_credentials; the trace, inspect-archive and zip
bodies use Body::from_stream. extract_plugin_instance_filters takes the
URI so extensions.rs keeps calling it, and the two idp_compat
delegations stay on the legacy trait until their delegates migrate.

The s3s footprint baseline drops from 99 to 90 files.

* test(admin): anchor the replication diff body contract on the call

The source-contract test looked for `let body = read_compatible_admin_body(`
on one line; rustfmt wrapped that binding after the handler moved to the
owned request boundary, so the marker was no longer found. Anchor on the
call itself so formatting cannot change what the contract checks.

* refactor(admin): migrate table catalog and batch 1 to owned requests (#8955)

* fix(admin): preserve catalog body limit response bytes

* refactor(admin): migrate identity and cluster handlers to owned requests (#8966)

* refactor(admin): migrate identity and cluster handlers to owned requests

Move the T1.12 batch 4 admin handlers (user, service_account, oidc, tier,
system, pools, quota, kms_dynamic, kms_backup, rebalance, module_switch,
bucket_meta, cluster_snapshot, realtime, bucket_recovery, profile) from the
s3s request/response types to the owned AdminRequest/AdminResponse
boundary. All 90 impl Operation blocks now take AdminRequest and return
AdminResponse; handler logic, authorization order, error codes and response
bytes are unchanged. The handlers use the shared owned helpers
(owned_json_response, owned_admin_json_response, authorize_profile_envelope,
the envelope-form runtime sources and audit constructors) instead of local
copies.

profile.rs drops the s3s forms of authorize_profile_request and
profile_not_implemented_response now that profile_admin.rs is on the
envelope gate too; profile_not_implemented_response answers with an
AdminResponse. The realtime metrics stream moves from the s3s ByteStream
adapter to Body::from_stream, and the rebalance idle-404 document still
renders through s3s::S3Error::to_http_response with its body converted at
the edge.

The idp_compat delegators ImportIamV2 and AddServiceAccountLdap move to the
owned trait as well: with their targets on both traits, the method-call
delegation became ambiguous between the owned impl and the router blanket
impl, so they now take the owned request directly.

KmsAdminAudit::from_request loses its last production caller with this batch
and is deleted; its comparison test now asserts the envelope facts directly.

Lower the s3s footprint baseline to the measured 68 files.

Part of rustfs/backlog#2788 (rustfs/backlog#2734 T1.12).

* refactor(admin): remove the legacy admin helpers batch 4 superseded

Batch 4 moved the last production callers of the s3s-typed admin helpers
to their owned forms, which left `admin_json_response`, `json_response`,
`authenticate_request`, `authorize_admin_request`, `object_store_from_req`
and `object_store_from_extensions` unused, along with the `Body`,
`S3Request` and `S3Response` re-exports of the admin `s3` facade. Remove
them. The owned forms keep their documentation; the behavior tests of the
removed `json_response` now exercise `owned_json_response`, and the
compat-prefix test asserts the owned response directly.

Two tests existed only to compare a removed legacy helper with its owned
twin (`owned_json_response_matches_the_legacy_shape`) or to cover the
removed legacy gate (`authorize_admin_request_without_credentials_is_rejected`);
they go with the helpers, and the owned equivalents stay. The rustfs
test-count row drops from 5957 to 5955 and the s3s footprint from 68 to 66
files.

* refactor(admin): drop the legacy target descriptor JSON response helper (#8968)

`target_descriptor::build_json_response` returned the s3s response and
had no production caller left once the event, audit and plugin instance
handlers moved to `owned_build_json_response`; it survived only for the
legacy/owned equivalence test. Remove it, give the owned helper its own
documentation, and make the test assert the owned response directly:
status, `application/json`, the echoed `x-request-id` when present and
only then, and the body bytes. The file no longer imports s3s, so the
footprint baseline drops from 66 to 65 files.

* test(ecstore): pin native history on cancellation under the cancellable queue

main moved the local write path onto the disk blocking queue, which skips an
operation once its waiter is dropped, so a borrowed write cancelled while it is
queued no longer creates its file. The integration-only native publication
test still expected the queued open to run. Assert the new execution model
instead (the open never runs) while keeping its safety claims: the Started
record is taken before queueing and the cancelled publication never regrants
native history.

* chore(gateway): raise test-count rows for tests merged from main

* test(s3select): use the owned JSON input types in merged planner tests

* fix(ecstore): scope the merged direct-writer test hook to test builds

* fix(ecstore): create parents before admitted truncate opens

Align the heal tests with removal-disabled preservation and local-only outcome
fields, and refresh the e2e-full selection for the synced tests.

* chore(gateway): update test-count baseline after main sync

Main #9241 removed the node pool-metadata readiness cache with 8 tests
and added 6, so the rustfs row drops from 6440 to 6438. The ecstore and
s3select rows rise for tests added by #9237, #9239 and #9241.

* feat(scripts): add junit_diff.py for dual-stack JUnit comparison (#9275)

* ci(gateway): make the test-count ratchet a floor, not exact (#9278)

ci(gateway): make the test-count ratchet a floor instead of an exact baseline

Tests below a row still fail; tests above it, a member without a row and ignores below the row now pass with a note. Ignores above a row still fail. Adds contract tests wired into make script-tests.

* test(guards): read file plus split directory in text guards

Text guards that read one file with include_str! or by path broke, or silently stopped seeing the code they guard, when that code moved to another file. They now read the file they read today, which stays required, plus the sibling directory a mechanical split will populate (storage/legacy_edge, app/bucket_usecase, app/multipart_usecase, app/lifecycle_transition_api_test). Counts are summed over the set and split markers must occur exactly once across it. Refs rustfs/backlog#2765.

* chore(gateway): record ignored microbenchmarks merged from main (#9290)

chore(gateway): record the ignored microbenchmarks merged from main

main added three throughput microbenchmarks that are ignored by design
(#9217): one each in rustfs-checksums, rustfs-rio and rustfs-utils. The
test-count ratchet treats a higher ignored count as a failure until the
row is raised, so raise those three rows to 1 and tighten the test rows
to the merged tree with --update.

* test(guards): avoid a typos false positive in a fixture

* ci(s3-tests): add reference parity lane against pinned 1.0.5 (#9288)

* build(codemods): add split_ecfs_legacy_edge codemod

Moves the impl S3 for FS method bodies out of rustfs/src/storage/ecfs.rs
into one file per migration batch under rustfs/src/storage/legacy_edge/
(rustfs/backlog#2765, preparatory task P1). The codemod refuses an input
it does not expect, such as a method without a batch assignment or an
already split tree. Its self-test runs from make script-tests.

* refactor(storage): move impl S3 for FS bodies into legacy_edge files

Output of scripts/codemods/split_ecfs_legacy_edge.py on the unsplit
ecfs.rs and ecfs_test.rs. Every trait method keeps its signature in
ecfs.rs and calls a FS::<method>_edge fn in the file of its batch; the
bodies, their single-batch helpers and their request-building tests move
unchanged. ListBuckets stays inline: the T2.5 skeleton owns it.

* test(guards): address review of the split-safe text guards

Make the super:: import rules depth-aware for split files, cover ecfs_test.rs moving to legacy_edge/tests, read production text only in the Rust count and contains guards, bound method slices by their own closing line, keep the split arrays safe on bash 3.2, and add an end-to-end split probe for the architecture, layer and logging guards. Refs rustfs/backlog#2765.

* fix(codemods): keep split imports that rustc credits elsewhere out

Two import decisions of split_ecfs_legacy_edge were wrong on the first CI
run: a fn-local use counted as a use of the module-level import of the
same name, and a batch file imported a trait that ecfs.rs still imports,
which rustc reports as unused because the ecfs glob already supplies it.
Both now have a regression case in the self-test.

* refactor(storage): regenerate the legacy_edge split

Output of the fixed codemod: ecfs_test.rs drops its now unused Method
import and object_meta.rs takes ObjectOperations from the ecfs glob.

* refactor(storage): regenerate ecfs_test.rs after merging main

The textual merge of the already split ecfs_test.rs kept persisted_lock
twice. Re-running split_ecfs_legacy_edge on the unsplit inputs (ecfs.rs
from the guards branch, ecfs_test.rs from main) yields the tree without
the duplicate; every other file regenerated byte-identical.

* fix(codemods): refuse an import no scanned code uses

The input compiles warning-free, so an import that neither the code left
in a module nor the moved code uses means the scan missed a use, most
likely a trait missing from TRAIT_METHODS. The codemod now fails instead
of dropping the import and leaving the error to the compiler.

* fix(guards): resolve attributed modules in test seam checks

---------

Co-authored-by: rustfs-cla-assistant[bot] <273121464+rustfs-cla-assistant[bot]@users.noreply.github.com>
2026-11-12 09:11:12 -05:00
GatewayJ 633c0d204a fix(s3-tables): reclaim dropped table commits and maintenance reports (#9351) 2026-11-12 09:10:58 -05:00
127 changed files with 10902 additions and 3120 deletions
+2 -2
View File
@@ -1,2 +1,2 @@
sha256-darwin=0374e6b9b061dd5d622686301386232a4aa1cb22eedc18af70d2a7411bc66f8d
sha256-linux=f2cee240f7a744ae991971820f48a6c9c28d19f525e8b2345fcb76eeaaf1e382
sha256-darwin=dde32c85d39a3c2eb1cad6912861883af2b3554fc6d04b86b3aeb2afbcc86c7e
sha256-linux=155b526f75743796c18c4c5202bbef3a1770b0ea4ba18b32910317a957ad36f2
+1 -1
View File
@@ -1 +1 @@
sha256=21e9a1561ac20382503c38353bbb9b830658534a4f277c254a42d0afc6ebed8b
sha256=caccbc5bcbfbe1b7548fbd184f1a6ae3aedec7cb84ed862d62dddbe27e75271f
+3
View File
@@ -38,6 +38,7 @@ script-tests: ## Run shell script tests
./scripts/test_entrypoint_credentials.sh
./scripts/test_entrypoint_directories.sh
./scripts/test_internode_grpc_ab_bench.sh
$(RUSTFS_PYTHON_BIN) ./scripts/test_internode_grpc_window.py
./scripts/test_object_batch_bench_enhanced.sh
./scripts/test_package_service_scripts.sh
./scripts/test_hotpath_warp_ab_gate.sh
@@ -67,6 +68,7 @@ script-tests: ## Run shell script tests
$(RUSTFS_PYTHON_BIN) ./scripts/check_test_wiring.py --self-test
$(RUSTFS_PYTHON_BIN) ./scripts/codemods/s3_error_to_s3_types.py --self-test
$(RUSTFS_PYTHON_BIN) ./scripts/codemods/split_bucket_usecase.py --self-test
$(RUSTFS_PYTHON_BIN) ./scripts/codemods/split_ecfs_legacy_edge.py --self-test
$(RUSTFS_PYTHON_BIN) ./scripts/codemods/split_lifecycle_transition_api_test.py --self-test
$(RUSTFS_PYTHON_BIN) ./scripts/check_test_seams.py --self-test
$(RUSTFS_PYTHON_BIN) ./scripts/test_e2e_binary.py
@@ -81,6 +83,7 @@ script-tests: ## Run shell script tests
$(RUSTFS_PYTHON_BIN) ./scripts/ci_gate.py --self-test
$(RUSTFS_PYTHON_BIN) ./scripts/check_connect_release_boundary.py --self-test
$(RUSTFS_PYTHON_BIN) ./scripts/test_ci_test_artifact.py
$(RUSTFS_PYTHON_BIN) ./scripts/test_ci_merge_group_proof.py
$(RUSTFS_PYTHON_BIN) ./scripts/test_ci_test_shards.py
$(RUSTFS_PYTHON_BIN) ./scripts/test_ci_merge_queue.py
$(RUSTFS_PYTHON_BIN) -m unittest discover -s scripts/ci -p 'test_*.py'
+1 -1
View File
@@ -96,7 +96,7 @@ env:
RUST_BACKTRACE: 1
jobs:
# Readers: workspace-lint-and-build, workspace-tests (workspace and ILM), build-rustfs-debug-binary,
# Readers: workspace-clippy, workspace-lint-and-build, workspace-tests (workspace and ILM), build-rustfs-debug-binary,
# e2e-tests, e2e-full.
warm-ci-dev:
name: Warm ci-dev
+75 -14
View File
@@ -88,6 +88,9 @@ jobs:
if: github.event_name != 'pull_request' || github.event.action != 'closed'
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
actions: read
outputs:
mode: ${{ steps.scope.outputs.mode }}
plan: ${{ steps.scope.outputs.plan }}
@@ -107,7 +110,14 @@ jobs:
CI_BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha || github.event.before }}
CI_EVENT_ACTION: ${{ github.event.action }}
CI_REHEARSE_SHARDS: ${{ inputs.rehearse_workspace_shards && 'true' || 'false' }}
# A push whose tree a successful merge-group run already passed skips
# the validation jobs; the lookup answers null on any doubt.
PROOF_TOKEN: ${{ github.event_name == 'push' && github.token || '' }}
run: |
if [[ "$GITHUB_EVENT_NAME" == "push" ]]; then
CI_MERGE_GROUP_PROOF="$(GITHUB_TOKEN="$PROOF_TOKEN" python3 -I scripts/ci_merge_group_proof.py)" || CI_MERGE_GROUP_PROOF=null
export CI_MERGE_GROUP_PROOF
fi
if [[ "$GITHUB_EVENT_NAME" != "pull_request" ]]; then
python3 -I scripts/ci_gate.py select
elif [[ "$CI_BASE_SHA" =~ ^[0-9a-f]{40}$ ]] && git show "$CI_BASE_SHA:scripts/ci_gate.py" > "$RUNNER_TEMP/ci-gate-base.py"; then
@@ -116,7 +126,7 @@ jobs:
printf '%s\n' 'mode=full' >> "$GITHUB_OUTPUT"
echo "Base CI policy unavailable; running the full matrix."
fi
if ! grep -q '^plan=' "$GITHUB_OUTPUT" || ! grep -q '^plan_contract=3$' "$GITHUB_OUTPUT"; then
if ! grep -q '^plan=' "$GITHUB_OUTPUT" || ! grep -q '^plan_contract=5$' "$GITHUB_OUTPUT"; then
printf '%s\n' 'mode=full' 'connect=true' 'full_validation=true' 'rehearse_shards=false' 'gateway_smoke=true' 'packages=' 'plan=' >> "$GITHUB_OUTPUT"
echo "Base policy does not cover this workflow contract; retaining the full matrix."
fi
@@ -171,6 +181,67 @@ jobs:
- name: Run shared quick checks
uses: ./.github/actions/quick-checks
# Clippy runs beside the test-binary build instead of in front of it. The two
# share no compiler output: Clippy writes check-mode metadata, the archive
# needs codegen and linked test binaries, and both restore the third-party
# artifacts of their own mode from ci-dev. As one job the lint held the
# archive, and every test that consumes it, back by its whole duration.
# scripts/ci_gate.py keeps this job in the Test and Lint aggregate and out
# of the archive consumer's dependency chain.
workspace-clippy:
name: Workspace Clippy
if: contains(fromJSON('["full", "e2e"]'), needs.classify-changes.outputs.mode) && (github.event_name != 'pull_request' || github.event.action != 'closed')
needs: [ quick-checks, classify-changes ]
# Reads the ci-dev cache that cache-warm.yml writes on this image, and the
# all-targets check of the workspace does not fit a hosted runner's disk.
runs-on: sm-standard-4
timeout-minutes: 60
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
CI_PACKAGES: ${{ needs.classify-changes.outputs.packages }}
steps:
- name: Checkout repository
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
# Checkout otherwise writes the token into .git/config, where a PR's
# own build.rs or proc-macro could read it back out.
persist-credentials: false
- name: Setup Rust environment
uses: ./.github/actions/setup
with:
rust-version: stable
# Read-only, like every other ci-dev reader; cache-warm.yml warms the
# check artifacts of this exact invocation.
cache-shared-key: ci-dev
cache-save-if: 'false'
install-build-packaging-tools: 'false'
# Clippy gates every event, pull requests included. When it ran only after
# merge, a lint regression merged freely and then failed every main run
# until someone fixed it (10 of 12 main runs on 2026-10-17/18). cache-warm
# writes the same invocation's check artifacts into ci-dev, so a pull
# request re-checks only the crates its diff touches. Main keeps the step
# because pull requests can merge without being up to date with main.
- name: Run clippy lints
run: |
./scripts/ci/resource_sampler.sh start clippy
trap './scripts/ci/resource_sampler.sh stop' EXIT
python3 scripts/ci_test_artifact.py cargo lint --configuration workspace
- name: Upload lint diagnostics
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: workspace-clippy-diagnostics-${{ github.run_number }}-${{ github.run_attempt }}
path: artifacts/workspace
retention-days: 3
if-no-files-found: error
# Keeps its job ID and check name: it is the archive producer that
# workspace-tests and scripts/check_test_wiring.py bind to. The lint half
# is workspace-clippy above; this job builds the test archive, then runs
# the doctests and the log-analyzer anchor check on the same build output.
workspace-lint-and-build:
name: Workspace Lint and Build
if: contains(fromJSON('["full", "e2e"]'), needs.classify-changes.outputs.mode) && (github.event_name != 'pull_request' || github.event.action != 'closed')
@@ -212,18 +283,6 @@ jobs:
echo "started_at=$(date --utc --iso-8601=seconds)"
} > artifacts/workspace/run-metadata.txt
# Clippy gates every event, pull requests included. When it ran only after
# merge, a lint regression merged freely and then failed every main run
# until someone fixed it (10 of 12 main runs on 2026-10-17/18). cache-warm
# writes the same invocation's check artifacts into ci-dev, so a pull
# request re-checks only the crates its diff touches. Main keeps the step
# because pull requests can merge without being up to date with main.
- name: Run clippy lints
run: |
./scripts/ci/resource_sampler.sh start clippy
trap './scripts/ci/resource_sampler.sh stop' EXIT
python3 scripts/ci_test_artifact.py cargo lint --configuration workspace
- name: Archive workspace test binaries
env:
# Workspace test compilation has high per-process memory use.
@@ -274,7 +333,7 @@ jobs:
# rustfs/backlog#1289: fail if a seed rule's log anchor no longer exists
# verbatim in the source tree (log message drifted without updating the
# rule). Placed here where the workspace — including the la-dump-anchors
# bin — is already built by the clippy/test steps above.
# bin's dependencies — is already built by the test archive step above.
- name: Check log-analyzer rule anchors
run: ./scripts/check_log_analyzer_rules.sh
@@ -1590,6 +1649,7 @@ jobs:
- classify-changes
- typos
- quick-checks
- workspace-clippy
- workspace-lint-and-build
- workspace-tests
- workspace-shards
@@ -1628,6 +1688,7 @@ jobs:
- required-checks
- typos
- quick-checks
- workspace-clippy
- workspace-lint-and-build
- workspace-tests
- core-contract
Generated
+2
View File
@@ -10766,6 +10766,7 @@ dependencies = [
"flatbuffers",
"hotpath",
"prost 0.14.5",
"rcgen",
"rmp-serde",
"rustfs-common",
"rustfs-config",
@@ -10776,6 +10777,7 @@ dependencies = [
"serde",
"serde_json",
"temp-env",
"tempfile",
"tokio",
"tonic",
"tonic-prost",
+52 -72
View File
@@ -34,9 +34,6 @@ async fn test_audit_system_startup_performance() {
println!("Audit system startup took: {elapsed:?}");
// Should complete within 5 seconds
assert!(elapsed < Duration::from_secs(5), "Startup took too long: {elapsed:?}");
// Clean up
let _ = system.close().await;
}
@@ -68,9 +65,6 @@ async fn test_concurrent_target_creation() {
println!("Concurrent target creation took: {elapsed:?}");
// Should complete quickly even with multiple targets
assert!(elapsed < Duration::from_secs(10), "Target creation took too long: {elapsed:?}");
// Verify it fails with expected error (server not initialized)
match result {
Err(AuditError::StorageNotAvailable(_)) => {
@@ -166,9 +160,6 @@ async fn test_audit_log_dispatch_performance() {
println!("Audit log dispatch took: {elapsed:?}");
// Should be very fast (sub-millisecond for no targets)
assert!(elapsed < Duration::from_millis(100), "Dispatch took too long: {elapsed:?}");
assert!(
matches!(result, Err(AuditError::NotInitialized(_))),
"Dispatch on a stopped system should return NotInitialized, got: {result:?}"
@@ -207,11 +198,12 @@ async fn test_system_state_transitions() {
}
#[test]
fn test_event_name_mask_performance() {
fn test_event_name_mask_matches_expansion() {
use rustfs_targets::EventName;
// Test that event name mask calculation is efficient
let events = vec![
// A compound event's mask is exactly the union of its leaves' masks, and a
// leaf owns exactly one bit. Timing is printed for information only.
let events = [
EventName::ObjectCreatedPut,
EventName::ObjectAccessedGet,
EventName::ObjectRemovedDelete,
@@ -220,47 +212,59 @@ fn test_event_name_mask_performance() {
];
let start = Instant::now();
// Calculate masks for many events
for _ in 0..1000 {
for event in &events {
let _mask = event.mask();
}
for event in events {
let union = event.expand().iter().fold(0u64, |acc, leaf| acc | leaf.mask());
assert_eq!(event.mask(), union, "{event:?} mask must equal the union of its expansion");
}
println!("Event mask check took: {:?}", start.elapsed());
let elapsed = start.elapsed();
println!("Event mask calculation (5000 ops) took: {elapsed:?}");
// Should be very fast
assert!(elapsed < Duration::from_millis(100), "Mask calculation too slow: {elapsed:?}");
assert_eq!(EventName::ObjectCreatedPut.mask().count_ones(), 1);
assert_eq!(EventName::ObjectCreatedAll.mask().count_ones(), 4);
}
#[test]
fn test_event_name_expansion_performance() {
use rustfs_targets::EventName;
use std::collections::HashSet;
// Test that event name expansion is efficient
let compound_events = vec![
EventName::ObjectCreatedAll,
EventName::ObjectAccessedAll,
EventName::ObjectRemovedAll,
EventName::Everything,
// Expansion is a pure lookup. Wall-clock bounds flake on shared runners, so
// this asserts what the time was a proxy for: each compound event yields a
// fixed-size set of distinct leaf events and nothing else (no duplicates,
// no compound left to expand again). Timing is printed for information only.
let cases = [
(EventName::ObjectCreatedAll, 4),
(EventName::ObjectAccessedAll, 5),
(EventName::ObjectRemovedAll, 3),
];
let start = Instant::now();
// Expand events many times
for _ in 0..1000 {
for event in &compound_events {
let _expanded = event.expand();
for (event, expected_len) in cases {
let expanded = event.expand();
assert_eq!(expanded.len(), expected_len, "{event:?} expansion size");
assert_eq!(
expanded.iter().collect::<HashSet<_>>().len(),
expanded.len(),
"{event:?} expansion has duplicates"
);
assert_eq!(expanded, event.expand(), "{event:?} expansion must be deterministic");
for leaf in &expanded {
assert_eq!(leaf.expand(), vec![*leaf], "{event:?} expanded to non-leaf {leaf:?}");
}
}
let elapsed = start.elapsed();
println!("Event expansion (4000 ops) took: {elapsed:?}");
// Should be very fast
assert!(elapsed < Duration::from_millis(100), "Expansion too slow: {elapsed:?}");
let everything = EventName::Everything.expand();
assert!(everything.len() > 12, "Everything must cover at least every compound's leaves");
assert_eq!(
everything.iter().collect::<HashSet<_>>().len(),
everything.len(),
"Everything has duplicates"
);
for (event, _) in cases {
for leaf in event.expand() {
assert!(everything.contains(&leaf), "Everything is missing {leaf:?}");
}
}
println!("Event expansion check took: {:?}", start.elapsed());
}
#[tokio::test]
@@ -278,46 +282,22 @@ async fn test_registry_operations_performance() {
let elapsed = start.elapsed();
println!("Registry operations (2000 ops) took: {elapsed:?}");
// Should be very fast for empty registry
assert!(elapsed < Duration::from_millis(100), "Registry ops too slow: {elapsed:?}");
}
// Performance requirements validation
// The former 3k EPS / latency assertions measured the CI host, not the code
// (the loop only formats strings and reads the clock), so they are replaced by
// a count of what the loop produced. Throughput is printed for information.
#[test]
fn test_performance_requirements() {
// According to requirements: ≥ 3k EPS/node; P99 < 30ms (default)
// These are synthetic tests since we can't actually achieve 3k EPS
// without real server storage and network targets, but we can validate
// that our core algorithms are efficient enough
use std::collections::HashSet;
let start = Instant::now();
// Simulate processing 3000 events worth of operations
for i in 0..3000 {
// Simulate event name parsing and processing
let _event_id = format!("s3:ObjectCreated:Put_{i}");
let _timestamp = jiff::Timestamp::now().to_string();
// Simulate basic audit entry creation overhead
let _entry_size = 512; // bytes
let _processing_time = std::time::Duration::from_nanos(100); // simulated
}
let ids: HashSet<String> = (0..3000).map(|i| format!("s3:ObjectCreated:Put_{i}")).collect();
let timestamps = (0..3000).filter(|_| !jiff::Timestamp::now().to_string().is_empty()).count();
let elapsed = start.elapsed();
let eps = 3000.0 / elapsed.as_secs_f64();
println!("Simulated 3000 events in {elapsed:?} ({eps:.0} EPS)");
println!("Simulated 3000 events in {elapsed:?}");
// Our core processing should easily handle 3k EPS worth of CPU overhead
// The actual EPS limit will be determined by network I/O to targets
assert!(eps > 10000.0, "Core processing too slow for 3k EPS target: {eps} EPS");
// P99 latency requirement: < 30ms
// For core processing, we should be much faster than this
let avg_latency = elapsed / 3000;
println!("Average processing latency: {avg_latency:?}");
assert!(avg_latency < Duration::from_millis(1), "Processing latency too high: {avg_latency:?}");
assert_eq!(ids.len(), 3000, "every simulated event id must be distinct");
assert_eq!(timestamps, 3000, "every simulated event must get a timestamp");
}
+16 -6
View File
@@ -50,10 +50,8 @@ const _: () = assert!(DEFAULT_ADMIN_PEER_PROBE_TIMEOUT_SECS <= MAX_ADMIN_PEER_PR
// ── Client-side internode gRPC channel tuning (P0) ──
// These mirror the server-side HTTP/2 transport tuning in `rustfs/src/server/http.rs`
// on the *client* `tonic` `Endpoint` used for internode control-plane RPCs. Prior to
// this, the client channel only set timeouts/keepalive, leaving Nagle enabled and the
// default 64KiB HTTP/2 window in place — hurting small lock-RPC latency and large
// metadata-response throughput respectively.
// on the *client* `tonic` `Endpoint` used for internode RPCs. Explicit values make
// the operator's receive-credit policy independent of changes to library defaults.
/// Disable Nagle's algorithm on internode gRPC client sockets.
///
@@ -69,8 +67,8 @@ const _: () = assert!(DEFAULT_INTERNODE_RPC_TCP_NODELAY);
/// HTTP/2 initial stream window size (bytes) for internode gRPC client channels.
///
/// The library default (64KiB) throttles larger unary responses (e.g. `ReadMultiple`,
/// `BatchReadVersion`) by the bandwidth-delay product. Set to 0 to fall back to the
/// Size this for the bandwidth-delay product and concurrent response budget of
/// unary responses such as `ReadMultiple` and `BatchReadVersion`. Set to 0 to use the
/// `tonic`/`hyper` default. Defaults to 1MiB.
pub const ENV_INTERNODE_RPC_HTTP2_STREAM_WINDOW_SIZE: &str = "RUSTFS_INTERNODE_RPC_HTTP2_STREAM_WINDOW_SIZE";
pub const DEFAULT_INTERNODE_RPC_HTTP2_STREAM_WINDOW_SIZE: u32 = 1024 * 1024;
@@ -284,6 +282,18 @@ const _: () = assert!(!DEFAULT_INTERNODE_CHANNEL_ISOLATION);
pub const ENV_INTERNODE_BULK_CHANNELS: &str = "RUSTFS_INTERNODE_BULK_CHANNELS";
pub const DEFAULT_INTERNODE_BULK_CHANNELS: usize = 2;
/// Optional receive-window overrides for isolated bulk channels. When unset, inherit the
/// corresponding `RUSTFS_INTERNODE_RPC_HTTP2_*` setting. Zero selects the library default.
pub const ENV_INTERNODE_BULK_HTTP2_STREAM_WINDOW_SIZE: &str = "RUSTFS_INTERNODE_BULK_HTTP2_STREAM_WINDOW_SIZE";
pub const ENV_INTERNODE_BULK_HTTP2_CONN_WINDOW_SIZE: &str = "RUSTFS_INTERNODE_BULK_HTTP2_CONN_WINDOW_SIZE";
/// Transport admission and pending-buffer limits for isolated bulk channels. These limits
/// apply to the tonic service; complete unary bodies are observed separately by RPC metrics.
pub const ENV_INTERNODE_BULK_CONCURRENCY_LIMIT: &str = "RUSTFS_INTERNODE_BULK_CONCURRENCY_LIMIT";
pub const DEFAULT_INTERNODE_BULK_CONCURRENCY_LIMIT: usize = 64;
pub const ENV_INTERNODE_BULK_BUFFER_SIZE: &str = "RUSTFS_INTERNODE_BULK_BUFFER_SIZE";
pub const DEFAULT_INTERNODE_BULK_BUFFER_SIZE: usize = 1024;
/// Profile selector for conservative internode HTTP data-plane client tuning.
pub const ENV_INTERNODE_HTTP_TUNING_PROFILE: &str = "RUSTFS_INTERNODE_HTTP_TUNING_PROFILE";
pub const DEFAULT_INTERNODE_HTTP_TUNING_PROFILE: &str = "legacy";
+1 -6
View File
@@ -1764,12 +1764,7 @@ impl DataUsageCache {
let mut found = HashSet::new();
found.insert(top);
mark(self, &top_e, &mut found);
self.cache.retain(|k, _| {
if !found.contains(k) {
return false;
}
true
});
self.cache.retain(|k, _| found.contains(k));
}
pub fn reduce_children_of(&mut self, path: &DataUsageHash, limit: usize, compact_self: bool) {
@@ -766,9 +766,9 @@ impl Run {
|| async {
let mut ready = true;
let mut observations = Vec::with_capacity(4);
for node in 0..4 {
for (node, cluster_node) in self.dist.cluster.nodes.iter().enumerate().take(4) {
match http
.get(format!("{}/health/ready", self.dist.cluster.nodes[node].url))
.get(format!("{}/health/ready", cluster_node.url))
.timeout(Duration::from_secs(3))
.send()
.await
@@ -1332,14 +1332,15 @@ mod tests {
.collect();
assert_eq!(confirmed.len(), 1);
let mut receivers = Vec::new();
for drive in 0..phase.disks.get(1).map_or(0, Vec::len) {
let receiver_endpoints = phase.disks.get(1).map_or(&[][..], |disks| &phase.endpoints[1][..disks.len()]);
for endpoint in receiver_endpoints {
for cas in [prepares[1], commits[1]] {
let matching: Vec<_> = events[1]
.iter()
.copied()
.filter(|event| {
event["kind"] == "receiver"
&& event["disk"] == phase.endpoints[1][drive]
&& event["disk"] == *endpoint
&& event["dst_volume"] == ".rustfs.sys"
&& event["dst_path"] == "pool.bin"
&& event["etag"] == cas["etag"]
@@ -395,6 +395,56 @@ impl Drop for OpaMock {
}
}
#[tokio::test]
async fn test_sts_session_policy_id_limits_object_access() -> TestResult {
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let admin = env.create_s3_client();
let bucket = "session-policy-id";
admin.create_bucket().bucket(bucket).send().await?;
for key in ["allowed", "denied"] {
admin
.put_object()
.bucket(bucket)
.key(key)
.body(Bytes::from_static(b"data").into())
.send()
.await?;
}
let policy = serde_json::json!({
"Id": "read-one-object",
"Version": "2012-10-17",
"Statement": [{"Effect": "Allow", "Action": "s3:GetObject", "Resource": format!("arn:aws:s3:::{bucket}/allowed")}]
});
let response = sts_client(&env.url, &env.access_key, &env.secret_key, None)
.assume_role()
.role_arn("arn:aws:iam::123456789012:role/test")
.role_session_name("session-policy-id")
.policy(policy.to_string())
.send()
.await?;
let credentials = response.credentials().ok_or("AssumeRole returned no credentials")?;
let session = aws_sdk_s3::Client::from_conf(build_test_s3_config(
&env.url,
credentials.access_key_id(),
credentials.secret_access_key(),
Some(credentials.session_token()),
"e2e-session-policy-id",
));
let allowed = session.get_object().bucket(bucket).key("allowed").send().await?;
assert_eq!(allowed.body.collect().await?.into_bytes().as_ref(), b"data");
let error = session
.get_object()
.bucket(bucket)
.key("denied")
.send()
.await
.expect_err("object outside session policy");
assert_eq!(error.as_service_error().and_then(ProvideErrorMetadata::code), Some("AccessDenied"));
Ok(())
}
#[tokio::test]
async fn test_sts_query_responses_are_aws_sdk_compatible() -> TestResult {
init_logging();
+4
View File
@@ -29,6 +29,10 @@ documentation = "https://docs.rs/rustfs-ecstore/latest/rustfs_ecstore/"
[lints]
workspace = true
[[example]]
name = "internode_rpc_bench"
test = true
[features]
internode-transport = []
default = []
@@ -0,0 +1,866 @@
// Copyright 2026 RustFS Team
// SPDX-License-Identifier: Apache-2.0
//! Read-only authenticated ReadAll/Ping probe against explicitly prepared test disks.
//! Usage: internode_rpc_bench manifest.json output.json
//! Fixtures live under each test disk's .rustfs.sys/tmp/<fixture_id>/ directory.
use rustfs_ecstore::api::{
disk::{DiskAPI, DiskOption, DiskStore, endpoint::Endpoint, new_disk},
rpc::{TonicInterceptor, gen_tonic_signature_interceptor, node_service_time_out_client},
};
use rustfs_protos::proto_gen::node_service::PingRequest;
use rustfs_tls_runtime::{OutboundTlsMaterial, TlsGeneration, ensure_crypto_provider, publish_global_outbound_tls_state};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::BTreeSet;
use std::io::{Error, ErrorKind, Result, Write};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use tokio::io::AsyncReadExt;
use tokio::sync::Semaphore;
use tokio::task::JoinSet;
use tokio::time::{Instant, sleep, sleep_until, timeout};
use uuid::Uuid;
const MAX_PAYLOAD: usize = 8 * 1024 * 1024;
const MAX_BULK_ROWS: usize = 4_000_000;
const MAX_CONTROL_ROWS: u32 = 1_000_000;
#[derive(Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct Config {
namespace: String,
targets: Vec<String>,
disk_ids: Option<Vec<Uuid>>,
fixture_id: Uuid,
fixture_file: PathBuf,
duration_secs: u32,
bulk_workers: usize,
control_rps: u32,
rpc_timeout_ms: u32,
tls_ca: Option<PathBuf>,
start_marker: Option<PathBuf>,
end_marker: Option<PathBuf>,
}
impl Config {
fn endpoints(&self) -> Result<Vec<Endpoint>> {
let valid_namespace = self.namespace.starts_with("grpc-bench-")
&& (12..=64).contains(&self.namespace.len())
&& self
.namespace
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-');
if !valid_namespace || self.fixture_id.is_nil() || !(1..=64).contains(&self.targets.len()) {
return Err(Error::new(ErrorKind::InvalidInput, "invalid test namespace, fixture ID or target count"));
}
if self
.disk_ids
.as_ref()
.is_some_and(|ids| ids.len() != self.targets.len() || ids.iter().any(Uuid::is_nil))
{
return Err(Error::new(
ErrorKind::InvalidInput,
"disk IDs must match every declared target and be non-nil",
));
}
if !(1..=1800).contains(&self.duration_secs)
|| !(1..=128).contains(&self.bulk_workers)
|| !(1..=10_000).contains(&self.control_rps)
|| self
.duration_secs
.checked_mul(self.control_rps)
.is_none_or(|count| count > MAX_CONTROL_ROWS)
|| !(1..=30_000).contains(&self.rpc_timeout_ms)
{
return Err(Error::new(ErrorKind::InvalidInput, "workload exceeds the probe's resource bounds"));
}
if self.start_marker.is_some() != self.end_marker.is_some()
|| self.start_marker == self.end_marker && self.start_marker.is_some()
{
return Err(Error::new(ErrorKind::InvalidInput, "provide distinct start/end markers or neither"));
}
let mut seen = BTreeSet::new();
self.targets
.iter()
.map(|target| {
let mut endpoint = Endpoint::try_from(target.as_str())
.map_err(|_| Error::new(ErrorKind::InvalidInput, "invalid test endpoint URL"))?;
let url = &endpoint.url;
if target.len() > 1024
|| !matches!(url.scheme(), "http" | "https")
|| url.host_str().is_none()
|| url.port().is_none_or(|port| port < 1024)
|| !url.username().is_empty()
|| url.password().is_some()
|| url.query().is_some()
|| url.fragment().is_some()
|| url.path().contains('%')
|| !url.path().ends_with(&format!("/{}", self.namespace))
|| !seen.insert(url.to_string())
|| url.scheme() == "https" && self.tls_ca.is_none()
{
return Err(Error::new(ErrorKind::InvalidInput, "target must name an explicit test-disk URL"));
}
// This probe never constructs a local disk or invokes its startup cleanup.
endpoint.is_local = false;
Ok(endpoint)
})
.collect()
}
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Marker {
time_ns: u64,
}
#[derive(Serialize)]
struct Sample {
operation: &'static str,
peer: usize,
scheduled_ns: u64,
dispatched_ns: Option<u64>,
completed_ns: u64,
outcome: &'static str,
bytes: usize,
}
#[derive(Serialize)]
struct Report {
complete: bool,
errors: usize,
manifest_sha256: String,
fixture_sha256: String,
start_unix_ns: String,
observation_end_unix_ns: String,
offered_duration_secs: u32,
control_rps: u32,
bulk_bytes_completed_during_offering: u64,
bulk_coverage_verified: bool,
bulk_workers: usize,
control_expected: u32,
targets: usize,
peers: usize,
samples: Vec<Sample>,
external_markers: bool,
scope: &'static str,
}
fn now_ns() -> Result<u64> {
u64::try_from(SystemTime::now().duration_since(UNIX_EPOCH).map_err(Error::other)?.as_nanos()).map_err(Error::other)
}
fn elapsed_ns(origin: Instant) -> Result<u64> {
u64::try_from(origin.elapsed().as_nanos()).map_err(Error::other)
}
fn measurement_origin(start_ns: u64, current_ns: u64, monotonic_now: Instant) -> Result<Instant> {
let lag = current_ns
.checked_sub(start_ns)
.filter(|lag| *lag <= 1_000_000_000)
.ok_or_else(|| Error::other("measurement start must be within the preceding second"))?;
monotonic_now
.checked_sub(Duration::from_nanos(lag))
.ok_or_else(|| Error::other("invalid monotonic origin"))
}
fn validate_observation_end(start_ns: u64, end_ns: u64, finished_ns: u64, current_ns: u64, elapsed: u64) -> Result<()> {
let clock_end = start_ns
.checked_add(elapsed)
.ok_or_else(|| Error::other("observation window overflow"))?;
if end_ns < finished_ns || end_ns > current_ns || clock_end.abs_diff(current_ns) > 100_000_000 {
return Err(Error::other("invalid measurement-end acknowledgement or clock drift"));
}
Ok(())
}
async fn bounded_file(path: &Path, limit: usize) -> Result<Vec<u8>> {
let file = tokio::fs::File::open(path).await?;
if !file.metadata().await?.is_file() {
return Err(Error::new(ErrorKind::InvalidInput, "probe inputs must be regular files"));
}
let mut data = Vec::new();
file.take(u64::try_from(limit).map_err(Error::other)? + 1)
.read_to_end(&mut data)
.await?;
if data.len() > limit {
return Err(Error::new(ErrorKind::InvalidData, "probe input exceeds its size bound"));
}
Ok(data)
}
async fn marker(path: &Path) -> Result<u64> {
timeout(Duration::from_secs(30), async {
loop {
match bounded_file(path, 512).await {
Ok(data) => {
return serde_json::from_slice::<Marker>(&data)
.map(|value| value.time_ns)
.map_err(Error::other);
}
Err(error) if error.kind() == ErrorKind::NotFound => sleep(Duration::from_millis(5)).await,
Err(error) => return Err(error),
}
}
})
.await
.map_err(|_| Error::new(ErrorKind::TimedOut, "measurement marker did not arrive"))?
}
fn phase(name: &str, time_ns: u64) -> Result<()> {
println!(
"{}",
serde_json::json!({"phase":name,"time_ns":time_ns.to_string(),"pid":std::process::id()})
);
std::io::stdout().flush()
}
async fn ping(peer: &str) -> Result<(u64, bytes::Bytes)> {
let mut client = node_service_time_out_client(peer, TonicInterceptor::Signature(gen_tonic_signature_interceptor()))
.await
.map_err(|_| Error::other("authenticated control client acquisition failed"))?
.max_decoding_message_size(4096);
let response = client
.ping(PingRequest {
version: 1,
body: bytes::Bytes::new(),
})
.await
.map_err(|_| Error::other("authenticated Ping failed"))?
.into_inner();
Ok((response.version, response.body))
}
struct Context {
config: Config,
disks: Vec<DiskStore>,
peers: Vec<String>,
echoes: Vec<bytes::Bytes>,
payload: Vec<u8>,
path: String,
}
async fn bulk(context: Arc<Context>, worker: usize, origin: Instant, end: Instant) -> Result<Vec<Sample>> {
let mut rows = Vec::with_capacity(256);
let limit = MAX_BULK_ROWS / context.config.bulk_workers;
for sequence in 0..limit {
if Instant::now() >= end {
return Ok(rows);
}
let peer = (worker + sequence) % context.disks.len();
let dispatched = elapsed_ns(origin)?;
let result = timeout(
Duration::from_millis(u64::from(context.config.rpc_timeout_ms)),
context.disks[peer].read_all(".rustfs.sys", &context.path),
)
.await;
let completed = elapsed_ns(origin)?;
let outcome = match result {
Ok(Ok(data)) if data.as_ref() == context.payload.as_slice() => "ok",
Ok(Ok(_)) => "payload_mismatch",
Ok(Err(_)) => "rpc_error",
Err(_) => "deadline",
};
rows.push(Sample {
operation: "ReadAll",
peer,
scheduled_ns: dispatched,
dispatched_ns: Some(dispatched),
completed_ns: completed,
outcome,
bytes: if outcome == "ok" { context.payload.len() } else { 0 },
});
}
if Instant::now() >= end {
Ok(rows)
} else {
Err(Error::other("bulk raw-sample limit reached; attribution is invalid"))
}
}
async fn control(context: Arc<Context>, origin: Instant) -> Result<Vec<Sample>> {
let count = context.config.duration_secs * context.config.control_rps;
let capacity = usize::try_from(count).map_err(Error::other)?;
let mut rows = Vec::with_capacity(capacity);
let permits = Arc::new(Semaphore::new(128));
let end = origin + Duration::from_secs(u64::from(context.config.duration_secs));
let mut tasks = JoinSet::new();
for sequence in 0..count {
// Configuration bounds count to one million, so this product fits u64.
let scheduled = u64::from(sequence) * 1_000_000_000 / u64::from(context.config.control_rps);
sleep_until(origin + Duration::from_nanos(scheduled)).await;
while let Some(result) = tasks.try_join_next() {
rows.push(result.map_err(Error::other)??);
}
let peer = usize::try_from(sequence).map_err(Error::other)? % context.peers.len();
let permit = (Instant::now() < end)
.then(|| permits.clone().try_acquire_owned())
.and_then(std::result::Result::ok);
let Some(permit) = permit else {
rows.push(Sample {
operation: "Ping",
peer,
scheduled_ns: scheduled,
dispatched_ns: None,
completed_ns: elapsed_ns(origin)?,
outcome: "schedule_drop",
bytes: 0,
});
continue;
};
let context = context.clone();
tasks.spawn(async move {
let _permit = permit;
let dispatched = elapsed_ns(origin)?;
let result = timeout(
Duration::from_millis(u64::from(context.config.rpc_timeout_ms)),
ping(&context.peers[peer]),
)
.await;
let completed = elapsed_ns(origin)?;
let outcome = match result {
Ok(Ok((1, body))) if body == context.echoes[peer] => "ok",
Ok(Ok(_)) => "payload_mismatch",
Ok(Err(_)) => "rpc_error",
Err(_) => "deadline",
};
Ok::<_, Error>(Sample {
operation: "Ping",
peer,
scheduled_ns: scheduled,
dispatched_ns: Some(dispatched),
completed_ns: completed,
outcome,
bytes: 0,
})
});
}
while let Some(result) = tasks.join_next().await {
rows.push(result.map_err(Error::other)??);
}
if rows.len() != capacity {
return Err(Error::other("control arrival accounting is incomplete"));
}
Ok(rows)
}
async fn run(config_bytes: &[u8], base: &Path, secret: String) -> Result<Report> {
let mut config: Config = serde_json::from_slice(config_bytes).map_err(|_| Error::other("invalid probe manifest"))?;
if config.fixture_file.is_relative() {
config.fixture_file = base.join(&config.fixture_file);
}
for path in [&mut config.tls_ca, &mut config.start_marker, &mut config.end_marker]
.into_iter()
.flatten()
{
if path.is_relative() {
*path = base.join(&*path);
}
}
let endpoints = config.endpoints()?;
for path in [&config.start_marker, &config.end_marker].into_iter().flatten() {
if tokio::fs::try_exists(path).await? {
return Err(Error::other("measurement markers must be fresh"));
}
}
// Require the test deployment's explicit secret; never use a default credential.
if secret.trim().is_empty() {
return Err(Error::other("RUSTFS_RPC_SECRET is required"));
}
rustfs_credentials::set_global_rpc_secret(secret).map_err(|_| Error::other("RPC secret already initialized"))?;
ensure_crypto_provider().map_err(Error::other)?;
if let Some(path) = &config.tls_ca {
let ca = bounded_file(path, 1024 * 1024).await?;
if ca.is_empty() {
return Err(Error::other("the explicit test CA must not be empty"));
}
publish_global_outbound_tls_state(
TlsGeneration(1),
&OutboundTlsMaterial {
root_ca_pem: ca,
mtls_identity: None,
},
)
.await;
}
let payload = bounded_file(&config.fixture_file, MAX_PAYLOAD).await?;
if payload.is_empty()
|| payload
.len()
.checked_mul(config.bulk_workers)
.is_none_or(|bytes| bytes > 512 * 1024 * 1024)
{
return Err(Error::other("fixture and worker count exceed the payload memory budget"));
}
let path = format!("tmp/{}/grpc-bench-payload.bin", config.fixture_id);
let mut disks = Vec::with_capacity(endpoints.len());
let mut peers = BTreeSet::new();
for (index, endpoint) in endpoints.into_iter().enumerate() {
peers.insert(endpoint.grid_host());
let disk = new_disk(
&endpoint,
&DiskOption {
cleanup: false,
health_check: true,
},
)
.await
.map_err(Error::other)?;
if let Some(ids) = &config.disk_ids {
disk.set_disk_id(Some(ids[index])).await.map_err(Error::other)?;
}
let data = timeout(Duration::from_secs(30), disk.read_all(".rustfs.sys", &path))
.await
.map_err(Error::other)?
.map_err(|error| Error::other(format!("ReadAll fixture preflight failed: disk_error_code={}", error.to_u32())))?;
if data.as_ref() != payload.as_slice() {
return Err(Error::other("ReadAll fixture preflight mismatch"));
}
disks.push(disk);
}
let peers: Vec<_> = peers.into_iter().collect();
let mut echoes = Vec::with_capacity(peers.len());
for peer in &peers {
let (version, body) = timeout(Duration::from_secs(30), ping(peer)).await.map_err(Error::other)??;
if version != 1 || body.is_empty() {
return Err(Error::other("Ping preflight mismatch"));
}
echoes.push(body);
}
let context = Arc::new(Context {
config,
disks,
peers,
echoes,
payload,
path,
});
phase("ready", now_ns()?)?;
let start_ns = match &context.config.start_marker {
Some(path) => marker(path).await?,
None => now_ns()?,
};
let current_ns = now_ns()?;
let origin = measurement_origin(start_ns, current_ns, Instant::now())?;
let offered = Duration::from_secs(u64::from(context.config.duration_secs));
let end = origin + offered;
phase("measure_start", start_ns)?;
let mut tasks = JoinSet::new();
for worker in 0..context.config.bulk_workers {
tasks.spawn(bulk(context.clone(), worker, origin, end));
}
tasks.spawn(control(context.clone(), origin));
let mut samples = Vec::new();
while let Some(result) = tasks.join_next().await {
samples.extend(result.map_err(Error::other)??);
}
let finished_ns = now_ns()?;
phase("load_finished", finished_ns)?;
let end_ns = match &context.config.end_marker {
Some(path) => marker(path).await?,
None => now_ns()?,
};
let current_ns = now_ns()?;
validate_observation_end(start_ns, end_ns, finished_ns, current_ns, elapsed_ns(origin)?)?;
let offered_ns = u64::try_from(offered.as_nanos()).map_err(Error::other)?;
let errors = samples.iter().filter(|sample| sample.outcome != "ok").count();
let measured_bytes = samples
.iter()
.filter(|sample| sample.operation == "ReadAll" && sample.outcome == "ok" && sample.completed_ns <= offered_ns)
.try_fold(0_u64, |total, sample| {
total
.checked_add(u64::try_from(sample.bytes).map_err(Error::other)?)
.ok_or_else(|| Error::other("measured payload byte counter overflow"))
})?;
let report = Report {
complete: true,
errors,
manifest_sha256: hex_simd::encode_to_string(Sha256::digest(config_bytes), hex_simd::AsciiCase::Lower),
fixture_sha256: hex_simd::encode_to_string(Sha256::digest(&context.payload), hex_simd::AsciiCase::Lower),
start_unix_ns: start_ns.to_string(),
observation_end_unix_ns: end_ns.to_string(),
offered_duration_secs: context.config.duration_secs,
control_rps: context.config.control_rps,
bulk_bytes_completed_during_offering: measured_bytes,
bulk_coverage_verified: measured_bytes > 0,
bulk_workers: context.config.bulk_workers,
control_expected: context.config.duration_secs * context.config.control_rps,
targets: context.disks.len(),
peers: context.peers.len(),
samples,
external_markers: context.config.start_marker.is_some(),
scope: "Authenticated production RPC clients and ReadAll temporary fixtures; client telemetry export is disabled. Storage S3/durability, mTLS/rotation and ABBA qualification remain separate.",
};
for disk in &context.disks {
disk.close().await.map_err(Error::other)?;
}
phase("measure_end", end_ns)?;
Ok(report)
}
#[tokio::main(worker_threads = 8)]
async fn main() -> Result<()> {
let mut args = std::env::args_os().skip(1);
let config = args
.next()
.map(PathBuf::from)
.ok_or_else(|| Error::other("usage: internode_rpc_bench manifest.json output.json"))?;
let output = args
.next()
.map(PathBuf::from)
.ok_or_else(|| Error::other("output path is required"))?;
if args.next().is_some() {
return Err(Error::other("unexpected probe arguments"));
}
let input = bounded_file(&config, 64 * 1024).await?;
let file = tokio::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(output)
.await?
.into_std()
.await;
let secret = std::env::var("RUSTFS_RPC_SECRET").map_err(|_| Error::other("RUSTFS_RPC_SECRET is required"))?;
let report = run(&input, config.parent().unwrap_or_else(|| Path::new(".")), secret).await?;
let success = report.errors == 0 && report.bulk_coverage_verified;
tokio::task::spawn_blocking(move || {
let mut output = std::io::BufWriter::new(file);
serde_json::to_writer(&mut output, &report).map_err(Error::other)?;
output.flush()
})
.await
.map_err(Error::other)??;
if success {
Ok(())
} else {
Err(Error::other(
"RPC failures or missing bulk coverage invalidate attribution; see raw samples",
))
}
}
#[cfg(test)]
mod tests {
use super::*;
use rustfs_protos::proto_gen::node_service::{PingResponse, ReadAllRequest, ReadAllResponse};
use std::convert::Infallible;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::task::{Context as TaskContext, Poll};
use tonic::codegen::{Body, BoxFuture, Service, StdError, http};
fn config() -> Config {
Config {
namespace: "grpc-bench-test".into(),
targets: vec!["http://127.0.0.1:19000/data/grpc-bench-test".into()],
disk_ids: None,
fixture_id: Uuid::from_u128(1),
fixture_file: PathBuf::from("fixture.bin"),
duration_secs: 1,
bulk_workers: 2,
control_rps: 20,
rpc_timeout_ms: 1000,
tls_ca: None,
start_marker: None,
end_marker: None,
}
}
#[test]
fn probe_rejects_non_test_and_credential_bearing_targets() {
for target in [
"file:///data/grpc-bench-test",
"http://user:private@127.0.0.1:19000/data/grpc-bench-test",
"http://127.0.0.1:19000/data/mnmd",
"http://127.0.0.1:80/data/grpc-bench-test",
"http://127.0.0.1:19000/data/grpc-bench-test?token=private",
"http://127.0.0.1:19000/data/%67rpc-bench-test",
"https://127.0.0.1:19000/data/grpc-bench-test",
] {
let mut config = config();
config.targets = vec![target.into()];
assert!(config.endpoints().is_err(), "unsafe target was accepted");
}
}
#[test]
fn probe_forces_remote_client_and_rejects_duplicate_disks() {
let mut config = config();
assert!(!config.endpoints().expect("valid loopback test URL")[0].is_local);
config.targets.push(config.targets[0].clone());
assert!(config.endpoints().is_err(), "duplicate target must not inflate the inventory");
}
#[test]
fn probe_requires_a_complete_non_nil_disk_id_mapping() {
let mut config = config();
config.disk_ids = Some(vec![]);
assert!(config.endpoints().is_err());
config.disk_ids = Some(vec![Uuid::nil()]);
assert!(config.endpoints().is_err());
config.disk_ids = Some(vec![Uuid::from_u128(42)]);
assert!(config.endpoints().is_ok());
}
#[test]
fn probe_bounds_work_and_requires_distinct_measurement_markers() {
for (seconds, workers, rate, deadline) in [
(0, 2, 20, 1000),
(1, 0, 20, 1000),
(1, 129, 20, 1000),
(1800, 2, 10_000, 1000),
(1, 2, 0, 1000),
(1, 2, 20, 0),
] {
let mut config = config();
config.duration_secs = seconds;
config.bulk_workers = workers;
config.control_rps = rate;
config.rpc_timeout_ms = deadline;
assert!(config.endpoints().is_err());
}
let mut config = config();
config.start_marker = Some("marker.json".into());
assert!(config.endpoints().is_err());
config.end_marker = config.start_marker.clone();
assert!(config.endpoints().is_err());
}
#[test]
fn probe_rejects_stale_future_or_truncated_measurement_markers() {
let now = Instant::now();
assert_eq!(
measurement_origin(10_000_000_000, 11_000_000_000, now).expect("one-second lag bound"),
now - Duration::from_secs(1)
);
assert!(measurement_origin(10_000_000_000, 11_000_000_001, now).is_err());
assert!(measurement_origin(11_000_000_001, 11_000_000_000, now).is_err());
assert!(validate_observation_end(100, 200, 200, 250, 150).is_ok());
assert!(validate_observation_end(100, 199, 200, 250, 150).is_err());
assert!(validate_observation_end(100, 251, 200, 250, 150).is_err());
assert!(validate_observation_end(100, 200, 200, 250_000_000, 150).is_err());
}
#[tokio::test]
async fn probe_marker_input_has_an_explicit_schema_and_size_bound() {
let directory = tempfile::tempdir().expect("marker directory");
let path = directory.path().join("marker.json");
tokio::fs::write(&path, br#"{"time_ns":100}"#).await.expect("marker");
assert_eq!(marker(&path).await.expect("valid marker"), 100);
tokio::fs::write(&path, br#"{"time_ns":100,"unknown":true}"#)
.await
.expect("invalid marker");
assert!(marker(&path).await.is_err());
tokio::fs::write(&path, vec![b' '; 513]).await.expect("oversized marker");
assert!(marker(&path).await.is_err());
}
#[tokio::test]
async fn probe_bounds_fixture_reads_and_rejects_directories() {
let directory = tempfile::tempdir().expect("temporary fixture directory");
let path = directory.path().join("fixture.bin");
tokio::fs::write(&path, [7_u8; 32]).await.expect("fixture write");
assert_eq!(bounded_file(&path, 32).await.expect("exact-size fixture"), vec![7_u8; 32]);
tokio::fs::write(&path, [7_u8; 33]).await.expect("oversized fixture write");
assert!(bounded_file(&path, 32).await.is_err());
assert!(bounded_file(directory.path(), 32).await.is_err());
}
#[tokio::test]
async fn probe_accounts_for_expired_control_arrivals_without_sending_them() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("owned control socket");
let context = Arc::new(super::Context {
config: config(),
disks: vec![],
peers: vec![format!("http://{}", listener.local_addr().expect("owned address"))],
echoes: vec![bytes::Bytes::from_static(b"hello")],
payload: vec![],
path: String::new(),
});
let rows = control(context, Instant::now() - Duration::from_secs(2))
.await
.expect("arrival accounting");
assert_eq!(rows.len(), 20);
assert!(
rows.iter()
.all(|row| row.outcome == "schedule_drop" && row.dispatched_ns.is_none())
);
}
#[derive(Clone)]
struct ReadFixture(bytes::Bytes, Arc<AtomicBool>);
impl tonic::server::UnaryService<ReadAllRequest> for ReadFixture {
type Response = ReadAllResponse;
type Future = BoxFuture<tonic::Response<Self::Response>, tonic::Status>;
fn call(&mut self, request: tonic::Request<ReadAllRequest>) -> Self::Future {
assert_eq!(request.get_ref().volume, ".rustfs.sys");
assert_eq!(request.get_ref().disk, Uuid::from_u128(42).to_string());
assert!(request.get_ref().path.ends_with("/grpc-bench-payload.bin"));
let data = if self.1.load(Ordering::SeqCst) {
bytes::Bytes::from_static(b"corrupt fixture")
} else {
self.0.clone()
};
Box::pin(async move {
Ok(tonic::Response::new(ReadAllResponse {
success: true,
data,
error: None,
}))
})
}
}
#[derive(Clone)]
struct ReplyPing;
impl tonic::server::UnaryService<PingRequest> for ReplyPing {
type Response = PingResponse;
type Future = BoxFuture<tonic::Response<Self::Response>, tonic::Status>;
fn call(&mut self, _: tonic::Request<PingRequest>) -> Self::Future {
Box::pin(async {
Ok(tonic::Response::new(PingResponse {
version: 1,
body: bytes::Bytes::from_static(b"hello, caller"),
}))
})
}
}
#[derive(Clone)]
struct FixtureService {
data: bytes::Bytes,
signed: Arc<AtomicUsize>,
corrupt: Arc<AtomicBool>,
}
impl tonic::server::NamedService for FixtureService {
const NAME: &'static str = "node_service.NodeService";
}
impl<B> Service<http::Request<B>> for FixtureService
where
B: Body + Send + 'static,
B::Error: Into<StdError> + Send + 'static,
{
type Response = http::Response<tonic::body::Body>;
type Error = Infallible;
type Future = BoxFuture<Self::Response, Self::Error>;
fn poll_ready(&mut self, _: &mut TaskContext<'_>) -> Poll<std::result::Result<(), Self::Error>> {
Poll::Ready(Ok(()))
}
fn call(&mut self, request: http::Request<B>) -> Self::Future {
assert_eq!(
request
.headers()
.get("x-rustfs-rpc-auth-version")
.expect("signed production client"),
"2"
);
assert!(request.headers().contains_key("x-rustfs-rpc-signature-v2"));
self.signed.fetch_add(1, Ordering::Relaxed);
let data = self.data.clone();
let corrupt = self.corrupt.clone();
Box::pin(async move {
if request.uri().path().ends_with("/ReadAll") {
Ok(tonic::server::Grpc::new(tonic_prost::ProstCodec::default())
.unary(ReadFixture(data, corrupt), request)
.await)
} else {
assert!(request.uri().path().ends_with("/Ping"));
Ok(tonic::server::Grpc::new(tonic_prost::ProstCodec::default())
.unary(ReplyPing, request)
.await)
}
})
}
}
#[tokio::test]
async fn probe_exercises_production_readall_and_fixed_rate_ping_clients() {
let directory = tempfile::tempdir().expect("probe fixtures");
let payload = vec![17_u8; 4096];
let file = directory.path().join("fixture.bin");
tokio::fs::write(&file, &payload).await.expect("fixture bytes");
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("owned RPC listener");
let address = listener.local_addr().expect("owned RPC address");
let incoming = futures_util::stream::unfold(listener, |listener| async move {
Some((listener.accept().await.map(|(stream, _)| stream), listener))
});
let (stop, stopped) = tokio::sync::oneshot::channel();
let signed = Arc::new(AtomicUsize::new(0));
let corrupt = Arc::new(AtomicBool::new(false));
let service = FixtureService {
data: payload.into(),
signed: signed.clone(),
corrupt: corrupt.clone(),
};
let mut server = tokio::spawn(
tonic::transport::Server::builder()
.add_service(service)
.serve_with_incoming_shutdown(Box::pin(incoming), async {
let _ = stopped.await;
}),
);
let mut config = config();
config.targets = vec![format!("http://{address}/data/grpc-bench-test")];
config.disk_ids = Some(vec![Uuid::from_u128(42)]);
config.fixture_file = file;
let input = serde_json::to_vec(&config).expect("probe manifest");
let result = run(&input, directory.path(), "test-bench-rpc-secret".into()).await;
let negative = async {
let endpoint = config.endpoints()?.remove(0);
let disk = new_disk(
&endpoint,
&DiskOption {
cleanup: false,
health_check: true,
},
)
.await
.map_err(Error::other)?;
disk.set_disk_id(Some(Uuid::from_u128(42))).await.map_err(Error::other)?;
corrupt.store(true, Ordering::SeqCst);
let context = Arc::new(super::Context {
path: format!("tmp/{}/grpc-bench-payload.bin", config.fixture_id),
config: config.clone(),
disks: vec![disk.clone()],
peers: vec![],
echoes: vec![],
payload: tokio::fs::read(&config.fixture_file).await?,
});
let origin = Instant::now();
let rows = bulk(context, 0, origin, origin + Duration::from_millis(50)).await?;
disk.close().await.map_err(Error::other)?;
Ok::<_, Error>(rows)
}
.await;
let _ = stop.send(());
let retirement = timeout(Duration::from_secs(5), &mut server).await;
if retirement.is_err() {
server.abort();
let _ = server.await;
}
assert!(matches!(retirement, Ok(Ok(Ok(())))), "owned RPC fixture must retire successfully");
let result = result.expect("production client probe");
assert!(result.bulk_coverage_verified);
let report = serde_json::to_value(result).expect("raw sample report");
assert_eq!(report["errors"], 0);
assert_eq!(report["control_expected"], 20);
let rows = report["samples"].as_array().expect("raw rows");
assert_eq!(rows.iter().filter(|row| row["operation"] == "Ping").count(), 20);
assert!(rows.iter().any(|row| row["operation"] == "ReadAll"));
assert!(signed.load(Ordering::Relaxed) > 20);
let rejected = negative.expect("corrupted-response probe");
assert!(!rejected.is_empty());
assert!(rejected.iter().all(|row| row.outcome == "payload_mismatch" && row.bytes == 0));
}
}
+1 -1
View File
@@ -580,7 +580,7 @@ mod tests {
let first: Error = make_error("buckets/first/.metadata.bin".into()).into();
let second: Error = make_error("buckets/second/.metadata.bin".into()).into();
assert_eq!(first, second, "record paths must not fragment error grouping");
assert_eq!(first.clone(), second, "cloning must preserve error grouping");
assert_eq!(first, second, "cloning must preserve error grouping");
let io_error = std::io::Error::from(first);
let detail = io_error
+18 -19
View File
@@ -54,6 +54,10 @@ use bytes::Bytes;
use futures::lock::Mutex;
use metrics::counter;
use rustfs_filemeta::{FileInfo, ObjectPartInfo, RawFileInfo};
use rustfs_io_metrics::internode_metrics::{
INTERNODE_OPERATION_GRPC_BATCH_READ_VERSION, INTERNODE_OPERATION_GRPC_READ_ALL, INTERNODE_OPERATION_GRPC_READ_MULTIPLE,
INTERNODE_OPERATION_GRPC_WRITE_ALL, measure_client_rpc,
};
use rustfs_io_metrics::internode_metrics::{
INTERNODE_STAGE_BATCH_READ_VERSION_REQUEST_ENCODE, INTERNODE_STAGE_BATCH_READ_VERSION_RESPONSE_DECODE,
INTERNODE_STAGE_BATCH_READ_VERSION_RPC_ROUNDTRIP, INTERNODE_STAGE_READ_VERSION_REQUEST_ENCODE,
@@ -3737,13 +3741,17 @@ impl DiskAPI for RemoteDisk {
}
Err(err) => {
record_read_version_stage(INTERNODE_STAGE_READ_VERSION_RPC_ROUNDTRIP, rpc_started);
crate::cluster::rpc::runtime_sources::record_remote_disk_grpc_read_version_error();
crate::cluster::rpc::runtime_sources::record_remote_disk_grpc_read_version_error(
crate::cluster::rpc::runtime_sources::ReadVersionErrorStage::RpcStatus,
);
return Err(err.into());
}
};
if !response.success {
crate::cluster::rpc::runtime_sources::record_remote_disk_grpc_read_version_error();
crate::cluster::rpc::runtime_sources::record_remote_disk_grpc_read_version_error(
crate::cluster::rpc::runtime_sources::ReadVersionErrorStage::RemoteResponse,
);
return Err(response.error.unwrap_or_default().into());
}
@@ -3762,7 +3770,9 @@ impl DiskAPI for RemoteDisk {
}
Err(err) => {
record_read_version_stage(INTERNODE_STAGE_READ_VERSION_RESPONSE_DECODE, decode_started);
crate::cluster::rpc::runtime_sources::record_remote_disk_grpc_read_version_error();
crate::cluster::rpc::runtime_sources::record_remote_disk_grpc_read_version_error(
crate::cluster::rpc::runtime_sources::ReadVersionErrorStage::ResponseDecode,
);
return Err(err);
}
};
@@ -3824,12 +3834,8 @@ impl DiskAPI for RemoteDisk {
);
}
let rpc_started = read_version_stage_timer(batch_read_version_attribution_enabled);
let response_result = client.batch_read_version(request).await;
if let Some(rpc_started) = rpc_started {
crate::cluster::rpc::runtime_sources::record_remote_disk_grpc_batch_read_version_duration(
rpc_started.elapsed(),
);
}
let response_result =
measure_client_rpc(INTERNODE_OPERATION_GRPC_BATCH_READ_VERSION, client.batch_read_version(request)).await;
let response = match response_result {
Ok(response) => {
record_batch_read_version_stage(INTERNODE_STAGE_BATCH_READ_VERSION_RPC_ROUNDTRIP, rpc_started);
@@ -4855,9 +4861,7 @@ impl DiskAPI for RemoteDisk {
read_multiple_req_bin: read_multiple_req_bin.into(),
});
let rpc_started = Instant::now();
let response = client.read_multiple(request).await;
crate::cluster::rpc::runtime_sources::record_remote_disk_grpc_read_multiple_duration(rpc_started.elapsed());
let response = measure_client_rpc(INTERNODE_OPERATION_GRPC_READ_MULTIPLE, client.read_multiple(request)).await;
let response = response?.into_inner();
if !response.success {
@@ -4909,8 +4913,7 @@ impl DiskAPI for RemoteDisk {
attach_mutation_body_digest(&mut request, canonical_body, "write_all")?;
crate::cluster::rpc::runtime_sources::record_remote_disk_grpc_write_all_request();
let rpc_started = Instant::now();
let response = match client.write_all(request).await {
let response = match measure_client_rpc(INTERNODE_OPERATION_GRPC_WRITE_ALL, client.write_all(request)).await {
Ok(response) => response.into_inner(),
Err(err) => {
crate::cluster::rpc::runtime_sources::record_remote_disk_grpc_write_all_error();
@@ -4918,7 +4921,6 @@ impl DiskAPI for RemoteDisk {
}
};
crate::cluster::rpc::runtime_sources::record_remote_disk_grpc_write_all_duration(rpc_started.elapsed());
crate::cluster::rpc::runtime_sources::record_remote_disk_grpc_write_all_sent_bytes(data_len);
if !response.success {
@@ -5016,8 +5018,7 @@ impl DiskAPI for RemoteDisk {
});
crate::cluster::rpc::runtime_sources::record_remote_disk_grpc_read_all_request();
let rpc_started = Instant::now();
let response = match client.read_all(request).await {
let response = match measure_client_rpc(INTERNODE_OPERATION_GRPC_READ_ALL, client.read_all(request)).await {
Ok(response) => response.into_inner(),
Err(err) => {
crate::cluster::rpc::runtime_sources::record_remote_disk_grpc_read_all_error();
@@ -5025,8 +5026,6 @@ impl DiskAPI for RemoteDisk {
}
};
crate::cluster::rpc::runtime_sources::record_remote_disk_grpc_read_all_duration(rpc_started.elapsed());
if !response.success {
crate::cluster::rpc::runtime_sources::record_remote_disk_grpc_read_all_error();
return Err(response.error.unwrap_or_default().into());
@@ -481,14 +481,6 @@ pub(crate) fn record_remote_disk_grpc_write_all_request() {
.record_outgoing_request_for_operation_and_backend(INTERNODE_OPERATION_GRPC_WRITE_ALL, INTERNODE_TRANSPORT_BACKEND_GRPC);
}
pub(crate) fn record_remote_disk_grpc_write_all_duration(duration: Duration) {
global_internode_metrics().record_duration_for_operation_and_backend(
INTERNODE_OPERATION_GRPC_WRITE_ALL,
INTERNODE_TRANSPORT_BACKEND_GRPC,
duration,
);
}
pub(crate) fn record_remote_disk_grpc_write_all_sent_bytes(bytes: usize) {
global_internode_metrics().record_sent_bytes_for_operation_and_backend(
INTERNODE_OPERATION_GRPC_WRITE_ALL,
@@ -529,14 +521,6 @@ pub(crate) fn record_remote_disk_grpc_read_all_request() {
.record_outgoing_request_for_operation_and_backend(INTERNODE_OPERATION_GRPC_READ_ALL, INTERNODE_TRANSPORT_BACKEND_GRPC);
}
pub(crate) fn record_remote_disk_grpc_read_all_duration(duration: Duration) {
global_internode_metrics().record_duration_for_operation_and_backend(
INTERNODE_OPERATION_GRPC_READ_ALL,
INTERNODE_TRANSPORT_BACKEND_GRPC,
duration,
);
}
#[cfg(feature = "diagnostic-read-version-caller")]
fn read_version_request_mode(opts: &ReadOptions) -> ReadVersionRequestMode {
match (opts.read_data, opts.healing, opts.incl_free_versions) {
@@ -614,17 +598,6 @@ pub(crate) fn record_remote_disk_grpc_batch_read_version_stage(stage: &'static s
);
}
pub(crate) fn record_remote_disk_grpc_batch_read_version_duration(duration: Duration) {
if !rustfs_io_metrics::get_stage_metrics_enabled() {
return;
}
global_internode_metrics().record_duration_for_operation_and_backend(
INTERNODE_OPERATION_GRPC_BATCH_READ_VERSION,
INTERNODE_TRANSPORT_BACKEND_GRPC,
duration,
);
}
pub(crate) fn record_remote_disk_grpc_batch_read_version_error() {
if !rustfs_io_metrics::get_stage_metrics_enabled() {
return;
@@ -656,12 +629,34 @@ pub(crate) fn record_remote_disk_grpc_batch_read_version_recv_bytes(bytes: usize
record_grpc_payload_size(INTERNODE_OPERATION_GRPC_BATCH_READ_VERSION, bytes);
}
pub(crate) fn record_remote_disk_grpc_read_version_error() {
#[derive(Clone, Copy)]
pub(crate) enum ReadVersionErrorStage {
RpcStatus,
RemoteResponse,
ResponseDecode,
}
impl ReadVersionErrorStage {
fn classification(self) -> &'static str {
match self {
Self::RpcStatus => "grpc_status",
Self::RemoteResponse => "remote_response",
Self::ResponseDecode => "response_decode",
}
}
}
pub(crate) fn record_remote_disk_grpc_read_version_error(stage: ReadVersionErrorStage) {
if !rustfs_io_metrics::get_stage_metrics_enabled() {
return;
}
global_internode_metrics()
.record_error_for_operation_and_backend(INTERNODE_OPERATION_GRPC_READ_VERSION, INTERNODE_TRANSPORT_BACKEND_GRPC);
let metrics = global_internode_metrics();
metrics.record_error_for_operation_and_backend(INTERNODE_OPERATION_GRPC_READ_VERSION, INTERNODE_TRANSPORT_BACKEND_GRPC);
metrics.record_classified_error_for_operation_and_backend(
INTERNODE_OPERATION_GRPC_READ_VERSION,
INTERNODE_TRANSPORT_BACKEND_GRPC,
stage.classification(),
);
}
pub(crate) fn record_remote_disk_grpc_read_version_sent_bytes(bytes: usize) {
@@ -717,14 +712,6 @@ pub(crate) fn record_remote_disk_grpc_read_multiple_recv_bytes(bytes: usize) {
record_grpc_payload_size(INTERNODE_OPERATION_GRPC_READ_MULTIPLE, bytes);
}
pub(crate) fn record_remote_disk_grpc_read_multiple_duration(duration: Duration) {
global_internode_metrics().record_duration_for_operation_and_backend(
INTERNODE_OPERATION_GRPC_READ_MULTIPLE,
INTERNODE_TRANSPORT_BACKEND_GRPC,
duration,
);
}
/// Payload-size threshold (bytes) above which a unary internode gRPC response is counted as a
/// "large payload" for alerting. Env-overridable via `RUSTFS_INTERNODE_RPC_LARGE_PAYLOAD_WARN_BYTES`.
fn internode_rpc_large_payload_warn_bytes() -> usize {
@@ -804,6 +791,53 @@ mod tests {
#[cfg(feature = "diagnostic-read-version-caller")]
use super::*;
#[test]
fn read_version_errors_preserve_totals_and_classify_failure_boundaries() {
use super::{ReadVersionErrorStage, record_remote_disk_grpc_read_version_error};
use metrics_util::debugging::{DebugValue, DebuggingRecorder};
let recorder = DebuggingRecorder::new();
let snapshotter = recorder.snapshotter();
rustfs_io_metrics::set_get_stage_metrics_enabled(true);
metrics::with_local_recorder(&recorder, || {
for stage in [
ReadVersionErrorStage::RpcStatus,
ReadVersionErrorStage::RemoteResponse,
ReadVersionErrorStage::ResponseDecode,
] {
record_remote_disk_grpc_read_version_error(stage);
}
});
rustfs_io_metrics::set_get_stage_metrics_enabled(false);
let entries = snapshotter.snapshot().into_vec();
let total = entries
.iter()
.find(|entry| {
entry.0.key().name() == "rustfs_system_network_internode_operation_errors_total"
&& entry
.0
.key()
.labels()
.any(|label| label.key() == "operation" && label.value() == "grpc_read_version")
})
.expect("existing operation error counter");
assert!(matches!(total.3, DebugValue::Counter(3)));
for classification in ["grpc_status", "remote_response", "response_decode"] {
let count = entries
.iter()
.find(|entry| {
entry.0.key().name() == "rustfs_system_network_internode_operation_classified_errors_total"
&& entry
.0
.key()
.labels()
.any(|label| label.key() == "classification" && label.value() == classification)
})
.expect("bounded failure classification");
assert!(matches!(count.3, DebugValue::Counter(1)));
}
}
#[cfg(feature = "diagnostic-read-version-caller")]
fn diagnostic_operation(operation: rustfs_s3_ops::S3Operation) -> S3HttpOperationContext {
let mut request = rustfs_io_metrics::s3_http_metrics::S3HttpRequestGuard::new("GET");
+4 -4
View File
@@ -20945,12 +20945,12 @@ impl ECStore {
let locator = parse_decommission_durable_ilm_receipt_path(&receipt_path)?;
let mut proof = None::<DecommissionDurableIlmReceipt>;
let mut nonterminal_receipt_found = false;
for pool_idx in 0..self.pools.len() {
for (pool_idx, pool) in self.pools.iter().enumerate() {
if pool_idx == source_pool_idx {
continue;
}
let data = match read_config_limited_preserve_empty(
self.pools[pool_idx].clone(),
pool.clone(),
&receipt_path,
DECOMMISSION_DURABLE_ILM_RECEIPT_MAX_SIZE,
)
@@ -28413,7 +28413,7 @@ mod tests {
for wrapped in [
err.clone(),
data_movement::data_movement_stage_error_for_test("decommission_object", "put_object", "b", "o", err.clone()),
with_decommission_entry_context("migrate_object", "b", "o", err.clone()),
with_decommission_entry_context("migrate_object", "b", "o", err),
] {
assert_eq!(
classify_decommission_capacity_failure(&wrapped),
@@ -40281,7 +40281,7 @@ mod pools_tests {
)
.expect("finalize records the completion");
let mut adopted = meta.clone();
let mut adopted = meta;
let plan = decommission_capacity_intent_adoption_plan(&adopted, 0, generation)
.expect("plan adoption")
.expect("the interrupted completion should be planned");
+1 -1
View File
@@ -4242,7 +4242,7 @@ mod tests {
"compatible aliases for {suffix} should match"
);
let mut source_without_marker = source.clone();
let mut source_without_marker = source;
rustfs_utils::http::remove_str(Arc::make_mut(&mut source_without_marker.user_defined), suffix);
let mut target_with_generated_marker = source_without_marker.clone();
rustfs_utils::http::insert_str(
+2 -2
View File
@@ -1512,7 +1512,7 @@ async fn normalize_loaded_data_usage(
&& !cfg.role.is_empty()
{
data_usage_info.replication_info.insert(
cfg.role.clone(),
cfg.role,
BucketTargetUsageInfo {
replication_failed_size: bui.replication_failed_size_v1,
replication_failed_count: bui.replication_failed_count_v1,
@@ -1725,7 +1725,7 @@ fn merge_snapshot(aggregated: &mut DataUsageInfo, mut snapshot: LocalUsageSnapsh
match aggregated.buckets_usage.entry(bucket.clone()) {
Entry::Occupied(mut entry) => entry.get_mut().merge(&usage),
Entry::Vacant(entry) => {
entry.insert(usage.clone());
entry.insert(usage);
}
}
@@ -521,7 +521,7 @@ impl EndpointTopology {
}
};
topology.aliases.entry(host_port.clone()).or_insert(member_index);
topology.aliases.entry(host_port).or_insert(member_index);
topology.aliases.entry(ep.to_string()).or_insert(member_index);
if let Some(display_endpoint) = ep.url.host_str() {
match display_aliases.entry(display_endpoint.to_owned()) {
+1 -1
View File
@@ -1626,7 +1626,7 @@ mod tests {
for disk_error in disk_errors {
let original_message = disk_error.to_string();
let io_error: std::io::Error = disk_error.clone().into();
let io_error: std::io::Error = disk_error.into();
// The io::Error should contain the original error message
assert!(io_error.to_string().contains(&original_message));
File diff suppressed because it is too large Load Diff
+371 -86
View File
@@ -358,14 +358,26 @@ pub(in crate::disk) fn generation(mut canonical: FileInfo) -> Result<crate::disk
_ => {}
}
}
struct Sha256Writer(Sha256);
impl std::io::Write for Sha256Writer {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.0.update(buf);
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
let mut json_value = serde_json::to_value(&canonical)?;
sort_json_in_place(&mut json_value);
let mut hasher = Sha256Writer(Sha256::new());
serde_json::to_writer(&mut hasher, &json_value)?;
Ok(crate::disk::DeleteRecoveryVersionIdentity {
version_id: canonical.version_id,
mod_time: canonical.mod_time,
data_dir: canonical.data_dir,
deleted: canonical.deleted,
metadata_digest: digest(&serde_json::to_vec(&json_value)?),
metadata_digest: hasher.0.finalize().into(),
})
}
@@ -1174,6 +1186,7 @@ struct PreparedDeleteReceiptEntry {
sole_transaction: bool,
had_old_data_dir: bool,
rollback_dir_fd: Option<Arc<std::fs::File>>,
file_path_dir: Option<Arc<std::fs::File>>,
}
#[derive(Clone)]
@@ -1185,6 +1198,189 @@ struct PublishedDeleteReceiptEntry {
journal_raw: Vec<u8>,
had_old_data_dir: bool,
rollback_dir_fd: Option<Arc<std::fs::File>>,
file_path_dir: Option<Arc<std::fs::File>>,
}
#[cfg(unix)]
fn read_child_regular_bytes(
parent_fd: Option<&std::fs::File>,
child_name: &str,
fallback_path: &Path,
) -> std::io::Result<Vec<u8>> {
if let Some(dir) = parent_fd {
use rustix::fs::{Mode, OFlags, openat};
let fd = openat(dir, child_name, OFlags::RDONLY | OFlags::CLOEXEC, Mode::empty()).map_err(std::io::Error::from)?;
let mut file = std::fs::File::from(fd);
let cap = file
.metadata()
.ok()
.and_then(|m| usize::try_from(m.len()).ok())
.unwrap_or(0)
.min(64 * 1024);
let mut buf = Vec::with_capacity(cap);
std::io::Read::read_to_end(&mut file, &mut buf)?;
return Ok(buf);
}
std::fs::read(fallback_path)
}
#[cfg(not(unix))]
fn read_child_regular_bytes(
_parent_fd: Option<&std::fs::File>,
_child_name: &str,
fallback_path: &Path,
) -> std::io::Result<Vec<u8>> {
std::fs::read(fallback_path)
}
#[cfg(unix)]
fn create_and_write_child_file(
parent_fd: Option<&std::fs::File>,
child_name: &str,
fallback_path: &Path,
bytes: &[u8],
) -> std::io::Result<std::fs::File> {
let mut file = if let Some(dir) = parent_fd {
use rustix::fs::{Mode, OFlags, openat};
let fd = openat(
dir,
child_name,
OFlags::WRONLY | OFlags::CREATE | OFlags::TRUNC | OFlags::CLOEXEC,
Mode::from_raw_mode(0o666),
)
.map_err(std::io::Error::from)?;
std::fs::File::from(fd)
} else {
std::fs::File::create(fallback_path)?
};
std::io::Write::write_all(&mut file, bytes)?;
Ok(file)
}
#[cfg(not(unix))]
fn create_and_write_child_file(
_parent_fd: Option<&std::fs::File>,
_child_name: &str,
fallback_path: &Path,
bytes: &[u8],
) -> std::io::Result<std::fs::File> {
let mut file = std::fs::File::create(fallback_path)?;
std::io::Write::write_all(&mut file, bytes)?;
Ok(file)
}
#[cfg(unix)]
fn link_child_files(
src_dir_fd: Option<&std::fs::File>,
src_name: &str,
src_fallback: &Path,
dst_dir_fd: Option<&std::fs::File>,
dst_name: &str,
dst_fallback: &Path,
) -> std::io::Result<()> {
if let (Some(s_fd), Some(d_fd)) = (src_dir_fd, dst_dir_fd) {
return rustix::fs::linkat(s_fd, src_name, d_fd, dst_name, rustix::fs::AtFlags::empty()).map_err(std::io::Error::from);
}
std::fs::hard_link(src_fallback, dst_fallback)
}
#[cfg(not(unix))]
fn link_child_files(
_src_dir_fd: Option<&std::fs::File>,
_src_name: &str,
src_fallback: &Path,
_dst_dir_fd: Option<&std::fs::File>,
_dst_name: &str,
dst_fallback: &Path,
) -> std::io::Result<()> {
std::fs::hard_link(src_fallback, dst_fallback)
}
#[cfg(unix)]
fn link_from_dir_fd(
src_dir_fd: Option<&std::fs::File>,
src_name: &str,
src_fallback: &Path,
dst_path: &Path,
) -> std::io::Result<()> {
if let Some(s_fd) = src_dir_fd {
return rustix::fs::linkat(s_fd, src_name, rustix::fs::CWD, dst_path, rustix::fs::AtFlags::empty())
.map_err(std::io::Error::from);
}
std::fs::hard_link(src_fallback, dst_path)
}
#[cfg(not(unix))]
fn link_from_dir_fd(
_src_dir_fd: Option<&std::fs::File>,
_src_name: &str,
src_fallback: &Path,
dst_path: &Path,
) -> std::io::Result<()> {
std::fs::hard_link(src_fallback, dst_path)
}
#[cfg(unix)]
fn rename_into_dir_fd(
src_path: &Path,
dst_dir_fd: Option<&std::fs::File>,
dst_name: &str,
dst_fallback: &Path,
) -> std::io::Result<()> {
if let Some(d_fd) = dst_dir_fd {
return rustix::fs::renameat(rustix::fs::CWD, src_path, d_fd, dst_name).map_err(std::io::Error::from);
}
std::fs::rename(src_path, dst_fallback)
}
#[cfg(not(unix))]
fn rename_into_dir_fd(
src_path: &Path,
_dst_dir_fd: Option<&std::fs::File>,
_dst_name: &str,
dst_fallback: &Path,
) -> std::io::Result<()> {
std::fs::rename(src_path, dst_fallback)
}
#[cfg(unix)]
fn child_regular_file_exists(dir_fd: Option<&std::fs::File>, child_name: &str, fallback_path: &Path) -> bool {
if let Some(d_fd) = dir_fd {
return rustix::fs::statat(d_fd, child_name, rustix::fs::AtFlags::SYMLINK_NOFOLLOW)
.is_ok_and(|st| (st.st_mode & libc::S_IFMT) == libc::S_IFREG);
}
fallback_path.is_file()
}
#[cfg(not(unix))]
fn child_regular_file_exists(_dir_fd: Option<&std::fs::File>, _child_name: &str, fallback_path: &Path) -> bool {
fallback_path.is_file()
}
#[cfg(unix)]
fn unlink_child_file(dir_fd: Option<&std::fs::File>, child_name: &str, fallback_path: &Path) -> std::io::Result<()> {
if let Some(d_fd) = dir_fd {
return rustix::fs::unlinkat(d_fd, child_name, rustix::fs::AtFlags::empty()).map_err(std::io::Error::from);
}
std::fs::remove_file(fallback_path)
}
#[cfg(not(unix))]
fn unlink_child_file(_dir_fd: Option<&std::fs::File>, _child_name: &str, fallback_path: &Path) -> std::io::Result<()> {
std::fs::remove_file(fallback_path)
}
#[cfg(unix)]
fn rmdir_child_dir(dir_fd: Option<&std::fs::File>, child_name: &str, fallback_path: &Path) -> std::io::Result<()> {
if let Some(d_fd) = dir_fd {
return rustix::fs::unlinkat(d_fd, child_name, rustix::fs::AtFlags::REMOVEDIR).map_err(std::io::Error::from);
}
std::fs::remove_dir(fallback_path)
}
#[cfg(not(unix))]
fn rmdir_child_dir(_dir_fd: Option<&std::fs::File>, _child_name: &str, fallback_path: &Path) -> std::io::Result<()> {
std::fs::remove_dir(fallback_path)
}
const PREPARED_DELETE_RECEIPTS_PER_SHARD: usize = 64;
@@ -1211,6 +1407,21 @@ fn take_published_delete_receipt(rollback: &Path) -> Option<PublishedDeleteRecei
PUBLISHED_DELETE_RECEIPTS.remove(rollback)
}
pub(super) fn clear_delete_receipts_under(io_root: &Path, root: &Path) {
for prefix in [io_root, root] {
PREPARED_DELETE_RECEIPTS.retain_outside_prefix(prefix);
PUBLISHED_DELETE_RECEIPTS.retain_outside_prefix(prefix);
}
}
#[cfg(unix)]
fn open_dir_fd(path: &Path) -> std::io::Result<std::fs::File> {
use rustix::fs::{Mode, OFlags, open};
open(path, OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC, Mode::empty())
.map(std::fs::File::from)
.map_err(std::io::Error::from)
}
fn format_commit_receipt_bytes(journal_raw: &[u8], finished: bool) -> Vec<u8> {
let suffix: &[u8] = if finished {
b",\"finished\":true}"
@@ -2542,11 +2753,22 @@ impl LocalDisk {
let object_str = object.to_owned();
let recovery_scope = recovery_operation_scope();
type FusedStage1Outcome = Option<(Option<Uuid>, PathBuf, PreparedDeleteReceiptEntry)>;
let res = os::run_blocking_namespace_operation_with_sync(
lease,
sync,
move || -> std::io::Result<Result<Option<Option<Uuid>>>> {
let buf = match std::fs::read(&xl_path) {
move || -> std::io::Result<Result<FusedStage1Outcome>> {
#[cfg(unix)]
let file_path_dir = match open_dir_fd(&file_path) {
Ok(dir) => Some(Arc::new(dir)),
Err(err) if matches!(err.kind(), ErrorKind::NotFound | ErrorKind::NotADirectory) => {
return Ok(Ok(None));
}
Err(_) => None,
};
#[cfg(not(unix))]
let file_path_dir: Option<Arc<std::fs::File>> = None;
let buf = match read_child_regular_bytes(file_path_dir.as_deref(), STORAGE_FORMAT_FILE, &xl_path) {
Ok(buf) => buf,
Err(err) if err.kind() == ErrorKind::NotFound => return Ok(Ok(None)),
Err(err) => return Err(err),
@@ -2704,10 +2926,23 @@ impl LocalDisk {
Err(err) => return Err(err),
};
let staged_dir = staged.path().to_path_buf();
#[cfg(unix)]
let rollback_dir_fd = open_dir_fd(&staged_dir).ok().map(Arc::new);
#[cfg(not(unix))]
let rollback_dir_fd: Option<Arc<std::fs::File>> = None;
let backup_path = staged_dir.join(STORAGE_FORMAT_FILE_BACKUP);
if std::fs::hard_link(&xl_path, &backup_path).is_err() {
let mut file = std::fs::File::create(&backup_path)?;
std::io::Write::write_all(&mut file, &buf)?;
if link_child_files(
file_path_dir.as_deref(),
STORAGE_FORMAT_FILE,
&xl_path,
rollback_dir_fd.as_deref(),
STORAGE_FORMAT_FILE_BACKUP,
&backup_path,
)
.is_err()
{
let file =
create_and_write_child_file(rollback_dir_fd.as_deref(), STORAGE_FORMAT_FILE_BACKUP, &backup_path, &buf)?;
if sync && !buf.is_empty() {
file.sync_data()?;
}
@@ -2717,8 +2952,7 @@ impl LocalDisk {
let Some(bytes) = bytes else {
continue;
};
let mut file = std::fs::File::create(staged_dir.join(name))?;
std::io::Write::write_all(&mut file, bytes)?;
let file = create_and_write_child_file(rollback_dir_fd.as_deref(), name, &staged_dir.join(name), bytes)?;
if sync && !bytes.is_empty() {
written_files.push(file);
}
@@ -2726,21 +2960,20 @@ impl LocalDisk {
for file in written_files {
file.sync_data()?;
}
#[cfg(unix)]
let rollback_dir_fd = sync.then(|| std::fs::File::open(&staged_dir).ok().map(Arc::new)).flatten();
#[cfg(not(unix))]
let rollback_dir_fd: Option<Arc<std::fs::File>> = None;
if sync {
os::fsync_directory_handle_std(&staged_dir, rollback_dir_fd.as_deref())?;
}
let tx_name = transaction.to_string();
if !destination_missing {
match std::fs::symlink_metadata(&destination) {
Ok(metadata) if metadata.is_dir() => match std::fs::remove_dir(&destination) {
Ok(()) => {}
Err(err) if err.kind() == ErrorKind::NotFound => {}
Err(err) if is_dir_not_empty_error(&err) => return Ok(Err(DiskError::OutdatedXLMeta)),
Err(err) => return Err(err),
},
Ok(metadata) if metadata.is_dir() => {
match rmdir_child_dir(file_path_dir.as_deref(), &tx_name, &destination) {
Ok(()) => {}
Err(err) if err.kind() == ErrorKind::NotFound => {}
Err(err) if is_dir_not_empty_error(&err) => return Ok(Err(DiskError::OutdatedXLMeta)),
Err(err) => return Err(err),
}
}
Ok(_) => return Ok(Err(DiskError::OutdatedXLMeta)),
Err(err) if err.kind() == ErrorKind::NotFound => {}
Err(err) => return Err(err),
@@ -2748,18 +2981,14 @@ impl LocalDisk {
}
let staged_dir = staged.keep();
os::record_native_publication(&destination);
if let Err(err) = std::fs::rename(&staged_dir, &destination) {
if let Err(err) = rename_into_dir_fd(&staged_dir, file_path_dir.as_deref(), &tx_name, &destination) {
let _ = std::fs::remove_dir_all(&staged_dir);
return Err(err);
}
let old_data_path = old_dir
.map(|uuid| file_path.join(uuid.to_string()))
.filter(|p| old_data_dir_state.unwrap_or_else(|| p.is_dir()));
#[cfg(unix)]
let file_path_dir = sync.then(|| std::fs::File::open(&file_path).ok()).flatten();
#[cfg(not(unix))]
let file_path_dir: Option<std::fs::File> = None;
if sync && let Err(err) = os::fsync_directory_handle_std(&file_path, file_path_dir.as_ref()) {
if sync && let Err(err) = os::fsync_directory_handle_std(&file_path, file_path_dir.as_deref()) {
let _ = std::fs::remove_dir_all(&destination);
return Err(err);
}
@@ -2769,7 +2998,7 @@ impl LocalDisk {
if let Some(old_path) = old_data_path {
let reserved_marker = old_path.join(format!("{RESERVED_DELETE_DATA_DIR_MARKER_PREFIX}{transaction}"));
#[cfg(unix)]
let old_dir_file = sync.then(|| std::fs::File::open(&old_path).ok()).flatten();
let old_dir_file = sync.then(|| open_dir_fd(&old_path).ok()).flatten();
#[cfg(not(unix))]
let old_dir_file: Option<std::fs::File> = None;
let reserve_res = (|| -> std::io::Result<()> {
@@ -2792,16 +3021,16 @@ impl LocalDisk {
let target_in_rollback = destination.join(TARGET);
let linked_xl = staging_root.join(format!("delete-xl-{transaction}"));
os::record_native_publication(&xl_path);
let linked = match std::fs::hard_link(&target_in_rollback, &linked_xl) {
let linked = match link_from_dir_fd(rollback_dir_fd.as_deref(), TARGET, &target_in_rollback, &linked_xl) {
Ok(()) => true,
Err(err) if err.kind() == ErrorKind::AlreadyExists => {
let _ = std::fs::remove_file(&linked_xl);
std::fs::hard_link(&target_in_rollback, &linked_xl).is_ok()
link_from_dir_fd(rollback_dir_fd.as_deref(), TARGET, &target_in_rollback, &linked_xl).is_ok()
}
Err(_) => false,
};
let write_res = if linked {
let rename_res = std::fs::rename(&linked_xl, &xl_path);
let rename_res = rename_into_dir_fd(&linked_xl, file_path_dir.as_deref(), STORAGE_FORMAT_FILE, &xl_path);
if rename_res.is_err() {
let _ = std::fs::remove_file(&linked_xl);
}
@@ -2813,7 +3042,7 @@ impl LocalDisk {
if sync && !target_bytes.is_empty() {
staged_xl.as_file().sync_data()?;
}
std::fs::rename(staged_xl.path(), &xl_path)
rename_into_dir_fd(staged_xl.path(), file_path_dir.as_deref(), STORAGE_FORMAT_FILE, &xl_path)
})()
};
if let Err(err) = write_res {
@@ -2823,7 +3052,7 @@ impl LocalDisk {
let _ = std::fs::remove_dir_all(&destination);
return Err(err);
}
} else if let Err(err) = std::fs::remove_file(&xl_path)
} else if let Err(err) = unlink_child_file(file_path_dir.as_deref(), STORAGE_FORMAT_FILE, &xl_path)
&& err.kind() != ErrorKind::NotFound
{
if let Some((_, reserved_marker, _)) = &reserved_data_path {
@@ -2833,7 +3062,7 @@ impl LocalDisk {
return Err(err);
}
if sync {
os::fsync_directory_handle_std(&file_path, file_path_dir.as_ref())?;
os::fsync_directory_handle_std(&file_path, file_path_dir.as_deref())?;
}
let had_old_data_dir = reserved_data_path.is_some();
if let Some((old_path, reserved_marker, old_dir_file)) = reserved_data_path {
@@ -2845,7 +3074,8 @@ impl LocalDisk {
Err(err) => return Err(err),
}
}
put_prepared_delete_receipt(
Ok(Ok(Some((
old_dir.filter(|_| had_old_data_dir),
destination,
PreparedDeleteReceiptEntry {
object_path,
@@ -2857,17 +3087,18 @@ impl LocalDisk {
sole_transaction,
had_old_data_dir,
rollback_dir_fd,
file_path_dir,
},
);
Ok(Ok(Some(old_dir.filter(|_| had_old_data_dir))))
))))
},
)
.await
.map_err(to_file_error)??;
let Some(old_dir) = res else {
let Some((old_dir, destination, receipt_entry)) = res else {
return Ok(None);
};
put_prepared_delete_receipt(destination, receipt_entry);
if let Some(uuid) = old_dir {
self.io_backend
.invalidate_cached_fds_under(volume, &format!("{object}/{uuid}"));
@@ -2918,6 +3149,7 @@ impl LocalDisk {
entry.sole_transaction,
entry.had_old_data_dir,
entry.rollback_dir_fd,
entry.file_path_dir,
)),
),
None => {
@@ -2945,12 +3177,14 @@ impl LocalDisk {
let volume_str = volume.to_owned();
let object_str = object.to_owned();
os::run_blocking_namespace_operation_with_sync(
type PublishDeleteFastReturn = (DeleteRecoveryJournal, bool, bool, Option<(PathBuf, PublishedDeleteReceiptEntry)>);
let res = os::run_blocking_namespace_operation_with_sync(
lease,
sync,
move || -> std::io::Result<Result<Option<(DeleteRecoveryJournal, bool, bool)>>> {
if let Some((journal_raw, journal, sole_transaction, had_old_data_dir, rollback_dir_fd)) = cached_receipt
&& rollback.join(PREPARED).is_file()
move || -> std::io::Result<Result<Option<PublishDeleteFastReturn>>> {
if let Some((journal_raw, journal, sole_transaction, had_old_data_dir, rollback_dir_fd, file_path_dir)) =
cached_receipt
&& child_regular_file_exists(rollback_dir_fd.as_deref(), PREPARED, &rollback.join(PREPARED))
{
let tmp_receipt = staging_root.join(format!("receipt-{transaction}.tmp"));
let receipt_bytes = format_commit_receipt_bytes(&journal_raw, false);
@@ -2961,7 +3195,12 @@ impl LocalDisk {
file.sync_data()?;
}
os::record_native_publication(&receipt_path);
std::fs::rename(&tmp_receipt, &receipt_path)?;
rename_into_dir_fd(
&tmp_receipt,
rollback_dir_fd.as_deref(),
DELETE_QUORUM_COMMITTED_FILE,
&receipt_path,
)?;
if sync {
os::fsync_directory_handle_std(&rollback, rollback_dir_fd.as_deref())?;
}
@@ -2971,8 +3210,8 @@ impl LocalDisk {
let _ = std::fs::remove_file(&tmp_receipt);
return Err(err);
}
if let Some(bucket_path) = bucket_path {
put_published_delete_receipt(
let published_entry = bucket_path.map(|bucket_path| {
(
rollback,
PublishedDeleteReceiptEntry {
object_path,
@@ -2982,10 +3221,11 @@ impl LocalDisk {
journal_raw,
had_old_data_dir,
rollback_dir_fd,
file_path_dir,
},
);
}
return Ok(Ok(Some((journal, sole_transaction, true))));
)
});
return Ok(Ok(Some((journal, sole_transaction, true, published_entry))));
}
let prepared = match regular_bytes(&rollback.join(PREPARED)) {
Ok(Some(bytes)) => bytes,
@@ -3064,11 +3304,19 @@ impl LocalDisk {
if sync {
os::fsync_dir_std(&rollback)?;
}
Ok(Ok(Some((journal, sole_transaction, target_satisfied))))
Ok(Ok(Some((journal, sole_transaction, target_satisfied, None))))
},
)
.await
.map_err(to_file_error)?
.map_err(to_file_error)?;
let Some((journal, sole_transaction, target_satisfied, published_entry)) = res? else {
return Ok(None);
};
if let Some((rollback, entry)) = published_entry {
put_published_delete_receipt(rollback, entry);
}
Ok(Some((journal, sole_transaction, target_satisfied)))
}
#[cfg(test)]
@@ -3118,34 +3366,49 @@ impl LocalDisk {
None
};
let cached_published = rollback_open_path.as_deref().and_then(take_published_delete_receipt);
let (object_path, bucket_path, staging_root, root, cached_journal_raw, cached_had_old_data_dir, mut rollback_dir_fd) =
match cached_published {
Some(entry) => (
entry.object_path,
entry.bucket_path,
entry.staging_root,
entry.file_path,
Some(entry.journal_raw),
Some(entry.had_old_data_dir),
entry.rollback_dir_fd,
),
None => {
self.reject_multipart_delete_recovery_mutation(volume, object)?;
if !safe_path(object, false) {
return Err(DiskError::InvalidPath);
}
let obj_path = self.get_object_path(volume, object)?;
(
obj_path,
self.io_get_bucket_path(volume)?,
self.io_get_bucket_path(RUSTFS_META_TMP_BUCKET)?,
self.io_get_object_path(volume, object)?,
None,
None,
None,
)
if cached_published.is_none()
&& let Some(ref tx_open) = rollback_open_path
{
let _ = take_prepared_delete_receipt(tx_open);
}
let (
object_path,
bucket_path,
staging_root,
root,
cached_journal_raw,
cached_had_old_data_dir,
mut rollback_dir_fd,
mut file_path_dir,
) = match cached_published {
Some(entry) => (
entry.object_path,
entry.bucket_path,
entry.staging_root,
entry.file_path,
Some(entry.journal_raw),
Some(entry.had_old_data_dir),
entry.rollback_dir_fd,
entry.file_path_dir,
),
None => {
self.reject_multipart_delete_recovery_mutation(volume, object)?;
if !safe_path(object, false) {
return Err(DiskError::InvalidPath);
}
};
let obj_path = self.get_object_path(volume, object)?;
(
obj_path,
self.io_get_bucket_path(volume)?,
self.io_get_bucket_path(RUSTFS_META_TMP_BUCKET)?,
self.io_get_object_path(volume, object)?,
None,
None,
None,
None,
)
}
};
let metadata_owner = os::acquire_metadata_mutation_lease(&object_path, namespace_owner).await;
let volume_guard = os::disk_volume_mutation_lock(&self.root, volume).read_owned().await;
let owner: Option<Arc<dyn Send + Sync>> = Some(Arc::new((metadata_owner, volume_guard)));
@@ -3272,7 +3535,7 @@ impl LocalDisk {
}
}
if had_physical_data_dirs && sync && root.is_dir() {
os::fsync_dir_std(&root)?;
os::fsync_directory_handle_std(&root, file_path_dir.as_deref())?;
}
if fast_cleanup {
let backup_or_marker = if has_original {
@@ -3283,8 +3546,10 @@ impl LocalDisk {
None
};
if let Some(name) = backup_or_marker
&& let Err(err) = ignore_not_found(std::fs::remove_file(rollback.join(name)))
&& let Err(err) = ignore_not_found(unlink_child_file(rollback_dir_fd.as_deref(), name, &rollback.join(name)))
{
drop(rollback_dir_fd.take());
drop(file_path_dir.take());
if has_target || had_physical_data_dirs {
let _ = persist_receipt(true);
}
@@ -3294,7 +3559,9 @@ impl LocalDisk {
let path = rollback.join(name);
#[cfg(all(any(test, feature = "test-util"), not(windows)))]
os::prepared_publication_test_hooks::run(os::prepared_publication_test_hooks::Stage::Remove, &path);
if let Err(err) = ignore_not_found(std::fs::remove_file(path)) {
if let Err(err) = ignore_not_found(unlink_child_file(rollback_dir_fd.as_deref(), name, &path)) {
drop(rollback_dir_fd.take());
drop(file_path_dir.take());
if has_target || had_physical_data_dirs {
let _ = persist_receipt(true);
}
@@ -3303,7 +3570,10 @@ impl LocalDisk {
}
} else {
for name in [STORAGE_FORMAT_FILE_BACKUP, DELETE_MARKER_ROLLBACK_FILE] {
if let Err(err) = ignore_not_found(std::fs::remove_file(rollback.join(name))) {
if let Err(err) = ignore_not_found(unlink_child_file(rollback_dir_fd.as_deref(), name, &rollback.join(name)))
{
drop(rollback_dir_fd.take());
drop(file_path_dir.take());
if has_target || had_physical_data_dirs {
let _ = persist_receipt(true);
}
@@ -3314,7 +3584,9 @@ impl LocalDisk {
let path = rollback.join(name);
#[cfg(all(any(test, feature = "test-util"), not(windows)))]
os::prepared_publication_test_hooks::run(os::prepared_publication_test_hooks::Stage::Remove, &path);
if let Err(err) = ignore_not_found(std::fs::remove_file(path)) {
if let Err(err) = ignore_not_found(unlink_child_file(rollback_dir_fd.as_deref(), name, &path)) {
drop(rollback_dir_fd.take());
drop(file_path_dir.take());
if has_target || had_physical_data_dirs {
let _ = persist_receipt(true);
}
@@ -3322,27 +3594,39 @@ impl LocalDisk {
}
}
}
let rollback_sync = rollback_dir_fd.take();
if sync && let Err(err) = os::fsync_directory_handle_std(&rollback, rollback_sync.as_deref()) {
drop(rollback_sync);
if sync && let Err(err) = os::fsync_directory_handle_std(&rollback, rollback_dir_fd.as_deref()) {
drop(rollback_dir_fd.take());
drop(file_path_dir.take());
if has_target || had_physical_data_dirs {
let _ = persist_receipt(true);
}
return Err(err);
}
drop(rollback_sync);
#[cfg(all(any(test, feature = "test-util"), not(windows)))]
os::prepared_publication_test_hooks::run(os::prepared_publication_test_hooks::Stage::Remove, &receipt_path);
if let Err(err) = ignore_not_found(std::fs::remove_file(&receipt_path)) {
let receipt_unlink_res =
ignore_not_found(unlink_child_file(rollback_dir_fd.as_deref(), DELETE_QUORUM_COMMITTED_FILE, &receipt_path));
drop(rollback_dir_fd.take());
if let Err(err) = receipt_unlink_res {
drop(file_path_dir.take());
if has_target || had_physical_data_dirs {
let _ = persist_receipt(true);
}
return Err(err);
}
for dir_path in &prune_paths {
let tx_name = transaction.to_string();
for (idx, dir_path) in prune_paths.iter().enumerate() {
#[cfg(all(any(test, feature = "test-util"), not(windows)))]
os::prepared_publication_test_hooks::run(os::prepared_publication_test_hooks::Stage::Remove, dir_path);
if let Err(err) = std::fs::remove_dir(dir_path) {
let rmdir_res = if idx == 0 && *dir_path == rollback {
let r = rmdir_child_dir(file_path_dir.as_deref(), &tx_name, dir_path);
drop(file_path_dir.take());
r
} else {
drop(file_path_dir.take());
std::fs::remove_dir(dir_path)
};
if let Err(err) = rmdir_res {
if is_dir_not_empty_error(&err) {
break;
}
@@ -5834,6 +6118,7 @@ mod tests {
drop(disk);
let endpoint = Endpoint::try_from(temp.path().to_str().expect("UTF-8 disk path")).expect("endpoint");
let disk = LocalDisk::new(&endpoint, false).await.expect("reopen after cleanup");
let root = disk.io_get_object_path("recovery", "object").expect("reopened object path");
let observed = disk
.inspect_delete_transaction("recovery", "object", transaction)
.await
@@ -295,17 +295,11 @@ pub(super) fn put_active_multipart_source_head(source: impl AsRef<Path>, raw: By
}
fn stamp_matches_active_disk(source: &Path, expected_head_stamp: FileStamp) -> bool {
if FileStamp::from_path(&source.join(STORAGE_FORMAT_FILE)) != Some(expected_head_stamp) {
return false;
}
#[cfg(test)]
if source.join(STATE).exists()
|| source.join(crate::disk::MULTIPART_RECOVERY_DIR).exists()
|| source.join(".mpu-completions").exists()
{
return false;
}
true
FileStamp::from_path(&source.join(STORAGE_FORMAT_FILE)) == Some(expected_head_stamp)
&& !(cfg!(test)
&& (source.join(STATE).exists()
|| source.join(crate::disk::MULTIPART_RECOVERY_DIR).exists()
|| source.join(".mpu-completions").exists()))
}
pub(super) fn get_active_multipart_source_head(source: &Path) -> Option<(Bytes, FileInfo)> {
+2 -2
View File
@@ -2137,7 +2137,7 @@ mod multipart_proof_authority_tests {
assert_eq!(expected, snapshot);
assert_eq!(actual_round, round);
let publication = PublicationIdentity {
lineage: round.lineage.clone(),
lineage: round.lineage,
source_generation: snapshot.generation,
attempt: Uuid::new_v4(),
version_id: None,
@@ -2145,7 +2145,7 @@ mod multipart_proof_authority_tests {
modified_unix_nanos: nanos,
completion_identity: [6; 32],
};
snapshot.publication = Some(publication.clone());
snapshot.publication = Some(publication);
let response = MultipartSourceResponse::TerminalConfirmed(
MultipartSourceProof::issue(
&authority,
+2 -4
View File
@@ -392,14 +392,11 @@ pub(crate) mod prepared_publication_test_hooks {
}
pub(crate) fn has_any_hooks() -> bool {
if !BEFORE_PUBLICATION.lock().is_empty() {
return true;
}
#[cfg(test)]
if !RENAME_DESTINATIONS.lock().is_empty() || !HARDLINK_DESTINATIONS.lock().is_empty() {
return true;
}
false
!BEFORE_PUBLICATION.lock().is_empty()
}
#[cfg(test)]
@@ -611,6 +608,7 @@ pub(crate) mod prepare_rename_test_hooks {
.push_back(Box::new(hook));
}
#[cfg(target_os = "linux")]
pub(crate) fn has_hooks() -> bool {
AFTER_COMPONENT_OPENED.lock().values().any(|q| !q.is_empty())
}
+1 -1
View File
@@ -439,7 +439,7 @@ mod tests {
reconstruct_with(&engine, &mut shards).expect("restore missing data and parity");
assert_eq!(shards, expected, "every restored byte must match at width {}", shards.len());
let mut corrupt = expected.clone();
let mut corrupt = expected;
corrupt[0] = None;
corrupt[data_shards] = None;
corrupt[data_shards + 1].as_mut().expect("surplus parity present")[0] ^= 1;
+1 -1
View File
@@ -1996,7 +1996,7 @@ mod tests {
for storage_error in storage_errors {
let original_message = storage_error.to_string();
let io_error: std::io::Error = storage_error.clone().into();
let io_error: std::io::Error = storage_error.into();
// The io::Error should contain the original error message or info
assert!(io_error.to_string().contains(&original_message));
+1 -1
View File
@@ -391,7 +391,7 @@ impl PoolEndpointList {
e.get_mut().extend(host_ip_set.iter());
}
Entry::Vacant(e) => {
e.insert(host_ip_set.clone());
e.insert(host_ip_set);
}
}
}
+1 -1
View File
@@ -47,7 +47,7 @@ pub(crate) fn formats_to_drives_info(
before_drives.push(HealDriveInfo {
uuid,
endpoint: drive,
state: state.to_string(),
state,
});
}
before_drives
+2 -2
View File
@@ -526,7 +526,7 @@ pub(crate) async fn record_local_disks(instance_ctx: &Arc<InstanceContext>, disk
let mut global_local_disk_map = map.write().await;
for disk in disks {
let path = disk.endpoint().to_string();
global_local_disk_map.insert(path, Some(disk.clone()));
global_local_disk_map.insert(path, Some(disk));
}
}
@@ -625,7 +625,7 @@ pub(crate) async fn initialize_local_disk_maps(
})?;
global_local_disk_map.insert(path, Some(disk.clone()));
global_set_drives[pool_idx][set_idx][disk_idx] = Some(disk.clone());
global_set_drives[pool_idx][set_idx][disk_idx] = Some(disk);
}
}
@@ -3168,7 +3168,7 @@ impl NotificationSys {
return Err(Error::other("scanner scoped dirty usage capability requires scoped acknowledgements"));
}
};
if by_host.insert(host.clone(), acknowledgement).is_some() {
if by_host.insert(host, acknowledgement).is_some() {
return Err(Error::other("duplicate scanner dirty usage acknowledgement target"));
}
}
@@ -3218,7 +3218,7 @@ impl NotificationSys {
ScannerDirtyUsageAcknowledgement::Generation { host, .. }
| ScannerDirtyUsageAcknowledgement::Scoped { host, .. } => host.clone(),
};
if by_host.insert(host.clone(), acknowledgement).is_some() {
if by_host.insert(host, acknowledgement).is_some() {
return Err(Error::other("duplicate scanner dirty usage acknowledgement target"));
}
}
@@ -802,7 +802,6 @@ impl ECStore {
let set = set.clone();
let rx = rx.clone();
let bucket = bucket.clone();
let entry_tasks = entry_tasks.clone();
let job = tokio::spawn(async move {
let list_rx = rx.clone();
@@ -8144,9 +8144,9 @@ mod tests {
assert_eq!(calls.total(disk_call_counters::KIND_DISK_SHARD_OPEN), 4);
assert_eq!(setup.available_shards(), 4);
assert_eq!(setup.deferred_shards(), 4);
for index in 4..8 {
for (index, attempted) in setup.attempted.iter().enumerate().take(8).skip(4) {
assert_eq!(calls.for_disk(disk_call_counters::KIND_DISK_SHARD_OPEN, index), 0);
assert!(!setup.attempted[index], "parity must stay unopened until needed");
assert!(!attempted, "parity must stay unopened until needed");
}
let (output, error) = local_batch_quorum_decode(setup, files[0].erasure.block_size).await;
assert!(error.is_none(), "healthy decode failed: {error:?}");
+4 -8
View File
@@ -6045,14 +6045,10 @@ fn should_use_inline_fast_path_with_plan(
// a stale/corrupt marker cannot route an oversized payload into the
// in-memory decoder. The independent direct-memory reader remains capped
// at 128 KiB below.
if !plan.is_plain()
|| shape.object_size > usize::try_from(INLINE_FAST_PATH_MAX_OBJECT_SIZE).expect("inline fast path limit fits usize")
|| fi.erasure.data_blocks == 0
|| fi.erasure.block_size == 0
{
return false;
}
true
plan.is_plain()
&& shape.object_size <= usize::try_from(INLINE_FAST_PATH_MAX_OBJECT_SIZE).expect("inline fast path limit fits usize")
&& fi.erasure.data_blocks != 0
&& fi.erasure.block_size != 0
}
enum SmallWritePath {
+4 -4
View File
@@ -1285,7 +1285,7 @@ impl SetDisks {
result.after.drives.push(HealDriveInfo {
uuid: "".to_string(),
endpoint: self.set_endpoints[index].to_string(),
state: drive_state.to_string(),
state: drive_state,
});
}
@@ -3102,7 +3102,7 @@ impl SetDisks {
result.after.drives.push(HealDriveInfo {
uuid: "".to_string(),
endpoint,
state: drive_state.to_string(),
state: drive_state,
});
}
@@ -3147,7 +3147,7 @@ impl SetDisks {
_ => DriveState::Corrupt.to_string(),
},
};
result.after.drives[index].state = drive_state.to_string();
result.after.drives[index].state = drive_state;
}
}
@@ -10994,7 +10994,7 @@ mod heal_result_report_tests {
set.heal_object(bucket, object, "", &heal_opts),
);
let put_info = put_res.expect("overwrite must succeed under lock serialization");
last_etag = put_info.etag.clone().unwrap_or_default();
last_etag = put_info.etag.unwrap_or_default();
// Heal outcome is unconstrained (may hit the tail race and report a
// retryable error); the invariant is checked on the end state.
drop(heal_res);
+4 -3
View File
@@ -690,9 +690,10 @@ impl SetDisks {
return Err(Error::other("DriveID: offline"));
}
for i in 0..self.format.erasure.sets.len() {
for j in 0..self.format.erasure.sets[0].len() {
if fm.erasure.this == self.format.erasure.sets[i][j] {
let set_drive_count = self.format.erasure.sets[0].len();
for (i, set) in self.format.erasure.sets.iter().enumerate() {
for (j, drive) in set.iter().enumerate().take(set_drive_count) {
if fm.erasure.this == *drive {
return Ok((i, j));
}
}
+1 -1
View File
@@ -11560,7 +11560,7 @@ mod tests {
break;
}
key_marker = page.next_key_marker.clone();
upload_id_marker = page.next_upload_id_marker.clone();
upload_id_marker = page.next_upload_id_marker;
assert!(key_marker.is_some(), "a truncated page must carry a next key marker");
assert!(upload_id_marker.is_some(), "a truncated upload page must carry a next upload marker");
}
+28 -35
View File
@@ -17932,10 +17932,9 @@ mod tests {
.expect("shared-tuple recursive prefix delete should commit");
assert_eq!(tier_delete_journal_count(store.clone()).await, 1);
for pool_idx in 0..2 {
for (pool_idx, pool) in store.pools.iter().enumerate().take(2) {
assert!(
store.pools[pool_idx]
.get_disks_by_key(object)
pool.get_disks_by_key(object)
.load_file_info_versions_exact(bucket, object)
.await
.expect("shared-tuple post-delete metadata should decode")
@@ -19768,8 +19767,8 @@ mod tests {
}
wait_for_expiry_workers_idle(&store).await;
let mut free_version_ids = Vec::new();
for pool_idx in 0..2 {
let retained = store.pools[pool_idx]
for (pool_idx, pool) in store.pools.iter().enumerate().take(2) {
let retained = pool
.get_disks_by_key(object)
.load_file_info_versions_exact(&bucket, object)
.await
@@ -19805,10 +19804,9 @@ mod tests {
);
wait_for_expiry_workers_idle(&store).await;
assert_eq!(backend.remove_count().await, 1, "shared remote tuple should be deleted exactly once");
for pool_idx in 0..2 {
for (pool_idx, pool) in store.pools.iter().enumerate().take(2) {
assert!(
store.pools[pool_idx]
.get_disks_by_key(object)
pool.get_disks_by_key(object)
.load_file_info_versions_exact(&bucket, object)
.await
.expect("post-delete metadata should decode")
@@ -19858,9 +19856,9 @@ mod tests {
.expect("multi-pool source bucket should be created");
let mut remote_tuples = Vec::new();
for pool_idx in 0..2 {
for (pool_idx, pool) in store.pools.iter().enumerate().take(2) {
let mut reader = PutObjReader::from_vec(vec![b'a' + pool_idx as u8; 1024 * 1024]);
let source = store.pools[pool_idx]
let source = pool
.put_object(
bucket,
object,
@@ -19872,31 +19870,27 @@ mod tests {
)
.await
.expect("pool-local source should be written");
store.pools[pool_idx]
.transition_object(
bucket,
object,
&ObjectOptions {
transition: TransitionOptions {
status: TRANSITION_PENDING.to_string(),
tier: tier_name.to_string(),
etag: source.etag.clone().expect("pool-local source should have an etag"),
..Default::default()
},
mod_time: source.mod_time,
pool.transition_object(
bucket,
object,
&ObjectOptions {
transition: TransitionOptions {
status: TRANSITION_PENDING.to_string(),
tier: tier_name.to_string(),
etag: source.etag.clone().expect("pool-local source should have an etag"),
..Default::default()
},
)
.await
.expect("pool-local source should transition");
let transitioned = store.pools[pool_idx]
mod_time: source.mod_time,
..Default::default()
},
)
.await
.expect("pool-local source should transition");
let transitioned = pool
.get_object_info(bucket, object, &ObjectOptions::default())
.await
.expect("pool-local transitioned source should be readable");
remote_tuples.push((
transitioned.transitioned_object.name.clone(),
transitioned.transitioned_object.version_id.clone(),
));
remote_tuples.push((transitioned.transitioned_object.name.clone(), transitioned.transitioned_object.version_id));
}
assert_ne!(remote_tuples[0], remote_tuples[1], "fixture must use distinct remote cleanup tuples");
assert_eq!(backend.object_count().await, 2);
@@ -19906,8 +19900,8 @@ mod tests {
.delete_object_with_tier_delete_journal(bucket, object, ObjectOptions::default())
.await
.expect("store-level delete should remove both pool copies");
for pool_idx in 0..2 {
let retained = store.pools[pool_idx]
for (pool_idx, pool) in store.pools.iter().enumerate().take(2) {
let retained = pool
.get_disks_by_key(object)
.load_file_info_versions_exact(bucket, object)
.await
@@ -19946,10 +19940,9 @@ mod tests {
removed.sort();
remote_tuples.sort();
assert_eq!(removed, remote_tuples, "recovery must use each pool's exact remote tuple");
for pool_idx in 0..2 {
for (pool_idx, pool) in store.pools.iter().enumerate().take(2) {
assert!(
store.pools[pool_idx]
.get_disks_by_key(object)
pool.get_disks_by_key(object)
.load_file_info_versions_exact(bucket, object)
.await
.expect("post-recovery pool metadata should decode")
+10 -10
View File
@@ -8504,7 +8504,7 @@ mod test {
let mut results = Vec::new();
while let Some(entry) = out_rx.recv().await {
results.push(entry.name.clone());
results.push(entry.name);
}
handle.await.unwrap().unwrap();
@@ -8533,7 +8533,7 @@ mod test {
let mut results = Vec::new();
while let Some(entry) = out_rx.recv().await {
results.push(entry.name.clone());
results.push(entry.name);
}
handle.await.unwrap().unwrap();
@@ -9177,7 +9177,7 @@ mod test {
let mut results = Vec::new();
while let Some(entry) = out_rx.recv().await {
results.push(entry.name.clone());
results.push(entry.name);
}
handle.await.unwrap().unwrap();
@@ -9265,7 +9265,7 @@ mod test {
let mut results = Vec::new();
while let Some(entry) = out_rx.recv().await {
results.push(entry.name.clone());
results.push(entry.name);
}
handle.await.unwrap().unwrap();
@@ -9295,7 +9295,7 @@ mod test {
let mut results = Vec::new();
while let Some(entry) = out_rx.recv().await {
results.push(entry.name.clone());
results.push(entry.name);
}
handle.await.unwrap().unwrap();
@@ -9367,7 +9367,7 @@ mod test {
let mut results = Vec::new();
while let Some(entry) = out_rx.recv().await {
results.push(entry.name.clone());
results.push(entry.name);
}
handle.await.unwrap().unwrap();
@@ -9401,7 +9401,7 @@ mod test {
let mut results = Vec::new();
while let Some(entry) = out_rx.recv().await {
results.push(entry.name.clone());
results.push(entry.name);
}
handle.await.unwrap().unwrap();
@@ -9491,7 +9491,7 @@ mod test {
let mut results = Vec::new();
while let Some(entry) = out_rx.recv().await {
assert!(entry.is_object(), "the directory-marker object must shadow the same-named prefix dir");
results.push(entry.name.clone());
results.push(entry.name);
}
handle.await.unwrap().unwrap();
@@ -9514,7 +9514,7 @@ mod test {
let mut results = Vec::new();
while let Some(entry) = out_rx.recv().await {
results.push(entry.name.clone());
results.push(entry.name);
}
handle.await.unwrap().unwrap();
@@ -9538,7 +9538,7 @@ mod test {
let mut results = Vec::new();
while let Some(entry) = out_rx.recv().await {
results.push(entry.name.clone());
results.push(entry.name);
}
handle.await.unwrap().unwrap();
+12 -13
View File
@@ -1722,20 +1722,19 @@ mod tests {
.await
.expect("bucket should be created in both pools");
for pool_index in 0..2 {
for (pool_index, pool) in store.pools.iter().enumerate().take(2) {
let mut reader = PutObjReader::from_vec(format!("pool-{pool_index}-history").into_bytes());
store.pools[pool_index]
.put_object(
&bucket,
object,
&mut reader,
&ObjectOptions {
versioned: true,
..Default::default()
},
)
.await
.expect("historical version should be stored");
pool.put_object(
&bucket,
object,
&mut reader,
&ObjectOptions {
versioned: true,
..Default::default()
},
)
.await
.expect("historical version should be stored");
}
let marker = store.pools[0]
.delete_object(
+1 -1
View File
@@ -908,7 +908,7 @@ mod tests {
assert_indexed_matches_scalar(&original, selected);
}
if inline_mode > 0 {
let mut legacy_null = original.clone();
let mut legacy_null = original;
let payload = legacy_null
.data
.find(NULL_VERSION_ID)
+1 -5
View File
@@ -113,11 +113,7 @@ impl FileMetaVersionHeader {
}
// Check erasure coding parameters
if self.has_ec() && (self.ec_n == 0 || self.ec_m == 0 || self.ec_m < self.ec_n) {
return false;
}
true
!(self.has_ec() && (self.ec_n == 0 || self.ec_m == 0 || self.ec_m < self.ec_n))
}
}
+4 -7
View File
@@ -4223,9 +4223,10 @@ mod tests {
o_hdr.ec_m = o_m;
o_hdr.ec_n = o_n;
let actual = FileMeta::headers_match_strict(&s_hdr, &o_hdr);
let expected = {
let mut s_cmp = s_hdr.clone();
let mut o_cmp = o_hdr.clone();
let mut s_cmp = s_hdr;
let mut o_cmp = o_hdr;
if s_cmp.has_ec() != o_cmp.has_ec() {
s_cmp.ec_m = 0;
s_cmp.ec_n = 0;
@@ -4234,11 +4235,7 @@ mod tests {
}
s_cmp == o_cmp
};
assert_eq!(
FileMeta::headers_match_strict(&s_hdr, &o_hdr),
expected,
"mismatch for s=({s_m},{s_n}) vs o=({o_m},{o_n})"
);
assert_eq!(actual, expected, "mismatch for s=({s_m},{s_n}) vs o=({o_m},{o_n})");
}
}
}
+2 -2
View File
@@ -574,7 +574,7 @@ mod tests {
tonic::Status::internal("operation was canceled"),
] {
assert!(
!Error::Storage(EcstoreError::from(status).clone()).is_recoverable_heal(),
!Error::Storage(EcstoreError::from(status)).is_recoverable_heal(),
"cancellation text alone must not change application error classification"
);
}
@@ -722,7 +722,7 @@ mod tests {
for error in [
Error::Disk(DiskError::from(status.clone())),
Error::Storage(EcstoreError::from(status.clone())),
Error::Io(std::io::Error::from(DiskError::from(status.clone()))),
Error::Io(std::io::Error::from(DiskError::from(status))),
] {
assert!(!error.is_recoverable_heal(), "application RPC errors must stay terminal: {error:?}");
}
+1 -1
View File
@@ -543,7 +543,7 @@ impl HealManager {
.filter_map(|target| local_endpoints.get(target).cloned())
.collect::<Vec<_>>();
if targets.len() != state.replacement_targets.len() {
deferred_recovery_sets.insert(state.set_disk_id.clone());
deferred_recovery_sets.insert(state.set_disk_id);
continue;
}
// A pending handoff may resolve the predecessor to
+10 -12
View File
@@ -257,8 +257,6 @@ impl HealManager {
let notify_clone = notify.clone();
let root_recovery_clone = root_recovery.clone();
let manager_cancel_token = cancel_token.clone();
let task_type_label_for_spawn = task_type_label.clone();
let task_set_label_for_spawn = task_set_label.clone();
let config_for_spawn = config.clone();
// start heal task
@@ -270,8 +268,8 @@ impl HealManager {
subsystem = LOG_SUBSYSTEM_MANAGER,
task_id,
priority = ?task_priority,
heal_type = %task_type_label_for_spawn,
set = %task_set_label_for_spawn,
heal_type = %task_type_label,
set = %task_set_label,
state = "task_started",
"Heal scheduler task started"
);
@@ -286,8 +284,8 @@ impl HealManager {
component = LOG_COMPONENT_HEAL,
subsystem = LOG_SUBSYSTEM_MANAGER,
task_id,
heal_type = %task_type_label_for_spawn,
set = %task_set_label_for_spawn,
heal_type = %task_type_label,
set = %task_set_label,
state = "task_completed",
"Heal scheduler task completed"
);
@@ -300,8 +298,8 @@ impl HealManager {
subsystem = LOG_SUBSYSTEM_MANAGER,
state = "verification_unavailable",
task_id,
heal_type = %task_type_label_for_spawn,
set = %task_set_label_for_spawn,
heal_type = %task_type_label,
set = %task_set_label,
detail = %e,
"Heal attempt completed without independent verification"
);
@@ -314,8 +312,8 @@ impl HealManager {
component = LOG_COMPONENT_HEAL,
subsystem = LOG_SUBSYSTEM_MANAGER,
task_id,
heal_type = %task_type_label_for_spawn,
set = %task_set_label_for_spawn,
heal_type = %task_type_label,
set = %task_set_label,
state = "task_retrying",
retry_attempt = task.retry_attempts.saturating_add(1),
error = %e,
@@ -328,8 +326,8 @@ impl HealManager {
subsystem = LOG_SUBSYSTEM_MANAGER,
state = "task_failed",
task_id,
heal_type = %task_type_label_for_spawn,
set = %task_set_label_for_spawn,
heal_type = %task_type_label,
set = %task_set_label,
error = %e,
"Heal scheduler task failed"
});
+1 -1
View File
@@ -2199,7 +2199,7 @@ async fn replay_into(
// runtime dispatches it after publishing the successor, avoiding
// a duplicate task in the startup-replay and steady-state paths.
if let Ok(Some(anchor)) = manager.durable_mrf_repair_anchor(&intent).await {
durable_replay_anchors.push(anchor.clone());
durable_replay_anchors.push(anchor);
}
let restored = intent_digest(&intent).and_then(|digest| take_lifecycle_record(&mut lifecycle, digest));
partial_writes.push((intent, restored));
+2 -2
View File
@@ -379,8 +379,8 @@ mod tests {
let mut stored = generated.stored;
assert_eq!(remaining(&stored), RECOVERY_CODE_COUNT as u32);
for index in 0..3 {
consume(&mut stored, &generated.plaintext[index], now());
for code in generated.plaintext.iter().take(3) {
consume(&mut stored, code, now());
}
assert_eq!(remaining(&stored), (RECOVERY_CODE_COUNT - 3) as u32);
}
+236 -1
View File
@@ -430,6 +430,8 @@ const INTERNODE_OPERATION_REQUESTS_INCOMING_TOTAL: &str = "rustfs_system_network
const INTERNODE_OPERATION_ERRORS_TOTAL: &str = "rustfs_system_network_internode_operation_errors_total";
const INTERNODE_OPERATION_DURATION_MS: &str = "rustfs_system_network_internode_operation_duration_ms";
const INTERNODE_OPERATION_SERVER_DURATION_MS: &str = "rustfs_system_network_internode_operation_server_duration_ms";
const INTERNODE_RPC_ACTIVE: &str = "rustfs_system_network_internode_rpc_active";
const INTERNODE_RPC_COMPLETIONS_TOTAL: &str = "rustfs_system_network_internode_rpc_completions_total";
const INTERNODE_OPERATION_STAGE_DURATION_MS: &str = "rustfs_system_network_internode_operation_stage_duration_ms";
const INTERNODE_OPERATION_CLASSIFIED_ERRORS_TOTAL: &str = "rustfs_system_network_internode_operation_classified_errors_total";
const INTERNODE_OPERATION_RETRIES_TOTAL: &str = "rustfs_system_network_internode_operation_retries_total";
@@ -463,6 +465,8 @@ pub struct InternodeOperationMetricDescriptor {
}
const SERVER_OPERATION_BACKEND_LABELS: &[&str] = &[SERVER_LABEL, OPERATION_LABEL, BACKEND_LABEL];
const SERVER_OPERATION_BACKEND_SIDE_LABELS: &[&str] = &[SERVER_LABEL, OPERATION_LABEL, BACKEND_LABEL, "side"];
const SERVER_OPERATION_BACKEND_OUTCOME_LABELS: &[&str] = &[SERVER_LABEL, OPERATION_LABEL, BACKEND_LABEL, "outcome"];
const SERVER_OPERATION_BACKEND_CLASSIFICATION_LABELS: &[&str] =
&[SERVER_LABEL, OPERATION_LABEL, BACKEND_LABEL, CLASSIFICATION_LABEL];
const SERVER_OPERATION_BACKEND_HTTP_VERSION_LABELS: &[&str] = &[SERVER_LABEL, OPERATION_LABEL, BACKEND_LABEL, HTTP_VERSION_LABEL];
@@ -567,6 +571,14 @@ pub const INTERNODE_OPERATION_METRICS: &[InternodeOperationMetricDescriptor] = &
name: INTERNODE_OPERATION_SERVER_DURATION_MS,
labels: SERVER_OPERATION_BACKEND_LABELS,
},
InternodeOperationMetricDescriptor {
name: INTERNODE_RPC_ACTIVE,
labels: SERVER_OPERATION_BACKEND_SIDE_LABELS,
},
InternodeOperationMetricDescriptor {
name: INTERNODE_RPC_COMPLETIONS_TOTAL,
labels: SERVER_OPERATION_BACKEND_OUTCOME_LABELS,
},
];
static STABLE_SERVER_LABEL: OnceLock<String> = OnceLock::new();
@@ -597,6 +609,148 @@ impl InternodeServerMetricHandles {
static INTERNODE_SERVER_METRIC_HANDLES: LazyLock<InternodeServerMetricHandles> =
LazyLock::new(|| InternodeServerMetricHandles::new(current_server_label()));
/// Measures a unary handler from entry through its returned response or cancellation. The
/// handler metric is separate from client round trips, which include transport and body decode.
pub struct InternodeRpcDurationGuard {
started_at: Instant,
handles: UnaryRpcMetricHandles,
outcome: RpcOutcome,
}
#[derive(Clone)]
struct UnaryRpcMetricHandles {
duration: metrics::Histogram,
active: metrics::Gauge,
completion: Option<(metrics::Counter, metrics::Counter, metrics::Counter)>,
}
#[derive(Clone, Copy)]
enum RpcOutcome {
Success,
Error,
Cancelled,
}
impl UnaryRpcMetricHandles {
fn new(operation: &'static str, client: bool, detailed: bool) -> Self {
let server = current_server_label();
let duration_name = if client {
INTERNODE_OPERATION_DURATION_MS
} else {
INTERNODE_OPERATION_SERVER_DURATION_MS
};
let duration = metrics::histogram!(
duration_name,
SERVER_LABEL => server, OPERATION_LABEL => operation, BACKEND_LABEL => INTERNODE_TRANSPORT_BACKEND_GRPC
);
let active = if detailed {
metrics::gauge!(
INTERNODE_RPC_ACTIVE,
SERVER_LABEL => server, OPERATION_LABEL => operation, BACKEND_LABEL => INTERNODE_TRANSPORT_BACKEND_GRPC,
"side" => if client { "client" } else { "server" }
)
} else {
metrics::Gauge::noop()
};
let completion = (client && detailed).then(|| {
let counter = |outcome| {
metrics::counter!(
INTERNODE_RPC_COMPLETIONS_TOTAL,
SERVER_LABEL => server, OPERATION_LABEL => operation,
BACKEND_LABEL => INTERNODE_TRANSPORT_BACKEND_GRPC, "outcome" => outcome
)
};
(counter("success"), counter("error"), counter("cancelled"))
});
Self {
duration,
active,
completion,
}
}
}
// Register once after recorder installation and node identity publication. Before that point,
// use uncached handles so startup no-op instruments cannot poison the process-lifetime cache.
#[cfg(not(test))]
fn cached_unary_rpc_handles(operation: &'static str, client: bool) -> Option<UnaryRpcMetricHandles> {
const OPERATIONS: [&str; 4] = [
INTERNODE_OPERATION_GRPC_READ_ALL,
INTERNODE_OPERATION_GRPC_WRITE_ALL,
INTERNODE_OPERATION_GRPC_READ_MULTIPLE,
INTERNODE_OPERATION_GRPC_BATCH_READ_VERSION,
];
static CLIENT: LazyLock<[UnaryRpcMetricHandles; 4]> =
LazyLock::new(|| OPERATIONS.map(|op| UnaryRpcMetricHandles::new(op, true, true)));
static SERVER: LazyLock<[UnaryRpcMetricHandles; 4]> =
LazyLock::new(|| OPERATIONS.map(|op| UnaryRpcMetricHandles::new(op, false, true)));
if !crate::metrics_enabled() {
return None;
}
STABLE_SERVER_LABEL.get()?;
let index = OPERATIONS.iter().position(|candidate| *candidate == operation)?;
Some(if client {
CLIENT[index].clone()
} else {
SERVER[index].clone()
})
}
impl InternodeRpcDurationGuard {
/// Start timing a server handler. `operation` must be a bounded RPC operation constant.
pub fn handler(operation: &'static str) -> Self {
Self::new(operation, false)
}
fn new(operation: &'static str, client: bool) -> Self {
#[cfg(not(test))]
let handles = cached_unary_rpc_handles(operation, client)
.unwrap_or_else(|| UnaryRpcMetricHandles::new(operation, client, crate::metrics_enabled()));
#[cfg(test)]
let handles = UnaryRpcMetricHandles::new(operation, client, crate::metrics_enabled());
handles.active.increment(1.0);
Self {
started_at: Instant::now(),
handles,
outcome: RpcOutcome::Cancelled,
}
}
}
impl Drop for InternodeRpcDurationGuard {
fn drop(&mut self) {
self.handles.duration.record(self.started_at.elapsed().as_secs_f64() * 1000.0);
self.handles.active.decrement(1.0);
if let Some((success, error, cancelled)) = &self.handles.completion {
match self.outcome {
RpcOutcome::Success => success,
RpcOutcome::Error => error,
RpcOutcome::Cancelled => cancelled,
}
.increment(1);
}
}
}
/// Measure one complete client unary attempt, including body consumption by the supplied
/// future. Dropping a pending attempt records its elapsed time. General metrics enable the
/// active gauge and success/error/cancelled completion counters alongside the duration.
/// Transport deadlines returned by the future are errors; outer deadlines cancel the attempt.
/// Retries should call this once per attempt. `operation` must be a bounded RPC constant.
pub async fn measure_client_rpc<T, E>(
operation: &'static str,
future: impl std::future::Future<Output = Result<T, E>>,
) -> Result<T, E> {
let mut guard = InternodeRpcDurationGuard::new(operation, true);
let result = future.await;
guard.outcome = if result.is_ok() {
RpcOutcome::Success
} else {
RpcOutcome::Error
};
result
}
#[cfg(not(test))]
struct GrpcReadVersionMetricHandles {
sent_bytes: metrics::Counter,
@@ -1617,6 +1771,83 @@ mod tests {
use metrics_util::debugging::{DebugValue, DebuggingRecorder};
use std::collections::{HashMap, HashSet};
#[test]
fn unary_rpc_metrics_separate_handler_and_client_and_cover_cancellation() {
use std::future::{Future, pending};
use std::task::{Context, Poll, Waker};
let recorder = DebuggingRecorder::new();
let snapshotter = recorder.snapshotter();
let previously_enabled = crate::metrics_enabled();
crate::set_metrics_enabled(true);
with_local_recorder(&recorder, || {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("test runtime");
runtime.block_on(async {
let operation = INTERNODE_OPERATION_GRPC_READ_MULTIPLE;
assert_eq!(measure_client_rpc(operation, async { Ok::<_, ()>(7) }).await, Ok(7));
assert_eq!(measure_client_rpc(operation, async { Err::<(), _>("transport") }).await, Err("transport"));
let mut cancelled = Box::pin(measure_client_rpc(operation, pending::<Result<(), ()>>()));
assert!(matches!(cancelled.as_mut().poll(&mut Context::from_waker(Waker::noop())), Poll::Pending));
drop(cancelled);
drop(InternodeRpcDurationGuard::handler(operation));
});
});
crate::set_metrics_enabled(previously_enabled);
let entries = snapshotter.snapshot().into_vec();
let histogram_samples = |name| {
entries
.iter()
.find_map(|(key, _, _, value)| {
if key.key().name() != name {
return None;
}
match value {
DebugValue::Histogram(values) => Some(values.len()),
_ => None,
}
})
.expect("duration histogram")
};
assert_eq!(histogram_samples(INTERNODE_OPERATION_DURATION_MS), 3);
assert_eq!(histogram_samples(INTERNODE_OPERATION_SERVER_DURATION_MS), 1);
let mut outcomes = HashSet::new();
let mut active_sides = HashSet::new();
for (key, _, _, value) in entries {
match key.key().name() {
INTERNODE_RPC_COMPLETIONS_TOTAL => {
assert!(matches!(value, DebugValue::Counter(1)));
outcomes.insert(
key.key()
.labels()
.find(|label| label.key() == "outcome")
.expect("outcome")
.value()
.to_owned(),
);
}
INTERNODE_RPC_ACTIVE => {
assert!(matches!(value, DebugValue::Gauge(value) if value.into_inner() == 0.0));
active_sides.insert(
key.key()
.labels()
.find(|label| label.key() == "side")
.expect("side")
.value()
.to_owned(),
);
}
_ => {}
}
}
assert_eq!(
outcomes,
HashSet::from(["success".to_owned(), "error".to_owned(), "cancelled".to_owned()])
);
assert_eq!(active_sides, HashSet::from(["client".to_owned(), "server".to_owned()]));
}
#[test]
fn snapshot_reports_recorded_values() {
let metrics = global_internode_metrics();
@@ -1787,7 +2018,11 @@ mod tests {
#[test]
fn operation_metric_descriptors_include_backend_and_operation_labels() {
assert_eq!(INTERNODE_OPERATION_METRICS.len(), 23);
assert_eq!(INTERNODE_OPERATION_METRICS.len(), 25);
assert_eq!(INTERNODE_OPERATION_METRICS[23].name, INTERNODE_RPC_ACTIVE);
assert_eq!(INTERNODE_OPERATION_METRICS[23].labels, SERVER_OPERATION_BACKEND_SIDE_LABELS);
assert_eq!(INTERNODE_OPERATION_METRICS[24].name, INTERNODE_RPC_COMPLETIONS_TOTAL);
assert_eq!(INTERNODE_OPERATION_METRICS[24].labels, SERVER_OPERATION_BACKEND_OUTCOME_LABELS);
for metric in &INTERNODE_OPERATION_METRICS[..6] {
assert_eq!(metric.labels, &[SERVER_LABEL, OPERATION_LABEL, BACKEND_LABEL]);
}
+3 -2
View File
@@ -179,8 +179,9 @@ fn record_get_stage_local_summary(
/// Enable or disable general (non GET/PUT-stage) metric emission.
///
/// Called once during startup, typically gated by `rustfs_obs::observability_metric_enabled()`.
/// Enable after installing the recorder; the release/acquire pair publishes it to handle caches.
pub fn set_metrics_enabled(enabled: bool) {
METRICS_ENABLED.store(enabled, Ordering::Relaxed);
METRICS_ENABLED.store(enabled, Ordering::Release);
}
/// Returns `true` if detailed per-stage PUT metrics are enabled.
@@ -447,7 +448,7 @@ pub fn get_stage_metrics_enabled() -> bool {
/// Returns `true` if general (non GET/PUT-stage) metric emission is enabled.
#[inline(always)]
pub fn metrics_enabled() -> bool {
METRICS_ENABLED.load(Ordering::Relaxed)
METRICS_ENABLED.load(Ordering::Acquire)
}
// Handle-cached emission for hot, LABEL-LESS metrics. The `metrics` macros
@@ -0,0 +1,48 @@
// Copyright 2026 RustFS Team
// SPDX-License-Identifier: Apache-2.0
use metrics_util::debugging::{DebugValue, DebuggingRecorder};
use rustfs_io_metrics::internode_metrics::{INTERNODE_OPERATION_GRPC_READ_ALL, measure_client_rpc, set_internode_server_label};
#[tokio::test]
async fn unary_rpc_handles_wait_for_recorder_installation() {
set_internode_server_label("unary-recorder-test");
// Exercise the real non-test library path before a process recorder exists.
measure_client_rpc(INTERNODE_OPERATION_GRPC_READ_ALL, async { Ok::<_, ()>(()) })
.await
.expect("startup call");
let recorder = DebuggingRecorder::new();
let snapshotter = recorder.snapshotter();
metrics::set_global_recorder(recorder).expect("install test recorder once");
measure_client_rpc(INTERNODE_OPERATION_GRPC_READ_ALL, async { Ok::<_, ()>(()) })
.await
.expect("legacy duration before detailed metrics enablement");
let before_enable = snapshotter.snapshot().into_vec();
assert_eq!(
before_enable.len(),
1,
"disabled detailed metrics register only the existing duration instrument"
);
assert_eq!(before_enable[0].0.key().name(), "rustfs_system_network_internode_operation_duration_ms");
assert!(matches!(&before_enable[0].3, DebugValue::Histogram(samples) if samples.len() == 1));
rustfs_io_metrics::set_metrics_enabled(true);
for _ in 0..3 {
measure_client_rpc(INTERNODE_OPERATION_GRPC_READ_ALL, async { Ok::<_, ()>(()) })
.await
.expect("recorded call");
}
let entries = snapshotter.snapshot().into_vec();
let duration = entries
.iter()
.find(|(key, _, _, _)| {
key.key().name() == "rustfs_system_network_internode_operation_duration_ms"
&& key
.key()
.labels()
.any(|label| label.key() == "operation" && label.value() == INTERNODE_OPERATION_GRPC_READ_ALL)
})
.expect("installed recorder receives the cached RPC duration handle");
// Snapshotter drains histogram observations, so this snapshot contains the three later calls.
assert!(matches!(&duration.3, DebugValue::Histogram(samples) if samples.len() == 3));
}
+1 -1
View File
@@ -181,7 +181,7 @@ where
);
let body: BoxBody = Full::new(Bytes::from(error_xml))
.map_err(|e| -> BoxError { Box::new(e) })
.map_err(|never| match never {})
.boxed_unsync();
let response = Response::builder()
+1 -1
View File
@@ -1348,7 +1348,7 @@ mod tests {
let summary = KmsConfigSummary::from(&config);
let response = KmsStatusResponse {
status: KmsServiceStatus::Configured,
backend_type: Some(config.backend.clone()),
backend_type: Some(config.backend),
healthy: None,
config_summary: Some(summary),
};
+1 -1
View File
@@ -977,7 +977,7 @@ async fn enumerate_target_conflicts(target: &Path, manifest: &BackupManifest) ->
(stem.to_string(), format!("target already has key file {entry:?}"))
} else if entry == LOCAL_KMS_MASTER_KEY_SALT_FILE {
(
entry.clone(),
entry,
"target holds an orphan master-key KDF salt; it would poison every key created after the restore".to_string(),
)
} else {
+1 -1
View File
@@ -718,7 +718,7 @@ impl Lifecycle for PersistedLifecycleConfiguration {
let predicted = Event {
action: IlmAction::DeleteAction,
rule_id: rule.id.clone().unwrap_or_default(),
rule_id: rule.id.unwrap_or_default(),
due: Some(due),
noncurrent_days: 0,
newer_noncurrent_versions: 0,
+241 -48
View File
@@ -30,19 +30,92 @@ use crate::{
const DEFAULT_GUARD_SHARD_COUNT: usize = 64;
const EVENT_NAMESPACE_LOCK_ACQUIRE: &str = "namespace_lock_acquire";
const EVENT_NAMESPACE_LOCK_RELEASE: &str = "namespace_lock_release";
const EVENT_LOCK_GUARD_RECLAIMED: &str = "lock_guard_reclaimed";
const LOG_COMPONENT_LOCK: &str = "lock";
const LOG_SUBSYSTEM_NAMESPACE: &str = "namespace";
type GuardMap = HashMap<LockId, LocalGuardEntry>;
type GuardShard = Arc<GuardRegistry>;
type GuardStorage = Arc<Vec<GuardShard>>;
#[derive(Debug, Default)]
struct GuardRegistryState {
entries: HashMap<LockId, LocalGuardEntry>,
// A lower bound on every registered lease deadline. Removal and refresh
// may leave it early, so a due sweep recomputes it from the surviving leases.
next_expiry: Option<Instant>,
#[cfg(test)]
expiry_scans: usize,
}
impl GuardRegistryState {
fn insert(&mut self, lock_id: LockId, entry: LocalGuardEntry) -> Option<LocalGuardEntry> {
self.next_expiry = Some(
self.next_expiry
.map_or(entry.deadline, |deadline| deadline.min(entry.deadline)),
);
self.entries.insert(lock_id, entry)
}
fn remove(&mut self, lock_id: &LockId) -> Option<LocalGuardEntry> {
let entry = self.entries.remove(lock_id);
if self.entries.is_empty() {
self.next_expiry = None;
}
entry
}
fn refresh(&mut self, lock_id: &LockId) -> bool {
let Some(entry) = self.entries.get_mut(lock_id) else {
return false;
};
if entry.is_expired() {
return false;
}
entry.refresh();
self.next_expiry = Some(
self.next_expiry
.map_or(entry.deadline, |deadline| deadline.min(entry.deadline)),
);
true
}
fn sweep_due(&self) -> bool {
self.next_expiry.is_some_and(|deadline| deadline <= Instant::now())
}
fn extract_expired(&mut self) -> Vec<LocalGuardEntry> {
if !self.sweep_due() {
return Vec::new();
}
#[cfg(test)]
{
self.expiry_scans += 1;
}
let now = Instant::now();
let mut next_expiry: Option<Instant> = None;
let expired = self
.entries
.extract_if(|_, entry| {
if entry.deadline <= now {
true
} else {
next_expiry = Some(next_expiry.map_or(entry.deadline, |deadline| deadline.min(entry.deadline)));
false
}
})
.map(|(_, entry)| entry)
.collect();
self.next_expiry = next_expiry;
expired
}
}
/// Registry access never reserves the table for a task that has not resumed.
/// Only short synchronous map operations hold the native lock; contention
/// waits on a notification without blocking an executor thread.
#[derive(Debug, Default)]
struct GuardRegistry {
entries: RwLock<GuardMap>,
entries: RwLock<GuardRegistryState>,
changed: Notify,
}
@@ -83,7 +156,7 @@ impl GuardRegistry {
}
}
async fn read(&self) -> GuardRegistryAccess<'_, RwLockReadGuard<'_, GuardMap>> {
async fn read(&self) -> GuardRegistryAccess<'_, RwLockReadGuard<'_, GuardRegistryState>> {
tokio::task::consume_budget().await;
if let Some(guard) = self.entries.try_read() {
return self.access(guard);
@@ -100,7 +173,7 @@ impl GuardRegistry {
}
}
async fn write(&self) -> GuardRegistryAccess<'_, RwLockWriteGuard<'_, GuardMap>> {
async fn write(&self) -> GuardRegistryAccess<'_, RwLockWriteGuard<'_, GuardRegistryState>> {
tokio::task::consume_budget().await;
if let Some(guard) = self.entries.try_write() {
return self.access(guard);
@@ -284,8 +357,8 @@ impl LocalClient {
replaced_entry,
registration_wait,
mutation_started.elapsed(),
guards.len(),
guards.capacity(),
guards.entries.len(),
guards.entries.capacity(),
)
};
// Repeated shared requests can replace an entry. Releasing
@@ -415,8 +488,8 @@ impl LocalClient {
self.manager.clone().unwrap_or_else(crate::get_global_lock_manager)
}
/// Guard entries for one resource share a shard so expiration checks do not
/// need to take every shard's write lock on the acquisition hot path.
/// Guard entries for one resource share a shard so acquisition only checks
/// that shard's expiration deadline.
fn get_resource_shard_index(&self, resource: &crate::ObjectKey) -> usize {
resource.shard_index(self.shard_mask)
}
@@ -429,46 +502,32 @@ impl LocalClient {
async fn reclaim_expired_guards_for_resource(&self, resource: &crate::ObjectKey) -> usize {
let shard = &self.guard_storage[self.get_resource_shard_index(resource)];
let has_expired = {
let guards = shard.read().await;
guards
.iter()
.any(|(lock_id, entry)| &lock_id.resource == resource && entry.is_expired())
};
if !has_expired {
if !shard.read().await.sweep_due() {
return 0;
}
let expired_entries = {
let mut guards = shard.write().await;
guards
.extract_if(|lock_id, entry| &lock_id.resource == resource && entry.is_expired())
.map(|(_, entry)| entry)
.collect::<Vec<_>>()
guards.extract_expired()
};
Self::release_reclaimed_guards(expired_entries, Some(resource))
let reclaimed_for_resource = expired_entries.iter().filter(|entry| entry.guard.key() == resource).count();
Self::release_reclaimed_guards(expired_entries);
reclaimed_for_resource
}
async fn extract_expired_guards(storage: &GuardStorage) -> Vec<LocalGuardEntry> {
let mut expired_entries = Vec::new();
for shard in storage.iter() {
let has_expired = {
let guards = shard.read().await;
guards.values().any(LocalGuardEntry::is_expired)
};
if !has_expired {
if !shard.read().await.sweep_due() {
continue;
}
let mut guards = shard.write().await;
expired_entries.extend(guards.extract_if(|_, entry| entry.is_expired()).map(|(_, entry)| entry));
expired_entries.extend(guards.extract_expired());
}
expired_entries
}
fn release_reclaimed_guards(
entries: impl IntoIterator<Item = LocalGuardEntry>,
resource: Option<&crate::ObjectKey>,
) -> usize {
fn release_reclaimed_guards(entries: impl IntoIterator<Item = LocalGuardEntry>) -> usize {
let mut reclaimed = 0;
for mut entry in entries {
let _ = entry.guard.release();
@@ -476,11 +535,14 @@ impl LocalClient {
reclaimed += 1;
}
if reclaimed > 0 {
if let Some(resource) = resource {
tracing::debug!(event = "lock_guard_reclaimed", resource = %resource, count = reclaimed, "expired lock guards reclaimed");
} else {
tracing::debug!(event = "lock_guard_reaper_sweep", count = reclaimed, "expired lock guards reclaimed");
}
tracing::debug!(
event = EVENT_LOCK_GUARD_RECLAIMED,
component = LOG_COMPONENT_LOCK,
subsystem = LOG_SUBSYSTEM_NAMESPACE,
state = "reclaimed",
count = reclaimed,
"Expired lock guards reclaimed"
);
}
reclaimed
}
@@ -499,7 +561,7 @@ impl LocalClient {
break;
};
let expired_entries = Self::extract_expired_guards(&storage).await;
Self::release_reclaimed_guards(expired_entries, None);
Self::release_reclaimed_guards(expired_entries);
}
});
}
@@ -585,19 +647,17 @@ impl LockClient for LocalClient {
let shard = self.get_shard(lock_id);
let expired_entry = {
let mut guards = shard.write().await;
let Some(entry) = guards.get_mut(lock_id) else {
return Ok(false);
};
if entry.is_expired() {
guards.remove(lock_id)
} else {
entry.refresh();
if guards.refresh(lock_id) {
None
} else if let Some(entry) = guards.remove(lock_id) {
Some(entry)
} else {
return Ok(false);
}
};
if let Some(entry) = expired_entry {
Self::release_reclaimed_guards([entry], Some(&lock_id.resource));
Self::release_reclaimed_guards([entry]);
Ok(false)
} else {
Ok(true)
@@ -611,7 +671,7 @@ impl LockClient for LocalClient {
async fn check_status(&self, lock_id: &LockId) -> Result<Option<LockInfo>> {
let shard = self.get_shard(lock_id);
let guards = shard.read().await;
if let Some(entry) = guards.get(lock_id) {
if let Some(entry) = guards.entries.get(lock_id) {
// We have an active guard for this lock
let lock_type = match entry.guard.mode() {
crate::LockMode::Shared => LockType::Shared,
@@ -644,8 +704,8 @@ impl LockClient for LocalClient {
let mut leases = Vec::new();
for shard in self.guard_storage.iter() {
let guards = shard.read().await;
leases.reserve(guards.len());
leases.extend(guards.iter().map(|(lock_id, entry)| crate::LockLeaseInfo {
leases.reserve(guards.entries.len());
leases.extend(guards.entries.iter().map(|(lock_id, entry)| crate::LockLeaseInfo {
resource: lock_id.resource.clone(),
lock_type: match entry.guard.mode() {
crate::LockMode::Shared => LockType::Shared,
@@ -851,6 +911,139 @@ mod tests {
.with_acquire_timeout(Duration::from_millis(80))
}
#[tokio::test(start_paused = true)]
async fn unexpired_shared_acquisitions_and_reaper_do_not_scan_guard_entries() {
let client = LocalClient::with_manager(Arc::new(GlobalLockManager::new()));
client.reaper_started.store(true, Ordering::Release);
let resource = crate::ObjectKey::new("bucket", "unexpired-shared-registry");
let mut requests = Vec::with_capacity(512);
for _ in 0..512 {
let request = LockRequest::new(resource.clone(), LockType::Shared, "reader");
assert!(client.acquire_lock(&request).await.expect("register shared lease").success);
requests.push(request);
}
assert!(LocalClient::extract_expired_guards(&client.guard_storage).await.is_empty());
{
let registry = client.get_shard(&requests[0].lock_id).read().await;
assert_eq!(registry.entries.len(), 512);
assert_eq!(registry.expiry_scans, 0, "live lease acquisition and reaping must use the deadline hint");
assert!(registry.next_expiry.is_some());
}
for request in &requests {
assert!(client.release(&request.lock_id).await.expect("release shared lease"));
}
let registry = client.get_shard(&requests[0].lock_id).read().await;
assert!(registry.entries.is_empty());
assert_eq!(registry.next_expiry, None, "the final release must clear the deadline hint");
}
#[tokio::test(start_paused = true)]
async fn same_shard_expiration_sweep_only_counts_the_requested_resource() {
let manager = Arc::new(GlobalLockManager::new());
let client = LocalClient::with_manager(manager.clone());
client.reaper_started.store(true, Ordering::Release);
let resource = crate::ObjectKey::new("bucket", "live-sweep-trigger");
let shard_index = client.get_resource_shard_index(&resource);
let other_resource = (0..4096)
.map(|index| crate::ObjectKey::new("bucket", format!("expired-same-shard-{index}")))
.find(|candidate| client.get_resource_shard_index(candidate) == shard_index)
.expect("find an independent resource in the same guard shard");
let live = request(resource.clone(), "live", Duration::from_secs(30));
let expired = request(other_resource.clone(), "expired", Duration::from_secs(5));
assert!(client.acquire_lock(&live).await.expect("register live lease").success);
assert!(client.acquire_lock(&expired).await.expect("register earlier lease").success);
tokio::time::advance(Duration::from_secs(5)).await;
assert_eq!(client.reclaim_expired_guards_for_resource(&resource).await, 0);
assert!(
client
.check_status(&expired.lock_id)
.await
.expect("expired lease status")
.is_none()
);
assert!(client.check_status(&live.lock_id).await.expect("live lease status").is_some());
let replacement = manager
.acquire_lock(crate::ObjectLockRequest::new_write(other_resource, "replacement"))
.await
.expect("the sweep must release the other resource's expired physical guard");
drop(replacement);
assert_eq!(client.reclaim_expired_guards_for_resource(&resource).await, 0);
assert_eq!(client.get_shard(&live.lock_id).read().await.expiry_scans, 1);
assert!(client.release(&live.lock_id).await.expect("release live lease"));
}
#[tokio::test(start_paused = true)]
async fn refreshed_and_removed_earliest_leases_only_trigger_one_sweep_per_deadline() {
let client = LocalClient::with_manager(Arc::new(GlobalLockManager::new()));
client.reaper_started.store(true, Ordering::Release);
let resource = crate::ObjectKey::new("bucket", "refresh-expiry-hint");
let short = LockRequest::new(resource.clone(), LockType::Shared, "short").with_ttl(Duration::from_secs(10));
let long = LockRequest::new(resource.clone(), LockType::Shared, "long").with_ttl(Duration::from_secs(30));
assert!(client.acquire_lock(&short).await.expect("short lease").success);
assert!(client.acquire_lock(&long).await.expect("long lease").success);
tokio::time::advance(Duration::from_secs(5)).await;
assert!(client.refresh(&short.lock_id).await.expect("extend the earliest deadline"));
tokio::time::advance(Duration::from_secs(5)).await;
assert_eq!(client.reclaim_expired_guards_for_resource(&resource).await, 0);
assert_eq!(client.reclaim_expired_guards_for_resource(&resource).await, 0);
assert_eq!(client.get_shard(&short.lock_id).read().await.expiry_scans, 1);
assert!(
client
.release(&short.lock_id)
.await
.expect("remove the earliest surviving lease")
);
tokio::time::advance(Duration::from_secs(5)).await;
assert_eq!(client.reclaim_expired_guards_for_resource(&resource).await, 0);
assert_eq!(client.reclaim_expired_guards_for_resource(&resource).await, 0);
assert_eq!(client.get_shard(&long.lock_id).read().await.expiry_scans, 2);
tokio::time::advance(Duration::from_secs(15)).await;
assert_eq!(client.reclaim_expired_guards_for_resource(&resource).await, 1);
let registry = client.get_shard(&long.lock_id).read().await;
assert_eq!(registry.expiry_scans, 3);
assert!(registry.entries.is_empty());
assert_eq!(registry.next_expiry, None);
}
#[tokio::test(start_paused = true)]
async fn repeated_shared_id_replacement_preserves_earlier_expiration_and_reaper_cleanup() {
let manager = Arc::new(GlobalLockManager::new());
let client = LocalClient::with_manager(manager.clone());
client.reaper_started.store(true, Ordering::Release);
let resource = crate::ObjectKey::new("bucket", "replace-expiry-hint");
let original = LockRequest::new(resource.clone(), LockType::Shared, "owner").with_ttl(Duration::from_secs(30));
assert!(client.acquire_lock(&original).await.expect("register original lease").success);
let replacement = original.clone().with_ttl(Duration::from_secs(5));
assert!(
client
.acquire_lock(&replacement)
.await
.expect("replace the same grant identity")
.success
);
assert_eq!(client.get_shard(&original.lock_id).read().await.entries.len(), 1);
let backend = manager.as_fast_lock_manager().expect("enabled physical lock backend");
assert_eq!(backend.get_shard(&resource).active_guard_count(), 1);
tokio::time::advance(Duration::from_secs(5)).await;
let expired = LocalClient::extract_expired_guards(&client.guard_storage).await;
assert_eq!(LocalClient::release_reclaimed_guards(expired), 1);
assert_eq!(backend.get_shard(&resource).active_guard_count(), 0);
assert!(
client
.check_status(&original.lock_id)
.await
.expect("replaced lease status")
.is_none()
);
let registry = client.get_shard(&original.lock_id).read().await;
assert_eq!(registry.expiry_scans, 1);
assert_eq!(registry.next_expiry, None);
}
fn hold_guard_shard(shard: GuardShard) -> (std::sync::mpsc::Sender<()>, std::thread::JoinHandle<()>) {
let (entered_tx, entered_rx) = std::sync::mpsc::sync_channel(0);
let (release_tx, release_rx) = std::sync::mpsc::channel();
@@ -1037,7 +1230,7 @@ mod tests {
let access = timeout(Duration::from_millis(100), survivor)
.await
.expect("a cancelled waiter must not consume another waiter's unlock notification");
assert!(access.is_empty());
assert!(access.entries.is_empty());
}
#[tokio::test]
+54 -8
View File
@@ -21,10 +21,14 @@ use crate::fast_lock::optimized_notify::OptimizedNotify;
use crate::fast_lock::types::{LockMode, LockPriority};
/// Optimized atomic lock state encoding in u64
/// Bits: [63:48] reserved | [47:32] writers_waiting | [31:16] readers_waiting | [15:8] readers_count | [7:1] flags | [0] writer_flag
/// Bits: [63:48] readers_count | [47:32] writers_waiting | [31:16] readers_waiting | [15:1] reserved | [0] writer_flag
///
/// The reader count is 16 bits wide because distributed bucket-scoped read locks (for example the
/// per-bucket lifecycle and metadata-transaction fences) are held by every in-flight request into that
/// bucket on every node, so a single key routinely has hundreds of concurrent holders.
const WRITER_FLAG_MASK: u64 = 0x1;
const READERS_SHIFT: u8 = 8;
const READERS_MASK: u64 = 0xFF << READERS_SHIFT; // Support up to 255 concurrent readers
const READERS_SHIFT: u8 = 48;
const READERS_MASK: u64 = 0xFFFF << READERS_SHIFT;
const READERS_WAITING_SHIFT: u8 = 16;
const READERS_WAITING_MASK: u64 = 0xFFFF << READERS_WAITING_SHIFT;
const WRITERS_WAITING_SHIFT: u8 = 32;
@@ -96,9 +100,7 @@ impl AtomicLockState {
return false;
}
let readers = self.readers_count(current);
if readers == 0xFF {
// Updated limit to 255
if self.readers_count(current) == u16::MAX {
return false; // Too many readers
}
@@ -303,8 +305,8 @@ impl AtomicLockState {
self.last_accessed.store(now, Ordering::Relaxed);
}
fn readers_count(&self, state: u64) -> u8 {
((state & READERS_MASK) >> READERS_SHIFT) as u8
fn readers_count(&self, state: u64) -> u16 {
((state & READERS_MASK) >> READERS_SHIFT) as u16
}
fn readers_waiting(&self, state: u64) -> u16 {
@@ -622,6 +624,50 @@ mod tests {
assert!(state.is_free());
}
// Bucket-scoped fences are read-locked by every in-flight request into the bucket, so one key
// needs far more than 255 concurrent shared holders. The old 8-bit reader count refused the
// 256th reader outright.
#[test]
fn shared_lock_admits_more_than_255_holders() {
let state = ObjectLockState::new();
let timeout = Duration::from_secs(30);
let owners: Vec<Arc<str>> = (0..1024).map(|i| Arc::from(format!("owner-{i}"))).collect();
for owner in &owners {
assert!(
state.try_acquire_shared_fast(owner, timeout),
"shared acquisition for {owner} must succeed"
);
}
assert_eq!(state.atomic_state.holder_and_waiter_counts(), (1024, 0));
assert!(!state.try_acquire_exclusive_fast(&Arc::from("writer"), timeout));
for owner in &owners {
assert!(state.release_shared(owner), "shared release for {owner} must succeed");
}
assert!(state.try_acquire_exclusive_fast(&Arc::from("writer"), timeout));
}
#[test]
fn shared_lock_refuses_readers_beyond_counter_capacity() {
let state = AtomicLockState::new();
assert!(state.inc_readers_waiting());
for _ in 0..u16::MAX {
assert!(state.try_acquire_shared());
}
assert!(!state.try_acquire_shared(), "the reader count must not overflow");
assert_eq!(state.holder_and_waiter_counts(), (u64::from(u16::MAX), 1));
assert_eq!(
state.readers_waiting_count(),
1,
"a full reader count must not spill into the waiter counters"
);
assert!(state.release_shared());
assert!(state.try_acquire_shared(), "a released slot must be reusable");
}
#[test]
fn test_object_lock_state() {
let state = ObjectLockState::new();
+1 -1
View File
@@ -487,7 +487,7 @@ mod fast_lock_tests {
// Waiter: starts a touch later so the holder wins the lock first, then
// must survive the whole hold and acquire promptly after the release.
let waiter_mgr = manager.clone();
let waiter_key = key.clone();
let waiter_key = key;
handles.push(tokio::spawn(async move {
sleep(Duration::from_millis(10)).await;
let request = ObjectLockRequest::new_write(waiter_key, "waiter").with_acquire_timeout(Duration::from_secs(5));
+37
View File
@@ -300,6 +300,43 @@ fn create_test_object_key(bucket: &str, object: &str) -> ObjectKey {
}
}
#[tokio::test(start_paused = true)]
async fn distributed_namespace_shared_readers_exceed_255() {
let managers = (0..3).map(|_| Arc::new(GlobalLockManager::new())).collect::<Vec<_>>();
let clients = managers
.iter()
.map(|manager| Arc::new(LocalClient::with_manager(Arc::clone(manager))) as Arc<dyn LockClient>)
.collect();
let lock = NamespaceLock::with_clients("shared-reader-capacity".to_string(), clients);
let resource = create_test_object_key(".rustfs.sys", "bucket-targets/bucket/transaction.lock");
let mut guards = Vec::with_capacity(512);
for reader in 0..512 {
let guard = lock
.get_read_lock(resource.clone(), &format!("reader-{reader}"), Duration::from_secs(1))
.await
.unwrap_or_else(|error| panic!("shared reader {reader} must reach quorum: {error}"));
guards.push(guard);
}
assert_eq!(guards.len(), 512);
assert!(guards.iter().all(|guard| !guard.is_lock_lost()));
let contended_writer = lock
.lock_guard(resource.clone(), "blocked-writer", Duration::from_millis(100), Duration::from_secs(30))
.await
.expect("writer contention must preserve the acquisition result");
assert!(
contended_writer.is_none(),
"a writer must remain excluded while shared readers hold quorum"
);
drop(guards);
wait_until_all_managers_can_write(&managers, resource.clone()).await;
let _writer = lock
.get_write_lock(resource, "writer-after-readers", Duration::from_secs(1))
.await
.expect("all reader grants must drain before the next writer acquires quorum");
}
async fn wait_until_all_managers_can_write(managers: &[Arc<GlobalLockManager>], resource: ObjectKey) {
let deadline = tokio::time::Instant::now() + Duration::from_secs(2);
+1 -1
View File
@@ -1537,7 +1537,7 @@ impl RealtimeMetrics {
self.hosts.sort();
for (k, v) in other.by_disk.into_iter() {
self.by_disk.entry(k.to_string()).and_modify(|h| *h = v.clone()).or_insert(v);
self.by_disk.entry(k).and_modify(|h| *h = v.clone()).or_insert(v);
}
}
}
@@ -120,8 +120,8 @@ pub fn collect_erasure_set_metrics(stats: &[ErasureSetStats]) -> Vec<PrometheusM
);
metrics.push(
PrometheusMetric::from_descriptor(&ERASURE_SET_WRITE_HEALTH_MD, stat.write_health as f64)
.with_label_owned(POOL_ID_L, pool_id_label.clone())
.with_label_owned(SET_ID_L, set_id_label.clone()),
.with_label_owned(POOL_ID_L, pool_id_label)
.with_label_owned(SET_ID_L, set_id_label),
);
}
+20 -1
View File
@@ -104,6 +104,16 @@ const OBJECT_LOCK_DURATION_HISTOGRAM_BUCKETS: &[f64] = &[
0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0, 15.0, 20.0, 30.0, 60.0, 120.0,
];
const INTERNODE_DURATION_HISTOGRAM_METRICS: &[&str] = &[
"rustfs_system_network_internode_operation_duration_ms",
"rustfs_system_network_internode_operation_server_duration_ms",
];
// Internode instruments record milliseconds, including sub-millisecond control RPCs.
const INTERNODE_DURATION_HISTOGRAM_BUCKETS: &[f64] = &[
0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 0.75, 1.0, 1.5, 2.0, 3.0, 4.0, 5.0, 7.5, 10.0, 15.0, 20.0, 30.0, 50.0, 75.0, 100.0, 250.0,
500.0, 1000.0, 2500.0, 5000.0, 10000.0, 30000.0, 60000.0, 120000.0,
];
const DEFAULT_OTLP_HTTP_TIMEOUT: Duration = Duration::from_secs(10);
const OTEL_EXPORTER_OTLP_TIMEOUT: &str = "OTEL_EXPORTER_OTLP_TIMEOUT";
const OTEL_EXPORTER_OTLP_TRACES_TIMEOUT: &str = "OTEL_EXPORTER_OTLP_TRACES_TIMEOUT";
@@ -516,6 +526,8 @@ fn duration_histogram_buckets(name: &str) -> Option<&'static [f64]> {
Some(GET_OBJECT_DURATION_HISTOGRAM_BUCKETS)
} else if OBJECT_LOCK_DURATION_HISTOGRAM_METRICS.contains(&name) {
Some(OBJECT_LOCK_DURATION_HISTOGRAM_BUCKETS)
} else if INTERNODE_DURATION_HISTOGRAM_METRICS.contains(&name) {
Some(INTERNODE_DURATION_HISTOGRAM_BUCKETS)
} else {
None
}
@@ -1133,6 +1145,9 @@ mod tests {
for name in OBJECT_LOCK_DURATION_HISTOGRAM_METRICS {
assert_eq!(duration_histogram_buckets(name), Some(OBJECT_LOCK_DURATION_HISTOGRAM_BUCKETS));
}
for name in INTERNODE_DURATION_HISTOGRAM_METRICS {
assert_eq!(duration_histogram_buckets(name), Some(INTERNODE_DURATION_HISTOGRAM_BUCKETS));
}
for name in [
"rustfs_io_put_object_request_duration_seconds",
"rustfs_io_get_object_response_size_bytes",
@@ -1164,6 +1179,8 @@ mod tests {
for (name, observations) in [
(OBJECT_LOCK_DURATION_HISTOGRAM_METRICS[0], [0.002, 0.004, 0.012]),
(OBJECT_LOCK_DURATION_HISTOGRAM_METRICS[1], [0.002, 12.9, 31.0]),
(INTERNODE_DURATION_HISTOGRAM_METRICS[0], [0.04, 3.2, 31000.0]),
(INTERNODE_DURATION_HISTOGRAM_METRICS[1], [0.02, 1.2, 1300.0]),
] {
let histogram = recorder.register_histogram(&metrics::Key::from_name(name), &metadata);
for seconds in observations {
@@ -1181,6 +1198,8 @@ mod tests {
let expected = match metric.name() {
"rustfs_object_lock_diag_acquire_duration_seconds" => [(0.002, 1), (0.004, 1), (0.015, 1)],
"rustfs_object_lock_diag_hold_duration_seconds" => [(0.002, 1), (15.0, 1), (60.0, 1)],
"rustfs_system_network_internode_operation_duration_ms" => [(0.05, 1), (4.0, 1), (60000.0, 1)],
"rustfs_system_network_internode_operation_server_duration_ms" => [(0.025, 1), (1.5, 1), (2500.0, 1)],
_ => continue,
};
let AggregatedMetrics::F64(MetricData::Histogram(histogram)) = metric.data() else {
@@ -1196,7 +1215,7 @@ mod tests {
assert_eq!(occupied, expected, "seconds remain distinct at millisecond and long-hold scales");
checked += 1;
}
assert_eq!(checked, 2, "both acquisition and hold histograms were exported");
assert_eq!(checked, 4, "lock and internode duration histograms were exported");
provider.shutdown().expect("stop lock histogram test provider");
}
+1 -1
View File
@@ -102,7 +102,7 @@ fn check() -> Result<(), OpaConfigError> {
let prefix = format!("{ENV_PREFIX}{POLICY_PLUGIN_SUB_SYS}").to_uppercase();
for (key, value) in env_list {
if key.starts_with(&prefix) {
candidate.insert(key.to_string(), value);
candidate.insert(key, value);
}
}
+1 -1
View File
@@ -141,7 +141,7 @@ impl Policy {
merged.version = p.version.clone();
}
for st in p.statements {
merged.statements.push(st.clone());
merged.statements.push(st);
}
}
merged.drop_duplicate_statements();
+4 -1
View File
@@ -85,5 +85,8 @@ uuid = { workspace = true }
doctest = false
[dev-dependencies]
rcgen = { workspace = true }
serde_json = { workspace = true }
temp-env = { workspace = true }
temp-env = { workspace = true, features = ["async_closure"] }
tempfile = { workspace = true }
tokio = { workspace = true, features = ["macros"] }
@@ -0,0 +1,233 @@
// Copyright 2026 RustFS Team
// SPDX-License-Identifier: Apache-2.0
//! Local transport experiment using the production channel pool and two loopback tonic peers.
//! Arguments: bulk workers (16), requests per bulk worker (64), payload bytes (262144), control workers (0).
use rustfs_protos::proto_gen::node_service::{PingRequest, PingResponse, node_service_client::NodeServiceClient};
use rustfs_protos::{ChannelClass, get_channel_for_class};
use std::convert::Infallible;
use std::io::Error;
use std::sync::{
Arc,
atomic::{AtomicUsize, Ordering},
};
use std::task::{Context, Poll};
use std::time::{Duration, Instant};
use tonic::codegen::{
Body, BoxFuture, Service, StdError, http,
tokio_stream::{StreamExt, wrappers::TcpListenerStream},
};
#[derive(Clone)]
struct Echo;
impl tonic::server::NamedService for Echo {
const NAME: &'static str = "node_service.NodeService";
}
impl tonic::server::UnaryService<PingRequest> for Echo {
type Response = PingResponse;
type Future = BoxFuture<tonic::Response<PingResponse>, tonic::Status>;
fn call(&mut self, request: tonic::Request<PingRequest>) -> Self::Future {
Box::pin(async move {
let request = request.into_inner();
Ok(tonic::Response::new(PingResponse {
version: request.version,
body: request.body,
}))
})
}
}
impl<B> Service<http::Request<B>> for Echo
where
B: Body + Send + 'static,
B::Error: Into<StdError> + Send + 'static,
{
type Response = http::Response<tonic::body::Body>;
type Error = Infallible;
type Future = BoxFuture<Self::Response, Self::Error>;
fn poll_ready(&mut self, _: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
Poll::Ready(Ok(()))
}
fn call(&mut self, request: http::Request<B>) -> Self::Future {
Box::pin(async move {
let codec = tonic_prost::ProstCodec::<PingResponse, PingRequest>::default();
let mut grpc = tonic::server::Grpc::new(codec)
.max_decoding_message_size(rustfs_protos::DEFAULT_GRPC_SERVER_MESSAGE_LEN)
.max_encoding_message_size(rustfs_protos::DEFAULT_GRPC_SERVER_MESSAGE_LEN);
Ok(grpc.unary(Echo, request).await)
})
}
}
async fn roundtrip(address: &str, class: ChannelClass, payload: prost::bytes::Bytes) -> Result<(), StdError> {
let channel = get_channel_for_class(address, class)
.await
.map_err(|error| Error::other(error.to_string()))?;
let response = NodeServiceClient::new(channel)
.max_decoding_message_size(rustfs_protos::DEFAULT_GRPC_SERVER_MESSAGE_LEN)
.max_encoding_message_size(rustfs_protos::DEFAULT_GRPC_SERVER_MESSAGE_LEN)
.ping(PingRequest {
version: 1,
body: payload.clone(),
})
.await?
.into_inner();
if response.version != 1 || response.body != payload {
return Err(Error::other("echo response content mismatch").into());
}
Ok(())
}
#[tokio::main]
async fn main() -> Result<(), StdError> {
let mut args = std::env::args().skip(1);
let mut argument =
|default| -> Result<usize, StdError> { Ok(args.next().map(|value| value.parse()).transpose()?.unwrap_or(default)) };
let workers = argument(16)?;
let requests = argument(64)?;
let bytes = argument(262_144)?;
let control_workers = argument(0)?;
let total_workers = workers
.checked_add(control_workers)
.ok_or_else(|| Error::other("worker count overflow"))?;
if args.next().is_some()
|| workers == 0
|| !(1..=256).contains(&total_workers)
|| requests == 0
|| bytes == 0
|| bytes > 32 * 1024 * 1024
|| total_workers.checked_mul(requests).is_none_or(|count| count > 1_000_000)
|| workers.checked_mul(bytes).is_none_or(|size| size > 512 * 1024 * 1024)
{
return Err(Error::other("usage: internode_channel_bench [bulk-workers] [requests-per-bulk-worker] [payload-bytes] [control-workers]; maximum 256 workers, 1M samples, 32 MiB/message and 512 MiB concurrent payload").into());
}
let mut addresses = Vec::new();
let mut servers = Vec::new();
let mut connection_counts = Vec::new();
for _ in 0..2 {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
addresses.push(format!("http://{}", listener.local_addr()?));
let accepted = Arc::new(AtomicUsize::new(0));
connection_counts.push(Arc::clone(&accepted));
let incoming = TcpListenerStream::new(listener).map(move |socket| {
if socket.is_ok() {
accepted.fetch_add(1, Ordering::Relaxed);
}
socket
});
servers.push(tokio::spawn(async move {
tonic::transport::Server::builder()
.initial_stream_window_size(rustfs_config::DEFAULT_H2_INITIAL_STREAM_WINDOW_SIZE)
.initial_connection_window_size(rustfs_config::DEFAULT_H2_INITIAL_CONN_WINDOW_SIZE)
.max_concurrent_streams(Some(rustfs_config::DEFAULT_H2_MAX_CONCURRENT_STREAMS))
.max_frame_size(rustfs_config::DEFAULT_H2_MAX_FRAME_SIZE)
.http2_max_header_list_size(rustfs_config::DEFAULT_H2_MAX_HEADER_LIST_SIZE)
.http2_keepalive_interval(Some(Duration::from_secs(rustfs_config::DEFAULT_H2_KEEP_ALIVE_INTERVAL)))
.http2_keepalive_timeout(Some(Duration::from_secs(rustfs_config::DEFAULT_H2_KEEP_ALIVE_TIMEOUT)))
.add_service(Echo)
.serve_with_incoming(incoming)
.await
}));
}
let payload = prost::bytes::Bytes::from(vec![0x5a; bytes]);
// Consecutive warmups fill both default bulk slots without depending on peer interleaving.
for address in &addresses {
for _ in 0..4 {
roundtrip(address, ChannelClass::Bulk, payload.clone()).await?;
}
if control_workers > 0 {
roundtrip(address, ChannelClass::Control, prost::bytes::Bytes::from_static(b"control")).await?;
}
}
let addresses = Arc::new(addresses);
let barrier = Arc::new(tokio::sync::Barrier::new(total_workers + 1));
let mut tasks = tokio::task::JoinSet::new();
let finished_bulk = Arc::new(AtomicUsize::new(0));
let control_sample_limit = (1_000_000 - workers * requests) / control_workers.max(1);
for worker in 0..total_workers {
let addresses = Arc::clone(&addresses);
let barrier = Arc::clone(&barrier);
let control = worker >= workers;
let class = if control { ChannelClass::Control } else { ChannelClass::Bulk };
let payload = if control {
prost::bytes::Bytes::from_static(b"control")
} else {
payload.clone()
};
let finished_bulk = Arc::clone(&finished_bulk);
tasks.spawn(async move {
let mut latencies = Vec::with_capacity(requests);
barrier.wait().await;
let started = Instant::now();
loop {
if control {
if !latencies.is_empty() && finished_bulk.load(Ordering::Acquire) == workers {
break;
}
if latencies.len() >= control_sample_limit {
return Err(
Error::other("control sample budget exhausted; reduce the workload or control workers").into()
);
}
} else if latencies.len() == requests {
break;
}
let start = Instant::now();
roundtrip(&addresses[(worker + latencies.len()) % addresses.len()], class, payload.clone()).await?;
latencies.push(start.elapsed().as_secs_f64() * 1000.0);
}
if !control {
finished_bulk.fetch_add(1, Ordering::Release);
}
Ok::<_, StdError>((control, started, Instant::now(), latencies))
});
}
barrier.wait().await;
let mut bulk_latencies = Vec::with_capacity(workers * requests);
let mut control_latencies = Vec::new();
let mut first = None::<Instant>;
let mut last = None::<Instant>;
while let Some(result) = tasks.join_next().await {
let (control, started, finished, samples) = result??;
first = Some(first.map_or(started, |previous| previous.min(started)));
last = Some(last.map_or(finished, |previous| previous.max(finished)));
if control {
control_latencies.extend(samples);
} else {
bulk_latencies.extend(samples);
}
}
let first = first.ok_or_else(|| Error::other("no worker started"))?;
let last = last.ok_or_else(|| Error::other("no worker completed"))?;
let seconds = last.duration_since(first).as_secs_f64();
let summary = |latencies: &mut [f64]| {
latencies.sort_unstable_by(f64::total_cmp);
let percentile =
|percent: usize| (!latencies.is_empty()).then(|| latencies[(latencies.len() * percent).div_ceil(100) - 1]);
serde_json::json!({"requests": latencies.len(), "p50_ms": percentile(50), "p95_ms": percentile(95), "p99_ms": percentile(99)})
};
let payload_mib = f64::from(u32::try_from(bytes)?) / (1024.0 * 1024.0);
println!(
"{}",
serde_json::json!({
"bulk_workers": workers, "control_workers": control_workers, "payload_bytes": bytes, "control_payload_bytes": 7,
"elapsed_seconds": seconds,
"roundtrip_payload_mib_per_second": payload_mib * f64::from(u32::try_from(bulk_latencies.len())?) / seconds,
"quantile_method": "nearest_rank", "bulk": summary(&mut bulk_latencies), "control": summary(&mut control_latencies),
"connections_per_peer": connection_counts.iter().map(|count| count.load(Ordering::Relaxed)).collect::<Vec<_>>()
})
);
rustfs_common::clear_all_connections().await;
for server in servers {
server.abort();
let _ = server.await;
}
Ok(())
}
+703 -64
View File
@@ -23,13 +23,14 @@ mod runtime_sources;
use proto_gen::node_service::node_service_client::NodeServiceClient;
use rustfs_common::{cache_connection, cached_connection, evict_connection_with_log_level};
use std::{
borrow::Cow,
collections::HashMap,
error::Error,
sync::atomic::{AtomicU8, AtomicUsize, Ordering},
sync::{Arc, LazyLock, Mutex as StdMutex, Weak},
sync::{Arc, LazyLock, Mutex as StdMutex, RwLock as StdRwLock, Weak},
time::{Duration, Instant},
};
use tokio::sync::Mutex;
use tokio::sync::{Mutex, RwLock};
use tonic::{
Request, Status,
service::interceptor::InterceptedService,
@@ -89,7 +90,7 @@ const TLS_GENERATION_CACHE_MAX_SIZE: usize = 512;
const INTERNODE_RPC_MSGPACK_ONLY_CACHE_UNSET: u8 = 0;
const INTERNODE_RPC_MSGPACK_ONLY_CACHE_FALSE: u8 = 1;
const INTERNODE_RPC_MSGPACK_ONLY_CACHE_TRUE: u8 = 2;
static TLS_GENERATION_CACHE: LazyLock<Mutex<HashMap<String, u64>>> = LazyLock::new(|| Mutex::new(HashMap::new()));
static TLS_GENERATION_CACHE: LazyLock<RwLock<HashMap<String, u64>>> = LazyLock::new(|| RwLock::new(HashMap::new()));
// Lock order: generation metadata, then the common connection cache. The dial
// registry's standard mutex is released before waiting on any async lock.
static DIAL_LOCK_REGISTRY: LazyLock<StdMutex<HashMap<String, Weak<Mutex<()>>>>> = LazyLock::new(|| StdMutex::new(HashMap::new()));
@@ -3276,7 +3277,7 @@ pub enum ChannelClass {
Bulk,
}
// Keep multiplexed unary RPCs below h2's per-connection small-frame budget.
// Limits transport response futures; client RPC metrics separately cover unary body decoding.
const INTERNODE_RPC_CONCURRENCY_LIMIT: usize = 64;
/// Whether control/bulk channel isolation is enabled (env-gated, default off for safe rollout).
@@ -3292,10 +3293,64 @@ fn bulk_channel_pool_size() -> usize {
rustfs_utils::get_env_usize(rustfs_config::ENV_INTERNODE_BULK_CHANNELS, rustfs_config::DEFAULT_INTERNODE_BULK_CHANNELS).max(1)
}
/// Round-robin cursor for selecting a bulk channel within a peer's pool. A single global cursor
/// is sufficient: it advances once per bulk acquisition and `% pool_size` spreads consecutive
/// acquisitions across the pool.
static BULK_CHANNEL_CURSOR: AtomicUsize = AtomicUsize::new(0);
/// Bounds peer-selection bookkeeping across topology changes. Evicting a cursor
/// only resets its next slot; established channels keep their existing cache identity.
const BULK_CHANNEL_CURSOR_CACHE_CAPACITY: usize = 512;
// Cover the default pool and common tuning pools without allocating
// from an arbitrarily large environment-configured pool size.
const BULK_CHANNEL_CACHED_KEYS: usize = 4;
static BULK_CHANNEL_CURSORS: LazyLock<BulkChannelCursors> = LazyLock::new(BulkChannelCursors::default);
#[derive(Default)]
struct BulkChannelCursors {
peers: StdRwLock<HashMap<String, Arc<BulkChannelCursor>>>,
}
impl BulkChannelCursors {
fn for_peer(&self, addr: &str) -> Arc<BulkChannelCursor> {
let cached = self
.peers
.read()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.get(addr)
.cloned();
cached.unwrap_or_else(|| {
let mut peers = self.peers.write().unwrap_or_else(|poisoned| poisoned.into_inner());
if let Some(cursor) = peers.get(addr) {
return Arc::clone(cursor);
}
if peers.len() >= BULK_CHANNEL_CURSOR_CACHE_CAPACITY
&& let Some(victim) = peers.keys().next().cloned()
{
peers.remove(&victim);
}
let cursor = Arc::new(BulkChannelCursor {
next: AtomicUsize::new(0),
keys: std::array::from_fn(|index| bulk_cache_key(addr, index)),
});
peers.insert(addr.to_owned(), Arc::clone(&cursor));
cursor
})
}
}
struct BulkChannelCursor {
next: AtomicUsize,
keys: [String; BULK_CHANNEL_CACHED_KEYS],
}
impl BulkChannelCursor {
fn next(&self, pool_size: usize) -> usize {
self.next.fetch_add(1, Ordering::Relaxed) % pool_size
}
fn cache_key(&self, addr: &str, index: usize) -> Cow<'_, str> {
match self.keys.get(index) {
Some(key) => Cow::Borrowed(key),
None => Cow::Owned(bulk_cache_key(addr, index)),
}
}
}
/// Connection-cache key for the `idx`-th bulk channel to `addr`. The NUL separator cannot appear
/// in a URL, so a bulk key never collides with the control key (the bare `addr`).
@@ -3307,7 +3362,19 @@ async fn cached_channel_for_tls_generation(dial_addr: &str, cache_key: &str) ->
if !dial_addr.starts_with(RUSTFS_HTTPS_PREFIX) {
return cached_connection(cache_key).await;
}
let mut generations = TLS_GENERATION_CACHE.lock().await;
// Cache readers share the generation lock. Both publication and eviction take it before
// the common connection cache, preserving the lock order and the generation/channel pair.
{
let generations = TLS_GENERATION_CACHE.read().await;
let generation = runtime_sources::outbound_tls_generation();
if generations.get(cache_key).copied() == Some(generation)
&& let Some(channel) = cached_connection(cache_key).await
&& runtime_sources::outbound_tls_generation() == generation
{
return Some(channel);
}
}
let mut generations = TLS_GENERATION_CACHE.write().await;
let Some(channel) = cached_connection(cache_key).await else {
generations.remove(cache_key);
return None;
@@ -3326,7 +3393,7 @@ async fn cache_dialed_channel(dial_addr: &str, cache_key: &str, channel: &Channe
cache_connection(cache_key.to_string(), channel.clone()).await;
return true;
}
let mut generations = TLS_GENERATION_CACHE.lock().await;
let mut generations = TLS_GENERATION_CACHE.write().await;
if generation != Some(runtime_sources::outbound_tls_generation()) {
runtime_sources::record_stale_grpc_channel_tls_generation();
return false;
@@ -3350,6 +3417,33 @@ fn channel_acquisition_timeout(_: tokio::time::error::Elapsed) -> std::io::Error
std::io::Error::new(std::io::ErrorKind::TimedOut, "internode channel acquisition deadline elapsed")
}
fn checked_rpc_window(value: Option<u32>) -> std::io::Result<Option<u32>> {
if value.is_some_and(|bytes| bytes > 0x7fff_ffff) {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"internode HTTP/2 receive window exceeds the 31-bit flow-control limit",
));
}
Ok(value.filter(|bytes| *bytes != 0))
}
fn bulk_window_override(name: &str, inherited: Option<u32>) -> Option<u32> {
match rustfs_utils::get_env_u32(name, inherited.unwrap_or(0)) {
0 => None,
value => Some(value),
}
}
fn checked_rpc_capacity(value: usize) -> std::io::Result<usize> {
if value > tokio::sync::Semaphore::MAX_PERMITS {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"internode bulk admission exceeds the semaphore capacity limit",
));
}
Ok(value.max(1))
}
/// Acquire a cached-or-newly-dialed channel for the given peer and channel class.
///
/// For [`ChannelClass::Control`] (or whenever isolation is disabled) this is exactly the legacy
@@ -3362,17 +3456,16 @@ pub async fn get_channel_for_class(addr: &str, class: ChannelClass) -> Result<Ch
tokio::time::timeout_at(deadline, async {
if class == ChannelClass::Control || !channel_isolation_enabled() {
if let Some(channel) = cached_channel_for_tls_generation(addr, addr).await {
debug!("Using cached control gRPC channel for: {}", addr);
return Ok(channel);
}
return build_channel_until(addr, addr, deadline, internode_rpc_timeout()).await;
}
let pool_size = bulk_channel_pool_size();
let idx = BULK_CHANNEL_CURSOR.fetch_add(1, Ordering::Relaxed) % pool_size;
let cache_key = bulk_cache_key(addr, idx);
let cursor = BULK_CHANNEL_CURSORS.for_peer(addr);
let idx = cursor.next(pool_size);
let cache_key = cursor.cache_key(addr, idx);
if let Some(channel) = cached_channel_for_tls_generation(addr, &cache_key).await {
debug!("Using cached bulk gRPC channel {} for: {}", idx, addr);
return Ok(channel);
}
build_channel_until(addr, &cache_key, deadline, internode_rpc_timeout()).await
@@ -3388,8 +3481,8 @@ pub async fn get_channel_for_class(addr: &str, class: ChannelClass) -> Result<Ch
/// - Connect timeout: `DEFAULT_INTERNODE_CONNECT_TIMEOUT_SECS` (3s)
/// - TCP keepalive: `DEFAULT_INTERNODE_TCP_KEEPALIVE_SECS` (10s)
/// - HTTP/2 keepalive interval: `DEFAULT_INTERNODE_HTTP2_KEEPALIVE_INTERVAL_SECS` (5s)
/// - HTTP/2 keepalive timeout: `DEFAULT_INTERNODE_HTTP2_KEEPALIVE_TIMEOUT_SECS` (3s)
/// - RPC timeout: `DEFAULT_INTERNODE_RPC_TIMEOUT_SECS` (10s)
/// - HTTP/2 keepalive timeout: `DEFAULT_INTERNODE_HTTP2_KEEPALIVE_TIMEOUT_SECS` (20s)
/// - RPC timeout: `DEFAULT_INTERNODE_RPC_TIMEOUT_SECS` (30s)
pub async fn create_new_channel(addr: &str) -> Result<Channel, Box<dyn Error>> {
// The control channel is cached under the bare address, preserving the legacy key.
build_channel(addr, addr).await
@@ -3479,10 +3572,27 @@ async fn dial_channel(dial_addr: &str, cache_key: &str, rpc_timeout: Duration) -
let tcp_keepalive = internode_tcp_keepalive();
let http2_keepalive_interval = internode_http2_keep_alive_interval();
let http2_keepalive_timeout = internode_http2_keep_alive_timeout();
let is_bulk = cache_key
.strip_prefix(dial_addr)
.is_some_and(|suffix| suffix.starts_with("\0bulk\0"));
let mut stream_window = internode_rpc_http2_stream_window();
let mut conn_window = internode_rpc_http2_conn_window();
let concurrency_limit = if is_bulk {
stream_window = bulk_window_override(rustfs_config::ENV_INTERNODE_BULK_HTTP2_STREAM_WINDOW_SIZE, stream_window);
conn_window = bulk_window_override(rustfs_config::ENV_INTERNODE_BULK_HTTP2_CONN_WINDOW_SIZE, conn_window);
checked_rpc_capacity(rustfs_utils::get_env_usize(
rustfs_config::ENV_INTERNODE_BULK_CONCURRENCY_LIMIT,
rustfs_config::DEFAULT_INTERNODE_BULK_CONCURRENCY_LIMIT,
))?
} else {
INTERNODE_RPC_CONCURRENCY_LIMIT
};
let stream_window = checked_rpc_window(stream_window)?;
let conn_window = checked_rpc_window(conn_window)?;
let mut connector = Endpoint::from_shared(dial_addr.to_string())?
// Fast connection timeout for dead peer detection
.connect_timeout(connect_timeout)
.concurrency_limit(INTERNODE_RPC_CONCURRENCY_LIMIT)
.concurrency_limit(concurrency_limit)
// TCP-level keepalive - OS will probe connection
.tcp_keepalive(Some(tcp_keepalive))
// Disable Nagle so latency-sensitive control-plane RPCs (locks/health) are not batched
@@ -3496,13 +3606,18 @@ async fn dial_channel(dial_addr: &str, cache_key: &str, rpc_timeout: Duration) -
// Overall timeout for any RPC - fail fast on unresponsive peers
.timeout(rpc_timeout);
// Raise HTTP/2 flow-control windows above the 64KiB library default so larger unary
// responses (ReadMultiple/BatchReadVersion) are not throttled by the BDP. Mirrors the
// server-side window tuning in `rustfs/src/server/http.rs`.
if let Some(stream_window) = internode_rpc_http2_stream_window() {
// Apply the selected receive-credit policy. A zero setting delegates to the linked
// library's defaults; isolated bulk channels can override the common RPC settings.
if is_bulk {
connector = connector.buffer_size(checked_rpc_capacity(rustfs_utils::get_env_usize(
rustfs_config::ENV_INTERNODE_BULK_BUFFER_SIZE,
rustfs_config::DEFAULT_INTERNODE_BULK_BUFFER_SIZE,
))?);
}
if let Some(stream_window) = stream_window {
connector = connector.initial_stream_window_size(stream_window);
}
if let Some(conn_window) = internode_rpc_http2_conn_window() {
if let Some(conn_window) = conn_window {
connector = connector.initial_connection_window_size(conn_window);
}
@@ -3510,36 +3625,17 @@ async fn dial_channel(dial_addr: &str, cache_key: &str, rpc_timeout: Duration) -
if dial_addr.starts_with(RUSTFS_HTTPS_PREFIX) {
let outbound_tls = runtime_sources::outbound_tls_state().await;
generation = Some(outbound_tls.generation.0);
if let Some(cert_pem) = outbound_tls.root_ca_pem.as_ref() {
let ca = Certificate::from_pem(cert_pem);
// Derive the hostname from the HTTPS URL for TLS hostname verification.
let domain = dial_addr
.trim_start_matches(RUSTFS_HTTPS_PREFIX)
.split('/')
.next()
.unwrap_or("")
.split(':')
.next()
.unwrap_or("");
let tls = if !domain.is_empty() {
let mut cfg = ClientTlsConfig::new().ca_certificate(ca).domain_name(domain);
if let Some(id) = outbound_tls.mtls_identity.as_ref() {
let identity = tonic::transport::Identity::from_pem(id.cert_pem.clone(), id.key_pem.clone());
cfg = cfg.identity(identity);
}
cfg
} else {
// Fallback: configure TLS without explicit domain if parsing fails.
ClientTlsConfig::new().ca_certificate(ca)
};
connector = connector.tls_config(tls)?;
debug!("Configured TLS with custom root certificate for: {}", dial_addr);
let mut tls = if let Some(cert_pem) = outbound_tls.root_ca_pem.as_ref() {
ClientTlsConfig::new().ca_certificate(Certificate::from_pem(cert_pem))
} else {
// No custom root CA published — fall back to system roots.
// This is the expected path when no TLS path is configured.
debug!("No custom root certificate configured; using system roots for TLS: {}", dial_addr);
connector = connector.tls_config(ClientTlsConfig::new())?;
ClientTlsConfig::new().with_native_roots()
};
if let Some(id) = outbound_tls.mtls_identity.as_ref() {
tls = tls.identity(tonic::transport::Identity::from_pem(id.cert_pem.clone(), id.key_pem.clone()));
}
// Tonic derives certificate names and SNI from the URI, including IPv6. Native-root
// loading and certificate parsing run off the async workers under the same deadline.
connector = tokio::task::spawn_blocking(move || connector.tls_config(tls)).await??;
}
let channel = match connector.connect().await {
@@ -3566,6 +3662,14 @@ pub async fn evict_failed_connection(addr: &str) {
evict_failed_connection_with_log_level(addr, ConnectionEvictionLogLevel::Warn).await;
}
async fn evict_cached_channel(cache_key: &str, log_level: ConnectionEvictionLogLevel) {
// Match publication's lock order. Holding the generation guard across the short in-memory
// cache mutation prevents an older eviction from deleting a replacement's generation.
let mut generations = TLS_GENERATION_CACHE.write().await;
evict_connection_with_log_level(cache_key, log_level).await;
generations.remove(cache_key);
}
/// Evict a connection from the cache after a failure with an explicit log level.
pub async fn evict_failed_connection_with_log_level(addr: &str, log_level: ConnectionEvictionLogLevel) {
match log_level {
@@ -3593,8 +3697,7 @@ pub async fn evict_failed_connection_with_log_level(addr: &str, log_level: Conne
ConnectionEvictionLogLevel::Warn => ConnectionEvictionLogLevel::Info,
level => level,
};
evict_connection_with_log_level(addr, cache_log_level).await;
TLS_GENERATION_CACHE.lock().await.remove(addr);
evict_cached_channel(addr, cache_log_level).await;
// An RPC-triggered eviction is a peer-failure signal; feed it into the online/offline state so
// the peer flips offline after enough consecutive failures (grpc-optimization P3).
@@ -3606,8 +3709,7 @@ pub async fn evict_failed_connection_with_log_level(addr: &str, log_level: Conne
if channel_isolation_enabled() {
for idx in 0..bulk_channel_pool_size() {
let cache_key = bulk_cache_key(addr, idx);
evict_connection_with_log_level(&cache_key, cache_log_level).await;
TLS_GENERATION_CACHE.lock().await.remove(&cache_key);
evict_cached_channel(&cache_key, cache_log_level).await;
}
}
}
@@ -3751,7 +3853,7 @@ mod tests {
let stale_generation = generation.checked_add(1).unwrap_or(0);
for key in [addr.to_string(), bulk_cache_key(addr, 0)] {
cache_connection(key.clone(), Endpoint::from_static("http://127.0.0.1:1").connect_lazy()).await;
TLS_GENERATION_CACHE.lock().await.insert(key.clone(), stale_generation);
TLS_GENERATION_CACHE.write().await.insert(key.clone(), stale_generation);
assert!(
cached_channel_for_tls_generation(addr, &key).await.is_none(),
"TLS02_INVARIANT stale HTTPS cache entry cannot be returned"
@@ -3769,7 +3871,7 @@ mod tests {
let addr = "https://tls02-missing-generation.invalid:9000";
for key in [addr.to_string(), bulk_cache_key(addr, 0)] {
cache_connection(key.clone(), Endpoint::from_static("http://127.0.0.1:1").connect_lazy()).await;
TLS_GENERATION_CACHE.lock().await.remove(&key);
TLS_GENERATION_CACHE.write().await.remove(&key);
assert!(
cached_channel_for_tls_generation(addr, &key).await.is_none(),
"TLS02_INVARIANT HTTPS channel without a generation cannot be returned"
@@ -3793,16 +3895,16 @@ mod tests {
for cached_generation in [None, Some(generation.checked_add(1).unwrap_or(0))] {
cache_connection(cache_key.clone(), Endpoint::from_static("http://127.0.0.1:1").connect_lazy()).await;
if let Some(generation) = cached_generation {
TLS_GENERATION_CACHE.lock().await.insert(cache_key.clone(), generation);
TLS_GENERATION_CACHE.write().await.insert(cache_key.clone(), generation);
} else {
TLS_GENERATION_CACHE.lock().await.remove(&cache_key);
TLS_GENERATION_CACHE.write().await.remove(&cache_key);
}
assert!(
create_channel_with_timeout(&addr, rpc_timeout).await.is_err(),
"recovery must dial the unavailable HTTPS peer instead of returning an unbound or stale cached channel"
);
assert!(!rustfs_common::has_cached_connection(&cache_key).await);
assert!(!TLS_GENERATION_CACHE.lock().await.contains_key(&cache_key));
assert!(!TLS_GENERATION_CACHE.write().await.contains_key(&cache_key));
}
}
@@ -3813,14 +3915,14 @@ mod tests {
let generation = rustfs_tls_runtime::load_global_outbound_tls_generation().0;
for key in [addr.to_string(), bulk_cache_key(addr, 0)] {
cache_connection(key.clone(), Endpoint::from_static("http://127.0.0.1:1").connect_lazy()).await;
TLS_GENERATION_CACHE.lock().await.insert(key.clone(), generation);
TLS_GENERATION_CACHE.write().await.insert(key.clone(), generation);
assert!(
cached_channel_for_tls_generation(addr, &key).await.is_some(),
"TLS02_INVARIANT HTTPS channel at the canonical generation is reused"
);
assert!(rustfs_common::has_cached_connection(&key).await);
evict_connection_with_log_level(&key, ConnectionEvictionLogLevel::Debug).await;
TLS_GENERATION_CACHE.lock().await.remove(&key);
TLS_GENERATION_CACHE.write().await.remove(&key);
}
}
@@ -3832,14 +3934,14 @@ mod tests {
for key in [addr.to_string(), bulk_cache_key(addr, 0)] {
cache_connection(key.clone(), Endpoint::from_static("http://127.0.0.1:1").connect_lazy()).await;
TLS_GENERATION_CACHE
.lock()
.write()
.await
.insert(key.clone(), generation.checked_add(1).unwrap_or(0));
assert!(
cached_channel_for_tls_generation(addr, &key).await.is_some(),
"TLS02_INVARIANT HTTP cache is independent of TLS generations"
);
TLS_GENERATION_CACHE.lock().await.remove(&key);
TLS_GENERATION_CACHE.write().await.remove(&key);
assert!(
cached_channel_for_tls_generation(addr, &key).await.is_some(),
"TLS02_INVARIANT HTTP cache needs no TLS generation metadata"
@@ -3894,7 +3996,294 @@ mod tests {
!rustfs_common::has_cached_connection(addr).await,
"TLS02_INVARIANT late stale dial is never cached"
);
assert!(!TLS_GENERATION_CACHE.lock().await.contains_key(addr));
assert!(!TLS_GENERATION_CACHE.write().await.contains_key(addr));
}
#[tokio::test]
async fn tls02_custom_ca_uses_ipv6_uri_and_rejects_wrong_certificate_names() {
use rustfs_tls_runtime::{OutboundTlsMaterial, TlsGeneration, publish_global_outbound_tls_state};
use tonic::codegen::tokio_stream::{StreamExt, wrappers::TcpListenerStream};
use tonic::transport::ServerTlsConfig;
let _test_guard = TLS02_TEST_LOCK.lock().await;
let previous = runtime_sources::outbound_tls_state().await;
let cases = [
("DNS", "127.0.0.1:0", "localhost", "localhost", true),
("IPv4", "127.0.0.1:0", "127.0.0.1", "127.0.0.1", true),
("IPv6", "[::1]:0", "[::1]", "::1", true),
("IPv6 name mismatch", "[::1]:0", "[::1]", "localhost", false),
];
let restore_offset = u64::try_from(cases.len() + 1).expect("bounded TLS case count");
let restore_generation = previous
.generation
.0
.checked_add(restore_offset)
.expect("test generation capacity");
let mut observations = Vec::new();
for (index, (case, listen_addr, dial_host, certificate_name, expected)) in cases.into_iter().enumerate() {
let certificate =
rcgen::generate_simple_self_signed(vec![certificate_name.to_owned()]).expect("ephemeral TLS test certificate");
let certificate_pem = certificate.cert.pem();
let server_tls = ServerTlsConfig::new().identity(tonic::transport::Identity::from_pem(
&certificate_pem,
certificate.signing_key.serialize_pem(),
));
let listener = tokio::net::TcpListener::bind(listen_addr)
.await
.expect("loopback TLS listener");
let port = listener.local_addr().expect("TLS test server address").port();
let addr = format!("https://{dial_host}:{port}/data");
let accepted = Arc::new(AtomicUsize::new(0));
let counter = Arc::clone(&accepted);
let incoming = TcpListenerStream::new(listener).map(move |stream| {
if stream.is_ok() {
counter.fetch_add(1, Ordering::SeqCst);
}
stream
});
let mut server = tonic::transport::Server::builder()
.tls_config(server_tls)
.expect("valid TLS server configuration");
let server = tokio::spawn(async move {
server
.add_routes(tonic::service::Routes::default())
.serve_with_incoming(incoming)
.await
});
let material = OutboundTlsMaterial {
root_ca_pem: certificate_pem.into_bytes(),
mtls_identity: None,
};
let offset = u64::try_from(index + 1).expect("bounded generation offset");
publish_global_outbound_tls_state(TlsGeneration(previous.generation.0 + offset), &material).await;
let mut results = Vec::new();
for key in [addr.clone(), bulk_cache_key(&addr, 0)] {
let (connected, reached_rpc) = match build_channel(&addr, &key).await {
Ok(channel) => {
let mut client = NodeServiceClient::new(channel);
let response = tokio::time::timeout(
Duration::from_secs(3),
client.ping(proto_gen::node_service::PingRequest::default()),
)
.await;
// The empty router answers only after TLS and HTTP/2 reach the server.
let reached_rpc = matches!(response, Ok(Err(status)) if status.code() == tonic::Code::Unimplemented);
(Ok(()), reached_rpc)
}
Err(error) => (Err(error.to_string()), false),
};
results.push((connected, reached_rpc));
evict_cached_channel(&key, ConnectionEvictionLogLevel::Debug).await;
}
server.abort();
let _ = server.await;
observations.push((case, expected, results, accepted.load(Ordering::SeqCst)));
}
let restore = OutboundTlsMaterial {
root_ca_pem: previous.root_ca_pem.unwrap_or_default(),
mtls_identity: previous.mtls_identity,
};
publish_global_outbound_tls_state(TlsGeneration(restore_generation), &restore).await;
let restored = runtime_sources::outbound_tls_state().await;
assert_eq!(restored.root_ca_pem.as_deref().unwrap_or_default(), restore.root_ca_pem.as_slice());
assert_eq!(
restored
.mtls_identity
.as_ref()
.map(|identity| (&identity.cert_pem, &identity.key_pem)),
restore
.mtls_identity
.as_ref()
.map(|identity| (&identity.cert_pem, &identity.key_pem)),
);
assert!(restored.generation.0 > previous.generation.0);
for (case, expected, results, accepted) in observations {
for (connected, reached_rpc) in results {
assert_eq!(
connected.is_ok(),
expected,
"{case} must verify the URI certificate identity: {connected:?}"
);
assert_eq!(reached_rpc, expected, "{case} must reach the RPC service only after successful TLS");
}
assert_eq!(accepted, 2, "{case} must exercise actual control and bulk connections");
}
}
#[tokio::test]
async fn tls02_native_roots_preserve_mtls_identity_and_private_ca_scope() {
use rustfs_common::MtlsIdentityPem;
use rustfs_tls_runtime::{OutboundTlsMaterial, TlsGeneration, publish_global_outbound_tls_state};
use tonic::codegen::tokio_stream::wrappers::TcpListenerStream;
use tonic::transport::{Identity, ServerTlsConfig};
let _test_guard = TLS02_TEST_LOCK.lock().await;
let mut ca_params = rcgen::CertificateParams::new(Vec::<String>::new()).expect("CA parameters");
ca_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained);
let ca_key = rcgen::KeyPair::generate().expect("CA key");
let ca = ca_params.self_signed(&ca_key).expect("CA certificate");
let issuer = rcgen::Issuer::from_params(&ca_params, &ca_key);
let server_key = rcgen::KeyPair::generate().expect("server key");
let server_cert = rcgen::CertificateParams::new(vec!["127.0.0.1".into()])
.expect("server parameters")
.signed_by(&server_key, &issuer)
.expect("server certificate");
let mut client_params = rcgen::CertificateParams::new(vec!["client.example".into()]).expect("client parameters");
client_params.extended_key_usages = vec![rcgen::ExtendedKeyUsagePurpose::ClientAuth];
let client_key = rcgen::KeyPair::generate().expect("client key");
let client_cert = client_params.signed_by(&client_key, &issuer).expect("client certificate");
let identity = MtlsIdentityPem {
cert_pem: client_cert.pem().into_bytes(),
key_pem: client_key.serialize_pem().into_bytes(),
};
let other_ca = rcgen::generate_simple_self_signed(vec!["other-ca.example".into()]).expect("other CA");
let directory = tempfile::tempdir().expect("native root fixture directory");
let roots_file = directory.path().join("roots.pem");
let roots_directory = directory.path().join("empty");
std::fs::write(&roots_file, ca.pem()).expect("native trust fixture");
std::fs::create_dir(&roots_directory).expect("empty native trust directory");
temp_env::async_with_vars(
[
("SSL_CERT_FILE", roots_file.to_str()),
("SSL_CERT_DIR", roots_directory.to_str()),
(rustfs_config::ENV_INTERNODE_CHANNEL_ISOLATION, Some("true")),
(rustfs_config::ENV_INTERNODE_BULK_CHANNELS, Some("1")),
],
async {
let previous = runtime_sources::outbound_tls_state().await;
let restore_generation = previous.generation.0.checked_add(5).expect("test generation capacity");
let cases = [
("native server trust", false, false, false, true),
("native mTLS identity", true, true, false, true),
("required client identity", true, false, false, false),
("explicit private trust", false, false, true, false),
];
let mut observations = Vec::new();
for (index, (case, require_identity, send_identity, private_root, expected)) in cases.into_iter().enumerate() {
let mut tls =
ServerTlsConfig::new().identity(Identity::from_pem(server_cert.pem(), server_key.serialize_pem()));
if require_identity {
tls = tls
.client_ca_root(Certificate::from_pem(ca.pem()))
.client_auth_optional(false);
}
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("TLS listener");
let addr = format!("https://{}", listener.local_addr().expect("TLS test address"));
let mut server = tonic::transport::Server::builder()
.tls_config(tls)
.expect("TLS server configuration");
let server = tokio::spawn(async move {
server
.add_routes(tonic::service::Routes::default())
.serve_with_incoming(TcpListenerStream::new(listener))
.await
});
let material = OutboundTlsMaterial {
root_ca_pem: if private_root {
other_ca.cert.pem().into_bytes()
} else {
Vec::new()
},
mtls_identity: send_identity.then(|| identity.clone()),
};
let offset = u64::try_from(index + 1).expect("bounded generation offset");
publish_global_outbound_tls_state(TlsGeneration(previous.generation.0 + offset), &material).await;
for class in [ChannelClass::Control, ChannelClass::Bulk] {
let reached_rpc = match get_channel_for_class(&addr, class).await {
Ok(channel) => {
let mut client = NodeServiceClient::new(channel);
let result = tokio::time::timeout(
Duration::from_secs(3),
client.ping(proto_gen::node_service::PingRequest::default()),
)
.await;
// The empty router returns Unimplemented only after the peer completes TLS.
matches!(result, Ok(Err(status)) if status.code() == tonic::Code::Unimplemented)
}
Err(_) => false,
};
observations.push((case, class, reached_rpc, expected));
}
evict_cached_channel(&addr, ConnectionEvictionLogLevel::Debug).await;
evict_cached_channel(&bulk_cache_key(&addr, 0), ConnectionEvictionLogLevel::Debug).await;
server.abort();
let _ = server.await;
}
let restore = OutboundTlsMaterial {
root_ca_pem: previous.root_ca_pem.unwrap_or_default(),
mtls_identity: previous.mtls_identity,
};
publish_global_outbound_tls_state(TlsGeneration(restore_generation), &restore).await;
for (case, class, actual, expected) in observations {
assert_eq!(actual, expected, "{case} on {class:?}");
}
},
)
.await;
}
#[test]
fn tls02_queued_tls_configuration_obeys_acquisition_deadline() {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.max_blocking_threads(1)
.build()
.expect("single blocking-worker runtime");
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("connection observation listener");
listener.set_nonblocking(true).expect("nonblocking connection observation");
let addr = format!("https://{}", listener.local_addr().expect("observation address"));
runtime.block_on(async {
use rustfs_tls_runtime::{OutboundTlsMaterial, TlsGeneration, publish_global_outbound_tls_state};
let _test_guard = TLS02_TEST_LOCK.lock().await;
let previous = runtime_sources::outbound_tls_state().await;
let generation = previous.generation.0.checked_add(2).expect("test generation capacity");
let certificate = rcgen::generate_simple_self_signed(vec!["127.0.0.1".into()]).expect("test certificate");
publish_global_outbound_tls_state(
TlsGeneration(generation - 1),
&OutboundTlsMaterial {
root_ca_pem: certificate.cert.pem().into_bytes(),
mtls_identity: None,
},
)
.await;
let (started_tx, started_rx) = tokio::sync::oneshot::channel();
let (release_tx, release_rx) = std::sync::mpsc::channel();
let blocker = tokio::task::spawn_blocking(move || {
started_tx.send(()).expect("report occupied blocking worker");
release_rx.recv().expect("release occupied blocking worker");
});
started_rx.await.expect("blocking worker is occupied");
let deadline = tokio::time::Instant::now() + Duration::from_millis(100);
let result = build_channel_until(&addr, &addr, deadline, internode_rpc_timeout()).await;
let timed_out = result
.as_ref()
.err()
.and_then(|error| error.downcast_ref::<std::io::Error>())
.is_some_and(|error| error.kind() == std::io::ErrorKind::TimedOut);
let cached = rustfs_common::has_cached_connection(&addr).await;
let registered = DIAL_LOCK_REGISTRY.lock().expect("dial registry").contains_key(&addr);
release_tx.send(()).expect("release blocking worker before assertions");
blocker.await.expect("original blocking worker joined");
let restore = OutboundTlsMaterial {
root_ca_pem: previous.root_ca_pem.unwrap_or_default(),
mtls_identity: previous.mtls_identity,
};
publish_global_outbound_tls_state(TlsGeneration(generation), &restore).await;
assert!(timed_out, "queued TLS configuration shares the original acquisition deadline");
assert!(!cached, "timed-out TLS setup leaves no published channel");
assert!(!registered, "timed-out TLS setup releases its dial registry entry");
});
// Runtime retirement joins the queued certificate work before inspecting its side effects.
drop(runtime);
let accepted = listener.accept();
assert!(
matches!(accepted, Err(error) if error.kind() == std::io::ErrorKind::WouldBlock),
"a timed-out queued TLS configuration must retire before opening a connection"
);
}
#[tokio::test]
@@ -4045,6 +4434,255 @@ mod tests {
assert!(bulk_channel_pool_size() >= 1);
}
#[test]
fn bulk_round_robin_is_independent_for_interleaved_peers() {
let cursors = BulkChannelCursors::default();
for round in 0..12 {
for peer in ["peer-a", "peer-b"] {
assert_eq!(cursors.for_peer(peer).next(2), round % 2, "each peer must use both slots");
}
}
}
#[test]
fn bulk_round_robin_balances_concurrent_acquisitions() {
let cursors = Arc::new(BulkChannelCursors::default());
let tasks = (0..8)
.map(|_| {
let cursors = Arc::clone(&cursors);
std::thread::spawn(move || {
let mut counts = [[0; 4]; 2];
for _ in 0..100 {
for (peer, counts) in ["peer-a", "peer-b"].into_iter().zip(&mut counts) {
counts[cursors.for_peer(peer).next(4)] += 1;
}
}
counts
})
})
.collect::<Vec<_>>();
let mut total = [[0; 4]; 2];
for task in tasks {
for (total, counts) in total.iter_mut().zip(task.join().expect("selection worker")) {
for (total, count) in total.iter_mut().zip(counts) {
*total += count;
}
}
}
assert_eq!(total, [[200; 4]; 2]);
}
#[test]
fn bulk_round_robin_bounds_retired_peer_bookkeeping() {
let cursors = BulkChannelCursors::default();
for index in 0..BULK_CHANNEL_CURSOR_CACHE_CAPACITY * 3 {
assert_eq!(cursors.for_peer(&format!("peer-{index}")).next(2), 0);
}
assert_eq!(cursors.peers.read().expect("cursor cache").len(), BULK_CHANNEL_CURSOR_CACHE_CAPACITY);
assert_eq!(cursors.for_peer("current-peer").next(2), 0);
assert_eq!(cursors.for_peer("current-peer").next(2), 1);
}
#[test]
fn bulk_pool_hits_borrow_stable_keys_and_large_pools_remain_bounded() {
let cursors = BulkChannelCursors::default();
let peer = cursors.for_peer("https://peer.example:9000");
let same_peer = cursors.for_peer("https://peer.example:9000");
assert!(Arc::ptr_eq(&peer, &same_peer));
for index in 0..BULK_CHANNEL_CACHED_KEYS {
let first = peer.cache_key("https://peer.example:9000", index);
let second = same_peer.cache_key("https://peer.example:9000", index);
assert!(matches!(first, Cow::Borrowed(_)));
assert_eq!(first, bulk_cache_key("https://peer.example:9000", index));
assert_eq!(first.as_ptr(), second.as_ptr(), "cache hits reuse the established key allocation");
}
let key = peer.cache_key("https://peer.example:9000", usize::MAX - 1);
assert!(matches!(key, Cow::Owned(_)));
assert_eq!(key, bulk_cache_key("https://peer.example:9000", usize::MAX - 1));
assert_eq!(peer.keys.len(), BULK_CHANNEL_CACHED_KEYS);
for (sequence, size) in [1, 2, 4, usize::MAX, 2].into_iter().enumerate() {
assert_eq!(peer.next(size), sequence % size, "pool changes preserve the peer's selection sequence");
}
}
#[test]
fn grpc_receive_windows_reject_overflow_and_preserve_library_default() {
assert_eq!(checked_rpc_window(None).expect("unset window"), None);
assert_eq!(checked_rpc_window(Some(0)).expect("default window"), None);
assert_eq!(checked_rpc_window(Some(1)).expect("minimum window"), Some(1));
assert_eq!(checked_rpc_window(Some(0x7fff_ffff)).expect("maximum window"), Some(0x7fff_ffff));
assert_eq!(
checked_rpc_window(Some(0x8000_0000)).expect_err("overflow window").kind(),
std::io::ErrorKind::InvalidInput
);
}
#[test]
fn grpc_bulk_capacity_rejects_overflow_and_normalizes_zero() {
assert_eq!(checked_rpc_capacity(0).expect("minimum capacity"), 1);
assert_eq!(checked_rpc_capacity(64).expect("default concurrency"), 64);
assert_eq!(checked_rpc_capacity(1024).expect("default buffer"), 1024);
assert_eq!(
checked_rpc_capacity(usize::MAX).expect_err("oversized capacity").kind(),
std::io::ErrorKind::InvalidInput
);
}
#[test]
fn grpc_bulk_windows_inherit_legacy_settings_and_allow_library_default() {
let name = rustfs_config::ENV_INTERNODE_BULK_HTTP2_CONN_WINDOW_SIZE;
for (setting, expected) in [(None, Some(2097152)), (Some("0"), None), (Some("8388608"), Some(8388608))] {
temp_env::with_var(name, setting, || {
assert_eq!(
checked_rpc_window(bulk_window_override(name, Some(2097152))).expect("valid bulk window"),
expected
);
});
}
}
#[tokio::test]
async fn tls02_cache_hits_allow_concurrent_generation_readers() {
let _test_guard = TLS02_TEST_LOCK.lock().await;
let addr = "https://tls02-shared-cache.invalid:9000";
let generation = runtime_sources::outbound_tls_generation();
let channel = Endpoint::from_static("http://127.0.0.1:1").connect_lazy();
assert!(cache_dialed_channel(addr, addr, &channel, Some(generation)).await);
let generations = TLS_GENERATION_CACHE.read().await;
let cached = tokio::time::timeout(Duration::from_secs(1), cached_channel_for_tls_generation(addr, addr))
.await
.expect("cache hit must not acquire an exclusive generation lock");
assert!(cached.is_some());
drop(generations);
evict_connection_with_log_level(addr, ConnectionEvictionLogLevel::Debug).await;
TLS_GENERATION_CACHE.write().await.remove(addr);
}
#[tokio::test]
async fn tls02_eviction_cannot_remove_a_replacement_generation() {
use std::future::Future;
use std::task::{Context, Waker};
let _test_guard = TLS02_TEST_LOCK.lock().await;
let addr = "https://tls02-replacement-race.invalid:9000";
let generation = runtime_sources::outbound_tls_generation();
let channel = Endpoint::from_static("http://127.0.0.1:1").connect_lazy();
assert!(cache_dialed_channel(addr, addr, &channel, Some(generation)).await);
// Pause eviction on generation metadata. It must retain the cached channel until it
// owns that metadata, then allow the queued replacement to publish a matching pair.
let generations = TLS_GENERATION_CACHE.read().await;
let mut eviction = Box::pin(evict_failed_connection_with_log_level(addr, ConnectionEvictionLogLevel::Debug));
let mut replacement = Box::pin(cache_dialed_channel(addr, addr, &channel, Some(generation)));
let mut context = Context::from_waker(Waker::noop());
assert!(eviction.as_mut().poll(&mut context).is_pending());
assert!(replacement.as_mut().poll(&mut context).is_pending());
assert!(
rustfs_common::has_cached_connection(addr).await,
"eviction must acquire generation metadata before removing the channel"
);
drop(generations);
let (_, published) = tokio::time::timeout(Duration::from_secs(1), async { tokio::join!(eviction, replacement) })
.await
.expect("eviction and replacement must make progress");
assert!(published);
assert!(
cached_channel_for_tls_generation(addr, addr).await.is_some(),
"replacement keeps its TLS generation"
);
evict_failed_connection_with_log_level(addr, ConnectionEvictionLogLevel::Debug).await;
}
#[tokio::test]
async fn bulk_pool_interleaving_reuses_two_real_connections_per_peer() {
use tonic::codegen::tokio_stream::{StreamExt, wrappers::TcpListenerStream};
let _test_guard = TLS02_TEST_LOCK.lock().await;
temp_env::async_with_vars(
[
(rustfs_config::ENV_INTERNODE_CHANNEL_ISOLATION, Some("true")),
(rustfs_config::ENV_INTERNODE_BULK_CHANNELS, Some("2")),
(rustfs_config::ENV_INTERNODE_BULK_HTTP2_CONN_WINDOW_SIZE, Some("8388608")),
(rustfs_config::ENV_INTERNODE_BULK_CONCURRENCY_LIMIT, Some("2")),
(rustfs_config::ENV_INTERNODE_BULK_BUFFER_SIZE, Some("4")),
],
async {
let mut peers = Vec::new();
for _ in 0..2 {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("bind peer");
let addr = format!("http://{}", listener.local_addr().expect("peer address"));
let accepted = Arc::new(AtomicUsize::new(0));
let changed = Arc::new(tokio::sync::Notify::new());
let count = Arc::clone(&accepted);
let notify = Arc::clone(&changed);
let incoming = TcpListenerStream::new(listener).map(move |stream| {
if stream.is_ok() {
count.fetch_add(1, Ordering::SeqCst);
notify.notify_one();
}
stream
});
let task = tokio::spawn(async move {
tonic::transport::Server::builder()
.add_routes(tonic::service::Routes::default())
.serve_with_incoming(incoming)
.await
});
peers.push((addr, accepted, changed, task));
}
for _ in 0..8 {
for (addr, _, _, _) in &peers {
get_channel_for_class(addr, ChannelClass::Bulk).await.expect("bulk channel");
}
}
for (addr, accepted, changed, task) in peers {
get_channel_for_class(&addr, ChannelClass::Control)
.await
.expect("control channel");
tokio::time::timeout(Duration::from_secs(2), async {
while accepted.load(Ordering::SeqCst) < 3 {
changed.notified().await;
}
})
.await
.expect("server accepts all three pooled sockets");
assert_eq!(accepted.load(Ordering::SeqCst), 3, "two bulk connections plus one control connection");
for key in [addr.clone(), bulk_cache_key(&addr, 0), bulk_cache_key(&addr, 1)] {
assert!(rustfs_common::has_cached_connection(&key).await);
evict_connection_with_log_level(&key, ConnectionEvictionLogLevel::Debug).await;
}
task.abort();
let _ = task.await;
}
},
)
.await;
}
#[tokio::test]
async fn invalid_bulk_window_fails_before_dial_and_control_keeps_its_settings() {
let _test_guard = TLS02_TEST_LOCK.lock().await;
temp_env::async_with_vars(
[(rustfs_config::ENV_INTERNODE_BULK_HTTP2_STREAM_WINDOW_SIZE, Some("4294967295"))],
async {
let addr = "http://127.0.0.1:1";
let error = dial_channel(addr, &bulk_cache_key(addr, 0), Duration::from_secs(1))
.await
.expect_err("reject overflowing bulk window");
assert_eq!(
error
.downcast_ref::<std::io::Error>()
.expect("typed configuration error")
.kind(),
std::io::ErrorKind::InvalidInput
);
assert_eq!(
checked_rpc_window(internode_rpc_http2_stream_window()).expect("control setting"),
Some(1024 * 1024)
);
},
)
.await;
}
#[test]
fn internode_rpc_msgpack_only_reuses_cached_env_until_reset() {
let _guard = INTERNODE_RPC_MSGPACK_ONLY_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
@@ -4105,6 +4743,7 @@ mod tests {
#[tokio::test]
async fn get_channel_for_class_bulk_reuses_control_cache_when_isolation_disabled() {
let _test_guard = TLS02_TEST_LOCK.lock().await;
// Isolation defaults off, so a Bulk request must reuse the control channel keyed by the
// bare address and must NOT create a separate bulk-keyed entry (zero behavior change).
let addr = "http://get-channel-isolation-off-test";
+73 -18
View File
@@ -60,7 +60,9 @@ use rustfs_utils::{
AMZ_CHECKSUM_SHA256,
},
net::get_endpoint_url,
retry::{DEFAULT_RETRY_CAP, DEFAULT_RETRY_UNIT, MAX_JITTER, MAX_RETRY, RetryTimer},
retry::{
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;
@@ -119,6 +121,33 @@ fn transition_transport_error(err: hyper_util::client::legacy::Error) -> std::io
std::io::Error::other(err)
}
fn is_retryable_transport_error(error: &std::io::Error) -> bool {
// Hyper wraps connection and body errors; the outer io::Error is often Other.
let mut current: Option<&(dyn StdError + 'static)> = Some(error);
while let Some(error) = current {
if error.downcast_ref::<std::io::Error>().is_some_and(|error| {
matches!(
error.kind(),
std::io::ErrorKind::Interrupted
| std::io::ErrorKind::WouldBlock
| std::io::ErrorKind::TimedOut
| std::io::ErrorKind::ConnectionAborted
| std::io::ErrorKind::ConnectionRefused
| std::io::ErrorKind::ConnectionReset
| std::io::ErrorKind::NotConnected
| std::io::ErrorKind::UnexpectedEof
)
}) || error
.downcast_ref::<hyper::Error>()
.is_some_and(|error| error.is_incomplete_message() || error.is_closed())
{
return true;
}
current = error.source();
}
false
}
async fn next_response_body_data<B>(
mut body: Pin<&mut B>,
idle_timeout: Option<StdDuration>,
@@ -727,6 +756,18 @@ impl TransitionClient {
Ok(resp)
}
/// Executes a remote request, retrying transient failures up to `max_retries` total attempts.
///
/// Only GET, HEAD, and PUT of a fixed multipart upload part with a buffered body
/// are replayed. Object readers and other mutations get one attempt: replaying
/// ordinary PUT or DELETE can create additional versions or delete markers.
/// Request construction failures and errors reading a successful response body
/// are not retried. Dropping this future stops further attempts and any pending
/// backoff; a failed or canceled request may still have reached the remote server.
///
/// # Errors
/// Returns a request construction error, a permanent remote error, or the last
/// transient error when the attempt budget is exhausted.
pub async fn execute_method(
&self,
method: http::Method,
@@ -738,24 +779,27 @@ impl TransitionClient {
return Err(std::io::Error::other(s));
}
let retryable: bool;
//let mut body_seeker: BufferReader;
let mut req_retry = self.max_retries;
let mut resp: Response<Incoming>;
//if metadata.content_body != nil {
//body_seeker = BufferReader::new(metadata.content_body.read_all().await?);
retryable = true;
if !retryable {
req_retry = 1;
}
//}
let retryable = matches!(&metadata.content_body, ReaderImpl::Body(_))
&& (method == http::Method::GET
|| method == http::Method::HEAD
|| (method == http::Method::PUT
&& metadata.query_values.get("uploadId").is_some_and(|value| !value.is_empty())
&& metadata.query_values.get("partNumber").is_some_and(|value| !value.is_empty())));
let req_retry = if retryable { self.max_retries.max(1) } else { 1 };
let mut last_error = None;
let mut retry_timer = RetryTimer::new(req_retry, DEFAULT_RETRY_UNIT, DEFAULT_RETRY_CAP, MAX_JITTER, self.random);
while retry_timer.next().await.is_some() {
let req = self.new_request(&method, metadata).await?;
resp = self.doit(req).await?;
let resp = match self.doit(req).await {
Ok(resp) => resp,
Err(error) if retryable && is_retryable_transport_error(&error) => {
last_error = Some(error);
continue;
}
Err(error) => return Err(error),
};
for http_status in SUCCESS_STATUS {
if http_status == resp.status() {
@@ -766,11 +810,17 @@ impl TransitionClient {
let resp_status = resp.status();
let h = resp.headers().clone();
let body_vec = self
.collect_response_body(resp.into_body(), MAX_S3_ERROR_RESPONSE_SIZE)
.await?;
let body_vec = match self.collect_response_body(resp.into_body(), MAX_S3_ERROR_RESPONSE_SIZE).await {
Ok(body) => body,
Err(error) if retryable && is_retryable_transport_error(&error) => {
last_error = Some(error);
continue;
}
Err(error) => return Err(error),
};
let parsed_error =
http_resp_to_error_response(resp_status, &h, body_vec, &metadata.bucket_name, &metadata.object_name);
let retryable_response = is_http_status_retryable(&resp_status) || is_s3code_retryable(parsed_error.code.as_str());
let routing_region = parsed_error.region;
let code = match parsed_error.code {
code if code.is_custom() => RemoteErrorCode::RESPONSE_INTERRUPTED,
@@ -785,6 +835,11 @@ impl TransitionClient {
..Default::default()
};
if retryable && retryable_response {
last_error = Some(std::io::Error::other(err_response));
continue;
}
if self.region == "" {
return match err_response.code {
RemoteErrorCode::AUTHORIZATION_HEADER_MALFORMED | RemoteErrorCode::INVALID_ARGUMENT /*RemoteErrorCode::INVALID_REGION*/ => {
@@ -819,7 +874,7 @@ impl TransitionClient {
return Err(std::io::Error::other(err_response));
}
Err(std::io::Error::other("remote tier request did not produce a response"))
Err(last_error.unwrap_or_else(|| std::io::Error::other("remote tier request did not produce a response")))
}
pub async fn collect_response_body<B>(&self, body: B, limit: usize) -> Result<Vec<u8>, std::io::Error>
@@ -0,0 +1,320 @@
// Copyright 2026 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use bytes::Bytes;
use http::{Method, StatusCode};
use rustfs_s3_client::{
api_error_response::to_error_response,
credentials::{Credentials, SignatureType, Static, Value},
remote_error_code::RemoteErrorCode,
transition_api::{
BucketLookupType, MAX_S3_ERROR_RESPONSE_SIZE, ObjectReader, Options, ReaderImpl, RequestMetadata, TransitionClient,
TransitionClientTimeouts,
},
};
use std::time::Duration;
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
net::TcpListener,
};
#[derive(Clone, Copy)]
enum Reply {
Http(StatusCode, &'static str),
Disconnect,
Truncated,
StallHeaders,
MalformedHttp,
Oversized,
}
fn metadata(body: ReaderImpl, content_length: i64) -> RequestMetadata {
RequestMetadata {
bucket_name: "bucket".into(),
object_name: "object".into(),
bucket_location: "us-east-1".into(),
content_body: body,
content_length,
pre_sign_url: false,
query_values: Default::default(),
custom_header: Default::default(),
extra_pre_sign_header: None,
expires: 0,
content_md5_base64: String::new(),
content_sha256_hex: String::new(),
stream_sha256: false,
trailer: Default::default(),
}
}
fn empty_metadata() -> RequestMetadata {
metadata(ReaderImpl::Body(Bytes::new()), 0)
}
async fn run_case(
method: Method,
metadata: &mut RequestMetadata,
attempts: i64,
replies: Vec<Reply>,
) -> (Result<StatusCode, std::io::Error>, Vec<Vec<u8>>) {
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind retry fixture");
let endpoint = listener.local_addr().expect("fixture address").to_string();
let (stop, mut stopped) = tokio::sync::oneshot::channel();
let server = tokio::spawn(async move {
let mut requests = Vec::new();
let mut stalled_sockets = Vec::new();
loop {
let (mut socket, _) = tokio::select! {
_ = &mut stopped => break,
accepted = listener.accept() => accepted.expect("accept retry request"),
};
let mut request = Vec::new();
let header_end = loop {
let mut buffer = [0; 1024];
let size = socket.read(&mut buffer).await.expect("read request headers");
assert_ne!(size, 0, "client closed before sending headers");
request.extend_from_slice(&buffer[..size]);
if let Some(end) = request.windows(4).position(|part| part == b"\r\n\r\n") {
break end + 4;
}
};
let headers = String::from_utf8_lossy(&request[..header_end]).to_ascii_lowercase();
assert!(headers.contains("authorization: aws4-hmac-sha256"), "each attempt must be signed");
let length = headers
.lines()
.find_map(|line| line.strip_prefix("content-length: "))
.map(|value| value.parse::<usize>().expect("numeric content length"))
.unwrap_or(0);
while request.len() < header_end + length {
let mut buffer = [0; 1024];
let size = socket.read(&mut buffer).await.expect("read request body");
assert_ne!(size, 0, "client closed before sending the complete body");
request.extend_from_slice(&buffer[..size]);
}
let reply = replies[requests.len().min(replies.len() - 1)];
requests.push(request);
let (status, body, extra_length) = match reply {
Reply::Disconnect => continue,
Reply::StallHeaders => {
stalled_sockets.push(socket);
continue;
}
Reply::MalformedHttp => {
socket
.write_all(b"invalid HTTP\r\n\r\n")
.await
.expect("write malformed response");
continue;
}
Reply::Truncated => (StatusCode::SERVICE_UNAVAILABLE, "<Error>".to_string(), 100),
Reply::Oversized => (StatusCode::SERVICE_UNAVAILABLE, "x".repeat(MAX_S3_ERROR_RESPONSE_SIZE + 1), 0),
Reply::Http(status, body) => (status, body.to_string(), 0),
};
let is_head = requests.last().expect("recorded request").starts_with(b"HEAD ");
let response = format!(
"HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\nx-amz-request-id: fixture\r\n\r\n{}",
body.len() + extra_length,
if is_head { "" } else { &body },
);
socket.write_all(response.as_bytes()).await.expect("write fixture response");
}
requests
});
let client = TransitionClient::new_with_timeouts(
&endpoint,
Options {
creds: Credentials::new(Static(Value {
access_key_id: "access-key".into(),
secret_access_key: "secret-key".into(),
signer_type: SignatureType::SignatureV4,
..Default::default()
})),
region: "us-east-1".into(),
bucket_lookup: BucketLookupType::BucketLookupPath,
max_retries: attempts,
..Default::default()
},
"s3",
TransitionClientTimeouts::new(Duration::from_secs(1), Duration::from_secs(1), Duration::from_secs(1)),
)
.await
.expect("build real transition client");
let result = tokio::time::timeout(Duration::from_secs(5), client.execute_method(method, metadata)).await;
let _ = stop.send(());
let requests = tokio::time::timeout(Duration::from_secs(2), server)
.await
.expect("fixture must stop")
.expect("fixture must join");
(result.expect("retry loop must terminate").map(|response| response.status()), requests)
}
#[tokio::test]
async fn retries_transient_http_statuses_then_succeeds() {
for method in [Method::GET, Method::HEAD] {
for status in [
StatusCode::SERVICE_UNAVAILABLE,
StatusCode::REQUEST_TIMEOUT,
StatusCode::TOO_MANY_REQUESTS,
StatusCode::INTERNAL_SERVER_ERROR,
StatusCode::BAD_GATEWAY,
StatusCode::GATEWAY_TIMEOUT,
] {
let (result, requests) = run_case(
method.clone(),
&mut empty_metadata(),
2,
vec![Reply::Http(status, ""), Reply::Http(StatusCode::OK, "")],
)
.await;
assert_eq!(result.expect("transient status should recover"), StatusCode::OK);
assert_eq!(requests.len(), 2, "{method} {status} must issue a second request");
}
}
}
#[tokio::test]
async fn retries_s3_error_codes_even_with_a_non_retryable_http_status() {
for body in [
"<Error><Code>SlowDown</Code></Error>",
"<Error><Code>Throttling</Code></Error>",
"<Error><Code>ThrottlingException</Code></Error>",
"<Error><Code>RequestTimeout</Code></Error>",
] {
let (result, requests) = run_case(
Method::GET,
&mut empty_metadata(),
2,
vec![Reply::Http(StatusCode::BAD_REQUEST, body), Reply::Http(StatusCode::OK, "")],
)
.await;
assert_eq!(result.expect("transient S3 error should recover"), StatusCode::OK);
assert_eq!(requests.len(), 2, "{body} must trigger a retry");
}
}
#[tokio::test]
async fn retries_disconnected_transport_and_interrupted_error_body() {
for first_reply in [Reply::Disconnect, Reply::Truncated, Reply::StallHeaders] {
let (result, requests) =
run_case(Method::GET, &mut empty_metadata(), 2, vec![first_reply, Reply::Http(StatusCode::OK, "")]).await;
assert_eq!(result.expect("interrupted request should recover"), StatusCode::OK);
assert_eq!(requests.len(), 2);
}
}
#[tokio::test]
async fn retry_exhaustion_preserves_the_transport_timeout() {
let (result, requests) = run_case(Method::GET, &mut empty_metadata(), 2, vec![Reply::StallHeaders]).await;
assert_eq!(result.expect_err("both attempts timed out").kind(), std::io::ErrorKind::TimedOut);
assert_eq!(requests.len(), 2);
}
#[tokio::test]
async fn malformed_and_oversized_responses_are_not_retried() {
for reply in [Reply::MalformedHttp, Reply::Oversized] {
let (result, requests) = run_case(Method::GET, &mut empty_metadata(), 3, vec![reply]).await;
let error = result.expect_err("invalid responses must fail");
if matches!(reply, Reply::Oversized) {
assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
}
assert_eq!(requests.len(), 1, "permanent protocol errors must not be replayed");
}
}
#[tokio::test]
async fn invalid_request_headers_fail_before_dispatch() {
let mut metadata = empty_metadata();
metadata.content_md5_base64 = "invalid\nheader".into();
let (result, requests) = run_case(Method::GET, &mut metadata, 3, vec![Reply::Http(StatusCode::OK, "")]).await;
assert_eq!(result.expect_err("invalid header must fail").kind(), std::io::ErrorKind::InvalidInput);
assert!(requests.is_empty(), "request construction failures must not reach the server");
}
#[tokio::test]
async fn retry_exhaustion_returns_the_last_s3_error() {
let (result, requests) = run_case(
Method::GET,
&mut empty_metadata(),
3,
vec![
Reply::Http(StatusCode::BAD_GATEWAY, ""),
Reply::Http(StatusCode::SERVICE_UNAVAILABLE, "<Error><Code>SlowDown</Code></Error>"),
],
)
.await;
let error = to_error_response(&result.expect_err("all attempts failed"));
assert_eq!(requests.len(), 3, "max_retries counts total attempts");
assert_eq!(error.status_code, StatusCode::SERVICE_UNAVAILABLE);
assert_eq!(error.code, RemoteErrorCode::from_wire("SlowDown"));
}
#[tokio::test]
async fn permanent_errors_and_single_attempts_are_not_retried() {
for (status, body, attempts) in [
(StatusCode::FORBIDDEN, "<Error><Code>AccessDenied</Code></Error>", 3),
(StatusCode::NOT_FOUND, "<Error><Code>NoSuchKey</Code></Error>", 3),
(StatusCode::BAD_REQUEST, "<Error><Code>InvalidArgument</Code></Error>", 3),
(StatusCode::SERVICE_UNAVAILABLE, "<Error><Code>SlowDown</Code></Error>", 1),
(StatusCode::OK, "", 3),
] {
let (result, requests) = run_case(Method::GET, &mut empty_metadata(), attempts, vec![Reply::Http(status, body)]).await;
assert_eq!(result.is_ok(), status.is_success());
assert_eq!(requests.len(), 1, "{status} with {attempts} attempts");
}
}
#[tokio::test]
async fn buffered_upload_parts_replay_identical_body_bytes() {
let mut metadata = metadata(ReaderImpl::Body(Bytes::from_static(b"part bytes")), 10);
metadata.query_values.insert("uploadId".into(), "upload-1".into());
metadata.query_values.insert("partNumber".into(), "1".into());
let (result, requests) = run_case(
Method::PUT,
&mut metadata,
2,
vec![
Reply::Http(StatusCode::SERVICE_UNAVAILABLE, ""),
Reply::Http(StatusCode::OK, ""),
],
)
.await;
assert_eq!(result.expect("buffered upload part should recover"), StatusCode::OK);
assert_eq!(requests.len(), 2);
for request in requests {
assert!(request.ends_with(b"\r\n\r\npart bytes"), "every attempt must send the full body");
}
}
#[tokio::test]
async fn non_replayable_bodies_and_object_mutations_are_not_retried() {
for method in [Method::GET, Method::PUT, Method::POST, Method::DELETE] {
let mut metadata = if method == Method::GET {
metadata(ReaderImpl::ObjectBody(ObjectReader::new(std::io::Cursor::new(b"stream"))), 6)
} else {
empty_metadata()
};
let (result, requests) =
run_case(method.clone(), &mut metadata, 3, vec![Reply::Http(StatusCode::SERVICE_UNAVAILABLE, "")]).await;
assert!(result.is_err(), "{method} must return the first failure");
assert_eq!(requests.len(), 1, "{method} must not be replayed");
}
let mut metadata = metadata(ReaderImpl::ObjectBody(ObjectReader::new(std::io::Cursor::new(b"stream"))), 6);
metadata.query_values.insert("uploadId".into(), "upload-1".into());
metadata.query_values.insert("partNumber".into(), "1".into());
let (result, requests) =
run_case(Method::PUT, &mut metadata, 3, vec![Reply::Http(StatusCode::SERVICE_UNAVAILABLE, "")]).await;
assert!(result.is_err(), "streaming upload parts must return the first failure");
assert_eq!(requests.len(), 1, "a consumed object reader must not be replayed");
assert!(requests[0].ends_with(b"\r\n\r\nstream"));
}
+39 -8
View File
@@ -3975,8 +3975,8 @@ impl Metrics {
m.partial_cycles = self.partial_scan_cycles.load(Ordering::Relaxed);
// Lifetime operation counts
for i in 0..Metric::Last as usize {
let count = self.operations[i].load(Ordering::Relaxed);
for (i, operation) in self.operations.iter().enumerate().take(Metric::Last as usize) {
let count = operation.load(Ordering::Relaxed);
if count > 0
&& let Some(metric) = Metric::from_index(i)
{
@@ -3985,8 +3985,8 @@ impl Metrics {
}
// Last-minute stats for realtime metrics — now plain sync calls
for i in 0..Metric::LastRealtime as usize {
let last_min = self.latency[i].total();
for (i, latency) in self.latency.iter().enumerate().take(Metric::LastRealtime as usize) {
let last_min = latency.total();
if last_min.n > 0
&& let Some(metric) = Metric::from_index(i)
{
@@ -4002,8 +4002,8 @@ impl Metrics {
}
// Lifetime ILM counts
for i in 0..IlmAction::ActionCount as usize {
let count = self.actions[i].load(Ordering::Relaxed);
for (i, action_count) in self.actions.iter().enumerate().take(IlmAction::ActionCount as usize) {
let count = action_count.load(Ordering::Relaxed);
if count > 0
&& let Some(action) = IlmAction::from_index(i)
{
@@ -4012,8 +4012,8 @@ impl Metrics {
}
// Last-minute ILM latency — plain sync calls
for i in 0..IlmAction::ActionCount as usize {
let last_min = self.actions_latency[i].total();
for (i, action_latency) in self.actions_latency.iter().enumerate().take(IlmAction::ActionCount as usize) {
let last_min = action_latency.total();
if last_min.n > 0
&& let Some(action) = IlmAction::from_index(i)
{
@@ -5324,6 +5324,37 @@ mod tests {
assert_eq!(report.life_time_ops.get("scan_bucket_drive_failure"), Some(&1));
}
#[tokio::test]
async fn report_includes_the_last_slot_of_each_realtime_and_ilm_table() {
assert_eq!(Metric::HealAbandonedObject as usize + 1, Metric::LastRealtime as usize);
assert_eq!(IlmAction::DelMarkerDeleteAllVersionsAction as usize + 1, IlmAction::ActionCount as usize);
let metrics = Metrics::new();
let last_ilm = IlmAction::DelMarkerDeleteAllVersionsAction as usize;
metrics.latency[Metric::HealAbandonedObject as usize].add(Duration::from_millis(1));
metrics.actions[last_ilm].fetch_add(1, Ordering::Relaxed);
metrics.actions_latency[last_ilm].add(Duration::from_millis(1));
let report = metrics.report().await;
assert_eq!(
report
.last_minute
.actions
.get("heal_abandoned_object")
.map(|action| action.count),
Some(1)
);
assert_eq!(report.life_time_ilm.get("del_marker_delete_all_versions"), Some(&1));
assert_eq!(
report
.last_minute
.ilm
.get("del_marker_delete_all_versions")
.map(|action| action.count),
Some(1)
);
}
#[tokio::test]
async fn active_bucket_drive_snapshot_is_structured_and_retired_on_end() {
let metrics = Metrics::new();
@@ -3995,7 +3995,7 @@ fn test_lifecycle_cache_legacy_msgpack_oracle() {
[
rmp_serde::to_vec(&frozen_tuple_read).expect("encode the fixed legacy tuple after production read"),
rmp_serde::to_vec(&frozen_map_read).expect("encode the fixed legacy map after production read"),
current_map.clone(),
current_map,
rmp_serde::to_vec(&crate::lifecycle_cache::LifecycleCache(&current.lifecycle))
.expect("encode the independent domain lifecycle payload"),
],
@@ -578,7 +578,7 @@ async fn periodic_nested_snapshot_checks_suspended_ancestors_before_certifying_f
.new_cache
.copy_with_children(&scanner.old_cache, &hash_path(frontier), &Some(hash_path("bucket")));
}
let root_entry = scanner.new_cache.root().expect("suspended root owner").clone();
let root_entry = scanner.new_cache.root().expect("suspended root owner");
let mut ancestor_entry = scanner.new_cache.find(ancestor).expect("suspended subtree owner").clone();
ancestor_entry.objects = 1;
ancestor_entry.size = 2;
+1 -1
View File
@@ -708,7 +708,7 @@ pub fn create_multi_cert_resolver(
for (domain, (certs, key)) in cert_key_pairs {
let certified_key = validate_certified_key(&certs, &key, &domain, true).map_err(io::Error::other)?;
if domain == "default" {
default_cert = Some(Arc::new(certified_key.clone()));
default_cert = Some(Arc::new(certified_key));
} else {
resolver
.add(&domain, certified_key)
+1 -1
View File
@@ -74,7 +74,7 @@ impl ResolverState {
let certified_key = CertifiedKey::new(certs, signing_key);
if domain.as_str() == "default" {
default_cert = Some(Arc::new(certified_key.clone()));
default_cert = Some(Arc::new(certified_key));
} else {
cert_resolver
.add(&domain, certified_key)
@@ -127,11 +127,7 @@ impl ValidationUtils {
}
}
if value.len() > 8192 {
return false;
}
true
value.len() <= 8192
}
/// Validates an entire HeaderMap for security.
+13 -4
View File
@@ -65,10 +65,10 @@ impl Stream for RetryTimer {
let jitter = self.jitter.clamp(NO_JITTER, MAX_JITTER);
let attempt = self.max_retry - self.rem;
let mut sleep = self.base_sleep * (1 << attempt);
if sleep > self.max_sleep {
sleep = self.max_sleep;
}
let mut sleep = u32::try_from(attempt)
.ok()
.and_then(|shift| 1_u32.checked_shl(shift))
.map_or(self.max_sleep, |factor| self.base_sleep.saturating_mul(factor).min(self.max_sleep));
if (jitter - NO_JITTER).abs() > 1e-9 {
let sleep_ms = sleep.as_millis();
let reduction = ((sleep_ms as f64) * (self.random as f64 * jitter / 100_f64)).round() as u128;
@@ -255,6 +255,15 @@ mod tests {
assert_eq!(retry_timer.next().await, None);
}
#[tokio::test]
async fn retry_timer_caps_backoff_for_large_attempt_budgets() {
let retry_timer = RetryTimer::new(40, Duration::from_millis(1), Duration::from_millis(2), NO_JITTER, 0);
let retries = timeout(Duration::from_secs(5), retry_timer.collect::<Vec<_>>())
.await
.expect("a large retry budget should finish without overflowing the backoff");
assert_eq!(retries.len(), 40);
}
#[test]
fn is_s3code_in_message_retryable_matches_each_retryable_code() {
for code in [
+1 -4
View File
@@ -443,10 +443,7 @@ pub fn has_ellipses<T: AsRef<str>>(s: &[T]) -> bool {
}
// Bare `...` without braces (e.g. "1...64") is still an ellipsis attempt.
if v.contains(ELLIPSES) {
return true;
}
false
v.contains(ELLIPSES)
})
}
@@ -20,12 +20,25 @@ Every stage is env-gated, so "before" and "after" run the *same binary* with dif
scripts/run_internode_grpc_ab_bench.sh --stage <p0|p1|p2|p3> --phase <before|after|request-only|canary|rollback> [--dry-run] [-- <bench args>]
```
The driver writes the stage/phase server env to `<out-dir>/server-env.sh` and runs the underlying bench into `target/bench/internode-transport/<stage>-<phase>/`. `p0/p1/p3` accept `before|after`; `p2` also accepts `request-only|canary|rollback`. `--dry-run` prints the env and command only.
The driver writes the stage/phase server env to `<out-dir>/server-env.sh` and runs the underlying bench into `target/bench/internode-transport/<stage>-<phase>/`. `p0/p1/p3` accept `before|after`; `p2` also accepts `request-only|canary|rollback`. Each phase first unsets its managed settings, then exports its overrides, so successive phases restore defaults correctly. Other environment variables retain their values. `--dry-run` writes the phase file and prints the command while skipping the workload. Apply the phase file through the deployment's service environment and read back the effective settings on every restarted node.
For P3 single-variable experiments, select `--lifecycle-feature prewarm` or `--lifecycle-feature offline`. The default `both` preserves the combined experiment. Measure prewarm with cold first calls and delayed peer startup, and measure offline bypass with sustained peer loss and survivor-only client traffic. Record the selected feature with each result.
For P0 single-parameter experiments, select `--transport-feature nodelay`, `stream-window`, `connection-window` or `max-message`. The default `all` retains the original combined preset and cannot attribute a result to one parameter. Selected P0 features write to `<out-root>/p0-<feature>-<phase>/`, keeping separate parameter artifacts from overwriting each other. Both legs clear the four P0-managed settings; the baseline overrides only the selected setting, while the candidate restores defaults. Apply any intended non-default production settings equally after the phase file, and read back effective settings, including bulk overrides that may take precedence. Zero receive windows select the linked library default, rather than a zero-byte or historical 64 KiB window; the phase names do not imply that the candidate is faster.
```bash
scripts/run_internode_grpc_ab_bench.sh --stage p0 --phase before --transport-feature connection-window --dry-run
scripts/run_internode_grpc_ab_bench.sh --stage p0 --phase after --transport-feature connection-window --dry-run
```
Dry-run command display redacts access keys, secret keys and session tokens. Actual dispatch preserves the supplied arguments. Keep credentials out of published evidence and shell history; only use task-owned buckets because load tools may clear their selected bucket.
Set `GRPC_BENCH_METRICS_URL` to the Collector Prometheus exporter endpoint. Generate each phase with `--dry-run`, apply its environment through the node service configuration, restart and verify all nodes, then execute the matching workload command.
```bash
# P1 A/B: restart the cluster with each phase's server-env.sh between the two runs
scripts/run_internode_grpc_ab_bench.sh --stage p1 --phase before -- --access-key AK --secret-key SK --metrics-url http://node1:9000/metrics
scripts/run_internode_grpc_ab_bench.sh --stage p1 --phase after -- --access-key AK --secret-key SK --metrics-url http://node1:9000/metrics
scripts/run_internode_grpc_ab_bench.sh --stage p1 --phase before -- --access-key AK --secret-key SK --metrics-url "${GRPC_BENCH_METRICS_URL:?set the Collector Prometheus exporter URL}"
scripts/run_internode_grpc_ab_bench.sh --stage p1 --phase after -- --access-key AK --secret-key SK --metrics-url "${GRPC_BENCH_METRICS_URL:?set the Collector Prometheus exporter URL}"
# P3 failover A/B (docker four-node)
scripts/run_internode_grpc_ab_bench.sh --stage p3 --phase after
# P2 env previews
@@ -45,7 +58,7 @@ Run **before** at the baseline column and **after** at the enabled column, every
| P0 conn window | `RUSTFS_INTERNODE_RPC_HTTP2_CONN_WINDOW_SIZE` | `0` | unset (`DEFAULT_INTERNODE_RPC_HTTP2_CONN_WINDOW_SIZE`) |
| P0 msg limit | `RUSTFS_INTERNODE_RPC_MAX_MESSAGE_SIZE` | `4194304` (tonic default) | unset (RustFS default, see `rustfs/src/server/http.rs`) |
| P1 isolation | `RUSTFS_INTERNODE_CHANNEL_ISOLATION` | `false` (default) | `true` |
| P1 bulk pool | `RUSTFS_INTERNODE_BULK_CHANNELS` | `1` | unset (`DEFAULT_INTERNODE_BULK_CHANNELS`) or higher |
| P1 bulk pool | `RUSTFS_INTERNODE_BULK_CHANNELS` | unset | `2` |
| P2 msgpack-only | `RUSTFS_INTERNODE_RPC_MSGPACK_ONLY` + `RUSTFS_INTERNODE_RPC_MSGPACK_ONLY_FLEET_CONFIRMED` | both `false` (default) | per the convergence runbook |
| P3 prewarm | `RUSTFS_INTERNODE_PREWARM` | `false` (default) | `true` |
| P3 offline bypass | `RUSTFS_INTERNODE_OFFLINE_BYPASS` | `false` (default) | `true` |
@@ -53,12 +66,16 @@ Run **before** at the baseline column and **after** at the enabled column, every
## Metrics to capture
The lifetime guards cover ReadAll, WriteAll, ReadMultiple and BatchReadVersion. Their active/completion metrics describe these measured operations and follow the general metrics runtime gate. Startup duration recording keeps the existing instrument, and disabled detailed metrics avoid the additional gauge/counter registrations. Bulk client unary duration covers each attempt from polling the tonic call through response-body decoding, including transport errors and cancellation by an outer deadline. Retries contribute separate samples. Channel acquisition and application serialization before the call require their own timing. The server handler duration covers handler entry through return or drop, including application payload decoding, disk work and response construction. Protobuf transport encoding and response transmission follow handler completion. Keep these two instruments separate when calculating quantiles.
All names are defined in `crates/io-metrics/src/internode_metrics.rs`.
| Metric | Stage signal |
| --- | --- |
| `rustfs_system_network_internode_operation_duration_ms{operation,backend}` | control-plane RTT (P0), lock/bulk latency (P1), first-op latency (P3) |
| `rustfs_system_network_internode_operation_server_duration_ms{operation,backend}` | server-side `ReadMultiple` / `BatchReadVersion` handler lifetime, including decode, disk work, encode, errors, and cancellation; separate from client RTT |
| `rustfs_system_network_internode_operation_server_duration_ms{operation,backend}` | server handler time for ReadAll, WriteAll, ReadMultiple and BatchReadVersion |
| `rustfs_system_network_internode_rpc_active{operation,backend,side}` | live client attempts or server handlers, including pending unary response bodies on the client |
| `rustfs_system_network_internode_rpc_completions_total{operation,backend,outcome}` | client attempt outcomes `success`, `error` and `cancelled`. An outer timeout cancels an attempt, while a returned transport timeout is an error |
| `rustfs_system_network_internode_operation_payload_bytes` | payload size distribution (P0/P1 sizing) |
| `rustfs_system_network_internode_operation_large_payloads_total` | large unary RPCs sharing a channel (P1 target) |
| `rustfs_system_network_internode_dial_avg_time_nanos`, `rustfs_system_network_internode_dial_errors_total` | connect cost and failures (P3) |
@@ -66,6 +83,126 @@ All names are defined in `crates/io-metrics/src/internode_metrics.rs`.
| `rustfs_cluster_servers_offline_total` | offline detection correctness (P3 bypass) |
| lock p99 (lock metrics, needs `RUSTFS_OBJECT_LOCK_DIAG_ENABLE=true`) | P1 head-of-line-blocking win |
Verify `RUSTFS_OBS_METRICS_EXPORT_ENABLED=true` and that the Collector exports the intended node/process identities. The client `success` outcome denotes an `Ok` tonic exchange. Interpret payload-level success using the returned operation result and operation error counters. RPC duration buckets use milliseconds and resolve sub-millisecond samples; object-lock duration buckets use seconds. The active-client metric counts the complete measured unary future, while tonic's transport concurrency limit ends when its HTTP response future completes. The two counts have different lifetimes. `BatchReadVersion` traffic requires the batch gate, such as `RUSTFS_GET_METADATA_READ_VERSION_COALESCE=on` on compatible peers; verify actual batch request counts and keep that setting identical across A/B legs.
## Isolated bulk tuning
These settings apply when `RUSTFS_INTERNODE_CHANNEL_ISOLATION=true` selects an isolated bulk cache key. Control and dedicated recovery channels use their existing transport settings. Defaults preserve the existing transport behavior.
P1 phase files clear bulk-specific overrides so an isolation experiment inherits the same common RPC window policy on both legs. For a separate bulk-tuning experiment, apply the chosen override after the phase file and include it in the effective configuration record.
| Environment variable | Default | Scope |
| --- | --- | --- |
| `RUSTFS_INTERNODE_BULK_HTTP2_STREAM_WINDOW_SIZE` | Inherit `RUSTFS_INTERNODE_RPC_HTTP2_STREAM_WINDOW_SIZE` | Bulk response receive credit per stream, bytes |
| `RUSTFS_INTERNODE_BULK_HTTP2_CONN_WINDOW_SIZE` | Inherit `RUSTFS_INTERNODE_RPC_HTTP2_CONN_WINDOW_SIZE` | Bulk response receive credit per connection, bytes |
| `RUSTFS_INTERNODE_BULK_CONCURRENCY_LIMIT` | `64` | Active tonic transport response futures per bulk channel; minimum 1 |
| `RUSTFS_INTERNODE_BULK_BUFFER_SIZE` | `1024` | Pending tonic service buffer capacity per bulk channel; minimum 1 |
Zero receive-window values select the linked library default. In the locked Hyper 1.12 client this is 2 MiB per stream and 5 MiB per connection, while RustFS explicitly defaults to 1 MiB and 2 MiB. Record which policy the experiment actually selects. Values above the HTTP/2 31-bit limit fail channel acquisition with an invalid-input error. Change connection receive credit first, then stream credit only when measured bandwidth-delay product and resource budgets justify it. For example, compare the default 2 MiB connection window with `RUSTFS_INTERNODE_BULK_HTTP2_CONN_WINDOW_SIZE=8388608`, holding other settings fixed. Request-upload flow control is configured by the receiving server. Keep server-wide window changes in a separate experiment.
Bulk selection advances independently for each peer. Cursor bookkeeping caches up to 512 peer addresses. Each entry keeps four immutable slot keys so common bulk pools borrow their cache keys on hits. Larger pools construct the remaining slot keys on demand with the same cache identity. Replacing an entry restarts its selection sequence while established channels keep their existing cache keys. HTTPS cache hits share the TLS-generation read lock, with generation validation retained before handoff. Connection misses still coalesce under the existing per-key dial lock.
Custom-CA channels use tonic's endpoint URI handling for certificate names and SNI, including IPv6 literals. The regression suite exercises real control and bulk TLS connections to IPv6 loopback and verifies that a trusted certificate for a different host is rejected.
When the outbound snapshot has no custom CA, channels explicitly load the platform trust roots. A configured mTLS client identity applies to both platform-root and custom-CA channels. An explicit custom CA selects its own trust store. TLS configuration loads and parses certificates on the blocking pool. The acquisition deadline covers waiting for that configuration. A timed-out blocking task can finish later and drops its endpoint, while the acquisition future owns connection establishment and channel publication. The native-root regression uses a process-local `SSL_CERT_FILE` fixture and exercises both channel classes, required client authentication, and rejection of a server outside the explicit private trust store.
## Local transport experiment
The [channel benchmark](../../crates/protos/examples/internode_channel_bench.rs) exercises the production channel acquisition path against two local tonic echo peers. It validates every returned byte and reports per-class request percentiles, bulk payload throughput and accepted connections per peer. An optional fourth argument selects control workers that continuously send 7-byte RPCs until the bulk workers complete. This is a closed-loop concurrency workload. Record achieved request rates and use fixed offered-load tests for the final control-plane SLO assessment. It bounds the requested workload and binds only to loopback. Its scope is the channel/HTTP2/TCP path. Distributed storage acceptance additionally requires the real four-node workload and fault matrix.
```bash
cargo build --release -p rustfs-protos --example internode_channel_bench
RUSTFS_INTERNODE_CHANNEL_ISOLATION=false target/release/examples/internode_channel_bench 16 256 262144 2
RUSTFS_INTERNODE_CHANNEL_ISOLATION=true RUSTFS_INTERNODE_BULK_CHANNELS=2 target/release/examples/internode_channel_bench 16 256 262144 2
```
Freeze the binary and environment, qualify repeated A baselines, then run A1/B1/B2/A2. Interpret payload throughput as one payload per validated round trip; the echo transfers that payload in both directions. Keep source revision, binary identity, workload arguments, connection counts, all errors and raw samples with the results. Stop attribution when baseline drift exceeds the declared gate. Use the effective four-node deployment configuration for final acceptance, with metadata, locks, multipart, TLS rotation and peer failure covered.
For fleet-wide transport changes such as switching between plaintext and TLS, stop all test nodes and verify their processes have exited before starting any node with the new settings. A per-node stop/start loop can temporarily mix protocols and exhaust peer startup retries. Preserve real shutdown failures such as `http12_deadline_missed`; a successful `systemctl stop` command alone does not establish a clean application exit. Use independent collector endpoints and configure its own telemetry listener too, so a second collector does not collide with an existing instance's default internal metrics port.
Object workload latency and RPC latency describe different boundaries. Verify that the intended bulk RPCs actually occurred before attributing isolation, pool or window effects; large S3 objects alone do not prove large unary gRPC payload coverage. Record the histogram boundaries when comparing code versions: a finer bucket layout changes quantile estimates without establishing a latency improvement. Keep the instrument name, units and client/server boundaries with the raw evidence.
## Measurement identity and maintenance observations
Freeze a fresh main revision and candidate before rebuilding both binaries in separate source and Cargo target directories. Record the actual deployed executable identity, configuration, workload and resource ownership. Earlier reports remain attached to their original revisions. Run fault recovery on separate test volumes from the steady-state campaign, or finish recovery and demonstrate stable maintenance observations before measuring. Keep healing enabled; bypassing recovery changes the correctness contract.
`ReadVersion` uses the control channel and already exports `read_version_request_encode`, `read_version_rpc_roundtrip` and `read_version_response_decode` under `rustfs_system_network_internode_operation_stage_duration_ms`; its server stages include `read_version_disk_read`. Metrics export enables these GET stages without the PUT-specific stage flag. The roundtrip timer starts after Channel acquisition, so it does not measure TLS cache or connection acquisition. Ordinary small-object GET can predominantly exercise this control method. Confirm the actual bulk methods, payloads and achieved control request rate before changing bulk settings.
Normal GET aggregation requires `RUSTFS_GET_METADATA_READ_VERSION_COALESCE=auto|on`, which defaults off. Setting only `RUSTFS_METADATA_BATCH_READ` does not assemble GET batches. The remote batch gate resolves `RUSTFS_GET_METADATA_READ_VERSION_COALESCE` before `RUSTFS_METADATA_BATCH_READ`, then the legacy `RUSTFS_BATCH_METADATA_RPC`; `auto` can fall back on UNIMPLEMENTED, while `on` cannot. Restart after changing these cached gates and verify `rustfs_get_metadata_read_version_coalescer_total{event="attempted_batch"}` and `rustfs_remote_disk_batch_read_version_gate_total{mode,decision}`. Treat enabling aggregation as its own experiment because it changes the method distribution and adds the coalescing delay.
For each cell, record prepare, warmup, measure and cleanup separately. Request-level timestamps from the load driver define the measurement window; a Prometheus snapshot bracketing the entire Warp command includes preparation and cleanup. Do not divide such counter deltas by the timed workload duration or call object STAT/GET P99 a control-RPC P99. Record cumulative histogram bucket bounds and per-series timestamps; a bucket upper bound is not an exact quantile. Use external RPC samples for comparisons across changed bucket layouts.
The offline [maintenance-window checker](../../scripts/check_internode_grpc_window.py) consumes recorded OTLP JSON, retaining resource identity and producer point timestamps that a collector's cached Prometheus exposition may conceal. Build one snapshot per observation from the most recent export of each current RustFS process. Do not concatenate multiple exports of the same series into a snapshot. Independently record each node's PID, `/proc/<pid>/stat` start-time ticks and `/proc/sys/kernel/random/boot_id` at every observation; process names can contain spaces and parentheses, so parse the fields after the final `)` before reading the start-time field. A PID alone does not detect reuse, and start-time ticks alone do not detect a reboot. Synchronize producer and capture clocks, and predeclare export-age and sample-gap budgets suitable for the configured export interval. Repeated cached points, stale/future points, resets, missing required gauges, process changes and phase mismatches invalidate evidence.
For a continuously appended native JSONL file, establish and record a byte offset before the cell, discard only the remainder of an already partial record at that offset, and wait for complete new exports from every current process before releasing the load start marker. Replaying a large historical file can consume the probe's bounded startup budget before any load begins. Keep the original export file, preserve producer timestamps and full series identity, reject truncation and conflicting points, and retain the existing freshness and coverage checks; starting at a fresh offset does not justify filling absent gauges with zero or accepting stale points.
The manifest is a bounded JSON object with this shape; replace the illustrative timestamps and identities with recorded values. Capture times at the first and last observations must match the driver-marked measurement boundaries. Every sample carries an independent process observation, even when the identity is unchanged.
```json
{
"scope": "measurement",
"start_ns": 100000000000,
"end_ns": 220000000000,
"max_sample_gap_ns": 2000000000,
"max_export_age_ns": 2000000000,
"processes": [{"node": "node1", "pid": 101, "start_ticks": 500, "boot_id": "11111111-1111-4111-8111-111111111111"}],
"samples": [
{"phase": "measurement", "captured_ns": 100000000000, "path": "measure-start.json", "processes": [{"node": "node1", "pid": 101, "start_ticks": 500, "boot_id": "11111111-1111-4111-8111-111111111111"}]},
{"phase": "measurement", "captured_ns": 102000000000, "path": "measure-002.json", "processes": [{"node": "node1", "pid": 101, "start_ticks": 500, "boot_id": "11111111-1111-4111-8111-111111111111"}]}
]
}
```
Include all intermediate observations and a final sample at `end_ns`; the abbreviated example is intentionally incomplete and does not pass. The `node` value matches OTLP `service.instance.id`, and `pid` matches `process.pid`. Supply every test node in each process inventory; `--expected-nodes` defaults to four and rejects a partial inventory. The checker ignores exports from other processes, requires current-process MRF, heal-activity and scanner-activity gauges on every node, checks optional running-task gauges when present, and detects heal-start counter increments even if the gauge is zero at both endpoints. Lazy counters may be absent while idle; a newly appearing nonzero counter is activity, not an assumed zero baseline.
```bash
python3 scripts/check_internode_grpc_window.py --manifest /path/to/cell/window.json --out /path/to/cell/maintenance-report.json
```
Exit 0 means the sampled maintenance observations were stable, 1 means activity was observed, and 2 means the evidence is invalid. The report binds the manifest and each snapshot's SHA-256. Use a fresh output path per invocation and retain the exit status; exit 2 cannot qualify even if an earlier report exists. This does not authorize performance attribution or establish that no activity occurred between samples: method coverage, external RPC timing, sufficient duration/sample count, errors, resource isolation and baseline/ABBA drift gates remain separate requirements. Do not relabel old whole-command snapshots as measurement-only evidence or fill missing points with zero. Collect native OTLP for new experiments; the checker deliberately does not turn old Prometheus snapshots into fresh producer observations.
Use this zero-maintenance check for isolated experiments that predeclare maintenance-free observations. A busy verdict is not a production health failure: legitimate healing or scanning under mixed load can be part of the workload. Such experiments need a separately declared maintenance-rate/queue envelope and comparable system observations on both sides; do not disable required recovery or reuse the zero-maintenance verdict as their general acceptance criterion.
The shared server currently uses fixed HTTP/2 receive windows. With locked Hyper 1.12.0, the fixed stream and connection window setters following `adaptive_window(true)` disable adaptation. Moving that call last would change flow-control behavior; it is a separate experiment affecting the shared HTTP/2 listener, not a no-op cleanup. Keep fixed-window and adaptive-policy changes separate from client receive-window experiments.
## Shutdown diagnostics
Enable the existing `RUSTFS_OBS_LOG_STDOUT_ENABLED=true` on the task-owned diagnostic deployment and verify the sink before testing; `RUST_LOG` only sets filtering. Select the server module at debug level for the low-frequency `http_connection_drain` lifecycle events, while keeping other filtering identical between compared cells. Record `graceful_retired`, `children_retired`, `tls_reloader_retired`, and timeout `phase` (`graceful`, `child_join`, `completion_observed`, or `tls_reloader`), elapsed time, budget available at phase entry and tracked future count. `completion_observed` means the result was observed after the original deadline, not that tasks remain running. A tracked-future count is a snapshot, not proof that the original JoinHandles have been observed. No task identity, request body or credential is emitted by these diagnostics.
Module-wide debug filtering also enables request-path diagnostics. Keep that diagnostic deployment separate from performance cells; freeze and read back the same production-like filter, such as `RUST_LOG=warn`, for both performance images while retaining native metrics and raw RPC samples. For task-owned systemd units, use `Type=exec` before validating `/proc/<MainPID>/exe`: `Type=simple` can report a successful start before the service child has executed the intended image. Preserve executable ownership checks instead of accepting an unverified process during that interval.
When a listener stops accepting connections, unfinished TLS handshakes are cancelled before HTTP graceful retirement. These peers have not dispatched an HTTP, S3 or RPC request; established TLS/HTTP connections still follow the original graceful drain and shared deadline. Cover both a TCP peer that never sends a ClientHello and an established TLS/H2 response held across shutdown. This distinction does not permit cancelling accepted writes or treating an unrelated graceful timeout as resolved without reproduction and recovery evidence.
Reproduce empty/active RPC drains on baseline and candidate, plaintext and TLS, single-node and full-stop barriers, with independent test data. Preserve the first deadline failure even if every process eventually exits. The original shared deadline remains authoritative, and timeout still waits for the real child/reloader retirement. Never abort accepted writes, discard join evidence, clear unclean-shutdown markers or extend the deadline merely to make a benchmark green. Compare confirmed writes and recovery state before declaring a shutdown fix.
## Authenticated remote RPC probe
The [remote probe](../../crates/ecstore/examples/internode_rpc_bench.rs) uses the production remote-disk ReadAll path and signed Ping client, with closed-loop bulk workers and fixed offered-rate control arrivals. It performs no remote writes or service operations. It always constructs remote disks, including for loopback test URLs, and rejects targets outside the declared `grpc-bench-` test-disk namespace. Prepare fixtures only after the isolated cluster has formatted its test disks and become ready: each test disk needs `.rustfs.sys/tmp/<fixture_id>/grpc-bench-payload.bin`, containing the exact bytes of the local `fixture_file`. This is a task-owned temporary-file RPC probe, not an erasure-coded S3 throughput or durability test. Preserve the separate S3 integrity/fault matrix.
Recreate these temporary RPC fixtures after every test-service restart, once readiness and the formatted disk UUIDs have been revalidated. Startup cleanup can remove `.rustfs.sys/tmp` content, so a missing fixture after restart is not an object-durability oracle. Preserve confirmed S3 objects and multipart uploads separately and verify their content across shutdown/recovery before creating the next temporary probe fixture.
```bash
cargo build --release --locked -p rustfs-ecstore --example internode_rpc_bench
# Load only this task's private RPC secret and outbound settings before running.
target/release/examples/internode_rpc_bench /path/to/cell/probe.json /path/to/cell/raw-rpc.json
```
The manifest supplies `namespace`, `targets` (full test-disk URLs), a non-nil UUID `fixture_id`, `fixture_file`, `duration_secs`, `bulk_workers`, `control_rps`, and `rpc_timeout_ms`. For example, use duration 120, 32 bulk workers, a 1 MiB fixture, 100 Ping arrivals per second and a 1000 ms per-call deadline. Bounds are enforced in the source; an exhausted raw-sample budget invalidates the cell rather than truncating samples silently. HTTPS requires `tls_ca`; mTLS and certificate rotation remain separate acceptance cases. `RUSTFS_RPC_SECRET` must be explicitly supplied by the test deployment. Paths in the manifest resolve relative to that manifest, and output creation refuses existing files.
The bulk recorder holds at most four million samples across its workers. Record the probe source and binary identity with the server identities, including any instrumentation overlay. Calibrate the workload before qualification when a fast local Docker network can exhaust the recorder capacity. Keep the complete failed attempt and restart qualification with the same probe on both sides.
ReadVersion failure counters retain their existing totals. The classified error counter identifies `grpc_status`, `remote_response`, and `response_decode` at the client failure boundary. A `remote_response` failure can include an absent system metadata file, so inspect the returned storage error before treating it as a connection failure. Classification labels use a fixed set of values and do not contain file paths or peer-provided error messages.
An optional `disk_ids` array identifies the formatted test disks by UUID, in exactly the same order as `targets`. Supply every target with a non-nil ID or omit the entire mapping. The probe installs these references through the existing remote-disk client API before ReadAll preflight; it does not modify server metadata. This is needed when the server registers a local path while the client dials a URL, as in isolated SNSD checks. Obtain IDs only from the task-owned formatted disks after verifying their ownership and inode; do not borrow identifiers from the original service's volumes. Preflight errors expose the numeric disk-error classification without printing error payloads.
For this standalone client, apply `RUSTFS_INTERNODE_CHANNEL_ISOLATION`, bulk-pool and client receive-window settings to the probe process. Keep server configuration fixed in a client-parameter experiment. Node-internal clients require their own node environment changes in the separate S3 experiment; changing a server's outgoing pool does not configure an external probe's connection pool. The probe does not export client telemetry, and its report states that limit. Collect server telemetry and load-generator CPU/RSS/FD/socket observations alongside the raw requests; do not generalize a client-recorder-free result to all production observability costs.
For aligned observation windows, supply distinct, initially absent `start_marker` and `end_marker` paths. After the `ready` receipt, the controller captures its initial native OTLP/process snapshot and atomically writes `{"time_ns": <capture_unix_nanoseconds>}` to the start marker. The start must arrive within the preceding second; any initial dispatch delay remains in the control samples. After `load_finished`, capture final producer exports and process facts and write the end marker. Both waits are bounded. Receipts and raw JSON bind the common epoch and observation endpoint; the observation window can include request drain/export synchronization, while the declared offering duration remains separate. Without markers the probe runs autonomously and does not establish a synchronized maintenance window.
Every offered Ping is recorded, including schedule drops, deadlines and errors. Compute offered-load latency from `completed_ns - scheduled_ns`, RPC/acquisition time from `completed_ns - dispatched_ns` when dispatched, and dispatch lag from their difference. These are monotonic offsets from `start_unix_ns`. Record the control count, `errors`, `bulk_coverage_verified` and exit status before calculating quantiles. Any failure, payload mismatch, schedule drop or absent validated bulk coverage prevents attribution; selecting only successful samples would hide overload. Bulk payload bytes count only validated replies completed within the offering interval, with one response payload per RPC, not TCP/TLS wire bytes. The production remote-disk path retains its existing idempotent-read retry policy, so preserve per-attempt server/error counters too; zero final method errors alone does not prove no underlying retry occurred.
Use identical probe source and settings on the two frozen library revisions, in separate Cargo targets, and record the harness overlay and binary identity independently from the already-built clean server binaries. Qualify repeated A cells and then A1/B1/B2/A2 before expanding to coalescing, isolation, pool or window experiments. A successful local mock regression verifies the probe's calling and accounting boundaries; acceptance against the actual authenticated four-node services is still required.
## Acceptance gates
Record the verdict (pass/fail plus measured delta) in the paired directory's summary.
@@ -13,7 +13,7 @@ The server persists an inactive catalog entry before clearing the bucket marker.
## Clean Up Deleted Empty Tables
`DELETE /iceberg/v1/buckets/{warehouse}?cleanup=true` removes recognized metadata and internal records left by deleted empty tables, then disables the catalog. Ordinary table deletion removes the table-specific maintenance configuration after removing the table entry. It preserves Iceberg data and metadata files. Bucket cleanup also accepts valid table-specific configurations left by earlier releases or interrupted drops. Remaining table entries and maintenance job records block bucket cleanup.
`DELETE /iceberg/v1/buckets/{warehouse}?cleanup=true` removes recognized metadata and internal records left by deleted empty tables, then disables the catalog. After removing an active table entry, ordinary table deletion reclaims its maintenance configuration, commit logs and idempotency receipts. It preserves Iceberg data and metadata files. Bucket cleanup can reclaim the same internal records left by older releases or interrupted drops, along with maintenance job reports for deleted tables. A retained table entry or a queued or running maintenance job blocks cleanup.
Complete the upgrade across all serving nodes and set `RUSTFS_TABLE_CATALOG_PUBLICATION_FENCE_FLEET_CONFIRMED=true` before cleanup. Delete the empty tables and namespaces through catalog APIs. The cleanup principal needs `admin:SetTableBucket` and `s3:GetObject`/`s3:DeleteObject` permissions for the target metadata objects. Omitting `cleanup` or setting it to `false` keeps ordinary disablement; repeated or invalid values are rejected.
+5 -3
View File
@@ -20,11 +20,11 @@ PRs changing only Rust sources under `crates/e2e_test/src/` and known E2E select
Specialized boundary, hardware and alternate-feature lanes run only on the scheduled (nightly) and manual full-validation runs: `Connect Feature Boundaries`, `io_uring Integration (real)`, and the rio-v2, Swift, SFTP and HTTP/3 feature lanes. They do not gate a merge. The nightly run reports their regressions within a day, and repeating these builds on every PR revision and merge group bought no earlier signal worth the runner occupancy (Connect alone measured 53.5 median minutes per run and occasionally set the wall). PRs, merge groups and main pushes run the default workspace and retain ILM, S3, E2E smoke, the short core contract and the debug build. The aggregate requires the specialized lanes to be skipped on pull-request, merge-group and push events and successful on scheduled and manual events. For changes to these features, dispatch `ci.yml` against the candidate branch before merging when that signal is needed, including PRs targeting `gateway/integration`: its pushes run the default workspace like main's, not the specialized lanes. Scheduled results supplement the main baseline and do not replace it.
Workspace Clippy runs on every event with a selected workspace Rust job, pull requests included, in `Workspace Lint and Build`. That job also creates the exact test archive and runs doctests; `Workspace tests` consumes the archive without rebuilding it. `cache-warm.yml` warms Clippy's check artifacts under `ci-dev` with the same invocation to reuse compatible dependency artifacts. `make pre-commit` does not run Clippy. Select local Clippy targets, features, and documentation exemptions from the root `AGENTS.md` verification tier; the workspace command below reproduces the CI lane and is not a universal pre-push requirement.
Workspace Clippy runs on every event with a selected workspace Rust job, pull requests included, in its own `Workspace Clippy` job. It runs beside `Workspace Lint and Build` instead of in front of it, because check-mode metadata and the linked test binaries share no compiler output; `scripts/ci_gate.py --check-workflow` rejects a workflow in which the archive producer or any archive consumer waits for the lint, or in which the lint leaves the aggregate. `Workspace Lint and Build` keeps its name as the archive producer: it creates the exact test archive and runs doctests; `Workspace tests` consumes the archive without rebuilding it. `cache-warm.yml` warms Clippy's check artifacts under `ci-dev` with the same invocation to reuse compatible dependency artifacts. `make pre-commit` does not run Clippy. Select local Clippy targets, features, and documentation exemptions from the root `AGENTS.md` verification tier; the workspace command below reproduces the CI lane and is not a universal pre-push requirement.
Serial ILM tests run at the end of `workspace-tests` for the `full` scope, consuming the same verified archive as ordinary workspace tests. The existing ignored-test filter and `-j1` remain; `--profile default` keeps the no-retry execution and preserves the separate core CI JUnit report. Any ILM failure fails the consumer and required aggregate. Logs remain in the workspace test evidence artifact. There is no separate ILM compilation or cache restore.
`required-checks` runs even after failed or skipped dependencies. `scripts/ci_gate.py verify` rejects missing jobs, unexpected jobs, failure, cancellation, and unexpected skips; optional lanes are required only on their declared events. `Workspace Lint and Build` and `Workspace tests` are the ordinary Rust producer and consumer, while `Test and Lint` uniquely names the aggregate. New validation jobs must update both its direct dependencies and the script contract. A report-only lane is registered in `REPORT_ONLY_JOBS` instead: it stays out of the aggregate, must keep its distinguishing environment, and must keep the steps it reruns identical to the required job it mirrors. Test this wiring and its failure cases with `python3 scripts/ci_gate.py --self-test`.
`required-checks` runs even after failed or skipped dependencies. `scripts/ci_gate.py verify` rejects missing jobs, unexpected jobs, failure, cancellation, and unexpected skips; optional lanes are required only on their declared events. `Workspace Clippy` is the workspace lint, `Workspace Lint and Build` and `Workspace tests` are the ordinary Rust producer and consumer, while `Test and Lint` uniquely names the aggregate. New validation jobs must update both its direct dependencies and the script contract. A report-only lane is registered in `REPORT_ONLY_JOBS` instead: it stays out of the aggregate, must keep its distinguishing environment, and must keep the steps it reruns identical to the required job it mirrors. Test this wiring and its failure cases with `python3 scripts/ci_gate.py --self-test`.
The `gateway/integration` ruleset (`24663286`) requires the same three contexts with the same review rules for the s3s to gateway integration branch (rustfs/backlog#2734). It omits `required_linear_history` because `gateway-integration-sync.yml` lands `--no-ff` merge commits from `main` on that branch every weekday, and it names the `rustfs-cla-assistant` app as the bypass actor that pushes them. `ci.yml` runs on pull requests targeting either branch and on pushes to either branch; a push to `gateway/integration` also runs `e2e-full`, because the daily merge commit lands without a pull request and must be proven like a `main` push.
@@ -39,6 +39,8 @@ The aggregate requires the validation lanes already selected by `ci.yml`; this c
Superseded PR attempts are cancelled. A running main validation finishes, with only the newest pending main run retained, so frequent merges cannot continuously cancel the full baseline. Having a `merge_group` trigger does not itself require use of the merge queue.
A push whose tree a successful `merge_group` run of `ci.yml` already validated does not validate it again. A merge-queue landing fast-forwards the branch to the group's commit, so `Select CI scope` looks that run up (`scripts/ci_merge_group_proof.py`, with the job's read-only Actions token) and plans mode `proven`: `Select CI scope`, `Typos` and `Quick Checks` run, every validation job is skipped, and `Test and Lint` passes only if each of them was skipped. The plan and the aggregate's summary name the proving run; a job skipped for any other reason keeps its own reason. A push that did not come through the queue, such as a direct administrator merge, has no such run, and any doubt about the lookup (no token, an API or network error, a rate limit, no matching successful run, a different tree) plans the full validation.
Protocol matrix jobs finish independently when a sibling fails. Each executed protocol test step preserves its log and available JUnit under a separate, attempt-specific artifact; a skipped test step cannot upload a cached report.
The four site-replication state-writer concurrency proofs reserve the available nextest slots in both local and CI profiles. This isolates their durable IO from unrelated test processes while retaining each proof's internal two-writer race, production five-second lock-acquisition limit, assertions, and zero retries.
@@ -50,7 +52,7 @@ The four site-replication state-writer concurrency proofs reserve the available
| Event | Check name | Workflow / job | Merge status | Reproduce |
|---|---|---|---|---|
| PR, non-doc change | `Quick Checks` | `ci.yml` `quick-checks` | Required | `make pre-commit` |
| PR, non-doc change, and every non-PR event | `Workspace Lint and Build`, `Workspace tests` | `ci.yml` `workspace-lint-and-build` / `workspace-tests` | Via aggregate | `cargo clippy --all-targets -- -D warnings`; `cargo nextest run --profile ci --all --exclude e2e_test`; `cargo test --all --exclude e2e_test --doc`; migration evidence check described below |
| PR, non-doc change, and every non-PR event | `Workspace Clippy`, `Workspace Lint and Build`, `Workspace tests` | `ci.yml` `workspace-clippy` / `workspace-lint-and-build` / `workspace-tests` | Via aggregate | `cargo clippy --all-targets -- -D warnings`; `cargo nextest run --profile ci --all --exclude e2e_test`; `cargo test --all --exclude e2e_test --doc`; migration evidence check described below |
| PR, non-doc change | `Typos` | `ci.yml` `typos` | Via aggregate | `typos` |
| Full selection | `Workspace tests` (serial ILM step) | `ci.yml` `workspace-tests` | Via aggregate | exact `Run ignored ILM integration tests serially` command in the job |
| Nightly schedule, manual dispatch | `Test and Lint (rio-v2)`, `Test and Lint (swift)`, `Test and Lint (sftp)`, `Test and Lint (http3)` | `ci.yml` `test-and-lint-rio-v2`, `test-and-lint-protocols` | Via aggregate on these events; skipped on PRs, merge groups and main pushes | `cargo nextest run` with the job's `--features` |
+1 -1
View File
@@ -2132,7 +2132,7 @@ mod tests {
normalize_configure_request_secrets(&mut decode_vault_request(changed), Some(&existing))
.expect_err("the request cannot select an arbitrary server file");
if variant != "TokenFile" {
let mut changed = value.clone();
let mut changed = value;
changed["auth_method"][variant]["mount"] = serde_json::json!("attacker-auth");
normalize_configure_request_secrets(&mut decode_vault_request(changed), Some(&existing))
.expect_err("file contents cannot be sent to a different authentication mount");
+61 -19
View File
@@ -13,14 +13,33 @@
// limitations under the License.
use rustfs_iam::sys::SESSION_POLICY_NAME;
use rustfs_policy::policy::Policy;
use rustfs_policy::policy::{ID, Policy, Statement};
use rustfs_s3_types::{S3Error, S3ErrorCode, S3Result, s3_error};
use serde::Deserialize;
use serde_json::Value;
use std::collections::HashMap;
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct SessionPolicyInput {
#[serde(default, rename = "Id", alias = "ID")]
id: ID,
#[serde(default, rename = "Version")]
version: String,
#[serde(default, rename = "Statement")]
statements: Vec<Statement>,
}
fn parse_session_policy(input: &str) -> rustfs_policy::error::Result<Policy> {
let SessionPolicyInput { id, version, statements } = serde_json::from_str(input)?;
let policy = Policy { id, version, statements };
policy.validate()?;
Ok(policy)
}
pub(crate) fn populate_session_policy(claims: &mut HashMap<String, Value>, policy: &str) -> S3Result<()> {
if !policy.is_empty() {
let session_policy = Policy::parse_config(policy.as_bytes()).map_err(|e| {
let session_policy = parse_session_policy(policy).map_err(|e| {
let error_msg = format!(
"Failed to parse session policy: {}. Please check that the policy is valid JSON format with standard brackets [] for arrays.",
e
@@ -59,27 +78,50 @@ mod tests {
}
#[test]
fn valid_policy_is_encoded_in_the_session_claim() {
let mut claims = HashMap::new();
let policy = r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:GetObject"],"Resource":["arn:aws:s3:::bucket/*"]}]}"#;
fn session_policy_claim_preserves_the_iam_policy_format() {
for id_key in [Some("Id"), Some("ID"), None] {
let mut input = serde_json::json!({
"Version": "2012-10-17",
"Statement": [{"Effect": "Allow", "Action": ["s3:GetObject"], "Resource": ["arn:aws:s3:::bucket/*"]}]
});
if let Some(key) = id_key {
input[key] = "read-objects".into();
}
if id_key == Some("Id") {
assert!(Policy::parse_config(input.to_string().as_bytes()).is_err());
}
let mut claims = HashMap::new();
populate_session_policy(&mut claims, &input.to_string()).expect("valid session policy");
populate_session_policy(&mut claims, policy).expect("valid policy should be accepted");
let encoded = claims
.get(SESSION_POLICY_NAME)
.and_then(Value::as_str)
.expect("session policy claim should be present");
let decoded = base64_simd::URL_SAFE_NO_PAD
.decode_to_vec(encoded.as_bytes())
.expect("session policy claim should be base64url encoded");
let decoded_policy: Value = serde_json::from_slice(&decoded).expect("session policy claim should contain JSON");
assert_eq!(decoded_policy["Version"], "2012-10-17");
let encoded = claims[SESSION_POLICY_NAME].as_str().expect("encoded session policy");
let decoded = base64_simd::URL_SAFE_NO_PAD
.decode_to_vec(encoded.as_bytes())
.expect("base64url claim");
let json: Value = serde_json::from_slice(&decoded).expect("JSON claim");
assert!(json.get("Id").is_none());
assert_eq!(json.get("ID").and_then(Value::as_str), id_key.map(|_| "read-objects"));
let policy = Policy::parse_config(&decoded).expect("claim must be readable by the IAM policy parser");
assert_eq!(policy.version, "2012-10-17");
assert_eq!(policy.statements.len(), 1);
}
}
#[test]
fn invalid_policy_is_rejected_as_invalid_request() {
let mut claims = HashMap::new();
let error = populate_session_policy(&mut claims, "not-json").expect_err("invalid policy should fail");
assert_eq!(error.code(), &S3ErrorCode::InvalidRequest);
for input in [
"not-json",
r#"{"Id":"a","ID":"b","Version":"2012-10-17"}"#,
r#"{"Id":"a","Id":"b","Version":"2012-10-17"}"#,
r#"{"id":"a","Version":"2012-10-17"}"#,
r#"{"Id":42,"Version":"2012-10-17"}"#,
r#"{"Id":"a"}"#,
r#"{"Id":"a","Version":"invalid"}"#,
r#"{"Id":"a","Version":"2012-10-17","Statement":[{"Effect":"Allow"}]}"#,
] {
let mut claims = HashMap::new();
let error = populate_session_policy(&mut claims, input).expect_err("invalid session policy");
assert_eq!(error.code(), &S3ErrorCode::InvalidRequest, "{input}");
assert!(claims.is_empty());
}
}
}
+1 -5
View File
@@ -2376,11 +2376,7 @@ fn should_buffer_get_object_in_memory_with_threshold(
);
}
if response_content_length > effective_threshold {
return false;
}
true
response_content_length <= effective_threshold
}
impl DefaultObjectUsecase {
+1 -1
View File
@@ -255,7 +255,7 @@ fn resolve_scope_with_root(
depth,
Scope {
leaf,
mount: mount.clone(),
mount,
components,
directories,
},
+1 -1
View File
@@ -2418,7 +2418,7 @@ mod tests {
StorageError::PartMissingOrCorrupt,
] {
assert!(is_object_read_unavailable(&source));
let api_error = slow_down_read_api_error(source.clone());
let api_error = slow_down_read_api_error(source);
assert_eq!(api_error.code, S3ErrorCode::Custom(SLOW_DOWN_READ_CODE.into()));
assert_eq!(api_error.message, SLOW_DOWN_READ_MESSAGE);
}
+182 -3
View File
@@ -93,7 +93,7 @@ use std::io::{Error, Result};
use std::net::{IpAddr, SocketAddr};
use std::pin::Pin;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::task::{Context, Poll};
use std::time::{Duration, Instant};
use tokio::net::{TcpListener, TcpStream};
@@ -883,6 +883,8 @@ async fn retire_http12_children(
unverified_tls_reloader: bool,
active_connections: usize,
) -> Http12ShutdownOutcome {
let started = tokio::time::Instant::now();
let graceful_complete = AtomicBool::new(false);
#[cfg(test)]
if let Some(control) = tasks.test_control() {
*control.deadline.lock() = Some(deadline);
@@ -890,6 +892,16 @@ async fn retire_http12_children(
}
let drain = async {
graceful.await;
graceful_complete.store(true, Ordering::Relaxed);
debug!(
event = EVENT_HTTP_CONNECTION_DRAIN,
component = LOG_COMPONENT_SERVER,
subsystem = LOG_SUBSYSTEM_TRANSPORT,
state = "graceful_retired",
elapsed_ms = started.elapsed().as_secs_f64() * 1000.0,
tracked_futures = tasks.0.tracker.len(),
"HTTP graceful shutdown retired"
);
#[cfg(test)]
if let Some(control) = tasks.test_control() {
control.graceful_finished.cancel();
@@ -905,6 +917,16 @@ async fn retire_http12_children(
component = LOG_COMPONENT_SERVER,
subsystem = LOG_SUBSYSTEM_TRANSPORT,
state = "timeout",
phase = if observed.is_ok() {
"completion_observed"
} else if graceful_complete.load(Ordering::Relaxed) {
"child_join"
} else {
"graceful"
},
elapsed_ms = started.elapsed().as_secs_f64() * 1000.0,
budget_ms = deadline.saturating_duration_since(started).as_secs_f64() * 1000.0,
tracked_futures = tasks.0.tracker.len(),
active_connections,
timeout_secs = 10,
"HTTP connection drain timed out"
@@ -914,6 +936,16 @@ async fn retire_http12_children(
Ok(result) => result,
Err(_) => drain.as_mut().await,
};
debug!(
event = EVENT_HTTP_CONNECTION_DRAIN,
component = LOG_COMPONENT_SERVER,
subsystem = LOG_SUBSYSTEM_TRANSPORT,
state = "children_retired",
deadline_missed,
child_failures,
elapsed_ms = started.elapsed().as_secs_f64() * 1000.0,
"HTTP child tasks retired"
);
Http12ShutdownOutcome {
deadline_missed,
first_child_error,
@@ -947,15 +979,38 @@ pub(super) async fn retire_tls_reloader(
outcome: &mut Http12ShutdownOutcome,
deadline: tokio::time::Instant,
) {
let started = tokio::time::Instant::now();
let stop = task.wait_stopped();
tokio::pin!(stop);
let observed = tokio::time::timeout_at(deadline, stop.as_mut()).await;
outcome.deadline_missed |= observed.is_err() || tokio::time::Instant::now() >= deadline;
let deadline_missed = observed.is_err() || tokio::time::Instant::now() >= deadline;
outcome.deadline_missed |= deadline_missed;
if deadline_missed {
warn!(
event = EVENT_HTTP_CONNECTION_DRAIN,
component = LOG_COMPONENT_SERVER,
subsystem = LOG_SUBSYSTEM_TRANSPORT,
state = "timeout",
phase = "tls_reloader",
elapsed_ms = started.elapsed().as_secs_f64() * 1000.0,
budget_ms = deadline.saturating_duration_since(started).as_secs_f64() * 1000.0,
"TLS reloader retirement timed out"
);
}
outcome.unverified_tls_reloader = true;
outcome.tls_reloader = Some(match observed {
Ok(receipt) => receipt,
Err(_) => stop.as_mut().await,
});
debug!(
event = EVENT_HTTP_CONNECTION_DRAIN,
component = LOG_COMPONENT_SERVER,
subsystem = LOG_SUBSYSTEM_TRANSPORT,
state = "tls_reloader_retired",
deadline_missed,
elapsed_ms = started.elapsed().as_secs_f64() * 1000.0,
"TLS reloader retired"
);
}
#[cfg(test)]
@@ -971,6 +1026,7 @@ pub(crate) async fn test_retire_http12_scope(
#[cfg(test)]
#[derive(Default)]
struct HttpLeafTestControl {
tls_handshake_started: tokio_util::sync::CancellationToken,
body_entered: tokio_util::sync::CancellationToken,
body_release: tokio_util::sync::CancellationToken,
drain_entered: tokio_util::sync::CancellationToken,
@@ -2627,6 +2683,7 @@ async fn start_http_server_inner(
let http_server = Arc::new(conn_builder);
let graceful = GracefulShutdown::new();
let tls_handshake_stop = tokio_util::sync::CancellationToken::new();
debug!(
event = EVENT_HTTP_ACCEPT_LOOP_STATE,
component = LOG_COMPONENT_SERVER,
@@ -2795,6 +2852,7 @@ async fn start_http_server_inner(
rate_limit_layer: api_rate_limit_layer.clone(),
server_ctx: Arc::clone(&server_ctx),
tls_handshake_timeout: Duration::from_secs(http1_header_read_timeout),
tls_handshake_stop: tls_handshake_stop.clone(),
request_body_idle_timeout: http_request_body_read_timeout,
};
@@ -2833,6 +2891,9 @@ async fn start_http_server_inner(
);
}
drop(listener);
// Unestablished TLS peers cannot receive HTTP graceful shutdown yet, but
// already hold a watcher. Retire only that pre-request phase immediately.
tls_handshake_stop.cancel();
drop(http_server);
#[cfg(feature = "http3")]
let unverified_h3 = http3_server.is_some();
@@ -2909,6 +2970,7 @@ struct ConnectionContext {
/// the existing slow-client bound for the pre-request phase, and is pre-computed with the
/// other transport parameters to avoid a per-connection env read.
tls_handshake_timeout: Duration,
tls_handshake_stop: tokio_util::sync::CancellationToken,
/// Inter-chunk timeout used while discarding a raw HTTP/1 request body
/// after an S3 operation has already produced its response.
request_body_idle_timeout: Duration,
@@ -3035,6 +3097,7 @@ fn process_connection(
rate_limit_layer,
server_ctx,
tls_handshake_timeout,
tls_handshake_stop,
request_body_idle_timeout,
} = context;
@@ -3260,7 +3323,16 @@ fn process_connection(
.ok()
.map_or_else(|| "unknown".to_string(), |addr| addr.to_string());
let acceptor = holder.get();
match accept_tls_with_deadline(&acceptor, socket, tls_handshake_timeout).await {
#[cfg(test)]
if let Some(control) = task_owner.test_control() {
control.tls_handshake_started.cancel();
}
let accepted = tokio::select! {
biased;
() = tls_handshake_stop.cancelled() => return,
accepted = accept_tls_with_deadline(&acceptor, socket, tls_handshake_timeout) => accepted,
};
match accepted {
Ok(tls_socket) => {
trace!("TLS handshake successful");
let stream = TokioIo::new(tls_socket);
@@ -4276,6 +4348,113 @@ mod tests {
.expect("actual production HTTP listener")
}
async fn http_tls_leaf_fixture(control: Arc<HttpLeafTestControl>) -> (ShutdownHandle, SocketAddr, tempfile::TempDir) {
if rustls::crypto::CryptoProvider::get_default().is_none() {
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
}
let directory = tempfile::TempDir::new().expect("TLS fixture directory");
let pair = rcgen::generate_simple_self_signed(vec!["localhost".to_string()]).expect("TLS fixture certificate");
std::fs::write(directory.path().join(rustfs_config::RUSTFS_TLS_CERT), pair.cert.pem())
.expect("TLS fixture certificate file");
std::fs::write(directory.path().join(rustfs_config::RUSTFS_TLS_KEY), pair.signing_key.serialize_pem())
.expect("TLS fixture key file");
let mut config = config::Config::new("127.0.0.1:0", Vec::new());
config.console_enable = false;
config.tls_path = Some(directory.path().to_str().expect("TLS fixture path").to_owned());
let readiness = Arc::new(GlobalReadiness::new());
readiness.mark_stage(rustfs_common::SystemStage::FullReady);
let (shutdown, address) = start_http_server_inner(&config, readiness, ServerContextSlot::new(), Some(control))
.await
.expect("actual production TLS listener");
(shutdown, address, directory)
}
#[tokio::test]
async fn http12_tls_stalled_handshake_retires_before_shutdown_deadline() {
let control = Arc::new(HttpLeafTestControl::default());
let (mut shutdown, address, _directory) = http_tls_leaf_fixture(control.clone()).await;
let socket = TcpStream::connect(address).await.expect("open TCP without a ClientHello");
let entered = tokio::time::timeout(Duration::from_secs(5), control.tls_handshake_started.cancelled()).await;
shutdown.signal();
let timely = tokio::time::timeout(Duration::from_secs(1), shutdown.wait_checked()).await;
// Always release the peer and join the original owner, including on a regression.
drop(socket);
let (timely, joined) = match timely {
Ok(joined) => (true, joined),
Err(_) => (false, shutdown.wait_checked().await),
};
entered.expect("accepted TLS handshake was actually polled");
joined.expect("original TLS owner remains joined");
assert!(timely, "an unestablished TLS peer must not hold the HTTP drain");
assert!(!control.body_entered.is_cancelled(), "no HTTP request reached a handler");
}
#[tokio::test]
async fn http12_tls_established_h2_response_keeps_graceful_drain() {
use rustls::pki_types::pem::PemObject;
let control = Arc::new(HttpLeafTestControl::default());
let (mut shutdown, address, directory) = http_tls_leaf_fixture(control.clone()).await;
let mut client = None;
let observations = async {
let mut roots = rustls::RootCertStore::empty();
let certificate =
rustls::pki_types::CertificateDer::from_pem_file(directory.path().join(rustfs_config::RUSTFS_TLS_CERT))
.map_err(Error::other)?;
roots.add(certificate).map_err(Error::other)?;
let mut config = rustls::ClientConfig::builder()
.with_root_certificates(roots)
.with_no_client_auth();
config.alpn_protocols = vec![b"h2".to_vec()];
let connector = tokio_rustls::TlsConnector::from(Arc::new(config));
let stream = connector
.connect("localhost".try_into().map_err(Error::other)?, TcpStream::connect(address).await?)
.await?;
let (mut sender, connection) =
hyper::client::conn::http2::handshake::<_, _, Empty<Bytes>>(TokioExecutor::new(), TokioIo::new(stream))
.await
.map_err(Error::other)?;
client = Some(tokio::spawn(connection));
let request = HttpRequest::builder()
.uri("https://localhost/__http_leaf_hold")
.body(Empty::new())
.map_err(Error::other)?;
let response = tokio::time::timeout(Duration::from_secs(5), sender.send_request(request))
.await
.map_err(Error::other)?
.map_err(Error::other)?;
tokio::time::timeout(Duration::from_secs(5), control.body_entered.cancelled())
.await
.map_err(Error::other)?;
shutdown.signal();
let held = tokio::time::timeout(Duration::from_millis(100), shutdown.wait_checked())
.await
.is_err();
control.body_release.cancel();
tokio::time::timeout(Duration::from_secs(5), response.into_body().collect())
.await
.map_err(Error::other)?
.map_err(Error::other)?;
drop(sender);
Ok::<_, Error>(held)
}
.await;
control.body_release.cancel();
shutdown.signal();
let joined = shutdown.wait_checked().await;
if let Some(client) = client {
client
.await
.expect("original TLS client task joined")
.expect("TLS H2 completed");
}
assert!(
observations.expect("verified TLS H2 observations"),
"established response must retain its graceful owner"
);
joined.expect("TLS response completed before the original shutdown deadline");
}
#[cfg(feature = "http3")]
async fn assert_tls_startup_failure_joins_started_http3_outer(fail: bool) {
use super::super::tls_material::TlsMaterialError;
@@ -496,8 +496,8 @@ impl Cluster {
locks,
_root: root,
};
for index in 1..=3 {
let peer = DistributedLock::new("warmup".to_owned(), vec![Arc::clone(&cluster.clients[index])], 1);
for (index, client) in cluster.clients.iter().enumerate().skip(1).take(3) {
let peer = DistributedLock::new("warmup".to_owned(), vec![Arc::clone(client)], 1);
drop(holder(&peer, keys(&format!("warmup-{index}"), 1).remove(0), "warmup-holder").await);
}
cluster.idle().await;
+2 -4
View File
@@ -455,9 +455,7 @@ fn s3_too_many_requests_response(request_id: Option<&str>, limit_rpm: u32, throt
<Message>Request rate limit exceeded. Reduce your request rate and retry after the indicated delay.</Message>\
{request_id_xml}</Error>"
);
let body: BoxBody = Full::new(Bytes::from(body))
.map_err(|e| -> BoxError { Box::new(e) })
.boxed_unsync();
let body: BoxBody = Full::new(Bytes::from(body)).map_err(|never| match never {}).boxed_unsync();
let mut response = Response::new(body);
*response.status_mut() = StatusCode::TOO_MANY_REQUESTS;
@@ -951,7 +949,7 @@ mod tests {
fn call(&mut self, _req: Request<ReqBody>) -> Self::Future {
let body: BoxBody = Full::new(Bytes::from_static(b"ok"))
.map_err(|e| -> BoxError { Box::new(e) })
.map_err(|never| match never {})
.boxed_unsync();
ready(Ok(Response::new(body)))
}
+1 -1
View File
@@ -177,7 +177,7 @@ fn readiness_pending_dependency(stage: rustfs_common::SystemStage) -> &'static s
fn service_not_ready_response(stage: rustfs_common::SystemStage) -> Response<BoxBody> {
let pending = readiness_pending_dependency(stage);
let body: BoxBody = Full::new(Bytes::from(format!("Service not ready: waiting for {pending}")))
.map_err(|e| -> BoxError { Box::new(e) })
.map_err(|never| match never {})
.boxed_unsync();
let mut response = Response::new(body);
+1 -3
View File
@@ -137,9 +137,7 @@ fn ssec_requires_tls_response(request_id: Option<&str>) -> Response<BoxBody> {
<Message>Requests specifying Server Side Encryption with Customer provided keys must be made over a secure connection.</Message>\
{request_id_xml}</Error>"
);
let body: BoxBody = Full::new(Bytes::from(body))
.map_err(|e| -> BoxError { Box::new(e) })
.boxed_unsync();
let body: BoxBody = Full::new(Bytes::from(body)).map_err(|never| match never {}).boxed_unsync();
let mut response = Response::new(body);
*response.status_mut() = StatusCode::BAD_REQUEST;
+81 -1752
View File
File diff suppressed because it is too large Load Diff
+5 -355
View File
@@ -53,7 +53,7 @@ mod tests {
remove_object_lock_retention_metadata, set_bucket_metadata, validate_bucket_object_lock_enabled,
validate_list_object_unordered_with_delimiter,
};
use http::{Extensions, HeaderMap, HeaderValue, Method, StatusCode, Uri};
use http::{HeaderMap, HeaderValue, StatusCode};
use rustfs_config::MI_B;
use rustfs_utils::http::{
AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER, AMZ_OBJECT_LOCK_MODE_LOWER, AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER,
@@ -62,108 +62,14 @@ mod tests {
};
use rustfs_zip::CompressionFormat;
use s3s::dto::{
DefaultRetention, DeleteObjectTaggingInput, Delimiter, FilterRule, FilterRuleName, GetBucketAclInput, GetObjectAclInput,
GetObjectLegalHoldInput, GetObjectRetentionInput, GetObjectTaggingInput, LambdaFunctionConfiguration,
NotificationConfigurationFilter, ObjectLockConfiguration, ObjectLockEnabled, ObjectLockLegalHold,
ObjectLockLegalHoldStatus, ObjectLockRetention, ObjectLockRetentionMode, ObjectLockRule, PutBucketAclInput,
PutObjectAclInput, PutObjectLegalHoldInput, PutObjectLockConfigurationInput, PutObjectRetentionInput,
PutObjectTaggingInput, QueueConfiguration, S3KeyFilter, Tag, Tagging, TopicConfiguration,
DefaultRetention, Delimiter, FilterRule, FilterRuleName, LambdaFunctionConfiguration, NotificationConfigurationFilter,
ObjectLockConfiguration, ObjectLockEnabled, ObjectLockLegalHold, ObjectLockLegalHoldStatus, ObjectLockRetention,
ObjectLockRetentionMode, ObjectLockRule, QueueConfiguration, S3KeyFilter, TopicConfiguration,
};
use s3s::{S3, S3Error, S3ErrorCode, S3Request, s3_error};
use s3s::{S3Error, S3ErrorCode, s3_error};
use std::collections::HashMap;
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
fn build_request<T>(input: T, method: Method) -> S3Request<T> {
S3Request {
input,
method,
uri: Uri::from_static("/"),
headers: HeaderMap::new(),
extensions: Extensions::new(),
credentials: None,
region: None,
service: None,
trailing_headers: None,
}
}
#[test]
#[serial_test::serial]
fn list_apis_report_storage_read_quorum_as_unavailable() {
crate::app::gating_test_env::run_large_stack_test("list-quorum-surface", || async {
use crate::storage::storage_api::contract::bucket::{BucketOperations as _, MakeBucketOptions};
use s3s::dto::{ListObjectVersionsInput, ListObjectsInput, ListObjectsV2Input};
crate::app::gating_test_env::shared_gating_ambient().await;
let store = crate::app::gating_test_env::shared_gating_ecstore().await;
let bucket = format!("list-quorum-{}", uuid::Uuid::new_v4().simple());
store
.make_bucket(&bucket, &MakeBucketOptions::default())
.await
.expect("create listing bucket");
let set = &store.pools[0].disk_set[0];
let saved_disks = set.disks.read().await.clone();
{
let mut disks = set.disks.write().await;
for disk in disks.iter_mut().skip(1) {
*disk = None;
}
}
let fs = FS::new();
let errors = [
S3::list_objects(
&fs,
build_request(
ListObjectsInput {
bucket: bucket.clone(),
..Default::default()
},
Method::GET,
),
)
.await
.expect_err("V1 cannot list below storage read quorum"),
S3::list_objects_v2(
&fs,
build_request(
ListObjectsV2Input {
bucket: bucket.clone(),
..Default::default()
},
Method::GET,
),
)
.await
.expect_err("V2 cannot list below storage read quorum"),
S3::list_object_versions(
&fs,
build_request(
ListObjectVersionsInput {
bucket,
..Default::default()
},
Method::GET,
),
)
.await
.expect_err("versions cannot list below storage read quorum"),
];
*set.disks.write().await = saved_disks;
for error in errors {
assert_eq!(error.code(), &S3ErrorCode::ServiceUnavailable);
assert_eq!(error.status_code(), Some(StatusCode::SERVICE_UNAVAILABLE));
assert_eq!(error.message(), Some("The service is unavailable. Please retry."));
let source = error.source().expect("retain storage diagnostics");
assert!(
matches!(
source.downcast_ref::<StorageError>(),
Some(StorageError::ErasureReadQuorum | StorageError::InsufficientReadQuorum(_, _))
),
"the production LIST boundary must expose a typed read-quorum failure: {source}"
);
}
});
}
#[test]
fn test_fs_creation() {
let _fs = FS::new();
@@ -293,164 +199,6 @@ mod tests {
assert_eq!(gz_format.extension(), "gz");
}
/// Premise guard for the `*_returns_internal_error_when_store_uninitialized`
/// tests (backlog#1830): they assert the error path taken while the global
/// object layer is absent. Under nextest — the authoritative runner — every
/// test owns its process, so the premise always holds and the assertion
/// always runs. Under the documented shared-process `cargo test` fallback a
/// sibling test may have initialized the store first; the premise is then
/// unattainable, so the test skips instead of asserting against a scenario
/// it does not describe.
fn store_uninitialized_premise_holds() -> bool {
if crate::runtime_sources::current_object_store_handle().is_some() {
eprintln!("skipping store-uninitialized assertion: a sibling test already initialized the global object layer");
return false;
}
true
}
#[tokio::test]
async fn test_get_object_acl_returns_internal_error_when_store_uninitialized() {
if !store_uninitialized_premise_holds() {
return;
}
let input = GetObjectAclInput::builder()
.bucket("test-bucket".to_string())
.key("test-key".to_string())
.build()
.unwrap();
let fs = FS::new();
let err = fs.get_object_acl(build_request(input, Method::GET)).await.unwrap_err();
assert_eq!(err.code(), &S3ErrorCode::InternalError);
}
#[tokio::test]
async fn test_get_bucket_acl_returns_internal_error_when_store_uninitialized() {
if !store_uninitialized_premise_holds() {
return;
}
let input = GetBucketAclInput::builder()
.bucket("test-bucket".to_string())
.build()
.unwrap();
let fs = FS::new();
let err = fs.get_bucket_acl(build_request(input, Method::GET)).await.unwrap_err();
assert_eq!(err.code(), &S3ErrorCode::InternalError);
}
#[tokio::test]
async fn test_get_object_legal_hold_returns_internal_error_when_store_uninitialized() {
if !store_uninitialized_premise_holds() {
return;
}
let input = GetObjectLegalHoldInput::builder()
.bucket("test-bucket".to_string())
.key("test-key".to_string())
.build()
.unwrap();
let fs = FS::new();
let err = fs.get_object_legal_hold(build_request(input, Method::GET)).await.unwrap_err();
assert_eq!(err.code(), &S3ErrorCode::InternalError);
}
#[tokio::test]
async fn test_get_object_retention_returns_internal_error_when_store_uninitialized() {
let input = GetObjectRetentionInput::builder()
.bucket("test-bucket".to_string())
.key("test-key".to_string())
.build()
.unwrap();
let fs = FS::new();
let err = fs.get_object_retention(build_request(input, Method::GET)).await.unwrap_err();
assert_eq!(err.code(), &S3ErrorCode::InternalError);
}
#[tokio::test]
async fn test_put_object_legal_hold_returns_internal_error_when_store_uninitialized() {
if !store_uninitialized_premise_holds() {
return;
}
let input = PutObjectLegalHoldInput::builder()
.bucket("test-bucket".to_string())
.key("test-key".to_string())
.build()
.unwrap();
let fs = FS::new();
let err = fs.put_object_legal_hold(build_request(input, Method::PUT)).await.unwrap_err();
assert_eq!(err.code(), &S3ErrorCode::InternalError);
}
#[tokio::test]
async fn test_put_bucket_acl_returns_internal_error_when_store_uninitialized() {
if !store_uninitialized_premise_holds() {
return;
}
let input = PutBucketAclInput::builder()
.bucket("test-bucket".to_string())
.build()
.unwrap();
let fs = FS::new();
let err = fs.put_bucket_acl(build_request(input, Method::PUT)).await.unwrap_err();
assert_eq!(err.code(), &S3ErrorCode::InternalError);
}
#[tokio::test]
async fn test_put_object_acl_returns_internal_error_when_store_uninitialized() {
if !store_uninitialized_premise_holds() {
return;
}
let input = PutObjectAclInput::builder()
.bucket("test-bucket".to_string())
.key("test-key".to_string())
.build()
.unwrap();
let fs = FS::new();
let err = fs.put_object_acl(build_request(input, Method::PUT)).await.unwrap_err();
assert_eq!(err.code(), &S3ErrorCode::InternalError);
}
#[tokio::test]
async fn test_put_object_retention_returns_internal_error_when_store_uninitialized() {
let input = PutObjectRetentionInput::builder()
.bucket("test-bucket".to_string())
.key("test-key".to_string())
.build()
.unwrap();
let fs = FS::new();
let err = fs.put_object_retention(build_request(input, Method::PUT)).await.unwrap_err();
assert_eq!(err.code(), &S3ErrorCode::InternalError);
}
#[tokio::test]
async fn test_put_object_lock_configuration_returns_internal_error_when_store_uninitialized() {
if !store_uninitialized_premise_holds() {
return;
}
let input = PutObjectLockConfigurationInput::builder()
.bucket("test-bucket".to_string())
.object_lock_configuration(Some(ObjectLockConfiguration {
object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)),
rule: None,
}))
.build()
.unwrap();
let fs = FS::new();
let err = fs
.put_object_lock_configuration(build_request(input, Method::PUT))
.await
.unwrap_err();
assert_eq!(err.code(), &S3ErrorCode::InternalError);
}
/// The bucket default retention path reads the persisted shape; the request
/// literals in these tests stay on the wire DTO and convert once here.
fn persisted_lock(cfg: &ObjectLockConfiguration) -> rustfs_gateway_types::persistence::PersistedObjectLockConfiguration {
@@ -777,104 +525,6 @@ mod tests {
}
}
#[tokio::test]
async fn test_get_object_tagging_returns_internal_error_when_store_uninitialized() {
if !store_uninitialized_premise_holds() {
return;
}
let input = GetObjectTaggingInput::builder()
.bucket("test-bucket".to_string())
.key("test-key".to_string())
.build()
.unwrap();
let fs = FS::new();
let err = fs.get_object_tagging(build_request(input, Method::GET)).await.unwrap_err();
assert_eq!(err.code(), &S3ErrorCode::InternalError);
}
#[tokio::test]
async fn test_put_object_tagging_rejects_too_many_tags() {
let tag_set = (0..11)
.map(|index| Tag {
key: Some(format!("k{index}")),
value: Some(format!("v{index}")),
})
.collect();
let input = PutObjectTaggingInput::builder()
.bucket("test-bucket".to_string())
.key("test-key".to_string())
.tagging(Tagging { tag_set })
.build()
.unwrap();
let fs = FS::new();
let err = fs.put_object_tagging(build_request(input, Method::PUT)).await.unwrap_err();
assert_eq!(err.code(), &S3ErrorCode::InvalidTag);
assert!(err.to_string().contains("Cannot have more than 10 tags per object"));
}
#[tokio::test]
async fn test_put_object_tagging_rejects_empty_tag_key_before_store_lookup() {
let input = PutObjectTaggingInput::builder()
.bucket("test-bucket".to_string())
.key("test-key".to_string())
.tagging(Tagging {
tag_set: vec![Tag {
key: Some(String::new()),
value: Some("v1".to_string()),
}],
})
.build()
.unwrap();
let fs = FS::new();
let err = fs.put_object_tagging(build_request(input, Method::PUT)).await.unwrap_err();
assert_eq!(err.code(), &S3ErrorCode::InvalidTag);
assert!(err.to_string().contains("Tag key cannot be empty"));
}
#[tokio::test]
async fn test_put_object_tagging_returns_internal_error_when_store_uninitialized() {
if !store_uninitialized_premise_holds() {
return;
}
let input = PutObjectTaggingInput::builder()
.bucket("test-bucket".to_string())
.key("test-key".to_string())
.tagging(Tagging {
tag_set: vec![Tag {
key: Some("k".to_string()),
value: Some("v".to_string()),
}],
})
.build()
.unwrap();
let fs = FS::new();
let err = fs.put_object_tagging(build_request(input, Method::PUT)).await.unwrap_err();
assert_eq!(err.code(), &S3ErrorCode::InternalError);
}
#[tokio::test]
async fn test_delete_object_tagging_returns_internal_error_when_store_uninitialized() {
if !store_uninitialized_premise_holds() {
return;
}
let input = DeleteObjectTaggingInput::builder()
.bucket("test-bucket".to_string())
.key("test-key".to_string())
.build()
.unwrap();
let fs = FS::new();
let err = fs
.delete_object_tagging(build_request(input, Method::DELETE))
.await
.unwrap_err();
assert_eq!(err.code(), &S3ErrorCode::InternalError);
}
#[test]
fn test_adaptive_buffer_size_with_profile() {
const KB: i64 = 1024;
@@ -0,0 +1,189 @@
// 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.
//! Legacy s3s edge of bucket policy, policy status, versioning, notification and ACL: batch A2 of
//! the gateway migration (rustfs/backlog#2765).
//!
//! `impl S3 for FS` in `ecfs.rs` calls the `*_edge` fn of each operation here. The bodies are the
//! former trait method bodies, unchanged; the batch that moves these operations onto the gateway
//! owns this file and is the only one that edits it.
use crate::error::ApiError;
use crate::storage::ecfs::*;
use crate::storage::helper::audit_only_operation;
use crate::storage::s3_api::{self, acl};
use crate::storage::storage_api::ecfs_consumer::contract::bucket::{BucketOperations, BucketOptions};
use crate::storage::{persisted_from_s3s, persisted_to_s3s};
use rustfs_io_metrics::record_s3_op;
use rustfs_s3_ops::S3Operation;
use tracing::instrument;
/// Handler bodies that [`S3`] runs through `audit_only_operation`.
impl FS {
async fn put_bucket_acl_inner(&self, req: S3Request<PutBucketAclInput>) -> S3Result<S3Response<PutBucketAclOutput>> {
let PutBucketAclInput {
bucket,
access_control_policy,
..
} = req.input;
record_s3_op(S3Operation::PutBucketAcl);
let Some(store) = self.server_ctx.object_store() else {
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
};
store
.get_bucket_info(&bucket, &BucketOptions::default())
.await
.map_err(ApiError::from)?;
if access_control_policy.is_some() {
return Err(s3_error!(
NotImplemented,
"ACL XML grants are not supported; use canned ACL headers or omit ACL"
));
}
Ok(S3Response::new(PutBucketAclOutput::default()))
}
}
impl FS {
pub(in crate::storage::ecfs) async fn delete_bucket_policy_edge(
&self,
req: S3Request<DeleteBucketPolicyInput>,
) -> S3Result<S3Response<DeleteBucketPolicyOutput>> {
let usecase = s3_api::bucket_usecase_for(self);
audit_only_operation(req, S3Operation::DeleteBucketPolicy, |req| usecase.execute_delete_bucket_policy(req)).await
}
pub(in crate::storage::ecfs) async fn get_bucket_acl_edge(
&self,
req: S3Request<GetBucketAclInput>,
) -> S3Result<S3Response<GetBucketAclOutput>> {
record_s3_op(S3Operation::GetBucketAcl);
let GetBucketAclInput { bucket, .. } = req.input;
let Some(store) = self.server_ctx.object_store() else {
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
};
store
.get_bucket_info(&bucket, &BucketOptions::default())
.await
.map_err(ApiError::from)?;
Ok(S3Response::new(acl::build_get_bucket_acl_output()))
}
pub(in crate::storage::ecfs) async fn get_bucket_notification_configuration_edge(
&self,
req: S3Request<GetBucketNotificationConfigurationInput>,
) -> S3Result<S3Response<GetBucketNotificationConfigurationOutput>> {
record_s3_op(S3Operation::GetBucketNotificationConfiguration);
let usecase = s3_api::bucket_usecase_for(self);
let notification_configuration = usecase
.execute_get_bucket_notification_configuration(req)
.await?
.as_ref()
.map(persisted_to_s3s::<_, NotificationConfiguration>)
.transpose()?;
match notification_configuration {
Some(NotificationConfiguration {
event_bridge_configuration,
lambda_function_configurations,
queue_configurations,
topic_configurations,
}) => Ok(S3Response::new(GetBucketNotificationConfigurationOutput {
event_bridge_configuration,
lambda_function_configurations,
queue_configurations,
topic_configurations,
})),
None => Ok(S3Response::new(GetBucketNotificationConfigurationOutput::default())),
}
}
pub(in crate::storage::ecfs) async fn get_bucket_policy_edge(
&self,
req: S3Request<GetBucketPolicyInput>,
) -> S3Result<S3Response<GetBucketPolicyOutput>> {
record_s3_op(S3Operation::GetBucketPolicy);
let usecase = s3_api::bucket_usecase_for(self);
usecase.execute_get_bucket_policy(req).await
}
pub(in crate::storage::ecfs) async fn get_bucket_policy_status_edge(
&self,
req: S3Request<GetBucketPolicyStatusInput>,
) -> S3Result<S3Response<GetBucketPolicyStatusOutput>> {
record_s3_op(S3Operation::GetBucketPolicyStatus);
let usecase = s3_api::bucket_usecase_for(self);
usecase.execute_get_bucket_policy_status(req).await
}
#[instrument(name = "get_bucket_versioning", level = "debug", skip(self, req))]
pub(in crate::storage::ecfs) async fn get_bucket_versioning_edge(
&self,
req: S3Request<GetBucketVersioningInput>,
) -> S3Result<S3Response<GetBucketVersioningOutput>> {
record_s3_op(S3Operation::GetBucketVersioning);
let usecase = s3_api::bucket_usecase_for(self);
let config = usecase.execute_get_bucket_versioning(req).await?;
Ok(S3Response::new(GetBucketVersioningOutput {
status: config.status.map(BucketVersioningStatus::from),
..Default::default()
}))
}
pub(in crate::storage::ecfs) async fn put_bucket_acl_edge(
&self,
req: S3Request<PutBucketAclInput>,
) -> S3Result<S3Response<PutBucketAclOutput>> {
audit_only_operation(req, S3Operation::PutBucketAcl, |req| self.put_bucket_acl_inner(req)).await
}
pub(in crate::storage::ecfs) async fn put_bucket_notification_configuration_edge(
&self,
req: S3Request<PutBucketNotificationConfigurationInput>,
) -> S3Result<S3Response<PutBucketNotificationConfigurationOutput>> {
let prepared = persisted_from_s3s(&req.input.notification_configuration);
let usecase = s3_api::bucket_usecase_for(self);
audit_only_operation(req, S3Operation::PutBucketNotificationConfiguration, |req| {
usecase.execute_put_bucket_notification_configuration(req, prepared)
})
.await
}
pub(in crate::storage::ecfs) async fn put_bucket_policy_edge(
&self,
req: S3Request<PutBucketPolicyInput>,
) -> S3Result<S3Response<PutBucketPolicyOutput>> {
let usecase = s3_api::bucket_usecase_for(self);
audit_only_operation(req, S3Operation::PutBucketPolicy, |req| usecase.execute_put_bucket_policy(req)).await
}
#[instrument(name = "put_bucket_versioning", level = "debug", skip(self, req))]
pub(in crate::storage::ecfs) async fn put_bucket_versioning_edge(
&self,
req: S3Request<PutBucketVersioningInput>,
) -> S3Result<S3Response<PutBucketVersioningOutput>> {
let prepared = persisted_from_s3s(&req.input.versioning_configuration);
let usecase = s3_api::bucket_usecase_for(self);
audit_only_operation(req, S3Operation::PutBucketVersioning, |req| {
usecase.execute_put_bucket_versioning(req, prepared)
})
.await
}
}
@@ -0,0 +1,173 @@
// 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.
//! Legacy s3s edge of the bucket CORS, tagging, public access block and encryption configurations:
//! batch A1 of the gateway migration (rustfs/backlog#2765).
//!
//! `impl S3 for FS` in `ecfs.rs` calls the `*_edge` fn of each operation here. The bodies are the
//! former trait method bodies, unchanged; the batch that moves these operations onto the gateway
//! owns this file and is the only one that edits it.
use crate::storage::ecfs::*;
use crate::storage::helper::audit_only_operation;
use crate::storage::s3_api;
use crate::storage::{persisted_from_s3s, persisted_to_s3s, tagging_from_s3s, tagging_to_s3s};
use rustfs_io_metrics::record_s3_op;
use rustfs_s3_ops::S3Operation;
use tracing::instrument;
impl FS {
#[instrument(name = "delete_bucket_cors", level = "debug", skip(self, req))]
pub(in crate::storage::ecfs) async fn delete_bucket_cors_edge(
&self,
req: S3Request<DeleteBucketCorsInput>,
) -> S3Result<S3Response<DeleteBucketCorsOutput>> {
let usecase = s3_api::bucket_usecase_for(self);
audit_only_operation(req, S3Operation::DeleteBucketCors, |req| usecase.execute_delete_bucket_cors(req)).await
}
pub(in crate::storage::ecfs) async fn delete_bucket_encryption_edge(
&self,
req: S3Request<DeleteBucketEncryptionInput>,
) -> S3Result<S3Response<DeleteBucketEncryptionOutput>> {
let usecase = s3_api::bucket_usecase_for(self);
audit_only_operation(req, S3Operation::DeleteBucketEncryption, |req| {
usecase.execute_delete_bucket_encryption(req)
})
.await
}
#[instrument(name = "delete_bucket_tagging", level = "debug", skip(self, req))]
pub(in crate::storage::ecfs) async fn delete_bucket_tagging_edge(
&self,
req: S3Request<DeleteBucketTaggingInput>,
) -> S3Result<S3Response<DeleteBucketTaggingOutput>> {
let usecase = s3_api::bucket_usecase_for(self);
audit_only_operation(req, S3Operation::DeleteBucketTagging, |req| usecase.execute_delete_bucket_tagging(req)).await
}
#[instrument(name = "delete_public_access_block", level = "debug", skip(self, req))]
pub(in crate::storage::ecfs) async fn delete_public_access_block_edge(
&self,
req: S3Request<DeletePublicAccessBlockInput>,
) -> S3Result<S3Response<DeletePublicAccessBlockOutput>> {
let usecase = s3_api::bucket_usecase_for(self);
audit_only_operation(req, S3Operation::DeletePublicAccessBlock, |req| {
usecase.execute_delete_public_access_block(req)
})
.await
}
#[instrument(name = "get_bucket_cors", level = "debug", skip(self, req))]
pub(in crate::storage::ecfs) async fn get_bucket_cors_edge(
&self,
req: S3Request<GetBucketCorsInput>,
) -> S3Result<S3Response<GetBucketCorsOutput>> {
record_s3_op(S3Operation::GetBucketCors);
let usecase = s3_api::bucket_usecase_for(self);
let cors_configuration = usecase.execute_get_bucket_cors(req).await?;
let cors_configuration: CORSConfiguration = persisted_to_s3s(&cors_configuration)?;
Ok(S3Response::new(GetBucketCorsOutput {
cors_rules: Some(cors_configuration.cors_rules),
}))
}
pub(in crate::storage::ecfs) async fn get_bucket_encryption_edge(
&self,
req: S3Request<GetBucketEncryptionInput>,
) -> S3Result<S3Response<GetBucketEncryptionOutput>> {
record_s3_op(S3Operation::GetBucketEncryption);
let usecase = s3_api::bucket_usecase_for(self);
let server_side_encryption_configuration = usecase
.execute_get_bucket_encryption(req)
.await?
.as_ref()
.map(persisted_to_s3s::<_, ServerSideEncryptionConfiguration>)
.transpose()?;
Ok(S3Response::new(GetBucketEncryptionOutput {
server_side_encryption_configuration,
}))
}
#[instrument(name = "get_bucket_tagging", level = "debug", skip(self, req))]
pub(in crate::storage::ecfs) async fn get_bucket_tagging_edge(
&self,
req: S3Request<GetBucketTaggingInput>,
) -> S3Result<S3Response<GetBucketTaggingOutput>> {
record_s3_op(S3Operation::GetBucketTagging);
let usecase = s3_api::bucket_usecase_for(self);
let tagging = usecase.execute_get_bucket_tagging(req).await?;
Ok(S3Response::new(GetBucketTaggingOutput {
tag_set: tagging_to_s3s(&tagging).tag_set,
}))
}
#[instrument(name = "get_public_access_block", level = "debug", skip(self, req))]
pub(in crate::storage::ecfs) async fn get_public_access_block_edge(
&self,
req: S3Request<GetPublicAccessBlockInput>,
) -> S3Result<S3Response<GetPublicAccessBlockOutput>> {
record_s3_op(S3Operation::GetPublicAccessBlock);
let usecase = s3_api::bucket_usecase_for(self);
let config = usecase.execute_get_public_access_block(req).await?;
Ok(S3Response::new(GetPublicAccessBlockOutput {
public_access_block_configuration: Some(persisted_to_s3s(&config)?),
}))
}
#[instrument(name = "put_bucket_cors", level = "debug", skip(self, req))]
pub(in crate::storage::ecfs) async fn put_bucket_cors_edge(
&self,
req: S3Request<PutBucketCorsInput>,
) -> S3Result<S3Response<PutBucketCorsOutput>> {
let prepared = persisted_from_s3s(&req.input.cors_configuration);
let usecase = s3_api::bucket_usecase_for(self);
audit_only_operation(req, S3Operation::PutBucketCors, |req| usecase.execute_put_bucket_cors(req, prepared)).await
}
pub(in crate::storage::ecfs) async fn put_bucket_encryption_edge(
&self,
req: S3Request<PutBucketEncryptionInput>,
) -> S3Result<S3Response<PutBucketEncryptionOutput>> {
let prepared = persisted_from_s3s(&req.input.server_side_encryption_configuration);
let usecase = s3_api::bucket_usecase_for(self);
audit_only_operation(req, S3Operation::PutBucketEncryption, |req| {
usecase.execute_put_bucket_encryption(req, prepared)
})
.await
}
#[instrument(name = "put_public_access_block", level = "debug", skip(self, req))]
pub(in crate::storage::ecfs) async fn put_public_access_block_edge(
&self,
req: S3Request<PutPublicAccessBlockInput>,
) -> S3Result<S3Response<PutPublicAccessBlockOutput>> {
let prepared = persisted_from_s3s(&req.input.public_access_block_configuration);
let usecase = s3_api::bucket_usecase_for(self);
audit_only_operation(req, S3Operation::PutPublicAccessBlock, |req| {
usecase.execute_put_public_access_block(req, prepared)
})
.await
}
#[instrument(name = "put_bucket_tagging", level = "debug", skip(self, req))]
pub(in crate::storage::ecfs) async fn put_bucket_tagging_edge(
&self,
req: S3Request<PutBucketTaggingInput>,
) -> S3Result<S3Response<PutBucketTaggingOutput>> {
let tagging = tagging_from_s3s(&req.input.tagging);
let usecase = s3_api::bucket_usecase_for(self);
audit_only_operation(req, S3Operation::PutBucketTagging, |req| usecase.execute_put_bucket_tagging(req, tagging)).await
}
}
@@ -0,0 +1,340 @@
// 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.
//! Legacy s3s edge of bucket create, delete, head and location, and the website, accelerate,
//! logging and request-payment configurations: batch A4 of the gateway migration
//! (rustfs/backlog#2765).
//!
//! `impl S3 for FS` in `ecfs.rs` calls the `*_edge` fn of each operation here. The bodies are the
//! former trait method bodies, unchanged; the batch that moves these operations onto the gateway
//! owns this file and is the only one that edits it.
use crate::storage::access::bucket_config_mutation_incarnation;
use crate::storage::ecfs::*;
use crate::storage::helper::audit_only_operation;
use crate::storage::s3_api;
use crate::storage::storage_api::ecfs_consumer::contract::bucket::{BucketOperations, BucketOptions};
use crate::storage::storage_api::ecstore_bucket::utils::serialize as serialize_bucket_config;
use crate::storage::validate_website_configuration;
use crate::storage::{
BUCKET_ACCELERATE_CONFIG, BUCKET_LOGGING_CONFIG, BUCKET_REQUEST_PAYMENT_CONFIG, BUCKET_WEBSITE_CONFIG, StorageError,
delete_bucket_metadata_config_if_incarnation, get_bucket_accelerate_config, get_bucket_logging_config,
get_bucket_request_payment_config, get_bucket_website_config, update_bucket_metadata_config_if_incarnation,
};
use crate::storage::{persisted_from_s3s, persisted_to_s3s};
use rustfs_io_metrics::record_s3_op;
use rustfs_s3_ops::S3Operation;
use tracing::instrument;
/// Handler bodies that [`S3`] runs through `audit_only_operation`.
impl FS {
async fn put_bucket_logging_inner(
&self,
req: S3Request<PutBucketLoggingInput>,
) -> S3Result<S3Response<PutBucketLoggingOutput>> {
let expected_incarnation_id = bucket_config_mutation_incarnation(&req, &req.input.bucket)?;
record_s3_op(S3Operation::PutBucketLogging);
let Some(store) = self.server_ctx.object_store() else {
return Err(s3_error!(InternalError, "Not init"));
};
store
.get_bucket_info(&req.input.bucket, &BucketOptions::default())
.await
.map_err(crate::error::ApiError::from)?;
let bucket_logging_status = persisted_from_s3s::<_, rustfs_gateway_types::persistence::PersistedBucketLoggingStatus>(
&req.input.bucket_logging_status,
)?;
let logging_config = serialize_bucket_config(&bucket_logging_status)
.map_err(|err| S3Error::with_message(S3ErrorCode::MalformedXML, format!("{err}")))?;
update_bucket_metadata_config_if_incarnation(
&req.input.bucket,
BUCKET_LOGGING_CONFIG,
logging_config,
expected_incarnation_id,
)
.await
.map_err(crate::error::ApiError::from)?;
Ok(S3Response::new(PutBucketLoggingOutput::default()))
}
}
impl FS {
#[instrument(
name = "create_bucket",
level = "debug",
skip(self, req),
fields(start_time=?time::OffsetDateTime::now_utc())
)]
pub(in crate::storage::ecfs) async fn create_bucket_edge(
&self,
req: S3Request<CreateBucketInput>,
) -> S3Result<S3Response<CreateBucketOutput>> {
let usecase = s3_api::bucket_usecase_for(self);
usecase.execute_create_bucket(req).await
}
/// Delete a bucket
#[instrument(name = "delete_bucket", level = "debug", skip(self, req))]
pub(in crate::storage::ecfs) async fn delete_bucket_edge(
&self,
req: S3Request<DeleteBucketInput>,
) -> S3Result<S3Response<DeleteBucketOutput>> {
let usecase = s3_api::bucket_usecase_for(self);
usecase.execute_delete_bucket(req).await
}
pub(in crate::storage::ecfs) async fn delete_bucket_website_edge(
&self,
req: S3Request<DeleteBucketWebsiteInput>,
) -> S3Result<S3Response<DeleteBucketWebsiteOutput>> {
let expected_incarnation_id = bucket_config_mutation_incarnation(&req, &req.input.bucket)?;
let Some(store) = self.server_ctx.object_store() else {
return Err(s3_error!(InternalError, "Not init"));
};
store
.get_bucket_info(&req.input.bucket, &BucketOptions::default())
.await
.map_err(crate::error::ApiError::from)?;
delete_bucket_metadata_config_if_incarnation(&req.input.bucket, BUCKET_WEBSITE_CONFIG, expected_incarnation_id)
.await
.map_err(crate::error::ApiError::from)?;
Ok(S3Response::new(DeleteBucketWebsiteOutput::default()))
}
pub(in crate::storage::ecfs) async fn get_bucket_accelerate_configuration_edge(
&self,
req: S3Request<GetBucketAccelerateConfigurationInput>,
) -> S3Result<S3Response<GetBucketAccelerateConfigurationOutput>> {
let Some(store) = self.server_ctx.object_store() else {
return Err(s3_error!(InternalError, "Not init"));
};
store
.get_bucket_info(&req.input.bucket, &BucketOptions::default())
.await
.map_err(crate::error::ApiError::from)?;
match get_bucket_accelerate_config(&req.input.bucket).await {
Ok((accelerate, _)) => Ok(S3Response::new(GetBucketAccelerateConfigurationOutput {
status: accelerate.status.map(BucketAccelerateStatus::from),
..Default::default()
})),
Err(StorageError::ConfigNotFound) => Ok(S3Response::new(GetBucketAccelerateConfigurationOutput::default())),
Err(err) => Err(crate::error::ApiError::from(err).into()),
}
}
/// Get bucket location
#[instrument(name = "get_bucket_location", level = "debug", skip(self, req))]
pub(in crate::storage::ecfs) async fn get_bucket_location_edge(
&self,
req: S3Request<GetBucketLocationInput>,
) -> S3Result<S3Response<GetBucketLocationOutput>> {
record_s3_op(S3Operation::GetBucketLocation);
let usecase = s3_api::bucket_usecase_for(self);
usecase.execute_get_bucket_location(req).await
}
pub(in crate::storage::ecfs) async fn get_bucket_request_payment_edge(
&self,
req: S3Request<GetBucketRequestPaymentInput>,
) -> S3Result<S3Response<GetBucketRequestPaymentOutput>> {
let Some(store) = self.server_ctx.object_store() else {
return Err(s3_error!(InternalError, "Not init"));
};
store
.get_bucket_info(&req.input.bucket, &BucketOptions::default())
.await
.map_err(crate::error::ApiError::from)?;
match get_bucket_request_payment_config(&req.input.bucket).await {
Ok((payment, _)) => Ok(S3Response::new(GetBucketRequestPaymentOutput {
payer: Some(Payer::from(payment.payer)),
})),
Err(StorageError::ConfigNotFound) => Ok(S3Response::new(GetBucketRequestPaymentOutput {
payer: Some(Payer::from_static(Payer::BUCKET_OWNER)),
})),
Err(err) => Err(crate::error::ApiError::from(err).into()),
}
}
pub(in crate::storage::ecfs) async fn get_bucket_website_edge(
&self,
req: S3Request<GetBucketWebsiteInput>,
) -> S3Result<S3Response<GetBucketWebsiteOutput>> {
let Some(store) = self.server_ctx.object_store() else {
return Err(s3_error!(InternalError, "Not init"));
};
store
.get_bucket_info(&req.input.bucket, &BucketOptions::default())
.await
.map_err(crate::error::ApiError::from)?;
match get_bucket_website_config(&req.input.bucket).await {
Ok((website, _)) => {
let website: WebsiteConfiguration = persisted_to_s3s(&website)?;
Ok(S3Response::new(GetBucketWebsiteOutput {
error_document: website.error_document,
index_document: website.index_document,
redirect_all_requests_to: website.redirect_all_requests_to,
routing_rules: website.routing_rules,
}))
}
Err(StorageError::ConfigNotFound) => Err(s3_error!(NoSuchWebsiteConfiguration)),
Err(err) => Err(crate::error::ApiError::from(err).into()),
}
}
#[instrument(name = "head_bucket", level = "debug", skip(self, req))]
pub(in crate::storage::ecfs) async fn head_bucket_edge(
&self,
req: S3Request<HeadBucketInput>,
) -> S3Result<S3Response<HeadBucketOutput>> {
let usecase = s3_api::bucket_usecase_for(self);
usecase.execute_head_bucket(req).await
}
pub(in crate::storage::ecfs) async fn put_bucket_accelerate_configuration_edge(
&self,
req: S3Request<PutBucketAccelerateConfigurationInput>,
) -> S3Result<S3Response<PutBucketAccelerateConfigurationOutput>> {
let expected_incarnation_id = bucket_config_mutation_incarnation(&req, &req.input.bucket)?;
let Some(store) = self.server_ctx.object_store() else {
return Err(s3_error!(InternalError, "Not init"));
};
store
.get_bucket_info(&req.input.bucket, &BucketOptions::default())
.await
.map_err(crate::error::ApiError::from)?;
let accelerate_configuration = persisted_from_s3s::<
_,
rustfs_gateway_types::persistence::PersistedAccelerateConfiguration,
>(&req.input.accelerate_configuration)?;
let accelerate_config = serialize_bucket_config(&accelerate_configuration)
.map_err(|err| S3Error::with_message(S3ErrorCode::MalformedXML, format!("{err}")))?;
update_bucket_metadata_config_if_incarnation(
&req.input.bucket,
BUCKET_ACCELERATE_CONFIG,
accelerate_config,
expected_incarnation_id,
)
.await
.map_err(crate::error::ApiError::from)?;
Ok(S3Response::new(PutBucketAccelerateConfigurationOutput::default()))
}
pub(in crate::storage::ecfs) async fn get_bucket_logging_edge(
&self,
req: S3Request<GetBucketLoggingInput>,
) -> S3Result<S3Response<GetBucketLoggingOutput>> {
record_s3_op(S3Operation::GetBucketLogging);
let Some(store) = self.server_ctx.object_store() else {
return Err(s3_error!(InternalError, "Not init"));
};
store
.get_bucket_info(&req.input.bucket, &BucketOptions::default())
.await
.map_err(crate::error::ApiError::from)?;
match get_bucket_logging_config(&req.input.bucket).await {
Ok((logging, _)) => {
let logging: BucketLoggingStatus = persisted_to_s3s(&logging)?;
Ok(S3Response::new(GetBucketLoggingOutput {
logging_enabled: logging.logging_enabled,
}))
}
Err(StorageError::ConfigNotFound) => Ok(S3Response::new(GetBucketLoggingOutput::default())),
Err(err) => Err(crate::error::ApiError::from(err).into()),
}
}
pub(in crate::storage::ecfs) async fn put_bucket_logging_edge(
&self,
req: S3Request<PutBucketLoggingInput>,
) -> S3Result<S3Response<PutBucketLoggingOutput>> {
audit_only_operation(req, S3Operation::PutBucketLogging, |req| self.put_bucket_logging_inner(req)).await
}
pub(in crate::storage::ecfs) async fn put_bucket_request_payment_edge(
&self,
req: S3Request<PutBucketRequestPaymentInput>,
) -> S3Result<S3Response<PutBucketRequestPaymentOutput>> {
let expected_incarnation_id = bucket_config_mutation_incarnation(&req, &req.input.bucket)?;
let Some(store) = self.server_ctx.object_store() else {
return Err(s3_error!(InternalError, "Not init"));
};
store
.get_bucket_info(&req.input.bucket, &BucketOptions::default())
.await
.map_err(crate::error::ApiError::from)?;
let request_payment_configuration = persisted_from_s3s::<
_,
rustfs_gateway_types::persistence::PersistedRequestPaymentConfiguration,
>(&req.input.request_payment_configuration)?;
let payment_config = serialize_bucket_config(&request_payment_configuration)
.map_err(|err| S3Error::with_message(S3ErrorCode::MalformedXML, format!("{err}")))?;
update_bucket_metadata_config_if_incarnation(
&req.input.bucket,
BUCKET_REQUEST_PAYMENT_CONFIG,
payment_config,
expected_incarnation_id,
)
.await
.map_err(crate::error::ApiError::from)?;
Ok(S3Response::new(PutBucketRequestPaymentOutput::default()))
}
pub(in crate::storage::ecfs) async fn put_bucket_website_edge(
&self,
req: S3Request<PutBucketWebsiteInput>,
) -> S3Result<S3Response<PutBucketWebsiteOutput>> {
let expected_incarnation_id = bucket_config_mutation_incarnation(&req, &req.input.bucket)?;
let Some(store) = self.server_ctx.object_store() else {
return Err(s3_error!(InternalError, "Not init"));
};
store
.get_bucket_info(&req.input.bucket, &BucketOptions::default())
.await
.map_err(crate::error::ApiError::from)?;
let website_configuration = persisted_from_s3s::<_, rustfs_gateway_types::persistence::PersistedWebsiteConfiguration>(
&req.input.website_configuration,
)?;
validate_website_configuration(&website_configuration)?;
let website_config = serialize_bucket_config(&website_configuration)
.map_err(|err| S3Error::with_message(S3ErrorCode::MalformedXML, format!("{err}")))?;
update_bucket_metadata_config_if_incarnation(
&req.input.bucket,
BUCKET_WEBSITE_CONFIG,
website_config,
expected_incarnation_id,
)
.await
.map_err(crate::error::ApiError::from)?;
Ok(S3Response::new(PutBucketWebsiteOutput::default()))
}
}
@@ -0,0 +1,383 @@
// 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.
//! Legacy s3s edge of the bucket lifecycle, replication and Object Lock configurations: batch A3 of
//! the gateway migration (rustfs/backlog#2765).
//!
//! `impl S3 for FS` in `ecfs.rs` calls the `*_edge` fn of each operation here. The bodies are the
//! former trait method bodies, unchanged; the batch that moves these operations onto the gateway
//! owns this file and is the only one that edits it.
use crate::admin::handlers::site_replication::site_replication_bucket_meta_hook;
use crate::app::storage_api::object_usecase::bucket::replication::OperatorRuleContract;
use crate::error::ApiError;
use crate::storage::StorageVersioningConfigExt as _;
use crate::storage::access::bucket_config_mutation_incarnation;
use crate::storage::ecfs::*;
use crate::storage::helper::audit_only_operation;
use crate::storage::s3_api;
use crate::storage::s3_compat::lifecycle::{lifecycle_configuration_from_s3s, lifecycle_configuration_to_s3s};
use crate::storage::s3_compat::replication::{replication_configuration_from_s3s, replication_configuration_to_s3s};
use crate::storage::storage_api::ecfs_consumer::contract::bucket::{BucketOperations, BucketOptions};
use crate::storage::storage_api::ecstore_bucket::utils::serialize as serialize_bucket_config;
use crate::storage::storage_api::runtime_sources_consumer::runtime_sources;
use crate::storage::{
BUCKET_VERSIONING_CONFIG, BucketVersioningSys, OBJECT_LOCK_CONFIG, StorageError, get_bucket_object_lock_config,
update_bucket_metadata_config_if_incarnation,
};
use crate::storage::{persisted_from_s3s, persisted_to_s3s};
use http::StatusCode;
use rustfs_io_metrics::record_s3_op;
use rustfs_madmin::{SITE_REPL_API_VERSION, SRBucketMeta};
use rustfs_s3_ops::S3Operation;
use tracing::{instrument, warn};
const LOG_SUBSYSTEM_OBJECT_LOCK: &str = "object_lock";
#[cfg(test)]
pub(super) static SITE_REPLICATION_GATE_TEST_OVERRIDE: std::sync::atomic::AtomicU8 = std::sync::atomic::AtomicU8::new(0);
#[cfg(test)]
pub(super) const SITE_REPLICATION_GATE_FORCE_DISABLED: u8 = 1;
#[cfg(test)]
pub(super) const SITE_REPLICATION_GATE_FORCE_ENABLED: u8 = 2;
async fn site_replication_gate_enabled() -> S3Result<bool> {
#[cfg(test)]
match SITE_REPLICATION_GATE_TEST_OVERRIDE.load(std::sync::atomic::Ordering::SeqCst) {
SITE_REPLICATION_GATE_FORCE_DISABLED => return Ok(false),
SITE_REPLICATION_GATE_FORCE_ENABLED => return Ok(true),
_ => {}
}
crate::admin::handlers::site_replication::site_replication_enabled()
.await
.map_err(Into::into)
}
/// Remote site-replication peer deployment ids and the operator-rule contract
/// the peers support, handed to the bucket usecase so an S3 replication-config
/// edit keeps exactly the reconciler-owned rules and merges the way every
/// peer will (issue #1948). Read here, in the interface layer, because the
/// usecase must not import the admin handlers (layer guard); a state-read
/// failure propagates so the edit fails closed.
async fn site_replication_edit_context() -> S3Result<(std::collections::HashSet<String>, OperatorRuleContract)> {
// While the gate override is in effect the test exercises the deny/allow
// branch, not the peer set; there is no persisted state to read.
#[cfg(test)]
if SITE_REPLICATION_GATE_TEST_OVERRIDE.load(std::sync::atomic::Ordering::SeqCst) != 0 {
return Ok((std::collections::HashSet::new(), OperatorRuleContract::Derived));
}
crate::admin::handlers::site_replication::site_replication_edit_context()
.await
.map_err(Into::into)
}
/// MinIO `ErrReplicationDenyEditError`.
fn replication_deny_edit_error() -> S3Error {
let mut err = S3Error::with_message(
S3ErrorCode::Custom("XMinioReplicationDenyEdit".into()),
"Sub-User is not allowed to edit Replication configuration",
);
err.set_status_code(StatusCode::BAD_REQUEST);
err
}
/// Site-replication gate for S3 replication-config edits (issue #1948).
///
/// On a site-replication deployment the bucket's replication config carries
/// the operator-managed `site-repl-*` rules that keep every peer in sync, and
/// a successful edit is broadcast to all peers — so a user holding only
/// bucket-scoped `s3:PutReplicationConfiguration` could rewrite or erase
/// replication net-wide. MinIO parity (`ErrReplicationDenyEditError`): only
/// owner credentials (root or root-parented) may edit. Runs after the policy
/// authorization in the access layer and only on the external S3 path — the
/// reconciler and peer bucket-meta ingestion never route through these
/// handlers.
async fn deny_replication_config_edit_for_non_owner<T>(req: &S3Request<T>) -> S3Result<()> {
if crate::storage::access::req_info_ref(req)?.is_owner {
return Ok(());
}
if site_replication_gate_enabled().await? {
return Err(replication_deny_edit_error());
}
Ok(())
}
/// Handler bodies that [`S3`] runs through `audit_only_operation`.
impl FS {
async fn delete_bucket_replication_inner(
&self,
req: S3Request<DeleteBucketReplicationInput>,
) -> S3Result<S3Response<DeleteBucketReplicationOutput>> {
deny_replication_config_edit_for_non_owner(&req).await?;
let (site_peers, contract) = site_replication_edit_context().await?;
let usecase = s3_api::bucket_usecase_for(self);
usecase.execute_delete_bucket_replication(req, site_peers, contract).await
}
async fn put_bucket_replication_inner(
&self,
mut req: S3Request<PutBucketReplicationInput>,
) -> S3Result<S3Response<PutBucketReplicationOutput>> {
deny_replication_config_edit_for_non_owner(&req).await?;
let (site_peers, contract) = site_replication_edit_context().await?;
let replication_configuration =
replication_configuration_from_s3s(std::mem::take(&mut req.input.replication_configuration));
let usecase = s3_api::bucket_usecase_for(self);
usecase
.execute_put_bucket_replication(req, replication_configuration, site_peers, contract)
.await
}
async fn put_object_lock_configuration_inner(
&self,
req: S3Request<PutObjectLockConfigurationInput>,
) -> S3Result<S3Response<PutObjectLockConfigurationOutput>> {
let expected_incarnation_id = bucket_config_mutation_incarnation(&req, &req.input.bucket)?;
let PutObjectLockConfigurationInput {
bucket,
object_lock_configuration,
..
} = req.input;
let Some(input_cfg) = object_lock_configuration else { return Err(s3_error!(InvalidArgument)) };
let Some(store) = self.server_ctx.object_store() else {
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
};
store
.get_bucket_info(&bucket, &BucketOptions::default())
.await
.map_err(ApiError::from)?;
validate_object_lock_configuration_input(&input_cfg)?;
match get_bucket_object_lock_config(&bucket).await {
Ok(_) => {}
Err(err) => {
if err == StorageError::ConfigNotFound {
if !BucketVersioningSys::enabled(&bucket).await {
return Err(S3Error::with_message(
S3ErrorCode::InvalidBucketState,
"Object Lock configuration cannot be enabled on existing buckets".to_string(),
));
}
} else {
warn!("get_object_lock_config err {:?}", err);
return Err(S3Error::with_message(
S3ErrorCode::InternalError,
"Failed to get bucket ObjectLockConfiguration".to_string(),
));
}
}
};
let persisted_cfg =
persisted_from_s3s::<_, rustfs_gateway_types::persistence::PersistedObjectLockConfiguration>(&input_cfg)?;
let data = serialize_bucket_config(&persisted_cfg)
.map_err(|err| S3Error::with_message(S3ErrorCode::InternalError, format!("{err}")))?;
let object_lock_config =
String::from_utf8(data.clone()).map_err(|err| S3Error::with_message(S3ErrorCode::InternalError, format!("{err}")))?;
let updated_at = update_bucket_metadata_config_if_incarnation(&bucket, OBJECT_LOCK_CONFIG, data, expected_incarnation_id)
.await
.map_err(ApiError::from)?;
// When Object Lock is enabled, automatically enable versioning if not already enabled.
// This matches S3-compatible behavior.
let versioning_config = BucketVersioningSys::get(&bucket).await.map_err(ApiError::from)?;
if !versioning_config.enabled() {
let enable_versioning_config = rustfs_gateway_types::persistence::PersistedVersioningConfiguration {
status: Some("Enabled".to_owned()),
..Default::default()
};
let versioning_data = serialize_bucket_config(&enable_versioning_config)
.map_err(|err| S3Error::with_message(S3ErrorCode::InternalError, format!("{err}")))?;
update_bucket_metadata_config_if_incarnation(
&bucket,
BUCKET_VERSIONING_CONFIG,
versioning_data,
expected_incarnation_id,
)
.await
.map_err(ApiError::from)?;
}
if let Some(notification_sys) =
runtime_sources::current_notification_system_for_context(self.server_ctx.app_context().as_deref())
{
propagate_object_lock_peer_reload(notification_sys.load_bucket_metadata(&bucket).await)?;
}
if let Err(err) = site_replication_bucket_meta_hook(SRBucketMeta {
bucket: bucket.clone(),
r#type: "object-lock-config".to_string(),
object_lock_config: Some(object_lock_config),
updated_at: Some(updated_at),
api_version: Some(SITE_REPL_API_VERSION.to_string()),
..Default::default()
})
.await
{
warn!(
component = LOG_COMPONENT_STORAGE,
subsystem = LOG_SUBSYSTEM_OBJECT_LOCK,
event = "put_object_lock_configuration",
bucket = %bucket,
result = "site_replication_hook_failed",
error = ?err,
"storage object lock state"
);
}
rustfs_scanner::record_dirty_usage_bucket_from_producer(
&bucket,
rustfs_scanner::SegmentInvalidationProducerIdentity::BucketMetadata,
);
Ok(S3Response::new(PutObjectLockConfigurationOutput::default()))
}
}
impl FS {
#[instrument(name = "delete_bucket_lifecycle", level = "debug", skip(self, req))]
pub(in crate::storage::ecfs) async fn delete_bucket_lifecycle_edge(
&self,
req: S3Request<DeleteBucketLifecycleInput>,
) -> S3Result<S3Response<DeleteBucketLifecycleOutput>> {
let usecase = s3_api::bucket_usecase_for(self);
audit_only_operation(req, S3Operation::DeleteBucketLifecycle, |req| {
usecase.execute_delete_bucket_lifecycle(req)
})
.await
}
pub(in crate::storage::ecfs) async fn delete_bucket_replication_edge(
&self,
req: S3Request<DeleteBucketReplicationInput>,
) -> S3Result<S3Response<DeleteBucketReplicationOutput>> {
audit_only_operation(req, S3Operation::DeleteBucketReplication, |req| self.delete_bucket_replication_inner(req)).await
}
#[instrument(name = "get_bucket_lifecycle_configuration", level = "debug", skip(self, req))]
pub(in crate::storage::ecfs) async fn get_bucket_lifecycle_configuration_edge(
&self,
req: S3Request<GetBucketLifecycleConfigurationInput>,
) -> S3Result<S3Response<GetBucketLifecycleConfigurationOutput>> {
record_s3_op(S3Operation::GetBucketLifecycleConfiguration);
let usecase = s3_api::bucket_usecase_for(self);
let config = usecase.execute_get_bucket_lifecycle_configuration(req).await?;
let config = lifecycle_configuration_to_s3s(config)
.map_err(|error| S3Error::with_message(S3ErrorCode::InternalError, error.to_string()))?;
Ok(S3Response::new(GetBucketLifecycleConfigurationOutput {
rules: Some(config.rules),
..Default::default()
}))
}
pub(in crate::storage::ecfs) async fn get_bucket_replication_edge(
&self,
req: S3Request<GetBucketReplicationInput>,
) -> S3Result<S3Response<GetBucketReplicationOutput>> {
record_s3_op(S3Operation::GetBucketReplication);
let usecase = s3_api::bucket_usecase_for(self);
let replication_configuration = usecase.execute_get_bucket_replication(req).await?;
Ok(S3Response::new(GetBucketReplicationOutput {
replication_configuration: Some(replication_configuration_to_s3s(replication_configuration)),
}))
}
#[instrument(name = "get_object_lock_configuration", level = "debug", skip(self, req))]
pub(in crate::storage::ecfs) async fn get_object_lock_configuration_edge(
&self,
req: S3Request<GetObjectLockConfigurationInput>,
) -> S3Result<S3Response<GetObjectLockConfigurationOutput>> {
record_s3_op(S3Operation::GetObjectLockConfiguration);
let GetObjectLockConfigurationInput { bucket, .. } = req.input;
let Some(store) = self.server_ctx.object_store() else {
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
};
store
.get_bucket_info(&bucket, &BucketOptions::default())
.await
.map_err(ApiError::from)?;
let object_lock_configuration = match get_bucket_object_lock_config(&bucket).await {
Ok((cfg, _created)) => Some(persisted_to_s3s::<_, ObjectLockConfiguration>(&cfg)?),
Err(err) => {
if err == StorageError::ConfigNotFound {
return Err(S3Error::with_message(
S3ErrorCode::ObjectLockConfigurationNotFoundError,
"Object Lock configuration does not exist for this bucket".to_string(),
));
}
warn!(
component = LOG_COMPONENT_STORAGE,
subsystem = LOG_SUBSYSTEM_OBJECT_LOCK,
event = "object_lock_config_load_failed",
bucket = %bucket,
error = ?err,
"Failed to load bucket object lock configuration"
);
return Err(S3Error::with_message(
S3ErrorCode::InternalError,
"Failed to load Object Lock configuration".to_string(),
));
}
};
Ok(S3Response::new(GetObjectLockConfigurationOutput {
object_lock_configuration,
}))
}
#[instrument(name = "put_bucket_lifecycle_configuration", level = "debug", skip(self, req))]
pub(in crate::storage::ecfs) async fn put_bucket_lifecycle_configuration_edge(
&self,
req: S3Request<PutBucketLifecycleConfigurationInput>,
) -> S3Result<S3Response<PutBucketLifecycleConfigurationOutput>> {
let usecase = s3_api::bucket_usecase_for(self);
let prepared = req
.input
.lifecycle_configuration
.clone()
.map(lifecycle_configuration_from_s3s)
.transpose();
audit_only_operation(req, S3Operation::PutBucketLifecycleConfiguration, |req| {
usecase.execute_put_bucket_lifecycle_configuration(req, prepared)
})
.await
}
pub(in crate::storage::ecfs) async fn put_bucket_replication_edge(
&self,
req: S3Request<PutBucketReplicationInput>,
) -> S3Result<S3Response<PutBucketReplicationOutput>> {
audit_only_operation(req, S3Operation::PutBucketReplication, |req| self.put_bucket_replication_inner(req)).await
}
#[instrument(name = "put_object_lock_configuration", level = "debug", skip(self, req))]
pub(in crate::storage::ecfs) async fn put_object_lock_configuration_edge(
&self,
req: S3Request<PutObjectLockConfigurationInput>,
) -> S3Result<S3Response<PutObjectLockConfigurationOutput>> {
audit_only_operation(req, S3Operation::PutObjectLockConfiguration, |req| {
self.put_object_lock_configuration_inner(req)
})
.await
}
}
@@ -0,0 +1,97 @@
// 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.
//! Legacy s3s edge of the object and multipart-upload listings, CreateMultipartUpload and
//! AbortMultipartUpload: batch B2 of the gateway migration (rustfs/backlog#2765).
//!
//! `impl S3 for FS` in `ecfs.rs` calls the `*_edge` fn of each operation here. The bodies are the
//! former trait method bodies, unchanged; the batch that moves these operations onto the gateway
//! owns this file and is the only one that edits it.
use crate::storage::ecfs::*;
use crate::storage::helper::audit_only_operation;
use crate::storage::s3_api;
use rustfs_io_metrics::record_s3_op;
use rustfs_s3_ops::S3Operation;
use tracing::instrument;
impl FS {
#[instrument(name = "abort_multipart_upload", level = "debug", skip(self, req))]
pub(in crate::storage::ecfs) async fn abort_multipart_upload_edge(
&self,
req: S3Request<AbortMultipartUploadInput>,
) -> S3Result<S3Response<AbortMultipartUploadOutput>> {
let usecase = s3_api::multipart_usecase_for(self);
audit_only_operation(req, S3Operation::AbortMultipartUpload, |req| usecase.execute_abort_multipart_upload(req)).await
}
#[instrument(name = "create_multipart_upload", level = "debug", skip(self, req))]
pub(in crate::storage::ecfs) async fn create_multipart_upload_edge(
&self,
req: S3Request<CreateMultipartUploadInput>,
) -> S3Result<S3Response<CreateMultipartUploadOutput>> {
crate::hp_guard!("S3::create_multipart_upload");
let usecase = s3_api::multipart_usecase_for(self);
usecase.execute_create_multipart_upload(req).await
}
pub(in crate::storage::ecfs) async fn list_multipart_uploads_edge(
&self,
req: S3Request<ListMultipartUploadsInput>,
) -> S3Result<S3Response<ListMultipartUploadsOutput>> {
record_s3_op(S3Operation::ListMultipartUploads);
let usecase = s3_api::multipart_usecase_for(self);
usecase.execute_list_multipart_uploads(req).await
}
pub(in crate::storage::ecfs) async fn list_object_versions_edge(
&self,
req: S3Request<ListObjectVersionsInput>,
) -> S3Result<S3Response<ListObjectVersionsOutput>> {
record_s3_op(S3Operation::ListObjectVersions);
let usecase = s3_api::bucket_usecase_for(self);
usecase.execute_list_object_versions(req).await
}
#[instrument(name = "list_objects", level = "debug", skip(self, req))]
pub(in crate::storage::ecfs) async fn list_objects_edge(
&self,
req: S3Request<ListObjectsInput>,
) -> S3Result<S3Response<ListObjectsOutput>> {
record_s3_op(S3Operation::ListObjects);
let usecase = s3_api::bucket_usecase_for(self);
usecase.execute_list_objects(req).await
}
#[instrument(name = "list_objects_v2", level = "debug", skip(self, req))]
pub(in crate::storage::ecfs) async fn list_objects_v2_edge(
&self,
req: S3Request<ListObjectsV2Input>,
) -> S3Result<S3Response<ListObjectsV2Output>> {
crate::hp_guard!("S3::list_objects_v2");
record_s3_op(S3Operation::ListObjectsV2);
let usecase = s3_api::bucket_usecase_for(self);
usecase.execute_list_objects_v2(req).await
}
#[instrument(name = "list_parts", level = "debug", skip(self, req))]
pub(in crate::storage::ecfs) async fn list_parts_edge(
&self,
req: S3Request<ListPartsInput>,
) -> S3Result<S3Response<ListPartsOutput>> {
record_s3_op(S3Operation::ListParts);
let usecase = s3_api::multipart_usecase_for(self);
usecase.execute_list_parts(req).await
}
}
+31
View File
@@ -0,0 +1,31 @@
// 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.
//! The bodies of `impl S3 for FS`, one file per batch of the s3s-to-gateway migration
//! (rustfs/backlog#2765), so that parallel batches edit disjoint files. `ecfs.rs` keeps the
//! trait impl, which delegates every method here, and the helpers several batches share.
//! `scripts/codemods/split_ecfs_legacy_edge.py` produced this split from the unsplit file.
mod bucket_access;
mod bucket_configs;
mod bucket_core;
mod bucket_replication_lifecycle;
mod listing_multipart;
mod object_meta;
mod object_misc;
mod object_read;
mod object_write;
#[cfg(test)]
mod tests;

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