Compare commits

...

36 Commits

Author SHA1 Message Date
cxymds 1afaf4491e fix(ecstore): bound decommission capacity gate waits (#8044)
* fix(ecstore): bound decommission capacity gate waits

* fix(ecstore): keep rebalance meta save lock errors retryable

The rebalance metadata retry policy only retries when the source chain still
carries the typed lock error, but resolve_rebalance_meta_save_result collapsed
every failure into a plain string. A transient rebalance.bin write-lock timeout
therefore bypassed retry_rebalance_metadata_access entirely and surfaced as a
hard failure of the periodic and terminal rebalance metadata saves.

Wrap the failure with the data-movement stage context instead, which keeps the
original lock error reachable through rebalance_error_source so the existing
transient lock policy applies. The rendered message is unchanged.

Add a regression test that feeds the wrapped meta save lock timeout through the
retry helper and asserts the retry engages.

* chore(ecstore): refresh error format ratchet baseline

Removing the string-wrapped meta save failure drops one `::other(format!`
call site in the rebalance worker, so the shrink-only baseline must be
regenerated in the same change.
2026-09-21 20:56:26 +08:00
hector bdea44f332 fix(ci): align FT evidence contract with scenario E (44 cases) (#8052)
The fault-tolerance suite now registers and runs scenario E
(rustfs/auto-testing#100): --all covers A, B, C, C2, D, E with 38 + 6
= 44 expected cases. The chain-evidence assertion still hardcoded
[A, B, C, C2, D] and 38 everywhere, which would fail every green FT
run's evidence validation once E is registered.

Bump the scenario list to include E and all count assertions from 38
to 44; step name updated to match. No other lanes reference 38.
2026-09-21 20:13:56 +08:00
cxymds 8ba3309624 fix(rebalance): retry fleet proof before start activation (#8049)
Retry the transient missing fleet capability proof in both the admin start flow and node RPC activation. Return a retryable 503 with Retry-After after rollback when readiness does not converge.
2026-09-21 11:27:58 +00:00
cxymds cd2fa50e50 fix: rename admin metrics endpoint to realtime (#8046) 2026-09-21 19:23:58 +08:00
cxymds 077b961232 fix(obs): default span events to none and gate span list by level (#8047)
JSON log layers synthesized span lifecycle events for every span and
embedded the full ancestor span list on every record. With INFO spans on
object hot paths that produced multi-megabyte lines, which journald
truncated at 48 KiB: the payload was silently dropped and the volume
itself became the dominant load.

Add RUSTFS_OBS_LOG_SPAN_EVENTS (none|close|full) with a default of none,
and keep with_span_list enabled only for verbose (debug/trace) levels so
the ancestor chain stops being re-serialized per record. The request_id
promotion path reads the span scope, not the span list, so it is
unaffected.

Refs rustfs/backlog#2642
2026-09-21 19:15:39 +08:00
cxymds e1bc731331 fix(lifecycle): reject empty expiration actions (#8045)
* fix(lifecycle): reject empty expiration actions

* style: apply rustfmt to lifecycle validation
2026-09-21 15:43:07 +08:00
cxymds bb43ef861a fix(decommission): use owned data for source capacity (#8038) 2026-09-21 15:29:30 +08:00
Chris 4047ed6c8d fix(heal): restore bucket metadata before replacement completion (#8042) 2026-09-21 12:18:06 +08:00
hector ae6bbaef27 fix(ci): give the chain staleness probe a token that can read auto-testing (#8041)
resolve_functional_candidate.py probes rustfs/auto-testing (private) to
age the pinned functional-script revision and fall back to main HEAD
after 24h. The prepare step passed github.token, which cannot see the
private repo, so every nightly chain logged

  staleness probe failed (...exit status 1.); keeping pinned revision

and replayed the 09-14 harness. On the 09-20 nightly that harness died
on the dpkg conffile prompt in all 12 lanes (see rustfs/auto-testing#97)
because the --force-confold and other fixes never reached the chain.

Use PF_TESTING_GH_TOKEN - already required by the other steps in this
workflow - so the probe can actually run and the >24h fallback works.
2026-09-21 11:08:41 +08:00
cxymds a7c875b48f fix(heal): preserve remote corruption and reject incomplete deep scans (#8032)
* fix(heal): reject truncated encoded shards

* test(heal): cover truncated shards across integrity modes (#8035)

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* fix(heal): preserve remote corruption and reject incomplete deep scans

* test(admin): tolerate config lock timeout while polling

* fix(test): use admin storage error facade

---------

Co-authored-by: Hauser <housemecn@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
Co-authored-by: overtrue <anzhengchao@gmail.com>
2026-09-21 02:33:03 +00:00
マルコメ 42dd76d3b2 ci(docker): sync the Docker Hub overview from README.md on release (#8034)
Docker Hub's overview is a separate `full_description` field that
`docker push` never touches, so it had drifted into an 8 KB snapshot of
an old README.md that still linked to
https://docs.rustfs.com/introduction.html (now 404).

Add a `sync-dockerhub-description` job to docker.yml that runs after the
images are pushed and republishes README.md from the same commit via
peter-evans/dockerhub-description (pinned to v5.0.0). It reuses the
existing DOCKERHUB_USERNAME / DOCKERHUB_TOKEN credentials, so no new
secrets are needed. Relative links (docs/, CONTRIBUTING.md) are
rewritten to github.com URLs so they resolve on Docker Hub.

Docker Hub caps the field at 25,000 bytes and the action truncates to
fit with only a warning; README.md is at 22,879 bytes today. Read the
published overview back after the sync and fail the job if it hit the
cap, so a truncated overview cannot be published silently.

Fixes #7995

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Hauser <housemecn@gmail.com>
2026-09-21 07:04:47 +08:00
Chris 9aeecad4a9 docs(security): add encrypted ETag advisory lesson (#8040) 2026-09-20 23:52:47 +08:00
Hauser 3738b32df1 chore(deps): update flake.lock (#8033)
Flake lock file updates:

• Updated input 'nixpkgs':
    'github:NixOS/nixpkgs/aff8a0b' (2026-09-10)
  → 'github:NixOS/nixpkgs/a32edd7' (2026-09-17)
• Updated input 'rust-overlay':
    'github:oxalica/rust-overlay/228ecef' (2026-09-12)
  → 'github:oxalica/rust-overlay/26a71e6' (2026-09-19)
2026-09-20 14:54:45 +08:00
Hauser d997b07505 fix(get): skip futile resume for single-disk objects (#8031)
* chore: pin s3s to upstream git revision

Use the upstream s3s git repository at 50c94aeb1e5ea9ef8e7393ef6bbc87b72f6891c6 for both s3s and s3s-sigv4.

Allow the official s3s git source in cargo-deny so source checks continue to pass.

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

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

* fix(get): skip futile resume for single-disk objects

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

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

---------

Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-20 13:25:08 +08:00
Chris 0632a64f91 fix(ecstore): allow server-side copy at normal read quorum (#8029) 2026-09-20 12:36:53 +08:00
hector cbf6b02905 ci(chain): fall back to auto-testing main HEAD when the pinned harness goes stale (#8026)
The functional chain pins the test harness to the revision recorded in
.config/functional-script-revision.txt, which is only refreshed by
release syncs - it went stale on Sep 16 and the nightly chain has been
replaying a Sep-17-era harness ever since. Last night that meant:
the performance lane died on a dpkg conffile prompt (fix merged as
#88 but never reached the chain), the FT lane died on 'unknown option:
--results-file' (harness/workflow version mismatch), and the
STS-105 signing fix (#94) sat untested.

When the pinned revision's commit is older than 24 hours, fall back to
auto-testing main HEAD so the chain always runs the current harness.
Any staleness-probe failure keeps the pinned revision (fail-safe).
The revision file stays in place as the audit trail and the release
sync continues to manage it.
2026-09-20 11:27:56 +08:00
hector ed2c3eaf77 fix(package): preserve service state across upgrades (#8024)
* fix(package): preserve service state across upgrades

* fix(package): match legacy DEB versions in tilde form

Published prerelease packages carry ~ in the dpkg control version
(package_versions.sh maps the SemVer prerelease - to ~), so the
legacy fallback list written with dots never matched 1.0.0~rc.x and
upgrades away from those DEBs still left the service stopped (#8011).

Fix the legacy glob to the tilde form and update the contract test,
which had enshrined the dot form. Verified on Ubuntu 24.04 systemd
containers: DEB upgrade 1.0.0~rc.5 -> 1.0.1 now keeps the service
running; 1.0.0 -> 1.0.1 and 1.0.1 -> 1.0.2 marker path still pass.

* docs(package): expand /etc/default/rustfs example template

Document the commonly used RUSTFS_* settings as commented examples in
the packaged conffile and point to docs.rustfs.com.
2026-09-20 11:27:41 +08:00
cxymds 22243e791d test(heal): cover stale current delete marker rejoin (#8021) 2026-09-19 19:33:24 +08:00
Hauser c35de00e8a fix(scanner): protect single-disk foreground latency (#8022)
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-19 19:32:02 +08:00
cxymds 0e03bcda49 fix: serialize notify runtime module switch reconciliation (#8020) 2026-09-19 13:41:39 +08:00
cxymds c3269ad4c2 fix(heal): close tail-truncation and stale current-marker gaps (#8019)
* fix(heal): verify complete encoded shard tails

* fix(heal): prove stale current marker deletion
2026-09-19 13:41:30 +08:00
Chris 0fcd34c331 fix(ecstore): retry fleet capability probe while the notification system boots (#8017)
Startup finalizes IAM before init_notification_runtime publishes the notification system, and IAM finalization is what starts the fleet capability probe. The first probe pass therefore always failed closed and then slept the full ten-second probe interval. On a single node that left every durable capability, including the durable hard quota fence, unavailable for about ten seconds after /health already reported ok, so SetBucketQuota answered 503 durable quota capability is not confirmed across the cluster during that window.

Publishing the notification system now wakes the probe immediately through a Notify permit, with a 100ms bootstrap poll as the fallback for a wakeup that races the availability check. The probe keeps failing closed while the system is absent and logs the wait once. The four probe futures no longer carry an unreachable notification-system-unavailable arm, and the seven proof slots are revoked through one helper.

Fixes #8014
2026-09-19 13:41:16 +08:00
Chris ddc5f97a04 fix(release): sort pre-release packages below their final release (#8016)
Pre-release RPM versions replaced the SemVer `-` with `_`, which rpm treats as an ordinary segment separator, so `1.0.0_rc.5` compared as newer than `1.0.0` and a host with an rc RPM installed could not `dnf upgrade` to the GA package. DEB previews had the mirror-image problem: only the first `-` became `~`, so `1.0.0~rc.5-preview.2` carried `preview.2` as its Debian revision and sorted above `1.0.0~rc.5`.

Both formats now spell every pre-release separator as `~`, which dpkg and rpm (>= 4.10) both treat as "sorts before anything". Verified with rpm 4.16 (AlmaLinux 9) and dpkg 1.21: `1.0.0~rc.5 < 1.0.0`, `1.0.0~rc.5~preview.2 < 1.0.0~rc.5 < 1.0.0~rc.6`, alpha < beta < rc, and `rc.9 < rc.10`; fpm 1.18 passes `~` through into both package headers, and `dnf upgrade` from a `~rc.5` RPM to the GA RPM succeeds where the `_rc.5` one refused. The test now pins the full ordering contract under both package managers and fails against the previous spelling.

The release-checksum step already normalizes `~` to `.` for every uploaded asset, so RPM assets need no further handling. Hosts that already installed an `_rc`/`_beta` RPM need a one-time `dnf install rustfs-1.0.0` or `rpm -Uvh --oldpackage` to reach GA.

Fixes #8012
2026-09-19 03:22:44 +08:00
cxymds ff62c810b4 fix(tier): gate opaque version recovery cleanup (#8006)
* fix(tier): gate opaque version recovery cleanup

* test(tier): authorize opaque recovery fixtures

---------

Co-authored-by: Chris <anzhengchao@gmail.com>
2026-09-18 21:54:03 +08:00
cxymds 950db7412c fix(heal): inspect stale members during deep scans (#8009)
* fix(heal): inspect stale members during deep scans

* test(heal): prove convergence outcomes
2026-09-18 21:53:31 +08:00
Chris 4e7a8fabb8 fix(ecstore): repair buckets left with only an incarnation sidecar (#8008)
The legacy bucket metadata migration writes the `.bucket-incarnation`
sidecar before `.metadata.bin`. A crash or a lost namespace lease between
the two writes left the bucket with a sidecar and no metadata, and every
later load failed closed with "bucket incarnation sidecar exists without
bucket metadata", so the bucket answered 500 to every request on every
node with no repair path (rustfs/rustfs#8003).

Load the sidecar-only state as a legacy bucket so the migration runs
again. The migration and the force-create path re-read the sidecar under
the transaction lock and keep its incarnation when persisting the
metadata, so the retry never replaces an identity other nodes fenced on.
A retired incarnation is never adopted: it is residue of a deleted
bucket, and re-publishing it would let heal reclaim the new objects.
2026-09-18 18:14:00 +08:00
cxymds b0f4e66c92 fix(tier): model R2 as unversioned storage (#8004) 2026-09-18 10:12:51 +00:00
cxymds e9da1fa074 feat: expose subscriptions used by event destinations (#8001)
* feat: expose event subscriptions by destination

* style: format event subscription handler

* fix: preserve s3 error footprint checks
2026-09-18 16:11:19 +08:00
Hauser 023adb1696 fix(heal): discharge absent durable partial writes (#8000)
Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-18 16:07:18 +08:00
Hauser 0f69c073de fix(ecstore): preserve bounded listing completion reason (#7994)
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-18 15:41:44 +08:00
cxymds 7a5e1efa95 fix(heal): fail admin heal with unhealthy drives (#7996) 2026-09-18 12:58:24 +08:00
Chris aa7f55946c docs(operations): add cluster and erasure-coding lifecycle runbook (#7998)
Consolidate the operator entry point for planning, storage classes and
EC:0 consequences, expansion, rebalance, decommission, heal, drive
replacement, restart recovery, and the rc CLI mapping. Every claim was
checked against the current handlers, storage-class validation, quorum
arithmetic, and the rustfs/cli admin reference; the runbook links the
normative contracts instead of restating them.

Refs rustfs/backlog#2607, rustfs/backlog#2608
2026-09-18 12:50:14 +08:00
Hauser 785b44a696 fix(replication): harden delete marker HEAD convergence (#7993)
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-18 11:05:47 +08:00
Hauser 415bd2310c build(deps): bump dial9 to 0.5.1 (#7991)
Update dial9 and the resolved companion crates to 0.5.1.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-18 09:53:30 +08:00
Chris 3f469ab257 fix(helm): default the ingress backend port and refresh the nix cli hashes (#7990) 2026-09-18 09:08:24 +08:00
Hauser 31f446bed1 fix(ecstore): preserve vectored bitrot writes (#7981)
* fix(ecstore): preserve vectored bitrot writes

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

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

* fix(ecstore): route test trait through contracts

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

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

---------

Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-18 08:07:37 +08:00
98 changed files with 5264 additions and 653 deletions
@@ -101,6 +101,7 @@ Update this file only when an advisory adds or changes a reusable lesson, affect
### SSE and on-disk storage invariants
- `GHSA-xrrf-67jm-3c2r`: SSE metadata reported encryption while reader composition bypassed `EncryptReader` and stored plaintext. Lesson: test actual bytes on disk and wrapper order, not only API metadata.
- `GHSA-wqmc-vjgv-jrpw`: SSE-C persisted and listed the plaintext MD5 as the ETag, allowing list-only users to confirm or brute-force low-entropy content and correlate equal plaintext across keys. Lesson: encrypted-object ETags must not expose deterministic plaintext fingerprints; protect them with per-object key material and keep listing, conditional, copy, and multipart semantics consistent.
### Object Lock and retention invariants
@@ -126,7 +127,7 @@ rg -n "normalize_extract_entry_key|Snowball|auto-extract|PathBuf::join|canonical
rg -n "DEFAULT_SECRET|DEFAULT_ACCESS|TEST_PRIVATE_KEY|rustfs rpc|RUSTFS_RPC_SECRET" rustfs crates
rg -n "TONIC_RPC_PREFIX|verify_rpc_signature|check_auth|NodeServiceServer|x-rustfs-signature" rustfs crates
rg -n "debug!|trace!|info!|error!|\\?resp|\\?merged_config|session_token|secret_key" rustfs crates
rg -n "HashReader|EncryptReader|SSE|server-side encryption|Access-Control-Allow-Credentials|Origin" rustfs crates
rg -n "HashReader|EncryptReader|try_resolve_etag|ETag|SSE|server-side encryption|Access-Control-Allow-Credentials|Origin" rustfs crates
rg -n "ObjectLock|object_lock|retention|COMPLIANCE|GOVERNANCE|delete_prefix|lifecycle|scanner" rustfs crates
rg -n "deny_unknown_fields|serde.default|as u32|as usize|as i32" rustfs crates
```
@@ -145,5 +146,5 @@ rg -n "deny_unknown_fields|serde.default|as u32|as usize|as i32" rustfs crates
- IAM export fixes: assert exported archives omit plaintext user and service-account secrets unless the format deliberately encrypts or seals them.
- RPC auth fixes: include captured metadata replay across two concrete methods, stale timestamps, wrong path, wrong method surrogate, wrong secret, and valid same-method calls.
- Browser/CORS fixes: assert no credentials on reflected/default origins, correct behavior for explicit allowlists, and no same-origin script execution for previewed object content.
- SSE fixes: inspect stored bytes and verify API metadata, read-back behavior, and on-disk ciphertext together.
- SSE fixes: inspect stored bytes and verify API metadata, read-back behavior, and on-disk ciphertext together; for ETags, upload equal plaintext under different keys and assert list-only callers cannot derive or correlate the plaintext digest, including multipart objects.
- Object Lock fixes: include unreadable metadata, fabricated metadata defaults, unparsable config, confirmed absent config, COMPLIANCE/GOVERNANCE retention, lifecycle expiry, scanner sweeps, and force-delete paths.
+1
View File
@@ -29,6 +29,7 @@ script-tests: ## Run shell script tests
./scripts/test_entrypoint_credentials.sh
./scripts/test_internode_grpc_ab_bench.sh
./scripts/test_object_batch_bench_enhanced.sh
./scripts/test_package_service_scripts.sh
./scripts/test_hotpath_warp_ab_gate.sh
./scripts/test_hotpath_warp_abba.sh
./scripts/test_scanner_validation_harness.sh
+7
View File
@@ -24,7 +24,9 @@ on:
- '.github/actions/**'
- '.github/workflows/**'
- 'scripts/release/create_or_update_release.sh'
- 'scripts/release/package_service_scripts.sh'
- 'scripts/release/package_versions.sh'
- 'scripts/test_package_service_scripts.sh'
- 'scripts/test_package_versions.sh'
- 'scripts/security/check_performance_ab_workflow.sh'
- 'scripts/security/check_preview_release_workflow.sh'
@@ -40,7 +42,9 @@ on:
- '.github/actions/**'
- '.github/workflows/**'
- 'scripts/release/create_or_update_release.sh'
- 'scripts/release/package_service_scripts.sh'
- 'scripts/release/package_versions.sh'
- 'scripts/test_package_service_scripts.sh'
- 'scripts/test_package_versions.sh'
- 'scripts/security/check_performance_ab_workflow.sh'
- 'scripts/security/check_preview_release_workflow.sh'
@@ -158,6 +162,9 @@ jobs:
- name: Check package version contract
run: ./scripts/test_package_versions.sh
- name: Check package service lifecycle contract
run: ./scripts/test_package_service_scripts.sh
dependency-review:
name: Dependency Review
runs-on: ubuntu-latest
+48
View File
@@ -542,6 +542,54 @@ jobs:
if-no-files-found: error
retention-days: 30
# Docker Hub's overview is a separate `full_description` field that
# `docker push` never touches, so it drifted from README.md and kept
# linking to pages that no longer exist (#7995). Republish it from the
# commit the images were built from, only when images were pushed.
sync-dockerhub-description:
name: Sync Docker Hub Description
needs: [ build-check, build-docker ]
if: needs.build-check.outputs.should_build == 'true' && needs.build-check.outputs.should_push == 'true'
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
ref: ${{ needs.build-check.outputs.source_ref }}
sparse-checkout: README.md
sparse-checkout-cone-mode: false
- name: Sync Docker Hub description
uses: peter-evans/dockerhub-description@1b9a80c056b620d92cedb9d9b5a223409c68ddfa # v5.0.0
with:
username: ${{ env.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
repository: ${{ env.REGISTRY_DOCKERHUB }}
readme-filepath: ./README.md
# README.md links to docs/ and CONTRIBUTING.md by relative path;
# rewrite those to github.com URLs so they resolve on Docker Hub.
enable-url-completion: true
# Docker Hub caps `full_description` at 25,000 bytes and the action
# truncates to fit with only a warning, which would publish a README
# cut off mid-sentence. README.md is already near the cap, so read the
# published overview back and fail if it hit the limit.
- name: Check published description was not truncated
env:
REPOSITORY: ${{ env.REGISTRY_DOCKERHUB }}
run: |
limit=25000
published=$(curl -sSf "https://hub.docker.com/v2/repositories/${REPOSITORY}/" \
| jq -j '.full_description' | wc -c | tr -d ' ')
echo "Published overview: ${published} bytes (Docker Hub limit: ${limit})"
# Truncation lands within one UTF-8 character of the cap.
if [ "$published" -ge $((limit - 4)) ]; then
echo "::error file=README.md::Docker Hub overview was truncated at ${limit} bytes; trim README.md before the next release."
exit 1
fi
# Docker build summary
docker-summary:
name: Docker Build Summary
+26 -62
View File
@@ -117,10 +117,15 @@ jobs:
cat > "${PKG_DIR}/etc/default/rustfs" << 'ENVEOF'
# RustFS Environment Configuration
# See https://rustfs.com/docs/ for more information
# See https://docs.rustfs.com for more information
# RUSTFS_ACCESS_KEY=<changeme>
# RUSTFS_SECRET_KEY=<changeme>
# RUSTFS_VOLUMES=""
# RUSTFS_ROOT_USER=""
# RUSTFS_ROOT_PASSWORD=""
# RUSTFS_ADDRESS=":9000"
# RUSTFS_CONSOLE_ADDRESS=":9001"
# RUSTFS_CONSOLE_ENABLE=true
# RUSTFS_OBS_LOGGER_LEVEL=error
# RUSTFS_OBS_LOG_DIRECTORY="/var/log/rustfs/"
ENVEOF
# dpkg versions must start with a digit and cannot contain hyphens;
@@ -146,37 +151,13 @@ jobs:
/etc/default/rustfs
CONFFILES
cat > "${PKG_DIR}/DEBIAN/postinst" << 'POSTINST'
#!/bin/bash
set -e
if ! getent passwd rustfs > /dev/null 2>&1; then
useradd -r -s /bin/false -d /opt/rustfs rustfs
fi
mkdir -p /opt/rustfs /data/rustfs /var/log/rustfs
chown rustfs:rustfs /opt/rustfs /data/rustfs /var/log/rustfs
if [ -d /run/systemd/system ]; then
systemctl daemon-reload
fi
echo "RustFS installed. Configure /etc/default/rustfs then: systemctl start rustfs"
POSTINST
./scripts/release/package_service_scripts.sh after-install > "${PKG_DIR}/DEBIAN/postinst"
chmod 755 "${PKG_DIR}/DEBIAN/postinst"
cat > "${PKG_DIR}/DEBIAN/prerm" << 'PRERM'
#!/bin/bash
set -e
if [ -d /run/systemd/system ] && systemctl is-active --quiet rustfs; then
systemctl stop rustfs
fi
PRERM
./scripts/release/package_service_scripts.sh before-remove > "${PKG_DIR}/DEBIAN/prerm"
chmod 755 "${PKG_DIR}/DEBIAN/prerm"
cat > "${PKG_DIR}/DEBIAN/postrm" << 'POSTRM'
#!/bin/bash
set -e
if [ -d /run/systemd/system ]; then
systemctl daemon-reload
fi
POSTRM
./scripts/release/package_service_scripts.sh after-remove > "${PKG_DIR}/DEBIAN/postrm"
chmod 755 "${PKG_DIR}/DEBIAN/postrm"
cp LICENSE "${PKG_DIR}/usr/share/doc/rustfs/"
@@ -214,10 +195,15 @@ jobs:
mkdir -p ./tmp-pkg/etc/default
cat > ./tmp-pkg/etc/default/rustfs << 'ENVEOF'
# RustFS Environment Configuration
# See https://rustfs.com/docs/ for more information
# See https://docs.rustfs.com for more information
# RUSTFS_ACCESS_KEY=<changeme>
# RUSTFS_SECRET_KEY=<changeme>
# RUSTFS_VOLUMES=""
# RUSTFS_ROOT_USER=""
# RUSTFS_ROOT_PASSWORD=""
# RUSTFS_ADDRESS=":9000"
# RUSTFS_CONSOLE_ADDRESS=":9001"
# RUSTFS_CONSOLE_ENABLE=true
# RUSTFS_OBS_LOGGER_LEVEL=error
# RUSTFS_OBS_LOG_DIRECTORY="/var/log/rustfs/"
ENVEOF
fpm -s dir -t rpm \
@@ -231,35 +217,13 @@ jobs:
--description "High-performance distributed object storage" \
--url "https://rustfs.com" \
--license "Apache-2.0" \
--after-install <(cat << 'POSTINST'
#!/bin/bash
set -e
if ! getent passwd rustfs > /dev/null 2>&1; then
useradd -r -s /bin/false -d /opt/rustfs rustfs
fi
mkdir -p /opt/rustfs /data/rustfs /var/log/rustfs
chown rustfs:rustfs /opt/rustfs /data/rustfs /var/log/rustfs
if [ -d /run/systemd/system ]; then
systemctl daemon-reload
fi
POSTINST
) \
--before-remove <(cat << 'PRERM'
#!/bin/bash
set -e
if [ -d /run/systemd/system ] && systemctl is-active --quiet rustfs; then
systemctl stop rustfs
fi
PRERM
) \
--after-remove <(cat << 'POSTRM'
#!/bin/bash
set -e
if [ -d /run/systemd/system ]; then
systemctl daemon-reload
fi
POSTRM
) \
--before-install <(./scripts/release/package_service_scripts.sh rpm-before-install) \
--before-upgrade <(./scripts/release/package_service_scripts.sh rpm-before-upgrade) \
--after-install <(./scripts/release/package_service_scripts.sh after-install) \
--after-upgrade <(./scripts/release/package_service_scripts.sh after-install) \
--before-remove <(./scripts/release/package_service_scripts.sh before-remove) \
--after-remove <(./scripts/release/package_service_scripts.sh after-remove) \
--rpm-posttrans <(./scripts/release/package_service_scripts.sh rpm-posttrans) \
--config-files /etc/default/rustfs \
"rustfs-nightly-${DEB_DATE}/usr/bin/rustfs=/usr/bin/rustfs" \
./tmp-pkg/etc/default/rustfs=/etc/default/rustfs \
+26 -62
View File
@@ -328,10 +328,15 @@ jobs:
cat > "${PKG_DIR}/etc/default/rustfs" << 'ENVEOF'
# RustFS Environment Configuration
# See https://rustfs.com/docs/ for more information
# See https://docs.rustfs.com for more information
# RUSTFS_ACCESS_KEY=<changeme>
# RUSTFS_SECRET_KEY=<changeme>
# RUSTFS_VOLUMES=""
# RUSTFS_ROOT_USER=""
# RUSTFS_ROOT_PASSWORD=""
# RUSTFS_ADDRESS=":9000"
# RUSTFS_CONSOLE_ADDRESS=":9001"
# RUSTFS_CONSOLE_ENABLE=true
# RUSTFS_OBS_LOGGER_LEVEL=error
# RUSTFS_OBS_LOG_DIRECTORY="/var/log/rustfs/"
ENVEOF
cat > "${PKG_DIR}/DEBIAN/control" << EOF
@@ -354,37 +359,13 @@ jobs:
/etc/default/rustfs
CONFFILES
cat > "${PKG_DIR}/DEBIAN/postinst" << 'POSTINST'
#!/bin/bash
set -e
if ! getent passwd rustfs > /dev/null 2>&1; then
useradd -r -s /bin/false -d /opt/rustfs rustfs
fi
mkdir -p /opt/rustfs /data/rustfs /var/log/rustfs
chown rustfs:rustfs /opt/rustfs /data/rustfs /var/log/rustfs
if [ -d /run/systemd/system ]; then
systemctl daemon-reload
fi
echo "RustFS installed. Configure /etc/default/rustfs then: systemctl start rustfs"
POSTINST
./scripts/release/package_service_scripts.sh after-install > "${PKG_DIR}/DEBIAN/postinst"
chmod 755 "${PKG_DIR}/DEBIAN/postinst"
cat > "${PKG_DIR}/DEBIAN/prerm" << 'PRERM'
#!/bin/bash
set -e
if [ -d /run/systemd/system ] && systemctl is-active --quiet rustfs; then
systemctl stop rustfs
fi
PRERM
./scripts/release/package_service_scripts.sh before-remove > "${PKG_DIR}/DEBIAN/prerm"
chmod 755 "${PKG_DIR}/DEBIAN/prerm"
cat > "${PKG_DIR}/DEBIAN/postrm" << 'POSTRM'
#!/bin/bash
set -e
if [ -d /run/systemd/system ]; then
systemctl daemon-reload
fi
POSTRM
./scripts/release/package_service_scripts.sh after-remove > "${PKG_DIR}/DEBIAN/postrm"
chmod 755 "${PKG_DIR}/DEBIAN/postrm"
cp LICENSE "${PKG_DIR}/usr/share/doc/rustfs/"
@@ -422,10 +403,15 @@ jobs:
mkdir -p ./tmp-pkg/etc/default
cat > ./tmp-pkg/etc/default/rustfs << 'ENVEOF'
# RustFS Environment Configuration
# See https://rustfs.com/docs/ for more information
# See https://docs.rustfs.com for more information
# RUSTFS_ACCESS_KEY=<changeme>
# RUSTFS_SECRET_KEY=<changeme>
# RUSTFS_VOLUMES=""
# RUSTFS_ROOT_USER=""
# RUSTFS_ROOT_PASSWORD=""
# RUSTFS_ADDRESS=":9000"
# RUSTFS_CONSOLE_ADDRESS=":9001"
# RUSTFS_CONSOLE_ENABLE=true
# RUSTFS_OBS_LOGGER_LEVEL=error
# RUSTFS_OBS_LOG_DIRECTORY="/var/log/rustfs/"
ENVEOF
fpm -s dir -t rpm \
@@ -439,35 +425,13 @@ jobs:
--description "High-performance distributed object storage" \
--url "https://rustfs.com" \
--license "Apache-2.0" \
--after-install <(cat <<'POSTINST'
#!/bin/bash
set -e
if ! getent passwd rustfs > /dev/null 2>&1; then
useradd -r -s /bin/false -d /opt/rustfs rustfs
fi
mkdir -p /opt/rustfs /data/rustfs /var/log/rustfs
chown rustfs:rustfs /opt/rustfs /data/rustfs /var/log/rustfs
if [ -d /run/systemd/system ]; then
systemctl daemon-reload
fi
POSTINST
) \
--before-remove <(cat <<'PRERM'
#!/bin/bash
set -e
if [ -d /run/systemd/system ] && systemctl is-active --quiet rustfs; then
systemctl stop rustfs
fi
PRERM
) \
--after-remove <(cat <<'POSTRM'
#!/bin/bash
set -e
if [ -d /run/systemd/system ]; then
systemctl daemon-reload
fi
POSTRM
) \
--before-install <(./scripts/release/package_service_scripts.sh rpm-before-install) \
--before-upgrade <(./scripts/release/package_service_scripts.sh rpm-before-upgrade) \
--after-install <(./scripts/release/package_service_scripts.sh after-install) \
--after-upgrade <(./scripts/release/package_service_scripts.sh after-install) \
--before-remove <(./scripts/release/package_service_scripts.sh before-remove) \
--after-remove <(./scripts/release/package_service_scripts.sh after-remove) \
--rpm-posttrans <(./scripts/release/package_service_scripts.sh rpm-posttrans) \
--config-files /etc/default/rustfs \
./bin/rustfs=/usr/bin/rustfs \
./tmp-pkg/etc/default/rustfs=/etc/default/rustfs \
@@ -149,7 +149,7 @@ jobs:
CHAIN_MANIFEST: ${{ inputs.chain_manifest }}
run: python3 scripts/prepare_functional_package.py prepare
- name: Run fault-tolerance scenarios (A, B, C, C2, D)
- name: Run fault-tolerance scenarios (A, B, C, C2, D, E)
timeout-minutes: 45
id: test
# Standalone case failures are reported by the backlog manager.
@@ -194,21 +194,21 @@ jobs:
jq -e --arg testing_sha "${AUTO_TESTING_SHA}" '
.schema_version == 1 and
.auto_testing_sha == $testing_sha and
.selected_scenarios == ["A", "B", "C", "C2", "D"] and
.selected_scenarios == ["A", "B", "C", "C2", "D", "E"] and
.complete == true and
.counts.expected == 38 and
.counts.seen == 38 and
.counts.expected == 44 and
.counts.seen == 44 and
.counts.harness_errors == 0 and
(.expected_cases | length) == 38 and
(.expected_cases | unique | length) == 38 and
(.seen_cases | length) == 38 and
(.seen_cases | unique | length) == 38 and
(.expected_cases | length) == 44 and
(.expected_cases | unique | length) == 44 and
(.seen_cases | length) == 44 and
(.seen_cases | unique | length) == 44 and
(.seen_cases | sort) == (.expected_cases | sort) and
(.missing_cases | length) == 0 and
(.harness_errors | length) == 0 and
(.cases | length) == 38 and
(.cases | length) == 44 and
([.cases[].case_id] | sort) == (.expected_cases | sort) and
([.cases[].case_id] | unique | length) == 38 and
([.cases[].case_id] | unique | length) == 44 and
all(.cases[]; .verdict == "pass" or .verdict == "known-divergence" or .verdict == "UNEXPECTED") and
.counts.unexpected == ([.cases[] | select(.verdict == "UNEXPECTED")] | length) and
.counts.known_divergence == ([.cases[] | select(.verdict == "known-divergence")] | length)
@@ -54,7 +54,10 @@ jobs:
- name: Resolve published candidate
id: candidate
env:
GH_TOKEN: ${{ github.token }}
# The staleness probe in resolve_functional_candidate.py reads the
# private rustfs/auto-testing repo; github.token cannot, and a probe
# failure silently keeps a stale testing_sha pinned for the chain.
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
BUILD_RUN_ID: ${{ inputs.build_run_id }}
BUILD_RUN_ATTEMPT: ${{ inputs.build_run_attempt }}
CHAIN_OUTPUT: ${{ runner.temp }}/chain-candidate.json
Generated
+36 -27
View File
@@ -1984,9 +1984,9 @@ dependencies = [
[[package]]
name = "cc"
version = "1.4.6"
version = "1.4.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a3eb0f42d6c360dc3f8a821f6bf2fdea7f72bfd36b3076eb0e6d1e9e0752fff4"
checksum = "54413ede23c2daf518f35156dfde027feb2374004d63bd497f983c8db9c0e313"
dependencies = [
"find-msvc-tools",
"jobserver",
@@ -3792,9 +3792,9 @@ dependencies = [
[[package]]
name = "dial9"
version = "0.5.0"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49b11bce55173f77d9003cce8f129b4af889a4639005c53c3498cef933bc895c"
checksum = "bab33c9f10e909fb3f1f6af2a9d976398ea793b90d1d286b4e23647a7d131d53"
dependencies = [
"bon",
"dial9-core",
@@ -3809,9 +3809,9 @@ dependencies = [
[[package]]
name = "dial9-core"
version = "0.5.0"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e8cbbc8955394be626249a3b52ddd6bf664373661eaeae70d87eb12bf6f20b6"
checksum = "ffd7231006b074b02765ae929bded4757a414224b06c7db324d3e6e902f182ee"
dependencies = [
"arc-swap",
"bon",
@@ -3842,9 +3842,9 @@ dependencies = [
[[package]]
name = "dial9-metrique"
version = "0.5.0"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d42fe0e3e14e8780c4ecf9ee14c183dbcb9b1ae3612cc9a689e7dc83b13adef1"
checksum = "f987e56dbafcab5998acc8e77eb91aaf66fa5441a691b38e99c8058c70725f58"
dependencies = [
"dial9-core",
"dial9-trace-format",
@@ -3856,9 +3856,9 @@ dependencies = [
[[package]]
name = "dial9-perf-self-profile"
version = "0.5.0"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8f65948455c504bf08576c7b5cfe93bfcaa486d661dc4ee85c2a6309ab89629"
checksum = "d81f74ada03919614ca5b1ba767271d00980376a5a0e0fc43f8c265bf90413d4"
dependencies = [
"blazesym",
"bon",
@@ -3875,9 +3875,9 @@ dependencies = [
[[package]]
name = "dial9-tokio-telemetry"
version = "0.5.0"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af1244091367c805b5d98a6a590f8ab992efda06e6d2560a6967ef898ffd30a2"
checksum = "934c027f628c59ae4c9664ca4dce758843c3da027dee5052d358f86a5d292ae6"
dependencies = [
"bon",
"bytes",
@@ -4410,9 +4410,9 @@ dependencies = [
[[package]]
name = "find-msvc-tools"
version = "0.1.12"
version = "0.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d"
checksum = "ef25905e51abafe4dcea6c15fec58c57b601cdbd0ee53d22ea1d3016c587d39b"
[[package]]
name = "findshlibs"
@@ -11258,9 +11258,8 @@ checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "s3s"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87d22507a8ebd0ea64972c76dfedbc477418dc70afd98bfce928263894067d2f"
version = "0.16.0"
source = "git+https://github.com/s3s-project/s3s.git?rev=50c94aeb1e5ea9ef8e7393ef6bbc87b72f6891c6#50c94aeb1e5ea9ef8e7393ef6bbc87b72f6891c6"
dependencies = [
"arc-swap",
"arrayvec",
@@ -11278,7 +11277,6 @@ dependencies = [
"http 1.5.0",
"http-body 1.1.0",
"http-body-util",
"httparse",
"hyper",
"itoa",
"jiff",
@@ -11291,6 +11289,7 @@ dependencies = [
"pin-project-lite",
"quick-xml",
"regex",
"s3s-multipart",
"s3s-rfc2047",
"s3s-sigv2",
"s3s-sigv4",
@@ -11315,11 +11314,23 @@ dependencies = [
"zeroize",
]
[[package]]
name = "s3s-multipart"
version = "0.17.0-alpha.1"
source = "git+https://github.com/s3s-project/s3s.git?rev=50c94aeb1e5ea9ef8e7393ef6bbc87b72f6891c6#50c94aeb1e5ea9ef8e7393ef6bbc87b72f6891c6"
dependencies = [
"arrayvec",
"bytes",
"futures-core",
"httparse",
"memchr",
"thiserror 2.0.20",
]
[[package]]
name = "s3s-rfc2047"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9d3e6b36be7cca3da377b4cabcd668ffe5b16c3b73a83f7a8f7b63ff6a178ee"
version = "0.16.0"
source = "git+https://github.com/s3s-project/s3s.git?rev=50c94aeb1e5ea9ef8e7393ef6bbc87b72f6891c6#50c94aeb1e5ea9ef8e7393ef6bbc87b72f6891c6"
dependencies = [
"base64-simd",
"thiserror 2.0.20",
@@ -11327,9 +11338,8 @@ dependencies = [
[[package]]
name = "s3s-sigv2"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d96880b5ad8d639c9224155126741708aeb62ad3682e0476d2fd14290af808ef"
version = "0.16.0"
source = "git+https://github.com/s3s-project/s3s.git?rev=50c94aeb1e5ea9ef8e7393ef6bbc87b72f6891c6#50c94aeb1e5ea9ef8e7393ef6bbc87b72f6891c6"
dependencies = [
"base64-simd",
"hmac 0.13.0",
@@ -11341,9 +11351,8 @@ dependencies = [
[[package]]
name = "s3s-sigv4"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fed605cbaaa1cf4d82e8ee15098fb3e42d49c92bb6215eb1359f63bfc0c6b020"
version = "0.16.0"
source = "git+https://github.com/s3s-project/s3s.git?rev=50c94aeb1e5ea9ef8e7393ef6bbc87b72f6891c6#50c94aeb1e5ea9ef8e7393ef6bbc87b72f6891c6"
dependencies = [
"arrayvec",
"base64-simd",
+3 -3
View File
@@ -313,8 +313,8 @@ rustify = { version = "0.7", default-features = false }
rustix = { version = "1.1.5" }
rust-embed = { version = "8.12.0" }
rustc-hash = { version = "2.1.3" }
s3s = { version = "0.16.1", features = ["minio"] }
s3s-sigv4 = { version = "0.16.1" }
s3s = { git = "https://github.com/s3s-project/s3s.git", rev = "50c94aeb1e5ea9ef8e7393ef6bbc87b72f6891c6", features = ["minio"] }
s3s-sigv4 = { git = "https://github.com/s3s-project/s3s.git", rev = "50c94aeb1e5ea9ef8e7393ef6bbc87b72f6891c6" }
serial_test = "4.0.1"
shadow-rs = { default-features = false, version = "2.0.0" }
siphasher = "1.0.3"
@@ -351,7 +351,7 @@ zstd = "0.14.0"
# Observability and Metrics
metrics = "0.24.6"
metrics-util = "0.20"
dial9 = { version = "0.5.0", default-features = false }
dial9 = { version = "0.5.1", default-features = false }
opentelemetry = { version = "0.32.0" }
opentelemetry-appender-tracing = { version = "0.32.0" }
opentelemetry-otlp = { version = "0.32.0" }
+11
View File
@@ -55,6 +55,13 @@ pub const ENV_OBS_LOG_FILENAME: &str = "RUSTFS_OBS_LOG_FILENAME";
pub const ENV_OBS_LOG_ROTATION_TIME: &str = "RUSTFS_OBS_LOG_ROTATION_TIME";
pub const ENV_OBS_LOG_KEEP_FILES: &str = "RUSTFS_OBS_LOG_KEEP_FILES";
/// Span lifecycle events emitted by the JSON log layers (`none`|`close`|`full`).
///
/// Defaults to `none`: a record per span lifecycle multiplies log volume on
/// object hot paths, and emitting the ancestor span list on every record
/// produced multi-megabyte lines that journald truncated (backlog#2642).
pub const ENV_OBS_LOG_SPAN_EVENTS: &str = "RUSTFS_OBS_LOG_SPAN_EVENTS";
/// Log cleanup related configurations
pub const ENV_OBS_LOG_MAX_TOTAL_SIZE_BYTES: &str = "RUSTFS_OBS_LOG_MAX_TOTAL_SIZE_BYTES";
pub const ENV_OBS_LOG_MAX_SINGLE_FILE_SIZE_BYTES: &str = "RUSTFS_OBS_LOG_MAX_SINGLE_FILE_SIZE_BYTES";
@@ -99,6 +106,8 @@ pub const DEFAULT_OBS_LOG_DRY_RUN: bool = false;
pub const DEFAULT_OBS_LOG_MATCH_MODE_PREFIX: &str = "prefix";
pub const DEFAULT_OBS_LOG_MATCH_MODE: &str = "suffix";
pub const DEFAULT_OBS_LOG_SPAN_EVENTS: &str = "none";
/// Default values for observability configuration
// ### Supported Environment Values
// - `production` - Secure file-only logging
@@ -141,6 +150,7 @@ mod tests {
assert_eq!(ENV_OBS_LOG_FILENAME, "RUSTFS_OBS_LOG_FILENAME");
assert_eq!(ENV_OBS_LOG_ROTATION_TIME, "RUSTFS_OBS_LOG_ROTATION_TIME");
assert_eq!(ENV_OBS_LOG_KEEP_FILES, "RUSTFS_OBS_LOG_KEEP_FILES");
assert_eq!(ENV_OBS_LOG_SPAN_EVENTS, "RUSTFS_OBS_LOG_SPAN_EVENTS");
assert_eq!(ENV_OBS_TRACES_EXPORT_ENABLED, "RUSTFS_OBS_TRACES_EXPORT_ENABLED");
assert_eq!(ENV_OBS_METRICS_EXPORT_ENABLED, "RUSTFS_OBS_METRICS_EXPORT_ENABLED");
assert_eq!(ENV_OBS_LOGS_EXPORT_ENABLED, "RUSTFS_OBS_LOGS_EXPORT_ENABLED");
@@ -178,6 +188,7 @@ mod tests {
assert_eq!(DEFAULT_OBS_LOG_COMPRESSION_ALGORITHM_GZIP, "gzip");
assert_eq!(DEFAULT_OBS_LOG_COMPRESSION_ALGORITHM_ZSTD, "zstd");
assert_eq!(DEFAULT_OBS_LOG_MATCH_MODE_PREFIX, "prefix");
assert_eq!(DEFAULT_OBS_LOG_SPAN_EVENTS, "none");
assert_eq!(DEFAULT_OBS_LOG_MATCH_MODE, "suffix");
assert_eq!(DEFAULT_OBS_LOG_COMPRESSION_ALGORITHM, "zstd");
}
@@ -184,7 +184,7 @@ async fn four_node_health_inventory_metrics_and_audit_delivery_are_consistent()
let (status, metrics_body) = admin_request(
&node.url,
Method::GET,
"/rustfs/admin/v3/metrics?n=1&by-host=true&by-disk=true",
"/rustfs/admin/v3/realtime?n=1&by-host=true&by-disk=true",
None,
&dist.cluster.access_key,
&dist.cluster.secret_key,
@@ -257,7 +257,7 @@ async fn node_admin_body(dist: &DistCluster, node: usize, path: &str) -> TestRes
}
async fn http_put_counts(dist: &DistCluster, node: usize) -> TestResult<[u64; 2]> {
let body = node_admin_body(dist, node, "/rustfs/admin/v3/metrics?types=512&by-host=true&n=1").await?;
let body = node_admin_body(dist, node, "/rustfs/admin/v3/realtime?types=512&by-host=true&n=1").await?;
let sample: RealtimeMetrics = serde_json::from_str(body.lines().next().ok_or("empty HTTP metrics stream")?)?;
assert!(sample.errors.is_empty(), "HTTP metrics returned errors: {:?}", sample.errors);
let http = sample.aggregated.http.ok_or("HTTP metrics missing at WARN log level")?;
@@ -35,6 +35,7 @@ use crate::bucket::lifecycle::tier_sweeper::{
use crate::disk::RUSTFS_META_BUCKET;
use crate::error::{Error, Result as EcstoreResult};
use crate::object_api::{ObjectInfo, ObjectOptions};
use crate::services::notification_sys::acquire_remote_version_state_writer_fleet_proof;
use crate::services::tier::{tier::TierConfigMgr, warm_backend::TransitionCandidateProbe};
use crate::storage_api_contracts::{
list::ListOperations as _,
@@ -1237,6 +1238,12 @@ pub async fn delete_transition_candidate_for_operator(
lease
.validate_remote_version_id(remote_version_id)
.map_err(TransitionOperatorError::Remote)?;
if remote_version_requires_fleet_proof(remote_version_id) && acquire_remote_version_state_writer_fleet_proof().is_none() {
return Err(TransitionOperatorError::Remote(std::io::Error::new(
std::io::ErrorKind::Unsupported,
"opaque remote tier version cleanup requires the operator-attested live fleet capability gate",
)));
}
let before_delete_probe = lease
.probe_transition_candidate_for(&transaction.remote_object, transaction.transaction_id)
.await
@@ -1845,6 +1852,11 @@ async fn recover_unknown_upload_outcome(
))
}
TransitionCandidateProbe::VersionedPresent(version_id) => {
if remote_version_requires_fleet_proof(&version_id) && acquire_remote_version_state_writer_fleet_proof().is_none() {
return Ok(TransitionTransactionRecoveryOutcome::RetainedAmbiguous(
IlmRecoveryErrorCode::RemoteVersionUnknown,
));
}
cleanup_recovered_unknown_upload_candidate(api, transaction, TransitionRemoteVersion::versioned(version_id)).await
}
TransitionCandidateProbe::Ambiguous => Ok(TransitionTransactionRecoveryOutcome::RetainedAmbiguous(
@@ -1940,6 +1952,14 @@ fn transition_source_lookup_options(transaction: &TransitionTransaction) -> Obje
async fn delete_transition_remote_candidate(api: Arc<ECStore>, transaction: &TransitionTransaction) -> EcstoreResult<()> {
let version_id = transaction.remote_version.tier_delete_version_id().unwrap_or_default();
let version_id_exact = transaction.remote_version.kind == TransitionRemoteVersionKind::Versioned;
if version_id_exact
&& remote_version_requires_fleet_proof(version_id)
&& acquire_remote_version_state_writer_fleet_proof().is_none()
{
return Err(Error::other(
"opaque remote tier version cleanup requires the operator-attested live fleet capability gate",
));
}
delete_object_from_remote_tier_idempotent_with_manager_and_identity(
&transaction.remote_object,
version_id,
@@ -1953,6 +1973,10 @@ async fn delete_transition_remote_candidate(api: Arc<ECStore>, transaction: &Tra
.map_err(Error::other)
}
fn remote_version_requires_fleet_proof(version_id: &str) -> bool {
!version_id.is_empty() && Uuid::parse_str(version_id).is_err()
}
pub async fn recover_transition_transaction_records(
api: Arc<ECStore>,
limit: usize,
@@ -2439,6 +2463,14 @@ mod tests {
}
}
#[test]
fn opaque_remote_versions_require_the_fleet_proof_boundary() {
assert!(!remote_version_requires_fleet_proof(""));
assert!(!remote_version_requires_fleet_proof(&Uuid::new_v4().to_string()));
assert!(remote_version_requires_fleet_proof("null"));
assert!(remote_version_requires_fleet_proof("b2-opaque-version"));
}
#[test]
fn remote_version_distinguishes_unknown_unversioned_and_versioned() {
assert_eq!(TransitionRemoteVersion::known_from_put_response("").tier_delete_version_id(), None);
+6 -2
View File
@@ -1464,9 +1464,13 @@ pub(crate) async fn load_bucket_metadata_parse_with_presence(
"bucket incarnation sidecar is missing for new-format metadata: {bucket}"
)));
}
} else if incarnation.is_some() {
return Err(Error::other("bucket incarnation sidecar exists without bucket metadata"));
}
// A sidecar without `.metadata.bin` is the window between the two legacy
// migration writes (rustfs/rustfs#8003): the sidecar lands first, and a
// crash or lost namespace lease before the metadata write leaves the
// bucket in this state on every node. Report it as not persisted so the
// migration runs again; the writers adopt the stored incarnation instead
// of minting a new one, so the sidecar keeps its authority.
bm.default_timestamps();
+102
View File
@@ -2504,6 +2504,16 @@ impl BucketMetadataSys {
if !persisted {
metadata = BucketMetadata::new(bucket);
metadata.created = bucket_info.created.unwrap_or(OffsetDateTime::UNIX_EPOCH);
// An interrupted migration may already have published this
// bucket's incarnation; re-read it under the transaction lock so
// the retry never replaces an identity other nodes fenced on. A
// retired incarnation is residue of a deleted bucket and must not
// come back, or heal would reclaim the bucket's new objects.
if let Some(stored) = load_bucket_incarnation(self.object_store(), bucket).await?
&& !crate::bucket::retirement::is_retired(self.object_store(), bucket, stored).await?
{
metadata.bucket_incarnation_id = stored;
}
} else if metadata.bucket_incarnation_id.is_nil() {
metadata.bucket_incarnation_id = Uuid::new_v4();
}
@@ -3556,6 +3566,98 @@ mod tests {
assert!(err.to_string().contains("sidecar is missing"));
}
/// rustfs/rustfs#8003: the legacy migration writes the incarnation sidecar
/// before `.metadata.bin`. A crash or lost namespace lease between the two
/// left every pre-existing bucket answering 500 on every request, with no
/// path back. The sidecar-only state must load as a legacy bucket, and the
/// retried migration must keep the stored incarnation.
#[tokio::test]
async fn issue_8003_sidecar_without_metadata_loads_as_legacy_and_migrates_with_its_incarnation() {
let (dirs, ecstore) = isolated_store_over_temp_disks().await;
let sys = BucketMetadataSys::new(ecstore.clone());
let bucket = "issue-8003-sidecar-only";
for dir in &dirs {
std::fs::create_dir_all(dir.path().join(bucket)).unwrap();
}
let stored = Uuid::new_v4();
save_bucket_incarnation(ecstore.clone(), bucket, stored)
.await
.expect("simulate the interrupted migration's first write");
let (fabricated, persisted) = load_bucket_metadata_parse_with_presence(ecstore.clone(), bucket, true)
.await
.expect("a sidecar without metadata must read as a legacy bucket, not fail closed");
assert!(!persisted);
assert!(!fabricated.bucket_incarnation_sidecar);
let (_, fabricated_read) = sys
.get_config(bucket)
.await
.expect("request-path metadata reads must not fail closed on the sidecar-only state");
assert!(fabricated_read);
let migrated = sys
.get_authoritative_metadata(bucket)
.await
.expect("the retried legacy migration must complete");
assert!(migrated.bucket_incarnation_sidecar);
assert_eq!(
migrated.bucket_incarnation_id, stored,
"the retry must adopt the published incarnation instead of minting a new one"
);
assert_eq!(sys.get_bucket_incarnation_id(bucket).await.unwrap(), stored);
let on_disk = sys.get_config_from_disk(bucket).await.expect("metadata is now persisted");
assert!(on_disk.bucket_incarnation_sidecar);
assert_eq!(on_disk.bucket_incarnation_id, stored);
assert_eq!(load_bucket_incarnation(ecstore, bucket).await.unwrap(), Some(stored));
}
/// A sidecar left behind by a deleted bucket names a retired incarnation.
/// The migration must mint a new identity for a same-name volume instead
/// of re-publishing the retired one, or heal would treat the bucket's new
/// objects as reclaimable residue.
#[tokio::test]
async fn issue_8003_sidecar_only_migration_does_not_adopt_a_retired_incarnation() {
let (dirs, ecstore) = isolated_store_over_temp_disks().await;
let sys = BucketMetadataSys::new(ecstore.clone());
let bucket = "issue-8003-retired-sidecar";
for dir in &dirs {
std::fs::create_dir_all(dir.path().join(bucket)).unwrap();
}
let retired = Uuid::new_v4();
save_bucket_incarnation(ecstore.clone(), bucket, retired)
.await
.expect("residual sidecar");
crate::bucket::retirement::commit_retirement(
ecstore.clone(),
bucket,
retired,
&ObjectOptions {
max_parity: true,
..Default::default()
},
)
.await
.expect("retirement record");
let migrated = sys
.get_authoritative_metadata(bucket)
.await
.expect("the migration must still complete for the same-name volume");
assert!(migrated.bucket_incarnation_sidecar);
assert_ne!(
migrated.bucket_incarnation_id, retired,
"a retired incarnation must never be re-published"
);
assert!(!migrated.bucket_incarnation_id.is_nil());
assert_eq!(
load_bucket_incarnation(ecstore, bucket).await.unwrap(),
Some(migrated.bucket_incarnation_id),
"the sidecar must be rewritten to the new incarnation"
);
}
/// Concurrent cache misses for one bucket must collapse into a single disk
/// load.
///
@@ -199,6 +199,38 @@ fn has_raw_status(err: &SdkError<HeadObjectError>, status: u16) -> bool {
err.raw_response().is_some_and(|r| r.status().as_u16() == status)
}
fn raw_header_value(err: &SdkError<HeadObjectError>, name: &str) -> Option<String> {
err.raw_response()
.and_then(|response| response.headers().get(name))
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum DeleteMarkerHeadOutcome {
Missing,
Exists,
Ambiguous,
}
fn classify_delete_marker_head_error(err: &SdkError<HeadObjectError>) -> DeleteMarkerHeadOutcome {
let (is_not_found, code) = err
.as_service_error()
.map(|service_err| (service_err.is_not_found(), service_err.code()))
.unwrap_or((false, None));
// Prefer the raw HTTP status when present. HEAD responses commonly have no
// body, and a contradictory XML code must not turn a 403/5xx into success.
match err.raw_response().map(|response| response.status().as_u16()) {
Some(404) => DeleteMarkerHeadOutcome::Missing,
Some(405) => DeleteMarkerHeadOutcome::Exists,
Some(_) => DeleteMarkerHeadOutcome::Ambiguous,
None if is_not_found => DeleteMarkerHeadOutcome::Missing,
None if !is_retryable_delete_replication_head_error(false, code) => DeleteMarkerHeadOutcome::Exists,
None => DeleteMarkerHeadOutcome::Ambiguous,
}
}
fn metadata_requires_existing_target(op_type: ReplicationType, object_info: &ObjectInfo) -> bool {
op_type == ReplicationType::Metadata
&& object_info
@@ -1809,32 +1841,14 @@ async fn verify_resync_head_result(
(roi.size, None)
}
Err(err) if roi.delete_marker => {
// Verifying a replicated delete marker: only a
// definitive 404/NoSuchKey or 405/MethodNotAllowed
// confirms the marker propagated. Any other
// (retryable/ambiguous) HEAD error leaves the outcome
// unverified, so it must count as failed — not as a
// blanket success (backlog#862 / #799 B13).
// A HEAD carries no body, so the SDK only synthesizes an error
// code for 404; a target answering the delete marker's version
// with 405 (RustFS, MinIO, AWS) leaves `code()` empty and the
// raw status is the only evidence (rustfs/backlog#2479).
// A marker *version purge* (`version_purge_status` set) asks
// for the opposite outcome: the marker must be gone from the
// target, so only absence verifies it and a 405 proves the
// marker is still there.
let retryable = {
let (is_not_found, code) = err
.as_service_error()
.map(|se| (se.is_not_found(), se.code()))
.unwrap_or((false, None));
let not_found = is_not_found || has_raw_status(&err, 404);
if roi.version_purge_status.is_empty() {
let code = code.or_else(|| has_raw_status(&err, 405).then_some("405"));
is_retryable_delete_replication_head_error(not_found, code)
} else {
!not_found
}
// A marker creation is verified by either absence (404) or
// presence (405). A marker-version purge has the opposite goal:
// only absence proves completion. All other outcomes remain
// failed (backlog#862 / #799 B13).
let retryable = if roi.version_purge_status.is_empty() {
matches!(classify_delete_marker_head_error(&err), DeleteMarkerHeadOutcome::Ambiguous)
} else {
!matches!(classify_delete_marker_head_error(&err), DeleteMarkerHeadOutcome::Missing)
};
if retryable {
st.failed_count += 1;
@@ -3553,7 +3567,7 @@ async fn replicate_delete_to_target(
}
}
if dobj.delete_object.delete_marker && dobj.delete_object.delete_marker_version_id.is_some() {
if !is_version_purge && dobj.delete_object.delete_marker && dobj.delete_object.delete_marker_version_id.is_some() {
match head_object_for_worker(
tgt_client.as_ref(),
&tgt_client.bucket,
@@ -3562,20 +3576,48 @@ async fn replicate_delete_to_target(
)
.await
{
Ok(_) => {}
Ok(head) if head.delete_marker == Some(true) => {
// Some targets expose an existing marker as a successful HEAD
// with the marker bit instead of the usual 405. Treat that as
// positive convergence evidence and retain the target version
// id for any later marker purge.
rinfo.target_delete_marker_version_id = head.version_id.filter(|version_id| !version_id.is_empty());
rinfo.replication_status = ReplicationStatusType::Completed;
return rinfo;
}
Ok(_) => {
// A successful HEAD without the delete-marker bit proves that
// the addressed version is a regular object (or that the
// target omitted the marker contract). Creating a marker in
// that state would hide a live version, so fail closed.
rinfo.replication_status = ReplicationStatusType::Failed;
rinfo.error = Some("target version is not a delete marker".to_string());
return rinfo;
}
Err(e) => {
let non_retryable = matches!(
e.as_ref(),
SdkError::ServiceError(service_err)
if is_retryable_delete_replication_head_error(
service_err.err().is_not_found(),
service_err.err().code(),
)
);
if non_retryable {
rinfo.replication_status = ReplicationStatusType::Failed;
rinfo.error = Some(e.to_string());
return rinfo;
match classify_delete_marker_head_error(e.as_ref()) {
DeleteMarkerHeadOutcome::Exists => {
// An explicit HEAD of a delete-marker version is expected
// to answer 405 (often without an SDK error code). That
// is positive evidence the marker already exists, so the
// creation is complete and must not issue another
// versionless DELETE that would mint a second marker.
if let Some(version_id) = raw_header_value(e.as_ref(), "x-amz-version-id") {
rinfo.target_delete_marker_version_id = Some(version_id);
}
rinfo.replication_status = ReplicationStatusType::Completed;
return rinfo;
}
DeleteMarkerHeadOutcome::Missing => {}
DeleteMarkerHeadOutcome::Ambiguous => {
// Only a definitive not-found means the marker is absent
// and may be created. Keep transport and ambiguous
// failures fail-closed instead of creating blindly.
rinfo.replication_status = ReplicationStatusType::Failed;
rinfo.error = Some(e.to_string());
mark_replication_target_offline_if_needed(&tgt_client, &e).await;
return rinfo;
}
}
}
}
@@ -6529,6 +6571,110 @@ mod tests {
}
}
fn marker_creation_dobj(target_arn: &str) -> DeletedObjectReplicationInfo {
let mut state = ReplicationState::default();
state.targets.insert(target_arn.to_string(), ReplicationStatusType::Pending);
DeletedObjectReplicationInfo {
bucket: "source".to_string(),
target_arn: target_arn.to_string(),
op_type: ReplicationType::Heal,
delete_object: ReplicationDeletedObject {
object_name: "gone.txt".to_string(),
delete_marker: true,
delete_marker_version_id: Some(Uuid::new_v4()),
replication_state: Some(state),
..Default::default()
},
..Default::default()
}
}
/// A target that already has the marker answers a versioned HEAD with a
/// bodiless 405. The live worker must settle the marker as completed and
/// avoid minting another marker with a versionless DELETE.
#[tokio::test]
async fn live_delete_marker_worker_accepts_bodiless_405_without_delete() {
let (endpoint, server) = spawn_scripted_target_server(1, |_| {
"HTTP/1.1 405 Method Not Allowed\r\nX-Amz-Version-Id: target-marker\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
.to_string()
});
let target = test_target_client(endpoint);
register_test_target(&target).await;
let dobj = marker_creation_dobj(&target.arn);
let rinfo = replicate_delete_to_target(&dobj, target.clone(), None).await;
assert_eq!(rinfo.replication_status, ReplicationStatusType::Completed, "{rinfo:?}");
assert!(rinfo.error.is_none(), "{rinfo:?}");
assert_eq!(rinfo.target_delete_marker_version_id.as_deref(), Some("target-marker"));
let seen = server.join().expect("test HTTP server should finish");
assert_eq!(seen.len(), 1, "an existing marker must not be re-created: {seen:?}");
assert!(seen[0].starts_with("HEAD "), "{seen:?}");
}
/// A successful HEAD that does not identify a delete marker is not
/// convergence evidence: creating a marker would hide a live version.
#[tokio::test]
async fn live_delete_marker_worker_rejects_successful_regular_head_without_delete() {
let (endpoint, server) = spawn_scripted_target_server(1, |_| empty_response("200 OK"));
let target = test_target_client(endpoint);
register_test_target(&target).await;
let dobj = marker_creation_dobj(&target.arn);
let rinfo = replicate_delete_to_target(&dobj, target.clone(), None).await;
assert_eq!(rinfo.replication_status, ReplicationStatusType::Failed, "{rinfo:?}");
assert_eq!(rinfo.error.as_deref(), Some("target version is not a delete marker"));
let seen = server.join().expect("test HTTP server should finish");
assert_eq!(seen.len(), 1, "a regular object must not be hidden by a new marker: {seen:?}");
assert!(seen[0].starts_with("HEAD "), "{seen:?}");
}
/// A definitive 404 means the marker is absent, so exactly one marker
/// creation DELETE is still required.
#[tokio::test]
async fn live_delete_marker_worker_creates_marker_after_404() {
let (endpoint, server) = spawn_scripted_target_server(2, |line| {
if line.starts_with("HEAD ") {
empty_response("404 Not Found")
} else if line.starts_with("DELETE ") {
empty_response("204 No Content")
} else {
empty_response("500 Unexpected")
}
});
let target = test_target_client(endpoint);
register_test_target(&target).await;
let dobj = marker_creation_dobj(&target.arn);
let rinfo = replicate_delete_to_target(&dobj, target.clone(), None).await;
assert_eq!(rinfo.replication_status, ReplicationStatusType::Completed, "{rinfo:?}");
let seen = server.join().expect("test HTTP server should finish");
assert_eq!(seen.len(), 2, "a missing marker should be created once: {seen:?}");
assert!(seen[0].starts_with("HEAD "), "{seen:?}");
assert!(seen[1].starts_with("DELETE "), "{seen:?}");
assert!(!seen[1].contains("versionId="), "marker creation must remain versionless: {seen:?}");
}
/// Ambiguous service errors must remain failed and must not fall through
/// to a blind marker-creation DELETE.
#[tokio::test]
async fn live_delete_marker_worker_keeps_ambiguous_head_failure_closed() {
let (endpoint, server) = spawn_scripted_target_server(1, |_| empty_response("503 Service Unavailable"));
let target = test_target_client(endpoint);
register_test_target(&target).await;
let dobj = marker_creation_dobj(&target.arn);
let rinfo = replicate_delete_to_target(&dobj, target.clone(), None).await;
assert_eq!(rinfo.replication_status, ReplicationStatusType::Failed, "{rinfo:?}");
assert!(rinfo.error.is_some(), "{rinfo:?}");
let seen = server.join().expect("test HTTP server should finish");
assert_eq!(seen.len(), 1, "an ambiguous HEAD must not trigger DELETE: {seen:?}");
assert!(seen[0].starts_with("HEAD "), "{seen:?}");
}
/// A target answers `HEAD ?versionId=<delete marker>` with a bodiless
/// 405, so the SDK reports no error code. That is the marker having
/// propagated, not a failure (rustfs/backlog#2479).
@@ -23,7 +23,7 @@ use std::{
future::Future,
io::ErrorKind,
pin::Pin,
sync::{Arc, OnceLock},
sync::{Arc, OnceLock, atomic::AtomicBool},
task::{Context, Poll},
time::Duration,
};
@@ -234,6 +234,8 @@ pub struct ListPathRawOptions {
pub skip_walkdir_total_timeout: bool,
pub walkdir_timeout: Option<Duration>,
pub walkdir_stall_timeout: Option<Duration>,
/// Shared terminal state for bounded producer walks.
pub producer_limit_reached: Option<Arc<AtomicBool>>,
pub agreed: Option<AgreedFn>,
pub partial: Option<PartialFn>,
pub finished: Option<FinishedFn>,
@@ -267,6 +269,7 @@ impl Clone for ListPathRawOptions {
skip_walkdir_total_timeout: self.skip_walkdir_total_timeout,
walkdir_timeout: self.walkdir_timeout,
walkdir_stall_timeout: self.walkdir_stall_timeout,
producer_limit_reached: self.producer_limit_reached.clone(),
#[cfg(test)]
test_reader_behaviors: self.test_reader_behaviors.clone(),
#[cfg(test)]
@@ -292,6 +295,7 @@ fn walk_dir_options(opts: &ListPathRawOptions) -> WalkDirOptions {
skip_total_timeout: opts.skip_walkdir_total_timeout,
timeout_ms: opts.walkdir_timeout.map(duration_millis),
stall_timeout_ms: opts.walkdir_stall_timeout.map(duration_millis),
producer_limit_reached: opts.producer_limit_reached.clone(),
..Default::default()
}
}
@@ -2141,7 +2141,7 @@ impl RemoteDisk {
let file_info_bin = encode_file_info_msgpack(fi)?;
let mut client = self.get_client().await?;
let mut request = Request::new(RenameDataRequest {
bucket_incarnation_id: crate::store::bucket_heal_scope(dst_volume)
bucket_incarnation_id: crate::store::bucket_heal_scope_for_object(dst_volume, dst_path)
.map(|scope| scope.incarnation.as_bytes().to_vec().into())
.unwrap_or_default(),
disk: self.endpoint.to_string(),
+356 -33
View File
@@ -41,7 +41,7 @@ use crate::config::com::{
};
use crate::data_movement;
use crate::data_movement::backpressure::{self, DataMovementOperation};
use crate::data_usage::DATA_USAGE_CACHE_NAME;
use crate::data_usage::{DATA_USAGE_CACHE_NAME, DATA_USAGE_ROOT, load_data_usage_cache};
use crate::disk::error::DiskError;
use crate::disk::{BUCKET_META_PREFIX, DiskAPI, RUSTFS_META_BUCKET};
use crate::error::{Error, Result};
@@ -135,6 +135,7 @@ const DECOMMISSION_CAPACITY_RELEASE_FAILED: &str = "failed";
const DECOMMISSION_CAPACITY_RELEASE_COMPLETED: &str = "completed";
pub(crate) const DECOMMISSION_CAPACITY_TARGET_LOCK_PREFIX: &str = "decommission/capacity-target";
const DECOMMISSION_CAPACITY_TARGET_LOCK_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(250);
const DECOMMISSION_CAPACITY_TARGET_GATE_MAX_ATTEMPTS: usize = 12;
const DECOMMISSION_CAPACITY_TARGET_GATE_BUSY_PREFIX: &str = "target pool ";
const DECOMMISSION_CAPACITY_TARGET_GATE_BUSY_SUFFIX: &str = " target capacity mutation gate is busy";
const METRIC_DECOMMISSION_CAPACITY_CONFLICTS_TOTAL: &str = "rustfs_decommission_capacity_conflicts_total";
@@ -158,6 +159,10 @@ const DECOMMISSION_COPY_RETRY_DELAY: std::time::Duration = std::time::Duration::
const DECOMMISSION_SOURCE_CHANGED_EXHAUSTION_LIMIT: usize = 100;
const DECOMMISSION_TERMINAL_RETRY_DELAY: std::time::Duration = std::time::Duration::from_secs(1);
const DECOMMISSION_CANCEL_TARGET_LOCK_MAX_ATTEMPTS: usize = 3;
fn decommission_capacity_target_gate_retry_exhausted(attempt: usize) -> bool {
attempt.saturating_add(1) >= DECOMMISSION_CAPACITY_TARGET_GATE_MAX_ATTEMPTS
}
const DECOMMISSION_DURABLE_ILM_RECEIPT_ROOT: &str = "decommission/ilm-receipts";
const DECOMMISSION_DURABLE_ILM_MANIFEST_ROOT: &str = "decommission/ilm-manifests";
const DECOMMISSION_DURABLE_ILM_RECEIPT_SCHEMA: &str = "v2";
@@ -925,6 +930,29 @@ fn capacity_target_physical_bytes(data_bytes: usize, layout: DecommissionErasure
Ok(capacity_mul_div_ceil(data_bytes, layout.width(), layout.data))
}
async fn decommission_owned_physical_usage(sets: &Sets, layout: DecommissionErasureLayout) -> HashMap<i32, usize> {
let mut usage = HashMap::with_capacity(sets.disk_set.len());
for (set_index, set) in sets.disk_set.iter().enumerate() {
let Ok(cache) = load_data_usage_cache(set.as_ref(), DATA_USAGE_CACHE_NAME).await else {
continue;
};
let data_usage = cache.dui(DATA_USAGE_ROOT, &[]);
if !data_usage.is_complete_bucket_usage_snapshot() {
continue;
}
let Ok(logical_bytes) = usize::try_from(data_usage.objects_total_size) else {
continue;
};
let Ok(set_index) = i32::try_from(set_index) else {
continue;
};
// The data-usage snapshot excludes unrelated files on a shared mount.
// Convert logical object bytes to the source set's physical EC footprint.
usage.insert(set_index, capacity_mul_div_ceil(logical_bytes, layout.width(), layout.data));
}
usage
}
fn worst_decommission_target_layout(targets: &[DecommissionPoolCapacityInfo]) -> Result<DecommissionErasureLayout> {
targets
.iter()
@@ -1861,6 +1889,132 @@ fn ensure_external_decommission_target_admission(
Err(Error::SlowDown)
}
fn grow_decommission_target_reservation(
meta: &mut PoolMeta,
capacity_infos: &[DecommissionPoolCapacityInfo],
source_pool_index: usize,
target_pool_index: usize,
expected_data_bytes: usize,
mutation_id: uuid::Uuid,
now: OffsetDateTime,
) -> Result<bool> {
let original = meta.clone();
let result = (|| {
let target_capacity = capacity_infos
.iter()
.find(|capacity| capacity.pool_index == target_pool_index)
.ok_or_else(|| {
decommission_capacity_blocked_error("target capacity snapshot is missing during reservation growth")
})?;
let pool_count = meta.pools.len();
let pool = meta
.pools
.get_mut(source_pool_index)
.ok_or_else(|| invalid_decommission_pool_index_error(pool_count, source_pool_index))?;
let reservation = pool
.decommission
.as_mut()
.and_then(|info| info.capacity_reservation.as_mut())
.filter(|reservation| reservation.active())
.ok_or_else(|| decommission_capacity_blocked_error("active reservation disappeared during reservation growth"))?;
let target_layout = reservation
.targets
.iter()
.find(|target| target.pool_index == target_pool_index)
.map(|target| target.layout)
.unwrap_or(target_capacity.layout);
let data_bytes = expected_data_bytes.max(1);
let required_target_bytes = capacity_target_physical_bytes(data_bytes, target_layout)?;
let required_peak = required_target_bytes.saturating_mul(1usize.saturating_add(reservation.temporary_copies));
let existing_target = reservation
.targets
.iter()
.find(|target| target.pool_index == target_pool_index);
if let Some(target) = existing_target
&& target.pending_physical_bytes > 0
&& target.pending_mutation_id != Some(mutation_id)
{
return Err(decommission_capacity_blocked_error(format!(
"source pool {source_pool_index} target pool {target_pool_index} has an unresolved target capacity intent"
)));
}
let existing_remaining = existing_target
.map(|target| target.remaining_reserved_physical_bytes(reservation.temporary_copies))
.unwrap_or_default();
if required_peak <= existing_remaining {
return Ok(false);
}
if reservation.source_data_equivalent_bytes > 0 {
return Err(decommission_capacity_blocked_error(format!(
"source pool {source_pool_index} reservation estimate is exhausted"
)));
}
if let Some(target) = existing_target
&& target_capacity.physical_free <= target.physical_free_at_reservation
{
return Err(decommission_capacity_blocked_error(format!(
"source pool {source_pool_index} target pool {target_pool_index} reservation is exhausted"
)));
}
let current_peak = reservation.remaining_peak_physical_bytes();
let additional_peak = required_peak.saturating_sub(existing_remaining);
let additional_predicted = additional_peak.div_ceil(1usize.saturating_add(reservation.temporary_copies));
let additional_source_data = capacity_source_data_equivalent(additional_predicted, target_layout)?;
reservation.source_data_equivalent_bytes = reservation
.source_data_equivalent_bytes
.saturating_add(additional_source_data);
reservation.source_physical_bytes = reservation
.source_physical_bytes
.saturating_add(capacity_target_physical_bytes(additional_source_data, reservation.source_layout)?);
reservation.predicted_physical_bytes = reservation
.predicted_physical_bytes
.saturating_add(additional_predicted)
.max(capacity_target_physical_bytes(reservation.source_data_equivalent_bytes, target_layout)?);
reservation.temporary_physical_bytes = reservation
.predicted_physical_bytes
.saturating_mul(reservation.temporary_copies);
reservation.peak_physical_bytes = reservation
.predicted_physical_bytes
.saturating_add(reservation.temporary_physical_bytes);
let allocation_growth = reservation.remaining_peak_physical_bytes().saturating_sub(current_peak);
if let Some(target) = reservation
.targets
.iter_mut()
.find(|target| target.pool_index == target_pool_index)
{
target.reserved_physical_bytes = target.reserved_physical_bytes.saturating_add(allocation_growth);
target.physical_total_at_reservation = target.physical_total_at_reservation.max(target_capacity.physical_total);
target.physical_free_at_reservation = target.physical_free_at_reservation.max(target_capacity.physical_free);
} else {
reservation.targets.push(DecommissionCapacityTarget {
pool_index: target_pool_index,
layout: target_layout,
physical_total_at_reservation: target_capacity.physical_total,
physical_free_at_reservation: target_capacity.physical_free,
reserved_physical_bytes: allocation_growth,
consumed_physical_bytes: 0,
observed_physical_bytes: 0,
inflight_physical_bytes: 0,
pending_physical_bytes: 0,
pending_mutation_id: None,
temporary_mutations: Vec::new(),
});
}
renew_decommission_capacity_reservation(reservation, now, true);
pool.last_update = pool.last_update.max(now);
Ok(true)
})();
if result.is_ok() {
if let Err(err) = ensure_decommission_capacity_reservations_available(meta, capacity_infos, "reservation growth") {
*meta = original;
return Err(err);
}
} else {
*meta = original;
}
result
}
fn ensure_decommission_target_owner_admission(
meta: &PoolMeta,
owner: DecommissionCapacityOwner,
@@ -11402,10 +11556,11 @@ impl ECStore {
}
})
.filter(|reservation| {
reservation
.targets
.iter()
.any(|target| target.pool_index == target_pool_index)
!temporary_release
|| reservation
.targets
.iter()
.any(|target| target.pool_index == target_pool_index)
})
.map(|reservation| (owner, reservation.model_version))
});
@@ -11464,10 +11619,11 @@ impl ECStore {
} else {
reservation.admits_owner(owner, OffsetDateTime::now_utc())
}) && reservation.model_version == model_version
&& reservation
.targets
.iter()
.any(|target| target.pool_index == target_pool_index)
&& (!temporary_release
|| reservation
.targets
.iter()
.any(|target| target.pool_index == target_pool_index))
})
.is_some();
if !owner_current {
@@ -11476,9 +11632,22 @@ impl ECStore {
));
}
let capacity_infos = self.get_decommission_all_pool_capacity_infos().await?;
if !temporary_release {
let reservation_grew = if temporary_release {
false
} else {
let data_bytes = expected_data_bytes.unwrap_or(1).max(1);
let grew = grow_decommission_target_reservation(
&mut snapshot,
&capacity_infos,
source_pool_index,
target_pool_index,
data_bytes,
mutation_id,
OffsetDateTime::now_utc(),
)?;
ensure_decommission_capacity_reservations_available(&snapshot, &capacity_infos, "mutation")?;
}
grew
};
let target_layout = snapshot
.pools
.get(source_pool_index)
@@ -11544,7 +11713,7 @@ impl ECStore {
OffsetDateTime::now_utc(),
)?
};
if pending_added > 0 {
if reservation_grew || pending_added > 0 {
let outcome = snapshot
.save_no_lock_armed(self.pools.clone(), &mut save_guard, write_guard.lock_lost_signal(), &[source_pool_index])
.await?;
@@ -12386,7 +12555,11 @@ impl ECStore {
Ok(())
}
async fn get_decommission_pool_capacity_info(&self, idx: usize) -> Result<DecommissionPoolCapacityInfo> {
async fn get_decommission_pool_capacity_info(
&self,
idx: usize,
use_owned_usage: bool,
) -> Result<DecommissionPoolCapacityInfo> {
if let Some(sets) = self.pools.get(idx) {
let mut info = sets.storage_info_snapshot().await;
info.backend = StorageAdminApi::backend_info(self).await;
@@ -12418,8 +12591,13 @@ impl ECStore {
layout.data, layout.parity
)));
}
let owned_physical_used = if use_owned_usage {
Some(decommission_owned_physical_usage(sets, layout).await)
} else {
None
};
let (physical_total, physical_free, physical_used) =
decommission_physical_pool_capacity(&info.disks, idx, layout, space);
decommission_physical_pool_capacity(&info.disks, idx, layout, space, owned_physical_used.as_ref());
Ok(DecommissionPoolCapacityInfo {
pool_index: idx,
@@ -12442,7 +12620,20 @@ impl ECStore {
let mut capacity_infos = Vec::with_capacity(self.pools.len());
for idx in 0..self.pools.len() {
capacity_infos.push(self.get_decommission_pool_capacity_info(idx).await?);
capacity_infos.push(self.get_decommission_pool_capacity_info(idx, false).await?);
}
Ok(capacity_infos)
}
async fn get_decommission_all_pool_capacity_infos_with_owned_usage(&self) -> Result<Vec<DecommissionPoolCapacityInfo>> {
#[cfg(any(test, feature = "test-util"))]
if let Some(capacity_infos) = take_decommission_capacity_info_override_for_test(self.id) {
return Ok(capacity_infos);
}
let mut capacity_infos = Vec::with_capacity(self.pools.len());
for idx in 0..self.pools.len() {
capacity_infos.push(self.get_decommission_pool_capacity_info(idx, true).await?);
}
Ok(capacity_infos)
}
@@ -12544,15 +12735,37 @@ impl ECStore {
// different target so one mutation never holds two target gates.
discard_decommission_capacity_target_permit_except(self.id, owner, None);
}
reservation
if let Some((pool_index, _)) = reservation
.targets
.iter()
.filter_map(candidate)
.max_by_key(|(_, remaining)| *remaining)
.map(|(pool_index, _)| pool_index)
{
return Ok(pool_index);
}
if reservation.source_data_equivalent_bytes > 0 {
return Err(decommission_capacity_blocked_error(format!(
"source pool {} has no target allocation for {expected_data_bytes} data bytes",
owner.source_pool_index
)));
}
let capacities = self.get_decommission_all_pool_capacity_infos().await?;
let active_sources = active_decommission_source_indices(&pool_meta);
capacities
.into_iter()
.filter(|capacity| {
capacity.pool_index != owner.source_pool_index
&& !active_sources.contains(&capacity.pool_index)
&& pool_meta
.pools
.get(capacity.pool_index)
.is_some_and(is_decommission_start_active_pool)
})
.max_by_key(|capacity| capacity.physical_free)
.map(|capacity| capacity.pool_index)
.ok_or_else(|| {
decommission_capacity_blocked_error(format!(
"source pool {} has no target allocation for {expected_data_bytes} data bytes",
"source pool {} has no target pool for {expected_data_bytes} data bytes",
owner.source_pool_index
))
})
@@ -13048,7 +13261,7 @@ impl ECStore {
let capacity_infos = if self.pools.is_empty() {
Vec::new()
} else {
self.get_decommission_all_pool_capacity_infos().await?
self.get_decommission_all_pool_capacity_infos_with_owned_usage().await?
};
let target_fence_proof = crate::services::notification_sys::acquire_decommission_target_fence_fleet_proof();
let mut pool_meta = self.pool_meta.write().await;
@@ -13749,6 +13962,20 @@ impl ECStore {
match self.acquire_decommission_capacity_target_guard(target_pool_index).await {
Ok(guard) => break guard,
Err(err) if is_decommission_capacity_target_gate_busy(&err) => {
if decommission_capacity_target_gate_retry_exhausted(wait_attempt) {
warn!(
event = EVENT_DECOMMISSION_STATE,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_POOLS,
pool_index = idx,
target_pool_index,
attempts = wait_attempt.saturating_add(1),
state = "capacity_gate_retry_exhausted",
error = %err,
"Decommission target capacity gate remained busy; pausing for supervised retry"
);
return Err(err);
}
wait_attempt = wait_attempt.saturating_add(1);
}
Err(err) => return Err(err),
@@ -15355,7 +15582,7 @@ impl ECStore {
.is_some_and(|reservation| !reservation.active())
};
if needs_capacity_reservation {
let capacity_infos = self.get_decommission_all_pool_capacity_infos().await?;
let capacity_infos = self.get_decommission_all_pool_capacity_infos_with_owned_usage().await?;
let mut pool_meta = self.pool_meta.write().await;
let version = pool_meta.version;
pool_meta.version = POOL_META_VERSION;
@@ -16219,7 +16446,7 @@ impl ECStore {
self.ensure_decommission_rebalance_idle_after_refresh_under_start_gate()
.await?;
let all_capacity_infos = self.get_decommission_all_pool_capacity_infos().await?;
let all_capacity_infos = self.get_decommission_all_pool_capacity_infos_with_owned_usage().await?;
let target_fence_proof_available =
crate::services::notification_sys::acquire_decommission_target_fence_fleet_proof().is_some();
// Signal cancellation before waiting for the movement writer so active
@@ -22653,6 +22880,7 @@ fn decommission_physical_pool_capacity(
pool_index: usize,
layout: DecommissionErasureLayout,
logical: PoolSpaceInfo,
owned_physical_used: Option<&HashMap<i32, usize>>,
) -> (usize, usize, usize) {
let width = layout.width();
let mut sets: HashMap<i32, Vec<&rustfs_madmin::Disk>> = HashMap::new();
@@ -22692,13 +22920,20 @@ fn decommission_physical_pool_capacity(
.map(|disk| disk.available_space as usize)
.min()
.unwrap_or_default();
let max_used = set_disks
.iter()
.map(|disk| (disk.used_space as usize).max((disk.total_space as usize).saturating_sub(disk.available_space as usize)))
.max()
.unwrap_or_default();
let physical_used_for_set = owned_physical_used
.and_then(|owned| owned.get(&set_disks[0].set_index).copied())
.unwrap_or_else(|| {
set_disks
.iter()
.map(|disk| {
(disk.used_space as usize).max((disk.total_space as usize).saturating_sub(disk.available_space as usize))
})
.max()
.unwrap_or_default()
.saturating_mul(width)
});
physical_total = physical_total.saturating_add(min_total.saturating_mul(width));
physical_used = physical_used.saturating_add(max_used.saturating_mul(width));
physical_used = physical_used.saturating_add(physical_used_for_set);
if set_disks.len() >= width {
physical_free = physical_free.saturating_add(min_free.saturating_mul(width));
}
@@ -22881,6 +23116,8 @@ pub(crate) fn fallback_free_capacity_dedup(disks: &[rustfs_madmin::Disk]) -> usi
#[cfg(test)]
mod pools_tests {
use std::collections::HashMap;
use super::DECOMMISSION_PROGRESS_SAVE_RETRY_BACKOFF;
use super::persist_v3_pool_meta_for_test;
use super::record_decommission_entry_error;
@@ -22914,10 +23151,11 @@ mod pools_tests {
ensure_decommission_start_rebalance_meta_allowed, ensure_decommission_start_target_capacity,
ensure_decommission_terminal_operation_supported, ensure_decommission_unresolved_verification_disk_count,
ensure_local_decommission_pool_leaders, ensure_pool_meta_write_fence, ensure_valid_decommission_pool_index, get_by_index,
guard_decommission_cancelers, has_active_decommission_canceler, is_decommission_active, is_decommission_cancel_requested,
load_decommission_entry_versions, local_decommission_queue_prefix, mark_decommission_bucket_done,
merge_decommission_durable_ilm_receipts, merge_pool_meta_updates_for_save, merge_pool_status_refresh,
missing_decommission_worker_prefix, next_decommission_capacity_generation, observe_decommission_terminal_reload_result,
grow_decommission_target_reservation, guard_decommission_cancelers, has_active_decommission_canceler,
is_decommission_active, is_decommission_cancel_requested, load_decommission_entry_versions,
local_decommission_queue_prefix, mark_decommission_bucket_done, merge_decommission_durable_ilm_receipts,
merge_pool_meta_updates_for_save, merge_pool_status_refresh, missing_decommission_worker_prefix,
next_decommission_capacity_generation, observe_decommission_terminal_reload_result,
parse_decommission_durable_ilm_receipt_path, pool_meta_has_active_decommission, publish_pool_meta_updates,
read_pool_meta_replica, reconcile_decommission_meta_buckets, reconcile_decommission_unresolved_entries_for_completion,
record_decommission_unresolved_entry, recover_decommission_capacity_reservations,
@@ -22943,8 +23181,9 @@ mod pools_tests {
with_decommission_entry_context,
};
use super::{
DecommissionCapacityAdmission, DecommissionCapacityOwner, DecommissionCapacityReleaseProof,
DecommissionCapacityReservation, DecommissionCapacityTemporaryMutation, decommission_capacity_mutation_id,
DECOMMISSION_CAPACITY_TARGET_GATE_MAX_ATTEMPTS, DecommissionCapacityAdmission, DecommissionCapacityOwner,
DecommissionCapacityReleaseProof, DecommissionCapacityReservation, DecommissionCapacityTemporaryMutation,
decommission_capacity_mutation_id, decommission_capacity_target_gate_retry_exhausted,
ensure_decommission_target_owner_admission, ensure_exact_delete_capacity_namespace_fences,
ensure_external_decommission_target_admission, is_decommission_capacity_blocked_error,
plan_exact_delete_capacity_reconciliations, record_decommission_target_consumption, release_decommission_target_inflight,
@@ -23051,6 +23290,17 @@ mod pools_tests {
identity: StdMutex<Option<(Vec<u8>, String)>>,
}
#[test]
fn decommission_target_gate_retry_is_bounded() {
assert!(!decommission_capacity_target_gate_retry_exhausted(0));
assert!(!decommission_capacity_target_gate_retry_exhausted(
DECOMMISSION_CAPACITY_TARGET_GATE_MAX_ATTEMPTS - 2
));
assert!(decommission_capacity_target_gate_retry_exhausted(
DECOMMISSION_CAPACITY_TARGET_GATE_MAX_ATTEMPTS - 1
));
}
#[tokio::test]
async fn pool_meta_recovery_reconciles_prepare_and_partial_commit_without_format_downgrade() {
for previous_version in [POOL_META_V1_VERSION, POOL_META_VERSION, POOL_META_GENERATION_VERSION] {
@@ -28472,6 +28722,44 @@ mod pools_tests {
}
}
#[test]
fn decommission_capacity_reservation_grows_after_stale_empty_snapshot() {
let now = OffsetDateTime::UNIX_EPOCH + Duration::minutes(1);
let layout = DecommissionErasureLayout { data: 3, parity: 1 };
let capacity_infos = vec![
DecommissionPoolCapacityInfo::for_test(0, layout, 0, 0, 0),
DecommissionPoolCapacityInfo::for_test(1, layout, 64, 64, 0),
];
let mut meta = PoolMeta {
version: POOL_META_VERSION,
pools: vec![decommission_test_pool_status(0, None), decommission_test_pool_status(1, None)],
..Default::default()
};
meta.decommission(0, capacity_infos[0].space).unwrap();
reserve_decommission_start_target_capacity(
&mut meta,
&[0],
&capacity_infos,
uuid::Uuid::new_v4(),
1,
now,
DECOMMISSION_CAPACITY_MODEL_VERSION,
)
.expect("an empty scanner snapshot should still start decommission");
let grew = grow_decommission_target_reservation(&mut meta, &capacity_infos, 0, 1, 1, uuid::Uuid::new_v4(), now)
.expect("the reservation should grow against current target capacity");
assert!(grew);
let reservation = meta.pools[0]
.decommission
.as_ref()
.and_then(|info| info.capacity_reservation.as_ref())
.unwrap();
assert_eq!(reservation.source_data_equivalent_bytes, 2);
assert_eq!(reservation.predicted_physical_bytes, 3);
assert_eq!(reservation.targets.len(), 1);
assert_eq!(reservation.targets[0].reserved_physical_bytes, 6);
}
#[test]
fn decommission_capacity_model_reserves_versions_delete_markers_and_temporary_copies_from_physical_usage() {
let reservation = build_decommission_capacity_reservation(
@@ -29028,11 +29316,46 @@ mod pools_tests {
total: 0,
used: 0,
},
None,
);
assert_eq!(capacity, (400, 40, 360));
}
#[test]
fn decommission_physical_capacity_prefers_rustfs_owned_usage_over_filesystem_usage() {
let disks = [10_u64, 20, 30, 40]
.into_iter()
.enumerate()
.map(|(disk_index, available_space)| rustfs_madmin::Disk {
endpoint: format!("http://node-{disk_index}"),
drive_path: format!("/disk-{disk_index}"),
state: "ok".to_string(),
total_space: 100,
used_space: 100 - available_space,
available_space,
pool_index: 0,
set_index: 0,
disk_index: disk_index as i32,
..Default::default()
})
.collect::<Vec<_>>();
let owned = HashMap::from([(0, 12)]);
let capacity = decommission_physical_pool_capacity(
&disks,
0,
DecommissionErasureLayout { data: 2, parity: 2 },
PoolSpaceInfo {
free: 0,
total: 0,
used: 0,
},
Some(&owned),
);
assert_eq!(capacity, (400, 40, 12));
}
#[test]
fn decommission_terminal_transitions_release_capacity_reservations() {
let now = OffsetDateTime::UNIX_EPOCH + Duration::hours(1);
+2 -2
View File
@@ -1493,12 +1493,12 @@ impl Sets {
object: &str,
version_id: &str,
opts: &HealOpts,
retirement: Option<&crate::bucket::retirement::MarkerRetirementContext<'_>>,
proof: crate::set_disk::AbsenceProofRequest<'_>,
) -> Result<(HealResultItem, Option<Error>, Option<crate::set_disk::HealedObjectAbsence>)> {
let mut absence = None;
let (item, error) = self
.get_disks_for_heal_object(object, opts)?
.heal_object_with_retirement(bucket, object, version_id, opts, &mut absence, retirement)
.heal_object_with_retirement(bucket, object, version_id, opts, &mut absence, proof)
.await?;
// A caller-owned lock does not expose its lease to this boundary.
// Keep cleanup unverified when that lease cannot be checked here.
+17 -18
View File
@@ -244,13 +244,16 @@ impl StdError for ConditionalFileNotCommittedError {
}
}
fn classify_internode_missing_error(error: &InternodeHttpError) -> Option<DiskError> {
fn classify_internode_disk_error(error: &InternodeHttpError) -> Option<DiskError> {
if error.is_remote_file_not_found() {
return Some(DiskError::FileNotFound);
}
if error.is_remote_volume_not_found() {
return Some(DiskError::VolumeNotFound);
}
if error.is_remote_file_corrupt() {
return Some(DiskError::FileCorrupt);
}
None
}
@@ -526,13 +529,10 @@ fn io_error_chain_contains_kind(io_error: &std::io::Error, kind: std::io::ErrorK
impl From<std::io::Error> for DiskError {
fn from(e: std::io::Error) -> Self {
if let Some(error) = e.get_ref().and_then(|source| source.downcast_ref::<InternodeHttpError>()) {
if error.is_remote_file_not_found() {
return DiskError::FileNotFound;
}
if error.is_remote_volume_not_found() {
return DiskError::VolumeNotFound;
}
if let Some(error) = e.get_ref().and_then(|source| source.downcast_ref::<InternodeHttpError>())
&& let Some(classified) = classify_internode_disk_error(error)
{
return classified;
}
let e = match e.downcast::<TerminalReadError>() {
Ok(terminal_error) => {
@@ -541,7 +541,7 @@ impl From<std::io::Error> for DiskError {
&& let Some(internode_error) = io_error
.get_ref()
.and_then(|source| source.downcast_ref::<InternodeHttpError>())
&& let Some(classified) = classify_internode_missing_error(internode_error)
&& let Some(classified) = classify_internode_disk_error(internode_error)
{
return classified;
}
@@ -963,6 +963,7 @@ mod tests {
for (remote_error, expected) in [
(rustfs_rio::new_test_remote_file_not_found_http_io_error(), DiskError::FileNotFound),
(rustfs_rio::new_test_remote_volume_not_found_http_io_error(), DiskError::VolumeNotFound),
(rustfs_rio::new_test_remote_file_corrupt_http_io_error(), DiskError::FileCorrupt),
] {
let wrapped = terminal_read_error_to_io(DiskError::Io(remote_error));
assert_eq!(DiskError::from(wrapped), expected);
@@ -1533,25 +1534,23 @@ mod tests {
}
#[test]
fn test_internode_missing_errors_preserve_disk_error_types() {
fn test_internode_disk_errors_preserve_disk_error_types() {
let file_missing = DiskError::from(rustfs_rio::new_test_remote_file_not_found_http_io_error());
let volume_missing = DiskError::from(rustfs_rio::new_test_remote_volume_not_found_http_io_error());
let file_corrupt = DiskError::from(rustfs_rio::new_test_remote_file_corrupt_http_io_error());
let unmarked_server_error = DiskError::from(rustfs_rio::new_test_internode_http_io_error(
rustfs_rio::InternodeHttpErrorKind::HttpStatus(http::StatusCode::INTERNAL_SERVER_ERROR),
));
assert_eq!(file_missing, DiskError::FileNotFound);
assert_eq!(volume_missing, DiskError::VolumeNotFound);
assert_eq!(file_corrupt, DiskError::FileCorrupt);
assert!(matches!(unmarked_server_error, DiskError::Io(_)));
for missing in [file_missing, volume_missing] {
assert_eq!(missing.clone(), missing);
for error in [file_missing, volume_missing, file_corrupt] {
assert_eq!(error.clone(), error);
assert_eq!(
crate::disk::error_reduce::reduce_write_quorum_errs(
&[Some(missing.clone()), Some(missing.clone()), None],
&[],
2
),
Some(missing)
crate::disk::error_reduce::reduce_write_quorum_errs(&[Some(error.clone()), Some(error.clone()), None], &[], 2),
Some(error)
);
}
}
+133 -60
View File
@@ -1899,6 +1899,61 @@ impl AsyncWrite for DirectWriter {
}
}
fn poll_write_vectored(
self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
bufs: &[std::io::IoSlice<'_>],
) -> std::task::Poll<std::io::Result<usize>> {
// Bitrot writes arrive as [hash, data]. Coalesce both slices into the
// aligned bounce buffer so the O_DIRECT path has the same byte-stream
// contract as the buffered writer instead of relying on the trait's
// first-slice fallback.
let this = self.get_mut();
loop {
match &mut this.state {
DirectWriteState::Busy(_) => {
std::task::ready!(this.poll_drive_busy(cx))?;
}
DirectWriteState::Idle(inner_opt) => {
let inner = inner_opt.as_mut().expect("idle direct writer must hold inner state");
let capacity = inner.buf.len;
let space = capacity - inner.filled;
let mut remaining = space;
let mut written = 0;
for src in bufs {
if remaining == 0 {
break;
}
let take = src.len().min(remaining);
let start = inner.filled + written;
inner.buf.as_mut_slice()[start..start + take].copy_from_slice(&src[..take]);
written += take;
remaining -= take;
}
if written == 0 {
return std::task::Poll::Ready(Ok(0));
}
inner.filled += written;
if inner.filled == capacity {
let mut inner = inner_opt.take().expect("idle direct writer must hold inner state");
let handle = tokio::task::spawn_blocking(move || {
let res = inner.flush_batch();
(inner, res)
});
this.state = DirectWriteState::Busy(handle);
}
return std::task::Poll::Ready(Ok(written));
}
}
}
}
fn is_write_vectored(&self) -> bool {
true
}
fn poll_flush(self: std::pin::Pin<&mut Self>, cx: &mut std::task::Context<'_>) -> std::task::Poll<std::io::Result<()>> {
// Only drive an in-flight batch to completion. Sub-alignment staged
// bytes cannot be flushed mid-stream (they would misalign the next
@@ -7556,7 +7611,7 @@ impl LocalDisk {
objs_returned: &mut i32,
skip_current_dir_object: bool,
multipart_dir_to_skip: Option<HashSet<String>>,
) -> Result<()>
) -> Result<bool>
where
W: AsyncWrite + Unpin + Send,
{
@@ -7574,7 +7629,7 @@ impl LocalDisk {
};
if opts.limit > 0 && *objs_returned >= opts.limit {
return Ok(());
return Ok(true);
}
// TODO(backlog): add directory listing lock to prevent concurrent enumeration
@@ -7635,12 +7690,12 @@ impl LocalDisk {
return Err(DiskError::FileNotFound);
}
return Ok(());
return Ok(false);
}
};
if entries.is_empty() {
return Ok(());
return Ok(false);
}
current = current.trim_matches('/').to_owned();
@@ -7654,7 +7709,7 @@ impl LocalDisk {
let entry = item.clone();
// check limit
if opts.limit > 0 && *objs_returned >= opts.limit {
return Ok(());
return Ok(true);
}
// check multipart dir
if skip_current_dir_object
@@ -7759,14 +7814,14 @@ impl LocalDisk {
prefix = "".to_owned();
for entry in entries.iter() {
if opts.limit > 0 && *objs_returned >= opts.limit {
return Ok(());
}
if entry.is_empty() {
continue;
}
if opts.limit > 0 && *objs_returned >= opts.limit {
return Ok(true);
}
let name = path_join_buf(&[current.as_str(), entry.as_str()]);
while let Some((last_name, _, _, _)) = dir_stack.last()
@@ -7780,7 +7835,7 @@ impl LocalDisk {
// moment the limit is reached, same as the check below this
// loop guards against for the current entry itself.
if opts.limit > 0 && *objs_returned >= opts.limit {
return Ok(());
return Ok(true);
}
let (pop, skip_object, dir_to_skip, scan_required) = dir_stack.pop().expect("operation should succeed");
@@ -7794,23 +7849,25 @@ impl LocalDisk {
.await?;
let scan_path = pop.clone();
if opts.recursive
&& scan_required
&& let Err(er) =
Box::pin(self.scan_dir(pop, prefix.clone(), opts, out, objs_returned, skip_object, dir_to_skip)).await
{
if !er.is_metacache_output_stream_closed() {
error!(
event = EVENT_DISK_LOCAL_SCAN_FAILED,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_DISK_LOCAL,
path = %scan_path,
operation = "scan_dir",
error = ?er,
"Disk local scan failed"
);
if opts.recursive && scan_required {
match Box::pin(self.scan_dir(pop, prefix.clone(), opts, out, objs_returned, skip_object, dir_to_skip)).await {
Ok(true) => return Ok(true),
Ok(false) => {}
Err(er) => {
if !er.is_metacache_output_stream_closed() {
error!(
event = EVENT_DISK_LOCAL_SCAN_FAILED,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_DISK_LOCAL,
path = %scan_path,
operation = "scan_dir",
error = ?er,
"Disk local scan failed"
);
}
return Err(er);
}
}
return Err(er);
}
}
@@ -7821,7 +7878,7 @@ impl LocalDisk {
// tail, permanently skipping those keys on the next page instead
// of just deferring them to it.
if opts.limit > 0 && *objs_returned >= opts.limit {
return Ok(());
return Ok(true);
}
let mut meta = MetaCacheEntry {
@@ -7964,7 +8021,7 @@ impl LocalDisk {
while let Some((dir, skip_object, dir_to_skip, scan_required)) = dir_stack.pop() {
if opts.limit > 0 && *objs_returned >= opts.limit {
return Ok(());
return Ok(true);
}
write_metacache_obj(
@@ -7977,27 +8034,29 @@ impl LocalDisk {
.await?;
let scan_path = dir.clone();
if opts.recursive
&& scan_required
&& let Err(er) =
Box::pin(self.scan_dir(dir, prefix.clone(), opts, out, objs_returned, skip_object, dir_to_skip)).await
{
if !er.is_metacache_output_stream_closed() {
error!(
event = EVENT_DISK_LOCAL_SCAN_FAILED,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_DISK_LOCAL,
path = %scan_path,
operation = "scan_dir",
error = ?er,
"Disk local recursive scan failed"
);
if opts.recursive && scan_required {
match Box::pin(self.scan_dir(dir, prefix.clone(), opts, out, objs_returned, skip_object, dir_to_skip)).await {
Ok(true) => return Ok(true),
Ok(false) => {}
Err(er) => {
if !er.is_metacache_output_stream_closed() {
error!(
event = EVENT_DISK_LOCAL_SCAN_FAILED,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_DISK_LOCAL,
path = %scan_path,
operation = "scan_dir",
error = ?er,
"Disk local recursive scan failed"
);
}
return Err(er);
}
}
return Err(er);
}
}
Ok(())
Ok(false)
}
/// Whether the backing directory of plain object `object_name` also holds
@@ -10121,21 +10180,25 @@ impl DiskAPI for LocalDisk {
}
}
self.scan_dir(
opts.base_dir.clone(),
opts.filter_prefix.clone().unwrap_or_default(),
&opts,
&mut out,
&mut objs_returned,
skip_current_dir_object,
if multipart_dir_to_skip.is_empty() {
None
} else {
Some(multipart_dir_to_skip)
},
)
.await?;
let limit_reached = self
.scan_dir(
opts.base_dir.clone(),
opts.filter_prefix.clone().unwrap_or_default(),
&opts,
&mut out,
&mut objs_returned,
skip_current_dir_object,
if multipart_dir_to_skip.is_empty() {
None
} else {
Some(multipart_dir_to_skip)
},
)
.await?;
if let Some(flag) = opts.producer_limit_reached.as_ref() {
flag.store(limit_reached, std::sync::atomic::Ordering::Release);
}
out.close().await?;
Ok(())
}
@@ -22134,7 +22197,7 @@ mod test {
#[cfg(target_os = "linux")]
#[tokio::test]
async fn direct_writer_state_machine_round_trips_over_plain_file() {
use std::io::Read;
use std::io::{IoSlice, Read};
use tempfile::tempdir;
let dir = tempdir().expect("tempdir");
@@ -22153,6 +22216,16 @@ mod test {
let mut writer = DirectWriter::from_std_file_for_test(file, align, capacity);
let mut off = 0;
if content.len() >= 2 {
let split = content.len().min(300);
let first = split / 2;
let written = writer
.write_vectored(&[IoSlice::new(&content[..first]), IoSlice::new(&content[first..split])])
.await
.expect("vectored write");
assert_eq!(written, split, "vectored write must consume both hash/data-like slices");
off = split;
}
while off < content.len() {
let end = (off + 300).min(content.len());
writer.write_all(&content[off..end]).await.expect("write_all");
+14 -2
View File
@@ -70,7 +70,12 @@ use rustfs_filemeta::{FileInfo, ObjectPartInfo, RawFileInfo};
use rustfs_madmin::info_commands::DiskMetrics;
use rustfs_rio::ChunkReaderBox;
use serde::{Deserialize, Serialize};
use std::{fmt::Debug, path::PathBuf, sync::Arc, time::Duration};
use std::{
fmt::Debug,
path::PathBuf,
sync::{Arc, atomic::AtomicBool},
time::Duration,
};
use time::OffsetDateTime;
use tokio::io::{AsyncRead, AsyncWrite};
use uuid::Uuid;
@@ -820,7 +825,7 @@ impl Disk {
dst_volume: &str,
dst_path: &str,
) -> Result<RenameDataResp> {
let Some(scope) = crate::store::bucket_heal_scope(dst_volume) else {
let Some(scope) = crate::store::bucket_heal_scope_for_object(dst_volume, dst_path) else {
return self
.rename_data_borrowed_with_fence(src_volume, src_path, fi, dst_volume, dst_path, None)
.await;
@@ -1479,6 +1484,12 @@ pub struct WalkDirOptions {
// Override the remote stream stall timeout for long background walks.
#[serde(default)]
pub stall_timeout_ms: Option<u64>,
/// In-process completion state for bounded local walks. This is skipped
/// from RPC serialization; remote peers retain the legacy natural-EOF
/// behavior until they support an explicit capability.
#[serde(skip)]
pub producer_limit_reached: Option<Arc<AtomicBool>>,
}
impl WalkDirOptions {
@@ -1802,6 +1813,7 @@ mod tests {
skip_total_timeout: false,
timeout_ms: Some(10_000),
stall_timeout_ms: Some(20_000),
producer_limit_reached: None,
};
assert_eq!(opts.bucket, "test-bucket");
+151 -3
View File
@@ -2573,6 +2573,11 @@ impl Erasure {
.with_deferred_parity_reopeners(deferred_reopeners);
let integrity = reader.integrity.clone();
// Copy sources use demand-bound scheduling for backpressure, but retain
// the ordinary GET read quorum. Only the opt-in GET rollout requires a
// surplus source; available surplus shards and integrity proofs are
// still verified before any reconstructed copy bytes are emitted.
let require_surplus_source = reader.demand_bound_lockstep && matches!(decode_read_policy(), DecodeReadPolicy::Default);
let start = offset / self.block_size;
let end = end_offset.saturating_sub(1) / self.block_size;
@@ -2667,7 +2672,6 @@ impl Erasure {
// `shards` are borrowed again below. In the `Stop` case that
// drop is what cancels the still-in-flight prefetch read.
let (flow, next): (Option<StripeFlow>, Option<StripeReadOutput>) = {
let require_surplus_source = reader.demand_bound_lockstep;
let read_fut = read_stripe_timed(&mut reader, stage_metrics_enabled);
let emit_fut = self.emit_decoded_stripe(
writer,
@@ -2728,7 +2732,7 @@ impl Erasure {
&mut written,
&mut ret_err,
stage_metrics_enabled,
reader.demand_bound_lockstep,
require_surplus_source,
integrity.as_ref(),
offset,
)
@@ -2771,7 +2775,7 @@ impl Erasure {
&mut written,
&mut ret_err,
stage_metrics_enabled,
reader.demand_bound_lockstep,
require_surplus_source,
integrity.as_ref(),
offset,
)
@@ -3449,6 +3453,150 @@ mod tests {
}
}
async fn copy_source_bitrot_shards(erasure: &Erasure, payload: &[u8], corrupt_parity: bool) -> Vec<Vec<u8>> {
let mut writers: Vec<_> = (0..erasure.data_shards + erasure.parity_shards)
.map(|_| BitrotWriter::new(Cursor::new(Vec::new()), erasure.shard_size(), HashAlgorithm::HighwayHash256))
.collect();
for block in payload.chunks(erasure.block_size) {
let shards = erasure.encode_data(block).expect("copy source should encode");
for (index, shard) in shards.iter().enumerate() {
let mut bytes = shard.to_vec();
if corrupt_parity && index == erasure.data_shards {
// A stale source can have a valid per-shard bitrot checksum.
// Reframe the changed parity to exercise reconstruction verification.
bytes[0] ^= 0x80;
}
writers[index].write(&bytes).await.expect("copy source should frame");
}
}
writers.into_iter().map(|writer| writer.into_inner().into_inner()).collect()
}
#[tokio::test]
#[serial_test::serial]
async fn copy_source_reads_exact_quorum_across_stripes_and_ranges() {
const BLOCK_SIZE: usize = 768;
let payload: Vec<_> = (0..BLOCK_SIZE * 3 + 37)
.map(|index| (index.wrapping_mul(31) % 251) as u8)
.collect();
for uses_legacy in [false, true] {
let erasure = Erasure::new_with_options(12, 4, BLOCK_SIZE, uses_legacy);
let buffers = copy_source_bitrot_shards(&erasure, &payload, false).await;
for offline in [[0, 1, 2, 3], [0, 5, 12, 15]] {
for (offset, length) in [(0, payload.len()), (BLOCK_SIZE + 17, payload.len() - BLOCK_SIZE - 17)] {
let frame_offset = offset / BLOCK_SIZE * (erasure.shard_size() + HashAlgorithm::HighwayHash256.size());
let positioned: Vec<_> = buffers.iter().map(|bytes| bytes[frame_offset..].to_vec()).collect();
let (mut readers, handles) =
readers_with_deferred_parity(&positioned, 12, erasure.shard_size(), &HashAlgorithm::HighwayHash256, &[]);
for index in offline {
readers[index] = None;
}
let mut output = Vec::new();
let (written, error) = crate::set_disk::with_get_object_read_policy(
crate::set_disk::GetObjectReadPolicy::CopySource,
erasure.decode_with_stripe_handles(&mut output, readers, offset, length, payload.len(), None, handles),
)
.await;
assert!(error.is_none(), "legacy={uses_legacy}, offline={offline:?}, offset={offset}: {error:?}");
assert_eq!(written, length);
assert_eq!(output, payload[offset..offset + length]);
}
}
}
}
#[tokio::test]
#[serial_test::serial]
async fn copy_source_recovers_exact_quorum_after_first_stripe() {
const BLOCK_SIZE: usize = 768;
let payload: Vec<_> = (0..BLOCK_SIZE * 3 + 37)
.map(|index| (index.wrapping_mul(31) % 251) as u8)
.collect();
for uses_legacy in [false, true] {
let erasure = Erasure::new_with_options(12, 4, BLOCK_SIZE, uses_legacy);
let buffers = copy_source_bitrot_shards(&erasure, &payload, false).await;
let first_frame = erasure.shard_size() + HashAlgorithm::HighwayHash256.size();
let (readers, handles) = readers_with_deferred_parity(
&buffers,
12,
erasure.shard_size(),
&HashAlgorithm::HighwayHash256,
&[(0, first_frame), (1, first_frame), (2, first_frame), (3, first_frame)],
);
let mut output = Vec::new();
let (written, error) = crate::set_disk::with_get_object_read_policy(
crate::set_disk::GetObjectReadPolicy::CopySource,
erasure.decode_with_stripe_handles(&mut output, readers, 0, payload.len(), payload.len(), None, handles),
)
.await;
assert!(error.is_none(), "legacy={uses_legacy}: late shard loss should recover: {error:?}");
assert_eq!(written, payload.len());
assert_eq!(output, payload);
}
}
#[tokio::test]
#[serial_test::serial]
async fn copy_source_below_read_quorum_fails_before_output() {
const BLOCK_SIZE: usize = 768;
let payload = vec![0x5a; BLOCK_SIZE * 2 + 37];
for uses_legacy in [false, true] {
let erasure = Erasure::new_with_options(12, 4, BLOCK_SIZE, uses_legacy);
let buffers = copy_source_bitrot_shards(&erasure, &payload, false).await;
let (mut readers, handles) =
readers_with_deferred_parity(&buffers, 12, erasure.shard_size(), &HashAlgorithm::HighwayHash256, &[]);
for reader in readers.iter_mut().take(5) {
*reader = None;
}
let mut output = Vec::new();
let (written, error) = crate::set_disk::with_get_object_read_policy(
crate::set_disk::GetObjectReadPolicy::CopySource,
erasure.decode_with_stripe_handles(&mut output, readers, 0, payload.len(), payload.len(), None, handles),
)
.await;
assert_eq!(written, 0);
assert!(output.is_empty());
assert_eq!(
error
.expect("eleven shards cannot reconstruct twelve data shards")
.to_string(),
Error::ErasureReadQuorum.to_string()
);
}
}
#[tokio::test]
#[serial_test::serial]
async fn copy_source_rejects_corrupt_surplus_parity_before_output() {
const BLOCK_SIZE: usize = 768;
let payload = vec![0x5a; BLOCK_SIZE * 2 + 37];
for uses_legacy in [false, true] {
let erasure = Erasure::new_with_options(12, 4, BLOCK_SIZE, uses_legacy);
let buffers = copy_source_bitrot_shards(&erasure, &payload, true).await;
let (mut readers, handles) =
readers_with_deferred_parity(&buffers, 12, erasure.shard_size(), &HashAlgorithm::HighwayHash256, &[]);
for reader in readers.iter_mut().take(3) {
*reader = None;
}
let mut output = Vec::new();
let (written, error) = crate::set_disk::with_get_object_read_policy(
crate::set_disk::GetObjectReadPolicy::CopySource,
erasure.decode_with_stripe_handles(&mut output, readers, 0, payload.len(), payload.len(), None, handles),
)
.await;
assert_eq!(written, 0);
assert!(output.is_empty());
let error = error.expect("copy must reject inconsistent surplus parity despite valid bitrot framing");
assert_eq!(error.kind(), ErrorKind::InvalidData);
assert!(error.to_string().contains("inconsistent read source shards"));
}
}
/// Guards the shard-buffer reuse in `ParallelReader`: a multi-stripe
/// `Erasure::decode` that reconstructs missing data shards on every stripe
/// must still return byte-exact output. Reconstructed and parity buffers are
@@ -587,7 +587,9 @@ pub(crate) async fn verify_deep_parts(
return Err(DiskError::FileCorrupt);
}
let part_status = statuses.get_mut(&part_index).ok_or(DiskError::FileCorrupt)?;
let length = erasure.shard_file_offset(0, part.size, part.size);
// Deep verification must consume every encoded stripe, including a
// partial final stripe, before certifying the shard's integrity.
let length = usize::try_from(erasure.shard_file_size(part.size as i64)).map_err(|_| DiskError::FileCorrupt)?;
let mut readers = Vec::with_capacity(disks.len());
for (index, disk) in disks.iter().enumerate() {
if part_status[index] != CHECK_PART_UNKNOWN {
@@ -631,6 +633,9 @@ pub(crate) async fn verify_deep_parts(
part_status[index] = CHECK_PART_FILE_CORRUPT;
}
Err(error) => {
if !matches!(error, DiskError::FileNotFound | DiskError::FileVersionNotFound | DiskError::FileCorrupt) {
return Err(error);
}
readers.push(None);
part_status[index] = conv_part_err_to_int(&Some(error));
}
+139 -58
View File
@@ -42,6 +42,7 @@ use std::sync::{
atomic::{AtomicBool, AtomicUsize, Ordering},
};
use std::time::{Duration, Instant, SystemTime};
use tokio::sync::Notify;
use tokio::time::{sleep, timeout};
use tokio_util::sync::CancellationToken;
use tracing::{debug, error, info, warn};
@@ -61,6 +62,14 @@ const TIER_CONFIG_RELOAD_RETRY_CAP: Duration = Duration::from_secs(5);
const REMOTE_VERSION_STATE_PROBE_INTERVAL: Duration = Duration::from_secs(10);
const REMOTE_VERSION_STATE_PROBE_TIMEOUT: Duration = Duration::from_secs(5);
const REMOTE_VERSION_STATE_PROOF_TTL: Duration = Duration::from_secs(30);
/// Fallback poll cadence while startup has not yet published the notification
/// system. IAM finalization starts the probe before `init_notification_runtime`
/// runs, so the first pass always fails closed; waiting a full probe interval
/// there left a single node without its durable quota capability for ten
/// seconds after `/health` reported ok (rustfs/rustfs#8014). Publication wakes
/// the probe immediately through `NOTIFICATION_SYS_PUBLISHED`; this bound only
/// covers a wakeup that races the availability check.
const REMOTE_VERSION_STATE_PROBE_BOOTSTRAP_INTERVAL: Duration = Duration::from_millis(100);
const CROSS_POOL_FENCE_SUPPORTED_VERSION: u32 = 2;
const TIER_DELETE_JOURNAL_POLICY_SUPPORTED_VERSION: u32 = 3;
const DECOMMISSION_TARGET_FENCE_POLICY_SUPPORTED_VERSION: u32 = 4;
@@ -338,6 +347,9 @@ static LEGACY_TRANSITION_STATE_RECONCILE_FLEET_PROOF: OnceLock<std::sync::RwLock
static ILM_RECOVERY_EXPORT_FLEET_PROOF: OnceLock<std::sync::RwLock<FleetCapabilityProofState>> = OnceLock::new();
static TRANSITION_TRANSACTION_COMPACTION_FLEET_PROOF: OnceLock<std::sync::RwLock<FleetCapabilityProofState>> = OnceLock::new();
static REMOTE_VERSION_STATE_PROBE_TOPOLOGY: OnceLock<String> = OnceLock::new();
/// Signalled once `GLOBAL_NOTIFICATION_SYS` is published so the fleet probe can
/// run its first real pass without waiting for the bootstrap poll.
static NOTIFICATION_SYS_PUBLISHED: Notify = Notify::const_new();
static ILM_RECOVERY_EXPORT_LOCAL_PROCESS_EPOCH: LazyLock<Uuid> = LazyLock::new(Uuid::new_v4);
fn cross_pool_fence_fleet_proof_slot() -> &'static std::sync::RwLock<FleetCapabilityProofState> {
@@ -368,6 +380,24 @@ fn transition_transaction_compaction_fleet_proof_slot() -> &'static std::sync::R
TRANSITION_TRANSACTION_COMPACTION_FLEET_PROOF.get_or_init(|| std::sync::RwLock::new(FleetCapabilityProofState::default()))
}
fn all_fleet_capability_proof_slots() -> [&'static std::sync::RwLock<FleetCapabilityProofState>; 7] {
[
remote_version_state_fleet_proof_slot(),
cross_pool_fence_fleet_proof_slot(),
tier_delete_journal_fleet_proof_slot(),
decommission_target_fence_fleet_proof_slot(),
legacy_transition_state_reconcile_fleet_proof_slot(),
ilm_recovery_export_fleet_proof_slot(),
transition_transaction_compaction_fleet_proof_slot(),
]
}
fn revoke_all_fleet_capability_proofs() {
for slot in all_fleet_capability_proof_slots() {
revoke_fleet_capability_proof(slot);
}
}
fn revoke_fleet_capability_proof_state(state: &mut FleetCapabilityProofState) {
if let Some(proof) = state.proof.take() {
proof.generation.revoke();
@@ -453,6 +483,20 @@ pub(crate) fn acquire_remote_version_state_fleet_proof() -> Option<RemoteVersion
acquire_fleet_capability_proof_from(&state, expected_topology, Instant::now()).map(RemoteVersionStateFleetProofToken)
}
pub(crate) fn acquire_remote_version_state_writer_fleet_proof() -> Option<RemoteVersionStateFleetProofToken> {
let requested = rustfs_utils::get_env_bool(
rustfs_config::ENV_TIER_REMOTE_VERSION_STATE_WRITE,
rustfs_config::DEFAULT_TIER_REMOTE_VERSION_STATE_WRITE,
);
let fleet_confirmed = rustfs_utils::get_env_bool(
rustfs_config::ENV_TIER_REMOTE_VERSION_STATE_FLEET_CONFIRMED,
rustfs_config::DEFAULT_TIER_REMOTE_VERSION_STATE_FLEET_CONFIRMED,
);
(requested && fleet_confirmed)
.then(acquire_remote_version_state_fleet_proof)
.flatten()
}
fn acquire_fleet_capability_proof_from(
state: &FleetCapabilityProofState,
expected_topology: &str,
@@ -1212,15 +1256,7 @@ fn insert_remote_version_state_peer(peer_epochs: &mut BTreeMap<String, Uuid>, pe
pub fn start_remote_version_state_fleet_probe(topology_fingerprint: String) {
if REMOTE_VERSION_STATE_PROBE_TOPOLOGY.set(topology_fingerprint.clone()).is_err() {
if REMOTE_VERSION_STATE_PROBE_TOPOLOGY.get() != Some(&topology_fingerprint) {
for slot in [
remote_version_state_fleet_proof_slot(),
cross_pool_fence_fleet_proof_slot(),
tier_delete_journal_fleet_proof_slot(),
decommission_target_fence_fleet_proof_slot(),
legacy_transition_state_reconcile_fleet_proof_slot(),
ilm_recovery_export_fleet_proof_slot(),
transition_transaction_compaction_fleet_proof_slot(),
] {
for slot in all_fleet_capability_proof_slots() {
mark_fleet_capability_topology_conflict(slot);
}
}
@@ -1228,53 +1264,59 @@ pub fn start_remote_version_state_fleet_probe(topology_fingerprint: String) {
}
tokio::spawn(async move {
let mut notification_sys_unavailable_logged = false;
loop {
let notification_sys = get_global_notification_sys();
let remote_version_state_probe = async {
match notification_sys.as_ref() {
Some(notification_sys) => timeout(
REMOTE_VERSION_STATE_PROBE_TIMEOUT,
notification_sys.probe_remote_version_state_fleet(&topology_fingerprint),
)
.await
.unwrap_or_else(|_| Err(Error::other("remote version state fleet capability probe timed out"))),
None => Err(Error::other("remote version state fleet capability notification system is unavailable")),
let Some(notification_sys) = get_global_notification_sys() else {
// Startup publishes the notification system after this probe
// is started. Stay failed closed, but retry on the bootstrap
// cadence so a single node gains its capabilities as soon as
// the system appears instead of one full probe interval later.
revoke_all_fleet_capability_proofs();
if !notification_sys_unavailable_logged {
notification_sys_unavailable_logged = true;
debug!(
event = EVENT_NOTIFICATION_CAPABILITY_PROBE,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_NOTIFICATION,
state = "waiting_for_notification_system",
"notification capability probe"
);
}
let _ = timeout(REMOTE_VERSION_STATE_PROBE_BOOTSTRAP_INTERVAL, NOTIFICATION_SYS_PUBLISHED.notified()).await;
continue;
};
notification_sys_unavailable_logged = false;
let remote_version_state_probe = async {
timeout(
REMOTE_VERSION_STATE_PROBE_TIMEOUT,
notification_sys.probe_remote_version_state_fleet(&topology_fingerprint),
)
.await
.unwrap_or_else(|_| Err(Error::other("remote version state fleet capability probe timed out")))
};
let cross_pool_fence_probe = async {
match notification_sys.as_ref() {
Some(notification_sys) => timeout(
REMOTE_VERSION_STATE_PROBE_TIMEOUT,
notification_sys.probe_cross_pool_fence_fleet(&topology_fingerprint),
)
.await
.unwrap_or_else(|_| Err(Error::other("cross-pool fence fleet capability probe timed out"))),
None => Err(Error::other("cross-pool fence fleet capability notification system is unavailable")),
}
timeout(
REMOTE_VERSION_STATE_PROBE_TIMEOUT,
notification_sys.probe_cross_pool_fence_fleet(&topology_fingerprint),
)
.await
.unwrap_or_else(|_| Err(Error::other("cross-pool fence fleet capability probe timed out")))
};
let recovery_export_probe = async {
match notification_sys.as_ref() {
Some(notification_sys) => timeout(
REMOTE_VERSION_STATE_PROBE_TIMEOUT,
notification_sys.probe_ilm_recovery_export_fleet(&topology_fingerprint),
)
.await
.unwrap_or_else(|_| Err(Error::other("ILM recovery export fleet capability probe timed out"))),
None => Err(Error::other("ILM recovery export fleet capability notification system is unavailable")),
}
timeout(
REMOTE_VERSION_STATE_PROBE_TIMEOUT,
notification_sys.probe_ilm_recovery_export_fleet(&topology_fingerprint),
)
.await
.unwrap_or_else(|_| Err(Error::other("ILM recovery export fleet capability probe timed out")))
};
let transition_transaction_compaction_probe = async {
match notification_sys.as_ref() {
Some(notification_sys) => timeout(
REMOTE_VERSION_STATE_PROBE_TIMEOUT,
notification_sys.probe_transition_transaction_compaction_fleet(&topology_fingerprint),
)
.await
.unwrap_or_else(|_| Err(Error::other("transition transaction compaction fleet capability probe timed out"))),
None => Err(Error::other(
"transition transaction compaction fleet capability notification system is unavailable",
)),
}
timeout(
REMOTE_VERSION_STATE_PROBE_TIMEOUT,
notification_sys.probe_transition_transaction_compaction_fleet(&topology_fingerprint),
)
.await
.unwrap_or_else(|_| Err(Error::other("transition transaction compaction fleet capability probe timed out")))
};
let (result, fence_probe, recovery_export_result, transition_transaction_compaction_result) = tokio::join!(
remote_version_state_probe,
@@ -1299,13 +1341,7 @@ pub fn start_remote_version_state_fleet_probe(topology_fingerprint: String) {
.unwrap_or_else(std::sync::PoisonError::into_inner)
.topology_conflict;
if topology_conflict {
revoke_fleet_capability_proof(remote_version_state_fleet_proof_slot());
revoke_fleet_capability_proof(cross_pool_fence_fleet_proof_slot());
revoke_fleet_capability_proof(tier_delete_journal_fleet_proof_slot());
revoke_fleet_capability_proof(decommission_target_fence_fleet_proof_slot());
revoke_fleet_capability_proof(legacy_transition_state_reconcile_fleet_proof_slot());
revoke_fleet_capability_proof(ilm_recovery_export_fleet_proof_slot());
revoke_fleet_capability_proof(transition_transaction_compaction_fleet_proof_slot());
revoke_all_fleet_capability_proofs();
} else if let Some(err) = publish_fleet_capability_probe_result(
remote_version_state_fleet_proof_slot(),
&topology_fingerprint,
@@ -1428,9 +1464,11 @@ pub fn start_remote_version_state_fleet_probe(topology_fingerprint: String) {
}
pub async fn new_global_notification_sys(eps: EndpointServerPools) -> Result<()> {
let _ = GLOBAL_NOTIFICATION_SYS
.set(Arc::new(NotificationSys::new(eps).await))
.map_err(|_| Error::other("init notification_sys fail"));
if GLOBAL_NOTIFICATION_SYS.set(Arc::new(NotificationSys::new(eps).await)).is_ok() {
// `notify_one` stores a permit, so a probe that checks availability
// just before this publication still wakes without losing the signal.
NOTIFICATION_SYS_PUBLISHED.notify_one();
}
Ok(())
}
@@ -3905,6 +3943,49 @@ mod tests {
stats
}
/// rustfs/rustfs#8014: IAM finalization starts the fleet probe before the
/// notification system is published. The first probe therefore fails
/// closed, and a single node used to wait a full probe interval before the
/// durable quota capability appeared, even though `/health` was already
/// reporting ok. The probe must retry promptly while it waits for startup
/// to publish the notification system.
#[tokio::test]
async fn fleet_probe_publishes_promptly_once_the_notification_system_appears() {
if REMOTE_VERSION_STATE_PROBE_TOPOLOGY.get().is_some() || GLOBAL_NOTIFICATION_SYS.get().is_some() {
// Process-wide state was bound by another test in this binary. The
// startup ordering under test needs a fresh process; nextest
// (the authoritative runner) always provides one.
eprintln!("skipping: fleet probe globals already bound in this process");
return;
}
// Share the fingerprint every other proof-installing test in this crate
// binds, so the plain `cargo test` fallback cannot see two topologies.
start_remote_version_state_fleet_probe("object-transaction-fencing-test".to_string());
sleep(Duration::from_millis(300)).await;
assert!(
acquire_cross_pool_fence_fleet_proof().is_none(),
"no capability proof may exist before the notification system is published"
);
let published_at = Instant::now();
new_global_notification_sys(EndpointServerPools::default())
.await
.expect("single-node notification system initializes");
let observed = timeout(Duration::from_secs(2), async {
while acquire_cross_pool_fence_fleet_proof().is_none() {
sleep(Duration::from_millis(20)).await;
}
})
.await;
assert!(
observed.is_ok(),
"single-node cross-pool fence proof should publish within 2s of the notification system, waited {:?}",
published_at.elapsed()
);
}
#[test]
fn merging_peer_rings_sums_each_tier_without_double_counting() {
let mut cluster = DailyAllTierStats::from([("WARM".to_string(), ring(10))]);
@@ -7,8 +7,9 @@ use super::meta::{
validate_start_rebalance_state,
};
use super::worker::{
resolve_rebalance_bucket_result, resolve_rebalance_meta_save_result, resolve_rebalance_save_task_result,
resolve_rebalance_terminal_error, send_rebalance_done_signal,
rebalance_max_attempts, resolve_rebalance_bucket_result, resolve_rebalance_meta_save_result,
resolve_rebalance_save_task_result, resolve_rebalance_terminal_error, retry_rebalance_metadata_access,
send_rebalance_done_signal,
};
use super::{
EVENT_REBALANCE_BUCKET, EVENT_REBALANCE_STATE, LOG_COMPONENT_ECSTORE, LOG_SUBSYSTEM_REBALANCE,
@@ -449,13 +450,14 @@ impl ECStore {
};
if terminal_state_present {
if let Err(err) = store
.save_rebalance_stats_inner(
if let Err(err) = retry_rebalance_metadata_access(None, rebalance_max_attempts(), || {
store.save_rebalance_stats_inner(
pool_index,
RebalSaveOpt::Stats,
Some(save_rebalance_id.as_ref()),
)
.await
})
.await
{
let mut rebalance_meta = store.rebalance_meta.write().await;
*rebalance_meta = previous_meta;
@@ -475,9 +477,10 @@ impl ECStore {
}
if !terminal_state_saved
&& let Err(err) = store
.save_rebalance_stats_for_id(pool_index, RebalSaveOpt::Stats, save_rebalance_id.as_ref())
.await
&& let Err(err) = retry_rebalance_metadata_access(None, rebalance_max_attempts(), || {
store.save_rebalance_stats_for_id(pool_index, RebalSaveOpt::Stats, save_rebalance_id.as_ref())
})
.await
{
let wrapped = Error::other(format!("rebalance save_task stats save failed for pool {pool_index}: {err}"));
error!("{} err: {:?}", msg, wrapped);
@@ -109,7 +109,13 @@ pub(super) fn resolve_rebalance_save_task_result(
}
pub(super) fn resolve_rebalance_meta_save_result(result: Result<()>, stage: &str) -> Result<()> {
result.map_err(|err| Error::other(format!("rebalance meta save failed during {stage}: {err}")))
// Keep the source error reachable: the metadata retry policy classifies
// transient lock timeouts by inspecting the source chain, so collapsing the
// failure into a plain string here would make that retry a no-op.
result.map_err(|err| {
let rendered = format!("rebalance meta save failed during {stage}: {err}");
crate::data_movement::data_movement_context_error(rendered, err)
})
}
pub(super) fn rebalance_meta_lock_error(err: rustfs_lock::LockError, mode: &'static str) -> Error {
@@ -739,6 +745,29 @@ mod error_source_tests {
}
}
#[tokio::test]
async fn rebalance_metadata_retry_engages_for_wrapped_meta_save_lock_timeout() {
let mut attempts = 0;
let result = retry_rebalance_metadata_access(None, 3, || {
attempts += 1;
std::future::ready(resolve_rebalance_meta_save_result(
Err(rebalance_meta_lock_error(
rustfs_lock::LockError::timeout(".rustfs.sys/rebalance.bin@latest", Duration::from_secs(5)),
"write",
)),
"save_rebalance_stats for pool 0 opt Stats",
))
})
.await;
assert_eq!(attempts, 3, "a wrapped meta save lock timeout must stay retryable");
let err = result.expect_err("persistent lock contention must not become success");
assert!(matches!(
rebalance_error_source(&err),
Error::Lock(rustfs_lock::LockError::Timeout { .. })
));
}
#[tokio::test]
async fn rebalance_metadata_retry_cancels_a_pending_attempt() {
struct DropProbe(Arc<std::sync::atomic::AtomicUsize>);
@@ -37,7 +37,7 @@ use rustfs_s3_client::{
api_s3_datatypes::ListVersionsResult,
credentials::{Credentials, SignatureType, Static, Value},
provider_versions::validate_remote_version_id,
transition_api::{BucketLookupType, Options, TransitionClient, TransitionCore},
transition_api::{BucketLookupType, ObjectInfo, Options, TransitionClient, TransitionCore},
transition_api::{ReadCloser, ReaderImpl},
};
use rustfs_utils::egress::validate_outbound_url;
@@ -278,19 +278,7 @@ impl WarmBackendS3 {
let mut stat_opts = GetObjectOptions::default();
stat_opts.version_id.clone_from(&version.version_id);
let info = self.client.stat_object(&self.bucket, &remote_object, &stat_opts).await?;
let mut metadata = info.user_metadata;
for (name, value) in &info.metadata {
if (name
.as_str()
.starts_with(rustfs_utils::http::metadata_compat::RUSTFS_INTERNAL_PREFIX)
|| name
.as_str()
.starts_with(rustfs_utils::http::metadata_compat::MINIO_INTERNAL_PREFIX))
&& let Ok(value) = value.to_str()
{
metadata.insert(name.as_str().to_string(), value.to_string());
}
}
let metadata = transition_candidate_metadata(info);
if transition_candidate_metadata_matches(&metadata, identity)? {
if matched_version.is_some() {
return Ok(TransitionCandidateProbe::Ambiguous);
@@ -313,6 +301,53 @@ impl WarmBackendS3 {
advance_version_markers(&mut key_marker, &mut version_id_marker, &versions)?;
}
}
async fn probe_unversioned_transition_candidate_identity(
&self,
object: &str,
identity: TransitionCandidateIdentity,
) -> Result<TransitionCandidateProbe, std::io::Error> {
let remote_object = self.get_dest(object);
match self
.client
.stat_object(&self.bucket, &remote_object, &GetObjectOptions::default())
.await
{
Ok(info) => {
let metadata = transition_candidate_metadata(info);
if transition_candidate_metadata_matches(&metadata, identity)? {
Ok(TransitionCandidateProbe::UnversionedPresent)
} else {
Ok(TransitionCandidateProbe::Unsupported)
}
}
Err(err) => {
let response = to_error_response(&err);
if response.code == S3ErrorCode::NoSuchKey {
Ok(TransitionCandidateProbe::Missing)
} else {
Err(err)
}
}
}
}
}
fn transition_candidate_metadata(info: ObjectInfo) -> HashMap<String, String> {
let mut metadata = info.user_metadata;
for (name, value) in &info.metadata {
if (name
.as_str()
.starts_with(rustfs_utils::http::metadata_compat::RUSTFS_INTERNAL_PREFIX)
|| name
.as_str()
.starts_with(rustfs_utils::http::metadata_compat::MINIO_INTERNAL_PREFIX))
&& let Ok(value) = value.to_str()
{
metadata.insert(name.as_str().to_string(), value.to_string());
}
}
metadata
}
fn transition_candidate_metadata_matches(
@@ -525,6 +560,13 @@ mod tests {
}
async fn scripted_probe_fixture(responses: Vec<String>) -> Option<(WarmBackendS3, tokio::task::JoinHandle<Vec<String>>)> {
scripted_probe_fixture_for_tier(responses, "s3").await
}
async fn scripted_probe_fixture_for_tier(
responses: Vec<String>,
tier_type: &str,
) -> Option<(WarmBackendS3, tokio::task::JoinHandle<Vec<String>>)> {
let listener = match tokio::net::TcpListener::bind("127.0.0.1:0").await {
Ok(listener) => listener,
Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => return None,
@@ -571,7 +613,7 @@ mod tests {
max_retries: 1,
..Default::default()
},
"s3",
tier_type,
)
.await
.expect("fixture client should build"),
@@ -675,6 +717,37 @@ mod tests {
assert!(requests[8].to_ascii_lowercase().contains("?versionid=historical-version"));
}
#[tokio::test]
async fn unversioned_candidate_reconciler_uses_head_without_version_apis() {
let identity = candidate_identity();
let transaction_id = identity.transaction_id;
let destination_id = rustfs_utils::crypto::hex(identity.destination_id);
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nx-amz-meta-x-rustfs-internal-transition-transaction-id: {transaction_id}\r\nx-amz-meta-x-rustfs-internal-transition-tier-destination-id: {destination_id}\r\nConnection: close\r\n\r\n"
);
let Some((backend, fixture)) = scripted_probe_fixture_for_tier(vec![response], "r2").await else {
return;
};
assert_eq!(
crate::services::tier::warm_backend::TransitionCandidateReconciler::probe_transition_candidate_for(
&backend,
"archive/object",
identity,
)
.await
.expect("R2 candidate probe should use the current unversioned object"),
TransitionCandidateProbe::UnversionedPresent
);
let requests = fixture.await.expect("R2 candidate fixture should finish");
assert_eq!(requests.len(), 1);
let request = requests[0].to_ascii_lowercase();
assert!(request.starts_with("head /bucket/archive/object"));
assert!(!request.contains("versioning"));
assert!(!request.contains("versions"));
assert!(!request.contains("versionid="));
}
fn legacy_probe_xml_response(body: &str) -> String {
format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/xml\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
@@ -1089,6 +1162,10 @@ impl TransitionCandidateReconciler for WarmBackendS3 {
object: &str,
identity: TransitionCandidateIdentity,
) -> Result<TransitionCandidateProbe, std::io::Error> {
let capabilities = self.client.provider_version_capabilities();
if !capabilities.bucket_versioning_state && !capabilities.list_object_versions {
return self.probe_unversioned_transition_candidate_identity(object, identity).await;
}
let bucket_versioning = self.remote_bucket_versioning().await?;
self.probe_transition_candidate_identity(object, identity, bucket_versioning)
.await
+137
View File
@@ -874,6 +874,7 @@ mod ctx;
mod metadata;
mod ops;
pub(crate) use ops::bucket::BucketInfoQuorum;
pub(crate) use ops::heal::AbsenceProofRequest;
#[cfg(test)]
pub(crate) use ops::heal::DanglingDeleteFailure;
pub(crate) use ops::heal::HealedObjectAbsence;
@@ -3914,6 +3915,72 @@ pub struct SetDisks {
>,
}
fn marker_purge_receipt_path(
bucket: &str,
object: &str,
version: Uuid,
purge: &rustfs_common::mrf_channel::MrfDeleteMarkerPurge,
) -> String {
let object_hash = rustfs_utils::crypto::hex(Sha256::digest(object.as_bytes()));
let marker_hash = rustfs_utils::crypto::hex(purge.marker_identity);
format!(
"marker-purge-receipts/{}/{}/{}-{}-{}.json",
bucket, purge.bucket_incarnation_id, version, object_hash, marker_hash
)
}
impl SetDisks {
/// Persist a quorum DELETE receipt outside the object marker itself. The
/// receipt is written to the metadata bucket so it survives a node restart
/// while an inaccessible member still holds the stale marker.
pub(crate) async fn persist_marker_purge_receipt(
&self,
bucket: &str,
object: &str,
version: Uuid,
purge: &rustfs_common::mrf_channel::MrfDeleteMarkerPurge,
) -> bool {
let path = marker_purge_receipt_path(bucket, object, version, purge);
let api = Arc::new(self.clone());
crate::config::com::save_config_with_opts(
api,
&path,
b"rustfs-marker-purge-receipt-v1".to_vec(),
&ObjectOptions {
max_parity: true,
no_lock: true,
..Default::default()
},
)
.await
.is_ok()
}
pub(crate) async fn has_marker_purge_receipt(
&self,
bucket: &str,
object: &str,
version: Uuid,
purge: &rustfs_common::mrf_channel::MrfDeleteMarkerPurge,
) -> bool {
let path = marker_purge_receipt_path(bucket, object, version, purge);
crate::config::com::read_config_limited_preserve_empty(Arc::new(self.clone()), &path, 128)
.await
.is_ok()
}
pub(crate) async fn consume_marker_purge_receipt(
&self,
bucket: &str,
object: &str,
version: Uuid,
purge: &rustfs_common::mrf_channel::MrfDeleteMarkerPurge,
) {
let path = marker_purge_receipt_path(bucket, object, version, purge);
let _ = crate::config::com::delete_config_no_lock(Arc::new(self.clone()), &path).await;
}
}
/// Read every physical copy before selecting a version quorum. A minority
/// legacy record is still evidence and must not disappear behind a majority
/// not-found result. Only an explicit file/volume absence produces `None`;
@@ -6406,6 +6473,28 @@ async fn verify_inline_part_bitrot(meta: &FileInfo) -> disk::error::Result<()> {
.map_err(|_| DiskError::FileCorrupt)
}
fn validate_deep_scan_results(results: &[usize], expected_parts: usize) -> disk::error::Result<()> {
if results.len() != expected_parts {
return Err(DiskError::other(format!(
"incomplete deep scan result: expected {expected_parts} parts, received {}",
results.len()
)));
}
for (part, status) in results.iter().enumerate() {
match *status {
CHECK_PART_SUCCESS | CHECK_PART_FILE_NOT_FOUND | CHECK_PART_FILE_CORRUPT => {}
CHECK_PART_DISK_NOT_FOUND => return Err(DiskError::DiskNotFound),
crate::disk::CHECK_PART_VOLUME_NOT_FOUND => return Err(DiskError::VolumeNotFound),
_ => {
return Err(DiskError::other(format!(
"incomplete deep scan result: part {part} has unverified status {status}"
)));
}
}
}
Ok(())
}
/// disks_with_all_partsv2 is a corrected version based on Go implementation.
/// It sets partsMetadata and onlineDisks when xl.meta is inexistant/corrupted or outdated.
/// It also checks if the status of each part (corrupted, missing, ok) in each drive.
@@ -6626,9 +6715,13 @@ async fn disks_with_all_parts(
// it needs healing too.
match disk.verify_file(bucket, object, meta).await {
Ok(v) => {
validate_deep_scan_results(&v.results, latest_meta.parts.len())?;
verify_resp = v;
}
Err(err) => {
if !matches!(err, DiskError::FileNotFound | DiskError::FileVersionNotFound | DiskError::FileCorrupt) {
return Err(err);
}
debug!(
event = EVENT_SET_DISK_HEAL,
component = LOG_COMPONENT_ECSTORE,
@@ -11042,6 +11135,50 @@ mod tests {
assert_eq!(conv_part_err_to_int(&Some(other_err)), CHECK_PART_UNKNOWN); // Other errors should return UNKNOWN, not SUCCESS
}
#[test]
fn deep_scan_results_accept_only_complete_verified_or_repairable_parts() {
for (results, expected_parts) in [
(vec![], 0),
(vec![CHECK_PART_SUCCESS], 1),
(vec![CHECK_PART_FILE_NOT_FOUND], 1),
(vec![CHECK_PART_FILE_CORRUPT], 1),
(vec![CHECK_PART_SUCCESS, CHECK_PART_FILE_NOT_FOUND, CHECK_PART_FILE_CORRUPT], 3),
] {
validate_deep_scan_results(&results, expected_parts)
.expect("complete results must allow healthy or repairable parts");
}
}
#[test]
fn deep_scan_results_reject_unknown_and_incomplete_observations() {
for (results, expected_parts) in [
(vec![CHECK_PART_UNKNOWN], 1),
(vec![usize::MAX], 1),
(vec![CHECK_PART_SUCCESS, CHECK_PART_UNKNOWN], 2),
(vec![], 1),
(vec![CHECK_PART_SUCCESS], 2),
(vec![CHECK_PART_SUCCESS, CHECK_PART_SUCCESS], 1),
(vec![CHECK_PART_SUCCESS], 0),
] {
let error =
validate_deep_scan_results(&results, expected_parts).expect_err("unverified parts must not become healthy");
assert!(matches!(error, DiskError::Io(_)), "an incomplete observation is not proven corruption");
assert!(error.to_string().contains("incomplete deep scan result"));
}
}
#[test]
fn deep_scan_results_preserve_disk_and_volume_failures() {
for (status, expected_error) in [
(CHECK_PART_DISK_NOT_FOUND, DiskError::DiskNotFound),
(CHECK_PART_VOLUME_NOT_FOUND, DiskError::VolumeNotFound),
] {
let error = validate_deep_scan_results(&[CHECK_PART_SUCCESS, status], 2)
.expect_err("an unavailable part cannot certify a healthy disk");
assert_eq!(error, expected_error);
}
}
#[test]
fn test_has_part_err() {
// Test checking for part errors
@@ -141,6 +141,7 @@ pub(in crate::set_disk::ops) async fn verify_written_bitrot_shards(
mod tests {
use super::super::object::hermetic_set_disks_support::hermetic_set_disks_for_pool_with_default_parity;
use super::*;
use crate::storage_api_contracts::object::ObjectIO as _;
async fn encode_streaming_shard(data: &[u8], shard_size: usize) -> Bytes {
let mut writer = coding::BitrotWriter::new(Cursor::new(Vec::new()), shard_size, HashAlgorithm::HighwayHash256S);
@@ -295,4 +296,19 @@ mod tests {
assert!(err.to_string().contains("trailing data"));
}
}
#[tokio::test]
async fn no_parity_put_round_trip_large_stream() {
let (_temp_dirs, disks, set_disks) = hermetic_set_disks_for_pool_with_default_parity(1, 0, 0).await;
let bucket = "no-parity-put-round-trip";
for disk in &disks {
disk.make_volume(bucket).await.expect("bucket volume should be created");
}
let payload = vec![0x5a; 13 * 1024 * 1024 + 727_213];
let mut reader = crate::set_disk::PutObjReader::from_vec(payload);
set_disks
.put_object(bucket, "object", &mut reader, &crate::set_disk::ObjectOptions::default())
.await
.expect("single-disk no-parity PUT should pass self-verify");
}
}
+837 -25
View File
@@ -38,6 +38,7 @@ use crate::disk::DiskAPI;
use crate::disk::local::{DELETE_DATA_DIR_MARKER_PREFIX, metadata_less_part_file};
use crate::io_support::bitrot::object_mmap_read_enabled;
use crate::storage_api_contracts::namespace::NamespaceLocking as _;
use rustfs_common::mrf_channel::MrfDeleteMarkerPurge;
use rustfs_common::trace_bus::{TraceEvent, TraceFunc, TraceKind, trace_emit};
use tracing::trace;
@@ -55,6 +56,13 @@ pub(crate) struct HealedObjectAbsence {
pub removed: bool,
}
/// Conditions under which a missing object may become a storage-owned proof.
#[derive(Clone, Copy)]
pub(crate) struct AbsenceProofRequest<'a> {
pub retirement: Option<&'a MarkerRetirementContext<'a>>,
pub allow_unversioned: bool,
}
fn is_unavailable_heal_rpc(error: &DiskError) -> bool {
let DiskError::Io(error) = error else {
return false;
@@ -846,7 +854,7 @@ impl SetDisks {
);
let disks = self.get_disks_internal().await;
let bucket_heal_scope = crate::store::bucket_heal_scope(bucket);
let bucket_heal_scope = crate::store::bucket_heal_scope_for_object(bucket, object);
if let Some(scope) = &bucket_heal_scope {
scope.check()?;
}
@@ -971,12 +979,25 @@ impl SetDisks {
result.parity_blocks = result.disk_count - read_quorum as usize;
result.data_blocks = read_quorum as usize;
let ((mut online_disks, quorum_mod_time, quorum_etag), disk_len) = {
let ((quorum_disks, quorum_mod_time, quorum_etag), disk_len) = {
let disks = self.disks.read().await;
let disk_len = disks.len();
(Self::list_online_disks(&disks, &parts_metadata, &errs, read_quorum as usize), disk_len)
};
// A deep heal must inspect every reachable member, not only the
// quorum that agreed on the canonical metadata. A returning disk
// can legitimately carry an older xl.meta (or an explicit-version
// absence) while still being the exact stale target that needs
// repair. Restricting the repair set to quorum members silently
// turns that stale member into `verified_healthy`/`unchanged`
// (backlog#2612, #2596). Normal scans retain the quorum fast path.
let mut online_disks = if opts.scan_mode == HealScanMode::Deep {
self.get_disks_internal().await
} else {
quorum_disks
};
trace!(
event = EVENT_SET_DISK_HEAL,
component = LOG_COMPONENT_ECSTORE,
@@ -1449,7 +1470,12 @@ impl SetDisks {
})
.transpose()
.map_err(DiskError::from)?;
let till_offset = erasure.shard_file_offset(0, part.size, part.size);
// This reader covers the whole encoded shard. A range
// offset can exclude the final encoded byte when the
// logical part ends inside a data block, allowing a
// one-byte tail truncation to evade deep Heal.
let till_offset = usize::try_from(erasure.shard_file_size(part.size as i64))
.map_err(|_| DiskError::FileCorrupt)?;
let use_mmap_read = object_mmap_read_enabled();
let mut readers = Vec::with_capacity(latest_disks.len());
@@ -1887,13 +1913,26 @@ impl SetDisks {
.zip(&errs)
.any(|(info, error)| error.is_none() && info.is_canonical_delete_marker())
{
let cleanup = match Self::retired_marker_candidate(&parts_metadata, &errs) {
Ok(marker) => {
self.remove_retired_marker(retirement, bucket, object, marker, opts, &disks)
.await
}
Err(error) => Err(error),
};
// A current-generation delete marker can be left behind on
// an inaccessible member after an acknowledged S3 DELETE.
// That case has no bucket-retirement record, so evaluate its
// stricter same-incarnation proof before the retired-marker
// path. The proof requires an exact marker identity, at
// least one replica already absent, and every target online.
let cleanup =
match Self::same_incarnation_marker_candidate(&parts_metadata, &errs, retirement.current_incarnation) {
Ok(marker) => {
self.remove_current_marker(retirement, bucket, object, marker, opts, &disks)
.await
}
Err(_) => match Self::retired_marker_candidate(&parts_metadata, &errs) {
Ok(marker) => {
self.remove_retired_marker(retirement, bucket, object, marker, opts, &disks)
.await
}
Err(error) => Err(error),
},
};
let absent = cleanup.is_ok();
let item = self
.dangling_heal_result(FileInfo::default(), &errs, bucket, object, version_id, absent)
@@ -2007,6 +2046,41 @@ impl SetDisks {
candidate.ok_or_else(|| DiskError::retired_marker_deferred("no exact marker candidate"))
}
fn same_incarnation_marker_candidate(
metadata: &[FileInfo],
errors: &[Option<DiskError>],
current_incarnation: Option<Uuid>,
) -> disk::error::Result<rustfs_filemeta::MetaDeleteMarker> {
let defer = DiskError::retired_marker_deferred;
let Some(current) = current_incarnation.filter(|id| !id.is_nil()) else {
return Err(defer("current bucket incarnation is unavailable"));
};
let mut candidate = None;
let mut missing_replica = false;
for (info, error) in metadata.iter().zip(errors) {
match error {
Some(DiskError::FileNotFound | DiskError::FileVersionNotFound) => {
missing_replica = true;
continue;
}
Some(_) => return Err(defer("not every replica is readable")),
None => {}
}
if !info.is_canonical_delete_marker() || info.delete_marker_incarnation() != Some(current) {
return Err(defer("marker is not a canonical current-generation marker"));
}
let marker = rustfs_filemeta::MetaDeleteMarker::from(info.clone());
if candidate.as_ref().is_some_and(|previous| previous != &marker) {
return Err(defer("surviving marker identities conflict"));
}
candidate = Some(marker);
}
if !missing_replica {
return Err(defer("marker is present on every replica"));
}
candidate.ok_or_else(|| defer("no exact current-generation marker candidate"))
}
async fn remove_retired_marker(
&self,
context: &MarkerRetirementContext<'_>,
@@ -2045,6 +2119,72 @@ impl SetDisks {
if context.lifecycle_guard.is_lock_lost() {
return Err(defer("bucket lifecycle fence was lost"));
}
let result = self
.remove_marker_exact_under_fence(bucket, object, marker, version, disks)
.await;
if context.lifecycle_guard.is_lock_lost() {
return Err(defer("bucket lifecycle fence was lost"));
}
result
}
async fn remove_current_marker(
&self,
context: &MarkerRetirementContext<'_>,
bucket: &str,
object: &str,
marker: rustfs_filemeta::MetaDeleteMarker,
opts: &HealOpts,
disks: &[Option<DiskStore>],
) -> disk::error::Result<()> {
let defer = DiskError::retired_marker_deferred;
if opts.dry_run || !opts.remove {
return Err(defer("cleanup requires remove=true and dry_run=false"));
}
if disks.len() != self.set_drive_count || disks.iter().any(Option::is_none) {
return Err(defer("every target disk must be online"));
}
let Some(current) = context.current_incarnation.filter(|id| !id.is_nil()) else {
return Err(defer("current bucket incarnation is unavailable"));
};
if context.lifecycle_guard.is_lock_lost() {
return Err(defer("bucket lifecycle fence was lost"));
}
let incarnation = marker.into_fileinfo(bucket, object, false)?.delete_marker_incarnation();
if incarnation != Some(current) {
return Err(defer("marker is not from the current bucket incarnation"));
}
let Some(version) = marker.version_id.filter(|id| !id.is_nil()) else {
return Err(defer("cleanup requires an explicit non-null version"));
};
let marker_identity = marker.stable_identity();
let marker_bytes = marker.marshal_msg().map_err(DiskError::other)?;
let Some(purge) = MrfDeleteMarkerPurge::new(current, current, marker_identity, marker_bytes) else {
return Err(defer("marker purge proof payload is invalid"));
};
if !self.has_marker_purge_receipt(bucket, object, version, &purge).await {
return Err(defer("same-incarnation marker lacks an acknowledged delete receipt"));
}
let result = self
.remove_marker_exact_under_fence(bucket, object, marker, version, disks)
.await;
if context.lifecycle_guard.is_lock_lost() {
return Err(defer("bucket lifecycle fence was lost"));
}
if result.is_ok() {
self.consume_marker_purge_receipt(bucket, object, version, &purge).await;
}
result
}
async fn remove_marker_exact_under_fence(
&self,
bucket: &str,
object: &str,
marker: rustfs_filemeta::MetaDeleteMarker,
version: Uuid,
disks: &[Option<DiskStore>],
) -> disk::error::Result<()> {
let request = FileInfo {
volume: bucket.to_owned(),
name: object.to_owned(),
@@ -2085,13 +2225,14 @@ impl SetDisks {
let same_targets = selected.len() == disks.len() && selected.iter().zip(disks).all(|(current, original)| {
matches!((current, original), (Some(current), Some(original)) if std::sync::Arc::ptr_eq(current, original))
});
if context.lifecycle_guard.is_lock_lost()
|| !same_targets
if !same_targets
|| !errors
.iter()
.all(|error| matches!(error, Some(DiskError::FileNotFound | DiskError::FileVersionNotFound)))
{
return Err(defer("complete marker absence could not be verified under the original fence"));
return Err(DiskError::retired_marker_deferred(
"complete marker absence could not be verified under the original target set",
));
}
self.invalidate_get_object_metadata_cache(bucket, object).await;
Ok(())
@@ -3012,8 +3153,18 @@ impl crate::storage_api_contracts::heal::HealOperations for SetDisks {
version_id: &str,
opts: &HealOpts,
) -> Result<(HealResultItem, Option<Error>)> {
self.heal_object_with_absence(bucket, object, version_id, opts, &mut None)
.await
self.heal_object_with_absence(
bucket,
object,
version_id,
opts,
&mut None,
AbsenceProofRequest {
retirement: None,
allow_unversioned: false,
},
)
.await
}
#[tracing::instrument(skip(self))]
@@ -3098,8 +3249,9 @@ impl SetDisks {
version_id: &str,
opts: &HealOpts,
absence: &mut Option<HealedObjectAbsence>,
proof: AbsenceProofRequest<'_>,
) -> Result<(HealResultItem, Option<Error>)> {
self.heal_object_with_retirement(bucket, object, version_id, opts, absence, None)
self.heal_object_with_retirement(bucket, object, version_id, opts, absence, proof)
.await
}
@@ -3110,7 +3262,7 @@ impl SetDisks {
version_id: &str,
opts: &HealOpts,
absence: &mut Option<HealedObjectAbsence>,
retirement: Option<&MarkerRetirementContext<'_>>,
proof: AbsenceProofRequest<'_>,
) -> Result<(HealResultItem, Option<Error>)> {
*absence = None;
let _write_lock_guard = if !opts.no_lock {
@@ -3176,7 +3328,7 @@ impl SetDisks {
.await;
// Check the lease after the final await before publishing the proof.
if !opts.dry_run
&& !version_id.is_empty()
&& (proof.allow_unversioned || !version_id.is_empty())
&& !disks.is_empty()
&& disks.iter().all(Option::is_some)
&& errs
@@ -3205,7 +3357,7 @@ impl SetDisks {
ExplicitVersionHeal {
opts: &inner_opts,
allow_regeneration: true,
retirement,
retirement: proof.retirement,
},
absence,
)
@@ -3225,7 +3377,7 @@ impl SetDisks {
ExplicitVersionHeal {
opts: &inner_opts,
allow_regeneration: true,
retirement,
retirement: proof.retirement,
},
absence,
)
@@ -3277,7 +3429,7 @@ impl SetDisks {
#[cfg(test)]
mod heal_result_report_tests {
use super::{
DanglingCheckPartsFailure, DanglingDeleteFailure, DanglingDeleteSafety, ReadRepairCommitFingerprint,
AbsenceProofRequest, DanglingCheckPartsFailure, DanglingDeleteFailure, DanglingDeleteSafety, ReadRepairCommitFingerprint,
ReadRepairPauseScope, SetDisks, heal_writer_error_summary,
};
use super::{HEAL_RENAME_INCOMPLETE, HealRenameFailureScope, HealWriterFailureScope};
@@ -3288,7 +3440,9 @@ mod heal_result_report_tests {
use crate::disk::{DiskAPI as _, DiskOption, DiskStore, RUSTFS_META_TMP_BUCKET, ReadOptions, STORAGE_FORMAT_FILE, new_disk};
use crate::error::Error;
use crate::object_api::{ObjectOptions, PutObjReader};
use crate::set_disk::ops::object::hermetic_set_disks_support::hermetic_set_disks_isolated;
use crate::set_disk::ops::object::hermetic_set_disks_support::{
hermetic_set_disks_for_pool_with_default_parity_isolated, hermetic_set_disks_isolated,
};
use crate::storage_api_contracts::bucket::{BucketOperations as _, DeleteBucketOptions, MakeBucketOptions};
use crate::storage_api_contracts::heal::HealOperations as _;
use crate::storage_api_contracts::object::{ObjectIO as _, ObjectOperations as _};
@@ -4069,6 +4223,634 @@ mod heal_result_report_tests {
);
}
#[tokio::test]
async fn deep_heal_rebuilds_a_stale_current_delete_marker_metadata_replica() {
let (_temp_dirs, disks, set) = hermetic_set_disks_isolated(4).await;
let bucket = "deep-heal-stale-current-marker";
let object = "object.bin";
for disk in &disks {
disk.make_volume(bucket).await.expect("test bucket should be created");
}
let versioned = ObjectOptions {
no_lock: true,
versioned: true,
..Default::default()
};
let original = set
.put_object(bucket, object, &mut PutObjReader::from_vec(vec![0x41; 1024]), &versioned)
.await
.expect("versioned source object should be written");
let stale_metadata = disks[0]
.read_version("", bucket, object, "", &ReadOptions::default())
.await
.expect("pre-marker metadata should be readable");
let marker = set
.delete_object(bucket, object, versioned)
.await
.expect("versioned delete should create a current marker");
let marker_version = marker.version_id.expect("delete marker should have a version id");
// Simulate a member that was offline while the acknowledged marker was
// committed on the other members: its physical root still contains the
// pre-delete metadata and the original data version.
let mut stale_metadata = stale_metadata;
stale_metadata.fresh = true;
disks[0]
.write_metadata("", bucket, object, stale_metadata)
.await
.expect("stale member metadata should be restored for the fixture");
assert_eq!(
disks[0]
.read_version("", bucket, object, &marker_version.to_string(), &ReadOptions::default())
.await
.expect_err("stale member must not contain the current marker")
.to_string(),
"file version not found"
);
let (result, error) = set
.heal_object(
bucket,
object,
&marker_version.to_string(),
&HealOpts {
no_lock: true,
scan_mode: HealScanMode::Deep,
..Default::default()
},
)
.await
.expect("deep heal should report the stale marker result");
assert!(error.is_none(), "deep heal should repair stale marker metadata: {error:?}");
assert_eq!(result.drives_healed(), Some(1));
let repaired = disks[0]
.read_version("", bucket, object, &marker_version.to_string(), &ReadOptions::default())
.await
.expect("rejoined member should contain the marker");
assert!(repaired.deleted, "repaired metadata must remain a delete marker");
assert_eq!(repaired.version_id, Some(marker_version));
assert!(original.version_id.is_some(), "source object should have a version id");
}
#[tokio::test]
async fn deep_heal_rebuilds_near_tail_truncated_part() {
let (temp_dirs, disks, set) = hermetic_set_disks_isolated(4).await;
let bucket = "deep-heal-near-tail-truncation";
let object = "object.bin";
for disk in &disks {
disk.make_volume(bucket).await.expect("bucket volume should be created");
}
let expected_payload = vec![0x6d; 1024 * 1024];
set.put_object(
bucket,
object,
&mut PutObjReader::from_vec(expected_payload.clone()),
&ObjectOptions {
no_lock: true,
..Default::default()
},
)
.await
.expect("source object should be written before shard truncation");
let source = disks[2]
.read_version("", bucket, object, "", &ReadOptions::default())
.await
.expect("source metadata should be readable");
let data_dir = source.data_dir.expect("non-inline source should have a data directory");
let truncated_part = temp_dirs[1]
.path()
.join(bucket)
.join(object)
.join(data_dir.to_string())
.join("part.1");
let original_len = tokio::fs::metadata(&truncated_part)
.await
.expect("target shard should exist")
.len();
assert!(original_len > 1, "test shard must be large enough to truncate");
let file = tokio::fs::OpenOptions::new()
.write(true)
.open(&truncated_part)
.await
.expect("target shard should be writable");
file.set_len(original_len - 1)
.await
.expect("target shard should be truncated");
let mut reader = set
.get_object_reader(bucket, object, None, Default::default(), &ObjectOptions::default())
.await
.expect("GET should remain readable after a one-byte shard truncation");
let mut read_back = Vec::new();
tokio::io::copy(&mut reader, &mut read_back)
.await
.expect("GET should reconstruct the truncated shard through EC");
assert_eq!(read_back, expected_payload, "EC GET must preserve the object bytes");
let (result, error) = set
.heal_object(
bucket,
object,
"",
&HealOpts {
no_lock: true,
scan_mode: HealScanMode::Deep,
..Default::default()
},
)
.await
.expect("deep heal should finish after a one-byte tail truncation");
assert!(error.is_none(), "deep heal should recover the truncated shard: {error:?}");
assert_eq!(result.drives_healed(), Some(1));
assert_eq!(result.before.drives[1].state, DriveState::Corrupt.to_string());
assert_eq!(
tokio::fs::metadata(&truncated_part)
.await
.expect("repaired shard should exist")
.len(),
original_len,
"deep heal must restore the complete shard length"
);
}
#[derive(Debug)]
struct FailedIntegrityReadTransport {
error: DiskError,
reads: std::sync::atomic::AtomicUsize,
}
#[async_trait::async_trait]
impl crate::cluster::rpc::internode_data_transport::InternodeDataTransport for FailedIntegrityReadTransport {
async fn open_read(
&self,
_request: crate::cluster::rpc::internode_data_transport::ReadStreamRequest,
) -> crate::disk::error::Result<crate::disk::FileReader> {
self.reads.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let error = match &self.error {
DiskError::FileCorrupt => DiskError::from(rustfs_rio::new_test_remote_file_corrupt_http_io_error()),
DiskError::FileNotFound => DiskError::from(rustfs_rio::new_test_remote_file_not_found_http_io_error()),
DiskError::VolumeNotFound => DiskError::from(rustfs_rio::new_test_remote_volume_not_found_http_io_error()),
error => error.clone(),
};
Err(error)
}
async fn open_write(
&self,
_request: crate::cluster::rpc::internode_data_transport::WriteStreamRequest,
) -> crate::disk::error::Result<crate::disk::FileWriter> {
panic!("an integrity scan must not write remote data");
}
async fn open_walk_dir(
&self,
_request: crate::cluster::rpc::internode_data_transport::WalkDirStreamRequest,
) -> crate::disk::error::Result<crate::disk::FileReader> {
panic!("an object integrity scan must not walk remote directories");
}
fn name(&self) -> &'static str {
"failed-integrity-read"
}
fn capabilities(&self) -> crate::cluster::rpc::internode_data_transport::InternodeDataTransportCapabilities {
crate::cluster::rpc::internode_data_transport::InternodeDataTransportCapabilities::tcp_http()
}
}
async fn assert_remote_integrity_read_failure(expected_error: DiskError) {
use crate::disk::{CHECK_PART_FILE_CORRUPT, CHECK_PART_FILE_NOT_FOUND, CHECK_PART_SUCCESS, CHECK_PART_UNKNOWN};
use crate::object_api::ShardIntegrityWriteMode;
let (dirs, disks, set) = hermetic_set_disks_for_pool_with_default_parity_isolated(4, 0, 2).await;
let bucket = "remote-integrity-read-failure";
let object = "object.bin";
for disk in &disks {
disk.make_volume(bucket).await.expect("create fixture bucket");
}
set.put_object(
bucket,
object,
&mut PutObjReader::from_vec(vec![0x6d; 1024 * 1024 + 123]),
&ObjectOptions {
shard_integrity_write_mode: Some(ShardIntegrityWriteMode::Protected),
no_lock: true,
..Default::default()
},
)
.await
.expect("write protected external fixture");
let mut files = Vec::new();
let mut snapshots = Vec::new();
for (dir, disk) in dirs.iter().zip(&disks) {
let file = disk
.read_version("", bucket, object, "", &ReadOptions::default())
.await
.expect("read fixture metadata");
assert!(file.data.is_none(), "the remote test must open an external shard");
let object_dir = dir.path().join(bucket).join(object);
let part_path = object_dir
.join(file.data_dir.expect("external shard directory").to_string())
.join("part.1");
for path in [part_path, object_dir.join("xl.meta")] {
let bytes = tokio::fs::read(&path).await.expect("snapshot fixture before scan");
snapshots.push((path, bytes));
}
files.push(file);
}
let transport = Arc::new(FailedIntegrityReadTransport {
error: expected_error.clone(),
reads: std::sync::atomic::AtomicUsize::new(0),
});
let endpoint = Endpoint {
url: url::Url::parse("http://remote-integrity.invalid:9000/data/disk0").expect("fixture endpoint"),
is_local: false,
pool_idx: 0,
set_idx: 0,
disk_idx: 0,
};
let remote = crate::cluster::rpc::RemoteDisk::new(
&endpoint,
&DiskOption {
cleanup: false,
health_check: false,
},
transport.clone(),
)
.await
.expect("construct remote disk with failing data transport");
let mut verification_disks: Vec<_> = disks.iter().cloned().map(Some).collect();
verification_disks[0] = Some(Arc::new(crate::disk::Disk::Remote(Box::new(remote))));
let mut statuses = [(0, vec![CHECK_PART_UNKNOWN; disks.len()])].into_iter().collect();
let result = crate::io_support::shard_integrity::verify_deep_parts(
&files,
&verification_disks,
&files[0],
bucket,
object,
&mut statuses,
)
.await;
assert!(transport.reads.load(std::sync::atomic::Ordering::Relaxed) > 0);
if matches!(expected_error, DiskError::FileCorrupt | DiskError::FileNotFound) {
result.expect("typed remote payload damage must identify a repairable shard");
let expected_status = if expected_error == DiskError::FileCorrupt {
CHECK_PART_FILE_CORRUPT
} else {
CHECK_PART_FILE_NOT_FOUND
};
assert_eq!(statuses[&0][0], expected_status);
assert!(statuses[&0][1..].iter().all(|status| *status == CHECK_PART_SUCCESS));
let (heal, metadata, reason) = super::should_heal_object_on_disk(&None, &[statuses[&0][0]], &files[0], &files[0]);
assert!(heal, "remote payload damage must schedule repair: {expected_error:?}");
assert!(!metadata);
let expected_reason = if expected_error == DiskError::FileCorrupt {
DiskError::FileCorrupt
} else {
DiskError::PartMissingOrCorrupt
};
assert_eq!(reason, Some(expected_reason));
} else {
let error = result.expect_err("unavailable payload storage must abort health verification");
if expected_error.is_internode_http_status(500) {
assert!(error.is_internode_http_status(500), "preserve the original transport error: {error:?}");
} else {
assert_eq!(error, expected_error, "preserve the original storage failure");
}
assert_eq!(statuses[&0][0], CHECK_PART_UNKNOWN, "failed verification cannot certify the shard");
}
for (path, before) in snapshots {
assert_eq!(tokio::fs::read(path).await.expect("read fixture after scan"), before);
}
}
#[tokio::test]
async fn deep_scan_remote_payload_damage_schedules_repair() {
for error in [DiskError::FileCorrupt, DiskError::FileNotFound] {
assert_remote_integrity_read_failure(error).await;
}
}
#[tokio::test]
async fn deep_scan_remote_storage_failure_cannot_certify_healthy() {
for error in [
DiskError::from(rustfs_rio::new_test_internode_http_io_error(
rustfs_rio::InternodeHttpErrorKind::HttpStatus(reqwest::StatusCode::INTERNAL_SERVER_ERROR),
)),
DiskError::DiskNotFound,
DiskError::VolumeNotFound,
DiskError::FileAccessDenied,
] {
assert_remote_integrity_read_failure(error).await;
}
}
#[cfg(unix)]
#[tokio::test]
async fn deep_heal_legacy_verification_error_cannot_certify_healthy() {
use crate::object_api::ShardIntegrityWriteMode;
let (dirs, disks, set) = hermetic_set_disks_isolated(4).await;
let bucket = "legacy-deep-scan-verification-error";
let object = "object.bin";
for disk in &disks {
disk.make_volume(bucket).await.expect("create legacy fixture bucket");
}
set.put_object(
bucket,
object,
&mut PutObjReader::from_vec(vec![0x6d; 1024 * 1024 + 123]),
&ObjectOptions {
shard_integrity_write_mode: Some(ShardIntegrityWriteMode::Legacy),
no_lock: true,
..Default::default()
},
)
.await
.expect("write external legacy fixture");
let mut files = Vec::new();
let mut part_paths = Vec::new();
let mut preserved = Vec::new();
for (index, (dir, disk)) in dirs.iter().zip(&disks).enumerate() {
let file = disk
.read_version("", bucket, object, "", &ReadOptions::default())
.await
.expect("read legacy fixture metadata");
assert!(file.data.is_none(), "legacy verification must open the physical shard");
assert!(file.parts.iter().all(|part| part.integrity.is_none()));
let object_dir = dir.path().join(bucket).join(object);
let part = object_dir
.join(file.data_dir.expect("external legacy shard directory").to_string())
.join("part.1");
let metadata = object_dir.join("xl.meta");
let bytes = tokio::fs::read(&metadata).await.expect("snapshot legacy metadata");
preserved.push((metadata, bytes));
if index != 0 {
let bytes = tokio::fs::read(&part).await.expect("snapshot healthy legacy shard");
preserved.push((part.clone(), bytes));
}
part_paths.push(part);
files.push(file);
}
let broken_part = &part_paths[0];
tokio::fs::remove_file(broken_part)
.await
.expect("remove fixture shard before symlink fault");
std::os::unix::fs::symlink("part.1", broken_part).expect("create self-referential shard symlink");
let open_error = tokio::fs::File::open(broken_part)
.await
.expect_err("self-referential symlink must fail to open");
assert_eq!(open_error.raw_os_error(), Some(libc::ELOOP));
let verification_error = disks[0]
.verify_file(bucket, object, &files[0])
.await
.expect_err("legacy disk verifier must reject the symlink path");
assert_eq!(verification_error, DiskError::InvalidPath);
let error = set
.heal_object(
bucket,
object,
"",
&HealOpts {
no_lock: true,
scan_mode: HealScanMode::Deep,
..Default::default()
},
)
.await
.expect_err("an unverified legacy part must fail the complete heal operation");
assert_eq!(error, DiskError::InvalidPath, "complete heal must preserve the verifier's path error");
assert_eq!(
tokio::fs::read_link(broken_part)
.await
.expect("failed heal must preserve the symlink fault"),
std::path::Path::new("part.1")
);
for (path, before) in preserved {
assert_eq!(tokio::fs::read(path).await.expect("read unchanged legacy fixture"), before);
}
}
async fn assert_h2_truncated_shards(
mode: crate::object_api::ShardIntegrityWriteMode,
coding_indexes: &[usize],
retained_len: fn(usize, usize) -> usize,
) {
use crate::object_api::ShardIntegrityWriteMode;
let (temp_dirs, disks, set) = hermetic_set_disks_for_pool_with_default_parity_isolated(16, 0, 4).await;
let bucket = "deep-heal-h2-truncation";
let object = "object.bin";
for disk in &disks {
disk.make_volume(bucket).await.expect("create bucket volume");
}
let expected_payload: Vec<_> = (0..5 * 1024 * 1024 + 123)
.map(|index| u8::try_from((index / 4096 + index) % 251).expect("payload byte fits"))
.collect();
set.put_object(
bucket,
object,
&mut PutObjReader::from_vec(expected_payload.clone()),
&ObjectOptions {
shard_integrity_write_mode: Some(mode),
no_lock: true,
..Default::default()
},
)
.await
.expect("commit all source shards");
let mut files = Vec::new();
let mut paths = Vec::new();
let mut original_shards = Vec::new();
let mut original_metadata = Vec::new();
let mut damaged = Vec::new();
for (slot, disk) in disks.iter().enumerate() {
let source = disk
.read_version("", bucket, object, "", &ReadOptions::default())
.await
.expect("read source metadata");
assert_eq!((source.erasure.data_blocks, source.erasure.parity_blocks), (12, 4));
assert_eq!(source.parts[0].integrity.is_some(), mode == ShardIntegrityWriteMode::Protected);
let directory = temp_dirs[slot].path().join(bucket).join(object);
let path = directory
.join(source.data_dir.expect("external shard data directory").to_string())
.join("part.1");
let original = tokio::fs::read(&path).await.expect("read original shard");
original_metadata.push(tokio::fs::read(directory.join("xl.meta")).await.expect("read xl.meta"));
if coding_indexes.contains(&source.erasure.index) {
let erasure = crate::erasure::coding::Erasure::try_new_with_options(
12,
4,
source.erasure.block_size,
source.uses_legacy_checksum,
)
.expect("valid erasure layout");
let stripe_size = erasure.shard_size() + source.erasure.get_checksum_info(1).algorithm.size();
let keep = retained_len(original.len(), stripe_size);
assert!(keep < original.len(), "fixture must truncate the shard");
tokio::fs::OpenOptions::new()
.write(true)
.open(&path)
.await
.expect("open target shard")
.set_len(u64::try_from(keep).expect("shard length fits"))
.await
.expect("truncate target shard");
damaged.push((slot, keep));
}
paths.push(path);
original_shards.push(original);
files.push(source);
}
assert_eq!(damaged.len(), coding_indexes.len());
if mode == ShardIntegrityWriteMode::Protected {
let mut statuses = [(0, vec![crate::disk::CHECK_PART_UNKNOWN; disks.len()])]
.into_iter()
.collect();
let online_disks: Vec<_> = disks.iter().cloned().map(Some).collect();
crate::io_support::shard_integrity::verify_deep_parts(
&files,
&online_disks,
&files[0],
bucket,
object,
&mut statuses,
)
.await
.expect("verify protected shards directly");
for (slot, status) in statuses[&0].iter().enumerate() {
let expected = if damaged.iter().any(|(index, _)| *index == slot) {
crate::disk::CHECK_PART_FILE_CORRUPT
} else {
crate::disk::CHECK_PART_SUCCESS
};
assert_eq!(*status, expected, "protected shard {slot}");
}
}
let opts = HealOpts {
no_lock: true,
scan_mode: HealScanMode::Deep,
..Default::default()
};
let (dry_run, error) = set
.heal_object(bucket, object, "", &HealOpts { dry_run: true, ..opts })
.await
.expect("dry-run should report corruption");
assert!(error.is_none(), "dry-run error: {error:?}");
assert_eq!(dry_run.drives_healed(), Some(0));
for (slot, keep) in &damaged {
assert_eq!(dry_run.after.drives[*slot].state, DriveState::Corrupt.to_string());
assert_eq!(
tokio::fs::read(&paths[*slot]).await.expect("dry-run preserved shard"),
original_shards[*slot][..*keep]
);
}
let recoverable = damaged.len() <= 4;
if recoverable {
let mut reader = set
.get_object_reader(bucket, object, None, Default::default(), &ObjectOptions::default())
.await
.expect("degraded GET should remain readable");
let mut read_back = Vec::new();
tokio::io::copy(&mut reader, &mut read_back)
.await
.expect("read complete degraded GET");
assert_eq!(read_back, expected_payload);
}
let (result, error) = set
.heal_object(bucket, object, "", &opts)
.await
.expect("heal should report outcome");
if recoverable {
assert!(error.is_none(), "heal error: {error:?}");
assert_eq!(result.drives_healed(), Some(damaged.len()));
for (slot, _) in &damaged {
assert_eq!(result.before.drives[*slot].state, DriveState::Corrupt.to_string());
}
} else {
assert_eq!(error, Some(DiskError::ErasureReadQuorum));
assert_eq!(result.drives_healed(), Some(0));
}
for (slot, path) in paths.iter().enumerate() {
let expected = if !recoverable && let Some((_, keep)) = damaged.iter().find(|(index, _)| *index == slot) {
&original_shards[slot][..*keep]
} else {
original_shards[slot].as_slice()
};
assert_eq!(
tokio::fs::read(path).await.expect("read shard after heal"),
expected,
"physical shard {slot}"
);
if !recoverable {
let metadata_path = temp_dirs[slot].path().join(bucket).join(object).join("xl.meta");
assert_eq!(tokio::fs::read(metadata_path).await.expect("preserved metadata"), original_metadata[slot]);
}
}
if recoverable {
let mut reader = set
.get_object_reader(bucket, object, None, Default::default(), &ObjectOptions::default())
.await
.expect("GET after repair");
let mut read_back = Vec::new();
tokio::io::copy(&mut reader, &mut read_back)
.await
.expect("read repaired object");
assert_eq!(read_back, expected_payload);
let (healthy, error) = set.heal_object(bucket, object, "", &opts).await.expect("second deep scan");
assert!(error.is_none());
assert_eq!(healthy.drives_healed(), Some(0), "repair must converge");
assert_eq!(healthy.integrity_verified, mode == ShardIntegrityWriteMode::Protected);
}
}
#[tokio::test]
async fn deep_heal_rebuilds_h2_near_tail_truncated_part() {
use crate::object_api::ShardIntegrityWriteMode::{Legacy, Protected};
for mode in [Legacy, Protected] {
for coding_index in [1, 13] {
assert_h2_truncated_shards(mode, &[coding_index], |length, _| length - 1).await;
}
}
}
#[tokio::test]
async fn deep_heal_rebuilds_h2_half_truncated_part() {
use crate::object_api::ShardIntegrityWriteMode::{Legacy, Protected};
for mode in [Legacy, Protected] {
for coding_index in [1, 13] {
assert_h2_truncated_shards(mode, &[coding_index], |length, _| length / 2).await;
}
}
}
#[tokio::test]
async fn deep_heal_rebuilds_h2_stripe_boundary_truncated_parts_at_read_quorum() {
use crate::object_api::ShardIntegrityWriteMode::{Legacy, Protected};
for mode in [Legacy, Protected] {
assert_h2_truncated_shards(mode, &[1, 2, 13, 16], |_, stripe| stripe).await;
}
}
#[tokio::test]
async fn deep_heal_rejects_h2_truncated_parts_below_read_quorum() {
use crate::object_api::ShardIntegrityWriteMode::{Legacy, Protected};
for mode in [Legacy, Protected] {
assert_h2_truncated_shards(mode, &[1, 2, 3, 13, 16], |length, _| length / 2).await;
}
}
#[tokio::test]
async fn deep_heal_inline_bitrot_rebuilds_physical_data_and_parity_shards() {
use crate::storage_api_contracts::range::HTTPRangeSpec;
@@ -5385,7 +6167,17 @@ mod heal_result_report_tests {
let failure = DanglingDeleteFailure::install(bucket, object, 0, DiskError::FaultyDisk);
let mut proof = None;
let (_, error) = set
.heal_object_with_absence(bucket, object, &version.to_string(), &opts, &mut proof)
.heal_object_with_absence(
bucket,
object,
&version.to_string(),
&opts,
&mut proof,
AbsenceProofRequest {
retirement: None,
allow_unversioned: false,
},
)
.await
.expect("heal should return a per-object failure");
assert!(
@@ -5401,7 +6193,17 @@ mod heal_result_report_tests {
);
drop(failure);
let (result, error) = set
.heal_object_with_absence(bucket, object, &version.to_string(), &opts, &mut proof)
.heal_object_with_absence(
bucket,
object,
&version.to_string(),
&opts,
&mut proof,
AbsenceProofRequest {
retirement: None,
allow_unversioned: false,
},
)
.await
.expect("retry should execute cleanup");
assert!(error.is_none(), "retry should complete: {error:?}");
@@ -5416,7 +6218,17 @@ mod heal_result_report_tests {
.all(|drive| drive.state == DriveState::Missing.to_string())
);
let (_, _) = set
.heal_object_with_absence(bucket, object, &version.to_string(), &opts, &mut proof)
.heal_object_with_absence(
bucket,
object,
&version.to_string(),
&opts,
&mut proof,
AbsenceProofRequest {
retirement: None,
allow_unversioned: false,
},
)
.await
.expect("already absent replay should execute");
assert!(!proof.expect("exact already-absent replay must remain provable").removed);
+9 -4
View File
@@ -6715,7 +6715,7 @@ fn remote_version_state_writer_enabled() -> bool {
}
fn remote_version_state_writer_fleet_proof() -> Option<RemoteVersionStateFleetProofToken> {
transaction_fencing_fleet_proof(remote_version_state_writer_requested())
crate::services::notification_sys::acquire_remote_version_state_writer_fleet_proof()
}
fn remote_version_state_writer_requested() -> bool {
@@ -7571,7 +7571,11 @@ impl SetDisks {
let fi = &transported;
let disks = self.disk_inventory().await;
let namespace_owner = (!is_meta_bucketname(bucket)).then(|| self.ctx.begin_namespace_commit());
let write_quorum = disks.len() / 2 + 1;
// Quorum is a property of the configured set, not of the current
// online snapshot. A decommission/offline refresh may temporarily
// shorten the snapshot; deriving quorum from it would turn a
// quorum-minus-one delete into an apparent success.
let write_quorum = self.set_drive_count / 2 + 1;
let rollback_dir = Uuid::new_v4();
let mut futures = Vec::with_capacity(disks.len());
@@ -7699,10 +7703,11 @@ impl SetDisks {
drop(namespace_owner);
if quorum_result.is_ok()
&& errs.iter().any(Option::is_some)
&& let Some(purge) = delete_marker_purge
&& let Some(purge) = delete_marker_purge.as_ref()
&& let Some(version) = fi.version_id.filter(|version| !version.is_nil())
{
let _ = self.persist_delete_marker_purge(bucket, object, version, purge).await;
let _ = self.persist_marker_purge_receipt(bucket, object, version, purge).await;
let _ = self.persist_delete_marker_purge(bucket, object, version, purge.clone()).await;
}
// An explicit purge can carry deleted=true for the existing marker.
// It must not create a repair intent that could reintroduce that marker.
+62
View File
@@ -645,6 +645,17 @@ impl ECStore {
.as_ref()
.and_then(|info| info.created)
.unwrap_or(OffsetDateTime::UNIX_EPOCH);
// A sidecar left by an interrupted legacy migration is the
// bucket's published incarnation; keep it rather than minting
// one that would invalidate fences taken on the stored value.
// A retired incarnation is residue of a deleted bucket and
// must not come back.
let store = metadata_sys::object_store_in(&self.ctx).await?;
if let Some(stored) = crate::bucket::metadata::load_bucket_incarnation(store.clone(), bucket).await?
&& !crate::bucket::retirement::is_retired(store, bucket, stored).await?
{
metadata.bucket_incarnation_id = stored;
}
} else if !metadata.bucket_incarnation_sidecar && !metadata.bucket_incarnation_id.is_nil() {
return Err(Error::other(format!(
"bucket incarnation sidecar is missing for new-format metadata: {bucket}"
@@ -2881,6 +2892,57 @@ mod tests {
);
}
/// rustfs/rustfs#8003: a force-create (site replication replay, admin
/// import) against a bucket whose legacy migration stopped after the
/// sidecar write must persist `.metadata.bin` under the stored incarnation
/// rather than fail closed or replace the published identity.
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn issue_8003_force_create_adopts_existing_incarnation_sidecar() {
let (disk_paths, ecstore) = setup_bucket_delete_test_env().await;
let bucket = format!("bucket-8003-sidecar-{}", Uuid::new_v4().simple());
ecstore
.make_bucket(&bucket, &MakeBucketOptions::default())
.await
.expect("bucket should be created");
let stored = metadata_sys::get_in(&ecstore.ctx, &bucket)
.await
.expect("metadata should load")
.bucket_incarnation_id;
assert!(!stored.is_nil());
let metadata_path = format!("{BUCKET_META_PREFIX}/{bucket}/{}", crate::bucket::metadata::BUCKET_METADATA_FILE);
crate::config::com::delete_config(ecstore.clone(), &metadata_path)
.await
.expect("simulate the interrupted migration by removing only .metadata.bin");
assert!(!any_disk_path_exists(&disk_paths, format!("{RUSTFS_META_BUCKET}/{metadata_path}")).await);
metadata_sys::remove_bucket_metadata_in(&ecstore.ctx, &bucket)
.await
.expect("drop the cached copy so the next read hits disk");
ecstore
.make_bucket(
&bucket,
&MakeBucketOptions {
force_create: true,
..Default::default()
},
)
.await
.expect("force create must repair the sidecar-only bucket");
let (repaired, persisted) = metadata_sys::get_config_from_disk_with_presence_in(&ecstore.ctx, &bucket)
.await
.expect("repaired metadata should load");
assert!(persisted, "force create must persist .metadata.bin again");
assert!(repaired.bucket_incarnation_sidecar);
assert_eq!(
repaired.bucket_incarnation_id, stored,
"the repair must keep the incarnation the sidecar already published"
);
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn make_bucket_seeds_new_bucket_durability_override() {
+381 -7
View File
@@ -58,6 +58,26 @@ pub(crate) fn bucket_heal_scope(bucket: &str) -> Option<Arc<BucketHealScope>> {
.flatten()
}
/// Resolve only the two canonical bucket records; other internal paths carry no bucket authority.
pub(super) fn bucket_metadata_owner(object: &str) -> Option<&str> {
use crate::bucket::metadata::{BUCKET_INCARNATION_FILE, BUCKET_METADATA_FILE};
let (prefix, rest) = object.split_once('/')?;
let (bucket, file) = rest.split_once('/')?;
(prefix == disk::BUCKET_META_PREFIX
&& matches!(file, BUCKET_METADATA_FILE | BUCKET_INCARNATION_FILE)
&& check_valid_bucket_name_strict(bucket).is_ok())
.then_some(bucket)
}
pub(crate) fn bucket_heal_scope_for_object(bucket: &str, object: &str) -> Option<Arc<BucketHealScope>> {
let owner = if bucket == RUSTFS_META_BUCKET {
bucket_metadata_owner(object)?
} else {
bucket
};
bucket_heal_scope(owner)
}
/// Storage-owned proof for the exact version and every selected erasure location.
/// This is an in-process result, never reconstructed from admin drive telemetry.
#[derive(Debug)]
@@ -296,6 +316,24 @@ impl ECStore {
.await
}
pub async fn heal_mrf_object_at_incarnation(
self: &Arc<Self>,
bucket: &str,
object: &str,
version_id: &str,
expected: uuid::Uuid,
opts: &HealOpts,
) -> Result<HealObjectStorageResult> {
let object = object.to_owned();
let version_id = version_id.to_owned();
self.run_bucket_heal_at_incarnation(bucket, expected, opts, |store, bucket, opts| async move {
store
.heal_object_with_authoritative_absence_proof(&bucket, &object, &version_id, &opts)
.await
})
.await
}
async fn acquire_heal_format_fence(
&self,
) -> Result<(
@@ -404,6 +442,97 @@ impl ECStore {
Ok(Arc::ptr_eq(&target_set, &pool.get_disks_by_key(POOL_META_NAME)))
}
/// Restore the bucket configuration and incarnation before retiring a replacement marker.
pub async fn heal_replacement_bucket_metadata(
self: &Arc<Self>,
bucket: &str,
opts: &HealOpts,
targets: &[String],
) -> Result<()> {
use crate::bucket::metadata::{BUCKET_INCARNATION_FILE, BUCKET_METADATA_FILE};
use crate::bucket::metadata_sys::acquire_bucket_metadata_transaction_read_lock_in;
let (Some(pool_index), Some(set_index)) = (opts.pool, opts.set) else {
return Err(Error::PreconditionFailed);
};
if targets.is_empty() || opts.dry_run || opts.no_lock || check_valid_bucket_name_strict(bucket).is_err() {
return Err(Error::PreconditionFailed);
}
let pool = self
.pools
.get(pool_index)
.ok_or_else(|| invalid_heal_pool_index(pool_index, self.pools.len()))?;
let target_set = pool.get_disks_for_heal_object(BUCKET_METADATA_FILE, opts)?;
if targets.iter().any(|target| {
target_set
.set_endpoints
.iter()
.filter(|endpoint| endpoint.to_string() == *target)
.count()
!= 1
}) {
return Err(Error::PreconditionFailed);
}
let objects: Vec<_> = [BUCKET_METADATA_FILE, BUCKET_INCARNATION_FILE]
.into_iter()
.map(|file| format!("{}/{bucket}/{file}", disk::BUCKET_META_PREFIX))
.filter(|object| Arc::ptr_eq(&target_set, &pool.get_disks_by_key(object)))
.collect();
if objects.is_empty() {
return Ok(());
}
// Read the persisted identity without lazy migration: a replacement
// must never turn lost configuration into newly fabricated defaults.
let incarnation = self.bucket_incarnation_id_from_disk(bucket).await?;
let targets = targets.to_vec();
self.run_bucket_heal_at_incarnation(bucket, incarnation, opts, move |store, bucket, opts| async move {
// Match config writers: lifecycle, metadata transaction, object locks.
// Keep the transaction stable through target readback, including when
// the caller is cancelled and the storage owner finishes its write.
let transaction = acquire_bucket_metadata_transaction_read_lock_in(&store.ctx, &bucket).await?;
for object in objects {
let scope = bucket_heal_scope(&bucket).ok_or(Error::PreconditionFailed)?;
scope.check()?;
if transaction.is_lock_lost() {
return Err(Error::PreconditionFailed);
}
// Config objects occupy one pool, unlike pool.bin. Require a
// successful all-pool lookup before accepting another pool's ownership.
if !store.single_pool() {
let (owner, _) = store
.get_pool_info_for_delete_marker(RUSTFS_META_BUCKET, &object, &ObjectOptions::default())
.await?;
if owner.index != pool_index {
continue;
}
}
let metadata_opts = HealOpts { remove: false, ..opts };
let (result, error) = store.heal_object(RUSTFS_META_BUCKET, &object, "", &metadata_opts).await?;
if let Some(error) = error {
return Err(error);
}
for target in &targets {
let mut drives = result.after.drives.iter().filter(|drive| &drive.endpoint == target);
if !drives.next().is_some_and(|drive| drive.state == "ok") || drives.next().is_some() {
return Err(Error::PreconditionFailed);
}
}
if !store
.replacement_targets_have_version(RUSTFS_META_BUCKET, &object, "", pool_index, set_index, &targets)
.await?
{
return Err(Error::PreconditionFailed);
}
}
if transaction.is_lock_lost() {
return Err(Error::PreconditionFailed);
}
Ok(())
})
.await
}
/// Return every live erasure set selected by an object-heal scope.
pub async fn heal_erasure_set_scopes(&self, opts: &HealOpts) -> Result<Vec<(usize, usize)>> {
let pools = self.get_pools_for_heal_object(opts)?;
@@ -684,8 +813,18 @@ impl ECStore {
version_id: &str,
opts: &HealOpts,
) -> Result<(HealResultItem, Option<Error>)> {
self.handle_heal_object_with_absence(bucket, object, version_id, opts, &mut None, None)
.await
self.handle_heal_object_with_absence(
bucket,
object,
version_id,
opts,
&mut None,
crate::set_disk::AbsenceProofRequest {
retirement: None,
allow_unversioned: false,
},
)
.await
}
pub async fn heal_object_with_proof(
@@ -695,7 +834,34 @@ impl ECStore {
version_id: &str,
opts: &HealOpts,
) -> Result<HealObjectStorageResult> {
if opts.dry_run || opts.no_lock || version_id.is_empty() || super::utils::is_reserved_or_invalid_bucket(bucket, false) {
self.heal_object_with_absence_proof(bucket, object, version_id, opts, false)
.await
}
async fn heal_object_with_authoritative_absence_proof(
&self,
bucket: &str,
object: &str,
version_id: &str,
opts: &HealOpts,
) -> Result<HealObjectStorageResult> {
self.heal_object_with_absence_proof(bucket, object, version_id, opts, true)
.await
}
async fn heal_object_with_absence_proof(
&self,
bucket: &str,
object: &str,
version_id: &str,
opts: &HealOpts,
allow_unversioned_absence: bool,
) -> Result<HealObjectStorageResult> {
if opts.dry_run
|| opts.no_lock
|| (!allow_unversioned_absence && version_id.is_empty())
|| super::utils::is_reserved_or_invalid_bucket(bucket, false)
{
let (item, error) = self.handle_heal_object(bucket, object, version_id, opts).await?;
return Ok(HealObjectStorageResult {
item,
@@ -738,7 +904,17 @@ impl ECStore {
};
let mut proofs = None;
let (item, mut error) = self
.handle_heal_object_with_absence(bucket, object, version_id, opts, &mut proofs, Some(&retirement))
.handle_heal_object_with_absence(
bucket,
object,
version_id,
opts,
&mut proofs,
crate::set_disk::AbsenceProofRequest {
retirement: Some(&retirement),
allow_unversioned: allow_unversioned_absence,
},
)
.await?;
// Read the authoritative incarnation only for an absence candidate.
// The lifecycle guard has pinned it throughout the storage operation.
@@ -780,7 +956,7 @@ impl ECStore {
version_id: &str,
opts: &HealOpts,
absence: &mut Option<Vec<crate::set_disk::HealedObjectAbsence>>,
retirement: Option<&crate::bucket::retirement::MarkerRetirementContext<'_>>,
proof: crate::set_disk::AbsenceProofRequest<'_>,
) -> Result<(HealResultItem, Option<Error>)> {
trace!(
event = EVENT_HEAL_OBJECT_STARTED,
@@ -871,7 +1047,7 @@ impl ECStore {
}
#[cfg(test)]
crate::core::pools::notify_decommission_external_heal_operation_started(store_id);
pool.heal_object_with_absence(bucket, &pool_object, version_id, &opts, retirement)
pool.heal_object_with_absence(bucket, &pool_object, version_id, &opts, proof)
.await
}
});
@@ -895,7 +1071,7 @@ impl ECStore {
move |opts| async move {
#[cfg(test)]
crate::core::pools::notify_decommission_external_heal_operation_started(store_id);
pool.heal_object_with_absence(bucket, &pool_object, version_id, &opts, retirement)
pool.heal_object_with_absence(bucket, &pool_object, version_id, &opts, proof)
.await
},
));
@@ -1280,6 +1456,16 @@ mod tests {
.await
.is_err()
);
for file in [".metadata.bin", ".bucket-incarnation"] {
let metadata = format!("buckets/{bucket}/{file}");
assert!(
store
.rename_local_data_at_incarnation(&disk_ref, (&bucket, object), &fi, (RUSTFS_META_BUCKET, &metadata), old)
.await
.is_err(),
"stale remote heal must not replace the successor's {file}"
);
}
assert!(store.heal_bucket_at_incarnation(&bucket, old, &opts).await.is_err());
assert!(
store
@@ -1678,6 +1864,194 @@ mod tests {
(temp_dir, store, shutdown)
}
#[test]
fn bucket_metadata_heal_owner_rejects_path_aliases_and_unrelated_records() {
for file in [".metadata.bin", ".bucket-incarnation"] {
assert_eq!(bucket_metadata_owner(&format!("buckets/example/{file}")), Some("example"));
}
for path in [
"/buckets/example/.metadata.bin",
"buckets/../.metadata.bin",
"buckets/example/../.metadata.bin",
"buckets/example/.metadata.bin/extra",
"buckets/example//.metadata.bin",
"buckets/example/.metadata.bin.old",
"config/iam/example/.metadata.bin",
"buckets/example/usage.json",
] {
assert_eq!(bucket_metadata_owner(path), None, "unexpected bucket authority for {path}");
}
}
#[tokio::test]
async fn replacement_bucket_metadata_respects_pool_ownership_and_required_records() {
let (root, store, shutdown) = multi_pool_heal_store().await;
let bucket = "replacement-bucket-metadata";
for invalid_bucket in ["MixedCase", "../example", RUSTFS_META_BUCKET] {
assert!(matches!(
store
.heal_replacement_bucket_metadata(
invalid_bucket,
&HealOpts {
pool: Some(0),
set: Some(0),
..Default::default()
},
&[root.path().join("pool0-disk0").to_string_lossy().into_owned()],
)
.await,
Err(Error::PreconditionFailed)
));
}
store
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("create bucket and its persisted records");
let mut owned = 0;
for pool in 0..2 {
let target = root.path().join(format!("pool{pool}-disk0"));
let records: Vec<_> = [".metadata.bin", ".bucket-incarnation"]
.into_iter()
.map(|file| {
let path = target.join(RUSTFS_META_BUCKET).join("buckets").join(bucket).join(file);
let exists = path.join("xl.meta").exists();
if exists {
std::fs::remove_dir_all(&path).expect("remove replacement metadata shard");
owned += 1;
}
(path, exists)
})
.collect();
store
.heal_replacement_bucket_metadata(
bucket,
&HealOpts {
pool: Some(pool),
set: Some(0),
recreate: true,
..Default::default()
},
&[target.to_string_lossy().into_owned()],
)
.await
.expect("repair owned records and accept authoritative ownership in the other pool");
for (path, existed) in records {
assert_eq!(path.join("xl.meta").exists(), existed, "wrong placement for {}", path.display());
}
}
assert_eq!(owned, 2, "each required record must have exactly one owning pool");
let object = format!("buckets/{bucket}/.metadata.bin");
let owner = store
.get_pool_idx_existing_with_opts(RUSTFS_META_BUCKET, &object, &ObjectOptions::default())
.await
.expect("persisted metadata owner");
let disk = store.pools[owner].disk_set[0].disks.read().await[0]
.clone()
.expect("metadata target disk");
let fi = disk
.read_version(
"",
RUSTFS_META_BUCKET,
&object,
"",
&disk::ReadOptions {
read_data: true,
..Default::default()
},
)
.await
.expect("inline metadata to repair through target-side admission");
assert!(fi.data.is_some(), "fixture must contain an inline metadata shard");
let target_path = root.path().join(format!("pool{owner}-disk0"));
std::fs::remove_dir_all(target_path.join(RUSTFS_META_BUCKET).join(&object)).expect("remove remote target shard");
let incarnation = store
.bucket_incarnation_id_from_disk(bucket)
.await
.expect("current incarnation");
store
.rename_local_data_at_incarnation(
&disk.endpoint().to_string(),
(disk::RUSTFS_META_TMP_BUCKET, "metadata-heal-control"),
&fi,
(RUSTFS_META_BUCKET, &object),
incarnation,
)
.await
.expect("the target-side RPC admission must accept current bucket metadata repair");
assert!(target_path.join(RUSTFS_META_BUCKET).join(&object).join("xl.meta").exists());
let (configuration, _) = read_config_no_lock_preserve_empty_with_metadata(store.clone(), &object)
.await
.expect("read the current bucket configuration");
save_config(store.clone(), &object, configuration)
.await
.expect("publish a newer config revision in the same bucket incarnation");
let committed =
std::fs::read(target_path.join(RUSTFS_META_BUCKET).join(&object).join("xl.meta")).expect("new target configuration");
assert!(
store
.rename_local_data_at_incarnation(
&disk.endpoint().to_string(),
(disk::RUSTFS_META_TMP_BUCKET, "delayed-metadata-heal"),
&fi,
(RUSTFS_META_BUCKET, &object),
incarnation,
)
.await
.is_err(),
"a delayed repair must not overwrite a newer config in the same incarnation"
);
assert_eq!(
std::fs::read(target_path.join(RUSTFS_META_BUCKET).join(&object).join("xl.meta")).expect("retained configuration"),
committed,
"rejected repair must leave the newer configuration unchanged"
);
for pool in 0..2 {
for disk in 0..4 {
let path = root
.path()
.join(format!("pool{pool}-disk{disk}"))
.join(RUSTFS_META_BUCKET)
.join("buckets")
.join(bucket)
.join(".metadata.bin");
if path.exists() {
std::fs::remove_dir_all(path).expect("remove every copy of required bucket configuration");
}
}
}
metadata_sys::remove_bucket_metadata_in(&store.ctx, bucket)
.await
.expect("remove the cached configuration so repair cannot rely on it");
assert!(
store
.heal_replacement_bucket_metadata(
bucket,
&HealOpts {
pool: Some(0),
set: Some(0),
recreate: true,
..Default::default()
},
&[root.path().join("pool0-disk0").to_string_lossy().into_owned()],
)
.await
.is_err(),
"total metadata loss must not be accepted as ownership in another pool"
);
for pool in 0..2 {
assert!(
!root
.path()
.join(format!("pool{pool}-disk0"))
.join(RUSTFS_META_BUCKET)
.join(&object)
.exists(),
"missing required configuration must not be recreated with defaults"
);
}
shutdown.cancel();
}
#[tokio::test]
#[serial_test::serial]
async fn absence_proof_requires_every_selected_pool() {
+22 -4
View File
@@ -10052,9 +10052,18 @@ mod tests {
recovered_transition_version.clone(),
)))
.await;
let transaction_stats = recover_transition_transaction_records(store.clone(), 100, None)
let transaction_stats = {
let _proof = crate::services::notification_sys::install_current_remote_version_state_fleet_proof_for_test();
temp_env::async_with_vars(
[
(rustfs_config::ENV_TIER_REMOTE_VERSION_STATE_WRITE, Some("true")),
(rustfs_config::ENV_TIER_REMOTE_VERSION_STATE_FLEET_CONFIRMED, Some("true")),
],
recover_transition_transaction_records(store.clone(), 100, None),
)
.await
.expect("transition recovery should advance and consume the migrated transaction before completion");
.expect("transition recovery should advance and consume the migrated transaction before completion")
};
backend.set_transition_candidate_probe_override(None).await;
assert_eq!(
(
@@ -21938,9 +21947,18 @@ mod tests {
expected_removes.push((transaction.remote_object, put_version));
}
let stats = recover_transition_transaction_records(store.clone(), 100, None)
let stats = {
let _proof = crate::services::notification_sys::install_current_remote_version_state_fleet_proof_for_test();
temp_env::async_with_vars(
[
(rustfs_config::ENV_TIER_REMOTE_VERSION_STATE_WRITE, Some("true")),
(rustfs_config::ENV_TIER_REMOTE_VERSION_STATE_FLEET_CONFIRMED, Some("true")),
],
recover_transition_transaction_records(store.clone(), 100, None),
)
.await
.expect("transition transaction recovery should run");
.expect("transition transaction recovery should run")
};
assert_eq!((stats.scanned, stats.recovered, stats.retained, stats.failed), (3, 3, 0, 0));
assert_eq!(transition_transaction_record_count(store.clone()).await, 0);
+67 -2
View File
@@ -66,7 +66,7 @@ use std::future::Future;
use std::path::{Path, PathBuf};
use std::sync::{
Arc,
atomic::{AtomicU64, Ordering},
atomic::{AtomicBool, AtomicU64, Ordering},
};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use tokio::io::duplex;
@@ -341,6 +341,18 @@ pub struct ListPathOptions {
pub cursor_generation: Option<String>,
pub walkdir_timeout: Option<Duration>,
pub walkdir_stall_timeout: Option<Duration>,
/// Shared by the collector and raw producers to preserve bounded-walk
/// completion when filtering removes all entries from a batch.
pub producer_limit_reached: Option<Arc<AtomicBool>>,
}
fn ensure_producer_limit_state(options: &mut ListPathOptions) -> Arc<AtomicBool> {
let state = options
.producer_limit_reached
.clone()
.unwrap_or_else(|| Arc::new(AtomicBool::new(false)));
options.producer_limit_reached = Some(state.clone());
state
}
async fn can_skip_hidden_prefix_check(options: &ListPathOptions) -> bool {
@@ -4304,6 +4316,7 @@ impl ECStore {
// cancel channel
let cancel = CancellationToken::new();
let _cancel_guard = cancel.clone().drop_guard();
ensure_producer_limit_state(&mut o);
let (err_tx, mut err_rx) = broadcast::channel::<Arc<Error>>(1);
@@ -5047,6 +5060,15 @@ async fn gather_results(
}
}
// A producer can close its stream after exhausting its own scan budget
// before this collector reaches its output limit. In that case the input
// channel closing is not authoritative EOF: the caller must advertise a
// continuation page even when every remaining entry was filtered out.
let producer_limit_reached = opts
.producer_limit_reached
.as_ref()
.is_some_and(|reached| reached.load(Ordering::Acquire));
// finish not full, return eof
let filtered = scanned_entries.saturating_sub(candidate_entries);
if let Some(started) = gather_started {
@@ -5083,7 +5105,7 @@ async fn gather_results(
o: MetaCacheEntries(entries),
..Default::default()
}),
err: Some(rustfs_filemeta::Error::Unexpected),
err: (!producer_limit_reached).then_some(rustfs_filemeta::Error::Unexpected),
})
.await
.is_err()
@@ -5734,6 +5756,7 @@ impl Sets {
let (err_tx, mut err_rx) = broadcast::channel::<Arc<Error>>(1);
let (sender, recv) = mpsc::channel(o.limit as usize);
ensure_producer_limit_state(&mut o);
let sets = self.clone();
let opts = o.clone();
let cancel_rx1 = cancel.clone();
@@ -6777,6 +6800,7 @@ impl SetDisks {
let (err_tx, mut err_rx) = broadcast::channel::<Arc<Error>>(1);
let (sender, recv) = mpsc::channel(o.limit as usize);
ensure_producer_limit_state(&mut o);
let set = self.clone();
let opts = o.clone();
let cancel_rx1 = cancel.clone();
@@ -7098,6 +7122,7 @@ impl SetDisks {
forward_to: opts.marker,
min_disks: raw_min_disks,
per_disk_limit: limit,
producer_limit_reached: opts.producer_limit_reached.clone(),
// A foreground listing is bounded by lack of drive progress (the walk
// stall timeout) and by the page limit, never by how long a healthy
// walk takes — a large prefix on slow media is not a fault (#4644).
@@ -7328,6 +7353,7 @@ mod test {
};
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::sync::atomic::AtomicBool;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tokio::sync::mpsc;
@@ -8347,6 +8373,45 @@ mod test {
assert!(!cancel.is_cancelled());
}
#[tokio::test]
async fn list_path_gather_results_preserves_bounded_producer_after_filtering() {
let (entry_tx, entry_rx) = mpsc::channel(4);
let (result_tx, mut result_rx) = mpsc::channel(1);
let cancel = CancellationToken::new();
let producer_limit_reached = Arc::new(AtomicBool::new(true));
entry_tx
.send(test_meta_entry("outside-prefix"))
.await
.expect("filtered test entry should be queued");
drop(entry_tx);
let handle = tokio::spawn(gather_results(
cancel,
ListPathOptions {
bucket: "bucket".to_owned(),
prefix: "requested/".to_owned(),
limit: 8,
incl_deleted: true,
producer_limit_reached: Some(producer_limit_reached),
..Default::default()
},
entry_rx,
result_tx,
));
let result = result_rx.recv().await.expect("bounded producer result should be delivered");
assert!(result.entries.expect("entries should be present").entries().is_empty());
assert!(result.err.is_none(), "bounded producer must not be reported as EOF");
assert_eq!(
handle
.await
.expect("gather task should not panic")
.expect("gather should succeed"),
GatherResultsState::InputClosed
);
}
/// A-1 guard (rustfs/backlog#1306): pin that a *successful* send is never
/// misclassified as `ConsumerGone`. With the receiver alive, gather_results
/// must return the real drain state and deliver the payload carrying the
+1 -1
View File
@@ -426,8 +426,8 @@ mod bucket_fence;
pub(crate) use bucket::await_bucket_namespace_operation;
pub use bucket_fence::BucketIncarnationFenceGuard;
mod heal;
pub(crate) use heal::bucket_heal_scope;
pub use heal::{HealObjectAbsenceProof, HealObjectStorageResult};
pub(crate) use heal::{bucket_heal_scope, bucket_heal_scope_for_object};
mod heal_walk;
pub use heal_walk::HealWalkVersion;
mod init;
+94
View File
@@ -9124,6 +9124,100 @@ mod tests {
assert_eq!(dirs.len(), 16);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn current_marker_delete_ack_removes_stale_rejoined_copy() {
let bucket = "current-marker-stale-rejoin";
let object = "marker.bin";
let ctx = Arc::new(crate::runtime::instance::InstanceContext::new());
let (_dirs, original) = make_local_set_disks_with_ctx(16, 4, ctx.clone()).await;
let store = Arc::new(new_prepared_reader_test_store_with_ctx(&[original], ctx).await);
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
store
.handle_make_bucket(
bucket,
&MakeBucketOptions {
versioning_enabled: true,
..Default::default()
},
)
.await
.expect("create versioned bucket");
let marker = store
.delete_object(
bucket,
object,
ObjectOptions {
versioned: true,
..Default::default()
},
)
.await
.expect("create current marker")
.version_id
.expect("marker has explicit version");
let current = store
.bucket_incarnation_id_from_disk(bucket)
.await
.expect("bucket incarnation");
let set = &store.pools[0].disk_set[0];
let before = set.disks.read().await.clone();
for slot in 12..16 {
set.disks.write().await[slot] = None;
}
store
.delete_object(
bucket,
object,
ObjectOptions {
versioned: true,
version_id: Some(marker.to_string()),
..Default::default()
},
)
.await
.expect("acknowledged delete should remove the marker from the online quorum");
*set.disks.write().await = before.clone();
for disk in before.iter().take(12).flatten() {
assert!(matches!(
disk.read_version("", bucket, object, &marker.to_string(), &crate::disk::ReadOptions::default())
.await,
Err(crate::disk::error::DiskError::FileVersionNotFound | crate::disk::error::DiskError::FileNotFound)
));
}
for disk in before.iter().skip(12).flatten() {
assert!(
disk.read_version("", bucket, object, &marker.to_string(), &crate::disk::ReadOptions::default())
.await
.is_ok(),
"offline member must retain the stale current marker"
);
}
let healed = store
.heal_object_at_incarnation(
bucket,
object,
&marker.to_string(),
current,
&rustfs_heal_contracts::heal_channel::HealOpts {
remove: true,
scan_mode: rustfs_heal_contracts::heal_channel::HealScanMode::Deep,
..Default::default()
},
)
.await
.expect("stale current marker heal should complete");
assert!(healed.error.is_none(), "stale current marker heal: {:?}", healed.error);
assert!(healed.absence.as_ref().is_some_and(|proof| proof.removed));
for disk in before.iter().flatten() {
assert!(matches!(
disk.read_version("", bucket, object, &marker.to_string(), &crate::disk::ReadOptions::default())
.await,
Err(crate::disk::error::DiskError::FileVersionNotFound | crate::disk::error::DiskError::FileNotFound)
));
}
}
#[tokio::test]
async fn multipool_delete_marker_stays_with_existing_versions() {
let bucket = "multipool-marker-routing";
+40 -2
View File
@@ -161,14 +161,52 @@ impl ECStore {
"incarnation-bound rename requires namespace locking and a non-nil identity",
));
}
let bucket = if destination.0 == RUSTFS_META_BUCKET {
super::heal::bucket_metadata_owner(destination.1).ok_or(DiskError::FileAccessDenied)?
} else {
destination.0
};
let guard = Arc::new(
self.acquire_bucket_incarnation_fence(destination.0, expected)
self.acquire_bucket_incarnation_fence(bucket, expected)
.await
.map_err(|error| DiskError::other(error.to_string()))?,
);
if guard.is_lock_lost() {
return Err(DiskError::other("bucket heal incarnation fence was lost"));
}
let transaction = if destination.0 == RUSTFS_META_BUCKET {
// The receiver must retain the config fence after an RPC timeout.
// A delayed request must also reject a newer config in the same
// bucket incarnation before publishing its older repair payload.
let transaction = crate::bucket::metadata_sys::acquire_bucket_metadata_transaction_read_lock_in(&self.ctx, bucket)
.await
.map_err(|error| DiskError::other(error.to_string()))?;
let (current, _) = self
.get_pool_info_for_delete_marker(
destination.0,
destination.1,
&ObjectOptions {
no_lock: true,
..Default::default()
},
)
.await
.map_err(|error| DiskError::other(error.to_string()))?;
let current = current.object_info;
if transaction.is_lock_lost()
|| guard.is_lock_lost()
|| fi.mod_time.is_none()
|| current.mod_time != fi.mod_time
|| current.data_dir != fi.data_dir
|| current.size != fi.size
|| current.etag != fi.get_etag()
{
return Err(DiskError::FileAccessDenied);
}
Some(transaction)
} else {
None
};
rename_local_data_with_ctx(
&self.ctx,
disk_ref,
@@ -176,7 +214,7 @@ impl ECStore {
fi,
destination,
RenameDataGuards {
external_guard: Some(guard),
external_guard: Some(Arc::new((guard, transaction))),
..Default::default()
},
)
+41
View File
@@ -907,6 +907,21 @@ impl ErasureSetHealer {
}
if failed_objects == 0 && skipped_objects == 0 && failed_buckets == 0 {
let targets = if self.pool_metadata_target_endpoints.is_empty() {
self.target_endpoints.as_ref()
} else {
self.pool_metadata_target_endpoints.as_ref()
};
if self.replacement_task_id.is_some() || (!self.heal_opts.dry_run && self.heal_opts.recreate && !targets.is_empty()) {
self.verify_replacement_identity_fence("bucket metadata").await?;
// Recheck even resumed buckets: their user-object cursor does not
// prove that the replacement holds the internal bucket records.
for bucket in buckets {
self.storage
.heal_replacement_bucket_metadata(bucket, &self.heal_opts, targets)
.await?;
}
}
self.heal_replacement_pool_metadata(
set_disk_id,
&mut ErasureSetPassCounters {
@@ -2267,6 +2282,8 @@ mod resume_loop_tests {
list_include_lifecycle_object_info: Mutex<Vec<bool>>,
replacement_target_identity_sequences: Mutex<VecDeque<Vec<ReplacementTargetIdentity>>>,
pool_metadata_placement: Mutex<Option<ReplacementCommitEvidence>>,
bucket_metadata_calls: Mutex<Vec<String>>,
bucket_metadata_failure: AtomicBool,
fail_listing: AtomicBool,
fail_listing_buckets: Mutex<HashSet<String>>,
}
@@ -2462,6 +2479,13 @@ mod resume_loop_tests {
None => Ok(true),
}
}
async fn heal_replacement_bucket_metadata(&self, bucket: &str, _opts: &HealOpts, _targets: &[String]) -> Result<()> {
self.bucket_metadata_calls.lock().unwrap().push(bucket.to_owned());
if self.bucket_metadata_failure.load(Ordering::SeqCst) {
return Err(Error::Storage(EcstoreError::PreconditionFailed));
}
Ok(())
}
async fn replacement_targets_have_version(
&self,
_bucket: &str,
@@ -3058,6 +3082,18 @@ mod resume_loop_tests {
)
.with_replacement_targets(vec!["replacement-a".to_string()], Some(replacement_task_id.clone()));
env.storage.bucket_metadata_failure.store(true, Ordering::SeqCst);
assert!(healer.heal_erasure_set(&["b".to_string()], "pool_0_set_0").await.is_err());
let incomplete = ResumeManager::load_replacement_intent(env.healer.disk.clone(), &replacement_task_id)
.await
.expect("failed metadata repair must retain replacement intent")
.get_state()
.await;
assert!(!incomplete.completed);
assert_ne!(incomplete.replacement_phase, crate::heal::resume::ReplacementPhase::Verified);
assert!(env.storage.calls().is_empty(), "metadata failure must stop completion before pool.bin");
env.storage.bucket_metadata_failure.store(false, Ordering::SeqCst);
let error = healer
.heal_erasure_set(&["b".to_string()], "pool_0_set_0")
.await
@@ -3073,6 +3109,11 @@ mod resume_loop_tests {
assert_eq!(state.replacement_phase, crate::heal::resume::ReplacementPhase::Intent);
assert_eq!(state.retry_count, 1);
assert_eq!(env.storage.calls(), vec![(POOL_META_NAME.to_string(), None)]);
assert_eq!(
*env.storage.bucket_metadata_calls.lock().unwrap(),
vec!["b", "b"],
"resuming a completed user scan must retry bucket metadata"
);
}
#[tokio::test]
+43
View File
@@ -532,6 +532,19 @@ pub trait HealStorageAPI: Send + Sync {
Err(Error::other("storage does not support incarnation-bound object healing"))
}
/// Durable MRF repair may request an authoritative unversioned absence proof.
async fn heal_mrf_object_at_incarnation(
&self,
bucket: &str,
object: &str,
version_id: Option<&str>,
expected: Uuid,
opts: &HealOpts,
) -> Result<HealStorageObjectResult> {
self.heal_object_at_incarnation(bucket, object, version_id, expected, opts)
.await
}
/// Heal object using ecstore
async fn heal_object(
&self,
@@ -578,6 +591,11 @@ pub trait HealStorageAPI: Send + Sync {
Err(Error::other("replacement pool metadata placement is unsupported"))
}
/// Heal and physically verify bucket configuration on its replacement targets.
async fn heal_replacement_bucket_metadata(&self, _bucket: &str, _opts: &HealOpts, _targets: &[String]) -> Result<()> {
Err(Error::Storage(StorageError::PreconditionFailed))
}
/// Read target-specific physical evidence for one replacement version.
///
/// This is only used by automatic replacement healing after the normal
@@ -967,6 +985,24 @@ impl HealStorageAPI for ECStoreHealStorage {
.await)
}
async fn heal_mrf_object_at_incarnation(
&self,
bucket: &str,
object: &str,
version_id: Option<&str>,
expected: Uuid,
opts: &HealOpts,
) -> Result<HealStorageObjectResult> {
let result = self
.ecstore
.heal_mrf_object_at_incarnation(bucket, object, version_id.unwrap_or_default(), expected, opts)
.await
.map_err(|error| incarnation_storage_error(bucket, expected, error))?;
Ok(self
.object_result_with_receipt(bucket, object, version_id, opts, result, Some(expected))
.await)
}
async fn get_object_meta(&self, bucket: &str, object: &str) -> Result<Option<HealObjectInfo>> {
debug!(
target: "rustfs::heal::storage",
@@ -1564,6 +1600,13 @@ impl HealStorageAPI for ECStoreHealStorage {
.map_err(Error::Storage)
}
async fn heal_replacement_bucket_metadata(&self, bucket: &str, opts: &HealOpts, targets: &[String]) -> Result<()> {
self.ecstore
.heal_replacement_bucket_metadata(bucket, opts, targets)
.await
.map_err(Error::Storage)
}
async fn replacement_targets_have_version(
&self,
bucket: &str,
+77
View File
@@ -298,6 +298,18 @@ impl HealTask {
"Heal object stage entered"
);
let object_size = result.object_size as u64;
let ok_drive_state = DriveState::Ok.to_string();
if self.source == HealRequestSource::Admin
&& !self.options.dry_run
&& !result.after.drives.is_empty()
&& result.after.drives.iter().any(|drive| drive.state != ok_drive_state)
{
return Err(Error::TaskExecutionFailed {
message: format!(
"Heal left one or more drives unhealthy for {bucket}/{object}; retry after the missing drives are restored"
),
});
}
debug!(
target: "rustfs::heal::task",
event = EVENT_HEAL_OBJECT_RESULT,
@@ -417,6 +429,10 @@ impl HealTask {
/// Recreate missing object (for EC decode scenarios)
async fn recreate_missing_object(&self, bucket: &str, object: &str, version_id: Option<&str>) -> Result<()> {
if self.source == HealRequestSource::Mrf {
return self.recreate_missing_mrf_object(bucket, object, version_id).await;
}
debug!(
target: "rustfs::heal::task",
event = EVENT_HEAL_OBJECT_STAGE,
@@ -517,4 +533,65 @@ impl HealTask {
}
}
}
/// Durable MRF responsibilities may complete only with an exact storage proof.
async fn recreate_missing_mrf_object(&self, bucket: &str, object: &str, version_id: Option<&str>) -> Result<()> {
let heal_opts = HealOpts {
recursive: false,
dry_run: self.options.dry_run,
remove: false,
recreate: true,
scan_mode: HealScanMode::Deep,
update_parity: true,
no_lock: self.options.no_lock,
read_repair: false,
pool: self.options.pool_index,
set: self.options.set_index,
};
let mut expected = self.outcome_identity(bucket, object, version_id, self.options.pool_index, self.options.set_index);
let bucket_incarnation_id = self
.outcome_bucket_incarnation_id(bucket, self.options.dry_run)
.await?
.ok_or_else(|| Error::TaskExecutionFailed {
message: format!("Missing bucket incarnation for durable MRF repair {bucket}/{object}"),
})?;
expected.bucket_incarnation_id = Some(bucket_incarnation_id);
let storage_result = self
.await_with_control(self.storage.heal_mrf_object_at_incarnation(
bucket,
object,
version_id,
bucket_incarnation_id,
&heal_opts,
))
.await?;
if let Some(error) = storage_result.error {
return Err(Error::TaskExecutionFailed {
message: format!("Failed to recreate missing object {bucket}/{object}: {error}"),
});
}
let object_size = storage_result.item.object_size as u64;
let authoritatively_absent = matches!(
storage_result.receipt.as_ref().map(|receipt| &receipt.disposition),
Some(HealObjectDisposition::AuthoritativelyAbsent)
);
if !self.record_verified_storage_receipt(expected, storage_result.receipt).await {
return Err(Error::TaskExecutionFailed {
message: format!("Missing exact storage proof for durable MRF repair {bucket}/{object}"),
});
}
{
let mut progress = self.progress.write().await;
if authoritatively_absent {
progress.update_object_progress(1, 0, 0, 1, 0);
} else {
progress.update_object_progress(1, 1, 0, 0, object_size);
}
}
self.record_result_item(storage_result.item).await;
Ok(())
}
}
+95
View File
@@ -3471,6 +3471,40 @@ async fn test_prefix_heal_lists_and_repairs_objects_under_prefix() {
);
}
#[tokio::test]
async fn admin_object_heal_fails_when_drive_remains_unhealthy() {
let storage = Arc::new(MockStorage::default());
storage.heal_object_outcomes.lock().expect("outcomes").insert(
"object-a".to_string(),
VecDeque::from([MockHealObjectOutcome::UnavailableDrive(DriveState::Missing)]),
);
let mut request = HealRequest::new(
HealType::Object {
bucket: "bucket-a".to_string(),
object: "object-a".to_string(),
version_id: None,
},
HealOptions {
recreate_missing: false,
timeout: None,
..Default::default()
},
HealPriority::Normal,
);
request.source = HealRequestSource::Admin;
let task = HealTask::from_request(request, storage);
let error = task
.execute()
.await
.expect_err("admin heal must not finish with an unhealthy drive");
assert!(error.to_string().contains("left one or more drives unhealthy"));
let outcome = task.get_outcome().await;
assert_eq!(outcome.counters.failed, 1);
assert_eq!(outcome.counters.unknown, 0);
assert_eq!(outcome.execution, HealExecutionOutcome::CompletedWithErrors);
}
#[tokio::test]
async fn test_data_usage_cache_lock_timeout_does_not_fail_object_heal() {
let storage = Arc::new(MockStorage::default());
@@ -3855,6 +3889,67 @@ async fn test_heal_recreate_scanner_non_dir_not_found_fails() {
assert!(matches!(task.get_status().await, HealTaskStatus::Failed { .. }));
}
#[tokio::test]
async fn mrf_recreate_missing_object_records_exact_absence_receipt_with_scope() {
let incarnation = Uuid::new_v4();
let storage = Arc::new(MockStorage {
object_exists: Mutex::new(Some(false)),
bucket_incarnation_id: Mutex::new(Some(incarnation)),
heal_object_receipts: Mutex::new(HashMap::from([(
"deleted.bin".to_string(),
VecDeque::from([HealObjectReceipt {
identity: HealObjectIdentity {
kind: HealObjectKind::Object,
bucket: "bucket-a".to_string(),
object: "deleted.bin".to_string(),
version_id: None,
bucket_incarnation_id: Some(incarnation),
pool_index: Some(2),
set_index: Some(3),
},
disposition: HealObjectDisposition::AuthoritativelyAbsent,
}]),
)])),
..Default::default()
});
let mut request = HealRequest::new(
HealType::Object {
bucket: "bucket-a".to_string(),
object: "deleted.bin".to_string(),
version_id: None,
},
HealOptions {
recreate_missing: true,
pool_index: Some(2),
set_index: Some(3),
timeout: None,
..Default::default()
},
HealPriority::Normal,
);
request.source = HealRequestSource::Mrf;
let task = HealTask::from_request(request, storage.clone());
task.execute()
.await
.expect("a complete MRF absence receipt must complete the task");
let opts = storage.object_heal_opts.lock().unwrap()[0];
assert_eq!(opts.pool, Some(2));
assert_eq!(opts.set, Some(3));
assert!(opts.recreate);
assert_eq!(opts.scan_mode, HealScanMode::Deep);
let outcome = task.get_outcome().await;
assert_eq!(outcome.counters.unchanged, 1);
assert_eq!(outcome.counters.unknown, 0);
assert_eq!(outcome.objects.len(), 1);
assert_eq!(outcome.objects[0].disposition, HealObjectDisposition::AuthoritativelyAbsent);
assert_eq!(
(outcome.objects[0].identity.pool_index, outcome.objects[0].identity.set_index),
(Some(2), Some(3))
);
}
#[tokio::test]
async fn test_heal_scanner_missing_object_without_recreate_probes_storage() {
let storage = Arc::new(MockStorage {
@@ -36,6 +36,7 @@ use rustfs_heal::heal::{
};
use rustfs_heal_contracts::heal_channel::{HealOpts, HealScanMode};
use serial_test::serial;
use sha2::{Digest, Sha256};
use std::{
path::{Path, PathBuf},
sync::Arc,
@@ -722,6 +723,91 @@ mod serial_tests {
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[serial]
async fn test_recursive_deep_heal_reports_stale_delete_marker_repair() {
let (disk_paths, ecstore, storage) = heal_env().await;
let manager = HealManager::new(
storage.clone(),
Some(HealConfig {
heal_interval: Duration::from_millis(1),
..Default::default()
}),
);
manager.start().await.expect("heal manager should start");
let bucket = "b5-stale-delete-marker-recursive";
let object = "obj.bin";
create_versioned_bucket(&ecstore, bucket).await;
put_unversioned(&ecstore, bucket, "control/object.bin", &versioned_test_data(42)).await;
let historical_data = versioned_test_data(43);
let historical = put_versioned(&ecstore, bucket, object, &historical_data).await;
let target = &disk_paths[1];
let target_meta = xl_meta_path(&object_dir(target, bucket, object));
let stale_meta = std::fs::read(&target_meta).expect("target xl.meta must exist before marker creation");
let stale_digest = Sha256::digest(&stale_meta);
let marker = put_delete_marker(&ecstore, bucket, object).await;
std::fs::write(&target_meta, &stale_meta).expect("restore stale target xl.meta");
let request = HealRequest::new(
HealType::Bucket {
bucket: bucket.to_string(),
},
HealOptions {
recursive: true,
scan_mode: HealScanMode::Deep,
pool_index: Some(0),
set_index: Some(0),
..Default::default()
},
HealPriority::Normal,
);
let task_id = request.id.clone();
assert!(
manager
.submit_heal_request(request)
.await
.expect("submit recursive heal")
.is_admitted()
);
wait_for_task(&manager, &task_id, Duration::from_secs(60)).await;
let report = manager.get_task_report(&task_id).await.expect("completed task report");
let outcome = report.outcome.expect("recursive task must have an outcome");
assert_eq!(outcome.counters.failed, 0);
assert_eq!(outcome.counters.unknown, 0);
assert_eq!(outcome.counters.processed, 3);
assert_eq!(outcome.counters.healed, 1, "delete marker repair must be counted as healed");
assert_eq!(outcome.counters.unchanged, 2);
let repaired_meta = std::fs::read(&target_meta).expect("repaired target xl.meta must exist");
assert_ne!(
Sha256::digest(&repaired_meta),
stale_digest,
"deep heal must replace stale rejoined metadata"
);
let marker_info = physical_version(target, bucket, object, &marker);
assert!(
marker_info.deleted && marker_info.is_latest,
"target metadata must converge to delete-marker latest"
);
let historical_info = physical_version(target, bucket, object, &historical);
assert!(!historical_info.is_latest, "historical version must no longer be marked latest");
assert_eq!(
read_version(&ecstore, bucket, object, &historical).await,
historical_data,
"historical version must remain readable after marker convergence"
);
let marker_receipt = outcome
.objects
.iter()
.find(|item| item.identity.version_id.as_deref() == Some(marker.as_str()))
.expect("deep heal must emit a receipt for the repaired delete marker");
assert_eq!(marker_receipt.disposition, HealObjectDisposition::Repaired);
manager.stop().await.expect("heal manager should stop");
}
/// A missing xl.meta must produce an exact marker repair receipt, while a
/// healthy replay must prove metadata health without claiming payload integrity.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
@@ -717,6 +717,49 @@ mod absence_receipt_regressions {
assert_versions(&store, bucket, &old, &current).await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[serial]
async fn unversioned_missing_object_produces_fenced_absence_receipt() {
let bucket = "absence-receipt-unversioned";
let object = "deleted.bin";
let (_paths, store, storage) = heal_env_n(16).await;
store
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("create unversioned bucket");
let incarnation = storage
.admit_bucket_incarnation(bucket)
.await
.expect("admit bucket incarnation for the durable proof");
let result = storage
.heal_mrf_object_at_incarnation(
bucket,
object,
None,
incarnation,
&HealOpts {
pool: Some(0),
set: Some(0),
..deep_heal_opts()
},
)
.await
.expect("fenced absence probe should return a storage result");
assert!(
result.error.is_none(),
"complete unversioned absence must not remain an error: {:?}",
result.error
);
let receipt = result.receipt.expect("complete unversioned absence needs a receipt");
assert_eq!(receipt.disposition, HealObjectDisposition::AuthoritativelyAbsent);
assert_eq!(receipt.identity.bucket, bucket);
assert_eq!(receipt.identity.object, object);
assert!(receipt.identity.version_id.is_none());
assert_eq!((receipt.identity.pool_index, receipt.identity.set_index), (Some(0), Some(0)));
assert_eq!(receipt.identity.bucket_incarnation_id, Some(incarnation));
}
#[test]
#[serial]
fn historical_absence_receipt_bucket_outcome_matches_c06() {
@@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
//! Regression for replacement healing requiring pool.bin on a non-owning set.
//! Replacement healing must restore internal records only on their owning sets.
#![recursion_limit = "256"]
use http::HeaderMap;
@@ -101,6 +101,31 @@ async fn replacement_pool_metadata_follows_real_two_set_placement() {
storage.replacement_pool_metadata_required(&opts).is_err(),
"invalid scope must fail closed"
);
assert!(
storage
.heal_replacement_bucket_metadata(bucket, &opts, &[paths[0].to_string_lossy().into_owned()])
.await
.is_err(),
"bucket metadata repair must reject an incomplete or invalid replacement scope"
);
}
for targets in [vec![], vec![paths[4].to_string_lossy().into_owned()]] {
assert!(
storage
.heal_replacement_bucket_metadata(
bucket,
&HealOpts {
pool: Some(0),
set: Some(0),
..Default::default()
},
&targets,
)
.await
.is_err(),
"empty or wrong-set targets must be rejected before repairing metadata"
);
}
let mut owning_set = None;
@@ -120,6 +145,17 @@ async fn replacement_pool_metadata_follows_real_two_set_placement() {
assert!(owning_set.replace(set).is_none(), "only one set owns pool.bin");
std::fs::remove_dir_all(&metadata_path).unwrap();
}
let bucket_records: Vec<_> = [".metadata.bin", ".bucket-incarnation"]
.into_iter()
.map(|file| {
let path = target_path.join(RUSTFS_META_BUCKET).join("buckets").join(bucket).join(file);
let existed = path.join("xl.meta").exists();
if existed {
std::fs::remove_dir_all(&path).expect("remove bucket metadata shard from replacement");
}
(path, existed)
})
.collect();
// Select a user key using the real placement algorithm, independently
// of the metadata-scope decision under test.
let key = (0..1000)
@@ -191,6 +227,14 @@ async fn replacement_pool_metadata_follows_real_two_set_placement() {
"completed repair must clear its healing marker"
);
assert_eq!(metadata_path.join("xl.meta").exists(), owns_metadata);
for (path, owned) in bucket_records {
assert_eq!(
path.join("xl.meta").exists(),
owned,
"replacement must restore each owned bucket record without creating a wrong-set copy: {}",
path.display()
);
}
for (relative, bytes) in original_parts {
assert_eq!(std::fs::read(object_path.join(relative)).unwrap(), bytes, "reconstructed shard differs");
}
@@ -90,6 +90,82 @@ async fn partial_write_persistence_failure_is_reported_and_retained_for_retry()
);
}
#[test]
fn unversioned_deleted_partial_write_is_discharged_by_an_absence_proof() {
const STACK_SIZE: usize = 8 * 1024 * 1024;
std::thread::Builder::new()
.name("mrf-partial-write-absence".to_owned())
.stack_size(STACK_SIZE)
.spawn(|| {
let runtime = tokio::runtime::Builder::new_current_thread()
.thread_stack_size(STACK_SIZE)
.enable_all()
.build()
.expect("partial-write absence runtime should build");
runtime.block_on(unversioned_deleted_partial_write_is_discharged_by_an_absence_proof_inner());
})
.expect("partial-write absence test thread should spawn")
.join()
.expect("partial-write absence test thread should finish");
}
async fn unversioned_deleted_partial_write_is_discharged_by_an_absence_proof_inner() {
use rustfs_common::mrf_channel::{MrfScope, persist_partial_write_intent};
temp_env::async_with_vars([("RUSTFS_HEAL_MRF_ENABLE", Some("true"))], async {
let root = tempfile::tempdir().expect("partial-write absence fixture directory");
let env = TestECStoreEnv::builder().disk_count(16).base_dir(root.path()).build().await;
env.make_bucket("partial-absence", false).await;
let mut coordinator_pool = env.endpoint_pools.as_ref()[0].clone();
let mut endpoints = coordinator_pool.endpoints.as_ref().to_vec();
for endpoint in endpoints.iter_mut().skip(4) {
endpoint.is_local = false;
}
coordinator_pool.endpoints = Endpoints::from(endpoints);
init_local_disks(EndpointServerPools::from(vec![coordinator_pool]))
.await
.expect("coordinator journal disks");
let manager = manager(&env);
mrf_queue::spawn_mrf_consumer(manager.clone());
for (object, version_id) in [
("deleted-unversioned.bin", None),
("deleted-versioned.bin", Some(uuid::Uuid::new_v4())),
] {
persist_partial_write_intent(
"partial-absence",
object,
version_id,
MrfScope {
pool_index: 0,
set_index: 0,
},
)
.await
.expect("durable partial-write responsibility must commit before scheduling");
assert!(snapshot_contains(object).await, "committed responsibility must exist before repair runs");
}
manager.start().await.expect("MRF scheduler should start");
for object in ["deleted-unversioned.bin", "deleted-versioned.bin"] {
assert!(
wait_until(|| async { !snapshot_contains(object).await }).await,
"complete absence proof must discharge the durable responsibility"
);
}
assert!(
wait_until(|| async {
let snapshot = manager.operations_snapshot().await;
snapshot.queue_length == 0 && snapshot.active_tasks == 0
})
.await,
"discharged absence repair must leave no queued work"
);
manager.stop().await.expect("absence manager should stop");
})
.await;
}
async fn wait_until<F: FnMut() -> Fut, Fut: Future<Output = bool>>(mut probe: F) -> bool {
tokio::time::timeout(Duration::from_secs(60), async {
loop {
+16 -1
View File
@@ -241,7 +241,9 @@ impl RuleValidate for LifecycleRule {
return Err(std::io::Error::other(ERR_LIFECYCLE_INVALID_DEL_MARKER_EXPIRATION_DAYS));
}
// Rule must have at least one action
let has_expiration = self.expiration.is_some();
let has_expiration = self.expiration.as_ref().is_some_and(|expiration| {
expiration.days.is_some() || expiration.date.is_some() || expiration.expired_object_delete_marker.is_some()
});
let has_transition = self.transitions.as_ref().is_some_and(|t| !t.is_empty());
// `NewerNoncurrentVersions` on its own is a MinIO extension, not an AWS
// form: it keeps the newest N noncurrent versions and expires the rest
@@ -4455,6 +4457,19 @@ mod tests {
.expect_err("a negative retention count on a transition must be rejected");
}
#[tokio::test]
async fn validate_rejects_empty_expiration_action() {
let mut rule = rule_with_filter(LifecycleRuleFilter::default());
rule.expiration = Some(LifecycleExpiration::default());
let err = config_with_rules(vec![rule])
.validate(&ObjectLockConfiguration::default())
.await
.expect_err("an empty Expiration object must not count as an action");
assert_eq!(err.to_string(), ERR_LIFECYCLE_RULE_MUST_HAVE_ACTION);
}
#[tokio::test]
async fn zero_newer_noncurrent_versions_means_no_count_constraint() {
// Zero carries no constraint, matching how the batch limit path has
+12
View File
@@ -89,6 +89,18 @@ fn rust_log_requests_verbose(rust_log: &str) -> bool {
})
}
/// Returns `true` when the effective logging configuration selects a verbose
/// (`debug`/`trace`) level.
///
/// Follows the same precedence as [`build_env_filter`]: `RUST_LOG` overrides
/// `logger_level` when it is set.
pub(super) fn effective_verbose_logging(logger_level: &str) -> bool {
match env::var("RUST_LOG").ok().filter(|value| !value.trim().is_empty()) {
Some(rust_log) => rust_log_requests_verbose(&rust_log),
None => is_verbose_level(logger_level),
}
}
fn should_suppress_noisy_crates(logger_level: &str, default_level: Option<&str>, rust_log: Option<&str>) -> bool {
if let Some(level) = default_level {
return !is_verbose_level(level);
+143 -13
View File
@@ -36,7 +36,7 @@ use crate::cleaner::LogCleaner;
use crate::cleaner::types::{CompressionAlgorithm, FileMatchMode};
use crate::config::OtelConfig;
use crate::global::{METRIC_LOG_CLEANER_RUN_FAILURES_TOTAL, METRIC_LOG_CLEANER_RUNS_TOTAL, set_observability_metric_enabled};
use crate::telemetry::filter::{build_env_filter, pyroscope_log_filter};
use crate::telemetry::filter::{build_env_filter, effective_verbose_logging, pyroscope_log_filter};
use crate::telemetry::rolling::{RollingAppender, Rotation};
use metrics::counter;
use rustfs_config::observability::{
@@ -44,10 +44,11 @@ use rustfs_config::observability::{
DEFAULT_OBS_LOG_COMPRESSION_ALGORITHM, DEFAULT_OBS_LOG_DELETE_EMPTY_FILES, DEFAULT_OBS_LOG_DRY_RUN,
DEFAULT_OBS_LOG_GZIP_COMPRESSION_LEVEL, DEFAULT_OBS_LOG_MATCH_MODE, DEFAULT_OBS_LOG_MAX_SINGLE_FILE_SIZE_BYTES,
DEFAULT_OBS_LOG_MAX_TOTAL_SIZE_BYTES, DEFAULT_OBS_LOG_MIN_FILE_AGE_SECONDS, DEFAULT_OBS_LOG_PARALLEL_COMPRESS,
DEFAULT_OBS_LOG_PARALLEL_WORKERS, DEFAULT_OBS_LOG_ZSTD_COMPRESSION_LEVEL, DEFAULT_OBS_LOG_ZSTD_FALLBACK_TO_GZIP,
DEFAULT_OBS_LOG_ZSTD_WORKERS,
DEFAULT_OBS_LOG_PARALLEL_WORKERS, DEFAULT_OBS_LOG_SPAN_EVENTS, DEFAULT_OBS_LOG_ZSTD_COMPRESSION_LEVEL,
DEFAULT_OBS_LOG_ZSTD_FALLBACK_TO_GZIP, DEFAULT_OBS_LOG_ZSTD_WORKERS, ENV_OBS_LOG_SPAN_EVENTS,
};
use rustfs_config::{APP_NAME, DEFAULT_LOG_KEEP_FILES, DEFAULT_LOG_ROTATION_TIME};
use rustfs_utils::get_env_str;
use serde_json::Value as JsonValue;
use std::sync::Arc;
use std::{collections::BTreeMap, fmt};
@@ -78,6 +79,37 @@ pub(super) fn resolve_file_stdout_mirror(configured: Option<bool>, is_production
configured.unwrap_or(!is_production)
}
/// Resolves the span lifecycle events emitted by the JSON log layers.
///
/// Defaults to [`FmtSpan::NONE`]. A record per span lifecycle multiplies log
/// volume on object hot paths and, combined with the ancestor span list,
/// produced multi-megabyte lines that journald truncated (backlog#2642).
/// Operators opt back in with `RUSTFS_OBS_LOG_SPAN_EVENTS=close|full`.
pub(super) fn resolve_span_events() -> FmtSpan {
match get_env_str(ENV_OBS_LOG_SPAN_EVENTS, DEFAULT_OBS_LOG_SPAN_EVENTS)
.trim()
.to_ascii_lowercase()
.as_str()
{
"close" => FmtSpan::CLOSE,
"full" => FmtSpan::FULL,
"none" | "" => FmtSpan::NONE,
unknown => {
// An unrecognised value must not silently re-enable span events.
eprintln!("{STDERR_WARNING_PREFIX} obs: unknown {ENV_OBS_LOG_SPAN_EVENTS}={unknown:?}; falling back to none");
FmtSpan::NONE
}
}
}
/// Whether JSON log records embed the full ancestor span list.
///
/// `with_span_list(true)` re-serializes every ancestor span on each record, so
/// it is reserved for verbose levels where that context earns its volume.
pub(super) fn resolve_span_list(logger_level: &str) -> bool {
effective_verbose_logging(logger_level)
}
#[cfg(unix)]
pub(super) fn validate_stdout_sink(file_appender: &RollingAppender) -> Result<(), TelemetryError> {
use std::os::fd::AsFd;
@@ -222,7 +254,12 @@ where
request_id
}
pub(super) fn build_json_log_layer<S, W>(writer: W, enable_ansi: bool, span_events: FmtSpan) -> impl tracing_subscriber::Layer<S>
pub(super) fn build_json_log_layer<S, W>(
writer: W,
enable_ansi: bool,
span_events: FmtSpan,
span_list: bool,
) -> impl tracing_subscriber::Layer<S>
where
S: Subscriber + for<'span> LookupSpan<'span>,
W: for<'writer> tracing_subscriber::fmt::MakeWriter<'writer> + Send + Sync + 'static,
@@ -239,7 +276,7 @@ where
.json()
.flatten_event(true)
.with_current_span(true)
.with_span_list(true)
.with_span_list(span_list)
.with_span_events(span_events)
.map_event_format(RequestIdJsonFormat::new)
}
@@ -311,11 +348,12 @@ pub(super) fn init_local_logging(
fn init_stdout_only(_config: &OtelConfig, logger_level: &str, is_production: bool) -> Result<OtelGuard, TelemetryError> {
let env_filter = build_env_filter(logger_level, None);
let (nb, guard) = tracing_appender::non_blocking(std::io::stdout());
let span_events = if is_production { FmtSpan::CLOSE } else { FmtSpan::FULL };
let span_events = resolve_span_events();
let span_list = resolve_span_list(logger_level);
// Keep stdout formatting JSON-shaped even in local-only mode so operators
// can ship the same log schema to external collectors if needed.
let fmt_layer = build_json_log_layer(nb, std::io::stdout().is_terminal(), span_events);
let fmt_layer = build_json_log_layer(nb, std::io::stdout().is_terminal(), span_events, span_list);
tracing_subscriber::registry()
.with(env_filter)
@@ -413,11 +451,12 @@ fn init_file_logging_internal(
// ── 4. Build subscriber layers ────────────────────────────────────────────
let env_filter = build_env_filter(logger_level, None);
let span_events = if is_production { FmtSpan::CLOSE } else { FmtSpan::FULL };
let span_events = resolve_span_events();
let span_list = resolve_span_list(logger_level);
// File output stays machine-readable and free of ANSI sequences so the
// resulting files are safe to parse or ship to log processors.
let file_layer = build_json_log_layer(non_blocking, false, span_events.clone());
let file_layer = build_json_log_layer(non_blocking, false, span_events.clone(), span_list);
// Optional stdout mirror: enabled explicitly via `log_stdout_enabled`, or
// unconditionally in non-production environments so developers still see
@@ -425,7 +464,10 @@ fn init_file_logging_internal(
let (stdout_layer, stdout_guard) = if resolve_file_stdout_mirror(config.log_stdout_enabled, is_production) {
let (stdout_nb, stdout_guard) = tracing_appender::non_blocking(std::io::stdout());
let enable_color = std::io::stdout().is_terminal();
(Some(build_json_log_layer(stdout_nb, enable_color, span_events)), Some(stdout_guard))
(
Some(build_json_log_layer(stdout_nb, enable_color, span_events, span_list)),
Some(stdout_guard),
)
} else {
(None, None)
};
@@ -773,7 +815,7 @@ mod tests {
fn render_json_log(enable_ansi: bool) -> (String, Value) {
let writer = SharedWriter::default();
let layer = build_json_log_layer(writer.clone(), enable_ansi, FmtSpan::NONE);
let layer = build_json_log_layer(writer.clone(), enable_ansi, FmtSpan::NONE, false);
let subscriber = Registry::default().with(layer);
tracing::subscriber::with_default(subscriber, || {
@@ -793,9 +835,32 @@ mod tests {
(raw, parsed)
}
fn render_event_in_nested_spans(depth: usize, span_list: bool) -> String {
let writer = SharedWriter::default();
let layer = build_json_log_layer(writer.clone(), false, FmtSpan::NONE, span_list);
let subscriber = Registry::default().with(layer);
tracing::subscriber::with_default(subscriber, || {
let spans = (0..depth)
.map(|level| tracing::info_span!("layer", depth = level, payload = "x".repeat(1024)))
.collect::<Vec<_>>();
let _guards = spans.iter().map(tracing::Span::enter).collect::<Vec<_>>();
info!(component = "obs_contract_test", "nested event");
});
String::from_utf8(
writer
.inner
.lock()
.expect("shared writer lock should not be poisoned")
.clone(),
)
.expect("log output should be valid UTF-8")
}
fn render_json_log_with_request_span() -> Value {
let writer = SharedWriter::default();
let layer = build_json_log_layer(writer.clone(), false, FmtSpan::NONE);
let layer = build_json_log_layer(writer.clone(), false, FmtSpan::NONE, false);
let subscriber = Registry::default().with(layer);
tracing::subscriber::with_default(subscriber, || {
@@ -818,7 +883,7 @@ mod tests {
fn render_json_log_with_recovery_monitor_child_span() -> Value {
let writer = SharedWriter::default();
let layer = build_json_log_layer(writer.clone(), false, FmtSpan::NONE);
let layer = build_json_log_layer(writer.clone(), false, FmtSpan::NONE, true);
let subscriber = Registry::default().with(layer);
tracing::subscriber::with_default(subscriber, || {
@@ -1075,6 +1140,10 @@ mod tests {
assert_eq!(parsed["request-id"], Value::String("req-123".to_string()));
assert_eq!(parsed["message"], Value::String("inside request span".to_string()));
assert_eq!(parsed["span"]["request_id"], Value::String("req-123".to_string()));
assert!(
parsed.get("spans").is_none(),
"ancestor span list must stay out of records when span_list is disabled: {parsed}"
);
}
#[test]
@@ -1112,4 +1181,65 @@ mod tests {
assert_eq!(resolve_log_cleanup_file_pattern(&config, "rustfs.log"), "rustfs.log");
}
/// Regression for backlog#2642: records must not embed the ancestor span
/// list, so their size stays flat as the enclosing span chain deepens.
#[test]
fn record_size_does_not_grow_with_span_depth_when_span_list_is_disabled() {
let shallow = render_event_in_nested_spans(1, false);
let deep = render_event_in_nested_spans(32, false);
assert!(
deep.len() <= shallow.len() * 2,
"ancestor spans leaked into the record: shallow={} deep={}",
shallow.len(),
deep.len()
);
// The flag is what keeps it bounded: re-enabling the list must show the
// growth the default avoids.
let deep_with_list = render_event_in_nested_spans(32, true);
assert!(
deep_with_list.len() > deep.len(),
"enabling the span list must reintroduce per-ancestor growth"
);
}
#[test]
fn span_events_default_to_none_and_parse_overrides() {
temp_env::with_var(ENV_OBS_LOG_SPAN_EVENTS, None::<&str>, || {
assert_eq!(resolve_span_events(), FmtSpan::NONE);
});
temp_env::with_var(ENV_OBS_LOG_SPAN_EVENTS, Some("close"), || {
assert_eq!(resolve_span_events(), FmtSpan::CLOSE);
});
temp_env::with_var(ENV_OBS_LOG_SPAN_EVENTS, Some("FULL"), || {
assert_eq!(resolve_span_events(), FmtSpan::FULL);
});
temp_env::with_var(ENV_OBS_LOG_SPAN_EVENTS, Some("verbose"), || {
assert_eq!(
resolve_span_events(),
FmtSpan::NONE,
"an unrecognised value must not re-enable span events"
);
});
}
#[test]
fn span_list_is_reserved_for_verbose_levels() {
temp_env::with_var("RUST_LOG", None::<&str>, || {
assert!(!resolve_span_list("info"), "info records must not embed the ancestor span list");
assert!(!resolve_span_list("warn"));
assert!(!resolve_span_list("error"));
assert!(resolve_span_list("debug"));
assert!(resolve_span_list("trace"));
});
temp_env::with_var("RUST_LOG", Some("debug"), || {
assert!(
resolve_span_list("info"),
"RUST_LOG=debug overrides the base level and should keep span context"
);
});
}
}
+5 -4
View File
@@ -68,7 +68,7 @@ use std::{fs, io::IsTerminal, time::Duration};
use tracing::{info, warn};
use tracing_error::ErrorLayer;
use tracing_opentelemetry::{MetricsLayer, OpenTelemetryLayer};
use tracing_subscriber::{fmt::format::FmtSpan, layer::SubscriberExt, util::SubscriberInitExt};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
const GET_OBJECT_DURATION_HISTOGRAM_METRICS: &[&str] = &[
"rustfs_io_get_object_request_duration_seconds",
@@ -184,7 +184,8 @@ pub(super) fn init_observability_http(
// active, the OpenTelemetry bridge is the authoritative sink for
// `tracing` events unless local file logging is needed as a fallback.
}
let span_events = if is_production { FmtSpan::CLOSE } else { FmtSpan::FULL };
let span_events = crate::telemetry::local::resolve_span_events();
let span_list = crate::telemetry::local::resolve_span_list(logger_level);
// ── Case 2: File Logging
// If a log directory is configured and OTLP log export is unavailable, use
// the same rolling-file behavior as the local-only telemetry backend.
@@ -221,7 +222,7 @@ pub(super) fn init_observability_http(
crate::telemetry::local::validate_stdout_sink(&file_appender)?;
let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
let file_layer = build_json_log_layer(non_blocking, false, span_events.clone());
let file_layer = build_json_log_layer(non_blocking, false, span_events.clone(), span_list);
let cleanup_handle = spawn_cleanup_task(config, log_directory, log_filename, keep_files);
Ok((file_layer, guard, cleanup_handle, rotation_str))
})();
@@ -267,7 +268,7 @@ pub(super) fn init_observability_http(
if force_stdout_logging || crate::telemetry::local::resolve_file_stdout_mirror(config.log_stdout_enabled, is_production) {
let (stdout_nb, stdout_g) = tracing_appender::non_blocking(std::io::stdout());
stdout_guard = Some(stdout_g);
stdout_layer_opt = Some(build_json_log_layer(stdout_nb, std::io::stdout().is_terminal(), span_events));
stdout_layer_opt = Some(build_json_log_layer(stdout_nb, std::io::stdout().is_terminal(), span_events, span_list));
}
let local_file_fallback_enabled = file_layer_opt.is_some();
let stdout_mirror_enabled = stdout_guard.is_some();
+112
View File
@@ -57,6 +57,7 @@ const EXCESSIVE_EMPTY_CHUNKS_ERROR: &str = "HTTP body returned too many empty ch
pub const INTERNODE_DISK_ERROR_HEADER: &str = "x-rustfs-disk-error";
pub const INTERNODE_FILE_NOT_FOUND: &str = "file-not-found";
pub const INTERNODE_VOLUME_NOT_FOUND: &str = "volume-not-found";
pub const INTERNODE_FILE_CORRUPT: &str = "file-corrupt";
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub enum InternodeHttpErrorKind {
@@ -185,6 +186,7 @@ pub struct InternodeHttpError {
enum RemoteDiskErrorKind {
FileNotFound,
VolumeNotFound,
FileCorrupt,
}
impl std::fmt::Debug for InternodeHttpError {
@@ -215,6 +217,10 @@ impl InternodeHttpError {
self.remote_disk_error == Some(RemoteDiskErrorKind::VolumeNotFound)
}
pub fn is_remote_file_corrupt(&self) -> bool {
self.remote_disk_error == Some(RemoteDiskErrorKind::FileCorrupt)
}
fn new(kind: InternodeHttpErrorKind, context: InternodeHttpRequestContext) -> Self {
Self {
kind,
@@ -328,6 +334,20 @@ pub fn new_test_remote_volume_not_found_http_io_error() -> io::Error {
.into_io_error()
}
#[doc(hidden)]
pub fn new_test_remote_file_corrupt_http_io_error() -> io::Error {
InternodeHttpError::with_remote_disk_error(
InternodeHttpErrorKind::HttpStatus(reqwest::StatusCode::INTERNAL_SERVER_ERROR),
InternodeHttpRequestContext {
method: "GET".to_string(),
target: READ_FILE_STREAM_PATH.to_string(),
operation: Some(INTERNODE_OPERATION_READ_FILE_STREAM),
},
RemoteDiskErrorKind::FileCorrupt,
)
.into_io_error()
}
fn add_root_certificates_from_der(builder: reqwest::ClientBuilder, certs_der: &[Vec<u8>]) -> reqwest::ClientBuilder {
let mut b = builder;
for der in certs_der {
@@ -850,6 +870,7 @@ fn classify_http_response(
let remote_disk_error = match headers.get(INTERNODE_DISK_ERROR_HEADER).and_then(|value| value.to_str().ok()) {
Some(INTERNODE_FILE_NOT_FOUND) => Some(RemoteDiskErrorKind::FileNotFound),
Some(INTERNODE_VOLUME_NOT_FOUND) => Some(RemoteDiskErrorKind::VolumeNotFound),
Some(INTERNODE_FILE_CORRUPT) => Some(RemoteDiskErrorKind::FileCorrupt),
_ => None,
};
ClassifiedHttpResponse { kind, remote_disk_error }
@@ -2047,6 +2068,7 @@ mod tests {
assert_eq!(INTERNODE_DISK_ERROR_HEADER, "x-rustfs-disk-error");
assert_eq!(INTERNODE_FILE_NOT_FOUND, "file-not-found");
assert_eq!(INTERNODE_VOLUME_NOT_FOUND, "volume-not-found");
assert_eq!(INTERNODE_FILE_CORRUPT, "file-corrupt");
}
#[test]
@@ -2090,6 +2112,48 @@ mod tests {
assert!(wrong_operation.remote_disk_error.is_none());
}
#[test]
fn classify_http_response_scopes_corrupt_disk_error_to_read_failures() {
let mut headers = HeaderMap::new();
headers.insert(INTERNODE_DISK_ERROR_HEADER, INTERNODE_FILE_CORRUPT.parse().expect("valid error token"));
let classified = classify_http_response(
reqwest::StatusCode::INTERNAL_SERVER_ERROR,
&headers,
Some(INTERNODE_OPERATION_READ_FILE_STREAM),
);
assert_eq!(classified.remote_disk_error, Some(RemoteDiskErrorKind::FileCorrupt));
for status in [reqwest::StatusCode::NOT_FOUND, reqwest::StatusCode::SERVICE_UNAVAILABLE] {
assert!(
classify_http_response(status, &headers, Some(INTERNODE_OPERATION_READ_FILE_STREAM))
.remote_disk_error
.is_none()
);
}
for operation in [
None,
Some(INTERNODE_OPERATION_WALK_DIR),
Some(INTERNODE_OPERATION_PUT_FILE_STREAM),
] {
assert!(
classify_http_response(reqwest::StatusCode::INTERNAL_SERVER_ERROR, &headers, operation)
.remote_disk_error
.is_none()
);
}
headers.insert(INTERNODE_DISK_ERROR_HEADER, "unknown-disk-error".parse().expect("valid unknown token"));
for headers in [&headers, &HeaderMap::new()] {
assert!(
classify_http_response(
reqwest::StatusCode::INTERNAL_SERVER_ERROR,
headers,
Some(INTERNODE_OPERATION_READ_FILE_STREAM),
)
.remote_disk_error
.is_none()
);
}
}
#[derive(Clone, Default)]
struct TestState {
head_count: Arc<AtomicUsize>,
@@ -2158,6 +2222,16 @@ mod tests {
let addr = listener.local_addr().expect("listener local address should be available");
let app = Router::new()
.route("/stream", get(get_stream).head(reject_head).put(accept_put))
.route(
READ_FILE_STREAM_PATH,
get(|| async {
(
StatusCode::INTERNAL_SERVER_ERROR,
[(INTERNODE_DISK_ERROR_HEADER, INTERNODE_FILE_CORRUPT)],
"read file err file corrupt",
)
}),
)
.route(WALK_DIR_PATH, get(get_stream))
.route("/reject-put", get(get_stream).put(reject_put))
.route("/stall", get(get_stalling_stream))
@@ -2172,6 +2246,44 @@ mod tests {
Some((format!("http://{addr}/stream"), handle))
}
#[tokio::test]
async fn http_readers_preserve_remote_file_corruption() {
let (url, server) = start_test_server(TestState::default())
.await
.expect("corruption regression server must bind");
let url = format!("{}{READ_FILE_STREAM_PATH}", url.strip_suffix("/stream").expect("test server URL suffix"));
let byte_error = HttpReader::new(url.clone(), Method::GET, HeaderMap::new(), None)
.await
.err()
.expect("byte reader must reject corrupt shard response");
let chunk_error = HttpChunkReader::new_with_stall_timeout(url, Method::GET, HeaderMap::new(), None, None)
.await
.err()
.expect("chunk reader must reject corrupt shard response");
for error in [byte_error, chunk_error] {
let source = error
.get_ref()
.and_then(|source| source.downcast_ref::<InternodeHttpError>())
.expect("HTTP error must retain typed internode source");
assert!(source.is_remote_file_corrupt());
assert!(!source.is_remote_file_not_found());
assert!(!source.is_remote_volume_not_found());
assert_eq!(
source.kind(),
InternodeHttpErrorKind::HttpStatus(reqwest::StatusCode::INTERNAL_SERVER_ERROR)
);
let cloned = clone_internode_http_io_error(&error).expect("typed transport error must be cloneable");
assert!(
cloned
.get_ref()
.and_then(|source| source.downcast_ref::<InternodeHttpError>())
.expect("clone must retain typed internode source")
.is_remote_file_corrupt()
);
}
server.abort();
}
struct BlockedH2Server {
url: String,
shutdown: tokio::sync::oneshot::Sender<()>,
+33 -6
View File
@@ -83,13 +83,11 @@ impl ProviderVersionCapabilities {
if tier_type.eq_ignore_ascii_case("s3")
|| tier_type.eq_ignore_ascii_case("rustfs")
|| tier_type.eq_ignore_ascii_case("minio")
|| tier_type.eq_ignore_ascii_case("r2")
|| tier_type.eq_ignore_ascii_case("wasabi")
{
let list_object_versions = tier_type.eq_ignore_ascii_case("s3")
|| tier_type.eq_ignore_ascii_case("rustfs")
|| tier_type.eq_ignore_ascii_case("minio")
|| tier_type.eq_ignore_ascii_case("r2");
|| tier_type.eq_ignore_ascii_case("minio");
Self {
raw_version_header: Some(X_AMZ_VERSION_ID),
bucket_versioning_state: list_object_versions,
@@ -101,6 +99,17 @@ impl ProviderVersionCapabilities {
},
exact_get_delete: true,
}
} else if tier_type.eq_ignore_ascii_case("r2") {
// R2 exposes an x-amz-version-id on some PUT responses, but it does
// not provide routable S3 object versions. GET/DELETE must therefore
// use the unversioned object path and must not call version APIs.
Self {
raw_version_header: None,
bucket_versioning_state: false,
list_object_versions: false,
conditional_create: ConditionalCreateCapability::IfNoneMatchStar,
exact_get_delete: false,
}
} else if tier_type.eq_ignore_ascii_case("aliyun") {
Self {
raw_version_header: Some(X_OSS_VERSION_ID),
@@ -210,8 +219,6 @@ mod tests {
("RustFS", "x-amz-version-id"),
("minio", "x-amz-version-id"),
("MinIO", "x-amz-version-id"),
("r2", "x-amz-version-id"),
("R2", "x-amz-version-id"),
("wasabi", "x-amz-version-id"),
("Wasabi", "x-amz-version-id"),
("aliyun", "x-oss-version-id"),
@@ -255,6 +262,26 @@ mod tests {
);
}
#[test]
fn r2_ignores_non_routable_put_version_header() {
let mut headers = HeaderMap::new();
headers.insert("x-amz-version-id", HeaderValue::from_static("r2-response-token"));
let capabilities = ProviderVersionCapabilities::for_tier_type("r2");
assert_eq!(
capabilities
.raw_version_id(&headers)
.expect("R2 header parsing should succeed"),
None
);
assert_eq!(
capabilities
.remote_version(&headers, BucketVersioningState::Disabled)
.expect("R2 uses unversioned routing"),
RemoteVersion::Disabled
);
}
#[test]
fn provider_version_missing_header_is_unknown_until_bucket_state_is_known() {
let headers = HeaderMap::new();
@@ -280,7 +307,7 @@ mod tests {
("s3", true, true, ConditionalCreateCapability::IfNoneMatchStar, true),
("rustfs", true, true, ConditionalCreateCapability::Unsupported, true),
("minio", true, true, ConditionalCreateCapability::Unsupported, true),
("r2", true, true, ConditionalCreateCapability::IfNoneMatchStar, true),
("r2", false, false, ConditionalCreateCapability::IfNoneMatchStar, false),
("wasabi", false, false, ConditionalCreateCapability::Unsupported, true),
("aliyun", false, false, ConditionalCreateCapability::Unsupported, true),
("tencent", false, false, ConditionalCreateCapability::Unsupported, true),
+8 -5
View File
@@ -16,8 +16,8 @@
use crate::RUSTFS_META_BUCKET;
use crate::scanner_budget::{ScannerCycleBudget, ScannerCycleBudgetConfig};
use crate::scanner_io::{
DataUsageCacheReuseOptions, DataUsageCacheScanState, ScannerCheckpointPersistResult, ScannerDiskScanOptions,
ScannerDiskScanOutcome, ScannerIODisk, acquire_scanner_cache_locks, cache_root_entry_info,
DataUsageCacheReuseOptions, DataUsageCacheScanState, ScannerCheckpointPersistContext, ScannerCheckpointPersistResult,
ScannerDiskScanOptions, ScannerDiskScanOutcome, ScannerIODisk, acquire_scanner_cache_locks, cache_root_entry_info,
current_cache_root_or_prepare_with_generation, persist_scanner_checkpoint, scanner_set_disk_inventory,
};
use crate::storage_api::owner::NS_SCANNER_PROTOCOL_VERSION;
@@ -840,12 +840,15 @@ async fn scan_and_persist_local_bucket(
}
match persist_scanner_checkpoint(
set.clone(),
ScannerCheckpointPersistContext {
ctx: &scan_ctx,
expected_publication_epoch,
cycle: next_cycle,
leader_epoch,
},
&cache_name,
&checkpoint,
&mut revisions,
expected_publication_epoch,
next_cycle,
leader_epoch,
)
.await
{
+1 -3
View File
@@ -1778,9 +1778,7 @@ where
mark_scan_cycle_idle(cycle_info, &mut cycle_metrics_guard).await;
return ScannerCycleOutcome::Failed;
}
BackgroundHealInfoReadStatus::ErasureSd
| BackgroundHealInfoReadStatus::Loaded
| BackgroundHealInfoReadStatus::Missing => {}
BackgroundHealInfoReadStatus::Loaded | BackgroundHealInfoReadStatus::Missing => {}
}
let mut background_heal_info = background_heal_read.info;
let background_heal_epoch = background_heal_read.expected_epoch;
-15
View File
@@ -28,7 +28,6 @@ pub struct BackgroundHealInfo {
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(super) enum BackgroundHealInfoReadStatus {
ErasureSd,
Loaded,
Missing,
Blocked,
@@ -66,15 +65,6 @@ pub(super) async fn read_background_heal_info_with_epoch<S>(storeapi: Arc<S>) ->
where
S: ScannerStorage,
{
// Skip for ErasureSD setup
if storeapi.setup_is_erasure_sd().await {
return BackgroundHealInfoRead {
info: BackgroundHealInfo::default(),
expected_epoch: None,
status: BackgroundHealInfoReadStatus::ErasureSd,
};
}
let expected_epoch = scanner_publication_epoch(storeapi.clone()).await;
if expected_epoch.is_none() {
return BackgroundHealInfoRead {
@@ -146,11 +136,6 @@ pub(super) async fn save_background_heal_info_for_epoch<S>(
) where
S: ScannerStorage,
{
// Skip for ErasureSD setup
if storeapi.setup_is_erasure_sd().await {
return;
}
// Serialize to JSON
let data = match serde_json::to_vec(&info) {
Ok(data) => data,
+8
View File
@@ -1806,6 +1806,14 @@ impl FolderScanner {
continue;
}
// Do not start another metadata read while foreground work is
// active. The post-object timer protects the next request only
// after the read has already been dispatched; this admission
// point keeps the scanner from extending a single-disk I/O
// burst across foreground requests.
if crate::workload_admission::foreground_workload_activity() > 0 {
self.sleeper.sleep_folder().await;
}
let timer = self.sleeper.timer();
let heal_enabled = this_hash.mod_alt(
+2 -2
View File
@@ -1609,8 +1609,8 @@ use dirty_usage::*;
use guards::*;
pub(crate) use cache::{
DataUsageCacheReuseOptions, DataUsageCacheScanState, ScannerCheckpointPersistResult, acquire_scanner_cache_locks,
current_cache_root_or_prepare_with_generation, persist_scanner_checkpoint,
DataUsageCacheReuseOptions, DataUsageCacheScanState, ScannerCheckpointPersistContext, ScannerCheckpointPersistResult,
acquire_scanner_cache_locks, current_cache_root_or_prepare_with_generation, persist_scanner_checkpoint,
};
pub use dirty_usage::{
ScannerDirtyUsageAckError, ScannerDirtyUsageBucket, ScannerDirtyUsageClearObserver, ScannerDirtyUsageMutationObserver,
+57 -8
View File
@@ -31,6 +31,41 @@ pub(crate) enum ScannerCheckpointPersistResult {
Failed(StorageError),
}
pub(crate) struct ScannerCheckpointPersistContext<'a> {
pub(crate) ctx: &'a CancellationToken,
pub(crate) expected_publication_epoch: u64,
pub(crate) cycle: u64,
pub(crate) leader_epoch: u64,
}
const CHECKPOINT_FOREGROUND_QUIET_WAIT: Duration = Duration::from_secs(1);
async fn wait_for_checkpoint_foreground_quiet(ctx: &CancellationToken) -> bool {
let deadline = tokio::time::Instant::now() + CHECKPOINT_FOREGROUND_QUIET_WAIT;
loop {
if crate::workload_admission::foreground_workload_activity() == 0 {
return true;
}
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
if remaining.is_zero() {
return false;
}
let backoff = Duration::from_millis(
crate::workload_admission::foreground_workload_activity()
.saturating_mul(10)
.min(250),
)
.max(Duration::from_millis(10))
.min(remaining);
tokio::select! {
_ = ctx.cancelled() => return false,
_ = tokio::time::sleep(backoff) => {}
}
}
}
/// Persist one bounded checkpoint and refresh its CAS revisions.
///
/// Local and remote workers share the same publication/leader fencing and
@@ -38,23 +73,37 @@ pub(crate) enum ScannerCheckpointPersistResult {
/// at the caller because those guards have different concrete types.
pub(crate) async fn persist_scanner_checkpoint<S>(
store: Arc<S>,
context: ScannerCheckpointPersistContext<'_>,
cache_name: &str,
checkpoint: &DataUsageCache,
revisions: &mut DataUsageCacheRevisions,
expected_publication_epoch: u64,
cycle: u64,
leader_epoch: u64,
) -> ScannerCheckpointPersistResult
where
S: ScannerObjectIO + ScannerConfigObjectDelete,
{
if crate::remote_scanner::validate_remote_scanner_request_fence_with_store(cycle, leader_epoch, store.clone())
let foreground_quiet = wait_for_checkpoint_foreground_quiet(context.ctx).await;
if !foreground_quiet && context.ctx.is_cancelled() {
return ScannerCheckpointPersistResult::FenceChanged;
}
if !foreground_quiet {
debug!(
target: "rustfs::scanner::io",
event = EVENT_SCANNER_CACHE_PERSIST_STATE,
component = LOG_COMPONENT_SCANNER,
subsystem = LOG_SUBSYSTEM_IO,
cache_name,
state = "checkpoint_foreground_wait_expired",
"Scanner checkpoint foreground quiet wait expired; preserving bounded progress"
);
}
if crate::remote_scanner::validate_remote_scanner_request_fence_with_store(context.cycle, context.leader_epoch, store.clone())
.await
.is_err()
{
return ScannerCheckpointPersistResult::FenceChanged;
}
if scanner_publication_admission_for_epoch(store.clone(), expected_publication_epoch)
if scanner_publication_admission_for_epoch(store.clone(), context.expected_publication_epoch)
.await
.is_none()
{
@@ -62,16 +111,16 @@ where
}
if let Err(error) = checkpoint
.save_with_revisions_for_epoch(store.clone(), cache_name, revisions, expected_publication_epoch)
.save_with_revisions_for_epoch(store.clone(), cache_name, revisions, context.expected_publication_epoch)
.await
{
return ScannerCheckpointPersistResult::Failed(error);
}
if crate::remote_scanner::validate_remote_scanner_request_fence_with_store(cycle, leader_epoch, store.clone())
if crate::remote_scanner::validate_remote_scanner_request_fence_with_store(context.cycle, context.leader_epoch, store.clone())
.await
.is_err()
|| scanner_publication_admission_for_epoch(store.clone(), expected_publication_epoch)
|| scanner_publication_admission_for_epoch(store.clone(), context.expected_publication_epoch)
.await
.is_none()
{
+6 -3
View File
@@ -1191,12 +1191,15 @@ impl ScannerIOCache for SetDisks {
}
match persist_scanner_checkpoint(
store_clone_clone.clone(),
ScannerCheckpointPersistContext {
ctx: &ctx_clone,
expected_publication_epoch: expected_publication_epoch_clone,
cycle: want_cycle,
leader_epoch,
},
cache_name.as_str(),
&checkpoint,
&mut revisions,
expected_publication_epoch_clone,
want_cycle,
leader_epoch,
)
.await
{
+1 -1
View File
@@ -39,7 +39,7 @@ allow-git = [
# Official s3s repository. Temporarily pinned to the merged generic REST
# SigV4 payload-checksum fix until it is available in a crates.io release.
# owner: marshawcoco review: 2026-10
# "https://github.com/s3s-project/s3s.git",
"https://github.com/s3s-project/s3s.git",
# RustFS fork carrying presigned expiry and constant-time authentication fixes.
# owner: rustfs-maintainers review: 2026-10
# "https://github.com/rustfs/s3s.git",
+5
View File
@@ -18,6 +18,11 @@ operators should start with:
| [Replication object size limits](operations/replication-object-size-limits.md) | Multipart routing, large-object limits, and retry characteristics. |
| [Replication outbound transport](operations/replication-outbound-transport.md) | Integrity headers, generic target behavior, and transport knobs. |
For the erasure-coded cluster lifecycle (planning, parity and `EC:0`,
expansion, rebalance, decommission, heal, drive replacement, restart
recovery, and the `rc` CLI mapping), start with
[Cluster and erasure-coding lifecycle operations](operations/cluster-lifecycle-operations.md).
For persisted administrator bucket tasks and bucket recreation, see
[Bucket heal recovery](operations/bucket-heal-recovery.md).
+1
View File
@@ -17,6 +17,7 @@ This document describes the algorithm as implemented on `main`. The *invariant*
- [placement-repair-invariants.md](placement-repair-invariants.md) — owns object-to-**set** placement (which erasure set a key lands in), per-set readiness/lock quorum, scanner/heal admission, and the behavior-change gates.
- [ecstore-layout-boundary.md](ecstore-layout-boundary.md) — owns the ecstore module ownership map, `FormatV3` set-ordering and disk-UUID-position invariants, and where the erasure engine physically lives.
- [decommission-compatibility.md](decommission-compatibility.md) — owns moving encoded objects between pools (decommission/rebalance) and its persisted `PoolMeta` contract.
- [../operations/cluster-lifecycle-operations.md](../operations/cluster-lifecycle-operations.md) — owns the operator runbook (planning, `EC:0` consequences, expansion, rebalance, decommission, heal, drive replacement, restart recovery); this spec stays normative for the rules it cites.
- [../operations/tier-ilm-debugging.md](../operations/tier-ilm-debugging.md) — owns the ILM/tier runtime runbook, the dual internal-metadata-key table, the defensive binary-UUID read pattern, and the `xl.meta` inspection tooling (`dump_fileinfo` / `dump_versions`).
- [AGENTS.md](../../AGENTS.md) "Cross-Cutting Domain Invariants" — dual internal metadata keys, defensive UUID reads, unversioned tier buckets.
- [../../ARCHITECTURE.md](../../ARCHITECTURE.md) — crate roles (`ecstore`, `filemeta`, the `rustfs-erasure-codec` codec fork, `heal`, `scanner`).
@@ -41,7 +41,7 @@ Every route asserted in `rustfs/src/admin/route_registration_test.rs` is registe
| Pools decommission / cancel / clear | 部分兼容 | `rustfs/src/admin/handlers/pools.rs` returns `NotImplemented` when endpoints are not initialized (single-pool or uninitialized clusters). |
| `/v3/top/drives`, `/v3/top/net` | 缺失 | Only `/v3/top/locks` is registered (`rustfs/src/admin/handlers/diagnostics.rs`). |
| Bucket / site replication per-object diff | 缺失 | `/v3/replicationmetrics` and site-replication status exist; no diff endpoint. |
| MRF (most-recent-failures) replication metrics breakdown | 缺失 | Only the generic `/v3/metrics` stream and replication metrics wire (`rustfs/src/admin/replication_metrics_wire.rs`). |
| MRF (most-recent-failures) replication metrics breakdown | 缺失 | Only the generic `/v3/realtime` stream and replication metrics wire (`rustfs/src/admin/replication_metrics_wire.rs`). |
Formerly-missing families that are now registered and therefore not exceptions: `/v3/healthinfo`, `/v3/obdinfo`, `/v3/force-unlock`, `/v3/top/locks`, `/v3/speedtest*`, `/v3/log`, `/v3/trace`, `/v3/profile`, `/v3/profiling/*`, `/v3/idp/{ldap|openid}/*`, `/v3/idp-config/*`.
@@ -0,0 +1,290 @@
# Cluster and erasure-coding lifecycle operations
**Use this when:** planning, starting, expanding, rebalancing, decommissioning, healing, replacing drives in, or restarting an erasure-coded RustFS deployment, or deciding whether an `EC:<parity>` setting (including `EC:0`) is safe for a workload.
**Source of truth:** `crates/ecstore/src/layout/disks_layout.rs` (`DisksLayout::from_volumes`, `SET_SIZES`, `RUSTFS_ERASURE_SET_DRIVE_COUNT`), `crates/ecstore/src/config/storageclass.rs` (`default_parity_count`, `validate_parity_inner`, `Config::should_inline`, `Config::effective_inline_block`), `crates/ecstore/src/set_disk/mod.rs` (`resolve_write_layout`, `WriteLayout::from_parity`), `crates/ecstore/src/set_disk/metadata.rs` (`object_quorum_from_meta`, `common_parity`), `crates/ecstore/src/set_disk/ops/heal.rs` (no-parity `cannot_heal` classification), `crates/ecstore/src/store/init_format.rs` (`check_format_erasure_value_for_topology`), `rustfs/src/admin/handlers/pools.rs`, `rustfs/src/admin/handlers/rebalance.rs`, `rustfs/src/admin/handlers/heal.rs`, and `rustfs/src/admin/route_policy.rs` (admin surface).
This runbook is the operator entry point. It restates only what an operator needs to act and links the normative contracts for everything else: [erasure-coding.md](../architecture/erasure-coding.md) (algorithm, quorum, on-disk format), [ecstore-layout-boundary.md](../architecture/ecstore-layout-boundary.md) (immutable layout), [decommission-compatibility.md](../architecture/decommission-compatibility.md) (decommission and rebalance contract), [heal-concurrency-model.md](../architecture/heal-concurrency-model.md) (heal versus write locking), [rolling-restart.md](rolling-restart.md) (restarts), [replacement-generation-recovery.md](replacement-generation-recovery.md) (drive replacement recovery), and [scanner-runtime-controls.md](scanner-runtime-controls.md) (heal and scanner knobs). Admin routes below require an authenticated request holding the matching `AdminAction`; the route to action mapping is owned by `rustfs/src/admin/route_policy.rs` (see [admin-route-action-snapshot.md](../architecture/admin-route-action-snapshot.md)).
## 1. Immutable layout boundaries
A pool's endpoint count, set count, drives per set, position in the pool list, and the disk UUID positions in `FormatV3` are persisted layout. On startup `check_format_erasure_value_for_topology` (`crates/ecstore/src/store/init_format.rs`) compares the stored format with the configured drive count and set width and fails permanently with `PoolTopologyMismatch` (or `UnsupportedSnsdExpansion` for a single-drive deployment) instead of migrating. Restore the original endpoints and set width; do not delete `format.json` to get past the error. Details and the regression matrix are in [pool-layout-compatibility.md](../testing/pool-layout-compatibility.md).
Never do these:
- overwrite an existing pool with more or fewer endpoints, or change `RUSTFS_ERASURE_SET_DRIVE_COUNT` for an initialized pool;
- reorder volume arguments or move a drive directory to another slot;
- delete, copy, or rename object shards, `xl.meta`, or anything under `.rustfs.sys` (`RUSTFS_META_BUCKET` in `crates/ecstore/src/disk/mod.rs`) by hand;
- treat a manual file copy as data movement. Rebalance and decommission move objects under object locks with version re-checks and source cleanup; nothing else does.
Runtime roles:
- **Pool**: one endpoint list with persisted layout. Multi-drive sets contain 2 to 16 drives (`SET_SIZES`); a single local path is the only single-drive layout (`is_single_drive_layout`).
- **Pool leader**: the first endpoint of a pool. Decommission start, cancel, and clear may be sent to any node; when the target pool's first endpoint is remote the handler forwards over authenticated internode RPC (`decommission_peer_target` in `rustfs/src/admin/handlers/pools.rs`), and the leader still enforces `ensure_local_decommission_pool_leader` before mutating state.
- **Cluster-wide state**: rebalance and root heal propagate to peers and recover from persisted metadata on restart (§9).
## 2. Planning and first start
1. Plan endpoints by node, rack, power, and network failure domain. Topology admission is not a high-availability guarantee: a single-node pool loses every shard with its host, and at startup `log_storage_pool_layout` (`rustfs/src/startup_storage.rs`) warns `host_failure_data_unavailable` for every multi-drive pool.
2. Pick each pool's set width. Pools may differ in width, but each pool must split evenly into sets of one width from `SET_SIZES`; `RUSTFS_ERASURE_SET_DRIVE_COUNT` may pin a width that is already a symmetric divisor, nothing else.
3. Pick STANDARD and RRS parity per §4, then validate against the narrowest pool, because an explicit parity must fit every pool.
4. Record the full launch command, endpoint order, pool order, set width, and storage-class variables. They must be identical on every node and on every restart.
5. Give RustFS exclusive ownership of every endpoint path.
Volumes come from positional arguments or `RUSTFS_VOLUMES`; `rustfs` with no arguments behaves as `rustfs server` with volumes from the environment (`rustfs/src/config/cli.rs`). Ellipsis expansion follows MinIO:
```bash
# one pool, four nodes, four drives each: sixteen endpoints
rustfs server http://node{1...4}:9000/data{1...4}
# two pools; every argument must carry an ellipsis once there is more than one
rustfs server http://node{1...4}:9000/data{1...4} http://node{5...8}:9000/data{1...4}
```
Rules enforced by `DisksLayout::from_volumes`: with more than one argument every argument needs an ellipsis; each pool must expand to at least two distinct drive endpoints; a single-drive pool cannot join a multi-pool deployment; a singleton range such as `{3...3}` does not bypass the minimum.
Startup order (`init_startup_storage_foundation` in `rustfs/src/startup_storage.rs`): parse endpoints, validate storage classes against every pool (`ECStore::validate_startup_storage_class`), enforce the unsupported-filesystem policy, initialize local disks, initialize lock clients, then load or create the format quorum and build the store. A storage-class error stops startup before any disk is touched.
## 3. Startup configuration
| Variable | Effect | Boundary |
|---|---|---|
| `RUSTFS_ERASURE_SET_DRIVE_COUNT` | Pin the set width | Must be a valid symmetric divisor of the pool, at most `MAX_ERASURE_SET_DRIVE_COUNT` (16); cannot change an initialized pool |
| `RUSTFS_STORAGE_CLASS_STANDARD` | STANDARD parity as `EC:<n>` | Validated per pool; affects new writes only |
| `RUSTFS_STORAGE_CLASS_RRS` | RRS parity as `EC:<n>` | Validated per pool; an explicit value fails on a single drive, the persisted default `EC:1` resolves to `0` there |
| `RUSTFS_STORAGE_CLASS_OPTIMIZE` | Accepted for MinIO compatibility | Stored on `Config`, but `Config::capacity_optimized` has no caller: it changes nothing at runtime today |
| `RUSTFS_STORAGE_CLASS_INLINE_BLOCK` | Fixed per-shard inline limit (bytesize syntax such as `128KiB`) | Replaces the scaled default in §4.3; values above 128 KiB log `storage_class_inline_block_large` |
An empty `RUSTFS_STORAGE_CLASS_STANDARD` or `RUSTFS_STORAGE_CLASS_RRS` restores the automatic policy and overrides a persisted value (`standard_policy` / `rrs_policy`). `MINIO_`-prefixed spellings of these keys, of `ERASURE_SET_DRIVE_COUNT`, and of `VOLUMES` are mapped onto the `RUSTFS_` names when the `RUSTFS_` name is unset (`apply_external_env_compat` in `crates/utils/src/envs.rs`); when both are set the `RUSTFS_` value wins and the conflict is reported at startup.
Heal knobs (`RUSTFS_HEAL_*`) and their defaults are documented once, in [scanner-runtime-controls.md](scanner-runtime-controls.md#heal-runtime-controls); the deep bitrot cycle is the admin config key `heal.bitrot_cycle` (environment `RUSTFS_SCANNER_BITROT_CYCLE_SECS`), not a `RUSTFS_HEAL_*` variable. Data-movement knobs: `RUSTFS_REBALANCE_MAX_ATTEMPTS` (default `DEFAULT_REBALANCE_MAX_ATTEMPTS` = 3, `crates/ecstore/src/services/rebalance/mod.rs`), `RUSTFS_DECOMMISSION_BUCKET_CONCURRENCY` (default cap 4) and `RUSTFS_DECOMMISSION_ENTRY_CONCURRENCY` (default cap 8, hard cap 64) in `crates/ecstore/src/core/pools.rs`, and `RUSTFS_LIST_OBJECTS_QUORUM` (default `optimal`; `disk`, `reduced`, `optimal`, `auto`, anything else `strict`, per `normalize_list_quorum` in `crates/ecstore/src/store/list_objects.rs`). None of them relaxes object quorum, object locks, or version checks.
## 4. Storage classes, parity, capacity, quorum
### 4.1 Geometry
Each object is Reed-Solomon coded across the `N` drives of one set into `K` data and `M` parity shards, `N = K + M`; any `K` shards rebuild it. The geometry is stored per object in `xl.meta`, so changing the default later rewrites nothing. Objects hash to a set (`get_hashed_set_index`, [placement-repair-invariants.md](../architecture/placement-repair-invariants.md)) and shards rotate within it; sets never share a failure budget.
Default STANDARD parity (`default_parity_count`): 0 for one drive, 1 for 2 to 3, 2 for 4 to 5, 3 for 6 to 7, 4 for 8 to 16. Default RRS parity is 1 (0 on a single drive).
Validation (`validate_parity_inner`): each parity is at most `N/2` for every pool, and STANDARD parity is at least RRS parity **only when both are non-zero**. There is no minimum, so `EC:0` passes on any width (§5). Only `STANDARD` and `REDUCED_REDUNDANCY` are accepted on PUT, CopyObject, and CreateMultipartUpload; AWS labels return `InvalidStorageClass`.
### 4.2 Quorum and capacity
```text
usable capacity ≈ raw × K / N
read quorum = K (metadata vote and shard decode)
write quorum = K, or K + 1 when K == M
delete markers = N/2 + 1 (majority, both write and vote)
```
Sixteen drives at `EC:4`: `K = 12`, about 75 percent usable, four failures tolerated, twelve valid shards needed to read. Two drives at `EC:1`: `K = M = 1`, so one drive down still reads but no longer writes. Internal metadata under `.rustfs.sys` is written with `max_parity`, meaning parity `N/2`, regardless of the configured class. Contract: [erasure-coding.md §2.2 and §10](../architecture/erasure-coding.md#22-parity-selection).
### 4.3 Inline objects
An object is stored inline in `xl.meta` when every shard is at most the per-shard budget (`Config::should_inline`). The default budget is `DEFAULT_INLINE_OBJECT_BUDGET / K` capped at `DEFAULT_INLINE_BLOCK` (256 KiB / K, at most 128 KiB per shard), so a wider set does not raise the maximum inline object size; on a versioned bucket the budget is divided by 8. `RUSTFS_STORAGE_CLASS_INLINE_BLOCK` replaces the scaled default with a fixed per-shard limit. Each drive keeps only its own shard inline, so inline data has the same loss semantics as external shards. Compressed or encrypted streams inline only when the stored size is known and within budget.
### 4.4 Write and read paths
Writes resolve `WriteLayout` per pool (`resolve_write_layout`), encode with `rs-vandermonde` in 1 MiB blocks (`BLOCK_SIZE_V2`), write bitrot-protected shards, and commit only after write quorum. A write below quorum is never reported as success; rollback is best effort and residue is reconciled by heal or the scanner ([erasure-coding.md §7](../architecture/erasure-coding.md#7-write-path-and-write-quorum)). Reads pick the authoritative metadata at read quorum, verify bitrot, decode from at least `K` shards, and fail closed below that; a read that succeeds with missing shards enqueues read-repair ([§8](../architecture/erasure-coding.md#8-read-path-and-read-quorum)).
Multipart parts use the object's geometry and write quorum; `CompleteMultipartUpload` re-checks metadata and commit quorum, so an uploaded part is not a committed object.
## 5. `EC:0`: zero-parity mode
`EC:0` is accepted for STANDARD, for RRS, or for both, on any set width. It gives raw capacity and no redundancy. Every statement here is what the code does, not a guideline.
**Configuration.**
- Explicit `EC:0` produces no startup warning. The `storage_class_zero_redundancy` warning (`publish_storage_class_config` in `crates/ecstore/src/config/mod.rs`) fires only for automatic zero parity, meaning a single-drive pool.
- `STANDARD=EC:2` with `RRS=EC:0` is valid because the ordering rule is skipped when either side is zero. Only objects written with `x-amz-storage-class: REDUCED_REDUNDANCY` then carry the semantics below; STANDARD objects keep parity 2.
- The set's default parity (`SetDisks.default_parity_count`) stays the topology default from `ec_drives_no_config`, not 0. Quorum arithmetic for a multi-drive set therefore takes the parity-present branch of `object_quorum_from_meta`; the `default_parity_count == 0` branch is the single-drive case.
**Writes.** `WriteLayout::from_parity(N, 0)` yields `K = N`, `M = 0`, write quorum `N`. If any drive of the set is unwritable, `put_object` returns `ErasureWriteQuorum` before or after encoding; nothing is committed. Because a zero-parity shard cannot be rebuilt later, the write path verifies every shard it just wrote against its bitrot hash before commit (`verify_written_bitrot_shards` in `put_object` and `put_object_part`, gated on `parity_blocks == 0`) and refuses to commit a shard that already fails.
**Reads.** `common_parity` classifies a parity value of 0 like a delete marker for vote selection (majority of replies), but the resulting read quorum is `data_blocks = N`. `find_file_info_in_quorum` (GET and HEAD) and `pick_valid_fileinfo` (listing cache) then need `N` matching metadata copies, and decode needs `N` shards. With any drive of the set offline, HEAD and GET of every zero-parity object in that set fail with `ErasureReadQuorum`. There is no degraded read.
**Deletes.** Delete markers and version deletes use majority quorum (`disks.len() / 2 + 1`), so a delete can succeed on a set whose reads and writes are failing. A successful delete is not evidence that the set is healthy.
**Heal.** `heal_object_with_explicit_version_regen` computes `cannot_heal` when the metadata copies to heal exceed `parity_blocks` (0) or any part has more missing or corrupt shards than `parity_blocks` (0). The result is `no-parity object is unrecoverable` with `FileCorrupt` for a bitrot failure and `ErasureReadQuorum` for a missing shard. Scanner and MRF heal requests repeat that outcome; they never repair. A replaced or wiped drive therefore loses every zero-parity object that had a shard on it, permanently, unless a copy exists outside the set. Recovery boundary and evidence handling: [no-parity-bitrot-recovery.md](no-parity-bitrot-recovery.md).
**What still works with one drive down.** Bucket operations and `.rustfs.sys` metadata (written at parity `N/2`), delete markers, and listings that satisfy the list quorum. Everything else in the set is unavailable, including rebalance and decommission of its objects, which must read all `N` shards.
**Operational consequences.**
- Any maintenance that takes a drive or its node offline is a read and write outage for ordinary objects in every set that spans it, for the whole window. Rolling restarts are not transparent.
- Drive replacement is data loss for that set, not a repair.
- Raising parity later rewrites nothing; re-upload or server-side copy each object after the change to re-protect it.
- Use `EC:0` only where an external layer (RAID, replication, backup, or a re-creatable source) owns durability and availability, and only with an explicit sign-off that one drive loss equals set loss. Production deployments should use `EC:1` or higher; `EC:2` or more per set is the usual floor once a set spans more than one node.
## 6. Post-start verification
Read-only checks:
```text
GET /rustfs/admin/v3/pools/list
GET /rustfs/admin/v3/pools/status?pool=<pool>[&by-id=true]
GET /rustfs/admin/v3/decommission/status[?pool=<pool>&by-id=true]
GET /rustfs/admin/v4/cluster/snapshot
GET /rustfs/admin/v4/runtime/capabilities
```
`pool` is a pool command line, or a zero-based index with `by-id=true`. Status queries ignore unknown parameters; mutation queries reject unknown or duplicate parameters and a `by-id` value other than `true` or `false` (`parse_pool_query`). Per-pool status fields, including `decommissionInfo`, are listed in [decommission-compatibility.md](../architecture/decommission-compatibility.md#status-response-shape); the `storage_classes` payload of runtime capabilities is owned by [runtime-capability-contracts.md](../architecture/runtime-capability-contracts.md); `pool_meta_write_gate` in the cluster snapshot is explained in [s3-write-failure-diagnostics.md](s3-write-failure-diagnostics.md).
Confirm: pool count, endpoints per pool, set count and width per pool, local versus remote endpoints, no legacy flag on a deployment that will ever decommission, peer readiness, and no topology mismatch.
Data-plane checks: write one STANDARD and one RRS object; GET, HEAD, range GET, CopyObject, and a multipart upload; take one drive or node offline within parity and confirm reads continue and writes behave per §4.2; bring it back and confirm the heal queue drains and bitrot errors stop; confirm every node reports the same pool metadata and cluster snapshot.
## 7. Expansion
An existing pool cannot grow in place. Add a pool:
1. Plan its nodes, drives, and failure domain; each new pool needs at least two drive endpoints and its own valid set layout.
2. Prepare empty, RustFS-exclusive paths on every node.
3. Append the pool to the launch arguments on **every** node, keeping existing pools, their order, endpoints, and set width unchanged. A single-node single-drive deployment cannot expand; migrate through S3 instead.
4. Restart per [rolling-restart.md](rolling-restart.md) and verify per §6.
5. New writes are placed across available pools; old objects stay where they are. To move them, start a rebalance (§8). A restart never rebalances.
Adding a pool does not add parity to existing objects and does not copy anything. Cross-pool movement is rebalance or decommission only.
## 8. Rebalance
Rebalance redistributes objects across pools by usage. Preconditions checked by `RebalanceStart` (`rustfs/src/admin/handlers/rebalance.rs`): more than one pool (a single pool returns `NotImplemented`), no decommission running or conflicting (`InvalidRequest`), and no query string on `start` or `stop` (`InvalidArgument`).
```text
POST /rustfs/admin/v3/rebalance/start → {"id": "<uuid>"}
GET /rustfs/admin/v3/rebalance/status
POST /rustfs/admin/v3/rebalance/stop
```
Status (`RebalanceAdminStatus`): `id`; `pools[]` with `id`, `status`, `stopping`, `used`, `lastError`, `cleanupWarnings`, and `progress` (`objects`, `versions`, `bytes`, `remainingBuckets`, `bucket`, `object`, `elapsed`, `eta`); `stoppedAt`; and `stopPropagation` (`lastAttemptAt`, `failedPeers`, `terminalReloadAttemptAt`, `terminalReloadFailedPeers`, `pendingTerminalReload`).
Start propagates an admission fence and worker state to peers; a propagation failure rolls back to a terminal state and reports any peer that did not roll back. Stop closes admission, stops local and remote workers, and persists the stopped state. Treat a rebalance as finished only when no pool is active or stopping, `lastError` is empty or explained, `cleanupWarnings` are understood, `pendingTerminalReload` is false, and migrated objects read from their new pool. Per-pool state lives in `rebalance.bin` (`REBAL_META_NAME`); never delete it to unblock a run. If the deployment ran rebalance on a release listed in [rebalance-stored-representation-impact.md](rebalance-stored-representation-impact.md), run that assessment first.
## 9. Decommission
Contract: [decommission-compatibility.md](../architecture/decommission-compatibility.md). Operator summary:
- Supported only on multi-pool, non-legacy (ellipsis) deployments; anything else returns `NotImplemented`. A rebalance in progress blocks start.
- Targets are validated as a batch before anything is persisted: unknown, duplicate, active, queued, or completed targets are rejected; failed or canceled targets must be cleared first unless they hold unresolved listing entries, in which case a new start is the retry path (`DecommissionStartPoolState::Retryable` versus `Blocked` in `crates/ecstore/src/core/pools.rs`).
- Requests go to any node and are forwarded to the pool leader (§1).
```text
POST /rustfs/admin/v3/pools/decommission?pool=<a>[,<b>] # by command line
POST /rustfs/admin/v3/pools/decommission?pool=1&by-id=true # by index
GET /rustfs/admin/v3/decommission/status[?pool=1&by-id=true]
POST /rustfs/admin/v3/pools/cancel?pool=1&by-id=true
POST /rustfs/admin/v3/pools/clear?pool=1&by-id=true
```
Entry states persisted in `pool.bin`: `queued` → `active` → `completed`, `failed`, or `canceled`. One entry owns a worker at a time; startup resumes non-terminal entries and skips terminal predecessors. A source pool in any decommission state, terminal or not, rejects new ordinary PUTs and new multipart uploads (a staged PUT gets `SlowDown`); existing multipart uploads may drain while the source is non-terminal. `clear` removes failed or canceled metadata only and never moves data back. `queuedBuckets` is an inventory, not proof of a live worker.
Remove a pool from the launch arguments only after its entry is `completed`, free versions and tier ownership have converged, and every node shows the same status. Never detach source drives before that, and never substitute a directory delete for the configuration change.
## 10. Heal and drive replacement
### 10.1 Admin heal
```text
POST /rustfs/admin/v3/heal/ # root: cluster, or one erasure set with pool+set
POST /rustfs/admin/v3/heal/<bucket>
POST /rustfs/admin/v3/heal/<bucket>/<prefix>
POST /rustfs/admin/v3/background-heal/status
GET /rustfs/admin/v4/heal/replacement-recovery
```
The JSON body carries `recursive`, `dryRun`, `remove`, `recreate`, `scanMode`, `updateParity`, `nolock`, `readRepair`, `pool`, and `set`. Handler rules (`rustfs/src/admin/handlers/heal.rs`):
- a root heal must set `recursive=true` or name both `pool` and `set`; a bucket heal without a prefix is always recursive, and a prefix heal honors `recursive`;
- `readRepair=true` is rejected with `InvalidArgument`;
- `nolock` is accepted on the wire but forced to `false` (`build_heal_channel_request`); admin heals always take the object namespace lock, so there is no lock bypass to misuse;
- `clientToken` correlates start, status, and stop; `forceStart` and `forceStop` are validated against it and against each other, and unknown or duplicate query keys are rejected;
- overlapping starts follow `RUSTFS_HEAL_OVERLAP_POLICY`.
Heal selects the authoritative version from quorum metadata, refuses when more shards are missing than parity can rebuild or when the stored geometry does not match the set (`heal-concurrency-model.md`, [erasure-coding.md §9](../architecture/erasure-coding.md#9-healing)), and shares the `(bucket, object)` lock with PUT, delete, multipart complete, and data movement. Retained outcomes are described in [heal-terminal-reports.md](../architecture/heal-terminal-reports.md).
### 10.2 Drive replacement
1. Confirm the affected set still meets read quorum for its objects (impossible at `EC:0`, see §5) and record pool index, set index, slot, and the failed disk's UUID from the pool status.
2. Replace the drive at the **same** endpoint and mount path with an empty filesystem owned by RustFS. Do not copy the old drive's directories; a copied `format.json` claims an identity the set already tracks.
3. Bring the node back. The empty drive is not a format-quorum member, so `retain_format_quorum_members` keeps its slot offline; the endpoint monitor (`monitor_and_connect_endpoints_task` in `crates/ecstore/src/core/sets.rs`) retries the connection every 15 seconds. The erasure-set heal task (`crates/heal/src/heal/task/heal_erasure_set.rs`) writes the format into the original slot, through `heal_replacement_format` when an automatic replacement intent exists (schema 7 records under `.rustfs.sys/buckets/ahm-replacement/`, created when startup or the disk scanner observes a changed mount identity) or through `heal_format` for an admin erasure-set heal (`POST /rustfs/admin/v3/heal/` with `pool` and `set`), and then rebuilds the shards. Legacy schema 5/6 intents follow [replacement-generation-recovery.md](replacement-generation-recovery.md). If the drive held the node's pool metadata, follow [pool-metadata-recovery.md](pool-metadata-recovery.md#disk-replacement-and-metadata-erasure) first.
4. Watch `GET /rustfs/admin/v4/heal/replacement-recovery`, background heal status, disk health, and bitrot counters. Completion criteria are in [scanner-runtime-controls.md](scanner-runtime-controls.md#replacement-recovery-completion): an `idle` queue or a readable object is not proof.
5. The replacement is done when recovery reports `completed` for that instance and sampled objects have `xl.meta` and parts on the new drive.
If a set has lost more drives than its parity, heal cannot rebuild; recover from replication, a remote tier, or backup.
## 11. Restart and recovery
- **Ordinary restart.** No topology change in flight, identical arguments on every node, one node at a time per [rolling-restart.md](rolling-restart.md). Startup reloads pool metadata, `rebalance.bin`, the decommission queue, and heal recovery records, and resumes workers by leader and owner rules.
- **Rebalance.** Read `/rebalance/status` first. Handle active or stopping pools, `lastError`, cleanup warnings, and stop propagation before deciding to stop or restart. Do not delete `rebalance.bin`.
- **Decommission.** Non-terminal entries resume; failed and canceled entries survive restarts until cleared or retried through the allowed path (§9).
- **Root heal.** A graceful shutdown persists an unfinished cluster heal as `root-heal-<task-id>.json` in `.rustfs.sys` (`RootHealRecovery` in `crates/heal/src/heal/manager/root_recovery.rs`, 64 KiB bound) and replays it with the same task id. Terminal records use `terminal-root-heal-`; invalid, oversized, or unsupported records move to `quarantined-root-heal-intent-` or `quarantined-root-heal-terminal-` and are skipped with a log line until an operator repairs them. Do not delete quarantine markers or reuse a task id.
- **Unclean shutdown.** When the previous run left its marker behind, the heal manager's `process_unclean_shutdown` (`crates/heal/src/heal/manager/unclean_shutdown.rs`) enqueues a full erasure-set heal for every local set; graceful shutdown clears the marker. See [durability-modes.md](durability-modes.md) for which sync tier can leave what behind.
## 12. Mutual exclusion and prohibited actions
| Operation | With rebalance | With decommission | Notes |
|---|---|---|---|
| PUT, DELETE, multipart complete | Object lock | Source pool rejects new publication | Never bypass the pool fence |
| Decommission | Rejected while rebalance runs | Serial queue | Leader-only mutation |
| Rebalance | Multi-pool only | Rejected while decommission runs or conflicts | No query parameters |
| Admin heal | Object lock | Repairs data, never changes decommission state | `nolock` is ignored |
| Drive replacement | Cannot change `FormatV3` layout | Avoid on a source pool mid-decommission | Same slot, empty drive |
Do not: change launch endpoints while rebalance or decommission runs; delete `.rustfs.sys`, `rebalance.bin`, `pool.bin`, or root-heal records; run manual heal, file copies, and drive replacement on one pool at the same time; treat an HTTP 200 on a start call as completion.
## 13. Checklists
After start or expansion: identical endpoints and pool order on every node; topology equals `FormatV3`; peers ready and the pool metadata write gate open; new endpoints unique and RustFS-exclusive; GET, HEAD, range GET, and multipart verified.
After rebalance: no pool active or stopping; `lastError` empty or explained; `cleanupWarnings` handled; `pendingTerminalReload` false; migrated objects readable.
After decommission: status `completed`; other entries handled by policy; source pool rejects new writes; free versions, tier ownership, and listing entries converged; launch arguments updated on every node before drives are detached.
After heal or replacement: replacement recovery `completed`; slot and UUID correct; heal queue drained; no growing bitrot count; sampled current and historical versions readable; no leftover temporary directories.
## 14. `rc` CLI mapping
`rc` ships from the separate `rustfs/cli` repository; its admin reference is https://github.com/rustfs/cli/blob/main/docs/reference/rc/admin.md, and its `expand` group is an alias of `rebalance` (there is no server-side expand API). Flags below are the ones that reference documents; anything else, check `rc admin <group> --help` for the installed version.
```bash
rc alias set local http://node1:9000 <ACCESS_KEY> <SECRET_KEY>
rc ping local
rc ready local --timeout 2
rc admin info cluster local
rc admin info disk local --offline
rc admin pool list local
rc admin pool status local 0 --by-id
rc admin rebalance start local
rc admin rebalance status local
rc admin rebalance stop local
rc admin decommission start local '/data/pool1/disk{1...4}'
rc admin decommission status local 1 --by-id
rc admin decommission cancel local 1 --by-id
rc admin decommission clear local 1 --by-id
rc admin heal start local --scan-mode deep
rc admin heal start local --bucket logs --prefix 2026/ --scan-mode deep
rc admin heal status local --client-token <TOKEN_FROM_START>
rc admin heal stop local --client-token <TOKEN_FROM_START>
```
| `rc` command | Server route |
|---|---|
| `rc ping` / `rc ready` | `GET /health` / `GET /health/ready` |
| `rc admin pool list` / `status` | `GET /rustfs/admin/v3/pools/list` / `GET /rustfs/admin/v3/pools/status` |
| `rc admin rebalance …` (alias `expand`) | `/rustfs/admin/v3/rebalance/*` |
| `rc admin decommission start` / `cancel` / `clear` | `POST /rustfs/admin/v3/pools/decommission` / `cancel` / `clear` |
| `rc admin decommission status` | `GET /rustfs/admin/v3/decommission/status` |
| `rc admin heal start` / `status` / `stop` | `/rustfs/admin/v3/heal/*`, `POST /rustfs/admin/v3/background-heal/status` |
`rc admin heal start` exposes `--bucket`, `--prefix`, `--scan-mode`, `--remove`, `--recreate`, and `--dry-run`; it does not expose `nolock`, `forceStart`, `forceStop`, `pool`, or `set`. There is no `rc` command for replacement recovery; call `GET /rustfs/admin/v4/heal/replacement-recovery` directly.
## 15. Source and contract index
- [erasure-coding.md](../architecture/erasure-coding.md): algorithm, quorum, on-disk format, change procedure.
- [ecstore-layout-boundary.md](../architecture/ecstore-layout-boundary.md): `FormatV3` ordering and disk UUID positions.
- [placement-repair-invariants.md](../architecture/placement-repair-invariants.md): object to set placement, scanner and heal admission.
- [decommission-compatibility.md](../architecture/decommission-compatibility.md): decommission and rebalance contract, `PoolMeta`, status shape.
- [heal-concurrency-model.md](../architecture/heal-concurrency-model.md) and [heal-terminal-reports.md](../architecture/heal-terminal-reports.md): heal locking and retained reports.
- [pool-layout-compatibility.md](../testing/pool-layout-compatibility.md): expansion admission rules and regression matrix.
- [no-parity-bitrot-recovery.md](no-parity-bitrot-recovery.md), [durability-modes.md](durability-modes.md), [rolling-restart.md](rolling-restart.md), [pool-metadata-recovery.md](pool-metadata-recovery.md), [replacement-generation-recovery.md](replacement-generation-recovery.md), [scanner-runtime-controls.md](scanner-runtime-controls.md).
- `rustfs/src/startup_storage.rs`, `crates/ecstore/src/layout/disks_layout.rs`, `crates/ecstore/src/config/storageclass.rs`, `crates/ecstore/src/set_disk/mod.rs`, `crates/ecstore/src/set_disk/metadata.rs`, `crates/ecstore/src/set_disk/ops/heal.rs`, `crates/ecstore/src/core/pools.rs`, `crates/ecstore/src/services/rebalance/mod.rs`, `rustfs/src/admin/handlers/pools.rs`, `rustfs/src/admin/handlers/rebalance.rs`, `rustfs/src/admin/handlers/heal.rs`, `crates/heal/src/heal/manager/root_recovery.rs`, `crates/config/src/constants/heal.rs`.
@@ -11,7 +11,7 @@ Every stage is env-gated, so "before" and "after" run the *same binary* with dif
| --- | --- |
| RPC secret | Internode RPC fails closed: remote endpoints with default credentials and no `RUSTFS_RPC_SECRET` abort startup with `store init aborted: endpoints include remote nodes but ...` (`crates/ecstore/src/store/init.rs`). Set a non-default `RUSTFS_RPC_SECRET`, identical on every node. |
| systemd start timeout | `deploy/build/rustfs.service` is `Type=notify` and ships `TimeoutStartSec=120s`; READY fires only after quorum. If freshly purged disks need longer, raise it in a drop-in rather than lowering it. |
| Metrics export | RustFS has no Prometheus pull endpoint (`/admin/v3/metrics` is NDJSON, see `rustfs/src/admin/handlers/metrics.rs`); it pushes OTLP. Run an otel-collector (OTLP receiver → Prometheus exporter) and set `RUSTFS_OBS_ENDPOINT`, `RUSTFS_OBS_METRICS_EXPORT_ENABLED=true`, `RUSTFS_OBS_METER_INTERVAL=5`. For lock p99 also set `RUSTFS_OBJECT_LOCK_DIAG_ENABLE=true` (default off). |
| Metrics export | RustFS has no Prometheus pull endpoint (`/admin/v3/realtime` is NDJSON, see `rustfs/src/admin/handlers/realtime.rs`); it pushes OTLP. Run an otel-collector (OTLP receiver → Prometheus exporter) and set `RUSTFS_OBS_ENDPOINT`, `RUSTFS_OBS_METRICS_EXPORT_ENABLED=true`, `RUSTFS_OBS_METER_INTERVAL=5`. For lock p99 also set `RUSTFS_OBJECT_LOCK_DIAG_ENABLE=true` (default off). |
| Server env | `RUSTFS_INTERNODE_*` are **server** env. For p0/p1/p2, source the emitted `server-env.sh` on every node and restart before the run; the driver cannot mutate a running server. |
## Driver
@@ -20,7 +20,7 @@ sum by (instance) (rate(rustfs_s3_http_requests_total{method="PUT",outcome=~"[1-
Inspect `service_error` and `cancelled` separately; neither implies a received HTTP status. A zero denominator or absent series means no observed traffic, not proof of health. Use `rate` or reset-aware deltas because counters restart with the process. The older `rustfs_s3_operations_total` counter measures handler entries and excludes pre-dispatch rejections; it is not this HTTP denominator.
The authenticated admin metrics endpoint exposes the same counters through the optional `http` field in `aggregated` and `by_host`. Request `/rustfs/admin/v3/metrics?types=512&by-host=true&n=1` on each node for HTTP-only data. The default type selection also includes HTTP outcomes. This endpoint remains an NDJSON stream and does not become a cluster-wide peer fanout. `http.requests` contains `method`, `operation`, `outcome`, and `total`; `http.collected` timestamps collection. Compare consecutive samples from the same host. Concurrent snapshots are not atomic across series.
The authenticated admin metrics endpoint exposes the same counters through the optional `http` field in `aggregated` and `by_host`. Request `/rustfs/admin/v3/realtime?types=512&by-host=true&n=1` on each node for HTTP-only data. The default type selection also includes HTTP outcomes. This endpoint remains an NDJSON stream and does not become a cluster-wide peer fanout. `http.requests` contains `method`, `operation`, `outcome`, and `total`; `http.collected` timestamps collection. Compare consecutive samples from the same host. Concurrent snapshots are not atomic across series.
A missing `http` field means an older or non-reporting node, not zero failures. Old map-encoded RPC readers ignore the additive field; new readers accept older snapshots. In mixed-version deployments, check reporting coverage before aggregating a fleet-wide ratio.
+1 -1
View File
@@ -317,7 +317,7 @@ Use the harness around a post-start bucket creation workload to cover the timing
1. Start RustFS from an empty data path.
2. Start the harness before creating buckets.
3. Create a bucket, upload objects, and keep the harness running until at least one usage save is observed.
4. Compare `scanner-summary.csv` with `/rustfs/admin/v3/metrics?types=1&n=1` bucket metrics.
4. Compare `scanner-summary.csv` with `/rustfs/admin/v3/realtime?types=1&n=1` bucket metrics.
Expected evidence: dirty usage is marked, `life_time_scan_cycle` or `life_time_scan_bucket_drive` advances, `life_time_scan_object` advances for object workloads, and `life_time_save_usage` plus `usage_last_save_result=success` appear before non-zero bucket usage metrics are accepted as fresh.
+2 -2
View File
@@ -481,7 +481,7 @@ Read this snapshot before changing scanner controls: `blocked_source` with `life
## Reading Distributed Metrics
`/rustfs/admin/v3/scanner/status` and `/rustfs/admin/v3/metrics` report the node that handles the HTTP request; the metrics endpoint does not fan out to peers. In distributed deployments, query every node explicitly and keep `by-host=true` so each response includes that node's host view:
`/rustfs/admin/v3/scanner/status` and `/rustfs/admin/v3/realtime` report the node that handles the HTTP request; the metrics endpoint does not fan out to peers. In distributed deployments, query every node explicitly and keep `by-host=true` so each response includes that node's host view:
```bash
for endpoint in http://node-a:9000 http://node-b:9000 http://node-c:9000; do
@@ -493,7 +493,7 @@ for endpoint in http://node-a:9000 http://node-b:9000 http://node-c:9000; do
--access_key "$RUSTFS_ACCESS_KEY" \
--secret_key "$RUSTFS_SECRET_KEY" \
--request GET \
"${endpoint}/rustfs/admin/v3/metrics?types=1&by-host=true&n=1" \
"${endpoint}/rustfs/admin/v3/realtime?types=1&by-host=true&n=1" \
> "artifacts/scanner-metrics.${node}.$(date -u +%Y%m%dT%H%M%SZ).ndjson"
done
```
+2
View File
@@ -65,6 +65,8 @@ get_bytes(&self.meta_sys, SUFFIX_TRANSITIONED_VERSION_ID)
`transition_version_id == None` means only that no usable legacy UUID projection exists; it does not prove the remote bucket's versioning model. Only an explicit `KnownDisabled` state authorizes ordinary GET/DELETE to omit `versionId`. A missing state with an absent or empty version key remains `Unknown` and requires the bounded compatibility probe or the approved reconcile workflow; it never directly authorizes cleanup. A nil UUID (`00000000-...`) sent as `?versionId=` causes `NoSuchVersion`. Do not use `Uuid::from_slice(..).unwrap_or_default()` here: it converts an empty metadata value into `Uuid::nil()`, which is exactly that failure.
Opaque remote version IDs, such as those returned by Backblaze B2, are treated as exact remote identifiers only after the operator-attested live fleet capability gate is active. The gate requires both `RUSTFS_TIER_REMOTE_VERSION_STATE_WRITE=true` and `RUSTFS_TIER_REMOTE_VERSION_STATE_FLEET_CONFIRMED=true`, plus a current fleet proof. Recovery and operator cleanup retain an ambiguous candidate while that proof is absent or stale; they never issue a guessed opaque-version delete. Existing persisted exact opaque IDs remain routable, but new persistence and recovery cleanup fail closed until the gate is current.
## Inspect xl.meta directly
```bash
+1 -1
View File
@@ -108,7 +108,7 @@ Post-merge and tag-driven; not a substitute for a PR gate.
|---|---|---|
| Push to `main`, weekly schedule, dispatch | `build.yml` `build-rustfs` (a development build on a main push restricts the matrix to the Linux targets) | build artifacts; no release publication |
| Valid release or preview tag | `build.yml` `build-rustfs`, `create-release`, `upload-release-assets`, `publish-release` | draft release, checksummed assets, publish |
| Successful non-preview release-tag build (`workflow_run`) | `docker.yml` `build-docker`, `scan-docker-image` | multi-architecture images and vulnerability report |
| Successful non-preview release-tag build (`workflow_run`) | `docker.yml` `build-docker`, `scan-docker-image`, `sync-dockerhub-description` | multi-architecture images, vulnerability report, and the Docker Hub overview republished from `README.md` |
| Successful release-tag build (`workflow_run`) | `package.yml` `package` | DEB/RPM packages and checksums uploaded to the release |
| Successful non-preview release-tag build (`workflow_run`) | `helm-package.yml` `build-helm-package`, `publish-helm-package` | versioned chart and repository index |
| Final tag's release published | `build.yml` `cleanup-preview-releases` | deletes every `<target>-preview.<N>` Release for that target; the tags are kept |
Generated
+6 -6
View File
@@ -2,11 +2,11 @@
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1789073787,
"narHash": "sha256-xfX/toC2QV707s06GbP4II/TxYF0fNQj7s5/LClNDKc=",
"lastModified": 1789632929,
"narHash": "sha256-RjR8AyvGlWuw16XRj7C1YDEw4E27ciuyPeB2nDMzgTU=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "aff8a0b28396750446e5537a96461bc4facdb287",
"rev": "a32edd7654519351e48e80372a928df336394670",
"type": "github"
},
"original": {
@@ -29,11 +29,11 @@
]
},
"locked": {
"lastModified": 1789196581,
"narHash": "sha256-yJr1Bt4fKkKIpPYbsKGqJ0VFdoDnURgRwuffmBQ2WzY=",
"lastModified": 1789802016,
"narHash": "sha256-QVFfArbYaQMGK4dFV+5RUL72Ci+hNHVhaF/R9qJfDhs=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "228ecefb6329d5a531b77b46b581a2f0c26ee056",
"rev": "26a71e661c47bd21a05d06fec749f3f7c75e9d12",
"type": "github"
},
"original": {
+2 -2
View File
@@ -115,11 +115,11 @@
clientAssets = {
"x86_64-linux" = {
name = "rustfs-cli-linux-amd64-v${clientVersion}.tar.gz";
hash = "sha256-qwDZNwedy28ce0HTS7+q0OsL1PchhnLLy3wzZS0cRt8=";
hash = "sha256-SoEokRzK1Oe0gfJmNaTP0ewGRBIhBSbletLHSNNW87c=";
};
"aarch64-linux" = {
name = "rustfs-cli-linux-arm64-v${clientVersion}.tar.gz";
hash = "sha256-1T1M9Q3lcy9IJo/n5eQezmbTaEgHVJbx1QFCaTX3BYY=";
hash = "sha256-bp3qesT4FWKt2eHjEga6YFnW9ZKa1O2SSYYiMoKzqpw=";
};
};
+1 -1
View File
@@ -43,7 +43,7 @@ spec:
service:
name: {{ include "rustfs.fullname" $ }}-svc
port:
name: {{ .servicePort }}
name: {{ .servicePort | default "console" }}
{{- end }}
{{- end }}
{{- end }}
@@ -189,6 +189,7 @@ mod tests {
use crate::admin::handlers::target_descriptor::admin_target_spec_from_builtin;
use crate::admin::runtime_sources::{IamInterface, KmsInterface};
use crate::admin::storage_api::config::save_admin_server_config;
use crate::admin::storage_api::error::StorageError;
use rustfs_config::audit::AUDIT_WEBHOOK_SUB_SYS;
use rustfs_config::server_config::KVS;
use rustfs_config::{ENABLE_KEY, EnableState, SCANNER_CYCLE, SCANNER_SUB_SYS, WEBHOOK_ENDPOINT, WEBHOOK_QUEUE_DIR};
@@ -231,11 +232,17 @@ mod tests {
let mut poll = tokio::time::interval(Duration::from_millis(10));
loop {
poll.tick().await;
let config = read_admin_config_without_migrate(store.clone())
.await
.expect("read persisted server config");
if config.0.get(subsystem).is_some_and(|targets| targets.contains_key(target)) {
return;
match read_admin_config_without_migrate(store.clone()).await {
Ok(config) => {
if config.0.get(subsystem).is_some_and(|targets| targets.contains_key(target)) {
return;
}
}
Err(StorageError::Lock(rustfs_lock::LockError::Timeout { .. })) => {
// The writer may still be committing the config snapshot. Retry after
// the object lock is released instead of failing the polling helper.
}
Err(error) => panic!("read persisted server config: {error}"),
}
}
})
+97 -1
View File
@@ -23,9 +23,12 @@ use crate::admin::{
target_mutation_block_reason as shared_target_mutation_block_reason,
},
router::{AdminOperation, Operation, S3Router},
runtime_sources::object_store_from_extensions,
runtime_sources::{AppContext, app_context_from_req},
service::config::{preflight_dynamic_config_reload_for_context, signal_dynamic_config_reload_checked_for_context},
storage_api::contract::bucket::{BucketOperations, BucketOptions},
};
use crate::app::storage_api::bucket::metadata_sys::get_notification_config;
use crate::server::{
ADMIN_PREFIX, is_notify_module_enabled, refresh_notify_module_enabled, refresh_persisted_module_switches_from_store,
};
@@ -37,8 +40,9 @@ use rustfs_config::server_config::Config;
use rustfs_config::{EVENT_DEFAULT_DIR, MAX_ADMIN_REQUEST_BODY_SIZE};
use rustfs_policy::policy::action::{Action, AdminAction};
use rustfs_targets::catalog::builtin::builtin_notify_target_admin_descriptors;
use s3s::{Body, S3Request, S3Response, S3Result, s3_error};
use s3s::{Body, S3Error, S3ErrorCode, S3Request, S3Response, S3Result, s3_error};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
use std::sync::LazyLock;
use tracing::{error, info, warn};
@@ -219,9 +223,101 @@ pub fn register_notification_target_route(r: &mut S3Router<AdminOperation>) -> s
AdminOperation(&ListTargetsArns {}),
)?;
r.insert(
Method::GET,
format!("{}{}", ADMIN_PREFIX, "/v3/target/{target_type}/{target_name}/subscriptions").as_str(),
AdminOperation(&ListTargetSubscriptions {}),
)?;
Ok(())
}
#[derive(Serialize, Debug)]
struct TargetSubscription {
bucket: String,
id: Option<String>,
events: Vec<String>,
prefix: Option<String>,
suffix: Option<String>,
}
pub struct ListTargetSubscriptions {}
#[async_trait::async_trait]
impl Operation for ListTargetSubscriptions {
async fn call(&self, req: S3Request<Body>, params: Params<'_, '_>) -> S3Result<S3Response<(StatusCode, Body)>> {
authorize_notification_admin_request(&req, AdminAction::GetBucketTargetAction).await?;
let (target_type, target_name) = extract_target_params(&params)?;
let Some(store) = object_store_from_extensions(&req.extensions) else {
return Err(S3Error::with_message(S3ErrorCode::InternalError, "object store is not initialized"));
};
let region = req.region.as_ref().map(ToString::to_string).unwrap_or_default();
let target_arn = rustfs_targets::arn::TargetID::new(target_name.to_string(), target_type.to_string())
.to_arn(&region)
.to_string();
let buckets = store
.list_bucket(&BucketOptions::default())
.await
.map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("failed to list buckets: {e}")))?;
let mut subscriptions = Vec::new();
for bucket in buckets {
let Some(config) = get_notification_config(&bucket.name).await.map_err(|e| {
S3Error::with_message(S3ErrorCode::InternalError, format!("failed to load notification config: {e}"))
})?
else {
continue;
};
let value = serde_json::to_value(config).map_err(|e| {
S3Error::with_message(S3ErrorCode::InternalError, format!("failed to serialize notification config: {e}"))
})?;
for key in [
"queue_configurations",
"topic_configurations",
"lambda_function_configurations",
] {
if let Some(entries) = value.get(key).and_then(Value::as_array) {
for entry in entries {
let arn = ["queue_arn", "topic_arn", "lambda_function_arn"]
.iter()
.find_map(|field| entry.get(field).and_then(Value::as_str));
if arn != Some(target_arn.as_str()) {
continue;
}
let filters = entry
.get("filter")
.and_then(|v| v.get("key"))
.and_then(|v| v.get("filter_rules"))
.and_then(Value::as_array);
let filter_value = |name: &str| {
filters.and_then(|rules| {
rules.iter().find_map(|rule| {
(rule.get("name").and_then(Value::as_str) == Some(name))
.then(|| rule.get("value").and_then(Value::as_str).map(str::to_string))
.flatten()
})
})
};
subscriptions.push(TargetSubscription {
bucket: bucket.name.clone(),
id: entry.get("id").and_then(Value::as_str).map(str::to_string),
events: entry
.get("events")
.and_then(Value::as_array)
.map(|events| events.iter().filter_map(Value::as_str).map(str::to_string).collect())
.unwrap_or_default(),
prefix: filter_value("Prefix").or_else(|| filter_value("prefix")),
suffix: filter_value("Suffix").or_else(|| filter_value("suffix")),
});
}
}
}
}
let data = serde_json::to_vec(&subscriptions)
.map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("failed to serialize subscriptions: {e}")))?;
Ok(build_json_response(StatusCode::OK, Body::from(data), req.headers.get("x-request-id")))
}
}
#[derive(Debug, Deserialize)]
pub struct KeyValue {
pub key: String,
+2 -2
View File
@@ -43,7 +43,6 @@ pub mod kms_key_metadata;
pub mod kms_keys;
pub mod kms_management;
pub mod kms_rekey;
pub mod metrics;
pub mod mfa;
pub mod module_switch;
mod notify_runtime_access;
@@ -58,6 +57,7 @@ pub mod pools;
pub mod profile;
pub mod profile_admin;
pub mod quota;
pub mod realtime;
pub mod rebalance;
pub mod replication;
pub mod scanner;
@@ -145,7 +145,7 @@ mod tests {
let _inspect_data_handler = system::InspectDataHandler {};
let _storage_info_handler = system::StorageInfoHandler {};
let _data_usage_handler = system::DataUsageInfoHandler {};
let _metrics_handler = metrics::MetricsHandler {};
let _metrics_handler = realtime::MetricsHandler {};
let _profile_handler = profile_admin::ProfileHandler {};
let _profile_status_handler = profile_admin::ProfileStatusHandler {};
let _tls_status_handler = tls_debug::TlsStatusHandler {};
+46 -41
View File
@@ -26,7 +26,7 @@ use crate::server::{
apply_audit_module_switch_for_context, current_module_switch_snapshot, mark_event_notifier_reconciled,
mark_event_notifier_unreconciled, refresh_audit_module_enabled, refresh_notify_module_enabled,
refresh_persisted_module_switches_from, refresh_persisted_module_switches_from_store, save_persisted_module_switches_to,
validate_module_switch_update,
validate_module_switch_update, with_notify_runtime_reconcile_lock,
};
use http::{HeaderMap, StatusCode};
use hyper::Method;
@@ -138,48 +138,53 @@ async fn refresh_module_switch_snapshot() -> S3Result<ModuleSwitchSnapshot> {
async fn apply_module_switch_update(context: Arc<AppContext>, switches: PersistedModuleSwitches) -> S3Result<()> {
preflight_dynamic_config_reload_for_context(Some(context.as_ref()), MODULE_SWITCHES_SIGNAL_SUBSYSTEM).await?;
let store = context.object_store();
mark_event_notifier_unreconciled();
let notification_system = rustfs_notify::ensure_live_events();
if switches.notify_enabled {
notification_system
.reload_persisted_config_from_store(store.clone())
.await
.map_err(|err| {
tracing::warn!(error = %err, "Failed to load notification config for module switch update");
s3_error!(InternalError, "failed to load notification config")
})?;
}
let mut failures = with_notify_runtime_reconcile_lock(async {
mark_event_notifier_unreconciled();
let notification_system = rustfs_notify::ensure_live_events();
if switches.notify_enabled {
notification_system
.reload_persisted_config_from_store(store.clone())
.await
.map_err(|err| {
tracing::warn!(error = %err, "Failed to load notification config for module switch update");
s3_error!(InternalError, "failed to load notification config")
})?;
}
let transition_system = notification_system.clone();
let notify_transition = save_persisted_module_switches_to(store.clone(), switches, move || {
let enabled = refresh_notify_module_enabled();
transition_system.publish_targets_enabled(enabled, None)
let transition_system = notification_system.clone();
let notify_transition = save_persisted_module_switches_to(store.clone(), switches, move || {
let enabled = refresh_notify_module_enabled();
transition_system.publish_targets_enabled(enabled, None)
})
.await
.map_err(|err| {
tracing::warn!(error = %err, "Failed to save module switches");
s3_error!(InternalError, "failed to save module switches")
})?;
let mut failures = Vec::new();
let mut notify_converged = true;
if let Err(err) = notify_transition.wait().await {
tracing::warn!(error = %err, "Local notification runtime failed to apply module switch update");
notify_converged = false;
failures.push("local notify");
}
if !switches.notify_enabled
&& let Err(err) = notification_system.reload_persisted_config_from_store(store).await
{
tracing::warn!(error = %err, "Local notification config cache failed to reload after module disable");
notify_converged = false;
failures.push("local notify config cache");
}
if notify_converged && notification_system.runtime_lifecycle_is_converged() {
mark_event_notifier_reconciled();
} else if notify_converged {
failures.push("local notify convergence");
}
Ok::<_, s3s::S3Error>(failures)
})
.await
.map_err(|err| {
tracing::warn!(error = %err, "Failed to save module switches");
s3_error!(InternalError, "failed to save module switches")
})?;
let mut failures = Vec::new();
let mut notify_converged = true;
if let Err(err) = notify_transition.wait().await {
tracing::warn!(error = %err, "Local notification runtime failed to apply module switch update");
notify_converged = false;
failures.push("local notify");
}
if !switches.notify_enabled
&& let Err(err) = notification_system.reload_persisted_config_from_store(store).await
{
tracing::warn!(error = %err, "Local notification config cache failed to reload after module disable");
notify_converged = false;
failures.push("local notify config cache");
}
if notify_converged && notification_system.runtime_lifecycle_is_converged() {
mark_event_notifier_reconciled();
} else if notify_converged {
failures.push("local notify convergence");
}
.await?;
if apply_audit_module_switch_for_context(Some(context.as_ref())).await.is_err() {
tracing::warn!(reason = "apply_failed", "Local audit runtime failed to apply module switch update");
@@ -14,7 +14,7 @@
//! Console realtime metrics API.
//!
//! This preserves the console's fixed `/admin/v3/metrics` contract while
//! This preserves the console's fixed `/admin/v3/realtime` contract while
//! keeping the response format explicitly NDJSON. It is not a Prometheus text
//! exposition endpoint.
@@ -302,7 +302,7 @@ mod tests {
#[test]
fn metrics_params_default_to_single_sample() {
let uri: Uri = "/rustfs/admin/v3/metrics".parse().unwrap();
let uri: Uri = "/rustfs/admin/v3/realtime".parse().unwrap();
let mp = extract_metrics_init_params(&uri);
assert_eq!(resolve_sample_count(&mp), DEFAULT_METRICS_SAMPLES);
@@ -310,7 +310,7 @@ mod tests {
#[test]
fn metrics_params_treat_zero_as_single_sample() {
let uri: Uri = "/rustfs/admin/v3/metrics?n=0".parse().unwrap();
let uri: Uri = "/rustfs/admin/v3/realtime?n=0".parse().unwrap();
let mp = extract_metrics_init_params(&uri);
assert_eq!(resolve_sample_count(&mp), DEFAULT_METRICS_SAMPLES);
@@ -318,7 +318,7 @@ mod tests {
#[test]
fn metrics_params_cap_samples_to_safety_limit() {
let uri: Uri = "/rustfs/admin/v3/metrics?n=9999".parse().unwrap();
let uri: Uri = "/rustfs/admin/v3/realtime?n=9999".parse().unwrap();
let mp = extract_metrics_init_params(&uri);
assert_eq!(resolve_sample_count(&mp), MAX_METRICS_SAMPLES);
@@ -332,7 +332,7 @@ mod tests {
#[tokio::test]
async fn metrics_handler_rejects_missing_credentials() {
let result = MetricsHandler {}
.call(build_metrics_request("/rustfs/admin/v3/metrics"), Params::new())
.call(build_metrics_request("/rustfs/admin/v3/realtime"), Params::new())
.await;
let err = match result {
Ok(_) => panic!("metrics handler must reject unauthenticated requests"),
+222 -16
View File
@@ -43,13 +43,21 @@ use s3s::{
s3_error,
};
use serde::{Deserialize, Serialize};
use std::{sync::Arc, time::Duration};
use std::{
future::Future,
sync::Arc,
time::{Duration, Instant},
};
use time::OffsetDateTime;
use tracing::{error, info, warn};
const LOG_COMPONENT_ADMIN: &str = "admin";
const LOG_SUBSYSTEM_REBALANCE: &str = "rebalance";
const EVENT_ADMIN_REBALANCE_STATE: &str = "admin_rebalance_state";
const REBALANCE_START_FLEET_PROOF_MARKER: &str = "pool activation requires a live fleet capability proof";
const REBALANCE_START_FLEET_PROOF_RETRY_BUDGET: Duration = Duration::from_secs(30);
const REBALANCE_START_FLEET_PROOF_RETRY_DELAY: Duration = Duration::from_secs(5);
const REBALANCE_START_RETRY_AFTER_SECS: &str = "5";
fn admin_request_id(headers: &HeaderMap) -> Option<&str> {
headers
@@ -105,6 +113,80 @@ fn rebalance_internal_error(message: impl Into<String>) -> S3Error {
S3Error::with_message(S3ErrorCode::InternalError, message.into())
}
fn rebalance_fleet_proof_unavailable_error() -> S3Error {
let mut err = S3Error::with_message(
S3ErrorCode::ServiceUnavailable,
"rebalance start is waiting for cluster capability readiness; retry later",
);
let mut headers = HeaderMap::new();
headers.insert(http::header::RETRY_AFTER, HeaderValue::from_static(REBALANCE_START_RETRY_AFTER_SECS));
err.set_headers(headers);
err
}
fn is_rebalance_start_fleet_proof_retryable(err: &StorageError) -> bool {
crate::storage_api::capacity::is_pool_activation_fleet_proof_error(err)
&& err.to_string().contains(REBALANCE_START_FLEET_PROOF_MARKER)
}
fn rebalance_start_failure_error(start_err: &StorageError, rollback_result: &Result<(), String>) -> S3Error {
if crate::storage_api::capacity::is_pool_activation_fleet_proof_error(start_err) {
if rollback_result.is_ok() {
return rebalance_fleet_proof_unavailable_error();
}
return rebalance_internal_error("failed to roll back rebalance start after fleet capability readiness failure");
}
rebalance_internal_error(rebalance_start_rollback_error(&start_err.to_string(), rollback_result))
}
async fn retry_rebalance_start_operation<T, F, Fut>(
operation: &'static str,
rebalance_id: &str,
request_id: &str,
actor: &str,
remote_addr: &str,
deadline: Instant,
mut operation_fn: F,
) -> Result<T, StorageError>
where
F: FnMut() -> Fut,
Fut: Future<Output = Result<T, StorageError>>,
{
let mut attempt = 1_u32;
loop {
match operation_fn().await {
Ok(value) => return Ok(value),
Err(err) if is_rebalance_start_fleet_proof_retryable(&err) => {
let now = Instant::now();
if now >= deadline {
return Err(err);
}
let delay = REBALANCE_START_FLEET_PROOF_RETRY_DELAY.min(deadline.saturating_duration_since(now));
warn!(
event = EVENT_ADMIN_REBALANCE_STATE,
component = LOG_COMPONENT_ADMIN,
subsystem = LOG_SUBSYSTEM_REBALANCE,
action = "start",
state = "fleet_proof_retry_scheduled",
result = "retrying",
operation,
attempt,
retry_delay_ms = delay.as_millis(),
request_id = %request_id,
actor = %actor,
remote_addr = %remote_addr,
rebalance_id = %rebalance_id,
"admin rebalance state"
);
tokio::time::sleep(delay).await;
attempt = attempt.saturating_add(1);
}
Err(err) => return Err(err),
}
}
}
fn rebalance_rollback_stop_failure_message(rebalance_id: &str, failures: &[String]) -> String {
format!("cluster stop_rebalance rollback for {rebalance_id} partial: {}", failures.join("; "))
}
@@ -209,7 +291,7 @@ async fn rollback_rebalance_start_for_admin(
store: &Arc<ECStore>,
notification_sys: Option<&NotificationSys>,
rebalance_id: &str,
start_err: &str,
start_err: &StorageError,
request_id: &str,
actor: &str,
remote_addr: &str,
@@ -246,7 +328,7 @@ async fn rollback_rebalance_start_for_admin(
),
}
Err(rebalance_internal_error(rebalance_start_rollback_error(start_err, &rollback_result)))
Err(rebalance_start_failure_error(start_err, &rollback_result))
}
pub fn register_rebalance_route(r: &mut S3Router<AdminOperation>) -> std::io::Result<()> {
@@ -562,6 +644,7 @@ impl Operation for RebalanceStart {
"admin rebalance state"
);
let notification_sys = current_notification_system();
let fleet_proof_deadline = Instant::now() + REBALANCE_START_FLEET_PROOF_RETRY_BUDGET;
for step in rebalance_start_steps(notification_sys.is_some()) {
match step {
RebalanceStartStep::PropagateFence => {
@@ -592,13 +675,11 @@ impl Operation for RebalanceStart {
error = %err,
"admin rebalance state"
);
let start_err = err.to_string();
rollback_rebalance_start_for_admin(
&store,
Some(notification_sys),
&id,
&start_err,
&err,
&request_id,
&actor,
&remote_addr,
@@ -608,7 +689,17 @@ impl Operation for RebalanceStart {
}
}
RebalanceStartStep::StartLocal => {
if let Err(err) = store.start_rebalance_for_id(&id).await {
let start_result = retry_rebalance_start_operation(
"start_rebalance_for_id",
&id,
&request_id,
&actor,
&remote_addr,
fleet_proof_deadline,
|| store.start_rebalance_for_id(&id),
)
.await;
if let Err(err) = start_result {
error!(
event = EVENT_ADMIN_REBALANCE_STATE,
component = LOG_COMPONENT_ADMIN,
@@ -682,6 +773,9 @@ impl Operation for RebalanceStart {
)));
}
}
if crate::storage_api::capacity::is_pool_activation_fleet_proof_error(&err) {
return Err(rebalance_fleet_proof_unavailable_error());
}
return Err(rebalance_internal_error(format!(
"failed to start rebalance after metadata initialized for {id}; local metadata was finalized as failed: {start_err}"
)));
@@ -701,7 +795,17 @@ impl Operation for RebalanceStart {
rebalance_id = %id,
"admin rebalance state"
);
if let Err(err) = notification_sys.load_rebalance_meta(true).await {
let worker_result = retry_rebalance_start_operation(
"load_rebalance_meta(start=true)",
&id,
&request_id,
&actor,
&remote_addr,
fleet_proof_deadline,
|| notification_sys.load_rebalance_meta(true),
)
.await;
if let Err(err) = worker_result {
error!(
event = EVENT_ADMIN_REBALANCE_STATE,
component = LOG_COMPONENT_ADMIN,
@@ -715,13 +819,11 @@ impl Operation for RebalanceStart {
error = %err,
"admin rebalance state"
);
let start_err = err.to_string();
rollback_rebalance_start_for_admin(
&store,
Some(notification_sys),
&id,
&start_err,
&err,
&request_id,
&actor,
&remote_addr,
@@ -1059,12 +1161,14 @@ mod rebalance_handler_tests {
use super::calculate_rebalance_progress;
use super::{
Body, HeaderMap, Method, Operation, Params, RebalPoolProgress, RebalanceAdminStatus, RebalancePoolStatus, RebalanceStart,
RebalanceStartStep, RebalanceStatus, RebalanceStop, RebalanceStopPropagationStatus, S3ErrorCode, S3Request, Uri,
build_rebalance_admin_status, build_rebalance_pool_statuses, build_rebalance_stop_propagation_status,
rebalance_pool_used, rebalance_query_present, rebalance_remaining_buckets, rebalance_rollback_failure_message,
rebalance_rollback_stop_failure_message, rebalance_start_rollback_error, rebalance_start_steps, rebalance_stop_target_id,
rebalance_used_pct, rollback_result_label, stop_rebalance_admission_first,
RebalanceStartStep, RebalanceStatus, RebalanceStop, RebalanceStopPropagationStatus, S3ErrorCode, S3Request, StatusCode,
Uri, build_rebalance_admin_status, build_rebalance_pool_statuses, build_rebalance_stop_propagation_status,
is_rebalance_start_fleet_proof_retryable, rebalance_pool_used, rebalance_query_present, rebalance_remaining_buckets,
rebalance_rollback_failure_message, rebalance_rollback_stop_failure_message, rebalance_start_failure_error,
rebalance_start_rollback_error, rebalance_start_steps, rebalance_stop_target_id, rebalance_used_pct,
retry_rebalance_start_operation, rollback_result_label, stop_rebalance_admission_first,
};
use crate::admin::storage_api::error::StorageError;
use crate::admin::storage_api::rebalance::{
DiskStat, RebalSaveOpt, RebalStatus, RebalanceCleanupWarningEntry, RebalanceCleanupWarnings, RebalanceInfo,
RebalanceMeta, RebalanceStats, RebalanceStopPropagationRecord, encode_rebalance_stop_propagation_record,
@@ -1328,6 +1432,108 @@ mod rebalance_handler_tests {
assert!(message.contains("rollback error: peer b stop_rebalance failed: timeout"));
}
#[test]
fn test_rebalance_start_fleet_proof_maps_to_retryable_503() {
let start_err = StorageError::other(super::REBALANCE_START_FLEET_PROOF_MARKER);
let err = rebalance_start_failure_error(&start_err, &Ok(()));
assert_eq!(err.code(), &S3ErrorCode::ServiceUnavailable);
assert_eq!(err.status_code(), Some(StatusCode::SERVICE_UNAVAILABLE));
assert_eq!(
err.headers()
.and_then(|headers| headers.get(http::header::RETRY_AFTER))
.and_then(|value| value.to_str().ok()),
Some(super::REBALANCE_START_RETRY_AFTER_SECS)
);
assert!(
!err.message()
.expect("retryable rebalance start error should have a message")
.contains(super::REBALANCE_START_FLEET_PROOF_MARKER)
);
}
#[test]
fn test_rebalance_start_fleet_proof_rollback_failure_is_generic_internal_error() {
let start_err = StorageError::other(super::REBALANCE_START_FLEET_PROOF_MARKER);
let rollback_result = Err("peer rollback failed".to_string());
let err = rebalance_start_failure_error(&start_err, &rollback_result);
assert_eq!(err.code(), &S3ErrorCode::InternalError);
assert!(
!err.message()
.expect("internal rebalance start error should have a message")
.contains(super::REBALANCE_START_FLEET_PROOF_MARKER)
);
}
#[test]
fn test_rebalance_start_unrelated_failure_remains_internal_error() {
let start_err = StorageError::other("disk read failed");
let err = rebalance_start_failure_error(&start_err, &Ok(()));
assert_eq!(err.code(), &S3ErrorCode::InternalError);
assert!(err.message().is_some_and(|message| message.contains("disk read failed")));
}
#[test]
fn test_rebalance_start_fleet_proof_retry_only_matches_missing_live_proof() {
let missing = StorageError::other(super::REBALANCE_START_FLEET_PROOF_MARKER);
let expired = StorageError::other(format!(
"rebalance meta save failed during start_rebalance: {}",
"pool activation fleet capability proof expired before commit"
));
assert!(is_rebalance_start_fleet_proof_retryable(&missing));
assert!(!is_rebalance_start_fleet_proof_retryable(&expired));
}
#[tokio::test(start_paused = true)]
async fn test_rebalance_start_retry_waits_for_fleet_proof() {
use std::sync::{
Arc,
atomic::{AtomicUsize, Ordering},
};
let attempts = Arc::new(AtomicUsize::new(0));
let task = tokio::spawn({
let attempts = Arc::clone(&attempts);
async move {
retry_rebalance_start_operation(
"load_rebalance_meta(start=true)",
"rebalance-id",
"request-id",
"actor",
"remote",
std::time::Instant::now() + super::REBALANCE_START_FLEET_PROOF_RETRY_BUDGET,
move || {
let attempts = Arc::clone(&attempts);
async move {
let attempt = attempts.fetch_add(1, Ordering::SeqCst);
if attempt == 0 {
Err(StorageError::other(super::REBALANCE_START_FLEET_PROOF_MARKER))
} else {
Ok(())
}
}
},
)
.await
}
});
tokio::task::yield_now().await;
assert_eq!(attempts.load(Ordering::SeqCst), 1);
tokio::time::advance(super::REBALANCE_START_FLEET_PROOF_RETRY_DELAY).await;
task.await
.expect("retry task should not panic")
.expect("fleet proof retry should eventually succeed");
assert_eq!(attempts.load(Ordering::SeqCst), 2);
}
#[test]
fn test_rebalance_rollback_stop_failure_message_lists_failures() {
let failures = vec![
+3 -3
View File
@@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use super::{cluster_snapshot, metrics};
use super::{cluster_snapshot, realtime};
use crate::admin::auth::authorize_admin_request;
use crate::admin::handlers::account::{ACCOUNT_INFO_ROUTE, ACCOUNT_PASSWORD_ROUTE};
use crate::admin::handlers::mfa::{ACCOUNT_MFA_ROUTE, MFA_CHALLENGE_ROUTE, USER_MFA_ROUTE};
@@ -167,8 +167,8 @@ pub fn register_system_route(r: &mut S3Router<AdminOperation>) -> std::io::Resul
r.insert(
Method::GET,
format!("{}{}", ADMIN_PREFIX, "/v3/metrics").as_str(),
AdminOperation(&metrics::MetricsHandler {}),
format!("{}{}", ADMIN_PREFIX, "/v3/realtime").as_str(),
AdminOperation(&realtime::MetricsHandler {}),
)?;
r.insert(
+8 -2
View File
@@ -312,9 +312,15 @@ pub const ADMIN_ROUTE_POLICY_SPECS: &[AdminRouteSpec] = &[
GET_BUCKET_TARGET,
RouteRiskLevel::Sensitive,
),
admin(
HttpMethod::Get,
"/rustfs/admin/v3/target/{target_type}/{target_name}/subscriptions",
GET_BUCKET_TARGET,
RouteRiskLevel::Sensitive,
),
admin(HttpMethod::Get, "/rustfs/admin/v3/info", SERVER_INFO, RouteRiskLevel::Sensitive),
admin(HttpMethod::Get, "/rustfs/admin/v3/storageinfo", STORAGE_INFO, RouteRiskLevel::Sensitive),
admin(HttpMethod::Get, "/rustfs/admin/v3/metrics", GET_METRICS, RouteRiskLevel::Sensitive),
admin(HttpMethod::Get, "/rustfs/admin/v3/realtime", GET_METRICS, RouteRiskLevel::Sensitive),
admin(
HttpMethod::Get,
"/rustfs/admin/v3/object-data-cache/stats",
@@ -2297,7 +2303,7 @@ mod tests {
#[test]
fn route_policy_maps_metrics_to_explicit_admin_action() {
assert_action(HttpMethod::Get, "/rustfs/admin/v3/metrics", GET_METRICS);
assert_action(HttpMethod::Get, "/rustfs/admin/v3/realtime", GET_METRICS);
}
#[test]
+7 -2
View File
@@ -175,6 +175,11 @@ fn expected_admin_route_matrix() -> Vec<RouteMatrixEntry> {
"/v3/target/webhook/test-target/reset",
),
admin_route(Method::GET, "/v3/target/arns"),
admin_route_sample(
Method::GET,
"/v3/target/{target_type}/{target_name}/subscriptions",
"/v3/target/webhook/test-target/subscriptions",
),
admin_route(Method::POST, "/v3/service"),
admin_route(Method::POST, "/v3/update"),
admin_route(Method::GET, "/v3/info"),
@@ -185,7 +190,7 @@ fn expected_admin_route_matrix() -> Vec<RouteMatrixEntry> {
admin_route(Method::GET, "/v3/storageinfo"),
admin_route(Method::GET, "/v3/datausageinfo"),
admin_route_sample(Method::GET, "/v3/usage/{bucket}", "/v3/usage/test-bucket"),
admin_route(Method::GET, "/v3/metrics"),
admin_route(Method::GET, "/v3/realtime"),
admin_route(Method::GET, "/v3/object-data-cache/stats"),
admin_route(Method::POST, "/v3/object-data-cache/flush"),
admin_route(Method::GET, "/v3/pools/list"),
@@ -1328,7 +1333,7 @@ fn test_register_routes_cover_representative_admin_paths() {
assert_route(&router, Method::POST, &admin_path("/v3/service"));
assert_route(&router, Method::GET, &admin_path("/v3/info"));
assert_route(&router, Method::GET, &admin_path("/v3/storageinfo"));
assert_route(&router, Method::GET, &admin_path("/v3/metrics"));
assert_route(&router, Method::GET, &admin_path("/v3/realtime"));
assert_route(&router, Method::GET, &admin_path("/v3/pools/list"));
assert_route(&router, Method::GET, &admin_path("/v3/decommission/status"));
+48 -10
View File
@@ -499,6 +499,14 @@ fn get_object_stream_failure_reason(error_class: &'static str) -> &'static str {
}
}
/// A mid-stream reopen can recover only when another representation of the
/// committed object may exist. A single-disk, no-parity object has no alternate
/// shard after a bitrot/read-quorum failure, so retrying the same read only
/// repeats the failure and adds latency.
fn should_attach_get_object_resume(info: &ObjectInfo) -> bool {
info.parity_blocks > 0 || info.data_blocks > 1 || !info.transitioned_object.tier.is_empty()
}
fn record_get_object_reader_stream_failure(
reason: &'static str,
error_class: &'static str,
@@ -4188,16 +4196,18 @@ impl DefaultObjectUsecase {
part_number,
lifecycle,
|info| {
Some(get_object_resume_control(GetObjectResumeContext::new(
store,
&bucket,
&key,
opts,
&req.headers,
info,
resume_range_start,
resume_range_end,
)))
should_attach_get_object_resume(info).then(|| {
get_object_resume_control(GetObjectResumeContext::new(
store,
&bucket,
&key,
opts,
&req.headers,
info,
resume_range_start,
resume_range_end,
))
})
},
)
.await;
@@ -8365,6 +8375,34 @@ mod tests {
assert_eq!(reopen_count.load(Ordering::Relaxed), 0);
}
#[test]
fn get_object_resume_only_attaches_for_recoverable_layouts() {
let single_disk = ObjectInfo {
data_blocks: 1,
parity_blocks: 0,
..Default::default()
};
assert!(!should_attach_get_object_resume(&single_disk));
let erasure = ObjectInfo {
data_blocks: 2,
parity_blocks: 1,
..Default::default()
};
assert!(should_attach_get_object_resume(&erasure));
let transitioned = ObjectInfo {
data_blocks: 1,
parity_blocks: 0,
transitioned_object: crate::storage_api::server::event::contract::lifecycle::TransitionedObject {
tier: "remote-tier".to_string(),
..Default::default()
},
..Default::default()
};
assert!(should_attach_get_object_resume(&transitioned));
}
#[test]
fn get_object_resume_identity_requires_same_version() {
let version_id = Uuid::from_u128(0x1234);
+60 -1
View File
@@ -29,6 +29,7 @@ use std::sync::OnceLock;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use tokio::spawn;
use tokio::sync::Mutex as AsyncMutex;
use tokio::task::JoinHandle;
use tokio::time::{Instant, MissedTickBehavior};
use tokio_util::sync::CancellationToken;
@@ -38,6 +39,12 @@ static NOTIFY_RUNTIME_RECONCILED: AtomicBool = AtomicBool::new(false);
static NOTIFY_BUCKET_RULES_RECONCILED: AtomicBool = AtomicBool::new(false);
static ECSTORE_EVENT_DISPATCH_HOOK: OnceLock<()> = OnceLock::new();
// Serialize local notification lifecycle publication with the periodic and
// peer-triggered reconciler. Lock order is this guard before the module-switch
// RMW/object locks and the server-config read lock; the module-switch handler's
// preliminary server-config read is released before it takes the RMW lock.
static NOTIFY_RUNTIME_RECONCILE_LOCK: AsyncMutex<()> = AsyncMutex::const_new(());
const EVENT_NOTIFIER_RECONCILE_INTERVAL: Duration = Duration::from_secs(5);
const EVENT_NOTIFIER_RECONCILE_ATTEMPT_TIMEOUT: Duration = Duration::from_secs(120);
const EVENT_NOTIFY_RUNTIME_RECONCILE: &str = "notify_runtime_reconcile";
@@ -55,6 +62,14 @@ pub(crate) fn mark_event_notifier_unreconciled() {
NOTIFY_BUCKET_RULES_RECONCILED.store(false, Ordering::Release);
}
pub(crate) async fn with_notify_runtime_reconcile_lock<Fut, T>(operation: Fut) -> T
where
Fut: Future<Output = T>,
{
let _guard = NOTIFY_RUNTIME_RECONCILE_LOCK.lock().await;
operation.await
}
fn are_bucket_notification_rules_reconciled() -> bool {
NOTIFY_BUCKET_RULES_RECONCILED.load(Ordering::Acquire)
}
@@ -161,6 +176,12 @@ fn ensure_event_notifier_converged(system: &NotificationSystem) -> Result<(), No
pub(crate) async fn reconcile_event_notifier_from_store(
store: std::sync::Arc<rustfs_notify::NotifyStore>,
) -> Result<(), NotificationError> {
with_notify_runtime_reconcile_lock(reconcile_event_notifier_from_store_unlocked(store)).await
}
async fn reconcile_event_notifier_from_store_unlocked(
store: std::sync::Arc<rustfs_notify::NotifyStore>,
) -> Result<(), NotificationError> {
let result = async {
validate_notify_module_env().map_err(NotificationError::Initialization)?;
@@ -310,7 +331,7 @@ pub async fn shutdown_event_notifier() -> Result<(), NotificationError> {
#[instrument]
pub async fn init_event_notifier() -> Result<(), NotificationError> {
init_event_notifier_with_store(runtime_sources::current_object_store_handle).await
with_notify_runtime_reconcile_lock(init_event_notifier_with_store(runtime_sources::current_object_store_handle)).await
}
async fn init_event_notifier_with_store<CurrentStore>(current_store: CurrentStore) -> Result<(), NotificationError>
@@ -408,6 +429,7 @@ mod tests {
use super::{
convert_ecstore_object_info, init_event_notifier_with_store, mark_bucket_notification_rules_reconciled,
parse_host_and_port, run_persisted_event_notifier_reconciler, should_reconcile_bucket_notification_rules,
with_notify_runtime_reconcile_lock,
};
use crate::server::is_event_notifier_reconciled;
use crate::storage_api::server::event::StorageObjectInfo;
@@ -471,6 +493,43 @@ mod tests {
.await;
}
#[tokio::test]
#[serial]
async fn notify_runtime_reconcile_lock_serializes_overlapping_operations() {
let entered = Arc::new(Notify::new());
let release = Arc::new(Notify::new());
let second_attempted = Arc::new(Notify::new());
let second_started = Arc::new(AtomicBool::new(false));
let first_entered = entered.clone();
let first_release = release.clone();
let first = tokio::spawn(with_notify_runtime_reconcile_lock(async move {
first_entered.notify_one();
first_release.notified().await;
}));
entered.notified().await;
let second_attempted_signal = second_attempted.clone();
let second_started_flag = second_started.clone();
let second = tokio::spawn(async move {
second_attempted_signal.notify_one();
with_notify_runtime_reconcile_lock(async move {
second_started_flag.store(true, Ordering::Release);
})
.await;
});
second_attempted.notified().await;
assert!(
!second_started.load(Ordering::Acquire),
"overlapping reconcile work must wait for the first operation"
);
release.notify_one();
first.await.expect("first reconcile operation should finish");
second.await.expect("second reconcile operation should finish");
assert!(second_started.load(Ordering::Acquire));
}
#[test]
fn parse_host_and_port_with_ipv4_and_port() {
let (host, port) = parse_host_and_port("127.0.0.1:9000".to_string());
+1 -1
View File
@@ -2538,7 +2538,7 @@ mod tests {
assert!(futures::poll!(cancelled.as_mut()).is_pending());
drop(cancelled);
for path in [
"/rustfs/admin/v3/metrics",
"/rustfs/admin/v3/realtime",
"/minio/admin/v3/storageinfo",
CONSOLE_PREFIX,
"/rustfs/rpc/test",
+1 -1
View File
@@ -48,7 +48,7 @@ pub use service_state::wait_for_shutdown;
// Items only used within the library crate (admin handlers, server/http.rs, etc.).
pub(crate) use event::{
is_event_notifier_reconciled, mark_event_notifier_reconciled, mark_event_notifier_unreconciled,
reconcile_event_notifier_from_store, start_persisted_event_notifier_reconciler,
reconcile_event_notifier_from_store, start_persisted_event_notifier_reconciler, with_notify_runtime_reconcile_lock,
};
#[cfg(test)]
pub(crate) use health::{
+7 -4
View File
@@ -1712,16 +1712,17 @@ fn response_with_status(status: StatusCode, message: impl Into<String>) -> Respo
}
fn response_with_disk_error(error: &DiskError, message: impl Into<String>) -> Response<Body> {
let missing = match error {
let disk_error = match error {
DiskError::FileNotFound => Some(rustfs_rio::INTERNODE_FILE_NOT_FOUND),
DiskError::VolumeNotFound => Some(rustfs_rio::INTERNODE_VOLUME_NOT_FOUND),
DiskError::FileCorrupt => Some(rustfs_rio::INTERNODE_FILE_CORRUPT),
_ => None,
};
let mut response = response_with_status(StatusCode::INTERNAL_SERVER_ERROR, message);
if let Some(missing) = missing {
if let Some(disk_error) = disk_error {
response
.headers_mut()
.insert(rustfs_rio::INTERNODE_DISK_ERROR_HEADER, HeaderValue::from_static(missing));
.insert(rustfs_rio::INTERNODE_DISK_ERROR_HEADER, HeaderValue::from_static(disk_error));
}
response
}
@@ -3087,12 +3088,14 @@ mod tests {
}
#[test]
fn read_file_error_response_marks_only_missing_disk_errors() {
fn read_file_error_response_preserves_typed_disk_errors() {
for (error, expected) in [
(DiskError::FileNotFound, rustfs_rio::INTERNODE_FILE_NOT_FOUND),
(DiskError::VolumeNotFound, rustfs_rio::INTERNODE_VOLUME_NOT_FOUND),
(DiskError::FileCorrupt, rustfs_rio::INTERNODE_FILE_CORRUPT),
] {
let response = response_with_disk_error(&error, error.to_string());
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(
response.headers().get(rustfs_rio::INTERNODE_DISK_ERROR_HEADER),
Some(&HeaderValue::from_static(expected))
+101 -5
View File
@@ -52,9 +52,11 @@ use serde::Deserialize;
use sha2::{Digest, Sha256};
use std::{
collections::HashMap,
future::Future,
io::Cursor,
pin::Pin,
sync::{Arc, LazyLock, OnceLock},
time::Instant,
};
use time::OffsetDateTime;
use tokio::spawn;
@@ -78,6 +80,9 @@ const EVENT_RPC_REQUEST_FAILED: &str = "rpc_request_failed";
const EVENT_RPC_RESPONSE_EMITTED: &str = "rpc_response_emitted";
const EVENT_RPC_BACKGROUND_TASK_SPAWNED: &str = "rpc_background_task_spawned";
const EVENT_RPC_BACKGROUND_TASK_FAILED: &str = "rpc_background_task_failed";
const REBALANCE_START_FLEET_PROOF_MARKER: &str = "pool activation requires a live fleet capability proof";
const REBALANCE_START_FLEET_PROOF_RETRY_BUDGET: Duration = Duration::from_secs(30);
const REBALANCE_START_FLEET_PROOF_RETRY_DELAY: Duration = Duration::from_secs(5);
const HEAL_CONTROL_REPLAY_CACHE_MAX_ENTRIES: usize = 4096;
const TIER_MUTATION_PEER_STATE_UNSPECIFIED_WIRE: i32 = 0;
const TIER_MUTATION_PEER_STATE_PREPARED_WIRE: i32 = 1;
@@ -501,6 +506,48 @@ fn background_rebalance_start_error_message(result: StorageResult<()>) -> Option
result.err().map(|err| format!("start_rebalance failed: {err}"))
}
fn is_rebalance_start_fleet_proof_retryable(err: &Error) -> bool {
crate::storage::storage_api::ecstore_capacity::is_pool_activation_fleet_proof_error(err)
&& err.to_string().contains(REBALANCE_START_FLEET_PROOF_MARKER)
}
async fn retry_rebalance_start_fleet_proof<F, Fut>(mut operation: F) -> StorageResult<()>
where
F: FnMut() -> Fut,
Fut: Future<Output = StorageResult<()>>,
{
let deadline = Instant::now() + REBALANCE_START_FLEET_PROOF_RETRY_BUDGET;
let mut attempt = 1_u32;
loop {
match operation().await {
Ok(()) => return Ok(()),
Err(err) if is_rebalance_start_fleet_proof_retryable(&err) => {
let now = Instant::now();
if now >= deadline {
return Err(err);
}
let delay = REBALANCE_START_FLEET_PROOF_RETRY_DELAY.min(deadline.saturating_duration_since(now));
warn!(
event = EVENT_RPC_BACKGROUND_TASK_FAILED,
component = LOG_COMPONENT_STORAGE,
subsystem = LOG_SUBSYSTEM_REBALANCE,
operation = "start_rebalance",
state = "fleet_proof_retry_scheduled",
result = "retrying",
attempt,
retry_delay_ms = delay.as_millis(),
error = %err,
"node rpc background task retry"
);
tokio::time::sleep(delay).await;
attempt = attempt.saturating_add(1);
}
Err(err) => return Err(err),
}
}
}
fn stop_rebalance_response(result: StorageResult<()>) -> StopRebalanceResponse {
match result {
Ok(_) => StopRebalanceResponse {
@@ -2800,7 +2847,9 @@ impl Node for NodeService {
if start_rebalance {
log_background_rebalance_task_spawned!(start_rebalance);
if let Some(message) = background_rebalance_start_error_message(store.start_rebalance().await) {
if let Some(message) =
background_rebalance_start_error_message(retry_rebalance_start_fleet_proof(|| store.start_rebalance()).await)
{
error!(
event = EVENT_RPC_BACKGROUND_TASK_FAILED,
component = LOG_COMPONENT_STORAGE,
@@ -2975,9 +3024,10 @@ mod tests {
SCANNER_ACTIVITY_LEGACY_PROTOCOL_VERSION, SCANNER_ACTIVITY_PREVIOUS_PROTOCOL_VERSION, SCANNER_PUBLICATION_LEASE_TTL_MS,
SERVICE_SIGNAL_REFRESH_CONFIG, SERVICE_SIGNAL_RELOAD_DYNAMIC, STORAGE_CLASS_SUB_SYS, admit_heal_control_replay,
background_rebalance_start_error_message, execute_heal_control_envelope_with_manager,
initialize_heal_topology_fingerprint, initialize_heal_topology_fingerprint_with_probe, legacy_scanner_activity_response,
make_heal_control_server, make_heal_control_server_with_cache, make_server, make_server_for_context,
make_tier_mutation_control_server_for_context, previous_scanner_activity_response, remove_heal_control_replay,
initialize_heal_topology_fingerprint, initialize_heal_topology_fingerprint_with_probe,
is_rebalance_start_fleet_proof_retryable, legacy_scanner_activity_response, make_heal_control_server,
make_heal_control_server_with_cache, make_server, make_server_for_context, make_tier_mutation_control_server_for_context,
previous_scanner_activity_response, remove_heal_control_replay, retry_rebalance_start_fleet_proof,
scanner_activity_response_v7, start_decommission_failure_response, stop_rebalance_response,
validate_admin_heal_control_start,
};
@@ -6586,7 +6636,10 @@ mod tests {
);
assert!(!remote_object_dir.join(rollback_dir.to_string()).exists());
*set.disks.write().await = vec![Some(remote.clone()), Some(local_disks[1].clone()), None, None];
// Model a transient membership snapshot that contains only two
// attached disks. The write quorum must still come from the configured
// four-drive set, otherwise this quorum-minus-one delete is accepted.
*set.disks.write().await = vec![Some(remote.clone()), Some(local_disks[1].clone())];
for _ in 0..3 {
let marker = rustfs_filemeta::FileInfo {
name: object.to_string(),
@@ -8118,6 +8171,49 @@ mod tests {
assert!(message.contains("boom"));
}
#[test]
fn test_rebalance_start_retry_ignores_expired_fleet_proof() {
let expired = Error::other("pool activation fleet capability proof expired before commit");
assert!(!is_rebalance_start_fleet_proof_retryable(&expired));
}
#[tokio::test(start_paused = true)]
async fn test_retry_rebalance_start_waits_for_fleet_proof() {
use std::sync::{
Arc,
atomic::{AtomicUsize, Ordering},
};
let attempts = Arc::new(AtomicUsize::new(0));
let task = tokio::spawn({
let attempts = Arc::clone(&attempts);
async move {
retry_rebalance_start_fleet_proof(move || {
let attempts = Arc::clone(&attempts);
async move {
let attempt = attempts.fetch_add(1, Ordering::SeqCst);
if attempt == 0 {
Err(Error::other(super::REBALANCE_START_FLEET_PROOF_MARKER))
} else {
Ok(())
}
}
})
.await
}
});
tokio::task::yield_now().await;
assert_eq!(attempts.load(Ordering::SeqCst), 1);
tokio::time::advance(super::REBALANCE_START_FLEET_PROOF_RETRY_DELAY).await;
task.await
.expect("retry task should not panic")
.expect("fleet proof retry should eventually succeed");
assert_eq!(attempts.load(Ordering::SeqCst), 2);
}
#[test]
fn test_stop_rebalance_response_reports_local_stop_error() {
let response = stop_rebalance_response(Err(Error::other("boom")));
+1
View File
@@ -90,6 +90,7 @@ their issue closes.
| `test_build_rustfs_options.sh` | dev-tool | Shell test for rustfs build-option wiring | `make test` (script-tests) |
| `test_entrypoint_credentials.sh` | dev-tool | Container entrypoint credential-handling test | `make test` (script-tests) |
| `test_helm_chart_version.sh` | dev-tool | Test for `helm_chart_version.sh` | — |
| `test_package_service_scripts.sh` | ci-gate | Verifies DEB/RPM install, upgrade, removal, and service restart scriptlets | audit.yml `workflow-pin-report` |
| `test_package_versions.sh` | ci-gate | Exact-output and fail-closed tests for DEB/RPM package version normalization | audit.yml `workflow-pin-report`; package.yml RPM build |
| `windows-sftp-listener-smoke.sh` | dev-tool | Confirms `rustfs.exe --features sftp` binds an SFTP listener on Windows | — |
@@ -2277,7 +2277,7 @@ fi
rustfs/src/admin/router.rs \
rustfs/src/admin/console.rs \
rustfs/src/admin/handlers/heal.rs \
rustfs/src/admin/handlers/metrics.rs \
rustfs/src/admin/handlers/realtime.rs \
rustfs/src/admin/handlers/object_zip_download.rs || true
) >"$RUSTFS_APP_ADMIN_STORAGE_API_BYPASS_HITS_FILE"
+1 -1
View File
@@ -50,7 +50,7 @@
1|crates/ecstore/src/services/rebalance/entry.rs
8|crates/ecstore/src/services/rebalance/meta.rs
8|crates/ecstore/src/services/rebalance/runtime.rs
18|crates/ecstore/src/services/rebalance/worker.rs
17|crates/ecstore/src/services/rebalance/worker.rs
33|crates/ecstore/src/services/tier/tier.rs
1|crates/ecstore/src/services/tier/tier_config.rs
1|crates/ecstore/src/services/tier/warm_backend.rs
+112
View File
@@ -0,0 +1,112 @@
#!/usr/bin/env bash
set -euo pipefail
case ${1:-} in
after-install)
cat <<'SCRIPT'
#!/bin/sh
set -e
if ! getent passwd rustfs > /dev/null 2>&1; then
useradd -r -s /bin/false -d /opt/rustfs rustfs
fi
mkdir -p /opt/rustfs /data/rustfs /var/log/rustfs
chown rustfs:rustfs /opt/rustfs /data/rustfs /var/log/rustfs
if [ -d /run/systemd/system ]; then
systemctl daemon-reload
if [ "${1:-}" = configure ]; then
marker=/run/rustfs-package-upgrade-was-active
if [ -z "${2:-}" ]; then
rm -f "$marker"
else
# Versions published before this fix had no upgrade-aware prerm:
# the old prerm stopped the service and left no marker, so the
# marker check below cannot see it. Fall back to restarting for
# upgrades from those versions. NOTE: these are dpkg control-file
# versions - package_versions.sh maps the SemVer prerelease "-"
# to "~", so 1.0.0-rc.5 was published as 1.0.0~rc.5. Drop this list once
# upgrades from <= 1.0.0 no longer need support.
case $2 in
1.0.0|1.0.0~rc.[1-6]|1.0.0~rc.[1-6]~preview.*) legacy_upgrade=true ;;
*) legacy_upgrade=false ;;
esac
if [ -f "$marker" ] || [ "$legacy_upgrade" = true ]; then
rm -f "$marker"
if command -v deb-systemd-invoke > /dev/null 2>&1; then
deb-systemd-invoke restart rustfs.service
else
systemctl restart rustfs.service
fi
fi
fi
fi
fi
SCRIPT
;;
before-remove)
cat <<'SCRIPT'
#!/bin/sh
set -e
case ${1:-} in
upgrade)
marker=/run/rustfs-package-upgrade-was-active
rm -f "$marker"
if [ -d /run/systemd/system ] && systemctl is-active --quiet rustfs; then
touch "$marker"
fi
exit 0
;;
0|remove|deconfigure) ;;
*) exit 0 ;;
esac
if [ -d /run/systemd/system ] && systemctl is-active --quiet rustfs; then
systemctl stop rustfs
fi
SCRIPT
;;
after-remove)
cat <<'SCRIPT'
#!/bin/sh
set -e
if [ -d /run/systemd/system ]; then
systemctl daemon-reload
fi
if [ "${1:-}" != upgrade ]; then
rm -f /run/rustfs-package-upgrade-was-active
fi
SCRIPT
;;
rpm-before-install)
cat <<'SCRIPT'
#!/bin/sh
set -e
rm -f /run/rustfs-package-upgrade-was-active
SCRIPT
;;
rpm-before-upgrade)
cat <<'SCRIPT'
#!/bin/sh
set -e
marker=/run/rustfs-package-upgrade-was-active
rm -f "$marker"
if [ -d /run/systemd/system ] && systemctl is-active --quiet rustfs; then
touch "$marker"
fi
SCRIPT
;;
rpm-posttrans)
cat <<'SCRIPT'
#!/bin/sh
set -e
marker=/run/rustfs-package-upgrade-was-active
if [ -f "$marker" ]; then
rm -f "$marker"
systemctl restart rustfs
fi
SCRIPT
;;
*)
echo "usage: $0 {after-install|before-remove|after-remove|rpm-before-install|rpm-before-upgrade|rpm-posttrans}" >&2
exit 2
;;
esac
+12 -4
View File
@@ -37,6 +37,14 @@ esac
semver_core='(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)'
prerelease_id='(alpha|beta|rc)\.(0|[1-9][0-9]*)'
# Every SemVer pre-release separator becomes '~' in both package formats.
# dpkg and rpm (>= 4.10) both treat '~' as "sorts before anything, even the
# empty string", so 1.0.0~rc.5 < 1.0.0 and 1.0.0~rc.5~preview.2 < 1.0.0~rc.5.
# Neither '_' (an ordinary rpm segment separator, which makes 1.0.0_rc.5 sort
# above 1.0.0) nor a second '-' (which dpkg reads as the start of the Debian
# revision, so 1.0.0~rc.5-preview.2 sorts above 1.0.0~rc.5) preserves the
# SemVer ordering. GitHub stores '~' in asset names as '.'; package.yml
# accounts for that when it writes the release checksums.
tilde='~'
case "$build_type" in
@@ -65,8 +73,8 @@ case "$build_type" in
[[ "$source_version" =~ ^${semver_core}-${prerelease_id}$ ]] ||
fail "prerelease version must be strict alpha, beta, or rc SemVer"
deb_version=${source_version/-/$tilde}
rpm_version=${source_version//-/_}
deb_version=${source_version//-/$tilde}
rpm_version=${source_version//-/$tilde}
rpm_release=1
;;
preview)
@@ -74,8 +82,8 @@ case "$build_type" in
[[ "$source_version" =~ ^${semver_core}-${prerelease_id}-preview\.(0|[1-9][0-9]*)$ ]] ||
fail "preview version must be strict prerelease-preview SemVer"
deb_version=${source_version/-/$tilde}
rpm_version=${source_version//-/_}
deb_version=${source_version//-/$tilde}
rpm_version=${source_version//-/$tilde}
rpm_release=1
;;
*) fail "unsupported build type" ;;
+20
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
import hashlib
import io
import json
from datetime import datetime, timezone
import os
from pathlib import Path
import re
@@ -112,6 +113,25 @@ def prepare():
candidate = resolve(run_id, attempt)
revision = (ROOT / ".config/functional-script-revision.txt").read_text().strip()
require(sha(revision), "private test script revision must be pinned")
# The revision file is refreshed only by release syncs and goes stale
# within days, so the nightly chain keeps replaying an old harness while
# fixes (conffile prompts, FT --results-file, tier retries, STS-105
# signing) sit untested on auto-testing main. When the pinned revision is
# older than a day, fall back to auto-testing main HEAD. Any probe
# failure keeps the pinned revision - the chain still runs, just on the
# old harness.
try:
head = api("repos/rustfs/auto-testing/git/ref/heads/main")["object"]["sha"]
pinned_date = api(f"repos/rustfs/auto-testing/commits/{revision}")["commit"]["committer"]["date"]
pinned_at = datetime.fromisoformat(pinned_date.replace("Z", "+00:00"))
age_hours = (datetime.now(timezone.utc) - pinned_at).total_seconds() / 3600
if age_hours > 24:
print(f"functional-script revision {revision[:8]} is {age_hours:.0f}h old; falling back to auto-testing main HEAD {head[:8]}")
revision = head
else:
print(f"functional-script revision {revision[:8]} is {age_hours:.0f}h old; keeping pin")
except Exception as error:
print(f"staleness probe failed ({error}); keeping pinned revision {revision[:8]}")
chain = {"schema": 1, "run_id": int(os.environ["GITHUB_RUN_ID"]), "attempt": int(os.environ["GITHUB_RUN_ATTEMPT"]),
"workflow_sha": os.environ["GITHUB_SHA"], "testing_sha": revision, "candidate": candidate}
encoded = json.dumps(chain, sort_keys=True, separators=(",", ":"))
+116
View File
@@ -0,0 +1,116 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
generator="${repo_root}/scripts/release/package_service_scripts.sh"
test_tmp=$(mktemp -d)
trap 'rm -rf "$test_tmp"' EXIT
mkdir -p "${test_tmp}/bin"
mkdir -p "${test_tmp}/run/systemd/system"
cat > "${test_tmp}/bin/getent" <<'SCRIPT'
#!/bin/sh
exit 0
SCRIPT
cat > "${test_tmp}/bin/mkdir" <<'SCRIPT'
#!/bin/sh
exit 0
SCRIPT
cat > "${test_tmp}/bin/chown" <<'SCRIPT'
#!/bin/sh
exit 0
SCRIPT
cat > "${test_tmp}/bin/systemctl" <<'SCRIPT'
#!/bin/sh
printf 'systemctl %s\n' "$*" >> "$PACKAGE_SCRIPT_LOG"
case "$1" in
is-active) exit "${SYSTEMCTL_ACTIVE_EXIT:-0}" ;;
esac
SCRIPT
cat > "${test_tmp}/bin/deb-systemd-invoke" <<'SCRIPT'
#!/bin/sh
printf 'deb-systemd-invoke %s\n' "$*" >> "$PACKAGE_SCRIPT_LOG"
SCRIPT
chmod +x "${test_tmp}/bin/"*
export PATH="${test_tmp}/bin:${PATH}"
export PACKAGE_SCRIPT_LOG="${test_tmp}/calls"
run_script() {
local name=$1
shift
"$generator" "$name" |
sed \
-e "s#/run/systemd/system#${test_tmp}/run/systemd/system#g" \
-e "s#/run/rustfs-package-upgrade-was-active#${test_tmp}/run/rustfs-package-upgrade-was-active#g" \
> "${test_tmp}/${name}"
chmod +x "${test_tmp}/${name}"
"${test_tmp}/${name}" "$@"
}
assert_log() {
local expected=$1
printf '%s\n' "$expected" > "${test_tmp}/expected"
diff -u "${test_tmp}/expected" "$PACKAGE_SCRIPT_LOG"
}
: > "$PACKAGE_SCRIPT_LOG"
run_script before-remove upgrade 1.0.0
assert_log 'systemctl is-active --quiet rustfs'
marker="${test_tmp}/run/rustfs-package-upgrade-was-active"
[[ -f $marker ]]
: > "$PACKAGE_SCRIPT_LOG"
run_script after-remove upgrade 1.0.1
[[ -f $marker ]]
run_script after-install configure 1.0.1
[[ ! -e $marker ]]
assert_log $'systemctl daemon-reload\nsystemctl daemon-reload\ndeb-systemd-invoke restart rustfs.service'
: > "$PACKAGE_SCRIPT_LOG"
touch "$marker"
SYSTEMCTL_ACTIVE_EXIT=1 run_script before-remove upgrade 1.0.1
[[ ! -e $marker ]]
run_script after-remove upgrade 1.0.2
run_script after-install configure 1.0.1
assert_log $'systemctl is-active --quiet rustfs\nsystemctl daemon-reload\nsystemctl daemon-reload'
: > "$PACKAGE_SCRIPT_LOG"
run_script after-install configure 1.0.0~rc.5
assert_log $'systemctl daemon-reload\ndeb-systemd-invoke restart rustfs.service'
: > "$PACKAGE_SCRIPT_LOG"
run_script after-install configure 0~dev.9000.0123456789abcdef
assert_log 'systemctl daemon-reload'
: > "$PACKAGE_SCRIPT_LOG"
run_script before-remove remove
assert_log $'systemctl is-active --quiet rustfs\nsystemctl stop rustfs'
: > "$PACKAGE_SCRIPT_LOG"
touch "$marker"
run_script after-remove remove
[[ ! -e $marker ]]
assert_log 'systemctl daemon-reload'
: > "$PACKAGE_SCRIPT_LOG"
touch "$marker"
run_script after-install configure
[[ ! -e $marker ]]
assert_log 'systemctl daemon-reload'
: > "$PACKAGE_SCRIPT_LOG"
run_script rpm-before-upgrade
[[ -f $marker ]]
run_script rpm-posttrans
[[ ! -e $marker ]]
assert_log $'systemctl is-active --quiet rustfs\nsystemctl restart rustfs'
: > "$PACKAGE_SCRIPT_LOG"
SYSTEMCTL_ACTIVE_EXIT=1 run_script rpm-before-upgrade
[[ ! -e $marker ]]
run_script rpm-posttrans
assert_log 'systemctl is-active --quiet rustfs'
echo "PASS package service script contract"
+31 -11
View File
@@ -74,16 +74,16 @@ run_success stable-amd64 \
$'deb_version=1.2.3\nrpm_version=1.2.3\nrpm_release=1\ndeb_file=rustfs_1.2.3_amd64.deb\nrpm_file=rustfs-1.2.3-1.x86_64.rpm' \
release 1.2.3 '' amd64 x86_64
run_success alpha-arm64 \
$'deb_version=1.2.3~alpha.1\nrpm_version=1.2.3_alpha.1\nrpm_release=1\ndeb_file=rustfs_1.2.3~alpha.1_arm64.deb\nrpm_file=rustfs-1.2.3_alpha.1-1.aarch64.rpm' \
$'deb_version=1.2.3~alpha.1\nrpm_version=1.2.3~alpha.1\nrpm_release=1\ndeb_file=rustfs_1.2.3~alpha.1_arm64.deb\nrpm_file=rustfs-1.2.3~alpha.1-1.aarch64.rpm' \
prerelease 1.2.3-alpha.1 '' arm64 aarch64
run_success beta-amd64 \
$'deb_version=1.2.3~beta.2\nrpm_version=1.2.3_beta.2\nrpm_release=1\ndeb_file=rustfs_1.2.3~beta.2_amd64.deb\nrpm_file=rustfs-1.2.3_beta.2-1.x86_64.rpm' \
$'deb_version=1.2.3~beta.2\nrpm_version=1.2.3~beta.2\nrpm_release=1\ndeb_file=rustfs_1.2.3~beta.2_amd64.deb\nrpm_file=rustfs-1.2.3~beta.2-1.x86_64.rpm' \
prerelease 1.2.3-beta.2 '' amd64 x86_64
run_success rc-amd64 \
$'deb_version=1.2.3~rc.4\nrpm_version=1.2.3_rc.4\nrpm_release=1\ndeb_file=rustfs_1.2.3~rc.4_amd64.deb\nrpm_file=rustfs-1.2.3_rc.4-1.x86_64.rpm' \
$'deb_version=1.2.3~rc.4\nrpm_version=1.2.3~rc.4\nrpm_release=1\ndeb_file=rustfs_1.2.3~rc.4_amd64.deb\nrpm_file=rustfs-1.2.3~rc.4-1.x86_64.rpm' \
prerelease 1.2.3-rc.4 '' amd64 x86_64
run_success preview-amd64 \
$'deb_version=1.0.0~rc.5-preview.2\nrpm_version=1.0.0_rc.5_preview.2\nrpm_release=1\ndeb_file=rustfs_1.0.0~rc.5-preview.2_amd64.deb\nrpm_file=rustfs-1.0.0_rc.5_preview.2-1.x86_64.rpm' \
$'deb_version=1.0.0~rc.5~preview.2\nrpm_version=1.0.0~rc.5~preview.2\nrpm_release=1\ndeb_file=rustfs_1.0.0~rc.5~preview.2_amd64.deb\nrpm_file=rustfs-1.0.0~rc.5~preview.2-1.x86_64.rpm' \
preview 1.0.0-rc.5-preview.2 '' amd64 x86_64
run_success development-amd64 \
"deb_version=0~dev.7463.${sha}
@@ -126,23 +126,43 @@ run_failure newline release $'1.2.3\nforged=1' '' amd64 x86_64
run_failure unsupported-deb-arch release 1.2.3 '' x86_64 x86_64
run_failure mismatched-arch release 1.2.3 '' amd64 aarch64
# Ordering contract shared by both package managers: every pre-release sorts
# below its final release, every preview sorts below the pre-release it
# previews, and pre-release kinds/numbers keep their SemVer order.
if command -v dpkg >/dev/null 2>&1; then
dpkg --compare-versions "0~dev.7462.${sha}" lt "0~dev.7463.${sha}"
dpkg --compare-versions "0~dev.7463.${sha}" lt 0.1.0
dpkg --compare-versions 1.2.3~rc.4 lt 1.2.3
passed=$((passed + 3))
dpkg --compare-versions 1.2.3~alpha.1 lt 1.2.3~beta.2
dpkg --compare-versions 1.2.3~beta.2 lt 1.2.3~rc.4
dpkg --compare-versions 1.2.3~rc.9 lt 1.2.3~rc.10
dpkg --compare-versions 1.0.0~rc.5~preview.2 lt 1.0.0~rc.5
dpkg --compare-versions 1.0.0~rc.5~preview.1 lt 1.0.0~rc.5~preview.2
dpkg --compare-versions 1.0.0~rc.5~preview.2 lt 1.0.0~rc.6
dpkg --compare-versions 1.0.0~rc.5 lt 1.0.1
passed=$((passed + 10))
elif [[ $require_package_managers == true ]]; then
printf 'FAIL package ordering: dpkg is required\n' >&2
exit 1
fi
if command -v rpm >/dev/null 2>&1; then
rpm_old="0-0.dev.7462.${sha}"
rpm_new="0-0.dev.7463.${sha}"
rpm_release=0.1.0-1
[[ $(rpm --eval "%{lua: print(rpm.vercmp('${rpm_old}', '${rpm_new}'))}") == -1 ]]
[[ $(rpm --eval "%{lua: print(rpm.vercmp('${rpm_new}', '${rpm_release}'))}") == -1 ]]
passed=$((passed + 2))
rpm_lt() {
[[ $(rpm --eval "%{lua: print(rpm.vercmp('$1', '$2'))}") == -1 ]] ||
{ printf 'FAIL rpm ordering: expected %s < %s\n' "$1" "$2" >&2; exit 1; }
passed=$((passed + 1))
}
rpm_lt "0-0.dev.7462.${sha}" "0-0.dev.7463.${sha}"
rpm_lt "0-0.dev.7463.${sha}" 0.1.0-1
# The former 1.2.3_rc.4 spelling compared as newer than 1.2.3 (issue #8012).
rpm_lt 1.2.3~rc.4-1 1.2.3-1
rpm_lt 1.2.3~alpha.1-1 1.2.3~beta.2-1
rpm_lt 1.2.3~beta.2-1 1.2.3~rc.4-1
rpm_lt 1.2.3~rc.9-1 1.2.3~rc.10-1
rpm_lt 1.0.0~rc.5~preview.2-1 1.0.0~rc.5-1
rpm_lt 1.0.0~rc.5~preview.1-1 1.0.0~rc.5~preview.2-1
rpm_lt 1.0.0~rc.5~preview.2-1 1.0.0~rc.6-1
rpm_lt 1.0.0~rc.5-1 1.0.1-1
elif [[ $require_package_managers == true ]]; then
printf 'FAIL package ordering: rpm is required\n' >&2
exit 1