merge: resolve conflicts with main branch

- Merge origin/main into perf/fileinfo-optimization
- Resolve conflicts in crates/ecstore/src/set_disk/ops/object.rs
- Keep AHashMap import and main branch's detailed imports

Co-Authored-By: heihutu <heihutu@gmail.com>
This commit is contained in:
houseme
2026-08-27 16:03:35 +08:00
333 changed files with 36566 additions and 31477 deletions
+11 -6
View File
@@ -138,7 +138,7 @@ test-group = 'e2e-inline-boundaries'
# does not cross nextest process boundaries, so keep every Vault-backed test in
# one group.
[[profile.default.overrides]]
filter = 'package(e2e_test) & (test(/^kms::kms_vault_test::/) | test(/^kms::configured_roundtrip_test::test_configured_vault_kms_admin_and_versioned_cleanup$/))'
filter = 'package(e2e_test) & (test(/^kms::kms_vault_test::/) | test(/^kms::kms_rekey_sweep_test::/) | test(/^kms::configured_roundtrip_test::test_configured_vault_kms_admin_and_versioned_cleanup$/))'
test-group = 'e2e-vault'
# ---------------------------------------------------------------------------
@@ -196,6 +196,15 @@ test-group = 'ecstore-serial-flaky'
filter = 'package(rustfs-ecstore) & test(walk_dir_does_not_charge_consumer_backpressure_to_the_stall_budget)'
retries = 2
# Serialize the relocated-pool GET resume regression under the ci profile too
# (see the matching default-profile override near the top). No longer a
# quarantine: the fixture race (rustfs#6701/rustfs#6703) was fixed by #6707,
# which made the staging tolerate quorum-tolerated disk gaps; only the 8-disk
# cross-disk-IO serialization remains.
[[profile.ci.overrides]]
filter = 'package(rustfs) & test(execute_get_object_resumes_from_relocated_pool_without_splicing_body)'
test-group = 'ecstore-serial-flaky'
# Serialize the 4-disk reliability / degraded-read e2e tests under the ci
# profile too (see the e2e-reliability test-group note near the top). Not a
# quarantine: no retries, just single-threaded so several 4-disk servers never
@@ -211,10 +220,6 @@ test-group = 'e2e-reliability'
filter = 'package(rustfs-ecstore) & test(/^set_disk::ops::multipart::tests::crash_consistency::/)'
test-group = 'ecstore-serial-flaky'
[[profile.ci.overrides]]
filter = 'package(rustfs) & test(execute_get_object_resumes_from_relocated_pool_without_splicing_body)'
test-group = 'ecstore-serial-flaky'
# Match the default-profile embedded test isolation without quarantining or
# retrying failures in CI.
[[profile.ci.overrides]]
@@ -484,5 +489,5 @@ filter = 'package(e2e_test) & test(/^inline_fast_path_cluster_test::/)'
test-group = 'e2e-inline-boundaries'
[[profile.e2e-full.overrides]]
filter = 'package(e2e_test) & (test(/^kms::kms_vault_test::/) | test(/^kms::configured_roundtrip_test::test_configured_vault_kms_admin_and_versioned_cleanup$/))'
filter = 'package(e2e_test) & (test(/^kms::kms_vault_test::/) | test(/^kms::kms_rekey_sweep_test::/) | test(/^kms::configured_roundtrip_test::test_configured_vault_kms_admin_and_versioned_cleanup$/))'
test-group = 'e2e-vault'
+71 -2
View File
@@ -429,6 +429,11 @@ jobs:
# - test_noncurrent_{expiry,transition}_still_works_after_immediate_compensation_transition:
# noncurrent transition/expiry after an immediate compensation transition.
- name: Run ignored ILM integration tests serially
env:
# Match the measured Test and Lint link budget. The default exposed
# all 14 pod CPUs and a cold cache spent the full 80m compiling
# without starting one ILM test (main run 32982910990).
CARGO_BUILD_JOBS: ${{ (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && '3' || '2' }}
run: |
mkdir -p artifacts/ilm-integration
set +e
@@ -492,9 +497,70 @@ jobs:
# job's 90-minute budget before any test starts.
CARGO_BUILD_JOBS: "2"
run: |
cargo nextest run -p rustfs -p rustfs-ecstore --features rio-v2
# --profile ci so the quarantine list (and its junit flaky markers)
# covers this leg too; the default profile is the local no-retry
# profile and silently ignored quarantined flakes here (rustfs#6703).
cargo nextest run --profile ci -p rustfs -p rustfs-ecstore --features rio-v2
cargo test -p rustfs --doc --features rio-v2
connect-short-credential-boundary:
name: Connect Short Credential Boundary
if: github.event_name != 'pull_request' || github.event.action != 'closed'
needs: [ quick-checks ]
runs-on: sm-standard-4
timeout-minutes: 60
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
- name: Setup Rust environment
uses: ./.github/actions/setup
with:
rust-version: stable
cache-shared-key: ci-dev
cache-save-if: 'false'
install-build-packaging-tools: 'false'
install-test-tools: 'false'
- name: Run short credential behavior tests
env:
CARGO_BUILD_JOBS: "2"
run: |
cargo test -p rustfs --test connect_registration \
--features connect-e2e-short-credentials \
registration_enforces_build_profile_credential_lifetime -- --exact
cargo test -p rustfs --test connect_registration \
--features connect-e2e-short-credentials \
rotation_waits_for_threshold_and_stops_on_revocation -- --exact
- name: Reject short credentials in release builds
env:
CARGO_BUILD_JOBS: "2"
run: |
log="$(mktemp)"
set +e
CARGO_TERM_COLOR=never cargo check -p rustfs --release \
--features connect-e2e-short-credentials >"$log" 2>&1
status=$?
set -e
cat "$log"
expected='error: connect-e2e-short-credentials is restricted to debug builds'
summary="error: could not compile \`rustfs\` (lib) due to 1 previous error"
expected_count="$(grep -Fxc "$expected" "$log" || true)"
summary_count="$(grep -Fc "$summary" "$log" || true)"
error_count="$(grep -Ec '^error(:|\[)' "$log" || true)"
if [ "$status" -ne 101 ] || [ "$expected_count" -ne 1 ] \
|| [ "$summary_count" -ne 1 ] || [ "$error_count" -ne 2 ]; then
echo "release feature gate did not fail solely at the expected compile_error" >&2
rm -f "$log"
exit 1
fi
rm -f "$log"
test-and-lint-protocols:
name: "Test and Lint (${{ matrix.features.name }})"
if: github.event_name != 'pull_request' || github.event.action != 'closed'
@@ -544,7 +610,10 @@ jobs:
# main nextest lane; Clippy is metadata-only and needs no such limit.
CARGO_BUILD_JOBS: "2"
run: |
cargo nextest run -p rustfs -p rustfs-protocols ${{ matrix.features.flags }}
# --profile ci so the quarantine list (and its junit flaky markers)
# covers this leg too; the default profile is the local no-retry
# profile and silently ignored quarantined flakes here (rustfs#6703).
cargo nextest run --profile ci -p rustfs -p rustfs-protocols ${{ matrix.features.flags }}
build-rustfs-debug-binary:
name: Build RustFS Debug Binary
+32 -20
View File
@@ -58,9 +58,12 @@ defaults:
env:
RUSTFS_ACCESS_KEY: ${{ secrets.RUSTFS_ACCESS_KEY }}
RUSTFS_SECRET_KEY: ${{ secrets.RUSTFS_SECRET_KEY }}
RUSTFS_API_ENDPOINT: ${{ vars.RUSTFS_API_ENDPOINT || vars.RUSTFS_RC_ENDPOINT }}
RUSTFS_NODES: ${{ vars.RUSTFS_NODES }}
RUSTFS_SSH_USER: ${{ vars.RUSTFS_SSH_USER }}
RUSTFS_API_ENDPOINT: ${{ secrets.RUSTFS_API_ENDPOINT || vars.RUSTFS_API_ENDPOINT || vars.RUSTFS_RC_ENDPOINT }}
RUSTFS_NODES: ${{ secrets.RUSTFS_NODES || vars.RUSTFS_NODES }}
RUSTFS_SSH_USER: ${{ secrets.RUSTFS_SSH_USER || vars.RUSTFS_SSH_USER }}
# Package used by the scheduled run (workflow_dispatch inputs are empty for
# schedule events), i.e. the latest nightly deb published by nightly-gnu.yml.
RUSTFS_NIGHTLY_PACKAGE_URL: ${{ vars.RUSTFS_NIGHTLY_PACKAGE_URL || 'https://dl.rustfs.com/artifacts/rustfs/packages/nightly/rustfs-nightly-latest.deb' }}
jobs:
pool-expansion-test:
@@ -81,18 +84,32 @@ jobs:
df -h /data | tail -1
- name: Reset test environment (before)
if: ${{ inputs.cleanup_before }}
if: ${{ inputs.cleanup_before != 'false' }}
run: |
chmod +x scripts/test/rustfs_pool_expand.sh
./scripts/test/rustfs_pool_expand.sh --reset -y
- name: Install RustFS package & start first pool
run: |
ARGS=(--steps 1,2,3 -y --endpoint "${{ env.RUSTFS_API_ENDPOINT }}")
if [ -n "${{ inputs.package_url }}" ]; then
ARGS+=(--package-url "${{ inputs.package_url }}")
elif [ -n "${{ inputs.rustfs_version }}" ]; then
ARGS+=(--version "${{ inputs.rustfs_version }}")
else
ARGS+=(--package-url "${{ env.RUSTFS_NIGHTLY_PACKAGE_URL }}")
fi
./scripts/test/rustfs_pool_expand.sh "${ARGS[@]}"
- name: Preflight checks
run: |
ARGS=(--preflight --endpoint "${{ env.RUSTFS_API_ENDPOINT }}")
if [ -n "${{ inputs.package_url }}" ]; then
ARGS+=(--package-url "${{ inputs.package_url }}")
else
elif [ -n "${{ inputs.rustfs_version }}" ]; then
ARGS+=(--version "${{ inputs.rustfs_version }}")
else
ARGS+=(--package-url "${{ env.RUSTFS_NIGHTLY_PACKAGE_URL }}")
fi
./scripts/test/rustfs_pool_expand.sh "${ARGS[@]}"
@@ -100,24 +117,19 @@ jobs:
id: pool_test
run: |
set -o pipefail
STEPS="1,2,3,4,5,6"
if [ "${{ inputs.pools }}" = "3" ]; then
STEPS="4,5,6"
if [ "${{ inputs.pools || '3' }}" = "3" ]; then
STEPS="$STEPS,7,8"
if [ "${{ inputs.run_decommission }}" = "true" ]; then
if [ "${{ inputs.run_decommission != 'false' }}" = "true" ]; then
STEPS="$STEPS,9"
fi
fi
ARGS=(--steps "$STEPS" --with-warp -y \
./scripts/test/rustfs_pool_expand.sh \
--steps "$STEPS" --with-warp -y \
--endpoint "${{ env.RUSTFS_API_ENDPOINT }}" \
--storage-threshold "${{ inputs.storage_threshold }}" \
--warp-duration "${{ inputs.warp_duration }}" \
--log-file /tmp/rustfs-pool-test.log)
if [ -n "${{ inputs.package_url }}" ]; then
ARGS+=(--package-url "${{ inputs.package_url }}")
else
ARGS+=(--version "${{ inputs.rustfs_version }}")
fi
./scripts/test/rustfs_pool_expand.sh "${ARGS[@]}"
--storage-threshold "${{ inputs.storage_threshold || '50' }}" \
--warp-duration "${{ inputs.warp_duration || '10m' }}" \
--log-file /tmp/rustfs-pool-test.log
- name: Upload test logs
if: always()
@@ -130,7 +142,7 @@ jobs:
if-no-files-found: warn
- name: Reset test environment (after)
if: ${{ always() && inputs.cleanup_after }}
if: ${{ always() && inputs.cleanup_after != 'false' }}
run: |
./scripts/test/rustfs_pool_expand.sh --reset -y
@@ -138,5 +150,5 @@ jobs:
if: failure()
run: |
echo "RustFS pool expansion test failed"
echo "Package source: ${{ inputs.package_url || format('release {0}', inputs.rustfs_version) }}"
echo "Package source: ${{ inputs.package_url || inputs.rustfs_version || 'nightly (R2 latest)' }}"
echo "See the uploaded log artifact for details."
+26 -13
View File
@@ -73,7 +73,7 @@ The main crate is organized in layers, top to bottom:
|-------|-----------|----------------|
| **Server** | `server/` | HTTP listener, TLS, CORS, compression, middleware, graceful shutdown |
| **Admin** | `admin/` | Admin API routing, 30+ handler modules, web console |
| **App** | `app/` | Use-case orchestration: object_usecase, bucket_usecase, multipart_usecase |
| **App** | `app/` | Use-case orchestration: object (per-operation modules under `app/object/`, re-exported as `object_usecase`), bucket_usecase, multipart_usecase |
| **Storage** | `storage/` | S3 API translation, erasure-coded FS, SSE encryption, RPC, concurrency |
| **Auth** | `auth.rs` | S3 signature verification, credential validation |
| **Config** | `config/` | CLI parsing, config struct, workload profiles |
@@ -93,7 +93,7 @@ refactors.
| Domain | Current workspace crates | Responsibility |
|--------|--------------------------|----------------|
| Foundation | `checksums`, `common`, `config`, `data-usage`, `heal-contracts`, `scanner-contracts`, `utils` | Shared configuration, data-usage models, heal/scanner domain contracts, utilities, and checksums. |
| I/O and storage | `concurrency`, `ecstore`, `filemeta`, `heal`, `io-core`, `io-metrics`, `lifecycle`, `lock`, `object-capacity`, `object-data-cache`, `replication`, `rio`, `rio-v2`, `scanner`, `storage-api` | Erasure-coded object storage, metadata, recovery, lifecycle, replication, locking, cache, and I/O pipelines. |
| I/O and storage | `concurrency`, `ecstore`, `filemeta`, `heal`, `io-core`, `io-metrics`, `lifecycle`, `lock`, `object-capacity`, `object-data-cache`, `replication`, `rio`, `rio-v2`, `s3-client`, `scanner`, `storage-api` | Erasure-coded object storage, metadata, recovery, lifecycle, replication, locking, cache, I/O pipelines, and the engine-side S3 client for remote tier/transition targets. |
| Security and identity | `credentials`, `crypto`, `iam`, `keystone`, `kms`, `policy`, `security-governance`, `signer`, `tls-runtime`, `trusted-proxies` | Credentials, authentication, authorization, encryption, key management, TLS, and security contracts. |
| Protocols and contracts | `extension-schema`, `madmin`, `protos`, `protocols`, `s3-ops`, `s3-types`, `s3select-api`, `s3select-query` | Admin, inter-node, S3, S3 Select, and optional protocol contracts. |
| Operations and integration | `audit`, `notify`, `obs`, `targets`, `zip` | Auditing, observability, event delivery, notification targets, and archive support. |
@@ -115,8 +115,15 @@ default build (lifecycle:
1. **Layers flow downward.** Server → Admin/App → Storage → ecstore → rio/io-core.
No upward imports.
2. **Leaf crates have zero internal dependencies.** `config`, `credentials`, `crypto`,
`io-metrics`, and `madmin` should depend only on external crates.
2. **Leaf crates depend only on external crates, with adjudicated exceptions
pinned by a guard.** `config`, `credentials`, and `crypto` take no internal
dependency. `io-metrics` takes exactly `rustfs-s3-ops` (transitively
`rustfs-s3-types`), a pure contract crate with no I/O and no global state —
adjudicated in rustfs/backlog#1834. `madmin` left the leaf set when #6166 made
it the SigV4-signed admin SDK client; its internal dependency surface is pinned
to exactly `rustfs-signer`. Both pins live in the leaf allowlist in
`scripts/check_architecture_migration_rules.sh`; any other internal dependency
fails the guard ([crate boundaries](docs/architecture/crate-boundaries.md)).
- ✅ RESOLVED: the historical `utils → config` and `common → filemeta`/`madmin`
edges were removed; do not reintroduce them (see Known Structural Issues).
@@ -138,15 +145,19 @@ default build (lifecycle:
`BackpressureSettings` copy that lingered in io-metrics was removed
(rustfs/backlog#1833).
4. **ecstore does not know about HTTP or S3 protocol details.** It operates on
storage-level abstractions (objects, buckets, disks, pools).
- ⚠️ VIOLATED: 58 files under `crates/ecstore/src` reference `s3s`
(`rg -l 's3s' crates/ecstore/src | wc -l`), `crates/ecstore/src/client/`
is a ~9.4K-line embedded S3 HTTP client, and `crates/ecstore/Cargo.toml`
depends on `s3s`, `http`, `hyper`/`hyper-util`/`hyper-rustls`, and
`reqwest`. Target state: the engine's need to act as an S3 client
(tiering, replication targets) is served by an extracted client crate,
and ecstore holds no wire or DTO types.
4. **ecstore does not *serve* HTTP or the S3 wire protocol.** It operates on
storage-level abstractions (objects, buckets, disks, pools) and holds no
wire or DTO types of the serving surface. *Consuming* remote S3-compatible
endpoints (ILM tier warm backends, transition targets) is a legitimate
engine capability, but it lives in the dedicated `rustfs-s3-client` crate
(`crates/s3-client`, extracted from the formerly embedded
`crates/ecstore/src/client/` by rustfs/backlog#1842), not inside ecstore.
- ⚠️ PARTIALLY VIOLATED: serving-side `s3s` references remain in ecstore
(bucket metadata/replication/lifecycle DTOs and error mapping). The
count is ratcheted shrink-only by `scripts/check_s3s_footprint.sh`
(`S3S_ECSTORE_FILES_BASELINE`; the `object_lock` module was converted to
storage-level types as the first ratchet step). Target state: the
baseline reaches zero and ecstore's `Cargo.toml` drops `s3s`.
5. **The `rustfs` binary crate is the only place that wires everything together.**
Individual crates should be testable in isolation.
@@ -321,6 +332,8 @@ The binary (`main.rs`) boots in this order:
- **"Where is replication configured?"**
`admin/handlers/replication.rs` and `admin/handlers/site_replication.rs` for API,
`rustfs/src/site_replication/` for the site-replication service subsystem
(state, peer transport, retry queue, repair, hooks),
`ecstore/src/bucket/replication/` for engine
- **"Where do I add a new admin endpoint?"**
Generated
+258 -182
View File
File diff suppressed because it is too large Load Diff
+59 -62
View File
@@ -72,7 +72,7 @@ edition = "2024"
license = "Apache-2.0"
repository = "https://github.com/rustfs/rustfs"
rust-version = "1.97.1"
version = "1.0.0-rc.3"
version = "1.0.0-rc.4"
homepage = "https://rustfs.com"
description = "RustFS is a high-performance distributed object storage software built using Rust, one of the most popular languages worldwide. "
keywords = ["RustFS", "Minio", "object-storage", "filesystem", "s3"]
@@ -89,55 +89,55 @@ redundant_clone = "warn"
[workspace.dependencies]
# RustFS Internal Crates
rustfs = { path = "./rustfs", version = "1.0.0-rc.3" }
rustfs-heal = { path = "crates/heal", version = "1.0.0-rc.3" }
rustfs-heal-contracts = { path = "crates/heal-contracts", version = "1.0.0-rc.3" }
rustfs-scanner-contracts = { path = "crates/scanner-contracts", version = "1.0.0-rc.3" }
rustfs-audit = { path = "crates/audit", version = "1.0.0-rc.3" }
rustfs-checksums = { path = "crates/checksums", version = "1.0.0-rc.3" }
rustfs-common = { path = "crates/common", version = "1.0.0-rc.3" }
rustfs-data-usage = { path = "crates/data-usage", version = "1.0.0-rc.3" }
rustfs-config = { path = "./crates/config", version = "1.0.0-rc.3" }
rustfs-concurrency = { path = "./crates/concurrency", version = "1.0.0-rc.3" }
rustfs-credentials = { path = "crates/credentials", version = "1.0.0-rc.3" }
rustfs-crypto = { path = "crates/crypto", version = "1.0.0-rc.3" }
rustfs-ecstore = { path = "crates/ecstore", version = "1.0.0-rc.3" }
rustfs-filemeta = { path = "crates/filemeta", version = "1.0.0-rc.3" }
rustfs-iam = { path = "crates/iam", version = "1.0.0-rc.3" }
rustfs-keystone = { path = "crates/keystone", version = "1.0.0-rc.3" }
rustfs-lifecycle = { path = "crates/lifecycle", version = "1.0.0-rc.3" }
rustfs-kms = { path = "crates/kms", version = "1.0.0-rc.3" }
rustfs-lock = { path = "crates/lock", version = "1.0.0-rc.3" }
rustfs-madmin = { path = "crates/madmin", version = "1.0.0-rc.3" }
rustfs-notify = { path = "crates/notify", version = "1.0.0-rc.3" }
rustfs-io-metrics = { path = "crates/io-metrics", version = "1.0.0-rc.3" }
rustfs-io-core = { path = "crates/io-core", version = "1.0.0-rc.3" }
rustfs-object-capacity = { path = "crates/object-capacity", version = "1.0.0-rc.3" }
rustfs-object-data-cache = { path = "crates/object-data-cache", version = "1.0.0-rc.3", default-features = false }
rustfs-log-analyzer = { path = "crates/log-analyzer", version = "1.0.0-rc.3" }
rustfs-obs = { path = "crates/obs", version = "1.0.0-rc.3" }
rustfs-policy = { path = "crates/policy", version = "1.0.0-rc.3" }
rustfs-protos = { path = "crates/protos", version = "1.0.0-rc.3" }
rustfs-protocols = { path = "crates/protocols", version = "1.0.0-rc.3" }
rustfs-replication = { path = "crates/replication", version = "1.0.0-rc.3" }
rustfs-rio = { path = "crates/rio", version = "1.0.0-rc.3" }
rustfs-rio-v2 = { path = "crates/rio-v2", version = "1.0.0-rc.3" }
rustfs-s3-client = { path = "crates/s3-client", version = "1.0.0-rc.3" }
rustfs-s3-types = { path = "crates/s3-types", version = "1.0.0-rc.3" }
rustfs-s3-ops = { path = "crates/s3-ops", version = "1.0.0-rc.3" }
rustfs-s3select-api = { path = "crates/s3select-api", version = "1.0.0-rc.3" }
rustfs-s3select-query = { path = "crates/s3select-query", version = "1.0.0-rc.3" }
rustfs-scanner = { path = "crates/scanner", version = "1.0.0-rc.3" }
rustfs-security-governance = { path = "crates/security-governance", version = "1.0.0-rc.3" }
rustfs-extension-schema = { path = "crates/extension-schema", version = "1.0.0-rc.3" }
rustfs-signer = { path = "crates/signer", version = "1.0.0-rc.3" }
rustfs-storage-api = { path = "crates/storage-api", version = "1.0.0-rc.3" }
rustfs-trusted-proxies = { path = "crates/trusted-proxies", version = "1.0.0-rc.3" }
rustfs-targets = { path = "crates/targets", version = "1.0.0-rc.3" }
rustfs-test-utils = { path = "crates/test-utils", version = "1.0.0-rc.3" }
rustfs-tls-runtime = { path = "crates/tls-runtime", version = "1.0.0-rc.3" }
rustfs-utils = { path = "crates/utils", version = "1.0.0-rc.3" }
rustfs-zip = { path = "./crates/zip", version = "1.0.0-rc.3" }
rustfs = { path = "./rustfs", version = "1.0.0-rc.4" }
rustfs-heal = { path = "crates/heal", version = "1.0.0-rc.4" }
rustfs-heal-contracts = { path = "crates/heal-contracts", version = "1.0.0-rc.4" }
rustfs-scanner-contracts = { path = "crates/scanner-contracts", version = "1.0.0-rc.4" }
rustfs-audit = { path = "crates/audit", version = "1.0.0-rc.4" }
rustfs-checksums = { path = "crates/checksums", version = "1.0.0-rc.4" }
rustfs-common = { path = "crates/common", version = "1.0.0-rc.4" }
rustfs-data-usage = { path = "crates/data-usage", version = "1.0.0-rc.4" }
rustfs-config = { path = "./crates/config", version = "1.0.0-rc.4" }
rustfs-concurrency = { path = "./crates/concurrency", version = "1.0.0-rc.4" }
rustfs-credentials = { path = "crates/credentials", version = "1.0.0-rc.4" }
rustfs-crypto = { path = "crates/crypto", version = "1.0.0-rc.4" }
rustfs-ecstore = { path = "crates/ecstore", version = "1.0.0-rc.4" }
rustfs-filemeta = { path = "crates/filemeta", version = "1.0.0-rc.4" }
rustfs-iam = { path = "crates/iam", version = "1.0.0-rc.4" }
rustfs-keystone = { path = "crates/keystone", version = "1.0.0-rc.4" }
rustfs-lifecycle = { path = "crates/lifecycle", version = "1.0.0-rc.4" }
rustfs-kms = { path = "crates/kms", version = "1.0.0-rc.4" }
rustfs-lock = { path = "crates/lock", version = "1.0.0-rc.4" }
rustfs-madmin = { path = "crates/madmin", version = "1.0.0-rc.4" }
rustfs-notify = { path = "crates/notify", version = "1.0.0-rc.4" }
rustfs-io-metrics = { path = "crates/io-metrics", version = "1.0.0-rc.4" }
rustfs-io-core = { path = "crates/io-core", version = "1.0.0-rc.4" }
rustfs-object-capacity = { path = "crates/object-capacity", version = "1.0.0-rc.4" }
rustfs-object-data-cache = { path = "crates/object-data-cache", version = "1.0.0-rc.4", default-features = false }
rustfs-log-analyzer = { path = "crates/log-analyzer", version = "1.0.0-rc.4" }
rustfs-obs = { path = "crates/obs", version = "1.0.0-rc.4" }
rustfs-policy = { path = "crates/policy", version = "1.0.0-rc.4" }
rustfs-protos = { path = "crates/protos", version = "1.0.0-rc.4" }
rustfs-protocols = { path = "crates/protocols", version = "1.0.0-rc.4" }
rustfs-replication = { path = "crates/replication", version = "1.0.0-rc.4" }
rustfs-rio = { path = "crates/rio", version = "1.0.0-rc.4" }
rustfs-rio-v2 = { path = "crates/rio-v2", version = "1.0.0-rc.4" }
rustfs-s3-client = { path = "crates/s3-client", version = "1.0.0-rc.4" }
rustfs-s3-types = { path = "crates/s3-types", version = "1.0.0-rc.4" }
rustfs-s3-ops = { path = "crates/s3-ops", version = "1.0.0-rc.4" }
rustfs-s3select-api = { path = "crates/s3select-api", version = "1.0.0-rc.4" }
rustfs-s3select-query = { path = "crates/s3select-query", version = "1.0.0-rc.4" }
rustfs-scanner = { path = "crates/scanner", version = "1.0.0-rc.4" }
rustfs-security-governance = { path = "crates/security-governance", version = "1.0.0-rc.4" }
rustfs-extension-schema = { path = "crates/extension-schema", version = "1.0.0-rc.4" }
rustfs-signer = { path = "crates/signer", version = "1.0.0-rc.4" }
rustfs-storage-api = { path = "crates/storage-api", version = "1.0.0-rc.4" }
rustfs-trusted-proxies = { path = "crates/trusted-proxies", version = "1.0.0-rc.4" }
rustfs-targets = { path = "crates/targets", version = "1.0.0-rc.4" }
rustfs-test-utils = { path = "crates/test-utils", version = "1.0.0-rc.4" }
rustfs-tls-runtime = { path = "crates/tls-runtime", version = "1.0.0-rc.4" }
rustfs-utils = { path = "crates/utils", version = "1.0.0-rc.4" }
rustfs-zip = { path = "./crates/zip", version = "1.0.0-rc.4" }
# Async Runtime and Networking
async-channel = "2.5.0"
@@ -199,7 +199,7 @@ serde_urlencoded = "0.7.1"
# releases.
aes-gcm = { version = "=0.11.1" }
argon2 = { version = "=0.6.0-rc.8" }
blake2 = "=0.11.0-rc.6"
blake2 = "=0.11.0"
chacha20poly1305 = { version = "=0.11.0" }
crc-fast = "1.10.0"
hmac = { version = "0.13.0" }
@@ -237,13 +237,12 @@ atoi = "3.1.0"
atomic_enum = "0.3.0"
aws-config = { version = "1.11.0" }
aws-credential-types = { version = "1.3.0" }
aws-sdk-kms = { default-features = false, version = "1.116.0" }
aws-sdk-s3 = { default-features = false, version = "1.143.0" }
aws-sdk-sts = { default-features = false, version = "1.112.0" }
aws-sdk-kms = { default-features = false, version = "1.117.0" }
aws-sdk-s3 = { default-features = false, version = "1.144.0" }
aws-sdk-sts = { default-features = false, version = "1.113.0" }
aws-smithy-http-client = { default-features = false, version = "1.4.0" }
aws-smithy-runtime-api = { version = "1.15.0" }
aws-smithy-types = { version = "1.6.2" }
base64 = "0.23.1"
base64-simd = "0.8.0"
brotli = "8.0.4"
clap = { version = "4.6.6" }
@@ -260,13 +259,12 @@ enumset = "1.1.14"
faster-hex = "0.10.0"
flate2 = "1.1.9"
glob = "0.3.4"
google-cloud-storage = "1.17.0"
google-cloud-auth = "1.15.0"
google-cloud-storage = "1.18.0"
google-cloud-auth = "1.16.0"
hashbrown = { version = "0.17.1" }
# Base32 for RFC 6238 TOTP shared secrets (RFC 4648 unpadded, the alphabet
# every authenticator app expects). Already in the graph transitively.
data-encoding = "2.11.1"
hex = "0.4.3"
hex-simd = "0.8.0"
highway = { version = "1.3.0" }
hostname = "0.4.2"
@@ -306,7 +304,7 @@ rustify = { version = "0.7", default-features = false }
rustix = { version = "1.1.4" }
rust-embed = { version = "8.12.0" }
rustc-hash = { version = "2.1.3" }
s3s = { git = "https://github.com/rustfs/s3s.git", rev = "5f22e8d0a37e83f531f653024aac11c72586479a", version = "0.15.0", features = ["minio"] }
s3s = { git = "https://github.com/rustfs/s3s.git", rev = "0f6f83d98b37fd9edcaa3be573db4aa8f568e088", version = "0.15.0", features = ["minio"] }
serial_test = "4.0.1"
shadow-rs = { default-features = false, version = "2.0.0" }
siphasher = "1.0.3"
@@ -329,7 +327,7 @@ tracing-subscriber = { version = "0.3.23" }
transform-stream = "0.3.1"
url = "2.5.8"
urlencoding = "2.1.3"
uuid = { version = "1.25.0" }
uuid = { version = "1.26.0" }
vaultrs = { version = "0.8.0" }
tar = "0.4.46"
walkdir = "2.5.0"
@@ -343,7 +341,7 @@ zstd = "0.13.3"
# Observability and Metrics
metrics = "0.24.6"
metrics-util = "0.20"
dial9-tokio-telemetry = "0.3"
dial9-tokio-telemetry = "0.5.0"
opentelemetry = { version = "0.32.0" }
opentelemetry-appender-tracing = { version = "0.32.0" }
opentelemetry-otlp = { version = "0.32.0" }
@@ -366,7 +364,6 @@ dav-server = "0.11.0"
# Performance Analysis and Memory Profiling
rustfs-mimalloc = { version = "0.5.1" }
rustfs-mimalloc-sys = { version = "0.5.1" }
hotpath = { version = "0.24.0", default-features = false }
# Snapshot testing for output format regression detection
insta = { version = "1.48" }
+1 -1
View File
@@ -115,7 +115,7 @@ chown -R 10001:10001 data logs
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:latest
# Using specific version
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.0-rc.3
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.0-rc.4
```
If you use [podman](https://github.com/containers/podman) instead of docker, you can install the RustFS with the below command
+1 -1
View File
@@ -112,7 +112,7 @@ chown -R 10001:10001 data logs
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:latest
# 使用指定版本运行
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.0-rc.3
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.0-rc.4
```
如果您通过绑定挂载启用 TLS 证书目录,也请用同样方式准备该目录:
+1 -1
View File
@@ -67,7 +67,7 @@ tokio = { workspace = true, features = ["sync", "fs", "rt-multi-thread", "time",
tracing = { workspace = true, features = ["std", "attributes"] }
[dev-dependencies]
async-trait = { workspace = true }
rustfs-targets = { workspace = true, features = ["test-support"] }
temp-env = { workspace = true }
url = { workspace = true }
+15 -81
View File
@@ -564,88 +564,21 @@ impl AuditRuntimeFacade {
mod tests {
use super::AuditPipeline;
use crate::{AuditEntry, AuditError, AuditRegistry};
use async_trait::async_trait;
use rustfs_targets::arn::TargetID;
use rustfs_targets::store::{Key, Store};
use rustfs_targets::target::{EntityTarget, QueuedPayload, QueuedPayloadMeta};
use rustfs_targets::{StoreError, Target, TargetError};
use rustfs_targets::testkit::MockTarget;
use std::sync::Arc;
use tokio::sync::{Mutex, Notify};
/// Mock target whose `save()` outcome is fixed at construction so tests can
/// force full-success / full-failure / partial-failure fan-outs.
#[derive(Clone)]
struct MockTarget {
id: TargetID,
fail: bool,
health_gate: Option<(Arc<Notify>, Arc<Notify>)>,
}
impl MockTarget {
fn new(id: &str, fail: bool) -> Self {
Self {
id: TargetID::new(id.to_string(), "webhook".to_string()),
fail,
health_gate: None,
}
}
fn with_health_gate(mut self, started: Arc<Notify>, release: Arc<Notify>) -> Self {
self.health_gate = Some((started, release));
self
}
}
#[async_trait]
impl<E> Target<E> for MockTarget
where
E: rustfs_targets::PluginEvent,
{
fn id(&self) -> TargetID {
self.id.clone()
}
async fn is_active(&self) -> Result<bool, TargetError> {
if let Some((started, release)) = &self.health_gate {
started.notify_one();
release.notified().await;
}
Ok(true)
}
async fn save(&self, _event: Arc<EntityTarget<E>>) -> Result<(), TargetError> {
if self.fail {
Err(TargetError::Configuration("forced save failure".to_string()))
} else {
Ok(())
}
}
async fn send_raw_from_store(&self, _key: Key, _body: Vec<u8>, _meta: QueuedPayloadMeta) -> Result<(), TargetError> {
Ok(())
}
async fn close(&self) -> Result<(), TargetError> {
Ok(())
}
fn store(&self) -> Option<&(dyn Store<QueuedPayload, Error = StoreError, Key = Key> + Send + Sync)> {
None
}
fn clone_dyn(&self) -> Box<dyn Target<E> + Send + Sync> {
Box::new(self.clone())
}
fn is_enabled(&self) -> bool {
true
}
/// Builds a mock target whose `save()` outcome is fixed at construction so tests can force
/// full-success / full-failure / partial-failure fan-outs.
fn mock_target(id: &str, fail: bool) -> MockTarget {
let target = MockTarget::new(id, "webhook");
if fail { target.with_save_failures(usize::MAX) } else { target }
}
fn pipeline_with(targets: Vec<MockTarget>) -> AuditPipeline {
let mut registry = AuditRegistry::new();
for target in targets {
registry.add_target(target.id.to_string(), Box::new(target));
registry.add_target(target.target_id().to_string(), Box::new(target));
}
AuditPipeline::new(Arc::new(Mutex::new(registry)))
}
@@ -658,7 +591,7 @@ mod tests {
// dispatch must return Err rather than swallowing the failures as Ok.
#[tokio::test]
async fn dispatch_returns_err_when_all_targets_fail() {
let pipeline = pipeline_with(vec![MockTarget::new("a:webhook", true), MockTarget::new("b:webhook", true)]);
let pipeline = pipeline_with(vec![mock_target("a:webhook", true), mock_target("b:webhook", true)]);
let result = pipeline.dispatch(entry()).await;
assert!(matches!(result, Err(AuditError::Target(_))), "expected Err, got {result:?}");
}
@@ -667,13 +600,13 @@ mod tests {
// so dispatch reports success (degradation is logged, not propagated).
#[tokio::test]
async fn dispatch_returns_ok_on_partial_failure() {
let pipeline = pipeline_with(vec![MockTarget::new("ok:webhook", false), MockTarget::new("bad:webhook", true)]);
let pipeline = pipeline_with(vec![mock_target("ok:webhook", false), mock_target("bad:webhook", true)]);
pipeline.dispatch(entry()).await.expect("partial success should return Ok");
}
#[tokio::test]
async fn dispatch_returns_ok_when_all_targets_succeed() {
let pipeline = pipeline_with(vec![MockTarget::new("a:webhook", false), MockTarget::new("b:webhook", false)]);
let pipeline = pipeline_with(vec![mock_target("a:webhook", false), mock_target("b:webhook", false)]);
pipeline.dispatch(entry()).await.expect("all-success should return Ok");
}
@@ -686,9 +619,10 @@ mod tests {
#[tokio::test]
async fn health_probe_does_not_hold_the_registry_lock() {
let started = Arc::new(Notify::new());
let release = Arc::new(Notify::new());
let pipeline = pipeline_with(vec![MockTarget::new("blocked", false).with_health_gate(started.clone(), release.clone())]);
let target = mock_target("blocked", false).with_health_gate(release.clone());
let started = target.health_started();
let pipeline = pipeline_with(vec![target]);
let registry = Arc::clone(&pipeline.registry);
let snapshot_task = tokio::spawn(async move { pipeline.snapshot_target_health().await });
started.notified().await;
@@ -706,14 +640,14 @@ mod tests {
// whole-batch loss instead of returning Ok.
#[tokio::test]
async fn dispatch_batch_returns_err_when_all_targets_fail() {
let pipeline = pipeline_with(vec![MockTarget::new("a:webhook", true)]);
let pipeline = pipeline_with(vec![mock_target("a:webhook", true)]);
let result = pipeline.dispatch_batch(vec![entry(), entry()]).await;
assert!(matches!(result, Err(AuditError::Target(_))), "expected Err, got {result:?}");
}
#[tokio::test]
async fn dispatch_batch_returns_ok_when_all_targets_succeed() {
let pipeline = pipeline_with(vec![MockTarget::new("a:webhook", false), MockTarget::new("b:webhook", false)]);
let pipeline = pipeline_with(vec![mock_target("a:webhook", false), mock_target("b:webhook", false)]);
pipeline
.dispatch_batch(vec![entry(), entry()])
.await
+14 -76
View File
@@ -286,70 +286,10 @@ impl AuditRegistry {
#[cfg(test)]
mod tests {
use super::AuditRegistry;
use crate::{AuditEntry, AuditError};
use rustfs_targets::arn::TargetID;
use rustfs_targets::store::{Key, Store};
use rustfs_targets::target::{ChannelTargetType, EntityTarget, QueuedPayload, QueuedPayloadMeta};
use rustfs_targets::{StoreError, Target, TargetError};
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
#[derive(Clone)]
struct CloseTestTarget {
id: TargetID,
close_calls: Arc<AtomicUsize>,
fail_on_close: bool,
}
impl CloseTestTarget {
fn new(id: TargetID, close_calls: Arc<AtomicUsize>, fail_on_close: bool) -> Self {
Self {
id,
close_calls,
fail_on_close,
}
}
}
#[async_trait::async_trait]
impl Target<AuditEntry> for CloseTestTarget {
fn id(&self) -> TargetID {
self.id.clone()
}
async fn is_active(&self) -> Result<bool, TargetError> {
Ok(true)
}
async fn save(&self, _event: Arc<EntityTarget<AuditEntry>>) -> Result<(), TargetError> {
Ok(())
}
async fn send_raw_from_store(&self, _key: Key, _body: Vec<u8>, _meta: QueuedPayloadMeta) -> Result<(), TargetError> {
Ok(())
}
async fn close(&self) -> Result<(), TargetError> {
self.close_calls.fetch_add(1, Ordering::SeqCst);
if self.fail_on_close {
Err(TargetError::Unknown("close failed".to_string()))
} else {
Ok(())
}
}
fn store(&self) -> Option<&(dyn Store<QueuedPayload, Error = StoreError, Key = Key> + Send + Sync)> {
None
}
fn clone_dyn(&self) -> Box<dyn Target<AuditEntry> + Send + Sync> {
Box::new(self.clone())
}
fn is_enabled(&self) -> bool {
true
}
}
use crate::AuditError;
use rustfs_targets::TargetError;
use rustfs_targets::target::ChannelTargetType;
use rustfs_targets::testkit::MockTarget;
#[test]
fn registry_registers_amqp_factory() {
@@ -361,23 +301,21 @@ mod tests {
#[tokio::test]
async fn close_all_returns_first_error_and_clears_targets() {
let mut registry = AuditRegistry::new();
let ok_calls = Arc::new(AtomicUsize::new(0));
let fail_calls = Arc::new(AtomicUsize::new(0));
let ok = MockTarget::new("ok", "webhook");
let ok_observer = ok.clone();
let fail = MockTarget::new("fail", "webhook")
.with_close_failures(usize::MAX)
.with_close_failure_error(|| TargetError::Unknown("close failed".to_string()));
let fail_observer = fail.clone();
let ok_id = TargetID::new("ok".to_string(), "webhook".to_string());
let fail_id = TargetID::new("fail".to_string(), "webhook".to_string());
registry.add_target(ok_id.to_string(), Box::new(CloseTestTarget::new(ok_id, Arc::clone(&ok_calls), false)));
registry.add_target(
fail_id.to_string(),
Box::new(CloseTestTarget::new(fail_id, Arc::clone(&fail_calls), true)),
);
registry.add_target(ok.target_id().to_string(), Box::new(ok));
registry.add_target(fail.target_id().to_string(), Box::new(fail));
let result = registry.close_all().await;
assert!(matches!(result, Err(AuditError::Target(TargetError::Unknown(_)))));
assert_eq!(ok_calls.load(Ordering::SeqCst), 1);
assert_eq!(fail_calls.load(Ordering::SeqCst), 1);
assert_eq!(ok_observer.close_call_count(), 1);
assert_eq!(fail_observer.close_call_count(), 1);
assert!(registry.list_targets().is_empty());
}
}
+11 -70
View File
@@ -577,76 +577,17 @@ fn warn_audit_state(state: &str, reason: Option<&str>) {
mod tests {
use super::{AuditSystem, AuditSystemState};
use crate::{AuditEntry, AuditError};
use async_trait::async_trait;
use rustfs_targets::ReplayWorkerManager;
use rustfs_targets::arn::TargetID;
use rustfs_targets::store::{Key, Store};
use rustfs_targets::target::{EntityTarget, QueuedPayload, QueuedPayloadMeta};
use rustfs_targets::{StoreError, Target, TargetError};
use rustfs_targets::testkit::MockTarget;
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use tokio::sync::mpsc;
#[derive(Clone)]
struct TestTarget {
close_calls: Arc<AtomicUsize>,
id: TargetID,
}
impl TestTarget {
fn new(id: &str, name: &str) -> Self {
Self {
close_calls: Arc::new(AtomicUsize::new(0)),
id: TargetID::new(id.to_string(), name.to_string()),
}
}
}
#[async_trait]
impl<E> Target<E> for TestTarget
where
E: rustfs_targets::PluginEvent,
{
fn id(&self) -> TargetID {
self.id.clone()
}
async fn is_active(&self) -> Result<bool, TargetError> {
Ok(true)
}
async fn save(&self, _event: Arc<EntityTarget<E>>) -> Result<(), TargetError> {
Ok(())
}
async fn send_raw_from_store(&self, _key: Key, _body: Vec<u8>, _meta: QueuedPayloadMeta) -> Result<(), TargetError> {
Ok(())
}
async fn close(&self) -> Result<(), TargetError> {
self.close_calls.fetch_add(1, Ordering::SeqCst);
Ok(())
}
fn store(&self) -> Option<&(dyn Store<QueuedPayload, Error = StoreError, Key = Key> + Send + Sync)> {
None
}
fn clone_dyn(&self) -> Box<dyn Target<E> + Send + Sync> {
Box::new(self.clone())
}
fn is_enabled(&self) -> bool {
true
}
}
#[tokio::test]
async fn reload_with_empty_config_stops_existing_runtime() {
let system = AuditSystem::new();
let target = TestTarget::new("primary", "webhook");
let close_calls = Arc::clone(&target.close_calls);
let target = MockTarget::new("primary", "webhook");
let observer = target.clone();
{
let mut registry = system.registry.lock().await;
@@ -671,7 +612,7 @@ mod tests {
assert_eq!(system.get_state().await, AuditSystemState::Stopped);
assert!(system.list_targets().await.is_empty());
assert_eq!(system.runtime_status_snapshot().await, ReplayWorkerManager::new().snapshot(0));
assert_eq!(close_calls.load(Ordering::SeqCst), 1);
assert_eq!(observer.close_call_count(), 1);
assert_eq!(*system.config.read().await, Some(rustfs_config::server_config::Config(HashMap::new())));
}
@@ -693,7 +634,7 @@ mod tests {
// Seed a target + replay worker so both critical sections touch real state.
{
let mut registry = system.registry.lock().await;
registry.add_target("primary:webhook".to_string(), Box::new(TestTarget::new("primary", "webhook")));
registry.add_target("primary:webhook".to_string(), Box::new(MockTarget::new("primary", "webhook")));
}
{
let mut replay_workers = system.stream_cancellers.write().await;
@@ -793,8 +734,8 @@ mod tests {
async fn commit_closes_old_targets_before_installing_new() {
let system = AuditSystem::new();
let old = TestTarget::new("old", "webhook");
let old_close = Arc::clone(&old.close_calls);
let old = MockTarget::new("old", "webhook");
let old_observer = old.clone();
{
let mut registry = system.registry.lock().await;
registry.add_target("old:webhook".to_string(), Box::new(old));
@@ -809,17 +750,17 @@ mod tests {
*state = AuditSystemState::Running;
}
let new = TestTarget::new("new", "webhook");
let new_close = Arc::clone(&new.close_calls);
let new = MockTarget::new("new", "webhook");
let new_observer = new.clone();
system
.commit_runtime_targets(vec![Box::new(new)], AuditSystemState::Running)
.await
.expect("commit should succeed");
// Old target closed exactly once during the pre-install shutdown.
assert_eq!(old_close.load(Ordering::SeqCst), 1);
assert_eq!(old_observer.close_call_count(), 1);
// New target installed and left open.
assert_eq!(new_close.load(Ordering::SeqCst), 0);
assert_eq!(new_observer.close_call_count(), 0);
assert_eq!(system.list_targets().await, vec!["new:webhook".to_string()]);
// Old replay worker stopped; the store-less new target adds none.
assert_eq!(system.runtime_status_snapshot().await.replay_worker_count, 0);
+18 -139
View File
@@ -12,136 +12,16 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use async_trait::async_trait;
use rustfs_audit::{AuditEntry, AuditError, AuditPipeline, AuditRegistry, AuditRuntimeFacade, AuditRuntimeView};
use rustfs_targets::arn::TargetID;
use rustfs_targets::store::{Key, Store};
use rustfs_targets::target::{EntityTarget, QueuedPayload, QueuedPayloadMeta};
use rustfs_targets::{SharedTarget, StoreError, Target, TargetError};
use serde::{Serialize, de::DeserializeOwned};
use rustfs_targets::SharedTarget;
use rustfs_targets::testkit::MockTarget;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use tokio::sync::{Mutex, RwLock};
#[derive(Clone)]
struct TestTarget {
close_calls: Arc<AtomicUsize>,
id: TargetID,
init_calls: Arc<AtomicUsize>,
}
impl TestTarget {
fn new(id: &str, name: &str) -> Self {
Self {
close_calls: Arc::new(AtomicUsize::new(0)),
id: TargetID::new(id.to_string(), name.to_string()),
init_calls: Arc::new(AtomicUsize::new(0)),
}
}
}
#[async_trait]
impl<E> Target<E> for TestTarget
where
E: Send + Sync + 'static + Clone + Serialize + DeserializeOwned,
{
fn id(&self) -> TargetID {
self.id.clone()
}
async fn is_active(&self) -> Result<bool, TargetError> {
Ok(true)
}
async fn save(&self, _event: Arc<EntityTarget<E>>) -> Result<(), TargetError> {
Ok(())
}
async fn send_raw_from_store(&self, _key: Key, _body: Vec<u8>, _meta: QueuedPayloadMeta) -> Result<(), TargetError> {
Ok(())
}
async fn close(&self) -> Result<(), TargetError> {
self.close_calls.fetch_add(1, Ordering::SeqCst);
Ok(())
}
fn store(&self) -> Option<&(dyn Store<QueuedPayload, Error = StoreError, Key = Key> + Send + Sync)> {
None
}
fn clone_dyn(&self) -> Box<dyn Target<E> + Send + Sync> {
Box::new(self.clone())
}
async fn init(&self) -> Result<(), TargetError> {
self.init_calls.fetch_add(1, Ordering::SeqCst);
Ok(())
}
fn is_enabled(&self) -> bool {
true
}
}
/// A target whose `save()` always fails, used to exercise the dispatch
/// Builds a target whose `save()` always fails, used to exercise the dispatch
/// failure-propagation paths.
#[derive(Clone)]
struct FailingTarget {
id: TargetID,
save_calls: Arc<AtomicUsize>,
}
impl FailingTarget {
fn new(id: &str, name: &str) -> Self {
Self {
id: TargetID::new(id.to_string(), name.to_string()),
save_calls: Arc::new(AtomicUsize::new(0)),
}
}
}
#[async_trait]
impl<E> Target<E> for FailingTarget
where
E: Send + Sync + 'static + Clone + Serialize + DeserializeOwned,
{
fn id(&self) -> TargetID {
self.id.clone()
}
async fn is_active(&self) -> Result<bool, TargetError> {
Ok(true)
}
async fn save(&self, _event: Arc<EntityTarget<E>>) -> Result<(), TargetError> {
self.save_calls.fetch_add(1, Ordering::SeqCst);
Err(TargetError::Storage("disk full".to_string()))
}
async fn send_raw_from_store(&self, _key: Key, _body: Vec<u8>, _meta: QueuedPayloadMeta) -> Result<(), TargetError> {
Ok(())
}
async fn close(&self) -> Result<(), TargetError> {
Ok(())
}
fn store(&self) -> Option<&(dyn Store<QueuedPayload, Error = StoreError, Key = Key> + Send + Sync)> {
None
}
fn clone_dyn(&self) -> Box<dyn Target<E> + Send + Sync> {
Box::new(self.clone())
}
async fn init(&self) -> Result<(), TargetError> {
Ok(())
}
fn is_enabled(&self) -> bool {
true
}
fn failing_target(id: &str, name: &str) -> MockTarget {
MockTarget::new(id, name).with_save_failures(usize::MAX)
}
fn pipeline_with_targets(targets: Vec<(&str, SharedTarget<AuditEntry>)>) -> AuditPipeline {
@@ -154,8 +34,8 @@ fn pipeline_with_targets(targets: Vec<(&str, SharedTarget<AuditEntry>)>) -> Audi
#[tokio::test]
async fn audit_pipeline_dispatch_propagates_total_failure() {
let failing = FailingTarget::new("primary", "webhook");
let save_calls = Arc::clone(&failing.save_calls);
let failing = failing_target("primary", "webhook");
let observer = failing.clone();
let pipeline = pipeline_with_targets(vec![("primary:webhook", Arc::new(failing))]);
let result = pipeline.dispatch(Arc::new(AuditEntry::default())).await;
@@ -164,13 +44,13 @@ async fn audit_pipeline_dispatch_propagates_total_failure() {
matches!(result, Err(AuditError::Target(_))),
"dispatch must surface an error when every target fails, got {result:?}"
);
assert_eq!(save_calls.load(Ordering::SeqCst), 1, "the failing target should have been invoked");
assert_eq!(observer.save_call_count(), 1, "the failing target should have been invoked");
}
#[tokio::test]
async fn audit_pipeline_dispatch_tolerates_partial_failure() {
let failing = FailingTarget::new("primary", "webhook");
let healthy = TestTarget::new("secondary", "webhook");
let failing = failing_target("primary", "webhook");
let healthy = MockTarget::new("secondary", "webhook");
let pipeline = pipeline_with_targets(vec![
("primary:webhook", Arc::new(failing)),
("secondary:webhook", Arc::new(healthy)),
@@ -186,7 +66,7 @@ async fn audit_pipeline_dispatch_tolerates_partial_failure() {
#[tokio::test]
async fn audit_pipeline_dispatch_batch_propagates_total_failure() {
let failing = FailingTarget::new("primary", "webhook");
let failing = failing_target("primary", "webhook");
let pipeline = pipeline_with_targets(vec![("primary:webhook", Arc::new(failing))]);
let entries = vec![Arc::new(AuditEntry::default()), Arc::new(AuditEntry::default())];
@@ -200,8 +80,8 @@ async fn audit_pipeline_dispatch_batch_propagates_total_failure() {
#[tokio::test]
async fn audit_pipeline_dispatch_batch_tolerates_partial_failure() {
let failing = FailingTarget::new("primary", "webhook");
let healthy = TestTarget::new("secondary", "webhook");
let failing = failing_target("primary", "webhook");
let healthy = MockTarget::new("secondary", "webhook");
let pipeline = pipeline_with_targets(vec![
("primary:webhook", Arc::new(failing)),
("secondary:webhook", Arc::new(healthy)),
@@ -266,9 +146,8 @@ async fn audit_runtime_facade_activates_empty_target_list() {
async fn audit_runtime_view_upsert_and_remove_target() {
let registry = Arc::new(Mutex::new(AuditRegistry::new()));
let runtime_view = AuditRuntimeView::new(registry.clone());
let target = TestTarget::new("primary", "webhook");
let init_calls = Arc::clone(&target.init_calls);
let close_calls = Arc::clone(&target.close_calls);
let target = MockTarget::new("primary", "webhook");
let observer = target.clone();
runtime_view
.upsert_target("primary:webhook".to_string(), Box::new(target))
@@ -276,7 +155,7 @@ async fn audit_runtime_view_upsert_and_remove_target() {
.expect("upsert should succeed");
assert_eq!(runtime_view.list_targets().await, vec!["primary:webhook".to_string()]);
assert_eq!(init_calls.load(Ordering::SeqCst), 1);
assert_eq!(observer.init_call_count(), 1);
runtime_view
.remove_target("primary:webhook")
@@ -284,7 +163,7 @@ async fn audit_runtime_view_upsert_and_remove_target() {
.expect("remove should succeed");
assert!(runtime_view.list_targets().await.is_empty());
assert_eq!(close_calls.load(Ordering::SeqCst), 1);
assert_eq!(observer.close_call_count(), 1);
}
#[tokio::test]
@@ -292,7 +171,7 @@ async fn audit_runtime_facade_replace_targets_commits_runtime_state() {
let registry = Arc::new(Mutex::new(AuditRegistry::new()));
let replay_workers = Arc::new(RwLock::new(rustfs_targets::ReplayWorkerManager::new()));
let facade = AuditRuntimeFacade::new(registry.clone(), replay_workers.clone());
let target = TestTarget::new("primary", "webhook");
let target = MockTarget::new("primary", "webhook");
let activation = rustfs_targets::RuntimeActivation {
replay_workers: rustfs_targets::ReplayWorkerManager::new(),
targets: vec![Arc::new(target) as rustfs_targets::SharedTarget<rustfs_audit::AuditEntry>],
+165 -8
View File
@@ -41,14 +41,22 @@ pub const XXHASH_64_NAME: &str = "xxhash64";
pub const XXHASH_128_NAME: &str = "xxhash128";
pub const MD5_NAME: &str = "md5";
/// One of three deliberately separate checksum registries (backlog#1833):
/// this enum owns the **streaming-hash algorithm registry**, including the
/// RustFS extensions (sha512, xxhash3/64/128). The on-disk xl.meta bitset
/// lives in `rustfs_rio::ChecksumType` (crates/rio/src/checksum.rs, varint
/// bits are append-only), and the MinIO-port client keeps its own
/// `ChecksumMode` (crates/ecstore/src/client/checksum.rs). When adding an
/// algorithm, extend all three (or record why not) — they do not derive from
/// each other.
/// The canonical checksum-algorithm registry (backlog#1833, backlog#1844):
/// this enum owns the streaming-hash implementations and, via the exhaustive
/// per-algorithm metadata methods below, the wire names, header names, digest
/// lengths, and checksum-type capabilities — including the RustFS extensions
/// (sha512, xxhash3/64/128). The MinIO-port client's `ChecksumMode`
/// (crates/s3-client/src/checksum.rs) delegates all per-algorithm dispatch
/// here through its `algorithm()` bridge. The on-disk xl.meta bitset remains
/// deliberately separate in `rustfs_rio::ChecksumType`
/// (crates/rio/src/checksum.rs, varint bits are append-only), and rio also
/// keeps its own hot-path hasher shells — equivalence with this crate's
/// hashers is enforced by both test suites pinning the same official
/// known-answer vectors (backlog#1844 PR3 verdict, recorded on
/// `rustfs_rio::ChecksumType`). When adding an algorithm: add the variant
/// here (the exhaustive matches force every metadata decision), bridge it in
/// the client, and allocate an xl.meta bit + hasher + shared vector in rio
/// (or record why not).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[non_exhaustive]
pub enum ChecksumAlgorithm {
@@ -120,6 +128,84 @@ impl ChecksumAlgorithm {
Self::Xxhash128 => XXHASH_128_NAME,
}
}
// Per-algorithm wire metadata. These matches are deliberately exhaustive
// (no `_` arm): adding a ChecksumAlgorithm variant without deciding its
// name, header, digest length, and checksum-type support must fail to
// compile rather than silently inherit a default (backlog#1844).
/// The canonical `x-amz-checksum-algorithm` wire value (uppercase), as
/// carried in S3 requests/responses and stored checksum maps.
pub fn s3_algorithm_name(&self) -> &'static str {
match self {
Self::Crc32 => "CRC32",
Self::Crc32c => "CRC32C",
Self::Crc64Nvme => "CRC64NVME",
Self::Sha1 => "SHA1",
Self::Sha256 => "SHA256",
Self::Sha512 => "SHA512",
Self::Xxhash3 => "XXHASH3",
Self::Xxhash64 => "XXHASH64",
Self::Xxhash128 => "XXHASH128",
}
}
/// The `x-amz-checksum-*` HTTP header that carries this algorithm's
/// base64-encoded digest.
pub fn http_header_name(&self) -> &'static str {
match self {
Self::Crc32 => http::CRC_32_HEADER_NAME,
Self::Crc32c => http::CRC_32_C_HEADER_NAME,
Self::Crc64Nvme => http::CRC_64_NVME_HEADER_NAME,
Self::Sha1 => http::SHA_1_HEADER_NAME,
Self::Sha256 => http::SHA_256_HEADER_NAME,
Self::Sha512 => http::SHA_512_HEADER_NAME,
Self::Xxhash3 => http::XXHASH_3_HEADER_NAME,
Self::Xxhash64 => http::XXHASH_64_HEADER_NAME,
Self::Xxhash128 => http::XXHASH_128_HEADER_NAME,
}
}
/// Raw (unencoded) digest length in bytes.
pub fn raw_len(&self) -> usize {
match self {
Self::Crc32 | Self::Crc32c => 4,
Self::Crc64Nvme => 8,
Self::Sha1 => 20,
Self::Sha256 => 32,
Self::Sha512 => 64,
Self::Xxhash3 | Self::Xxhash64 => 8,
Self::Xxhash128 => 16,
}
}
/// Whether the algorithm supports the S3 COMPOSITE multipart checksum
/// type. Per the AWS registry, every algorithm does except CRC64NVME,
/// which is FULL_OBJECT-only.
pub fn supports_composite(&self) -> bool {
match self {
Self::Crc64Nvme => false,
Self::Crc32
| Self::Crc32c
| Self::Sha1
| Self::Sha256
| Self::Sha512
| Self::Xxhash3
| Self::Xxhash64
| Self::Xxhash128 => true,
}
}
/// Whether the algorithm supports the S3 FULL_OBJECT checksum type, i.e.
/// part digests can be linearly combined into the whole-object digest.
/// Only the CRC family has this property; the hash algorithms are
/// COMPOSITE-only.
pub fn supports_full_object(&self) -> bool {
match self {
Self::Crc32 | Self::Crc32c | Self::Crc64Nvme => true,
Self::Sha1 | Self::Sha256 | Self::Sha512 | Self::Xxhash3 | Self::Xxhash64 | Self::Xxhash128 => false,
}
}
}
pub trait Checksum: Send + Sync {
@@ -731,6 +817,77 @@ mod tests {
assert_eq!(&raw[..], reference.digest128().to_be_bytes().as_slice());
}
#[test]
fn test_algorithm_metadata_is_consistent_for_every_variant() {
use crate::Checksum;
// Cross-checks the per-algorithm metadata methods against the hasher
// implementations themselves, so the registry cannot drift from the
// code that computes digests (backlog#1844). The list must cover every
// variant; the metadata methods use exhaustive matches, so a new
// variant that is missing here still fails to compile there first.
let all = [
ChecksumAlgorithm::Crc32,
ChecksumAlgorithm::Crc32c,
ChecksumAlgorithm::Crc64Nvme,
ChecksumAlgorithm::Sha1,
ChecksumAlgorithm::Sha256,
ChecksumAlgorithm::Sha512,
ChecksumAlgorithm::Xxhash3,
ChecksumAlgorithm::Xxhash64,
ChecksumAlgorithm::Xxhash128,
];
for algorithm in all {
// Digest length must match what the hasher actually produces.
let mut hasher = algorithm.into_impl();
hasher.update(b"metadata consistency probe");
assert_eq!(
algorithm.raw_len(),
Checksum::size(&*algorithm.into_impl()) as usize,
"{algorithm:?} raw_len() != hasher size()"
);
assert_eq!(hasher.finalize().len(), algorithm.raw_len(), "{algorithm:?} finalize length != raw_len()");
// Header name must match the hasher's own header binding.
assert_eq!(
algorithm.http_header_name(),
algorithm.into_impl().header_name(),
"{algorithm:?} http_header_name() != HttpChecksum::header_name()"
);
assert_eq!(
algorithm.http_header_name(),
format!("x-amz-checksum-{}", algorithm.as_str()),
"{algorithm:?} header must be x-amz-checksum-<name>"
);
// The uppercase wire name and the lowercase parse name must be the
// same word, and the wire name must parse back to the variant.
assert!(
algorithm.s3_algorithm_name().eq_ignore_ascii_case(algorithm.as_str()),
"{algorithm:?} s3_algorithm_name() and as_str() diverge"
);
assert_eq!(algorithm.s3_algorithm_name().parse::<ChecksumAlgorithm>().unwrap(), algorithm);
}
// AWS checksum-type support table: CRC64NVME is FULL_OBJECT-only, the
// CRC family supports FULL_OBJECT, everything else is COMPOSITE-only.
for algorithm in all {
let composite = algorithm.supports_composite();
let full_object = algorithm.supports_full_object();
assert!(composite || full_object, "{algorithm:?} supports no checksum type at all");
match algorithm {
ChecksumAlgorithm::Crc32 | ChecksumAlgorithm::Crc32c => {
assert!(composite && full_object, "{algorithm:?} must support both checksum types")
}
ChecksumAlgorithm::Crc64Nvme => {
assert!(!composite && full_object, "CRC64NVME must be FULL_OBJECT-only")
}
_ => assert!(composite && !full_object, "{algorithm:?} must be COMPOSITE-only"),
}
}
}
#[test]
fn test_xxhash64_matches_direct_computation_big_endian_seed0() {
use crate::Xxhash64;
-4
View File
@@ -39,10 +39,6 @@ tokio = { workspace = true, features = ["fs", "rt-multi-thread"] }
tonic = { workspace = true, features = ["gzip", "deflate"] }
uuid = { workspace = true, features = ["v4", "fast-rng", "macro-diagnostics"] }
metrics = { workspace = true }
# Transitional shim deps (backlog#1843): dropped with the re-export shims once
# every consumer imports the contracts crates directly.
rustfs-heal-contracts = { workspace = true }
rustfs-scanner-contracts = { workspace = true }
smallvec = { workspace = true }
tracing = { workspace = true }
-4
View File
@@ -27,10 +27,6 @@ pub static GLOBAL_ROOT_CERT: LazyLock<RwLock<Option<Vec<u8>>>> = LazyLock::new(|
pub static GLOBAL_MTLS_IDENTITY: LazyLock<RwLock<Option<MtlsIdentityPem>>> = LazyLock::new(|| RwLock::new(None));
pub static GLOBAL_OUTBOUND_TLS_GENERATION: LazyLock<AtomicU64> = LazyLock::new(|| AtomicU64::new(0));
// Transitional re-export shim (backlog#1843): the node init-time global moved
// to rustfs-scanner-contracts, whose metrics report reads it directly.
pub use rustfs_scanner_contracts::{GLOBAL_INIT_TIME, get_global_init_time, set_global_init_time_now};
/// Log level to use when reporting cached gRPC connection eviction.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ConnectionEvictionLogLevel {
-7
View File
@@ -19,13 +19,6 @@ mod readiness;
pub mod table_catalog;
pub mod trace_bus;
// Transitional re-export shims (backlog#1843): these modules moved to the
// rustfs-heal-contracts / rustfs-scanner-contracts crates. Consumers migrate
// to the new paths crate by crate; the shims are deleted once
// `rg 'rustfs_common::(metrics|heal_channel|last_minute)'` reports zero hits.
pub use rustfs_heal_contracts::heal_channel;
pub use rustfs_scanner_contracts::{last_minute, metrics};
pub use globals::*;
pub use readiness::{GlobalReadiness, SystemStage};
+2 -2
View File
@@ -121,10 +121,10 @@ tracing-subscriber = { workspace = true, features = ["env-filter", "time"] }
uuid = { workspace = true, features = ["v4", "fast-rng", "macro-diagnostics"] }
urlencoding.workspace = true
walkdir.workspace = true
base64 = { workspace = true }
base64-simd = { workspace = true }
rand = { workspace = true, features = ["serde"] }
chrono = { workspace = true, features = ["serde"] }
hex = { workspace = true }
hex-simd = { workspace = true }
md-5 = { workspace = true }
opentelemetry-proto = { workspace = true }
prost.workspace = true
+2 -3
View File
@@ -24,7 +24,6 @@ mod tests {
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::{ChecksumAlgorithm, ChecksumMode, CompletedMultipartUpload, CompletedPart};
use aws_smithy_http_client::Builder as SmithyHttpClientBuilder;
use base64::Engine;
use md5::{Digest as Md5Digest, Md5};
use rustfs_rio::{Checksum, ChecksumType as RioChecksumType};
use sha2::Sha256;
@@ -74,12 +73,12 @@ mod tests {
let mut hasher = Md5::new();
hasher.update(body);
let digest = hasher.finalize();
base64::engine::general_purpose::STANDARD.encode(digest.as_slice())
base64_simd::STANDARD.encode_to_string(digest.as_slice())
}
fn checksum_sha256_base64(body: &[u8]) -> String {
let digest = Sha256::digest(body);
base64::engine::general_purpose::STANDARD.encode(digest.as_slice())
base64_simd::STANDARD.encode_to_string(digest.as_slice())
}
fn checksum_crc64nvme_base64(body: &[u8]) -> String {
+1 -2
View File
@@ -652,11 +652,10 @@ const MPU_SSE_COMPRESSION_BUCKET: &str = "compression-mpu-sse-bucket";
async fn start_rustfs_with_compression_and_sse(
env: &mut RustFSTestEnvironment,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
use base64::Engine;
env.cleanup_existing_processes().await?;
let binary_path = rustfs_binary_path();
let master_key = base64::engine::general_purpose::STANDARD.encode([0x42u8; 32]);
let master_key = base64_simd::STANDARD.encode_to_string([0x42u8; 32]);
// Server output goes to a file inside the per-test temp dir so a failing
// run can be diagnosed from the child's logs.
let server_log = std::fs::File::create(format!("{}/server.log", env.temp_dir))?;
@@ -27,8 +27,7 @@ mod tests {
VersioningConfiguration,
};
use aws_smithy_http_client::Builder as SmithyHttpClientBuilder;
use base64::Engine as _;
use base64::engine::general_purpose::STANDARD as BASE64;
use base64_simd::STANDARD as BASE64;
use rustfs_rio::{Checksum, ChecksumType as RioChecksumType};
use sha2::{Digest, Sha256};
use tracing::info;
@@ -465,7 +464,7 @@ mod tests {
create_versioned_bucket(&client, dst_bucket).await;
let content = b"deterministic synthetic payload for copy-object checksum #4996";
let expected_sha256 = BASE64.encode(Sha256::digest(content));
let expected_sha256 = BASE64.encode_to_string(Sha256::digest(content));
client
.put_object()
@@ -534,7 +533,7 @@ mod tests {
create_versioned_bucket(&client, dst_bucket).await;
let content = b"another deterministic payload whose source checksum must survive the copy";
let expected_sha256 = BASE64.encode(Sha256::digest(content));
let expected_sha256 = BASE64.encode_to_string(Sha256::digest(content));
// Store the source WITH a SHA-256 checksum so it has one to preserve.
let put_src = client
@@ -614,7 +613,7 @@ mod tests {
create_versioned_bucket(&client, dst_bucket).await;
let content = b"payload whose copy must be re-checksummed with a different algorithm";
let expected_sha256 = BASE64.encode(Sha256::digest(content));
let expected_sha256 = BASE64.encode_to_string(Sha256::digest(content));
// Source is stored WITH a SHA-256 checksum.
client
@@ -23,8 +23,8 @@
//! It was fixed in three layers on `main`, each with its own *unit* regression:
//! * rustfs#4594 — `GetObjectStreamingReader::poll_read` now returns
//! `UnexpectedEof` on a short body instead of a clean `Ok(())`
//! (`rustfs/src/app/object_usecase.rs`,
//! `app::object_usecase::tests::get_object_streaming_reader_errors_on_short_eof`).
//! (`rustfs/src/app/object/get.rs`,
//! `app::object::get::tests::get_object_streaming_reader_errors_on_short_eof`).
//! * rustfs#4560 — the lazy multipart codec reader degrades a later part to
//! the legacy per-part decode in place, and surfaces reconstruction errors
//! instead of silently truncating
+3 -3
View File
@@ -1113,7 +1113,7 @@ fn md5_bytes(input: impl AsRef<[u8]>) -> [u8; 16] {
fn md5_hex(input: impl AsRef<[u8]>) -> String {
let mut hasher = Md5::new();
hasher.update(input.as_ref());
hex::encode(hasher.finalize())
hex_simd::encode_to_string(hasher.finalize(), hex_simd::AsciiCase::Lower)
}
fn ensure_store_budget(state: &StoreState, removed_bytes: usize, added_bytes: usize, adds_version: bool) -> S3Result {
@@ -1375,7 +1375,7 @@ impl S3 for FakeBackend {
Some(value) => value,
None => {
let (digest, _body_permit) = md5_digest(body.clone(), _body_permit).await?;
hex::encode(digest)
hex_simd::encode_to_string(digest, hex_simd::AsciiCase::Lower)
}
};
let version = ObjectVersion {
@@ -1660,7 +1660,7 @@ impl S3 for FakeBackend {
}
let body = collect_stream(input.body, input.content_length, fault.as_ref(), &self.control).await?;
let (digest, _body_permit) = md5_digest(body.clone(), _body_permit).await?;
let e_tag = hex::encode(digest);
let e_tag = hex_simd::encode_to_string(digest, hex_simd::AsciiCase::Lower);
let mut state = lock(&self.store);
let existing_bytes = state
.uploads
@@ -28,7 +28,6 @@ use aws_sdk_s3::types::{
BucketLifecycleConfiguration, BucketVersioningStatus, CompletedMultipartUpload, CompletedPart, ExpirationStatus,
LifecycleRule, LifecycleRuleFilter, ServerSideEncryption, Transition, TransitionStorageClass, VersioningConfiguration,
};
use base64::Engine;
use bytes::Bytes;
use flate2::read::GzDecoder;
use http::header::{CONTENT_ENCODING, HOST};
@@ -1808,7 +1807,7 @@ async fn four_node_inline_fallback_controls() -> TestResult {
let collector = OtlpMetricCollector::start().await?;
let mut cluster = RustFSTestClusterEnvironment::new(4).await?;
configure_reader_metric_cluster(&mut cluster, &collector);
let sse_master_key = base64::engine::general_purpose::STANDARD.encode([0x42u8; 32]);
let sse_master_key = base64_simd::STANDARD.encode_to_string([0x42u8; 32]);
cluster.set_env("RUSTFS_SSE_S3_MASTER_KEY", &sse_master_key);
cluster.start().await?;
@@ -2017,7 +2016,7 @@ async fn four_node_mixed_msgpack_compat_mode_preserves_fallback_controls() -> Te
let collector = OtlpMetricCollector::start().await?;
let mut cluster = RustFSTestClusterEnvironment::new(4).await?;
let sse_master_key = base64::engine::general_purpose::STANDARD.encode([0x42u8; 32]);
let sse_master_key = base64_simd::STANDARD.encode_to_string([0x42u8; 32]);
cluster.set_env("RUSTFS_SSE_S3_MASTER_KEY", sse_master_key);
cluster.set_env("RUSTFS_COMPRESSION_ENABLED", "true");
cluster.set_env("RUSTFS_COMPRESSION_MULTIPART_ENABLED", "true");
@@ -2489,7 +2488,7 @@ async fn four_node_mixed_msgpack_compat_mode_preserves_fallback_controls_during_
hot.set_env("RUSTFS_SCANNER_CYCLE", "1");
hot.set_env("RUSTFS_ILM_PROCESS_TIME", "1");
let sse_master_key = base64::engine::general_purpose::STANDARD.encode([0x42u8; 32]);
let sse_master_key = base64_simd::STANDARD.encode_to_string([0x42u8; 32]);
hot.set_env("RUSTFS_SSE_S3_MASTER_KEY", sse_master_key);
hot.set_env("RUSTFS_COMPRESSION_ENABLED", "true");
hot.start().await?;
+7 -7
View File
@@ -27,7 +27,7 @@ use aws_sdk_s3::Client;
use aws_sdk_s3::error::{ProvideErrorMetadata, SdkError};
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::ServerSideEncryption;
use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
use base64_simd::STANDARD as BASE64;
use http::header::{CONTENT_TYPE, HOST};
use md5::{Digest as Md5Digest, Md5};
use rustfs_signer::constants::UNSIGNED_PAYLOAD;
@@ -64,7 +64,7 @@ pub fn init_logging() {
pub fn sse_customer_key_md5_base64(key: &str) -> String {
let mut hasher = Md5::new();
hasher.update(key.as_bytes());
BASE64.encode(hasher.finalize())
BASE64.encode_to_string(hasher.finalize())
}
pub fn assert_s3_error<T, E>(result: Result<T, SdkError<E>>, status: u16, code: &str, message: &str, context: &str)
@@ -365,7 +365,7 @@ pub async fn create_key_with_specific_id(key_dir: &str, key_id: &str) -> Result<
"created_at": format!("{}[UTC]", chrono::Utc::now().to_rfc3339()),
"rotated_at": serde_json::Value::Null,
"created_by": "e2e-test",
"encrypted_key_material": BASE64.encode(key_data),
"encrypted_key_material": BASE64.encode_to_string(key_data),
"nonce": Vec::<u8>::new()
});
@@ -383,7 +383,7 @@ pub async fn test_sse_c_encryption(s3_client: &Client, bucket: &str) -> Result<(
info!("Testing SSE-C encryption");
let test_key = "01234567890123456789012345678901"; // 32-byte key
let test_key_b64 = base64::engine::general_purpose::STANDARD.encode(test_key);
let test_key_b64 = base64_simd::STANDARD.encode_to_string(test_key);
let test_key_md5 = sse_customer_key_md5_base64(test_key);
let test_data = b"Hello, KMS SSE-C World!";
let object_key = "test-sse-c-object";
@@ -551,8 +551,8 @@ pub async fn test_error_scenarios(s3_client: &Client, bucket: &str) -> Result<()
// Test SSE-C with wrong key for download
let test_key = "01234567890123456789012345678901";
let wrong_key = "98765432109876543210987654321098";
let test_key_b64 = base64::engine::general_purpose::STANDARD.encode(test_key);
let wrong_key_b64 = base64::engine::general_purpose::STANDARD.encode(wrong_key);
let test_key_b64 = base64_simd::STANDARD.encode_to_string(test_key);
let wrong_key_b64 = base64_simd::STANDARD.encode_to_string(wrong_key);
let test_key_md5 = sse_customer_key_md5_base64(test_key);
let wrong_key_md5 = sse_customer_key_md5_base64(wrong_key);
let test_data = b"Test data for error scenarios";
@@ -807,7 +807,7 @@ pub async fn test_multipart_upload_with_config(
// Prepare encryption parameters
let (sse_c_key_b64, sse_c_key_md5) = match &config.encryption_type {
EncryptionType::SSEC { key, key_md5 } => {
let key_b64 = base64::engine::general_purpose::STANDARD.encode(key);
let key_b64 = base64_simd::STANDARD.encode_to_string(key);
(Some(key_b64), Some(key_md5.clone()))
}
_ => (None, None),
@@ -177,7 +177,7 @@ async fn test_comprehensive_key_isolation() -> Result<(), Box<dyn std::error::Er
// Verify that files cannot be read with wrong keys
info!("🔒 Verify key isolation");
let wrong_key = "11111111111111111111111111111111";
let wrong_key_b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, wrong_key);
let wrong_key_b64 = base64_simd::STANDARD.encode_to_string(wrong_key);
let wrong_key_md5 = sse_customer_key_md5_base64(wrong_key);
// Try to read file encrypted with key1 using wrong key
@@ -24,7 +24,6 @@
use super::common::{LocalKMSTestEnvironment, SSE_C_KEY_MISMATCH_MESSAGE, assert_s3_error, sse_customer_key_md5_base64};
use crate::common::{TEST_BUCKET, init_logging};
use aws_sdk_s3::types::ServerSideEncryption;
use base64::Engine;
use std::sync::Arc;
use tokio::sync::Semaphore;
use tracing::{info, warn};
@@ -68,7 +67,7 @@ async fn test_kms_zero_byte_file_encryption() -> Result<(), Box<dyn std::error::
// Test SSE-C with zero-byte file
info!("📤 Testing SSE-C with zero-byte file");
let test_key = "01234567890123456789012345678901";
let test_key_b64 = base64::engine::general_purpose::STANDARD.encode(test_key);
let test_key_b64 = base64_simd::STANDARD.encode_to_string(test_key);
let test_key_md5 = sse_customer_key_md5_base64(test_key);
let object_key_c = "zero-byte-sse-c";
@@ -161,7 +160,7 @@ async fn test_kms_single_byte_file_encryption() -> Result<(), Box<dyn std::error
// Test SSE-C with single byte
info!("📤 Testing SSE-C with single-byte file");
let test_key = "01234567890123456789012345678901";
let test_key_b64 = base64::engine::general_purpose::STANDARD.encode(test_key);
let test_key_b64 = base64_simd::STANDARD.encode_to_string(test_key);
let test_key_md5 = sse_customer_key_md5_base64(test_key);
let object_key_c = "single-byte-sse-c";
@@ -287,7 +286,7 @@ async fn test_kms_invalid_key_scenarios() -> Result<(), Box<dyn std::error::Erro
// Test 1: Invalid key length for SSE-C
info!("🔍 Testing invalid SSE-C key length");
let invalid_short_key = "short"; // Too short
let invalid_key_b64 = base64::engine::general_purpose::STANDARD.encode(invalid_short_key);
let invalid_key_b64 = base64_simd::STANDARD.encode_to_string(invalid_short_key);
let invalid_key_md5 = sse_customer_key_md5_base64(invalid_short_key);
let invalid_key_result = s3_client
@@ -325,7 +324,7 @@ async fn test_kms_invalid_key_scenarios() -> Result<(), Box<dyn std::error::Erro
// Test 2: Mismatched MD5 for SSE-C
info!("🔍 Testing mismatched MD5 for SSE-C key");
let valid_key = "01234567890123456789012345678901";
let valid_key_b64 = base64::engine::general_purpose::STANDARD.encode(valid_key);
let valid_key_b64 = base64_simd::STANDARD.encode_to_string(valid_key);
let wrong_md5 = sse_customer_key_md5_base64("98765432109876543210987654321098");
let wrong_md5_result = s3_client
@@ -465,7 +464,7 @@ async fn test_kms_concurrent_encryption() -> Result<(), Box<dyn std::error::Erro
2 => {
// SSE-C
let key = format!("testkey{i:026}"); // 32-byte key
let key_b64 = base64::engine::general_purpose::STANDARD.encode(&key);
let key_b64 = base64_simd::STANDARD.encode_to_string(&key);
let key_md5 = sse_customer_key_md5_base64(&key);
client
@@ -535,8 +534,8 @@ async fn test_kms_key_validation_security() -> Result<(), Box<dyn std::error::Er
let key1 = "key1key1key1key1key1key1key1key1"; // 32 bytes
let key2 = "key2key2key2key2key2key2key2key2"; // 32 bytes
let key1_b64 = base64::engine::general_purpose::STANDARD.encode(key1);
let key2_b64 = base64::engine::general_purpose::STANDARD.encode(key2);
let key1_b64 = base64_simd::STANDARD.encode_to_string(key1);
let key2_b64 = base64_simd::STANDARD.encode_to_string(key2);
let key1_md5 = sse_customer_key_md5_base64(key1);
let key2_md5 = sse_customer_key_md5_base64(key2);
+3 -3
View File
@@ -138,8 +138,8 @@ async fn test_local_kms_key_isolation() {
// Test that different SSE-C keys create isolated encrypted objects
let key1 = "01234567890123456789012345678901";
let key2 = "98765432109876543210987654321098";
let key1_b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, key1);
let key2_b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, key2);
let key1_b64 = base64_simd::STANDARD.encode_to_string(key1);
let key2_b64 = base64_simd::STANDARD.encode_to_string(key2);
let key1_md5 = sse_customer_key_md5_base64(key1);
let key2_md5 = sse_customer_key_md5_base64(key2);
@@ -565,7 +565,7 @@ async fn test_multipart_upload_with_sse_c(
// SSE-C encryption key
let encryption_key = "01234567890123456789012345678901";
let key_b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, encryption_key);
let key_b64 = base64_simd::STANDARD.encode_to_string(encryption_key);
let key_md5 = sse_customer_key_md5_base64(encryption_key);
// Generate test data
+2 -2
View File
@@ -127,8 +127,8 @@ async fn test_vault_kms_key_isolation() -> Result<(), Box<dyn std::error::Error
let key1 = "01234567890123456789012345678901";
let key2 = "98765432109876543210987654321098";
let key1_b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, key1);
let key2_b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, key2);
let key1_b64 = base64_simd::STANDARD.encode_to_string(key1);
let key2_b64 = base64_simd::STANDARD.encode_to_string(key2);
let key1_md5 = sse_customer_key_md5_base64(key1);
let key2_md5 = sse_customer_key_md5_base64(key2);
@@ -497,7 +497,7 @@ async fn test_multipart_encryption_type(
// Prepare SSE-C keys when required
let (sse_c_key, sse_c_md5) = if matches!(encryption_type, EncryptionType::SSEC) {
let key = "01234567890123456789012345678901";
let key_b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, key);
let key_b64 = base64_simd::STANDARD.encode_to_string(key);
let key_md5 = sse_customer_key_md5_base64(key);
(Some(key_b64), Some(key_md5))
} else {
+11 -12
View File
@@ -22,7 +22,6 @@ use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::{
ServerSideEncryption, ServerSideEncryptionByDefault, ServerSideEncryptionConfiguration, ServerSideEncryptionRule,
};
use base64::Engine;
use chrono::{Duration as ChronoDuration, Utc};
use flate2::{Compression, write::GzEncoder};
use http::HeaderValue;
@@ -47,19 +46,19 @@ fn encode_post_policy(conditions: Vec<serde_json::Value>) -> String {
"conditions": conditions,
});
base64::engine::general_purpose::STANDARD.encode(policy.to_string())
base64_simd::STANDARD.encode_to_string(policy.to_string())
}
fn sse_customer_key_md5_base64(key: &str) -> String {
let mut hasher = Md5::new();
hasher.update(key.as_bytes());
base64::engine::general_purpose::STANDARD.encode(hasher.finalize())
base64_simd::STANDARD.encode_to_string(hasher.finalize())
}
fn md5_hex(input: impl AsRef<[u8]>) -> String {
let mut hasher = Md5::new();
hasher.update(input.as_ref());
hex::encode(hasher.finalize())
hex_simd::encode_to_string(hasher.finalize(), hex_simd::AsciiCase::Lower)
}
async fn create_restricted_user(
@@ -97,7 +96,7 @@ fn restricted_user_client(env: &RustFSTestEnvironment, username: &str, secret_ke
const LOCAL_SSE_MASTER_KEY_ENV: &str = "RUSTFS_SSE_S3_MASTER_KEY";
fn local_sse_master_key_value() -> String {
base64::engine::general_purpose::STANDARD.encode([0x42u8; 32])
base64_simd::STANDARD.encode_to_string([0x42u8; 32])
}
async fn make_tar(files: &[(&str, &[u8])], dirs: &[&str]) -> Vec<u8> {
@@ -1887,7 +1886,7 @@ async fn test_anonymous_post_object_allows_sse_c_fields_outside_policy_condition
let object_key = "sse-c-object.txt";
let expected_body = b"anonymous-post-sse-c".to_vec();
let customer_key = "01234567890123456789012345678901";
let customer_key_b64 = base64::engine::general_purpose::STANDARD.encode(customer_key);
let customer_key_b64 = base64_simd::STANDARD.encode_to_string(customer_key);
let customer_key_md5 = sse_customer_key_md5_base64(customer_key);
let admin_client = env.create_s3_client();
@@ -1941,7 +1940,7 @@ async fn test_anonymous_post_object_allows_sse_c_fields_outside_policy_condition
.bucket(bucket)
.key(object_key)
.sse_customer_algorithm("AES256")
.sse_customer_key(base64::engine::general_purpose::STANDARD.encode(customer_key))
.sse_customer_key(base64_simd::STANDARD.encode_to_string(customer_key))
.sse_customer_key_md5(customer_key_md5)
.send()
.await?;
@@ -1963,8 +1962,8 @@ async fn test_anonymous_post_object_rejects_sse_c_exact_policy_mismatch() -> Res
let object_key = "sse-c-mismatch-object.txt";
let policy_key = "01234567890123456789012345678901";
let request_key = "abcdefghijklmnopqrstuvwxyzABCDEF";
let policy_key_b64 = base64::engine::general_purpose::STANDARD.encode(policy_key);
let request_key_b64 = base64::engine::general_purpose::STANDARD.encode(request_key);
let policy_key_b64 = base64_simd::STANDARD.encode_to_string(policy_key);
let request_key_b64 = base64_simd::STANDARD.encode_to_string(request_key);
let admin_client = env.create_s3_client();
admin_client.create_bucket().bucket(bucket).send().await?;
@@ -3526,7 +3525,7 @@ async fn test_signed_put_object_extract_preserves_sse_s3_and_redirect() -> Resul
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
let sse_master_key = base64::engine::general_purpose::STANDARD.encode([0x42u8; 32]);
let sse_master_key = base64_simd::STANDARD.encode_to_string([0x42u8; 32]);
env.start_rustfs_server_with_env(vec![], &[("RUSTFS_SSE_S3_MASTER_KEY", sse_master_key.as_str())])
.await?;
@@ -3799,7 +3798,7 @@ async fn test_signed_put_object_extract_uses_bucket_default_sse_s3() -> Result<(
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
let sse_master_key = base64::engine::general_purpose::STANDARD.encode([0x42u8; 32]);
let sse_master_key = base64_simd::STANDARD.encode_to_string([0x42u8; 32]);
env.start_rustfs_server_with_env(vec![], &[("RUSTFS_SSE_S3_MASTER_KEY", sse_master_key.as_str())])
.await?;
@@ -3925,7 +3924,7 @@ async fn test_signed_put_object_extract_preserves_sse_c() -> Result<(), Box<dyn
let extracted_key = "nested/file.txt";
let expected_body = b"extract-sse-c-body".to_vec();
let customer_key = "01234567890123456789012345678901";
let customer_key_b64 = base64::engine::general_purpose::STANDARD.encode(customer_key);
let customer_key_b64 = base64_simd::STANDARD.encode_to_string(customer_key);
let customer_key_md5 = sse_customer_key_md5_base64(customer_key);
let client = env.create_s3_client();
@@ -87,7 +87,9 @@ fn valid_config() -> PresigningConfig {
}
/// Flip bytes inside the `X-Amz-Signature=` query value without changing its
/// length, producing a structurally valid but incorrect signature.
/// length, producing a structurally valid but incorrect signature. Every hex
/// digit is replaced by its complement (15 - v), which has no fixed point, so
/// the tamper changes the value no matter which digits the signature contains.
fn tamper_signature(uri: &str) -> String {
let marker = "X-Amz-Signature=";
let idx = uri.find(marker).expect("presigned uri must carry X-Amz-Signature") + marker.len();
@@ -96,10 +98,9 @@ fn tamper_signature(uri: &str) -> String {
let (sig, tail) = rest.split_at(end);
let tampered: String = sig
.chars()
.map(|c| match c {
'0' => 'f',
'a' => '0',
other => other,
.map(|c| {
let v = c.to_digit(16).expect("X-Amz-Signature value must be hex");
char::from_digit(15 - v, 16).expect("complement of a hex digit is a hex digit")
})
.collect();
assert_ne!(sig, tampered, "tamper must actually change the signature hex");
+1 -2
View File
@@ -35,7 +35,6 @@ use crate::common::local_http_client;
use crate::common::rustfs_binary_path_with_features;
use crate::protocols::test_env::{DEFAULT_ACCESS_KEY, DEFAULT_SECRET_KEY, ProtocolTestEnvironment};
use anyhow::Result;
use base64::Engine;
use http::header::{CONTENT_TYPE, HOST};
use reqwest::Client;
use rustfs_signer::constants::UNSIGNED_PAYLOAD;
@@ -64,7 +63,7 @@ fn basic_auth_header() -> String {
fn basic_auth_header_for(access_key: &str, secret_key: &str) -> String {
let credentials = format!("{}:{}", access_key, secret_key);
let encoded = base64::engine::general_purpose::STANDARD.encode(credentials);
let encoded = base64_simd::STANDARD.encode_to_string(credentials);
format!("Basic {}", encoded)
}
@@ -23,8 +23,9 @@ use rustfs_protos::{
proto_gen::node_service::{
BatchGenerallyLockRequest, BatchGenerallyLockResponse, BatchReadVersionRequest, BatchReadVersionResponse,
GenerallyLockRequest, GenerallyLockResponse, GenerallyLockResult, PingRequest, PingResponse,
SnapshotLeaseMutationResponse, SnapshotLeaseReleaseRequest, SnapshotLeaseRenewRequest, SnapshotLeaseRequest,
SnapshotLeaseResponse, node_service_server::NodeService,
ScannerPublicationLeaseReleaseRequest, ScannerPublicationLeaseReleaseResponse, ScannerPublicationLeaseRequest,
ScannerPublicationLeaseResponse, SnapshotLeaseMutationResponse, SnapshotLeaseReleaseRequest, SnapshotLeaseRenewRequest,
SnapshotLeaseRequest, SnapshotLeaseResponse, node_service_server::NodeService,
},
};
use std::pin::Pin;
@@ -126,6 +127,20 @@ impl NodeService for MinimalLockNodeService {
Err(Status::unimplemented("MinimalLockNodeService only supports lock RPCs"))
}
async fn acquire_scanner_publication_lease(
&self,
_request: Request<ScannerPublicationLeaseRequest>,
) -> Result<Response<ScannerPublicationLeaseResponse>, Status> {
Err(Status::unimplemented("MinimalLockNodeService only supports lock RPCs"))
}
async fn release_scanner_publication_lease(
&self,
_request: Request<ScannerPublicationLeaseReleaseRequest>,
) -> Result<Response<ScannerPublicationLeaseReleaseResponse>, Status> {
Err(Status::unimplemented("MinimalLockNodeService only supports lock RPCs"))
}
async fn lock(&self, request: Request<GenerallyLockRequest>) -> Result<Response<GenerallyLockResponse>, Status> {
let request = request.into_inner();
let args: LockRequest = match serde_json::from_str(&request.args) {
@@ -34,7 +34,7 @@ use aws_sdk_s3::types::{
VersioningConfiguration,
};
use aws_sdk_s3::{Client, Config};
use base64::{Engine, engine::general_purpose::STANDARD as BASE64_STANDARD};
use base64_simd::STANDARD as BASE64_STANDARD;
use bytes::Bytes;
use flate2::read::GzDecoder;
use futures::{Stream, StreamExt};
@@ -1244,7 +1244,7 @@ async fn wait_for_source_replication_pending_or_failed(
}
async fn wait_for_source_replication_status(client: &Client, bucket: &str, key: &str, expected: &str, ssec: bool) -> TestResult {
let customer_key = BASE64_STANDARD.encode(REPL17_SSEC_KEY);
let customer_key = BASE64_STANDARD.encode_to_string(REPL17_SSEC_KEY);
let customer_key_md5 = sse_customer_key_md5_base64(REPL17_SSEC_KEY);
let wait = async {
loop {
@@ -1339,7 +1339,7 @@ async fn assert_failed_replication_stays_absent_for(
ssec: bool,
duration: Duration,
) -> TestResult {
let customer_key = BASE64_STANDARD.encode(REPL17_SSEC_KEY);
let customer_key = BASE64_STANDARD.encode_to_string(REPL17_SSEC_KEY);
let customer_key_md5 = sse_customer_key_md5_base64(REPL17_SSEC_KEY);
let wait = async {
let deadline = tokio::time::Instant::now() + duration;
@@ -4332,7 +4332,7 @@ async fn test_bucket_replication_sse_c_contract() -> TestResult {
let target_client = target_env.create_s3_client();
let key = "ssec-contract.txt";
let body = b"repl-17 SSE-C payload";
let customer_key = BASE64_STANDARD.encode(REPL17_SSEC_KEY);
let customer_key = BASE64_STANDARD.encode_to_string(REPL17_SSEC_KEY);
let customer_key_md5 = sse_customer_key_md5_base64(REPL17_SSEC_KEY);
source_client
@@ -4387,7 +4387,7 @@ async fn test_bucket_replication_sse_c_contract() -> TestResult {
);
// A wrong customer key must fail too.
let wrong_key = BASE64_STANDARD.encode("99999999999999999999999999999999");
let wrong_key = BASE64_STANDARD.encode_to_string("99999999999999999999999999999999");
let wrong_key_md5 = sse_customer_key_md5_base64("99999999999999999999999999999999");
let wrong_read = target_client
.get_object()
@@ -4423,7 +4423,7 @@ async fn test_bucket_replication_sse_c_multipart_passthrough() -> TestResult {
let source_client = source_env.create_s3_client();
let target_client = target_env.create_s3_client();
let key = "ssec-mp-contract.bin";
let customer_key = BASE64_STANDARD.encode(REPL17_SSEC_KEY);
let customer_key = BASE64_STANDARD.encode_to_string(REPL17_SSEC_KEY);
let customer_key_md5 = sse_customer_key_md5_base64(REPL17_SSEC_KEY);
let created = source_client
@@ -4568,7 +4568,7 @@ async fn test_ssec_replication_fails_closed_when_target_drops_passthrough_header
.await?;
put_bucket_replication(&source_env, source_bucket, &target_arn).await?;
let customer_key = BASE64_STANDARD.encode(REPL17_SSEC_KEY);
let customer_key = BASE64_STANDARD.encode_to_string(REPL17_SSEC_KEY);
let customer_key_md5 = sse_customer_key_md5_base64(REPL17_SSEC_KEY);
let put_ssec = |key: &'static str| {
source_client
@@ -4741,7 +4741,7 @@ async fn test_bucket_replication_sse_c_heals_after_target_outage() -> TestResult
let source_client = source_env.create_s3_client();
let key = "ssec-heal-contract.txt";
let body = b"repl-22 ssec heal payload".to_vec();
let customer_key = BASE64_STANDARD.encode(REPL17_SSEC_KEY);
let customer_key = BASE64_STANDARD.encode_to_string(REPL17_SSEC_KEY);
let customer_key_md5 = sse_customer_key_md5_base64(REPL17_SSEC_KEY);
// Target outage: the SSE-C write cannot replicate.
@@ -4856,7 +4856,7 @@ async fn test_bucket_replication_sse_c_existing_object_resync() -> TestResult {
// The SSE-C object exists before any replication wiring.
let key = "ssec-existing-contract.txt";
let body = b"repl-22 ssec existing-object payload".to_vec();
let customer_key = BASE64_STANDARD.encode(REPL17_SSEC_KEY);
let customer_key = BASE64_STANDARD.encode_to_string(REPL17_SSEC_KEY);
let customer_key_md5 = sse_customer_key_md5_base64(REPL17_SSEC_KEY);
source_client
.put_object()
@@ -9152,7 +9152,7 @@ async fn test_get_and_head_proxy_unreplicated_object_to_replication_target() ->
// the real SSE-C decryption; the plaintext fake simply ignores them).
target.take_requests();
let ssec_key = "01234567890123456789012345678901";
let ssec_key_b64 = BASE64_STANDARD.encode(ssec_key);
let ssec_key_b64 = BASE64_STANDARD.encode_to_string(ssec_key);
let ssec_key_md5 = sse_customer_key_md5_base64(ssec_key);
let _ = source_client
.get_object()
+2 -3
View File
@@ -21,7 +21,6 @@ use aws_sdk_s3::error::BoxError;
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::{BucketVersioningStatus, CompletedMultipartUpload, CompletedPart, VersioningConfiguration};
use aws_smithy_http_client::Builder as SmithyHttpClientBuilder;
use base64::Engine;
use md5::{Digest as Md5Digest, Md5};
use std::collections::HashMap;
use std::sync::atomic::{AtomicUsize, Ordering};
@@ -107,8 +106,8 @@ fn customer_key(byte: u8) -> CustomerKey {
hasher.update(raw);
CustomerKey {
raw: String::from_utf8_lossy(&raw).into_owned(),
encoded: base64::engine::general_purpose::STANDARD.encode(raw),
md5: base64::engine::general_purpose::STANDARD.encode(hasher.finalize()),
encoded: base64_simd::STANDARD.encode_to_string(raw),
md5: base64_simd::STANDARD.encode_to_string(hasher.finalize()),
}
}
+2 -19
View File
@@ -25,7 +25,6 @@ keywords = ["erasure-coding", "storage", "rustfs", "Minio", "solomon"]
categories = ["web-programming", "development-tools", "filesystem"]
documentation = "https://docs.rs/rustfs-ecstore/latest/rustfs_ecstore/"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lints]
workspace = true
@@ -45,7 +44,6 @@ hotpath = [
"hotpath/async-channel",
"hotpath/parking_lot",
"hotpath/reqwest-0-13",
"rustfs-checksums/hotpath",
"rustfs-common/hotpath",
"rustfs-concurrency/hotpath",
"rustfs-config/hotpath",
@@ -63,16 +61,13 @@ hotpath = [
"rustfs-rio/hotpath",
"rustfs-rio-v2?/hotpath",
"rustfs-s3-types/hotpath",
"rustfs-signer/hotpath",
"rustfs-storage-api/hotpath",
"rustfs-tls-runtime/hotpath",
"rustfs-utils/hotpath",
"rustfs-crypto/hotpath",
]
hotpath-alloc = [
"hotpath",
"hotpath/hotpath-alloc",
"rustfs-checksums/hotpath-alloc",
"rustfs-common/hotpath-alloc",
"rustfs-concurrency/hotpath-alloc",
"rustfs-config/hotpath-alloc",
@@ -90,16 +85,13 @@ hotpath-alloc = [
"rustfs-rio/hotpath-alloc",
"rustfs-rio-v2?/hotpath-alloc",
"rustfs-s3-types/hotpath-alloc",
"rustfs-signer/hotpath-alloc",
"rustfs-storage-api/hotpath-alloc",
"rustfs-tls-runtime/hotpath-alloc",
"rustfs-utils/hotpath-alloc",
"rustfs-crypto/hotpath-alloc",
]
hotpath-cpu = [
"hotpath",
"hotpath/hotpath-cpu",
"rustfs-checksums/hotpath-cpu",
"rustfs-common/hotpath-cpu",
"rustfs-concurrency/hotpath-cpu",
"rustfs-config/hotpath-cpu",
@@ -117,9 +109,7 @@ hotpath-cpu = [
"rustfs-rio/hotpath-cpu",
"rustfs-rio-v2?/hotpath-cpu",
"rustfs-s3-types/hotpath-cpu",
"rustfs-signer/hotpath-cpu",
"rustfs-storage-api/hotpath-cpu",
"rustfs-tls-runtime/hotpath-cpu",
"rustfs-utils/hotpath-cpu",
"rustfs-crypto/hotpath-cpu",
]
@@ -129,20 +119,18 @@ hotpath-cpu = [
test-util = []
[dependencies]
starshard = { workspace = true }
hotpath.workspace = true
rustfs-filemeta.workspace = true
rustfs-utils = { workspace = true, features = ["full"] }
rustfs-rio.workspace = true
rustfs-rio-v2 = { workspace = true, optional = true }
rustfs-signer.workspace = true
rustfs-storage-api.workspace = true
rustfs-tls-runtime.workspace = true
rustfs-checksums.workspace = true
rustfs-config = { workspace = true, features = ["notify", "audit", "server-config-model"] }
rustfs-concurrency.workspace = true
rustfs-credentials = { workspace = true }
rustfs-common.workspace = true
rustfs-heal-contracts.workspace = true
rustfs-scanner-contracts.workspace = true
rustfs-policy.workspace = true
rustfs-protos.workspace = true
rustfs-replication.workspace = true
@@ -158,7 +146,6 @@ bytes = { workspace = true, features = ["serde"] }
byteorder = { workspace = true }
chrono = { workspace = true, features = ["serde"] }
jiff = { workspace = true, features = ["serde"] }
glob = { workspace = true }
thiserror.workspace = true
flatbuffers.workspace = true
futures.workspace = true
@@ -169,7 +156,6 @@ serde = { workspace = true, features = ["derive"] }
time = { workspace = true, features = ["parsing", "formatting", "macros", "serde"] }
bytesize.workspace = true
serde_json = { workspace = true, features = ["raw_value"] }
quick-xml = { workspace = true, features = ["serialize", "async-tokio"] }
s3s = { workspace = true, features = ["minio"] }
http.workspace = true
opentelemetry.workspace = true
@@ -189,9 +175,7 @@ rmp.workspace = true
rmp-serde.workspace = true
tokio-util = { workspace = true, features = ["io", "compat"] }
tokio-stream = { workspace = true, features = ["sync"] }
base64 = { workspace = true }
hmac = { workspace = true }
sha1 = { workspace = true }
sha2 = { workspace = true }
hex-simd = { workspace = true }
tempfile.workspace = true
@@ -206,7 +190,6 @@ tonic = { workspace = true, features = ["gzip", "deflate"] }
xxhash-rust = { workspace = true, features = ["xxh64", "xxh3"] }
tower = { workspace = true, features = ["timeout"] }
async-channel.workspace = true
enumset = { workspace = true }
num_cpus = { workspace = true }
rand = { workspace = true, features = ["serde"] }
pin-project-lite.workspace = true
+8 -20
View File
@@ -154,6 +154,12 @@ pub mod bucket {
pub mod object_lock {
pub use crate::bucket::object_lock::{ObjectLockApi, ObjectLockStatusExt};
pub mod types {
pub use crate::bucket::object_lock::types::{
DefaultRetention, LegalHoldStatus, ObjectLegalHold, ObjectRetention, RetentionMode,
};
}
pub mod objectlock {
pub use crate::bucket::object_lock::objectlock::{get_object_legalhold_meta, get_object_retention_meta};
}
@@ -249,26 +255,8 @@ pub mod capacity {
pub use crate::store::utils::is_reserved_or_invalid_bucket;
}
pub mod client {
pub mod admin_handler_utils {
pub use crate::client::admin_handler_utils::AdminError;
}
pub mod api_put_object {
pub use crate::client::api_put_object::{AdvancedPutOptions, PutObjectOptions};
}
pub mod object_api_utils {
pub use crate::client::object_api_utils::{ObjReaderFn, PutObjReader, get_raw_etag, new_getobjectreader, to_s3s_etag};
}
pub mod transition_api {
pub use crate::client::transition_api::{
BucketLookupType, CreateBucketConfiguration, LocationConstraint, ObjectInfo, ObjectMultipartInfo, Options,
PutObjectPartOptions, ReadCloser, ReaderImpl, RequestMetadata, RestoreInfo, SendRequest, TransitionClient,
TransitionCore, UploadInfo, to_object_info,
};
}
pub mod object_api_utils {
pub use crate::object_api::object_api_utils::{ObjReaderFn, PutObjReader, get_raw_etag, new_getobjectreader, to_s3s_etag};
}
pub mod cluster {
@@ -16,8 +16,8 @@ use super::runtime_boundary as runtime_sources;
use crate::bucket::lifecycle::lifecycle;
use crate::object_api::ObjectInfo;
use crate::services::event_notification::{EventArgs, send_event};
use rustfs_common::metrics::IlmAction;
use rustfs_s3_types::EventName;
use rustfs_scanner_contracts::metrics::IlmAction;
const LIFECYCLE_EXPIRY_USER_AGENT: &str = "Internal: [ILM-Expiry]";
const LIFECYCLE_TRANSITION_USER_AGENT: &str = "Internal: [ILM-Transition]";
@@ -73,9 +73,6 @@ use crate::store::ECStore;
use async_channel::{Receiver as A_Receiver, Sender as A_Sender, bounded};
use http::HeaderMap;
use rand::RngExt as _;
use rustfs_common::metrics::{
IlmAction, Metrics, ScannerLifecycleExpiryStateUpdate, ScannerLifecycleTransitionStateUpdate, global_metrics,
};
use rustfs_config::{
DEFAULT_TRANSITION_QUEUE_CAPACITY, DEFAULT_TRANSITION_QUEUE_SEND_TIMEOUT_MS, DEFAULT_TRANSITION_WORKERS_ABSOLUTE_MAX,
DEFAULT_TRANSITION_WORKERS_CAP, ENV_MAX_EXPIRY_WORKERS, ENV_TRANSITION_QUEUE_CAPACITY, ENV_TRANSITION_QUEUE_SEND_TIMEOUT_MS,
@@ -85,6 +82,9 @@ use rustfs_data_usage::TierStats;
use rustfs_filemeta::{
FileInfo, FileInfoOpts, NULL_VERSION_ID, RestoreStatusOps, TRANSITION_COMPLETE, get_file_info, is_restored_object_on_disk,
};
use rustfs_scanner_contracts::metrics::{
IlmAction, Metrics, ScannerLifecycleExpiryStateUpdate, ScannerLifecycleTransitionStateUpdate, global_metrics,
};
use rustfs_utils::{
get_env_i64, get_env_usize,
path::encode_dir_object,
@@ -940,7 +940,7 @@ impl ExpiryState {
let version_count = u64::try_from(v.versions.len()).unwrap_or(u64::MAX);
let trace = LifecycleExpiryTrace::for_batch(&v.bucket, &v.event, &v.src, version_count);
trace.emit(EVENT_LIFECYCLE_DELETE_DISPATCHED, "delete_dispatched", None);
crate::client::object_handlers_common::delete_object_versions(
crate::bucket::lifecycle::object_handlers_common::delete_object_versions(
&api,
&v.bucket,
&v.versions,
@@ -5432,8 +5432,6 @@ mod tests {
use crate::bucket::lifecycle::tier_sweeper::Jentry;
use crate::bucket::metadata::{BUCKET_LIFECYCLE_CONFIG, BUCKET_VERSIONING_CONFIG};
use crate::bucket::metadata_sys;
#[cfg(feature = "test-util")]
use crate::client::transition_api::ReaderImpl;
use crate::disk::endpoint::Endpoint;
use crate::disk::{RUSTFS_META_MULTIPART_BUCKET, STORAGE_FORMAT_FILE};
use crate::error::{Error, is_err_invalid_upload_id};
@@ -5461,11 +5459,13 @@ mod tests {
use futures::FutureExt;
#[cfg(feature = "test-util")]
use http::HeaderMap;
use rustfs_common::metrics::{IlmAction, global_metrics};
use rustfs_config::ENV_MAX_EXPIRY_WORKERS;
use rustfs_config::ENV_TRANSITION_WORKERS_ABSOLUTE_MAX;
use rustfs_data_usage::TierStats;
use rustfs_filemeta::{FileInfo, FileMeta};
#[cfg(feature = "test-util")]
use rustfs_s3_client::transition_api::ReaderImpl;
use rustfs_scanner_contracts::metrics::{IlmAction, global_metrics};
use s3s::dto::{
BucketLifecycleConfiguration, DefaultRetention, ExpirationStatus, LifecycleExpiration, LifecycleRule, MetadataEntry,
ObjectLockConfiguration, ObjectLockEnabled, ObjectLockRetentionMode, ObjectLockRule, OutputLocation, RestoreRequest,
@@ -6353,7 +6353,7 @@ mod tests {
assert_eq!(err.kind(), std::io::ErrorKind::Other);
let admin_err = err
.get_ref()
.and_then(|source| source.downcast_ref::<crate::client::admin_handler_utils::AdminError>())
.and_then(|source| source.downcast_ref::<rustfs_s3_client::admin_handler_utils::AdminError>())
.expect("identity mismatch should retain the typed tier error");
assert_eq!(admin_err.code, crate::services::tier::tier::ERR_TIER_INVALID_CONFIG.code);
assert_eq!(new_backend.get_count().await, 0);
@@ -11564,7 +11564,7 @@ mod tests {
lease
.put(
"remote/object",
crate::client::transition_api::ReaderImpl::Body(bytes::Bytes::from_static(b"candidate")),
rustfs_s3_client::transition_api::ReaderImpl::Body(bytes::Bytes::from_static(b"candidate")),
9,
)
.await
@@ -21,6 +21,7 @@ pub mod evaluator;
pub mod manual_transition_job;
mod metadata_boundary;
pub(crate) use metadata_boundary::{LifecycleExpiryConfigs, get_expiry_configs};
mod object_handlers_common;
mod object_lock_boundary;
pub use self::core as lifecycle;
mod replication_sink;
@@ -21,7 +21,7 @@ const EVENT_LIFECYCLE_CLEANUP_SKIPPED: &str = "lifecycle_cleanup_skipped";
const EVENT_LIFECYCLE_CLEANUP_FAILED: &str = "lifecycle_cleanup_failed";
use crate::bucket::lifecycle::lifecycle;
use crate::bucket::replication::{ReplicationLifecycleBridge, ReplicationObjectBridge};
use crate::bucket::lifecycle::replication_sink::{self, ReplicationObjectBridge};
use crate::object_api::ObjectOptions;
use crate::storage_api_contracts::object::{ObjectOperations as _, ObjectToDelete};
use crate::store::ECStore;
@@ -84,7 +84,7 @@ pub async fn delete_object_versions(
if deleted_obj.replication_state.is_none() {
continue;
}
ReplicationLifecycleBridge::schedule_delete(bucket.to_string(), deleted_obj.clone()).await;
replication_sink::schedule_delete(bucket.to_string(), deleted_obj.clone()).await;
}
for (i, err) in errors.iter().enumerate() {
@@ -26,7 +26,8 @@ pub(crate) fn check_object_lock_for_deletion_with_config(
obj_info: &ObjectInfo,
bypass_governance: bool,
) -> crate::error::Result<Option<ObjectLockBlockReason>> {
objectlock_sys::check_object_lock_for_deletion_with_config(config, obj_info, bypass_governance)
let default_retention = config.and_then(crate::bucket::metadata_sys::default_retention_from_object_lock_config);
objectlock_sys::check_object_lock_for_deletion_with_default_retention(default_retention.as_ref(), obj_info, bypass_governance)
}
#[cfg(test)]
@@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use rustfs_common::metrics::IlmAction;
use rustfs_scanner_contracts::metrics::IlmAction;
use crate::bucket::lifecycle::lifecycle::ObjectOpts;
use crate::bucket::replication::ReplicationLifecycleBridge;
@@ -77,7 +77,7 @@ mod tests {
use crate::bucket::replication::{DeleteReplicationConfigSnapshot, ReplicationObjectBridge};
use crate::object_api::{ObjectInfo, ObjectOptions};
use crate::storage_api_contracts::object::ObjectToDelete;
use rustfs_common::metrics::IlmAction;
use rustfs_scanner_contracts::metrics::IlmAction;
use s3s::dto::{
BucketVersioningStatus, DeleteMarkerReplication, DeleteMarkerReplicationStatus, DeleteReplication,
DeleteReplicationStatus, Destination, ReplicationConfiguration, ReplicationRule, ReplicationRuleStatus,
@@ -22,11 +22,11 @@ use super::runtime_boundary as runtime_sources;
use crate::bucket::lifecycle::bucket_lifecycle_ops::ExpiryOp;
use crate::bucket::lifecycle::lifecycle::{self, ObjectOpts};
use crate::bucket::lifecycle::tier_delete_journal::persist_tier_delete_journal_entry;
use crate::client::signer_error::error_chain_contains_signer_header_marker;
use crate::object_api::ObjectInfo;
use crate::services::tier::tier::{TierConfigMgr, TierDestinationId, TierOperationLease};
use crate::storage_api_contracts::lifecycle::TransitionedObject;
use crate::store::ECStore;
use rustfs_s3_client::signer_error::error_chain_contains_signer_header_marker;
use rustfs_utils::get_env_usize;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
@@ -670,7 +670,7 @@ pub(crate) fn transitioned_delete_journal_entry_for_source(
#[cfg(test)]
mod test {
use crate::client::signer_error::invalid_utf8_header_error;
use rustfs_s3_client::signer_error::invalid_utf8_header_error;
use super::{
CONFIRMED_TRANSITION_EMPTY_GUARD_DISPATCHES, ERR_REMOTE_DELETE_BREAKER_OPEN, ERR_REMOTE_DELETE_LIMITER_CLOSED,
+21 -1
View File
@@ -13,7 +13,7 @@
// limitations under the License.
use super::msgp_decode::{read_msgp_ext8_time, skip_msgp_value, write_msgp_time};
use super::object_lock::ObjectLockApi;
use super::object_lock::{ObjectLockApi, ObjectLockStatusExt};
use super::versioning::VersioningApi;
use super::{quota::BucketQuota, target::BucketTargets};
use crate::bucket::replication::invalid_replication_config_status_field;
@@ -39,6 +39,26 @@ use time::{Date, OffsetDateTime, PrimitiveDateTime, Time as CivilTime, UtcOffset
use tracing::error;
use uuid::Uuid;
// The serving-layer DTO impls for the storage-level Object Lock traits live
// here because this module owns the persisted `ObjectLockConfiguration`
// during the s3s ratchet migration (rustfs/backlog#1842).
impl ObjectLockApi for ObjectLockConfiguration {
fn enabled(&self) -> bool {
self.object_lock_enabled
.as_ref()
.is_some_and(|v| v.as_str() == s3s::dto::ObjectLockEnabled::ENABLED)
}
}
impl ObjectLockStatusExt for s3s::dto::ObjectLockLegalHoldStatus {
fn valid(&self) -> bool {
matches!(
self.as_str(),
s3s::dto::ObjectLockLegalHoldStatus::ON | s3s::dto::ObjectLockLegalHoldStatus::OFF
)
}
}
fn read_msgp_str<R: Read>(rd: &mut R) -> Result<String> {
let len = rmp::decode::read_str_len(rd)? as usize;
let mut buf = vec![0u8; len];
+48 -1
View File
@@ -27,7 +27,7 @@ use crate::storage_api_contracts::heal::HealOperations as _;
use crate::storage_api_contracts::namespace::NamespaceLocking as _;
use crate::store::{ECStore, await_bucket_namespace_operation};
use futures::future::join_all;
use rustfs_common::heal_channel::HealOpts;
use rustfs_heal_contracts::heal_channel::HealOpts;
use rustfs_policy::policy::BucketPolicy;
use s3s::dto::ReplicationConfiguration;
use s3s::dto::{
@@ -167,6 +167,53 @@ pub(crate) fn object_lock_config_state_from_authoritative_metadata(bm: &BucketMe
Ok(ObjectLockConfigState::ConfirmedAbsent)
}
/// Convert the persisted serving-layer configuration into the storage-level
/// [`DefaultRetention`](crate::bucket::object_lock::types::DefaultRetention)
/// the WORM evaluation code consumes (rustfs/backlog#1842). A rule without a
/// usable GOVERNANCE/COMPLIANCE mode converts to `None`, exactly like the
/// evaluation code has always ignored such rules; days/years are passed
/// through untouched so an invalid period still fails closed at evaluation.
pub(crate) fn default_retention_from_object_lock_config(
config: &ObjectLockConfiguration,
) -> Option<crate::bucket::object_lock::types::DefaultRetention> {
let default_retention = config.rule.as_ref()?.default_retention.as_ref()?;
let mode = crate::bucket::object_lock::types::RetentionMode::parse(default_retention.mode.as_ref()?.as_str())?;
Some(crate::bucket::object_lock::types::DefaultRetention {
mode,
days: default_retention.days,
years: default_retention.years,
})
}
/// Test-only builder for a `Configured` Object Lock state carrying a default
/// retention, so storage-side tests do not have to name serving-layer DTOs.
#[cfg(test)]
pub(crate) fn configured_object_lock_state_for_tests(
mode: crate::bucket::object_lock::types::RetentionMode,
days: i32,
) -> ObjectLockConfigState {
ObjectLockConfigState::Configured {
config: ObjectLockConfiguration {
object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)),
rule: Some(s3s::dto::ObjectLockRule {
default_retention: Some(s3s::dto::DefaultRetention {
mode: Some(s3s::dto::ObjectLockRetentionMode::from_static(match mode {
crate::bucket::object_lock::types::RetentionMode::Governance => {
s3s::dto::ObjectLockRetentionMode::GOVERNANCE
}
crate::bucket::object_lock::types::RetentionMode::Compliance => {
s3s::dto::ObjectLockRetentionMode::COMPLIANCE
}
})),
days: Some(days),
years: None,
}),
}),
},
updated_at: OffsetDateTime::now_utc(),
}
}
fn validate_authoritative_object_lock_config(config: &ObjectLockConfiguration) -> Result<()> {
if config.object_lock_enabled.as_ref().map(ObjectLockEnabled::as_str) != Some(ObjectLockEnabled::ENABLED) {
return Err(Error::other("persisted bucket Object Lock enabled state is invalid"));
+7 -16
View File
@@ -14,27 +14,18 @@
pub mod objectlock;
pub mod objectlock_sys;
pub mod types;
use s3s::dto::{ObjectLockConfiguration, ObjectLockEnabled, ObjectLockLegalHoldStatus};
/// Whether a bucket Object Lock configuration has locking enabled. The
/// serving-layer `ObjectLockConfiguration` DTO implements this in
/// the bucket-metadata module, which owns the persisted configuration type
/// during the s3s ratchet migration (rustfs/backlog#1842).
pub trait ObjectLockApi {
fn enabled(&self) -> bool;
}
impl ObjectLockApi for ObjectLockConfiguration {
fn enabled(&self) -> bool {
self.object_lock_enabled
.as_ref()
.is_some_and(|v| v.as_str() == ObjectLockEnabled::ENABLED)
}
}
/// Whether a legal-hold status value is one of the two valid wire values.
/// Implemented for the serving-layer DTO in the bucket-metadata module.
pub trait ObjectLockStatusExt {
fn valid(&self) -> bool;
}
impl ObjectLockStatusExt for ObjectLockLegalHoldStatus {
fn valid(&self) -> bool {
matches!(self.as_str(), ObjectLockLegalHoldStatus::ON | ObjectLockLegalHoldStatus::OFF)
}
}
@@ -12,8 +12,10 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use s3s::dto::{Date, ObjectLockLegalHold, ObjectLockLegalHoldStatus, ObjectLockRetention, ObjectLockRetentionMode};
use s3s::header::{X_AMZ_OBJECT_LOCK_LEGAL_HOLD, X_AMZ_OBJECT_LOCK_MODE, X_AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE};
use super::types::{LegalHoldStatus, ObjectLegalHold, ObjectRetention, RetentionMode};
use rustfs_utils::http::headers::{
AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER, AMZ_OBJECT_LOCK_MODE_LOWER, AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER,
};
use std::collections::HashMap;
use time::{OffsetDateTime, format_description};
@@ -31,65 +33,48 @@ pub fn utc_now_ntp() -> OffsetDateTime {
OffsetDateTime::now_utc()
}
pub fn get_object_retention_meta(meta: &HashMap<String, String>) -> ObjectLockRetention {
// Note: X_AMZ_OBJECT_LOCK_MODE.as_str() is already lowercase ("x-amz-object-lock-mode")
let mode_str = meta.get(X_AMZ_OBJECT_LOCK_MODE.as_str());
pub fn get_object_retention_meta(meta: &HashMap<String, String>) -> ObjectRetention {
// The persisted metadata keys are the lowercase wire header names.
let mode_str = meta.get(AMZ_OBJECT_LOCK_MODE_LOWER);
let Some(mode_str) = mode_str else {
return ObjectLockRetention {
mode: None,
retain_until_date: None,
};
return ObjectRetention::default();
};
// If mode is invalid, return empty retention (don't panic)
let Some(mode) = parse_ret_mode(mode_str.as_str()) else {
return ObjectLockRetention {
mode: None,
retain_until_date: None,
};
return ObjectRetention::default();
};
let till_str = meta.get(X_AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE.as_str());
let till_str = meta.get(AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER);
let retain_until_date = till_str
.and_then(|s| OffsetDateTime::parse(s, &format_description::well_known::Iso8601::DEFAULT).ok())
.map(Date::from);
let retain_until_date =
till_str.and_then(|s| OffsetDateTime::parse(s, &format_description::well_known::Iso8601::DEFAULT).ok());
ObjectLockRetention {
ObjectRetention {
mode: Some(mode),
retain_until_date,
}
}
pub fn get_object_legalhold_meta(meta: &HashMap<String, String>) -> ObjectLockLegalHold {
// Note: X_AMZ_OBJECT_LOCK_LEGAL_HOLD.as_str() is already lowercase
let hold_str = meta.get(X_AMZ_OBJECT_LOCK_LEGAL_HOLD.as_str());
pub fn get_object_legalhold_meta(meta: &HashMap<String, String>) -> ObjectLegalHold {
let hold_str = meta.get(AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER);
match hold_str.and_then(|s| parse_legalhold_status(s)) {
Some(status) => ObjectLockLegalHold { status: Some(status) },
None => ObjectLockLegalHold { status: None },
ObjectLegalHold {
status: hold_str.and_then(|s| parse_legalhold_status(s)),
}
}
/// Parse retention mode string into ObjectLockRetentionMode.
/// Parse retention mode string into [`RetentionMode`].
/// Returns None for invalid/unknown mode strings instead of panicking.
pub fn parse_ret_mode(mode_str: &str) -> Option<ObjectLockRetentionMode> {
match mode_str.to_uppercase().as_str() {
"GOVERNANCE" => Some(ObjectLockRetentionMode::from_static(ObjectLockRetentionMode::GOVERNANCE)),
"COMPLIANCE" => Some(ObjectLockRetentionMode::from_static(ObjectLockRetentionMode::COMPLIANCE)),
_ => None,
}
pub fn parse_ret_mode(mode_str: &str) -> Option<RetentionMode> {
RetentionMode::parse(mode_str)
}
/// Parse legal hold status string into ObjectLockLegalHoldStatus.
/// Parse legal hold status string into [`LegalHoldStatus`].
/// Returns None for invalid/unknown status strings instead of panicking.
pub fn parse_legalhold_status(hold_str: &str) -> Option<ObjectLockLegalHoldStatus> {
match hold_str.to_uppercase().as_str() {
"ON" => Some(ObjectLockLegalHoldStatus::from_static(ObjectLockLegalHoldStatus::ON)),
"OFF" => Some(ObjectLockLegalHoldStatus::from_static(ObjectLockLegalHoldStatus::OFF)),
_ => None,
}
pub fn parse_legalhold_status(hold_str: &str) -> Option<LegalHoldStatus> {
LegalHoldStatus::parse(hold_str)
}
#[cfg(test)]
@@ -101,25 +86,25 @@ mod tests {
// Test uppercase
let mode = parse_ret_mode("GOVERNANCE");
assert!(mode.is_some());
assert_eq!(mode.unwrap().as_str(), ObjectLockRetentionMode::GOVERNANCE);
assert_eq!(mode.unwrap().as_str(), RetentionMode::GOVERNANCE);
let mode = parse_ret_mode("COMPLIANCE");
assert!(mode.is_some());
assert_eq!(mode.unwrap().as_str(), ObjectLockRetentionMode::COMPLIANCE);
assert_eq!(mode.unwrap().as_str(), RetentionMode::COMPLIANCE);
// Test lowercase
let mode = parse_ret_mode("governance");
assert!(mode.is_some());
assert_eq!(mode.unwrap().as_str(), ObjectLockRetentionMode::GOVERNANCE);
assert_eq!(mode.unwrap().as_str(), RetentionMode::GOVERNANCE);
let mode = parse_ret_mode("compliance");
assert!(mode.is_some());
assert_eq!(mode.unwrap().as_str(), ObjectLockRetentionMode::COMPLIANCE);
assert_eq!(mode.unwrap().as_str(), RetentionMode::COMPLIANCE);
// Test mixed case
let mode = parse_ret_mode("Governance");
assert!(mode.is_some());
assert_eq!(mode.unwrap().as_str(), ObjectLockRetentionMode::GOVERNANCE);
assert_eq!(mode.unwrap().as_str(), RetentionMode::GOVERNANCE);
}
#[test]
@@ -136,20 +121,20 @@ mod tests {
// Test uppercase
let status = parse_legalhold_status("ON");
assert!(status.is_some());
assert_eq!(status.unwrap().as_str(), ObjectLockLegalHoldStatus::ON);
assert_eq!(status.unwrap().as_str(), LegalHoldStatus::ON);
let status = parse_legalhold_status("OFF");
assert!(status.is_some());
assert_eq!(status.unwrap().as_str(), ObjectLockLegalHoldStatus::OFF);
assert_eq!(status.unwrap().as_str(), LegalHoldStatus::OFF);
// Test lowercase
let status = parse_legalhold_status("on");
assert!(status.is_some());
assert_eq!(status.unwrap().as_str(), ObjectLockLegalHoldStatus::ON);
assert_eq!(status.unwrap().as_str(), LegalHoldStatus::ON);
let status = parse_legalhold_status("off");
assert!(status.is_some());
assert_eq!(status.unwrap().as_str(), ObjectLockLegalHoldStatus::OFF);
assert_eq!(status.unwrap().as_str(), LegalHoldStatus::OFF);
}
#[test]
@@ -175,7 +160,7 @@ mod tests {
meta.insert("x-amz-object-lock-mode".to_string(), "GOVERNANCE".to_string());
let retention = get_object_retention_meta(&meta);
assert!(retention.mode.is_some());
assert_eq!(retention.mode.unwrap().as_str(), ObjectLockRetentionMode::GOVERNANCE);
assert_eq!(retention.mode.unwrap().as_str(), RetentionMode::GOVERNANCE);
assert!(retention.retain_until_date.is_none());
}
@@ -196,7 +181,7 @@ mod tests {
meta.insert("x-amz-object-lock-retain-until-date".to_string(), "2030-01-01T00:00:00Z".to_string());
let retention = get_object_retention_meta(&meta);
assert!(retention.mode.is_some());
assert_eq!(retention.mode.unwrap().as_str(), ObjectLockRetentionMode::COMPLIANCE);
assert_eq!(retention.mode.unwrap().as_str(), RetentionMode::COMPLIANCE);
assert!(retention.retain_until_date.is_some());
}
@@ -210,17 +195,11 @@ mod tests {
meta.insert("x-amz-object-lock-legal-hold".to_string(), "ON".to_string());
let retention = get_object_retention_meta(&meta);
assert_eq!(
retention.mode.as_ref().map(|mode| mode.as_str()),
Some(ObjectLockRetentionMode::COMPLIANCE)
);
assert_eq!(retention.mode.as_ref().map(|mode| mode.as_str()), Some(RetentionMode::COMPLIANCE));
assert!(retention.retain_until_date.is_some(), "persisted retention date must remain readable");
let legal_hold = get_object_legalhold_meta(&meta);
assert_eq!(
legal_hold.status.as_ref().map(|status| status.as_str()),
Some(ObjectLockLegalHoldStatus::ON)
);
assert_eq!(legal_hold.status.as_ref().map(|status| status.as_str()), Some(LegalHoldStatus::ON));
}
#[test]
@@ -236,7 +215,7 @@ mod tests {
meta.insert("x-amz-object-lock-legal-hold".to_string(), "ON".to_string());
let legalhold = get_object_legalhold_meta(&meta);
assert!(legalhold.status.is_some());
assert_eq!(legalhold.status.unwrap().as_str(), ObjectLockLegalHoldStatus::ON);
assert_eq!(legalhold.status.unwrap().as_str(), LegalHoldStatus::ON);
}
#[test]
@@ -245,7 +224,7 @@ mod tests {
meta.insert("x-amz-object-lock-legal-hold".to_string(), "OFF".to_string());
let legalhold = get_object_legalhold_meta(&meta);
assert!(legalhold.status.is_some());
assert_eq!(legalhold.status.unwrap().as_str(), ObjectLockLegalHoldStatus::OFF);
assert_eq!(legalhold.status.unwrap().as_str(), LegalHoldStatus::OFF);
}
#[test]
@@ -12,12 +12,16 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use crate::bucket::metadata_sys::{ObjectLockConfigState, get_object_lock_config, get_object_lock_config_state};
use crate::bucket::metadata_sys::{
ObjectLockConfigState, default_retention_from_object_lock_config, get_object_lock_config, get_object_lock_config_state,
};
use crate::bucket::object_lock::objectlock;
use crate::bucket::object_lock::types::{DefaultRetention, LegalHoldStatus, RetentionMode};
use crate::error::{Error, Result, StorageError};
use crate::object_api::{ObjectInfo, ObjectOptions};
use s3s::dto::{Date, DefaultRetention, ObjectLockConfiguration, ObjectLockLegalHoldStatus, ObjectLockRetentionMode};
use s3s::header::{X_AMZ_OBJECT_LOCK_LEGAL_HOLD, X_AMZ_OBJECT_LOCK_MODE, X_AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE};
use rustfs_utils::http::headers::{
AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER, AMZ_OBJECT_LOCK_MODE_LOWER, AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER,
};
use std::sync::Arc;
use time::OffsetDateTime;
@@ -29,11 +33,12 @@ impl BucketObjectLockSys {
Arc::new(Self {})
}
/// The bucket's active default retention, if the bucket has an
/// authoritative Object Lock configuration with a usable
/// GOVERNANCE/COMPLIANCE default retention rule.
pub async fn get(bucket: &str) -> Option<DefaultRetention> {
if let Ok(object_lock_config) = get_object_lock_config(bucket).await
&& let Some(object_lock_rule) = object_lock_config.0.rule
{
return object_lock_rule.default_retention;
if let Ok(object_lock_config) = get_object_lock_config(bucket).await {
return default_retention_from_object_lock_config(&object_lock_config.0);
}
None
}
@@ -54,13 +59,10 @@ pub(crate) fn ensure_recursive_force_delete_allowed_for_state(bucket: &str, stat
}
/// Check if a retention period is still active based on mode and retain_until_date
pub fn is_retention_active(mode: &str, retain_until_date: Option<&s3s::dto::Date>) -> bool {
if mode != ObjectLockRetentionMode::COMPLIANCE && mode != ObjectLockRetentionMode::GOVERNANCE {
return false;
}
pub fn is_retention_active(_mode: RetentionMode, retain_until_date: Option<OffsetDateTime>) -> bool {
if let Some(retain_until) = retain_until_date {
let now = objectlock::utc_now_ntp();
return OffsetDateTime::from(retain_until.clone()).unix_timestamp() > now.unix_timestamp();
return retain_until.unix_timestamp() > now.unix_timestamp();
}
false
}
@@ -68,23 +70,20 @@ pub fn is_retention_active(mode: &str, retain_until_date: Option<&s3s::dto::Date
/// Check if retention modification is blocked for the given object.
pub fn check_retention_for_modification(
user_defined: &std::collections::HashMap<String, String>,
new_mode: Option<&str>,
new_mode: Option<RetentionMode>,
new_retain_until: Option<OffsetDateTime>,
bypass_governance: bool,
) -> Option<ObjectLockBlockReason> {
let retention = objectlock::get_object_retention_meta(user_defined);
let Some(mode) = &retention.mode else {
return None;
};
let mode = retention.mode?;
let mode_str = mode.as_str();
if !is_retention_active(mode_str, retention.retain_until_date.as_ref()) {
if !is_retention_active(mode, retention.retain_until_date) {
return None;
}
let existing_retain_until = retention.retain_until_date.as_ref().map(|d| OffsetDateTime::from(d.clone()));
let mode_changed = new_mode != Some(mode_str);
let existing_retain_until = retention.retain_until_date;
let mode_changed = new_mode != Some(mode);
// Check if new retention period is shorter than existing
let is_shortening = match (&existing_retain_until, &new_retain_until) {
@@ -93,35 +92,34 @@ pub fn check_retention_for_modification(
_ => false,
};
// COMPLIANCE mode: cannot shorten retention at all (even with bypass)
// Can only extend the retention period
if mode_str == ObjectLockRetentionMode::COMPLIANCE {
if mode_changed || is_shortening {
return Some(ObjectLockBlockReason::Retention {
mode: mode_str.to_string(),
retain_until: existing_retain_until,
});
match mode {
// COMPLIANCE mode: cannot shorten retention at all (even with bypass)
// Can only extend the retention period
RetentionMode::Compliance => {
if mode_changed || is_shortening {
return Some(ObjectLockBlockReason::Retention {
mode,
retain_until: existing_retain_until,
});
}
// Extending retention in COMPLIANCE mode is allowed
None
}
// Extending retention in COMPLIANCE mode is allowed
return None;
}
// GOVERNANCE mode: extending is always allowed, shortening requires bypass
// This matches AWS S3 behavior where:
// - Extending retention: allowed without bypass permission
// - Shortening/removing retention: requires bypass permission
if mode_str == ObjectLockRetentionMode::GOVERNANCE {
if (mode_changed || is_shortening) && !bypass_governance {
return Some(ObjectLockBlockReason::Retention {
mode: mode_str.to_string(),
retain_until: existing_retain_until,
});
// GOVERNANCE mode: extending is always allowed, shortening requires bypass
// This matches AWS S3 behavior where:
// - Extending retention: allowed without bypass permission
// - Shortening/removing retention: requires bypass permission
RetentionMode::Governance => {
if (mode_changed || is_shortening) && !bypass_governance {
return Some(ObjectLockBlockReason::Retention {
mode,
retain_until: existing_retain_until,
});
}
// Extending retention or shortening with bypass is allowed
None
}
// Extending retention or shortening with bypass is allowed
return None;
}
None
}
pub fn add_years(dt: OffsetDateTime, years: i32) -> OffsetDateTime {
@@ -137,8 +135,7 @@ pub fn add_years(dt: OffsetDateTime, years: i32) -> OffsetDateTime {
/// Check if an object has legal hold enabled.
/// Returns true if legal hold is ON.
fn has_legal_hold(user_defined: &std::collections::HashMap<String, String>) -> bool {
let lhold = objectlock::get_object_legalhold_meta(user_defined);
matches!(lhold.status, Some(ref st) if st.as_str() == ObjectLockLegalHoldStatus::ON)
objectlock::get_object_legalhold_meta(user_defined).is_on()
}
/// Whether an authorized replication write (`ObjectOptions::replication_request`)
@@ -172,11 +169,11 @@ pub fn replication_write_may_pass_worm_gate(
// Delete markers are never locked (same as the WORM gate).
return Ok(true);
}
let config = object_lock_config_from_state(state)?;
let default_retention = default_retention_from_state(state)?;
if legal_hold_locks(obj_info)? && opts.replication_legalhold_timestamp.is_none() {
return Ok(false);
}
let retention_locked = active_retention(config, obj_info)?.is_some();
let retention_locked = active_retention(default_retention.as_ref(), obj_info)?.is_some();
Ok(!(retention_locked && opts.replication_retention_timestamp.is_none()))
}
@@ -204,8 +201,8 @@ pub fn is_object_locked_by_metadata(user_defined: &std::collections::HashMap<Str
// Check retention - reuse is_retention_active to avoid code duplication
let ret = objectlock::get_object_retention_meta(user_defined);
if let Some(mode) = &ret.mode
&& is_retention_active(mode.as_str(), ret.retain_until_date.as_ref())
if let Some(mode) = ret.mode
&& is_retention_active(mode, ret.retain_until_date)
{
return true;
}
@@ -220,7 +217,7 @@ pub enum ObjectLockBlockReason {
LegalHold,
/// Object is under retention until the specified date
Retention {
mode: String,
mode: RetentionMode,
retain_until: Option<OffsetDateTime>,
},
}
@@ -246,30 +243,28 @@ impl ObjectLockBlockReason {
/// Check if retention blocks deletion based on mode and bypass permission.
/// Returns Some(ObjectLockBlockReason) if blocked, None if allowed.
fn check_retention_blocks_deletion(
mode_str: &str,
mode: RetentionMode,
retain_until: Option<OffsetDateTime>,
bypass_governance: bool,
) -> Option<ObjectLockBlockReason> {
// COMPLIANCE mode cannot be bypassed; GOVERNANCE can only be bypassed with permission
let can_bypass = mode_str == ObjectLockRetentionMode::GOVERNANCE && bypass_governance;
let can_bypass = mode == RetentionMode::Governance && bypass_governance;
if !can_bypass {
return Some(ObjectLockBlockReason::Retention {
mode: mode_str.to_string(),
retain_until,
});
return Some(ObjectLockBlockReason::Retention { mode, retain_until });
}
None
}
/// Check an object's lock metadata using an already resolved bucket Object
/// Lock configuration. `None` means the configuration is confirmed absent.
/// Check an object's lock metadata using an already resolved bucket default
/// retention. `None` means the bucket configuration is confirmed absent or
/// carries no usable default retention rule.
///
/// # S3 Standard Behavior
/// - COMPLIANCE mode: Cannot be deleted even with bypass header
/// - GOVERNANCE mode: Can be deleted if bypass_governance is true (caller must verify s3:BypassGovernanceRetention permission)
/// - Legal Hold: Cannot be bypassed regardless of mode
pub(crate) fn check_object_lock_for_deletion_with_config(
config: Option<&ObjectLockConfiguration>,
pub(crate) fn check_object_lock_for_deletion_with_default_retention(
default_retention: Option<&DefaultRetention>,
obj_info: &ObjectInfo,
bypass_governance: bool,
) -> Result<Option<ObjectLockBlockReason>> {
@@ -281,8 +276,8 @@ pub(crate) fn check_object_lock_for_deletion_with_config(
return Ok(Some(ObjectLockBlockReason::LegalHold));
}
if let Some((mode_str, retain_until)) = active_retention(config, obj_info)?
&& let Some(reason) = check_retention_blocks_deletion(mode_str, Some(retain_until), bypass_governance)
if let Some((mode, retain_until)) = active_retention(default_retention, obj_info)?
&& let Some(reason) = check_retention_blocks_deletion(mode, Some(retain_until), bypass_governance)
{
return Ok(Some(reason));
}
@@ -300,56 +295,41 @@ fn persisted_lock_value<'a>(obj_info: &'a ObjectInfo, key: &str) -> Option<&'a S
/// Whether the version's persisted legal hold is ON. Any other non-empty
/// value than ON/OFF is malformed metadata and fails closed.
fn legal_hold_locks(obj_info: &ObjectInfo) -> Result<bool> {
let Some(status) = persisted_lock_value(obj_info, X_AMZ_OBJECT_LOCK_LEGAL_HOLD.as_str()) else {
let Some(status) = persisted_lock_value(obj_info, AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER) else {
return Ok(false);
};
if status.eq_ignore_ascii_case(ObjectLockLegalHoldStatus::ON) {
return Ok(true);
match LegalHoldStatus::parse(status) {
Some(LegalHoldStatus::On) => Ok(true),
Some(LegalHoldStatus::Off) => Ok(false),
None => Err(Error::other("persisted object legal-hold metadata is invalid")),
}
if !status.eq_ignore_ascii_case(ObjectLockLegalHoldStatus::OFF) {
return Err(Error::other("persisted object legal-hold metadata is invalid"));
}
Ok(false)
}
/// The retention that currently locks the version, if any: the explicit
/// persisted retention when the keys are present, otherwise the bucket
/// default retention computed from the version's modification time. Returns
/// `(mode, retain_until)` only while the retention is still active.
fn active_retention<'a>(
config: Option<&'a ObjectLockConfiguration>,
fn active_retention(
default_retention: Option<&DefaultRetention>,
obj_info: &ObjectInfo,
) -> Result<Option<(&'a str, OffsetDateTime)>> {
let mode = persisted_lock_value(obj_info, X_AMZ_OBJECT_LOCK_MODE.as_str());
let retain_until = persisted_lock_value(obj_info, X_AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE.as_str());
) -> Result<Option<(RetentionMode, OffsetDateTime)>> {
let mode = persisted_lock_value(obj_info, AMZ_OBJECT_LOCK_MODE_LOWER);
let retain_until = persisted_lock_value(obj_info, AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER);
match (mode, retain_until) {
(None, None) => {}
(Some(mode), Some(retain_until)) => {
let mode =
objectlock::parse_ret_mode(mode).ok_or_else(|| Error::other("persisted object retention mode is invalid"))?;
let retain_until = OffsetDateTime::parse(retain_until, &time::format_description::well_known::Iso8601::DEFAULT)
.map(Date::from)
.map_err(|_| Error::other("persisted object retention date is invalid"))?;
let mode_str = match mode.as_str() {
ObjectLockRetentionMode::COMPLIANCE => ObjectLockRetentionMode::COMPLIANCE,
ObjectLockRetentionMode::GOVERNANCE => ObjectLockRetentionMode::GOVERNANCE,
_ => return Err(Error::other("persisted object retention mode is invalid")),
};
return Ok(is_retention_active(mode_str, Some(&retain_until)).then(|| (mode_str, OffsetDateTime::from(retain_until))));
return Ok(is_retention_active(mode, Some(retain_until)).then_some((mode, retain_until)));
}
_ => return Err(Error::other("persisted object retention metadata is incomplete")),
}
let Some(default_retention) = config.and_then(|config| config.rule.as_ref()?.default_retention.as_ref()) else {
let Some(default_retention) = default_retention else {
return Ok(None);
};
let Some(mode) = &default_retention.mode else {
return Ok(None);
};
let mode_str = mode.as_str();
if mode_str != ObjectLockRetentionMode::COMPLIANCE && mode_str != ObjectLockRetentionMode::GOVERNANCE {
return Ok(None);
}
// Calculate retention expiration date from object modification time
let mod_time = obj_info
.mod_time
@@ -363,12 +343,16 @@ fn active_retention<'a>(
.ok_or_else(|| Error::other("persisted bucket Object Lock retention period is invalid"))?;
add_years(mod_time, years)
};
Ok((retain_until.unix_timestamp() > now.unix_timestamp()).then_some((mode_str, retain_until)))
Ok((retain_until.unix_timestamp() > now.unix_timestamp()).then_some((default_retention.mode, retain_until)))
}
fn object_lock_config_from_state(state: &ObjectLockConfigState) -> Result<Option<&ObjectLockConfiguration>> {
/// The bucket default retention carried by an authoritative Object Lock
/// state. `ConfirmedAbsent` and a configuration without a usable default
/// retention rule are both `None`; a fabricated state is an error, never a
/// pass.
fn default_retention_from_state(state: &ObjectLockConfigState) -> Result<Option<DefaultRetention>> {
match state {
ObjectLockConfigState::Configured { config, .. } => Ok(Some(config)),
ObjectLockConfigState::Configured { config, .. } => Ok(default_retention_from_object_lock_config(config)),
ObjectLockConfigState::ConfirmedAbsent => Ok(None),
ObjectLockConfigState::Fabricated => Err(Error::other("bucket Object Lock metadata is not authoritative")),
}
@@ -379,7 +363,11 @@ pub(crate) fn check_object_lock_for_deletion_with_state(
obj_info: &ObjectInfo,
bypass_governance: bool,
) -> Result<Option<ObjectLockBlockReason>> {
check_object_lock_for_deletion_with_config(object_lock_config_from_state(state)?, obj_info, bypass_governance)
check_object_lock_for_deletion_with_default_retention(
default_retention_from_state(state)?.as_ref(),
obj_info,
bypass_governance,
)
}
/// Compatibility wrapper for callers that predate fallible metadata lookup.
@@ -402,7 +390,10 @@ pub async fn check_object_lock_for_deletion(
#[cfg(test)]
mod tests {
use super::*;
use s3s::dto::{ObjectLockEnabled, ObjectLockRule};
use crate::bucket::metadata_sys::configured_object_lock_state_for_tests;
use rustfs_utils::http::headers::{
AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER, AMZ_OBJECT_LOCK_MODE_LOWER, AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER,
};
use time::{Date, Month, PrimitiveDateTime, Time};
fn make_datetime(year: i32, month: u8, day: u8) -> OffsetDateTime {
@@ -411,51 +402,46 @@ mod tests {
PrimitiveDateTime::new(date, time).assume_utc()
}
fn default_retention_config(mode: &'static str) -> ObjectLockConfiguration {
ObjectLockConfiguration {
object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)),
rule: Some(ObjectLockRule {
default_retention: Some(DefaultRetention {
mode: Some(ObjectLockRetentionMode::from_static(mode)),
days: Some(30),
years: None,
}),
}),
fn default_retention(mode: RetentionMode) -> DefaultRetention {
DefaultRetention {
mode,
days: Some(30),
years: None,
}
}
#[test]
fn deletion_with_config_blocks_active_default_compliance_even_with_bypass() {
let config = default_retention_config(ObjectLockRetentionMode::COMPLIANCE);
let retention = default_retention(RetentionMode::Compliance);
let obj_info = ObjectInfo {
mod_time: Some(OffsetDateTime::now_utc()),
..Default::default()
};
let result = check_object_lock_for_deletion_with_config(Some(&config), &obj_info, true);
let result = check_object_lock_for_deletion_with_default_retention(Some(&retention), &obj_info, true);
assert!(matches!(result, Ok(Some(ObjectLockBlockReason::Retention { .. }))));
}
#[test]
fn deletion_with_config_allows_active_default_governance_with_bypass() {
let config = default_retention_config(ObjectLockRetentionMode::GOVERNANCE);
let retention = default_retention(RetentionMode::Governance);
let obj_info = ObjectInfo {
mod_time: Some(OffsetDateTime::now_utc()),
..Default::default()
};
assert!(matches!(
check_object_lock_for_deletion_with_config(Some(&config), &obj_info, true),
check_object_lock_for_deletion_with_default_retention(Some(&retention), &obj_info, true),
Ok(None)
));
}
#[test]
fn deletion_with_default_retention_rejects_missing_object_mod_time() {
let config = default_retention_config(ObjectLockRetentionMode::COMPLIANCE);
let retention = default_retention(RetentionMode::Compliance);
let err = check_object_lock_for_deletion_with_config(Some(&config), &ObjectInfo::default(), false)
let err = check_object_lock_for_deletion_with_default_retention(Some(&retention), &ObjectInfo::default(), false)
.expect_err("default retention needs an authoritative object modification time");
assert!(err.to_string().contains("modification time"));
@@ -465,7 +451,7 @@ mod tests {
fn deletion_with_confirmed_absence_still_blocks_explicit_compliance() {
let retain_until = OffsetDateTime::now_utc() + time::Duration::days(30);
let mut user_defined = std::collections::HashMap::new();
user_defined.insert("x-amz-object-lock-mode".to_string(), ObjectLockRetentionMode::COMPLIANCE.to_string());
user_defined.insert("x-amz-object-lock-mode".to_string(), RetentionMode::COMPLIANCE.to_string());
user_defined.insert(
"x-amz-object-lock-retain-until-date".to_string(),
retain_until
@@ -477,7 +463,7 @@ mod tests {
..Default::default()
};
let result = check_object_lock_for_deletion_with_config(None, &obj_info, true);
let result = check_object_lock_for_deletion_with_default_retention(None, &obj_info, true);
assert!(matches!(result, Ok(Some(ObjectLockBlockReason::Retention { .. }))));
}
@@ -501,16 +487,13 @@ mod tests {
#[test]
fn deletion_rejects_incomplete_persisted_retention_metadata() {
let mut user_defined = std::collections::HashMap::new();
user_defined.insert(
X_AMZ_OBJECT_LOCK_MODE.as_str().to_string(),
ObjectLockRetentionMode::COMPLIANCE.to_string(),
);
user_defined.insert(AMZ_OBJECT_LOCK_MODE_LOWER.to_string(), RetentionMode::COMPLIANCE.to_string());
let obj_info = ObjectInfo {
user_defined: Arc::new(user_defined),
..Default::default()
};
let err = check_object_lock_for_deletion_with_config(None, &obj_info, false)
let err = check_object_lock_for_deletion_with_default_retention(None, &obj_info, false)
.expect_err("mode without retain-until date must fail closed");
assert!(err.to_string().contains("incomplete"));
@@ -523,29 +506,24 @@ mod tests {
.expect("retain-until date should format");
let cases = [
("invalid mode", Some("INVALID"), Some(valid_date.as_str()), "retention mode"),
(
"invalid date",
Some(ObjectLockRetentionMode::COMPLIANCE),
Some("not-a-date"),
"retention date",
),
("invalid date", Some(RetentionMode::COMPLIANCE), Some("not-a-date"), "retention date"),
("date only", None, Some(valid_date.as_str()), "incomplete"),
];
for (case, mode, retain_until, expected) in cases {
let mut user_defined = std::collections::HashMap::new();
if let Some(mode) = mode {
user_defined.insert(X_AMZ_OBJECT_LOCK_MODE.as_str().to_string(), mode.to_string());
user_defined.insert(AMZ_OBJECT_LOCK_MODE_LOWER.to_string(), mode.to_string());
}
if let Some(retain_until) = retain_until {
user_defined.insert(X_AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE.as_str().to_string(), retain_until.to_string());
user_defined.insert(AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER.to_string(), retain_until.to_string());
}
let obj_info = ObjectInfo {
user_defined: Arc::new(user_defined),
..Default::default()
};
let err = check_object_lock_for_deletion_with_config(None, &obj_info, false).expect_err(case);
let err = check_object_lock_for_deletion_with_default_retention(None, &obj_info, false).expect_err(case);
assert!(err.to_string().contains(expected), "unexpected {case} error: {err}");
}
}
@@ -579,10 +557,6 @@ mod tests {
/// source timestamp of every category that currently locks the version.
#[test]
fn replication_write_passes_worm_gate_only_with_every_locking_category_timestamp() {
use rustfs_utils::http::headers::{
AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER, AMZ_OBJECT_LOCK_MODE_LOWER, AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER,
};
let hold = [(AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER, "ON")];
let retention = [
(AMZ_OBJECT_LOCK_MODE_LOWER, "GOVERNANCE"),
@@ -623,19 +597,15 @@ mod tests {
}
/// The bucket default retention locks a version that carries no explicit
/// retention keys (`check_object_lock_for_deletion_with_config` judges it
/// from the modification time), so the replication bypass must demand the
/// retention source timestamp for it too — a tagging-only replication
/// write must not overwrite the default-protected version unjudged.
/// retention keys (`check_object_lock_for_deletion_with_default_retention`
/// judges it from the modification time), so the replication bypass must
/// demand the retention source timestamp for it too — a tagging-only
/// replication write must not overwrite the default-protected version
/// unjudged.
#[test]
fn replication_write_under_bucket_default_retention_requires_retention_timestamp() {
use rustfs_utils::http::headers::{AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER, AMZ_OBJECT_LOCK_MODE_LOWER};
for mode in [ObjectLockRetentionMode::COMPLIANCE, ObjectLockRetentionMode::GOVERNANCE] {
let state = ObjectLockConfigState::Configured {
config: default_retention_config(mode),
updated_at: OffsetDateTime::now_utc(),
};
for mode in [RetentionMode::Compliance, RetentionMode::Governance] {
let state = configured_object_lock_state_for_tests(mode, 30);
let no_keys = lock_object_info(std::collections::HashMap::new());
assert!(
check_object_lock_for_deletion_with_state(&state, &no_keys, false)
@@ -689,8 +659,6 @@ mod tests {
/// state or malformed persisted lock metadata; both are errors, not a pass.
#[test]
fn replication_write_worm_gate_fails_closed_on_unverifiable_lock_state() {
use rustfs_utils::http::headers::AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER;
let opts = replication_opts(true, true);
let err = replication_write_may_pass_worm_gate(
&ObjectLockConfigState::Fabricated,
@@ -705,10 +673,7 @@ mod tests {
.expect_err("malformed legal hold must not be judged");
assert!(err.to_string().contains("legal-hold"));
let state = ObjectLockConfigState::Configured {
config: default_retention_config(ObjectLockRetentionMode::COMPLIANCE),
updated_at: OffsetDateTime::now_utc(),
};
let state = configured_object_lock_state_for_tests(RetentionMode::Compliance, 30);
let no_mod_time = ObjectInfo::default();
let err = replication_write_may_pass_worm_gate(&state, &no_mod_time, &opts)
.expect_err("default retention without a modification time must not be judged");
@@ -722,10 +687,6 @@ mod tests {
/// (rustfs/backlog#1953).
#[test]
fn deletion_treats_cleared_empty_lock_metadata_as_unlocked() {
use rustfs_utils::http::headers::{
AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER, AMZ_OBJECT_LOCK_MODE_LOWER, AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER,
};
let cases: [(&str, &[&str]); 3] = [
(
"cleared retention",
@@ -749,7 +710,7 @@ mod tests {
..Default::default()
};
let result = check_object_lock_for_deletion_with_config(None, &obj_info, false);
let result = check_object_lock_for_deletion_with_default_retention(None, &obj_info, false);
assert!(matches!(result, Ok(None)), "{case}: empty lock keys must read as unlocked: {result:?}");
}
}
@@ -757,13 +718,13 @@ mod tests {
#[test]
fn deletion_rejects_invalid_persisted_legal_hold_metadata() {
let mut user_defined = std::collections::HashMap::new();
user_defined.insert(X_AMZ_OBJECT_LOCK_LEGAL_HOLD.as_str().to_string(), "INVALID".to_string());
user_defined.insert(AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER.to_string(), "INVALID".to_string());
let obj_info = ObjectInfo {
user_defined: Arc::new(user_defined),
..Default::default()
};
let err = check_object_lock_for_deletion_with_config(None, &obj_info, false)
let err = check_object_lock_for_deletion_with_default_retention(None, &obj_info, false)
.expect_err("invalid legal-hold value must fail closed");
assert!(err.to_string().contains("legal-hold"));
@@ -829,42 +790,29 @@ mod tests {
assert_eq!(result.day(), 4);
}
#[test]
fn test_is_retention_active_invalid_mode() {
// Invalid mode should return false
assert!(!is_retention_active("INVALID", None));
assert!(!is_retention_active("", None));
}
#[test]
fn test_is_retention_active_no_date() {
// Valid mode but no retain_until_date should return false
assert!(!is_retention_active(ObjectLockRetentionMode::COMPLIANCE, None));
assert!(!is_retention_active(ObjectLockRetentionMode::GOVERNANCE, None));
assert!(!is_retention_active(RetentionMode::Compliance, None));
assert!(!is_retention_active(RetentionMode::Governance, None));
}
#[test]
fn test_is_retention_active_future_date() {
// Valid mode with future retain_until_date should return true
let future_date = OffsetDateTime::now_utc() + time::Duration::days(30);
let s3_date = s3s::dto::Date::from(future_date);
assert!(is_retention_active(ObjectLockRetentionMode::COMPLIANCE, Some(&s3_date)));
let future_date = OffsetDateTime::now_utc() + time::Duration::days(30);
let s3_date = s3s::dto::Date::from(future_date);
assert!(is_retention_active(ObjectLockRetentionMode::GOVERNANCE, Some(&s3_date)));
assert!(is_retention_active(RetentionMode::Compliance, Some(future_date)));
assert!(is_retention_active(RetentionMode::Governance, Some(future_date)));
}
#[test]
fn test_is_retention_active_past_date() {
// Valid mode with past retain_until_date should return false
let past_date = OffsetDateTime::now_utc() - time::Duration::days(30);
let s3_date = s3s::dto::Date::from(past_date);
assert!(!is_retention_active(ObjectLockRetentionMode::COMPLIANCE, Some(&s3_date)));
let past_date = OffsetDateTime::now_utc() - time::Duration::days(30);
let s3_date = s3s::dto::Date::from(past_date);
assert!(!is_retention_active(ObjectLockRetentionMode::GOVERNANCE, Some(&s3_date)));
assert!(!is_retention_active(RetentionMode::Compliance, Some(past_date)));
assert!(!is_retention_active(RetentionMode::Governance, Some(past_date)));
}
#[test]
@@ -890,10 +838,7 @@ mod tests {
// Extending by another 30 days should be allowed
let new_retain = Some(existing_retain + time::Duration::days(30));
assert!(
check_retention_for_modification(&user_defined, Some(ObjectLockRetentionMode::COMPLIANCE), new_retain, false)
.is_none()
);
assert!(check_retention_for_modification(&user_defined, Some(RetentionMode::Compliance), new_retain, false).is_none());
}
#[test]
@@ -911,8 +856,7 @@ mod tests {
// Shortening to 30 days should be blocked
let new_retain = Some(OffsetDateTime::now_utc() + time::Duration::days(30));
let result =
check_retention_for_modification(&user_defined, Some(ObjectLockRetentionMode::COMPLIANCE), new_retain, false);
let result = check_retention_for_modification(&user_defined, Some(RetentionMode::Compliance), new_retain, false);
assert!(result.is_some());
assert!(matches!(result, Some(ObjectLockBlockReason::Retention { .. })));
}
@@ -950,8 +894,7 @@ mod tests {
// Shortening from 30 days to 15 days without bypass should be blocked
let new_retain = Some(OffsetDateTime::now_utc() + time::Duration::days(15));
let result =
check_retention_for_modification(&user_defined, Some(ObjectLockRetentionMode::GOVERNANCE), new_retain, false);
let result = check_retention_for_modification(&user_defined, Some(RetentionMode::Governance), new_retain, false);
assert!(result.is_some());
}
@@ -971,10 +914,7 @@ mod tests {
// Extending from 30 days to 60 days without bypass should be allowed
let new_retain = Some(OffsetDateTime::now_utc() + time::Duration::days(60));
assert!(
check_retention_for_modification(&user_defined, Some(ObjectLockRetentionMode::GOVERNANCE), new_retain, false)
.is_none()
);
assert!(check_retention_for_modification(&user_defined, Some(RetentionMode::Governance), new_retain, false).is_none());
}
#[test]
@@ -992,10 +932,7 @@ mod tests {
// Shortening from 30 days to 15 days with bypass should be allowed
let new_retain = Some(OffsetDateTime::now_utc() + time::Duration::days(15));
assert!(
check_retention_for_modification(&user_defined, Some(ObjectLockRetentionMode::GOVERNANCE), new_retain, true)
.is_none()
);
assert!(check_retention_for_modification(&user_defined, Some(RetentionMode::Governance), new_retain, true).is_none());
}
#[test]
@@ -1010,12 +947,8 @@ mod tests {
.unwrap(),
);
let result = check_retention_for_modification(
&user_defined,
Some(ObjectLockRetentionMode::COMPLIANCE),
Some(existing_retain),
false,
);
let result =
check_retention_for_modification(&user_defined, Some(RetentionMode::Compliance), Some(existing_retain), false);
assert!(result.is_some());
}
@@ -1032,13 +965,8 @@ mod tests {
);
assert!(
check_retention_for_modification(
&user_defined,
Some(ObjectLockRetentionMode::COMPLIANCE),
Some(existing_retain),
true,
)
.is_none()
check_retention_for_modification(&user_defined, Some(RetentionMode::Compliance), Some(existing_retain), true)
.is_none()
);
}
@@ -1054,12 +982,8 @@ mod tests {
.unwrap(),
);
let result = check_retention_for_modification(
&user_defined,
Some(ObjectLockRetentionMode::GOVERNANCE),
Some(existing_retain),
true,
);
let result =
check_retention_for_modification(&user_defined, Some(RetentionMode::Governance), Some(existing_retain), true);
assert!(result.is_some());
}
@@ -0,0 +1,179 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Storage-level Object Lock types (rustfs/backlog#1842).
//!
//! The engine evaluates WORM state from persisted object metadata and the
//! bucket default retention; none of that needs S3 wire/DTO types. The
//! serving layer converts to/from its wire DTOs at its own boundary, and the
//! bucket-metadata module converts the persisted `ObjectLockConfiguration`
//! into [`DefaultRetention`] when handing it to the evaluation code here.
use std::fmt;
use time::OffsetDateTime;
/// Object Lock retention mode. Persisted metadata and the bucket default
/// retention only ever carry these two values; anything else is either
/// malformed metadata (fail-closed at the parse site) or an inactive
/// configuration (ignored at the conversion site).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RetentionMode {
Governance,
Compliance,
}
impl RetentionMode {
pub const GOVERNANCE: &'static str = "GOVERNANCE";
pub const COMPLIANCE: &'static str = "COMPLIANCE";
/// Parse the canonical S3 wire spelling, case-insensitively (matching the
/// historical `parse_ret_mode` behavior). Returns `None` for anything
/// that is not GOVERNANCE/COMPLIANCE.
pub fn parse(value: &str) -> Option<Self> {
if value.eq_ignore_ascii_case(Self::GOVERNANCE) {
Some(Self::Governance)
} else if value.eq_ignore_ascii_case(Self::COMPLIANCE) {
Some(Self::Compliance)
} else {
None
}
}
/// Parse only the exact canonical wire spelling. Use this for a mode a
/// caller supplies in a *request*: the retention-modification gate has
/// always compared the requested mode literally against the canonical
/// persisted mode, so a non-canonical spelling must stay "not the same
/// mode" (and therefore blocked), not be normalized into a match.
pub fn parse_exact(value: &str) -> Option<Self> {
match value {
Self::GOVERNANCE => Some(Self::Governance),
Self::COMPLIANCE => Some(Self::Compliance),
_ => None,
}
}
pub fn as_str(&self) -> &'static str {
match self {
Self::Governance => Self::GOVERNANCE,
Self::Compliance => Self::COMPLIANCE,
}
}
}
impl fmt::Display for RetentionMode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
/// Object Lock legal hold status (ON/OFF).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LegalHoldStatus {
On,
Off,
}
impl LegalHoldStatus {
pub const ON: &'static str = "ON";
pub const OFF: &'static str = "OFF";
/// Parse the canonical S3 wire spelling, case-insensitively (matching the
/// historical `parse_legalhold_status` behavior).
pub fn parse(value: &str) -> Option<Self> {
if value.eq_ignore_ascii_case(Self::ON) {
Some(Self::On)
} else if value.eq_ignore_ascii_case(Self::OFF) {
Some(Self::Off)
} else {
None
}
}
pub fn as_str(&self) -> &'static str {
match self {
Self::On => Self::ON,
Self::Off => Self::OFF,
}
}
}
impl fmt::Display for LegalHoldStatus {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
/// An object version's retention as read from persisted metadata. `mode` is
/// `None` when the metadata carries no (or an unparsable) retention mode.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ObjectRetention {
pub mode: Option<RetentionMode>,
pub retain_until_date: Option<OffsetDateTime>,
}
/// An object version's legal hold as read from persisted metadata. `status`
/// is `None` when the metadata carries no (or an unparsable) legal hold.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ObjectLegalHold {
pub status: Option<LegalHoldStatus>,
}
impl ObjectLegalHold {
pub fn is_on(&self) -> bool {
self.status == Some(LegalHoldStatus::On)
}
}
/// The bucket's default Object Lock retention, converted from the persisted
/// configuration. Conversion only yields a value for an active default
/// retention (a valid GOVERNANCE/COMPLIANCE mode); a rule without a usable
/// mode converts to `None`, matching how the evaluation code has always
/// ignored such rules.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DefaultRetention {
pub mode: RetentionMode,
pub days: Option<i32>,
pub years: Option<i32>,
}
#[cfg(test)]
mod tests {
use super::*;
/// The modification gate compares a *requested* mode against the canonical
/// persisted mode literally: a non-canonical spelling must not normalize
/// into a match, or a client could shorten GOVERNANCE retention without
/// bypass by spelling the mode differently. `parse_exact` is that pin.
#[test]
fn parse_exact_accepts_only_canonical_spellings() {
assert_eq!(RetentionMode::parse_exact("GOVERNANCE"), Some(RetentionMode::Governance));
assert_eq!(RetentionMode::parse_exact("COMPLIANCE"), Some(RetentionMode::Compliance));
for non_canonical in ["governance", "Governance", "compliance", "Compliance", "", "INVALID"] {
assert_eq!(RetentionMode::parse_exact(non_canonical), None, "{non_canonical:?} must not parse");
}
}
/// Persisted metadata parsing stays case-insensitive (the historical
/// `parse_ret_mode` / `parse_legalhold_status` behavior): on-disk values
/// written by older builds must keep locking.
#[test]
fn parse_is_case_insensitive_for_persisted_values() {
assert_eq!(RetentionMode::parse("governance"), Some(RetentionMode::Governance));
assert_eq!(RetentionMode::parse("Compliance"), Some(RetentionMode::Compliance));
assert_eq!(LegalHoldStatus::parse("on"), Some(LegalHoldStatus::On));
assert_eq!(LegalHoldStatus::parse("Off"), Some(LegalHoldStatus::Off));
assert_eq!(RetentionMode::parse("INVALID"), None);
assert_eq!(LegalHoldStatus::parse("MAYBE"), None);
}
}
+5 -5
View File
@@ -15,8 +15,8 @@
use super::{BucketQuota, QuotaCheckResult, QuotaError, QuotaOperation};
use crate::bucket::metadata_sys::{BucketMetadataSys, update, update_if_incarnation};
use crate::data_usage::get_bucket_usage_memory;
use rustfs_common::metrics::Metric;
use rustfs_config::QUOTA_CONFIG_FILE;
use rustfs_scanner_contracts::metrics::Metric;
use std::sync::Arc;
use std::time::Instant;
use time::OffsetDateTime;
@@ -120,9 +120,9 @@ impl QuotaChecker {
let duration = start_time.elapsed();
// inc_time is now a plain fn (not async) — no .await needed.
rustfs_common::metrics::Metrics::inc_time(Metric::QuotaCheck, duration);
rustfs_scanner_contracts::metrics::Metrics::inc_time(Metric::QuotaCheck, duration);
if !allowed {
rustfs_common::metrics::Metrics::inc_time(Metric::QuotaViolation, duration);
rustfs_scanner_contracts::metrics::Metrics::inc_time(Metric::QuotaViolation, duration);
}
Ok(result)
@@ -185,7 +185,7 @@ impl QuotaChecker {
.await
.map_err(QuotaError::StorageError)?;
rustfs_common::metrics::Metrics::inc_time(Metric::QuotaSync, start_time.elapsed());
rustfs_scanner_contracts::metrics::Metrics::inc_time(Metric::QuotaSync, start_time.elapsed());
Ok(updated_at)
}
@@ -206,7 +206,7 @@ impl QuotaChecker {
}
.map_err(QuotaError::StorageError)?;
rustfs_common::metrics::Metrics::inc_time(Metric::QuotaSync, start_time.elapsed());
rustfs_scanner_contracts::metrics::Metrics::inc_time(Metric::QuotaSync, start_time.elapsed());
Ok(updated_at)
}
@@ -18,7 +18,6 @@ use tokio_util::sync::CancellationToken;
use super::replication_error_boundary::Error;
use super::replication_filemeta_boundary::{replication_state_from_filemeta, version_purge_status_from_filemeta};
pub(crate) type ReplicationObjectStore = crate::store::ECStore;
pub(crate) use crate::client::api_get_options::{AdvancedGetOptions, StatObjectOptions};
pub(crate) use crate::object_api::{GetObjectReader, ObjectInfo, ObjectOptions, PutObjReader};
pub(crate) use crate::storage_api_contracts::list::{
ListOperations, StorageListObjectVersionsInfo, StorageListObjectsV2Info, StorageObjectInfoOrErr, StorageWalkOptions,
@@ -29,6 +28,7 @@ pub(crate) use crate::storage_api_contracts::object::{
};
pub(crate) use crate::storage_api_contracts::range::HTTPRangeSpec;
pub(crate) use rustfs_replication::{DeletedObject as ReplicationDeletedObject, ObjectToDelete as ReplicationObjectToDelete};
pub(crate) use rustfs_s3_client::api_get_options::{AdvancedGetOptions, StatObjectOptions};
type ListObjectsV2Info = StorageListObjectsV2Info<ObjectInfo>;
type ListObjectVersionsInfo = StorageListObjectVersionsInfo<ObjectInfo>;
@@ -200,7 +200,7 @@ impl ReplicationTargetStore {
}
pub(crate) fn replication_put_object_options(sc: &str, object_info: &ObjectInfo) -> Result<(PutObjectOptions, bool)> {
use base64::{Engine, engine::general_purpose::STANDARD as BASE64_STANDARD};
use base64_simd::STANDARD as BASE64_STANDARD;
use rustfs_utils::http::{AMZ_CHECKSUM_TYPE, AMZ_CHECKSUM_TYPE_FULL_OBJECT};
let mut meta = HashMap::new();
@@ -252,7 +252,7 @@ pub(crate) fn replication_put_object_options(sc: &str, object_info: &ObjectInfo)
&& !checksum_data.is_empty()
{
if is_ssec {
let encoded = BASE64_STANDARD.encode(checksum_data);
let encoded = BASE64_STANDARD.encode_to_string(checksum_data);
insert_header_map(&mut meta, SUFFIX_REPLICATION_SSEC_CRC, encoded);
} else if object_info.is_encrypted() {
// Encrypted checksums cannot be exposed as plaintext headers, and
-27
View File
@@ -1,27 +0,0 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// The S3-consuming client moved to the `rustfs-s3-client` crate
// (rustfs/backlog#1842). This shim keeps `crate::client::*` paths working for
// in-crate consumers during the migration window; it is deleted once every
// consumer imports `rustfs_s3_client` directly. Only the two server-side
// modules below (misfiled here historically) remain as real ecstore code.
pub use rustfs_s3_client::{
admin_handler_utils, api_get_options, api_list, api_put_object, api_remove, api_s3_datatypes, credentials, provider_versions,
signer_error, transition_api,
};
pub mod object_api_utils;
pub mod object_handlers_common;
+15 -16
View File
@@ -31,8 +31,7 @@ use crate::storage_api_contracts::internode::{
NS_SCANNER_PROTOCOL_VERSION, PUT_FILE_AUTH_TRAILER_DIGEST_LEN, PUT_FILE_AUTH_TRAILER_LEN, PUT_FILE_AUTH_TRAILER_MAC_LEN,
PUT_FILE_AUTH_TRAILER_MAGIC, PUT_FILE_CAPABILITY_VERSION,
};
use base64::Engine as _;
use base64::engine::general_purpose;
use hmac::{Hmac, KeyInit, Mac};
use http::uri::Authority;
use http::{HeaderMap, HeaderValue, Method, Uri};
@@ -523,11 +522,11 @@ fn generate_signature(secret: &str, url: &str, method: &Method, timestamp: i64)
let mut mac = <HmacSha256 as KeyInit>::new_from_slice(secret.as_bytes()).expect("HMAC can take key of any size");
mac.update(data.as_bytes());
let result = mac.finalize();
general_purpose::STANDARD.encode(result.into_bytes())
base64_simd::STANDARD.encode_to_string(result.into_bytes())
}
fn verify_signature(secret: &str, url: &str, method: &Method, timestamp: i64, signature: &str) -> bool {
let Ok(signature) = general_purpose::STANDARD.decode(signature) else {
let Ok(signature) = base64_simd::STANDARD.decode_to_vec(signature) else {
return false;
};
@@ -745,11 +744,11 @@ fn generate_signature_v2(secret: &str, scope: SignatureV2Scope<'_>) -> std::io::
let mut mac =
<HmacSha256 as KeyInit>::new_from_slice(secret.as_bytes()).map_err(|_| std::io::Error::other("Invalid RPC HMAC key"))?;
update_signature_v2(&mut mac, scope);
Ok(general_purpose::STANDARD.encode(mac.finalize().into_bytes()))
Ok(base64_simd::STANDARD.encode_to_string(mac.finalize().into_bytes()))
}
fn verify_signature_v2(secret: &str, scope: SignatureV2Scope<'_>, signature: &str) -> bool {
let Ok(signature) = general_purpose::STANDARD.decode(signature) else {
let Ok(signature) = base64_simd::STANDARD.decode_to_vec(signature) else {
return false;
};
let Ok(mut mac) = <HmacSha256 as KeyInit>::new_from_slice(secret.as_bytes()) else {
@@ -792,11 +791,11 @@ fn generate_replay_scope_signature(secret: &str, scope: ReplayScope<'_>) -> std:
let mut mac =
<HmacSha256 as KeyInit>::new_from_slice(secret.as_bytes()).map_err(|_| std::io::Error::other("Invalid RPC HMAC key"))?;
update_replay_scope(&mut mac, scope);
Ok(general_purpose::STANDARD.encode(mac.finalize().into_bytes()))
Ok(base64_simd::STANDARD.encode_to_string(mac.finalize().into_bytes()))
}
fn verify_replay_scope_signature(secret: &str, scope: ReplayScope<'_>, signature: &str) -> bool {
let Ok(signature) = general_purpose::STANDARD.decode(signature) else {
let Ok(signature) = base64_simd::STANDARD.decode_to_vec(signature) else {
return false;
};
let Ok(mut mac) = <HmacSha256 as KeyInit>::new_from_slice(secret.as_bytes()) else {
@@ -821,15 +820,15 @@ fn generate_boot_epoch_proof(secret: &str, audience: &str, challenge: Uuid, boot
let mut mac =
<HmacSha256 as KeyInit>::new_from_slice(secret.as_bytes()).map_err(|_| std::io::Error::other("Invalid RPC HMAC key"))?;
update_boot_epoch_proof(&mut mac, audience, challenge, boot_epoch);
Ok(general_purpose::STANDARD.encode(mac.finalize().into_bytes()))
Ok(base64_simd::STANDARD.encode_to_string(mac.finalize().into_bytes()))
}
fn verify_boot_epoch_proof(secret: &str, audience: &str, challenge: Uuid, boot_epoch: Uuid, proof: &str) -> std::io::Result<()> {
if audience.is_empty() || challenge.is_nil() || boot_epoch.is_nil() {
return Err(std::io::Error::other("Invalid RPC boot epoch proof scope"));
}
let proof = general_purpose::STANDARD
.decode(proof)
let proof = base64_simd::STANDARD
.decode_to_vec(proof)
.map_err(|_| std::io::Error::other("Invalid RPC boot epoch proof"))?;
let mut mac =
<HmacSha256 as KeyInit>::new_from_slice(secret.as_bytes()).map_err(|_| std::io::Error::other("Invalid RPC HMAC key"))?;
@@ -862,7 +861,7 @@ fn generate_replay_cache_capability_proof(
let mut mac =
<HmacSha256 as KeyInit>::new_from_slice(secret.as_bytes()).map_err(|_| std::io::Error::other("Invalid RPC HMAC key"))?;
update_replay_cache_capability_proof(&mut mac, audience, challenge, boot_epoch);
Ok(general_purpose::STANDARD.encode(mac.finalize().into_bytes()))
Ok(base64_simd::STANDARD.encode_to_string(mac.finalize().into_bytes()))
}
fn verify_replay_cache_capability_proof(
@@ -872,8 +871,8 @@ fn verify_replay_cache_capability_proof(
boot_epoch: Uuid,
proof: &str,
) -> std::io::Result<()> {
let proof = general_purpose::STANDARD
.decode(proof)
let proof = base64_simd::STANDARD
.decode_to_vec(proof)
.map_err(|_| std::io::Error::other("Invalid RPC replay cache capability proof"))?;
let mut mac =
<HmacSha256 as KeyInit>::new_from_slice(secret.as_bytes()).map_err(|_| std::io::Error::other("Invalid RPC HMAC key"))?;
@@ -1988,9 +1987,9 @@ mod tests {
let method = Method::GET;
let timestamp = 1640995200;
let signature = generate_signature(secret, url, &method, timestamp);
let mut tampered = general_purpose::STANDARD.decode(&signature).unwrap();
let mut tampered = base64_simd::STANDARD.decode_to_vec(&signature).unwrap();
tampered[0] ^= 1;
let tampered_signature = general_purpose::STANDARD.encode(tampered);
let tampered_signature = base64_simd::STANDARD.encode_to_string(tampered);
assert!(verify_signature(secret, url, &method, timestamp, &signature));
assert!(!verify_signature(secret, url, &method, timestamp, &tampered_signature));
@@ -113,6 +113,21 @@ fn peer_failure_without_details(op: &str, bucket: Option<&str>) -> Error {
}
}
/// Decode a control-plane response failure. Peers at or above the typed
/// `ControlPlaneErrorCode` change (backlog#1845) carry a machine-readable
/// discriminant beside the legacy `error_info` string; prefer it, then fall
/// back to the string, then to the detail-free per-op failure.
/// RUSTFS_COMPAT_TODO(not-initialized-error-code-v1): string fallback for peers that predate the typed wire code. Remove after the minimum supported RustFS peer version always sends error_code.
fn control_plane_failure(op: &str, bucket: Option<&str>, error_code: Option<i32>, error_info: Option<String>) -> Error {
if error_code == Some(rustfs_protos::proto_gen::node_service::ControlPlaneErrorCode::ControlPlaneErrorNotInitialized as i32) {
return Error::RemoteNotInitialized;
}
match error_info {
Some(msg) => Error::other(msg),
None => peer_failure_without_details(op, bucket),
}
}
fn decode_bucket_stats_response(response: GetBucketStatsDataResponse) -> Result<BucketStats> {
if !response.success {
return Err(Error::other(
@@ -845,10 +860,12 @@ impl PeerRestClient {
let response = client.local_storage_info(request).await?.into_inner();
if !response.success {
if let Some(msg) = response.error_info {
return Err(Error::other(msg));
}
return Err(peer_failure_without_details("local_storage_info", None));
return Err(control_plane_failure(
"local_storage_info",
None,
response.error_code,
response.error_info,
));
}
let data = response.storage_info;
@@ -1235,11 +1252,10 @@ impl PeerRestClient {
Err(status) => return Err(status.into()),
};
if !response.success {
return Err(Error::other(
response
.error_info
.unwrap_or_else(|| "peer background heal status failed without an error".to_string()),
));
return Err(match (response.error_code, response.error_info) {
(None, None) => Error::other("peer background heal status failed without an error"),
(error_code, error_info) => control_plane_failure("background_heal_status", None, error_code, error_info),
});
}
Ok(Some(response.bg_heal_state.to_vec()))
}
@@ -1267,11 +1283,12 @@ impl PeerRestClient {
Err(status) => return Err(status.into()),
};
if !response.success {
return Err(Error::other(
response
.error_info
.unwrap_or_else(|| "peer replacement recovery status failed without an error".to_string()),
));
return Err(match (response.error_code, response.error_info) {
(None, None) => Error::other("peer replacement recovery status failed without an error"),
(error_code, error_info) => {
control_plane_failure("replacement_recovery_status", None, error_code, error_info)
}
});
}
Ok(Some(response.recovery_status.to_vec()))
}
@@ -1489,10 +1506,12 @@ impl PeerRestClient {
let response = client.load_bucket_metadata(request).await?.into_inner();
if !response.success {
if let Some(msg) = response.error_info {
return Err(Error::other(msg));
}
return Err(peer_failure_without_details("load_bucket_metadata", Some(bucket)));
return Err(control_plane_failure(
"load_bucket_metadata",
Some(bucket),
response.error_code,
response.error_info,
));
}
Ok(())
}
@@ -1531,10 +1550,7 @@ impl PeerRestClient {
let response = client.delete_policy(request).await?.into_inner();
if !response.success {
if let Some(msg) = response.error_info {
return Err(Error::other(msg));
}
return Err(peer_failure_without_details("delete_policy", None));
return Err(control_plane_failure("delete_policy", None, response.error_code, response.error_info));
}
Ok(())
}
@@ -1554,10 +1570,7 @@ impl PeerRestClient {
let response = client.load_policy(request).await?.into_inner();
if !response.success {
if let Some(msg) = response.error_info {
return Err(Error::other(msg));
}
return Err(peer_failure_without_details("load_policy", None));
return Err(control_plane_failure("load_policy", None, response.error_code, response.error_info));
}
Ok(())
}
@@ -1579,10 +1592,12 @@ impl PeerRestClient {
let response = client.load_policy_mapping(request).await?.into_inner();
if !response.success {
if let Some(msg) = response.error_info {
return Err(Error::other(msg));
}
return Err(peer_failure_without_details("load_policy_mapping", None));
return Err(control_plane_failure(
"load_policy_mapping",
None,
response.error_code,
response.error_info,
));
}
Ok(())
}
@@ -1602,10 +1617,7 @@ impl PeerRestClient {
let response = client.delete_user(request).await?.into_inner();
if !response.success {
if let Some(msg) = response.error_info {
return Err(Error::other(msg));
}
return Err(peer_failure_without_details("delete_user", None));
return Err(control_plane_failure("delete_user", None, response.error_code, response.error_info));
}
Ok(())
}
@@ -1625,10 +1637,12 @@ impl PeerRestClient {
let response = client.delete_service_account(request).await?.into_inner();
if !response.success {
if let Some(msg) = response.error_info {
return Err(Error::other(msg));
}
return Err(peer_failure_without_details("delete_service_account", None));
return Err(control_plane_failure(
"delete_service_account",
None,
response.error_code,
response.error_info,
));
}
Ok(())
}
@@ -1649,10 +1663,7 @@ impl PeerRestClient {
let response = client.load_user(request).await?.into_inner();
if !response.success {
if let Some(msg) = response.error_info {
return Err(Error::other(msg));
}
return Err(peer_failure_without_details("load_user", None));
return Err(control_plane_failure("load_user", None, response.error_code, response.error_info));
}
Ok(())
}
@@ -1672,10 +1683,12 @@ impl PeerRestClient {
let response = client.load_service_account(request).await?.into_inner();
if !response.success {
if let Some(msg) = response.error_info {
return Err(Error::other(msg));
}
return Err(peer_failure_without_details("load_service_account", None));
return Err(control_plane_failure(
"load_service_account",
None,
response.error_code,
response.error_info,
));
}
Ok(())
}
@@ -1695,10 +1708,7 @@ impl PeerRestClient {
let response = client.load_group(request).await?.into_inner();
if !response.success {
if let Some(msg) = response.error_info {
return Err(Error::other(msg));
}
return Err(peer_failure_without_details("load_group", None));
return Err(control_plane_failure("load_group", None, response.error_code, response.error_info));
}
Ok(())
}
@@ -1716,10 +1726,12 @@ impl PeerRestClient {
let response = client.reload_site_replication_config(request).await?.into_inner();
if !response.success {
if let Some(msg) = response.error_info {
return Err(Error::other(msg));
}
return Err(peer_failure_without_details("reload_site_replication_config", None));
return Err(control_plane_failure(
"reload_site_replication_config",
None,
response.error_code,
response.error_info,
));
}
Ok(())
}
@@ -1987,10 +1999,7 @@ impl PeerRestClient {
let response = client.reload_pool_meta(request).await?.into_inner();
if !response.success {
if let Some(msg) = response.error_info {
return Err(Error::other(msg));
}
return Err(peer_failure_without_details("reload_pool_meta", None));
return Err(control_plane_failure("reload_pool_meta", None, response.error_code, response.error_info));
}
Ok(())
@@ -2011,10 +2020,7 @@ impl PeerRestClient {
let response = client.stop_rebalance(request).await?.into_inner();
if !response.success {
if let Some(msg) = response.error_info {
return Err(Error::other(msg));
}
return Err(peer_failure_without_details("stop_rebalance", None));
return Err(control_plane_failure("stop_rebalance", None, response.error_code, response.error_info));
}
Ok(())
@@ -2045,10 +2051,12 @@ impl PeerRestClient {
"peer rebalance metadata response"
);
if !response.success {
if let Some(msg) = response.error_info {
return Err(Error::other(msg));
}
return Err(peer_failure_without_details("load_rebalance_meta", None));
return Err(control_plane_failure(
"load_rebalance_meta",
None,
response.error_code,
response.error_info,
));
}
Ok(())
@@ -2073,10 +2081,12 @@ impl PeerRestClient {
let response = client.start_decommission(request).await?.into_inner();
if !response.success {
if let Some(msg) = response.error_info {
return Err(Error::other(msg));
}
return Err(peer_failure_without_details("start_decommission", None));
return Err(control_plane_failure(
"start_decommission",
None,
response.error_code,
response.error_info,
));
}
Ok(())
@@ -2097,10 +2107,12 @@ impl PeerRestClient {
let response = client.cancel_decommission(request).await?.into_inner();
if !response.success {
if let Some(msg) = response.error_info {
return Err(Error::other(msg));
}
return Err(peer_failure_without_details("decommission_cancel", None));
return Err(control_plane_failure(
"decommission_cancel",
None,
response.error_code,
response.error_info,
));
}
Ok(())
@@ -2121,10 +2133,12 @@ impl PeerRestClient {
let response = client.clear_decommission(request).await?.into_inner();
if !response.success {
if let Some(msg) = response.error_info {
return Err(Error::other(msg));
}
return Err(peer_failure_without_details("clear_decommission", None));
return Err(control_plane_failure(
"clear_decommission",
None,
response.error_code,
response.error_info,
));
}
Ok(())
@@ -2180,7 +2194,7 @@ impl PeerRestClient {
Err(status) => return tier_config_reload_status_outcome(status),
};
if !response.success {
return tier_config_reload_remote_failure(response.error_info);
return tier_config_reload_remote_failure(response.error_code, response.error_info);
}
TierConfigReloadOutcome::Success
@@ -2239,7 +2253,13 @@ fn is_tier_config_reload_connection_failure(err: &Error) -> bool {
/// reload every `TIER_CONFIG_RELOAD_RETRY_CAP`, and `Terminal` stays reachable
/// for transport and gRPC status failures, which is where a genuinely
/// unrecoverable peer surfaces.
fn tier_config_reload_remote_failure(error_info: Option<String>) -> TierConfigReloadOutcome {
fn tier_config_reload_remote_failure(error_code: Option<i32>, error_info: Option<String>) -> TierConfigReloadOutcome {
// Remote rejections are transient by design (see the doc comment above);
// the typed not-initialized code keeps the error typed for downstream
// classifiers instead of a bare string (backlog#1845).
if error_code == Some(rustfs_protos::proto_gen::node_service::ControlPlaneErrorCode::ControlPlaneErrorNotInitialized as i32) {
return TierConfigReloadOutcome::TransientRetrySameChannel(Error::RemoteNotInitialized);
}
TierConfigReloadOutcome::TransientRetrySameChannel(Error::other(error_info.unwrap_or_default()))
}
@@ -2278,6 +2298,63 @@ mod tests {
use temp_env::async_with_vars;
use tracing_subscriber::{Registry, fmt::MakeWriter, layer::SubscriberExt};
#[test]
fn control_plane_failure_prefers_typed_not_initialized_code() {
use rustfs_protos::proto_gen::node_service::ControlPlaneErrorCode;
let code = Some(ControlPlaneErrorCode::ControlPlaneErrorNotInitialized as i32);
// Typed code wins even when the legacy string is present (dual-write).
let err = control_plane_failure("load_bucket_metadata", Some("b"), code, Some("errServerNotInitialized".to_string()));
assert!(matches!(err, Error::RemoteNotInitialized));
assert!(crate::error::is_err_not_initialized(&err), "typed variant must satisfy the predicate");
// Legacy peers: no code, string only — the substring fallback still classifies.
let err = control_plane_failure("load_bucket_metadata", Some("b"), None, Some("errServerNotInitialized".to_string()));
assert!(crate::error::is_err_not_initialized(&err), "legacy string form must keep classifying");
// No code, no string: detail-free per-op failure, not misread as not-initialized.
let err = control_plane_failure("load_bucket_metadata", Some("b"), None, None);
assert!(!crate::error::is_err_not_initialized(&err));
assert!(err.to_string().contains("load_bucket_metadata"));
// Unspecified code behaves like no code.
let err = control_plane_failure(
"load_bucket_metadata",
None,
Some(ControlPlaneErrorCode::ControlPlaneErrorUnspecified as i32),
Some("boom".to_string()),
);
assert!(!matches!(err, Error::RemoteNotInitialized));
assert_eq!(err.to_string(), "Io error: boom");
}
#[test]
fn control_plane_not_initialized_wire_value_is_pinned() {
// The discriminant is wire contract: old peers ignore it, but a renumber
// would silently flip classification on mixed-version clusters.
use rustfs_protos::proto_gen::node_service::ControlPlaneErrorCode;
assert_eq!(ControlPlaneErrorCode::ControlPlaneErrorUnspecified as i32, 0);
assert_eq!(ControlPlaneErrorCode::ControlPlaneErrorNotInitialized as i32, 1);
}
#[test]
fn tier_config_reload_remote_failure_keeps_typed_not_initialized() {
use rustfs_protos::proto_gen::node_service::ControlPlaneErrorCode;
let outcome = tier_config_reload_remote_failure(
Some(ControlPlaneErrorCode::ControlPlaneErrorNotInitialized as i32),
Some("errServerNotInitialized".to_string()),
);
match outcome {
TierConfigReloadOutcome::TransientRetrySameChannel(err) => {
assert!(matches!(err, Error::RemoteNotInitialized));
}
TierConfigReloadOutcome::TransientReconnect(err) | TierConfigReloadOutcome::Terminal(err) => {
panic!("not-initialized must stay retry-same-channel, got {err}")
}
TierConfigReloadOutcome::Success => panic!("a rejection cannot classify as success"),
}
}
#[test]
fn scanner_publication_lease_response_rejects_stale_generation_and_session() {
let token = Uuid::new_v4();
@@ -3031,11 +3108,11 @@ mod tests {
// retired: the channel is healthy, so the rejection reflects remote state
// that the next attempt can find healed.
assert!(matches!(
tier_config_reload_remote_failure(Some("backend unavailable".to_string())),
tier_config_reload_remote_failure(None, Some("backend unavailable".to_string())),
TierConfigReloadOutcome::TransientRetrySameChannel(_)
));
assert!(matches!(
tier_config_reload_remote_failure(Some("errServerNotInitialized".to_string())),
tier_config_reload_remote_failure(None, Some("errServerNotInitialized".to_string())),
TierConfigReloadOutcome::TransientRetrySameChannel(_)
));
assert!(matches!(
@@ -3080,7 +3157,7 @@ mod tests {
] {
assert!(
matches!(
tier_config_reload_remote_failure(Some(error_info.to_string())),
tier_config_reload_remote_failure(None, Some(error_info.to_string())),
TierConfigReloadOutcome::TransientRetrySameChannel(_)
),
"a peer that rejected the apply must stay retryable so it converges: {error_info}"
@@ -3089,7 +3166,7 @@ mod tests {
// An absent error message is still a rejection, not a reason to stop.
assert!(matches!(
tier_config_reload_remote_failure(None),
tier_config_reload_remote_failure(None, None),
TierConfigReloadOutcome::TransientRetrySameChannel(_)
));
@@ -36,7 +36,7 @@ use crate::{
};
use async_trait::async_trait;
use futures::future::join_all;
use rustfs_common::heal_channel::{DriveState, HealItemType, HealOpts, RUSTFS_RESERVED_BUCKET};
use rustfs_heal_contracts::heal_channel::{DriveState, HealItemType, HealOpts, RUSTFS_RESERVED_BUCKET};
use rustfs_madmin::heal_commands::{HealDriveInfo, HealResultItem};
use rustfs_protos::proto_gen::node_service::node_service_client::NodeServiceClient;
use rustfs_protos::proto_gen::node_service::{
+571 -9
View File
@@ -35,7 +35,11 @@ use crate::disk::{
health_state::{RuntimeDriveHealthState, get_drive_returning_probe_interval, record_drive_runtime_state},
validate_batch_read_version_item_count,
};
use crate::disk::{disk_store::DiskHealthTracker, error::DiskError, local::ScanGuard};
use crate::disk::{
disk_store::DiskHealthTracker,
error::{DiskError, is_terminal_read_error, terminal_read_error_to_io},
local::ScanGuard,
};
use crate::set_disk::DEFAULT_READ_BUFFER_SIZE;
use bytes::Bytes;
use futures::lock::Mutex;
@@ -324,6 +328,39 @@ where
}
}
/// Mark a terminal fresh-shard recovery failure for adaptive retirement while
/// retaining its typed `DiskError` and original I/O kind. The decoder checks
/// the marker independently of the kind because not-found and transport
/// failures are terminal too, but must not be reported as timeouts.
fn remote_read_error_to_io(error: DiskError) -> io::Error {
terminal_read_error_to_io(error)
}
/// Retire a remote shard after its stream can no longer be trusted. A body
/// error that arrives after the one permitted resume is terminal: retaining
/// the reader would let the next stripe poll an already misaligned stream.
fn remote_terminal_io_error(error: io::Error) -> io::Error {
if is_terminal_read_error(&error) {
return error;
}
terminal_read_error_to_io(DiskError::from(error))
}
fn remote_terminal_message_to_io(message: &'static str) -> io::Error {
terminal_read_error_to_io(DiskError::Io(io::Error::other(message)))
}
fn remote_terminal_eof_to_io() -> io::Error {
terminal_read_error_to_io(DiskError::Io(io::Error::new(
io::ErrorKind::UnexpectedEof,
"remote read ended before requested length",
)))
}
fn remote_terminal_task_error_to_io(error: JoinError) -> io::Error {
terminal_read_error_to_io(DiskError::other(error))
}
struct AbortOnDropTask<T>(JoinHandle<T>);
impl<T> AbortOnDropTask<T> {
@@ -418,6 +455,9 @@ impl RetryingRemoteReader {
impl AsyncRead for RetryingRemoteReader {
fn poll_read(mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll<io::Result<()>> {
if buf.remaining() == 0 {
return Poll::Ready(Ok(()));
}
loop {
// After the absolute cutoff, let initial progress win over a stale fresh-open.
let resume_pending = if let Some(resume) = self.resume.as_mut() {
@@ -431,14 +471,14 @@ impl AsyncRead for RetryingRemoteReader {
Poll::Ready(Ok(Err(error))) => {
self.resume = None;
if self.reader.is_none() {
return Poll::Ready(Err(io::Error::other(error)));
return Poll::Ready(Err(remote_read_error_to_io(error)));
}
continue;
}
Poll::Ready(Err(error)) => {
self.resume = None;
if self.reader.is_none() {
return Poll::Ready(Err(io::Error::other(error)));
return Poll::Ready(Err(remote_terminal_task_error_to_io(error)));
}
continue;
}
@@ -479,6 +519,9 @@ impl AsyncRead for RetryingRemoteReader {
} else {
self.resume = None;
}
} else if produced == 0 && self.request.length != 0 && self.emitted < self.request.length {
self.reader = None;
return Poll::Ready(Err(remote_terminal_eof_to_io()));
}
return Poll::Ready(Ok(()));
}
@@ -494,7 +537,10 @@ impl AsyncRead for RetryingRemoteReader {
self.reader = None;
continue;
}
Poll::Ready(Err(error)) => return Poll::Ready(Err(error)),
Poll::Ready(Err(error)) => {
self.reader = None;
return Poll::Ready(Err(remote_terminal_io_error(error)));
}
}
}
}
@@ -585,21 +631,23 @@ impl rustfs_rio::ChunkReader for RetryingRemoteChunkReader {
Poll::Ready(Ok(Ok(None))) => {
self.resume = None;
if self.reader.is_none() {
return Poll::Ready(Err(io::Error::other("remote resume transport did not provide a chunk reader")));
return Poll::Ready(Err(remote_terminal_message_to_io(
"remote resume transport did not provide a chunk reader",
)));
}
continue;
}
Poll::Ready(Ok(Err(error))) => {
self.resume = None;
if self.reader.is_none() {
return Poll::Ready(Err(io::Error::other(error)));
return Poll::Ready(Err(remote_read_error_to_io(error)));
}
continue;
}
Poll::Ready(Err(error)) => {
self.resume = None;
if self.reader.is_none() {
return Poll::Ready(Err(io::Error::other(error)));
return Poll::Ready(Err(remote_terminal_task_error_to_io(error)));
}
continue;
}
@@ -635,10 +683,26 @@ impl rustfs_rio::ChunkReader for RetryingRemoteChunkReader {
return Poll::Ready(Ok(Some(chunk)));
}
Poll::Ready(Ok(None)) if resume_pending => {
// A clean EOF from the original stream wins when the
// request is unbounded (or has already emitted its full
// bounded length). Waiting for a speculative fresh open
// in that case can turn a successful read into a recovery
// timeout, especially on the read_file/unbounded path.
if self.request.length == 0 || self.emitted >= self.request.length {
self.reader = None;
self.resume = None;
return Poll::Ready(Ok(None));
}
self.reader = None;
continue;
}
Poll::Ready(Ok(None)) => return Poll::Ready(Ok(None)),
Poll::Ready(Ok(None)) => {
if self.request.length != 0 && self.emitted < self.request.length {
self.reader = None;
return Poll::Ready(Err(remote_terminal_eof_to_io()));
}
return Poll::Ready(Ok(None));
}
Poll::Ready(Err(error)) if !self.retried && is_retryable_remote_body_error(&error) => {
self.retried = true;
self.reader = None;
@@ -651,7 +715,10 @@ impl rustfs_rio::ChunkReader for RetryingRemoteChunkReader {
self.reader = None;
continue;
}
Poll::Ready(Err(error)) => return Poll::Ready(Err(error)),
Poll::Ready(Err(error)) => {
self.reader = None;
return Poll::Ready(Err(remote_terminal_io_error(error)));
}
}
}
}
@@ -5154,6 +5221,7 @@ mod tests {
enum ResumeReadStep {
PartialThenReset(Vec<u8>),
Data(Vec<u8>),
Eof,
}
#[derive(Debug, Default)]
@@ -5412,6 +5480,7 @@ mod tests {
struct PendingFreshOpenTransport {
fresh_read_drops: Arc<AtomicUsize>,
fresh_chunk_drops: Arc<AtomicUsize>,
initial_chunk_eof: bool,
}
#[async_trait::async_trait]
@@ -5429,6 +5498,9 @@ mod tests {
}
async fn open_read_chunks(&self, _request: ReadStreamRequest) -> Result<Option<rustfs_rio::ChunkReaderBox>> {
if self.initial_chunk_eof {
return Ok(Some(resume_step_chunk_reader(ResumeReadStep::Eof)));
}
Ok(Some(Box::new(ChunkPartialThenErrorReader {
data: None,
error: Some(io::Error::new(std_io::ErrorKind::ConnectionReset, "stream reset")),
@@ -5457,6 +5529,70 @@ mod tests {
}
}
#[derive(Debug)]
struct TerminalFreshOpenTransport {
fresh_read_opens: Arc<AtomicUsize>,
fresh_chunk_opens: Arc<AtomicUsize>,
chunk_returns_none: bool,
}
impl TerminalFreshOpenTransport {
fn new(chunk_returns_none: bool) -> Self {
Self {
fresh_read_opens: Arc::new(AtomicUsize::new(0)),
fresh_chunk_opens: Arc::new(AtomicUsize::new(0)),
chunk_returns_none,
}
}
}
#[async_trait::async_trait]
impl InternodeDataTransport for TerminalFreshOpenTransport {
async fn open_read(&self, _request: ReadStreamRequest) -> Result<FileReader> {
Ok(Box::new(PartialThenErrorReader {
cursor: Cursor::new(Vec::new()),
error: Some(io::Error::new(std_io::ErrorKind::ConnectionReset, "stream reset")),
}))
}
async fn open_read_fresh(&self, _request: ReadStreamRequest) -> Result<FileReader> {
self.fresh_read_opens.fetch_add(1, Ordering::Relaxed);
Err(DiskError::FileNotFound)
}
async fn open_read_chunks(&self, _request: ReadStreamRequest) -> Result<Option<rustfs_rio::ChunkReaderBox>> {
Ok(Some(Box::new(ChunkPartialThenErrorReader {
data: Some(Bytes::from_static(b"x")),
error: Some(io::Error::new(std_io::ErrorKind::ConnectionReset, "stream reset")),
})))
}
async fn open_read_chunks_fresh(&self, _request: ReadStreamRequest) -> Result<Option<rustfs_rio::ChunkReaderBox>> {
self.fresh_chunk_opens.fetch_add(1, Ordering::Relaxed);
if self.chunk_returns_none {
Ok(None)
} else {
Err(DiskError::FileNotFound)
}
}
async fn open_write(&self, _request: WriteStreamRequest) -> Result<FileWriter> {
panic!("open_write should not be used in terminal fresh-open tests");
}
async fn open_walk_dir(&self, _request: WalkDirStreamRequest) -> Result<FileReader> {
panic!("open_walk_dir should not be used in terminal fresh-open tests");
}
fn name(&self) -> &'static str {
"terminal-fresh-open-test"
}
fn capabilities(&self) -> InternodeDataTransportCapabilities {
InternodeDataTransportCapabilities::tcp_http()
}
}
fn resume_step_reader(step: ResumeReadStep) -> FileReader {
match step {
ResumeReadStep::PartialThenReset(data) => Box::new(PartialThenErrorReader {
@@ -5464,6 +5600,7 @@ mod tests {
error: Some(io::Error::new(std_io::ErrorKind::ConnectionReset, "stream reset")),
}),
ResumeReadStep::Data(data) => Box::new(Cursor::new(data)),
ResumeReadStep::Eof => Box::new(Cursor::new(Vec::new())),
}
}
@@ -5477,6 +5614,7 @@ mod tests {
data: Some(Bytes::from(data)),
error: None,
}),
ResumeReadStep::Eof => Box::new(ChunkPartialThenErrorReader { data: None, error: None }),
}
}
@@ -5553,6 +5691,430 @@ mod tests {
}
}
fn partial_hashed_shard(shard_size: usize) -> (rustfs_utils::HashAlgorithm, Vec<u8>, usize) {
let checksum = rustfs_utils::HashAlgorithm::HighwayHash256S;
let data = vec![0x5a; shard_size];
let hash_bytes = {
let hash = checksum.hash_encode(&data);
hash.as_ref().to_vec()
};
let hash_len = hash_bytes.len();
let encoded_length = hash_len + data.len();
let mut prefix = Vec::with_capacity(hash_len + shard_size / 2);
prefix.extend_from_slice(&hash_bytes);
prefix.extend_from_slice(&data[..shard_size / 2]);
(checksum, prefix, encoded_length)
}
#[test]
fn remote_read_error_conversion_preserves_recovery_classification() {
for disk_error in [DiskError::Timeout, DiskError::SourceStalled] {
let error = remote_read_error_to_io(disk_error);
assert_eq!(error.kind(), std_io::ErrorKind::TimedOut);
}
let error = remote_read_error_to_io(DiskError::Timeout);
assert!(
error
.get_ref()
.and_then(|source| source.downcast_ref::<crate::disk::error::TerminalReadError>())
.is_some()
);
assert!(matches!(DiskError::from(error), DiskError::Timeout));
let error = remote_read_error_to_io(DiskError::SourceStalled);
assert!(matches!(DiskError::from(error), DiskError::SourceStalled));
let error =
remote_read_error_to_io(DiskError::Io(io::Error::new(std_io::ErrorKind::ConnectionReset, "connection reset")));
assert_eq!(error.kind(), std_io::ErrorKind::ConnectionReset);
assert!(crate::disk::error::is_terminal_read_error(&error));
assert!(matches!(DiskError::from(error), DiskError::Io(inner) if inner.kind() == std_io::ErrorKind::ConnectionReset));
}
#[test]
fn remote_reader_zero_capacity_poll_is_a_noop() {
let transport: Arc<dyn InternodeDataTransport> = Arc::new(PendingFreshOpenTransport::default());
let mut reader = RetryingRemoteReader::new_with_timeouts(
Box::new(Cursor::new(b"x".to_vec())),
transport,
resume_request(1),
None,
None,
);
let mut empty = [];
let mut read_buf = ReadBuf::new(&mut empty);
let mut cx = Context::from_waker(std::task::Waker::noop());
assert!(matches!(Pin::new(&mut reader).poll_read(&mut cx, &mut read_buf), Poll::Ready(Ok(()))));
assert!(reader.reader.is_some(), "zero-capacity polls must not retire the remote reader");
let mut output = Vec::new();
futures::executor::block_on(reader.read_to_end(&mut output)).expect("the reader should remain usable");
assert_eq!(output, b"x");
}
#[tokio::test(start_paused = true)]
async fn remote_reader_fresh_open_timeout_preserves_timed_out_kind() {
let transport = Arc::new(PendingFreshOpenTransport::default());
let transport_for_reader: Arc<dyn InternodeDataTransport> = transport.clone();
let mut reader = RetryingRemoteReader::new_with_timeouts(
resume_step_reader(ResumeReadStep::PartialThenReset(Vec::new())),
transport_for_reader,
resume_request(1),
None,
Some(Duration::from_secs(1)),
);
let error = reader
.read_to_end(&mut Vec::new())
.await
.expect_err("a hung fresh open must surface its recovery timeout");
assert_eq!(error.kind(), std_io::ErrorKind::TimedOut);
assert!(
error
.get_ref()
.and_then(|source| source.downcast_ref::<crate::disk::error::TerminalReadError>())
.is_some()
);
assert!(matches!(DiskError::from(error), DiskError::Timeout));
assert_eq!(transport.fresh_read_drops.load(Ordering::Relaxed), 1);
}
#[tokio::test(start_paused = true)]
async fn remote_chunk_reader_fresh_open_timeout_preserves_timed_out_kind() {
let transport = Arc::new(PendingFreshOpenTransport::default());
let transport_for_reader: Arc<dyn InternodeDataTransport> = transport.clone();
let mut reader = RetryingRemoteChunkReader::new_with_timeouts(
resume_step_chunk_reader(ResumeReadStep::PartialThenReset(b"x".to_vec())),
transport_for_reader,
resume_request(2),
None,
Some(Duration::from_secs(1)),
);
let error = reader
.read_to_end(&mut Vec::new())
.await
.expect_err("a hung fresh chunk open must surface its recovery timeout");
assert_eq!(error.kind(), std_io::ErrorKind::TimedOut);
assert!(
error
.get_ref()
.and_then(|source| source.downcast_ref::<crate::disk::error::TerminalReadError>())
.is_some()
);
assert!(matches!(DiskError::from(error), DiskError::Timeout));
assert_eq!(transport.fresh_chunk_drops.load(Ordering::Relaxed), 1);
}
#[tokio::test(start_paused = true)]
async fn remote_chunk_reader_unbounded_clean_eof_wins_over_speculative_resume() {
let transport = Arc::new(PendingFreshOpenTransport {
initial_chunk_eof: true,
..PendingFreshOpenTransport::default()
});
let transport_for_reader: Arc<dyn InternodeDataTransport> = transport.clone();
let mut reader = RetryingRemoteChunkReader::new_with_timeouts(
resume_step_chunk_reader(ResumeReadStep::Eof),
transport_for_reader,
resume_request(0),
Some(Duration::ZERO),
Some(Duration::from_secs(1)),
);
let mut output = Vec::new();
reader
.read_to_end(&mut output)
.await
.expect("clean EOF from an unbounded original stream should finish the read");
assert!(output.is_empty());
// The executor may abort the speculative task before it is first
// polled, in which case the pending-open future never constructs its
// drop probe. The dedicated drop-cancellation tests cover the
// already-polled case; this regression only needs to establish that a
// clean unbounded EOF is not converted into a recovery timeout.
}
#[tokio::test(start_paused = true)]
async fn remote_reader_fresh_open_non_timeout_error_is_retired_from_adaptive_decode() {
let transport = Arc::new(TerminalFreshOpenTransport::new(false));
let transport_for_reader: Arc<dyn InternodeDataTransport> = transport.clone();
let retry = RetryingRemoteReader::new_with_timeouts(
resume_step_reader(ResumeReadStep::PartialThenReset(Vec::new())),
transport_for_reader,
resume_request(8),
None,
Some(Duration::from_secs(1)),
);
let shard = BitrotReader::new(ShardReader::Stream(Box::new(retry)), 8, rustfs_utils::HashAlgorithm::None, false);
let mut parallel = ParallelReader::new(vec![Some(shard)], Erasure::new(1, 0, 8), 0, 16);
let (_, first_errors) = parallel.read().await;
assert!(matches!(first_errors.first().and_then(Option::as_ref), Some(DiskError::FileNotFound)));
assert_eq!(transport.fresh_read_opens.load(Ordering::Relaxed), 1);
let (_, second_errors) = parallel.read().await;
assert!(matches!(second_errors.first().and_then(Option::as_ref), Some(DiskError::FileNotFound)));
assert_eq!(transport.fresh_read_opens.load(Ordering::Relaxed), 1);
}
#[tokio::test(start_paused = true)]
async fn remote_chunk_reader_missing_fresh_reader_is_retired_from_adaptive_decode() {
let transport = Arc::new(TerminalFreshOpenTransport::new(true));
let transport_for_reader: Arc<dyn InternodeDataTransport> = transport.clone();
let retry = RetryingRemoteChunkReader::new_with_timeouts(
resume_step_chunk_reader(ResumeReadStep::PartialThenReset(b"x".to_vec())),
transport_for_reader,
resume_request(8),
None,
Some(Duration::from_secs(1)),
);
let shard = BitrotReader::new(ShardReader::Chunked(Box::new(retry)), 8, rustfs_utils::HashAlgorithm::None, false);
let mut parallel = ParallelReader::new(vec![Some(shard)], Erasure::new(1, 0, 8), 0, 16);
let (_, first_errors) = parallel.read().await;
assert!(
matches!(first_errors.first().and_then(Option::as_ref), Some(DiskError::Io(error)) if error.kind() == std_io::ErrorKind::Other),
"unexpected first errors: {first_errors:?}"
);
assert_eq!(transport.fresh_chunk_opens.load(Ordering::Relaxed), 1);
let (_, second_errors) = parallel.read().await;
assert!(matches!(second_errors.first().and_then(Option::as_ref), Some(DiskError::FileNotFound)));
assert_eq!(transport.fresh_chunk_opens.load(Ordering::Relaxed), 1);
}
#[tokio::test]
async fn remote_reader_fresh_open_short_eof_is_retired_from_adaptive_decode() {
// A successful fresh open can still end before the bounded request.
// Treat that as terminal immediately so the next stripe does not poll
// an already exhausted reader and defer the failure to BitrotReader.
let transport = Arc::new(ResumeTransport::with_read_steps(vec![ResumeReadStep::Eof]));
let transport_for_reader: Arc<dyn InternodeDataTransport> = transport.clone();
let retry = RetryingRemoteReader::new_with_timeouts(
resume_step_reader(ResumeReadStep::PartialThenReset(b"01".to_vec())),
transport_for_reader,
resume_request(7),
None,
None,
);
let shard = BitrotReader::new(ShardReader::Stream(Box::new(retry)), 7, rustfs_utils::HashAlgorithm::None, false);
let mut parallel = ParallelReader::new(vec![Some(shard)], Erasure::new(1, 0, 7), 0, 14);
let (_, first_errors) = parallel.read().await;
assert!(matches!(
first_errors.first().and_then(Option::as_ref),
Some(DiskError::Io(error)) if error.kind() == std_io::ErrorKind::UnexpectedEof
));
assert_eq!(
transport
.fresh_read_requests
.lock()
.expect("fresh read request lock should not be poisoned")
.len(),
1
);
let (_, second_errors) = parallel.read().await;
assert!(matches!(second_errors.first().and_then(Option::as_ref), Some(DiskError::FileNotFound)));
assert_eq!(
transport
.fresh_read_requests
.lock()
.expect("fresh read request lock should not be poisoned")
.len(),
1
);
}
#[tokio::test]
async fn remote_chunk_reader_fresh_open_short_eof_is_retired_from_adaptive_decode() {
let transport = Arc::new(ResumeTransport::with_chunk_steps(vec![ResumeReadStep::Eof]));
let transport_for_reader: Arc<dyn InternodeDataTransport> = transport.clone();
let retry = RetryingRemoteChunkReader::new_with_timeouts(
resume_step_chunk_reader(ResumeReadStep::PartialThenReset(b"01".to_vec())),
transport_for_reader,
resume_request(7),
None,
None,
);
let shard = BitrotReader::new(ShardReader::Chunked(Box::new(retry)), 7, rustfs_utils::HashAlgorithm::None, false);
let mut parallel = ParallelReader::new(vec![Some(shard)], Erasure::new(1, 0, 7), 0, 14);
let (_, first_errors) = parallel.read().await;
assert!(matches!(
first_errors.first().and_then(Option::as_ref),
Some(DiskError::Io(error)) if error.kind() == std_io::ErrorKind::UnexpectedEof
));
assert_eq!(
transport
.fresh_chunk_requests
.lock()
.expect("fresh chunk request lock should not be poisoned")
.len(),
1
);
let (_, second_errors) = parallel.read().await;
assert!(matches!(second_errors.first().and_then(Option::as_ref), Some(DiskError::FileNotFound)));
assert_eq!(
transport
.fresh_chunk_requests
.lock()
.expect("fresh chunk request lock should not be poisoned")
.len(),
1
);
}
#[tokio::test]
async fn remote_reader_second_body_reset_is_retired_from_adaptive_decode() {
// The first connection emits a prefix, the one permitted fresh
// connection emits another prefix, and then resets again. The second
// reset must retire the reader so the next stripe cannot consume a
// misaligned stream.
let transport = Arc::new(ResumeTransport::with_read_steps(vec![ResumeReadStep::PartialThenReset(b"23".to_vec())]));
let transport_for_reader: Arc<dyn InternodeDataTransport> = transport.clone();
let retry = RetryingRemoteReader::new_with_timeouts(
resume_step_reader(ResumeReadStep::PartialThenReset(b"01".to_vec())),
transport_for_reader,
resume_request(7),
None,
None,
);
let shard = BitrotReader::new(ShardReader::Stream(Box::new(retry)), 7, rustfs_utils::HashAlgorithm::None, false);
let mut parallel = ParallelReader::new(vec![Some(shard)], Erasure::new(1, 0, 7), 0, 14);
let (_, first_errors) = parallel.read().await;
assert!(matches!(
first_errors.first().and_then(Option::as_ref),
Some(DiskError::Io(error)) if error.kind() == std_io::ErrorKind::ConnectionReset
));
assert_eq!(
transport
.fresh_read_requests
.lock()
.expect("fresh read request lock should not be poisoned")
.len(),
1
);
let (_, second_errors) = parallel.read().await;
assert!(matches!(second_errors.first().and_then(Option::as_ref), Some(DiskError::FileNotFound)));
assert_eq!(
transport
.fresh_read_requests
.lock()
.expect("fresh read request lock should not be poisoned")
.len(),
1
);
}
#[tokio::test]
async fn remote_chunk_reader_second_body_reset_is_retired_from_adaptive_decode() {
let transport = Arc::new(ResumeTransport::with_chunk_steps(vec![ResumeReadStep::PartialThenReset(b"23".to_vec())]));
let transport_for_reader: Arc<dyn InternodeDataTransport> = transport.clone();
let retry = RetryingRemoteChunkReader::new_with_timeouts(
resume_step_chunk_reader(ResumeReadStep::PartialThenReset(b"01".to_vec())),
transport_for_reader,
resume_request(7),
None,
None,
);
let shard = BitrotReader::new(ShardReader::Chunked(Box::new(retry)), 7, rustfs_utils::HashAlgorithm::None, false);
let mut parallel = ParallelReader::new(vec![Some(shard)], Erasure::new(1, 0, 7), 0, 14);
let (_, first_errors) = parallel.read().await;
assert!(matches!(
first_errors.first().and_then(Option::as_ref),
Some(DiskError::Io(error)) if error.kind() == std_io::ErrorKind::ConnectionReset
));
assert_eq!(
transport
.fresh_chunk_requests
.lock()
.expect("fresh chunk request lock should not be poisoned")
.len(),
1
);
let (_, second_errors) = parallel.read().await;
assert!(matches!(second_errors.first().and_then(Option::as_ref), Some(DiskError::FileNotFound)));
assert_eq!(
transport
.fresh_chunk_requests
.lock()
.expect("fresh chunk request lock should not be poisoned")
.len(),
1
);
}
#[tokio::test(start_paused = true)]
#[serial]
async fn remote_reader_hashed_timeout_is_retired_from_adaptive_decode() {
temp_env::async_with_vars([(rustfs_config::ENV_OBJECT_DISK_READ_TIMEOUT, Some("5"))], async {
const SHARD_SIZE: usize = 64;
let (checksum, encoded_prefix, encoded_length) = partial_hashed_shard(SHARD_SIZE);
let transport = Arc::new(PendingFreshOpenTransport::default());
let transport_for_reader: Arc<dyn InternodeDataTransport> = transport.clone();
let retry = RetryingRemoteReader::new_with_timeouts(
resume_step_reader(ResumeReadStep::PartialThenReset(encoded_prefix)),
transport_for_reader,
resume_request(encoded_length),
None,
Some(Duration::from_secs(1)),
);
let shard = BitrotReader::new(ShardReader::Stream(Box::new(retry)), SHARD_SIZE, checksum, false);
let mut parallel = ParallelReader::new(vec![Some(shard)], Erasure::new(1, 0, SHARD_SIZE), 0, SHARD_SIZE * 2);
let (first_buffers, first_errors) = parallel.read().await;
assert!(first_buffers.first().and_then(Option::as_ref).is_none());
assert!(matches!(first_errors.first().and_then(Option::as_ref), Some(DiskError::Timeout)));
assert_eq!(transport.fresh_read_drops.load(Ordering::Relaxed), 1);
// A TimedOut error retires the dead slot. The next stripe therefore
// reports the slot as unavailable instead of polling a reader whose
// fresh connection already timed out.
let (_, second_errors) = parallel.read().await;
assert!(matches!(second_errors.first().and_then(Option::as_ref), Some(DiskError::FileNotFound)));
})
.await;
}
#[tokio::test(start_paused = true)]
#[serial]
async fn remote_chunk_reader_hashed_timeout_is_retired_from_adaptive_decode() {
temp_env::async_with_vars([(rustfs_config::ENV_OBJECT_DISK_READ_TIMEOUT, Some("5"))], async {
const SHARD_SIZE: usize = 64;
let (checksum, encoded_prefix, encoded_length) = partial_hashed_shard(SHARD_SIZE);
let transport = Arc::new(PendingFreshOpenTransport::default());
let transport_for_reader: Arc<dyn InternodeDataTransport> = transport.clone();
let retry = RetryingRemoteChunkReader::new_with_timeouts(
resume_step_chunk_reader(ResumeReadStep::PartialThenReset(encoded_prefix)),
transport_for_reader,
resume_request(encoded_length),
None,
Some(Duration::from_secs(1)),
);
let shard = BitrotReader::new(ShardReader::Chunked(Box::new(retry)), SHARD_SIZE, checksum, false);
let mut parallel = ParallelReader::new(vec![Some(shard)], Erasure::new(1, 0, SHARD_SIZE), 0, SHARD_SIZE * 2);
let (first_buffers, first_errors) = parallel.read().await;
assert!(first_buffers.first().and_then(Option::as_ref).is_none());
assert!(matches!(first_errors.first().and_then(Option::as_ref), Some(DiskError::Timeout)));
assert_eq!(transport.fresh_chunk_drops.load(Ordering::Relaxed), 1);
let (_, second_errors) = parallel.read().await;
assert!(matches!(second_errors.first().and_then(Option::as_ref), Some(DiskError::FileNotFound)));
})
.await;
}
#[tokio::test]
async fn remote_reader_resumes_from_emitted_bytes_without_duplicates() {
let transport = Arc::new(ResumeTransport::with_read_steps(vec![ResumeReadStep::Data(b"456789".to_vec())]));
+194 -35
View File
@@ -27,7 +27,6 @@ use crate::storage_api_contracts::{
};
use crate::store::ECStore;
use http::HeaderMap;
use rustfs_common::heal_channel::{HealOpts, HealScanMode};
use rustfs_config::audit::{
AUDIT_AMQP_KEYS, AUDIT_AMQP_SUB_SYS, AUDIT_KAFKA_KEYS, AUDIT_KAFKA_SUB_SYS, AUDIT_MQTT_KEYS, AUDIT_MQTT_SUB_SYS,
AUDIT_MYSQL_KEYS, AUDIT_MYSQL_SUB_SYS, AUDIT_NATS_KEYS, AUDIT_NATS_SUB_SYS, AUDIT_POSTGRES_KEYS, AUDIT_POSTGRES_SUB_SYS,
@@ -46,8 +45,10 @@ use rustfs_config::{
SCANNER_SUB_SYS,
};
use rustfs_filemeta::FileInfo;
use rustfs_heal_contracts::heal_channel::{HealOpts, HealScanMode};
use serde_json::{Map, Value};
use std::collections::{HashMap, HashSet};
use std::hash::{DefaultHasher, Hash, Hasher};
use std::sync::LazyLock;
use std::sync::{Arc, RwLock};
use tokio::io::AsyncReadExt;
@@ -168,6 +169,7 @@ const EVENT_SERVER_CONFIG_READ_FAILED: &str = "server_config_read_failed";
const EVENT_SERVER_CONFIG_HEAL_RESULT: &str = "server_config_heal_result";
const EVENT_SERVER_CONFIG_RECOVERED: &str = "server_config_recovered_after_heal";
const EVENT_SERVER_CONFIG_FALLBACK: &str = "server_config_corruption_fallback";
const EVENT_SERVER_CONFIG_SCALAR_SECTION_IGNORED: &str = "server_config_scalar_section_ignored";
fn config_corruption_recovery_enabled() -> bool {
rustfs_utils::get_env_bool(ENV_CONFIG_RECOVER_ON_CORRUPTION, DEFAULT_CONFIG_RECOVER_ON_CORRUPTION)
@@ -218,6 +220,55 @@ fn server_config_decrypt_fn() -> Option<ServerConfigDecryptFn> {
SERVER_CONFIG_DECRYPT_FN.read().ok().and_then(|guard| guard.clone())
}
/// Dedup set for [`warn_ignored_scalar_section`], keyed by (subsystem, value)
/// hash. The persisted config is re-read on a short interval (event-notifier
/// reconcile runs every 5s), so an undeduplicated warn would flood the log with
/// one line per cycle for the same unchanged remnant.
static IGNORED_SCALAR_SECTION_WARNED: LazyLock<RwLock<HashSet<u64>>> = LazyLock::new(|| RwLock::new(HashSet::new()));
const IGNORED_SCALAR_SECTION_WARNED_CAP: usize = 64;
fn should_warn_ignored_scalar_section(subsystem_key: &str, config_value: &Value) -> bool {
let mut hasher = DefaultHasher::new();
subsystem_key.hash(&mut hasher);
config_value.to_string().hash(&mut hasher);
let digest = hasher.finish();
let Ok(mut seen) = IGNORED_SCALAR_SECTION_WARNED.write() else {
return true;
};
if seen.contains(&digest) {
return false;
}
if seen.len() >= IGNORED_SCALAR_SECTION_WARNED_CAP {
seen.clear();
}
seen.insert(digest);
true
}
fn warn_ignored_scalar_section(subsystem_key: &str, config_value: &Value) {
if !should_warn_ignored_scalar_section(subsystem_key, config_value) {
return;
}
let mut rendered = config_value.to_string();
if rendered.len() > 128 {
let mut cut = 128;
while !rendered.is_char_boundary(cut) {
cut -= 1;
}
rendered.truncate(cut);
rendered.push('…');
}
warn!(
event = EVENT_SERVER_CONFIG_SCALAR_SECTION_IGNORED,
component = LOG_COMPONENT_CONFIG,
subsystem = LOG_SUBSYSTEM_CONFIG,
config_subsystem = subsystem_key,
ignored_value = %rendered,
"Ignoring persisted {subsystem_key} config with a legacy scalar shape; it carries no decodable settings and is dropped on the next config save"
);
}
#[cfg(test)]
fn replace_server_config_decrypt_fn_for_test(decrypt_fn: Option<ServerConfigDecryptFn>) -> Option<ServerConfigDecryptFn> {
SERVER_CONFIG_DECRYPT_FN
@@ -980,10 +1031,20 @@ fn decode_scalar_config_value(config_value: &Value, descriptor: ScalarConfigDesc
}
Ok(overrides)
}
_ => Err(Error::other(format!(
"invalid external {} config shape: expected an object or KVS array",
descriptor.subsystem_key
))),
// Scalar and null section shapes (`"heal": ""`, `"scanner": null`,
// `"heal": {"default": null}`) are legacy remnants that carry no
// decodable settings; failing the whole config decode over them bricks
// every consumer of the persisted config (startup falls back to the
// default config, the admin config API cannot read-modify-write, and
// the notify reconciler retries forever). Ignore them with a deduped
// warning instead; the next config save scrubs them (see
// `normalize_scalar_section_seed`). Malformed values inside object or
// KVS-array shapes stay hard errors above — those shapes are where
// data (including fields from newer versions) can live.
_ => {
warn_ignored_scalar_section(descriptor.subsystem_key, config_value);
Ok(KVS::new())
}
}
}
@@ -1192,6 +1253,44 @@ fn decode_server_config_blob(data: &[u8]) -> Result<Config> {
Ok(cfg)
}
/// True when a persisted scanner/heal section value is a shape the decoder
/// warn-ignores instead of decoding: a bare scalar or null, or an object whose
/// nested `default`/`_` member is such a shape. Object and KVS-array shapes
/// with decodable structure are never ignorable — they are where data
/// (including fields written by newer versions) can live.
fn scalar_section_shape_is_ignorable(value: &Value) -> bool {
match value {
Value::Object(config_obj) => config_obj
.get("default")
.or_else(|| config_obj.get(DEFAULT_DELIMITER))
.is_some_and(scalar_section_shape_is_ignorable),
Value::Array(_) => false,
_ => true,
}
}
/// Strip warn-ignored scalar section shapes (see
/// [`scalar_section_shape_is_ignorable`]) from a scanner/heal seed value so the
/// canonical rewrite drops the remnant instead of preserving it verbatim, which
/// would keep tripping the decoder on every future read.
fn normalize_scalar_section_seed(existing: Option<Value>) -> Option<Value> {
match existing? {
Value::Object(mut config_obj) => {
for nested_key in ["default", DEFAULT_DELIMITER] {
if config_obj.get(nested_key).is_some_and(scalar_section_shape_is_ignorable) {
let nested = config_obj.remove(nested_key);
if let Some(normalized) = normalize_scalar_section_seed(nested) {
config_obj.insert(nested_key.to_string(), normalized);
}
}
}
(!config_obj.is_empty()).then_some(Value::Object(config_obj))
}
value if scalar_section_shape_is_ignorable(&value) => None,
value => Some(value),
}
}
fn parse_object_seed(data: &[u8]) -> Option<Map<String, Value>> {
let value: Value = serde_json::from_slice(data).ok()?;
value.as_object().cloned()
@@ -1811,10 +1910,7 @@ fn encode_server_config_blob(cfg: &Config, seed: Option<&[u8]>) -> Result<Vec<u8
root.remove("storage_class");
for descriptor in [scanner_config_descriptor(), heal_config_descriptor()] {
let mut existing = root.remove(descriptor.subsystem_key);
if descriptor.subsystem_key == HEAL_SUB_SYS && existing.as_ref().is_some_and(Value::is_null) {
existing = None;
}
let existing = normalize_scalar_section_seed(root.remove(descriptor.subsystem_key));
let rendered = build_scalar_config_object(cfg, descriptor);
if let Some(config_value) = sync_rendered_scalar_config_value(existing, &rendered, descriptor)? {
root.insert(descriptor.subsystem_key.to_string(), config_value);
@@ -1871,7 +1967,8 @@ fn is_standard_object_server_config(data: &[u8]) -> bool {
matches!(root.get("version"), Some(Value::String(v)) if !v.trim().is_empty())
&& matches!(root.get("storageclass"), Some(Value::Object(_)))
&& !root.contains_key("storage_class")
&& !matches!(root.get(HEAL_SUB_SYS), Some(Value::Null))
&& !matches!(root.get(HEAL_SUB_SYS), Some(value) if scalar_section_shape_is_ignorable(value))
&& !matches!(root.get(SCANNER_SUB_SYS), Some(value) if scalar_section_shape_is_ignorable(value))
}
fn configs_semantically_equal(lhs: &Config, rhs: &Config) -> bool {
@@ -2701,7 +2798,7 @@ mod tests {
lookup_configs, new_and_save_server_config, read_config, read_config_no_lock_preserve_empty_with_metadata,
read_config_preserve_empty, read_config_with_metadata, read_config_without_migrate, read_server_config_snapshot,
save_server_config, save_server_config_snapshot, save_server_config_snapshot_with_generation,
server_config_transaction_lock_path, storage_class_kvs_mut,
server_config_transaction_lock_path, should_warn_ignored_scalar_section, storage_class_kvs_mut,
};
use crate::config::{audit, heal, notify, oidc, scanner};
use crate::disk::endpoint::Endpoint;
@@ -3550,28 +3647,98 @@ mod tests {
}
#[test]
fn invalid_scalar_and_nested_null_config_shapes_remain_rejected() {
fn legacy_scalar_section_shapes_decode_as_no_override_and_canonicalize_on_save() {
let base = decode_server_config_blob(br#"{"version":"33","storageclass":{"standard":"","rrs":""}}"#)
.expect("base config should decode");
let ignorable_sections = [
(SCANNER_SUB_SYS, r#""scanner":null"#),
(SCANNER_SUB_SYS, r#""scanner":"cycle=61""#),
(HEAL_SUB_SYS, r#""heal":"""#),
(HEAL_SUB_SYS, r#""heal":false"#),
(HEAL_SUB_SYS, r#""heal":0"#),
(HEAL_SUB_SYS, r#""heal":{"default":null}"#),
(HEAL_SUB_SYS, r#""heal":{"_":null}"#),
];
for (section_key, section) in ignorable_sections {
let input = format!(r#"{{"version":"33","storageclass":{{"standard":"","rrs":""}},{section}}}"#);
let cfg = decode_server_config_blob(input.as_bytes())
.unwrap_or_else(|err| panic!("legacy scalar section {section} should be ignored, got: {err}"));
assert_eq!(cfg, base, "ignored section {section} must contribute no overrides");
assert!(
!is_standard_object_server_config(input.as_bytes()),
"seed with {section} must not count as standard so a save rewrites it"
);
let encoded =
encode_server_config_blob(&cfg, Some(input.as_bytes())).expect("legacy seed should canonicalize on save");
let value: Value = serde_json::from_slice(&encoded).expect("canonical config should be valid JSON");
assert!(
value.get(section_key).is_none(),
"canonical save must scrub the {section} remnant, got: {value}"
);
assert!(is_standard_object_server_config(&encoded));
decode_server_config_blob(&encoded).expect("canonicalized config must decode cleanly");
}
}
#[test]
fn scrubbing_ignorable_nested_member_preserves_unknown_section_fields() {
let input = br#"{"version":"33","storageclass":{"standard":"","rrs":""},"heal":{"_":null,"future_flag":"keep"}}"#;
let cfg = decode_server_config_blob(input).expect("ignorable nested member should not fail decode");
assert!(!is_standard_object_server_config(input));
let encoded = encode_server_config_blob(&cfg, Some(input)).expect("seed should canonicalize on save");
let value: Value = serde_json::from_slice(&encoded).expect("canonical config should be valid JSON");
assert!(value[HEAL_SUB_SYS].get(DEFAULT_DELIMITER).is_none(), "null member must be scrubbed");
assert_eq!(
value[HEAL_SUB_SYS]["future_flag"].as_str(),
Some("keep"),
"unknown section fields must survive the scrub"
);
assert!(is_standard_object_server_config(&encoded));
decode_server_config_blob(&encoded).expect("canonicalized config must decode cleanly");
}
#[test]
fn value_level_scalar_config_errors_remain_rejected() {
let invalid_sections = [
r#""scanner":null"#,
r#""heal":"""#,
r#""heal":false"#,
r#""heal":0"#,
r#""heal":{"default":null}"#,
r#""heal":{"_":null}"#,
r#""heal":{"bitrot_cycle":null}"#,
r#""heal":[{"key":"bitrot_cycle","value":null}]"#,
];
for section in invalid_sections {
let input = format!(r#"{{"version":"33","storageclass":{{"standard":"","rrs":""}},{section}}}"#);
let err = decode_server_config_blob(input.as_bytes()).expect_err("invalid scalar shape must remain rejected");
let err = decode_server_config_blob(input.as_bytes())
.expect_err("value-level errors inside object/array shapes must remain rejected");
assert!(
err.to_string().contains("expected"),
err.to_string().contains("expected a scalar"),
"invalid section {section} returned an unrelated error: {err}"
);
}
}
#[test]
fn ignored_scalar_sections_do_not_defeat_unrecognized_config_detection() {
let err = decode_server_config_blob(br#"{"scanner":"","heal":null}"#)
.expect_err("a config with only ignorable sections and no recognized header is still unrecognized");
assert!(
err.to_string().contains("unrecognized external server config shape"),
"unexpected error: {err}"
);
}
#[test]
fn ignored_scalar_section_warning_dedups_by_content() {
let value = Value::String("dedup-probe".to_string());
assert!(should_warn_ignored_scalar_section("dedup-test-subsystem", &value));
assert!(!should_warn_ignored_scalar_section("dedup-test-subsystem", &value));
let changed = Value::String("dedup-probe-changed".to_string());
assert!(should_warn_ignored_scalar_section("dedup-test-subsystem", &changed));
}
#[test]
fn valid_heal_object_and_kvs_array_shapes_remain_accepted() {
let empty_object = br#"{"version":"33","storageclass":{"standard":"","rrs":""},"heal":{}}"#;
@@ -4018,18 +4185,6 @@ mod tests {
);
}
#[test]
fn test_decode_rejects_scalar_scanner_section() {
let err = decode_server_config_blob(br#"{"version":"33","scanner":"cycle=61"}"#)
.expect_err("scalar scanner sections should be rejected");
assert!(
err.to_string()
.contains("invalid external scanner config shape: expected an object or KVS array"),
"unexpected scanner decode error: {err}"
);
}
#[test]
fn test_decode_rejects_malformed_known_scanner_kvs_value() {
let err = decode_server_config_blob(br#"{"scanner":[{"key":"cycle","value":{"seconds":61}}]}"#)
@@ -4710,7 +4865,7 @@ mod tests {
decode_persisted_server_config, fallback_server_config_after_corruption, is_server_config_corrupt_error,
read_config_without_migrate_with_recovery, replace_server_config_decrypt_fn_for_test,
};
use rustfs_common::heal_channel::HealOpts;
use rustfs_heal_contracts::heal_channel::HealOpts;
use std::sync::Mutex;
/// Bytes mirroring issue #4156: a bitrot-corrupted `config.json` whose
@@ -5065,7 +5220,7 @@ mod tests {
#[tokio::test]
async fn server_config_snapshot_serializes_read_modify_write_transactions() {
let baseline = encode_server_config_blob(&Config::new(), None).expect("baseline config should encode");
let store = Arc::new(RecoveryMockStore::new(RecoveryReadState::Blob(baseline), None));
let store = Arc::new(RecoveryMockStore::new(RecoveryReadState::Blob(baseline.clone()), None));
let first = read_server_config_snapshot(store.clone())
.await
.expect("first config snapshot");
@@ -5079,7 +5234,11 @@ mod tests {
.await
.expect("second transaction should acquire after the first snapshot is dropped")
.expect("second config snapshot");
assert!(configs_semantically_equal(&second.config, &Config::new()));
// Compare raw bytes against the baseline blob rather than a fresh
// Config::new(): the process-global DEFAULT_KVS can be registered by a
// sibling test mid-run, which would make a Config::new() evaluated here
// diverge from the baseline encoded above.
assert_eq!(second.raw.as_deref(), Some(baseline.as_slice()));
}
#[tokio::test]
+14 -14
View File
@@ -13,7 +13,7 @@
// limitations under the License.
use crate::bucket::replication::replication_state_from_filemeta;
#[cfg(test)]
#[cfg(all(test, feature = "test-util"))]
use crate::bucket::utils::is_meta_bucketname;
use crate::bucket::versioning_sys::BucketVersioningSys;
use crate::bucket::{
@@ -70,8 +70,8 @@ use http::HeaderMap;
#[cfg(test)]
use rmp_serde::Deserializer;
use rmp_serde::Serializer;
use rustfs_common::heal_channel::HealOpts;
use rustfs_filemeta::{FileInfoVersions, MetaCacheEntries, MetaCacheEntry, MetadataResolutionParams};
use rustfs_heal_contracts::heal_channel::HealOpts;
use rustfs_utils::crypto::{hex_sha256, is_sha256_checksum};
use rustfs_utils::path::{encode_dir_object, path_join, path_to_bucket_object, path_to_bucket_object_with_base_path};
use s3s::dto::{BucketLifecycleConfiguration, ObjectLockConfiguration, ReplicationConfiguration};
@@ -8503,7 +8503,7 @@ impl ECStore {
.await
}
#[cfg(test)]
#[cfg(all(test, feature = "test-util"))]
pub(crate) async fn decommission_entry_for_test_with_bucket_incarnation(
self: &Arc<Self>,
idx: usize,
@@ -8671,7 +8671,7 @@ impl ECStore {
Ok(())
}
#[cfg(test)]
#[cfg(all(test, feature = "test-util"))]
pub(crate) async fn decommission_pool_for_test(
self: &Arc<Self>,
rx: CancellationToken,
@@ -9587,7 +9587,7 @@ impl ECStore {
Ok(receipt_paths)
}
#[cfg(test)]
#[cfg(all(test, feature = "test-util"))]
async fn persist_decommission_durable_ilm_manifest(&self, source_pool_idx: usize) -> Result<()> {
let run_token = self.durable_ilm_receipt_run_token(source_pool_idx).await?;
self.persist_decommission_durable_ilm_manifest_for_run(source_pool_idx, &run_token)
@@ -9705,7 +9705,7 @@ impl ECStore {
Ok(receipts)
}
#[cfg(test)]
#[cfg(all(test, feature = "test-util"))]
async fn persist_decommission_durable_ilm_receipt(
&self,
source_pool_idx: usize,
@@ -10309,7 +10309,7 @@ impl ECStore {
Ok(())
}
#[cfg(test)]
#[cfg(all(test, feature = "test-util"))]
async fn verify_and_cleanup_decommissioned_durable_ilm_record(
&self,
source_pool_idx: usize,
@@ -10381,7 +10381,7 @@ impl ECStore {
resolve_decommission_entry_cleanup_delete_result(cleanup_result, RUSTFS_META_BUCKET, path)
}
#[cfg(test)]
#[cfg(all(test, feature = "test-util"))]
pub(crate) async fn verify_and_cleanup_decommissioned_durable_ilm_record_for_test(
&self,
source_pool_idx: usize,
@@ -10392,12 +10392,12 @@ impl ECStore {
.await
}
#[cfg(test)]
#[cfg(all(test, feature = "test-util"))]
pub(crate) async fn decommission_durable_ilm_receipt_count_for_test(&self, source_pool_idx: usize) -> Result<usize> {
Ok(self.list_decommission_durable_ilm_receipts(source_pool_idx).await?.len())
}
#[cfg(test)]
#[cfg(all(test, feature = "test-util"))]
pub(crate) async fn decommission_durable_ilm_receipt_paths_for_test(
&self,
source_pool_idx: usize,
@@ -10405,7 +10405,7 @@ impl ECStore {
self.list_decommission_durable_ilm_receipts(source_pool_idx).await
}
#[cfg(test)]
#[cfg(all(test, feature = "test-util"))]
pub(crate) async fn persist_decommission_durable_ilm_receipt_for_test(
&self,
source_pool_idx: usize,
@@ -10424,12 +10424,12 @@ impl ECStore {
Ok(decommission_durable_ilm_receipt_path(&run_token, source_path, record.id_kind, &record.id))
}
#[cfg(test)]
#[cfg(all(test, feature = "test-util"))]
pub(crate) async fn persist_decommission_durable_ilm_manifest_for_test(&self, source_pool_idx: usize) -> Result<()> {
self.persist_decommission_durable_ilm_manifest(source_pool_idx).await
}
#[cfg(test)]
#[cfg(all(test, feature = "test-util"))]
pub(crate) async fn cleanup_decommission_durable_ilm_receipts_for_test(&self, source_pool_idx: usize) -> Result<()> {
self.cleanup_decommission_durable_ilm_receipts(source_pool_idx).await
}
@@ -10782,7 +10782,7 @@ impl ECStore {
self.ensure_decommission_multipart_uploads_drained(idx, pool.as_ref(), &buckets)
.await
}
#[cfg(test)]
#[cfg(all(test, feature = "test-util"))]
pub(crate) async fn check_after_decommission_for_test(self: &Arc<Self>, idx: usize) -> Result<()> {
let generation = self.active_decommission_generation(idx).await?;
self.check_after_decommission(idx, &CancellationToken::new(), generation)
+2 -2
View File
@@ -46,9 +46,9 @@ use futures::{
stream::{FuturesUnordered, StreamExt},
};
use http::HeaderMap;
use rustfs_common::heal_channel::HealOpts;
use rustfs_common::heal_channel::{DriveState, HealItemType};
use rustfs_filemeta::FileInfo;
use rustfs_heal_contracts::heal_channel::HealOpts;
use rustfs_heal_contracts::heal_channel::{DriveState, HealItemType};
use rustfs_lock::NamespaceLockWrapper;
use rustfs_madmin::heal_commands::HealResultItem;
use rustfs_utils::{crc_hash, path::path_join_buf, sip_hash};
@@ -26,7 +26,7 @@ use crate::{
use crate::data_usage::load_data_usage_cache;
use crate::storage_api_contracts::admin::StorageAdminApi;
use crate::storage_api_contracts::bucket::BucketOptions;
use rustfs_common::heal_channel::DriveState;
use rustfs_heal_contracts::heal_channel::DriveState;
use rustfs_madmin::{
BackendDisks, Disk, ErasureSetInfo, ITEM_INITIALIZING, ITEM_OFFLINE, ITEM_ONLINE, ITEM_UNKNOWN, InfoMessage, MemStats,
ServerProperties,
+112
View File
@@ -23,6 +23,16 @@ pub type Result<T> = core::result::Result<T, Error>;
const METACACHE_OUTPUT_STREAM_CLOSED: &str = "metacache output stream closed";
/// Marker carried by a shard-read `io::Error` when the underlying reader can
/// no longer be realigned after a fresh remote open failed. The marker is
/// deliberately separate from the `ErrorKind`: a terminal read must retire
/// its reader, while its original typed disk error and I/O kind still need to
/// survive quorum/error mapping.
#[derive(Debug)]
pub(crate) struct TerminalReadError {
source: DiskError,
}
// DiskError == StorageErr
#[derive(Debug, thiserror::Error)]
pub enum DiskError {
@@ -168,6 +178,67 @@ pub enum DiskError {
RemoteClientUnavailable(String),
}
impl TerminalReadError {
pub(crate) fn new(source: DiskError) -> Self {
Self { source }
}
fn into_source(self) -> DiskError {
self.source
}
}
impl std::fmt::Display for TerminalReadError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.source.fmt(f)
}
}
impl StdError for TerminalReadError {
fn source(&self) -> Option<&(dyn StdError + 'static)> {
Some(&self.source)
}
}
fn classify_internode_missing_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);
}
None
}
/// Wrap a terminal shard-read failure without changing its typed
/// classification. Timeout-like disk errors retain `TimedOut`; other errors
/// retain their inner I/O kind or use `Other` when no more specific kind exists.
pub(crate) fn terminal_read_error_to_io(error: DiskError) -> io::Error {
let kind = match &error {
DiskError::Io(inner) => inner.kind(),
DiskError::SourceStalled | DiskError::Timeout => io::ErrorKind::TimedOut,
DiskError::DiskNotFound
| DiskError::FileNotFound
| DiskError::FileVersionNotFound
| DiskError::PathNotFound
| DiskError::VolumeNotFound => io::ErrorKind::NotFound,
DiskError::DiskAccessDenied | DiskError::FileAccessDenied | DiskError::VolumeAccessDenied => {
io::ErrorKind::PermissionDenied
}
DiskError::DiskFull => io::ErrorKind::StorageFull,
DiskError::FileCorrupt | DiskError::PartMissingOrCorrupt | DiskError::BitrotHashAlgoInvalid => io::ErrorKind::InvalidData,
_ => io::ErrorKind::Other,
};
io::Error::new(kind, TerminalReadError::new(error))
}
/// Whether an I/O error marks a shard reader as terminal for adaptive decode.
pub(crate) fn is_terminal_read_error(error: &io::Error) -> bool {
error
.get_ref()
.is_some_and(|source| source.downcast_ref::<TerminalReadError>().is_some())
}
impl From<crate::erasure::coding::ErasureConstructionError> for DiskError {
fn from(error: crate::erasure::coding::ErasureConstructionError) -> Self {
Self::Io(error.into_io_error())
@@ -344,6 +415,21 @@ impl From<std::io::Error> for DiskError {
return DiskError::VolumeNotFound;
}
}
let e = match e.downcast::<TerminalReadError>() {
Ok(terminal_error) => {
let source = terminal_error.into_source();
if let DiskError::Io(io_error) = &source
&& let Some(internode_error) = io_error
.get_ref()
.and_then(|source| source.downcast_ref::<InternodeHttpError>())
&& let Some(classified) = classify_internode_missing_error(internode_error)
{
return classified;
}
return source;
}
Err(e) => e,
};
match e.downcast::<DiskError>() {
Ok(disk_error) => disk_error,
// Mirror `From<io::Error> for StorageError`: a StorageError boxed
@@ -679,6 +765,32 @@ mod tests {
use super::*;
use std::collections::HashMap;
#[test]
fn terminal_read_error_preserves_kind_and_disk_classification() {
let timeout = terminal_read_error_to_io(DiskError::Timeout);
assert_eq!(timeout.kind(), io::ErrorKind::TimedOut);
assert!(is_terminal_read_error(&timeout));
assert!(matches!(DiskError::from(timeout), DiskError::Timeout));
let missing = terminal_read_error_to_io(DiskError::FileNotFound);
assert_eq!(missing.kind(), io::ErrorKind::NotFound);
assert!(is_terminal_read_error(&missing));
assert!(matches!(DiskError::from(missing), DiskError::FileNotFound));
let reset = terminal_read_error_to_io(DiskError::Io(io::Error::new(io::ErrorKind::ConnectionReset, "connection reset")));
assert_eq!(reset.kind(), io::ErrorKind::ConnectionReset);
assert!(is_terminal_read_error(&reset));
assert!(matches!(DiskError::from(reset), DiskError::Io(error) if error.kind() == io::ErrorKind::ConnectionReset));
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),
] {
let wrapped = terminal_read_error_to_io(DiskError::Io(remote_error));
assert_eq!(DiskError::from(wrapped), expected);
}
}
#[test]
fn other_preserves_erasure_construction_source_chain() {
use crate::erasure::coding::ErasureConstructionError;
@@ -208,7 +208,7 @@ async fn blackbox_get_restores_body_after_one_shard_file_is_removed() {
// Serialized: forces the reader-setup strategy through a process-global env var.
#[serial_test::serial]
async fn blackbox_heal_requests_preserve_repair_scope() {
use rustfs_common::heal_channel::{
use rustfs_heal_contracts::heal_channel::{
HealAdmissionResult, HealChannelCommand, HealChannelPriority, HealChannelReceiver, HealChannelRequest, HealRequestSource,
};
@@ -238,7 +238,7 @@ async fn blackbox_heal_requests_preserve_repair_scope() {
// (failing their submitter, which releases their dedup reservation), and
// fail fast once the receiver drops at test end. Tests that must observe a
// deterministic channel state serialize under the same serial key.
let mut heal_rx = rustfs_common::heal_channel::init_heal_channel()
let mut heal_rx = rustfs_heal_contracts::heal_channel::init_heal_channel()
.expect("this must be the only ecstore test that owns the heal channel receiver");
// Ordinary PUTs use the same admission channel as read repair. A single
File diff suppressed because it is too large Load Diff
+18 -1
View File
@@ -228,6 +228,14 @@ pub enum StorageError {
/// during quorum aggregation (backlog#1845).
#[error("remote rpc client unavailable: {0}")]
RemoteClientUnavailable(String),
/// A peer answered a control-plane RPC but its storage/IAM layer is not
/// initialized yet. Typed form of the legacy "errServerNotInitialized"
/// error_info string (backlog#1845); the wire carries it as
/// `ControlPlaneErrorCode::ControlPlaneErrorNotInitialized` alongside the
/// legacy string for rolling-upgrade compatibility.
#[error("remote peer not initialized")]
RemoteNotInitialized,
}
impl From<crate::erasure::coding::ErasureConstructionError> for StorageError {
@@ -581,6 +589,7 @@ impl Clone for StorageError {
limit: *limit,
},
StorageError::RemoteClientUnavailable(detail) => StorageError::RemoteClientUnavailable(detail.clone()),
StorageError::RemoteNotInitialized => StorageError::RemoteNotInitialized,
}
}
}
@@ -670,6 +679,7 @@ impl StorageError {
StorageError::NamespaceLockQuorumUnavailable { .. } => StorageErrorCode::NamespaceLockQuorumUnavailable,
StorageError::QuotaExceeded { .. } => StorageErrorCode::QuotaExceeded,
StorageError::RemoteClientUnavailable(_) => StorageErrorCode::RemoteClientUnavailable,
StorageError::RemoteNotInitialized => StorageErrorCode::RemoteNotInitialized,
}
}
@@ -800,6 +810,7 @@ impl StorageError {
limit: Default::default(),
}),
StorageErrorCode::RemoteClientUnavailable => Some(StorageError::RemoteClientUnavailable(Default::default())),
StorageErrorCode::RemoteNotInitialized => Some(StorageError::RemoteNotInitialized),
}
}
}
@@ -943,7 +954,13 @@ pub fn is_err_operation_canceled(err: &Error) -> bool {
#[allow(dead_code, reason = "predicate asserted by this file's tests (backlog#1823)")]
pub fn is_err_not_initialized(err: &Error) -> bool {
err.to_string().contains("errServerNotInitialized") || err.to_string().contains("ServerNotInitialized")
// Typed-first: peers at or above the ControlPlaneErrorCode change decode to
// the typed variant. The substring form only matches legacy peers' string
// responses.
// RUSTFS_COMPAT_TODO(not-initialized-error-code-v1): substring fallback for peers that predate the typed wire code. Remove after the minimum supported RustFS peer version always sends error_code.
matches!(err, StorageError::RemoteNotInitialized)
|| err.to_string().contains("errServerNotInitialized")
|| err.to_string().contains("ServerNotInitialized")
}
/// Strict "not found" predicate that only matches genuine object/version/volume
+5 -10
View File
@@ -346,16 +346,11 @@ impl AsyncRead for DeferredObjectReader {
}
fn disk_error_to_io_error(err: DiskError) -> io::Error {
let kind = match err {
DiskError::Timeout | DiskError::SourceStalled => io::ErrorKind::TimedOut,
DiskError::DiskNotFound | DiskError::FileNotFound | DiskError::FileVersionNotFound | DiskError::PathNotFound => {
io::ErrorKind::NotFound
}
DiskError::FileCorrupt | DiskError::PartMissingOrCorrupt | DiskError::BitrotHashAlgoInvalid => io::ErrorKind::InvalidData,
DiskError::Io(io_err) => return io_err,
_ => io::ErrorKind::Other,
};
io::Error::new(kind, err.to_string())
// Keep the typed disk error attached to deferred-reader failures. The
// decoder uses the marker to retire a stream that can no longer be
// realigned, while quorum reduction still sees Timeout/NotFound instead
// of an opaque `DiskError::Io` wrapper.
crate::disk::error::terminal_read_error_to_io(err)
}
async fn open_disk_reader(
+1 -1
View File
@@ -14,7 +14,7 @@
use crate::disk::{DiskInfo, error::DiskError};
use crate::layout::{endpoints::Endpoints, format::FormatV3};
use rustfs_common::heal_channel::DriveState;
use rustfs_heal_contracts::heal_channel::DriveState;
use rustfs_madmin::heal_commands::HealDriveInfo;
pub(crate) fn formats_to_drives_info(
-1
View File
@@ -56,7 +56,6 @@ mod storage_api_contracts;
mod store;
// pub mod checksum;
mod client;
mod event;
use rustfs_concurrency::WorkloadAdmissionSnapshotProvider;
@@ -1,33 +0,0 @@
/// Check mimalloc arena configuration and log diagnostics
pub fn log_mimalloc_diagnostics() {
#[cfg(feature = "mimalloc")]
{
use rustfs_mimalloc::MiMalloc;
// Check arena_max_object_size
let arena_max_obj_size = MiMalloc::option_get_size(
rustfs_mimalloc_sys::mi_option_t::mi_option_arena_max_object_size
);
tracing::info!(
arena_max_object_size_bytes = arena_max_obj_size,
"mimalloc arena_max_object_size"
);
// Check if pagemap is enabled
let pagemap_commit = MiMalloc::option_is_enabled(
rustfs_mimalloc_sys::mi_option_t::mi_option_pagemap_commit
);
tracing::info!(
pagemap_commit = pagemap_commit,
"mimalloc pagemap_commit"
);
// Log version
let version = MiMalloc::version();
tracing::info!(
mimalloc_version = version,
"mimalloc version"
);
}
}
+2
View File
@@ -14,6 +14,8 @@
// #730: object API readers keep staged compatibility paths during facade migration.
pub mod object_api_utils;
use crate::bucket::metadata_sys::get_versioning_config;
use crate::bucket::replication::{
DeleteReplicationConfigSnapshot, ReplicateDecision, ReplicationState, ReplicationStatusType, VersionPurgeStatusType,
+20 -18
View File
@@ -1407,8 +1407,7 @@ fn multipart_part_numbers(parts: &[ObjectPartInfo]) -> Vec<usize> {
#[cfg(test)]
mod tests {
use super::*;
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use base64_simd::STANDARD as BASE64_STANDARD;
use md5::{Digest, Md5};
use rustfs_utils::http::{SSEC_ALGORITHM_HEADER, SSEC_KEY_MD5_HEADER};
use std::collections::HashMap;
@@ -1468,7 +1467,7 @@ mod tests {
request: ReadEncryptionRequest<'_>,
) -> std::result::Result<Option<ReadEncryptionMaterial>, EncryptionResolutionError> {
if let Some(encoded) = request.metadata.get(TEST_OBJECT_KEY_HEADER) {
let decoded = BASE64_STANDARD.decode(encoded).map_err(|_| {
let decoded = BASE64_STANDARD.decode_to_vec(encoded).map_err(|_| {
EncryptionResolutionError::new(EncryptionResolutionErrorKind::InvalidMetadata, "invalid test object key")
})?;
let key_bytes = decoded.try_into().map_err(|_| {
@@ -1493,7 +1492,7 @@ mod tests {
.map_err(|_| {
EncryptionResolutionError::new(EncryptionResolutionErrorKind::InvalidRequest, "invalid test encryption key")
})?;
let decoded = BASE64_STANDARD.decode(encoded).map_err(|_| {
let decoded = BASE64_STANDARD.decode_to_vec(encoded).map_err(|_| {
EncryptionResolutionError::new(EncryptionResolutionErrorKind::InvalidRequest, "invalid test encryption key")
})?;
let key_bytes = decoded.try_into().map_err(|_| {
@@ -1505,7 +1504,7 @@ mod tests {
let base_nonce = request
.metadata
.get(TEST_NONCE_HEADER)
.and_then(|encoded| BASE64_STANDARD.decode(encoded).ok())
.and_then(|encoded| BASE64_STANDARD.decode_to_vec(encoded).ok())
.and_then(|bytes| bytes.try_into().ok())
.unwrap_or_else(|| fixture_nonce(request.bucket, request.object));
Ok(Some(ReadEncryptionMaterial {
@@ -1533,7 +1532,7 @@ mod tests {
let mut headers = HeaderMap::new();
headers.insert(
TEST_DIRECT_KEY_HEADER,
HeaderValue::from_str(&BASE64_STANDARD.encode(key_bytes)).expect("test key header is valid"),
HeaderValue::from_str(&BASE64_STANDARD.encode_to_string(key_bytes)).expect("test key header is valid"),
);
headers
}
@@ -2439,7 +2438,10 @@ mod tests {
user_defined: Arc::new(HashMap::from([
("X-Amz-Server-Side-Encryption".to_string(), "aws:kms".to_string()),
("X-Amz-Server-Side-Encryption-Iv".to_string(), "AAAAAAAAAAAAAAAA".to_string()),
("X-Amz-Server-Side-Encryption-Key".to_string(), BASE64_STANDARD.encode([7_u8; 32])),
(
"X-Amz-Server-Side-Encryption-Key".to_string(),
BASE64_STANDARD.encode_to_string([7_u8; 32]),
),
("x-rustfs-encryption-original-size".to_string(), "64".to_string()),
])),
..Default::default()
@@ -2748,7 +2750,7 @@ mod tests {
("x-amz-server-side-encryption-customer-algorithm".to_string(), "AES256".to_string()),
(
"x-amz-server-side-encryption-customer-key-md5".to_string(),
BASE64_STANDARD.encode(md5_bytes(key_bytes)),
BASE64_STANDARD.encode_to_string(md5_bytes(key_bytes)),
),
(
"x-amz-server-side-encryption-customer-original-size".to_string(),
@@ -2796,11 +2798,11 @@ mod tests {
name: object.to_string(),
size: encrypted.len() as i64,
user_defined: Arc::new(HashMap::from([
(TEST_OBJECT_KEY_HEADER.to_string(), BASE64_STANDARD.encode(object_key)),
(TEST_OBJECT_KEY_HEADER.to_string(), BASE64_STANDARD.encode_to_string(object_key)),
("x-amz-server-side-encryption-customer-algorithm".to_string(), "AES256".to_string()),
(
"x-amz-server-side-encryption-customer-key-md5".to_string(),
BASE64_STANDARD.encode(md5_bytes(customer_key)),
BASE64_STANDARD.encode_to_string(md5_bytes(customer_key)),
),
(
"x-amz-server-side-encryption-customer-original-size".to_string(),
@@ -2856,7 +2858,7 @@ mod tests {
("x-amz-server-side-encryption-customer-algorithm".to_string(), "AES256".to_string()),
(
"x-amz-server-side-encryption-customer-key-md5".to_string(),
BASE64_STANDARD.encode(md5_bytes(key_bytes)),
BASE64_STANDARD.encode_to_string(md5_bytes(key_bytes)),
),
(
"x-amz-server-side-encryption-customer-original-size".to_string(),
@@ -3008,13 +3010,13 @@ mod tests {
("x-amz-server-side-encryption-customer-algorithm".to_string(), "AES256".to_string()),
(
"x-amz-server-side-encryption-customer-key-md5".to_string(),
BASE64_STANDARD.encode(md5_bytes(key_bytes)),
BASE64_STANDARD.encode_to_string(md5_bytes(key_bytes)),
),
(
"x-amz-server-side-encryption-customer-original-size".to_string(),
total_plaintext.to_string(),
),
(TEST_NONCE_HEADER.to_string(), BASE64_STANDARD.encode(LEGACY_FIXTURE_BASE_NONCE)),
(TEST_NONCE_HEADER.to_string(), BASE64_STANDARD.encode_to_string(LEGACY_FIXTURE_BASE_NONCE)),
])
}
@@ -3751,11 +3753,11 @@ mod tests {
name: object.to_string(),
size: encrypted.len() as i64,
user_defined: Arc::new(HashMap::from([
(TEST_OBJECT_KEY_HEADER.to_string(), BASE64_STANDARD.encode(object_key)),
(TEST_OBJECT_KEY_HEADER.to_string(), BASE64_STANDARD.encode_to_string(object_key)),
("x-amz-server-side-encryption-customer-algorithm".to_string(), "AES256".to_string()),
(
"x-amz-server-side-encryption-customer-key-md5".to_string(),
BASE64_STANDARD.encode(md5_bytes(customer_key)),
BASE64_STANDARD.encode_to_string(md5_bytes(customer_key)),
),
(
"x-amz-server-side-encryption-customer-original-size".to_string(),
@@ -3821,7 +3823,7 @@ mod tests {
("x-amz-server-side-encryption-customer-algorithm".to_string(), "AES256".to_string()),
(
"x-amz-server-side-encryption-customer-key-md5".to_string(),
BASE64_STANDARD.encode(md5_bytes(key_bytes)),
BASE64_STANDARD.encode_to_string(md5_bytes(key_bytes)),
),
(
"x-amz-server-side-encryption-customer-original-size".to_string(),
@@ -3964,11 +3966,11 @@ mod tests {
..Default::default()
}]),
user_defined: Arc::new(HashMap::from([
(TEST_OBJECT_KEY_HEADER.to_string(), BASE64_STANDARD.encode(object_key)),
(TEST_OBJECT_KEY_HEADER.to_string(), BASE64_STANDARD.encode_to_string(object_key)),
("x-amz-server-side-encryption-customer-algorithm".to_string(), "AES256".to_string()),
(
"x-amz-server-side-encryption-customer-key-md5".to_string(),
BASE64_STANDARD.encode(md5_bytes(customer_key)),
BASE64_STANDARD.encode_to_string(md5_bytes(customer_key)),
),
(
"x-amz-server-side-encryption-customer-original-size".to_string(),
+1 -1
View File
@@ -281,7 +281,7 @@ pub struct QuotaAdmission {
pub struct LifecycleDeleteAllRequest {
pub(crate) version_id: Option<Uuid>,
pub(crate) delete_marker: bool,
pub(crate) action: rustfs_common::metrics::IlmAction,
pub(crate) action: rustfs_scanner_contracts::metrics::IlmAction,
pub(crate) rule_id: String,
pub(crate) phase: LifecycleDeleteAllPhase,
}
+27 -26
View File
@@ -18,7 +18,7 @@ use crate::storage_api_contracts::admin::StorageAdminApi;
#[cfg(test)]
use chrono::Utc;
use jiff::Timestamp;
use rustfs_common::{heal_channel::DriveState, metrics::global_metrics};
use rustfs_heal_contracts::heal_channel::DriveState;
use rustfs_io_metrics::internode_metrics::global_internode_metrics;
use rustfs_madmin::metrics::{
DiskIOStats, DiskMetric, LastMinute as MadminLastMinute, NetDevLine, NetMetrics, RPCMetrics, RealtimeMetrics,
@@ -32,6 +32,7 @@ use rustfs_madmin::metrics::{
ScannerSourceCycleSnapshot as MadminScannerSourceCycleSnapshot, ScannerSourceWorkSnapshot as MadminScannerSourceWorkSnapshot,
ScannerUsageFreshnessSnapshot as MadminScannerUsageFreshnessSnapshot, TimedAction as MadminTimedAction,
};
use rustfs_scanner_contracts::metrics::global_metrics;
use rustfs_utils::os::get_drive_stats;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
@@ -81,7 +82,7 @@ fn unix_millis_to_jiff_timestamp(millis: u64, fallback: Timestamp) -> Timestamp
}
}
fn to_madmin_scanner_metrics(metrics: rustfs_common::metrics::ScannerMetricsReport) -> MadminScannerMetrics {
fn to_madmin_scanner_metrics(metrics: rustfs_scanner_contracts::metrics::ScannerMetricsReport) -> MadminScannerMetrics {
MadminScannerMetrics {
collected_at: metrics.collected_at,
current_cycle: metrics.current_cycle,
@@ -563,8 +564,8 @@ async fn collect_local_disks_metrics(disks: &HashSet<String>) -> HashMap<String,
#[cfg(test)]
mod test {
use super::*;
use rustfs_common::metrics::CurrentCycle;
use rustfs_io_metrics::internode_metrics::global_internode_metrics;
use rustfs_scanner_contracts::metrics::CurrentCycle;
use serial_test::serial;
use std::time::Duration;
@@ -618,7 +619,7 @@ mod test {
#[test]
fn scanner_metrics_mapping_preserves_partial_source_status() {
let current_started = Utc::now() - chrono::Duration::seconds(5);
let scanner = to_madmin_scanner_metrics(rustfs_common::metrics::ScannerMetricsReport {
let scanner = to_madmin_scanner_metrics(rustfs_scanner_contracts::metrics::ScannerMetricsReport {
current_cycle_active: true,
current_started: chrono_to_jiff_timestamp(current_started),
last_cycle_partial_source: "usage".to_string(),
@@ -627,7 +628,7 @@ mod test {
cycle_recovery_required_total: 2,
cycle_last_progress_age: 17,
leader_lease_without_progress: true,
partial_cycles_by_source: vec![rustfs_common::metrics::ScannerSourceCycleSnapshot {
partial_cycles_by_source: vec![rustfs_scanner_contracts::metrics::ScannerSourceCycleSnapshot {
source: "usage".to_string(),
cycles: 2,
}],
@@ -653,11 +654,11 @@ mod test {
#[tokio::test]
#[serial]
async fn collect_local_metrics_preserves_scanner_cycle_started_time() {
let previous_init_time = *rustfs_common::globals::GLOBAL_INIT_TIME.read().await;
let previous_init_time = *rustfs_scanner_contracts::GLOBAL_INIT_TIME.read().await;
let previous_cycle = global_metrics().get_cycle().await;
let init_time = Utc::now() - chrono::Duration::hours(1);
let cycle_started = Utc::now() - chrono::Duration::seconds(5);
*rustfs_common::globals::GLOBAL_INIT_TIME.write().await = Some(init_time);
*rustfs_scanner_contracts::GLOBAL_INIT_TIME.write().await = Some(init_time);
let cycle = CurrentCycle {
current: 0,
next: 1,
@@ -672,7 +673,7 @@ mod test {
.finish_scan_cycle_work_with_cycle(cycle_start, previous_cycle.clone().unwrap_or_default())
.await;
global_metrics().set_cycle(previous_cycle).await;
*rustfs_common::globals::GLOBAL_INIT_TIME.write().await = previous_init_time;
*rustfs_scanner_contracts::GLOBAL_INIT_TIME.write().await = previous_init_time;
let encoded = rmp_serde::to_vec_named(&realtime).expect("realtime metrics should encode");
let decoded: RealtimeMetrics = rmp_serde::from_slice(&encoded).expect("realtime metrics should decode");
@@ -685,8 +686,8 @@ mod test {
#[test]
fn scanner_metrics_mapping_preserves_pacing_pressure() {
let scanner = to_madmin_scanner_metrics(rustfs_common::metrics::ScannerMetricsReport {
pacing_pressure: rustfs_common::metrics::ScannerPacingPressureSnapshot {
let scanner = to_madmin_scanner_metrics(rustfs_scanner_contracts::metrics::ScannerMetricsReport {
pacing_pressure: rustfs_scanner_contracts::metrics::ScannerPacingPressureSnapshot {
primary_pressure: "cycle_budget".to_string(),
current_queued_scans: 4,
current_active_scans: 2,
@@ -711,12 +712,12 @@ mod test {
#[test]
fn scanner_metrics_mapping_preserves_lifecycle_transition_status() {
let scanner = to_madmin_scanner_metrics(rustfs_common::metrics::ScannerMetricsReport {
let scanner = to_madmin_scanner_metrics(rustfs_scanner_contracts::metrics::ScannerMetricsReport {
current_cycle_lifecycle_expiry_actions: 2,
current_cycle_lifecycle_transition_actions: 3,
last_cycle_lifecycle_expiry_actions: 5,
last_cycle_lifecycle_transition_actions: 7,
lifecycle_expiry: rustfs_common::metrics::ScannerLifecycleExpirySnapshot {
lifecycle_expiry: rustfs_scanner_contracts::metrics::ScannerLifecycleExpirySnapshot {
current_queue_capacity: 16,
current_queued: 5,
current_active: 2,
@@ -728,7 +729,7 @@ mod test {
scanner_not_enqueued: 2,
delete_failed: 1,
},
lifecycle_transition: rustfs_common::metrics::ScannerLifecycleTransitionSnapshot {
lifecycle_transition: rustfs_scanner_contracts::metrics::ScannerLifecycleTransitionSnapshot {
current_queue_capacity: 16,
current_queued: 5,
current_active: 2,
@@ -777,10 +778,10 @@ mod test {
#[test]
fn scanner_metrics_mapping_preserves_maintenance_control_status() {
let scanner = to_madmin_scanner_metrics(rustfs_common::metrics::ScannerMetricsReport {
maintenance_control: rustfs_common::metrics::ScannerMaintenanceControlSnapshot {
let scanner = to_madmin_scanner_metrics(rustfs_scanner_contracts::metrics::ScannerMetricsReport {
maintenance_control: rustfs_scanner_contracts::metrics::ScannerMaintenanceControlSnapshot {
primary_control: "blocked_source".to_string(),
sources: vec![rustfs_common::metrics::ScannerMaintenanceSourceSnapshot {
sources: vec![rustfs_scanner_contracts::metrics::ScannerMaintenanceSourceSnapshot {
source: "lifecycle".to_string(),
state: "blocked".to_string(),
reason: "missed_work".to_string(),
@@ -814,8 +815,8 @@ mod test {
#[test]
fn scanner_metrics_mapping_preserves_usage_freshness_status() {
let scanner = to_madmin_scanner_metrics(rustfs_common::metrics::ScannerMetricsReport {
usage_freshness: rustfs_common::metrics::ScannerUsageFreshnessSnapshot {
let scanner = to_madmin_scanner_metrics(rustfs_scanner_contracts::metrics::ScannerMetricsReport {
usage_freshness: rustfs_scanner_contracts::metrics::ScannerUsageFreshnessSnapshot {
dirty_pending_buckets: 3,
last_dirty_mark_unix_secs: 10,
last_dirty_clear_unix_secs: 11,
@@ -856,7 +857,7 @@ mod test {
#[test]
fn scanner_metrics_mapping_preserves_distributed_status_fields() {
let scanner = to_madmin_scanner_metrics(rustfs_common::metrics::ScannerMetricsReport {
let scanner = to_madmin_scanner_metrics(rustfs_scanner_contracts::metrics::ScannerMetricsReport {
active_scan_paths: 2,
oldest_active_path_age_seconds: 45,
active_paths: vec!["disk-a/bucket-a".to_string(), "disk-b/bucket-b".to_string()],
@@ -912,7 +913,7 @@ mod test {
cycle_max_directories: 38,
bitrot_cycle_enabled: true,
bitrot_cycle_seconds: 39.0,
scan_checkpoint: Some(rustfs_common::metrics::ScannerCheckpointReport {
scan_checkpoint: Some(rustfs_scanner_contracts::metrics::ScannerCheckpointReport {
version: 1,
resume_after: "bucket-a/prefix-a".to_string(),
reason: "directories".to_string(),
@@ -922,7 +923,7 @@ mod test {
scan_checkpoint_cleared: 41,
scan_checkpoint_ignored: 42,
scan_checkpoint_stale: 43,
source_work: vec![rustfs_common::metrics::ScannerSourceWorkSnapshot {
source_work: vec![rustfs_scanner_contracts::metrics::ScannerSourceWorkSnapshot {
source: "usage".to_string(),
checked: 44,
queued: 45,
@@ -931,7 +932,7 @@ mod test {
skipped: 48,
missed: 49,
}],
current_cycle_source_work: vec![rustfs_common::metrics::ScannerSourceWorkSnapshot {
current_cycle_source_work: vec![rustfs_scanner_contracts::metrics::ScannerSourceWorkSnapshot {
source: "lifecycle".to_string(),
checked: 50,
queued: 51,
@@ -940,7 +941,7 @@ mod test {
skipped: 54,
missed: 55,
}],
last_cycle_source_work: vec![rustfs_common::metrics::ScannerSourceWorkSnapshot {
last_cycle_source_work: vec![rustfs_scanner_contracts::metrics::ScannerSourceWorkSnapshot {
source: "heal".to_string(),
checked: 56,
queued: 57,
@@ -949,7 +950,7 @@ mod test {
skipped: 60,
missed: 61,
}],
replication_repair: vec![rustfs_common::metrics::ScannerReplicationRepairSnapshot {
replication_repair: vec![rustfs_scanner_contracts::metrics::ScannerReplicationRepairSnapshot {
source: "bucket_replication".to_string(),
kind: "object".to_string(),
scanner_role: "repair_admission".to_string(),
@@ -961,7 +962,7 @@ mod test {
skipped: 66,
missed: 67,
}],
current_cycle_replication_repair: vec![rustfs_common::metrics::ScannerReplicationRepairSnapshot {
current_cycle_replication_repair: vec![rustfs_scanner_contracts::metrics::ScannerReplicationRepairSnapshot {
source: "bucket_replication".to_string(),
kind: "delete_marker".to_string(),
scanner_role: "repair_admission".to_string(),
@@ -973,7 +974,7 @@ mod test {
skipped: 72,
missed: 73,
}],
last_cycle_replication_repair: vec![rustfs_common::metrics::ScannerReplicationRepairSnapshot {
last_cycle_replication_repair: vec![rustfs_scanner_contracts::metrics::ScannerReplicationRepairSnapshot {
source: "site_replication".to_string(),
kind: "active_resync".to_string(),
scanner_role: "boundary_signal".to_string(),
@@ -2089,7 +2089,7 @@ async fn peer_disk_health(host: &str) -> Option<PeerDiskHealth> {
disks.push(rustfs_madmin::Disk {
endpoint: ep.to_string(),
state: if online {
rustfs_common::heal_channel::DriveState::Ok.to_string()
rustfs_heal_contracts::heal_channel::DriveState::Ok.to_string()
} else {
ItemState::Offline.to_string().to_owned()
},
@@ -68,7 +68,6 @@ use tokio::io::AsyncReadExt;
use tokio::sync::{Mutex, Notify, RwLock};
use uuid::Uuid;
use crate::client::transition_api::{ReadCloser, ReaderImpl};
use crate::disk::endpoint::Endpoint;
use crate::disk::format::FormatV3;
use crate::disk::{DiskAPI, DiskOption, FORMAT_CONFIG_FILE, RUSTFS_META_BUCKET, STORAGE_FORMAT_FILE, new_disk};
@@ -78,6 +77,7 @@ use crate::services::tier::warm_backend::{
TransitionCandidateProbe, WarmBackend, WarmBackendGetOpts, build_transition_put_options,
};
use rustfs_filemeta::FileMeta;
use rustfs_s3_client::transition_api::{ReadCloser, ReaderImpl};
use rustfs_utils::path::path_join_buf;
/// One-shot barrier before rejected transition cleanup resolves its ECStore.
+5 -5
View File
@@ -47,7 +47,6 @@ use tokio::{
};
use tracing::{debug, error, info, warn};
use crate::client::{admin_handler_utils::AdminError, provider_versions::ProviderVersionCapabilities};
use crate::error::{Error, Result, StorageError};
use crate::services::tier::{
tier_admin::TierCreds,
@@ -80,6 +79,7 @@ use crate::{
};
use rustfs_filemeta::FileInfo;
use rustfs_rio::HashReader;
use rustfs_s3_client::{admin_handler_utils::AdminError, provider_versions::ProviderVersionCapabilities};
use rustfs_utils::path::{SLASH_SEPARATOR, path_join};
use s3s::S3ErrorCode;
@@ -1529,14 +1529,14 @@ impl WarmBackend for SharedWarmBackendProxy {
self.0.validate_remote_version_id(remote_version_id)
}
async fn put(&self, object: &str, r: crate::client::transition_api::ReaderImpl, length: i64) -> io::Result<String> {
async fn put(&self, object: &str, r: rustfs_s3_client::transition_api::ReaderImpl, length: i64) -> io::Result<String> {
self.0.put(object, r, length).await
}
async fn put_with_meta(
&self,
object: &str,
r: crate::client::transition_api::ReaderImpl,
r: rustfs_s3_client::transition_api::ReaderImpl,
length: i64,
meta: HashMap<String, String>,
) -> io::Result<String> {
@@ -1548,7 +1548,7 @@ impl WarmBackend for SharedWarmBackendProxy {
object: &str,
rv: &str,
opts: crate::services::tier::warm_backend::WarmBackendGetOpts,
) -> io::Result<crate::client::transition_api::ReadCloser> {
) -> io::Result<rustfs_s3_client::transition_api::ReadCloser> {
self.0.get(object, rv, opts).await
}
@@ -5778,8 +5778,8 @@ mod tests {
// lets us drive `remove`/`verify` through every branch.
// ---------------------------------------------------------------------
use crate::client::transition_api::{ReadCloser, ReaderImpl};
use crate::services::tier::warm_backend::{WarmBackend, WarmBackendGetOpts};
use rustfs_s3_client::transition_api::{ReadCloser, ReaderImpl};
fn empty_mgr() -> TierConfigMgr {
TierConfigMgr {
@@ -12,9 +12,9 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use crate::client::admin_handler_utils::AdminError;
use http::status::StatusCode;
use lazy_static::lazy_static;
use rustfs_s3_client::admin_handler_utils::AdminError;
lazy_static! {
pub static ref ERR_TIER_ALREADY_EXISTS: AdminError = AdminError {
@@ -22,9 +22,9 @@ use super::tier_mutation_intent::{
MAX_TIER_MUTATION_INTENT_SIZE, TierMutationIntent, TierMutationIntentState, advance_tier_mutation_intent_record_idempotent,
load_tier_mutation_intent_record, save_tier_mutation_intent_record_if_absent,
};
use crate::client::admin_handler_utils::AdminError;
use crate::error::{Error, StorageError};
use crate::store::ECStore;
use rustfs_s3_client::admin_handler_utils::AdminError;
pub const MAX_TIER_MUTATION_PEER_COMMIT_ETAG_SIZE: usize = rustfs_protos::TIER_MUTATION_RPC_MAX_COMMIT_PAYLOAD_SIZE;
@@ -18,11 +18,6 @@
#![allow(unused_must_use)]
#![allow(clippy::all)]
use crate::client::{
admin_handler_utils::AdminError,
api_put_object::{AdvancedPutOptions, PutObjectOptions},
transition_api::{ReadCloser, ReaderImpl},
};
use crate::error::is_err_bucket_not_found;
use crate::services::tier::{
tier::{ERR_TIER_INVALID_CONFIG, ERR_TIER_TYPE_UNSUPPORTED},
@@ -41,6 +36,11 @@ use crate::services::tier::{
};
use bytes::Bytes;
use http::StatusCode;
use rustfs_s3_client::{
admin_handler_utils::AdminError,
api_put_object::{AdvancedPutOptions, PutObjectOptions},
transition_api::{ReadCloser, ReaderImpl},
};
use rustfs_utils::http::headers::{
CACHE_CONTROL, CONTENT_DISPOSITION, CONTENT_ENCODING, CONTENT_LANGUAGE, CONTENT_TYPE, EXPIRES, HeaderExt as _,
};
@@ -21,17 +21,17 @@
use std::collections::HashMap;
use std::sync::Arc;
use crate::client::{
admin_handler_utils::AdminError,
api_put_object::PutObjectOptions,
credentials::{Credentials, SignatureType, Static, Value},
transition_api::{BucketLookupType, Options, ReadCloser, ReaderImpl, TransitionClient, TransitionCore},
};
use crate::services::tier::{
tier_config::TierAliyun,
warm_backend::{WarmBackend, WarmBackendGetOpts, build_transition_put_options},
warm_backend_s3::WarmBackendS3,
};
use rustfs_s3_client::{
admin_handler_utils::AdminError,
api_put_object::PutObjectOptions,
credentials::{Credentials, SignatureType, Static, Value},
transition_api::{BucketLookupType, Options, ReadCloser, ReaderImpl, TransitionClient, TransitionCore},
};
use tracing::warn;
const MAX_MULTIPART_PUT_OBJECT_SIZE: i64 = 1024 * 1024 * 1024 * 1024 * 5;
@@ -21,17 +21,17 @@
use std::collections::HashMap;
use std::sync::Arc;
use crate::client::{
admin_handler_utils::AdminError,
api_put_object::PutObjectOptions,
credentials::{Credentials, SignatureType, Static, Value},
transition_api::{BucketLookupType, Options, ReadCloser, ReaderImpl, TransitionClient, TransitionCore},
};
use crate::services::tier::{
tier_config::TierAzure,
warm_backend::{WarmBackend, WarmBackendGetOpts, build_transition_put_options},
warm_backend_s3::WarmBackendS3,
};
use rustfs_s3_client::{
admin_handler_utils::AdminError,
api_put_object::PutObjectOptions,
credentials::{Credentials, SignatureType, Static, Value},
transition_api::{BucketLookupType, Options, ReadCloser, ReaderImpl, TransitionClient, TransitionCore},
};
use tracing::warn;
const MAX_MULTIPART_PUT_OBJECT_SIZE: i64 = 1024 * 1024 * 1024 * 1024 * 5;
@@ -30,15 +30,15 @@ use google_cloud_storage::client::Storage;
use google_cloud_storage::client::StorageControl;
use std::convert::TryFrom;
use crate::client::{
admin_handler_utils::AdminError,
api_put_object::PutObjectOptions,
transition_api::{Options, ReadCloser, ReaderImpl},
};
use crate::services::tier::{
tier_config::TierGCS,
warm_backend::{WarmBackend, WarmBackendGetOpts},
};
use rustfs_s3_client::{
admin_handler_utils::AdminError,
api_put_object::PutObjectOptions,
transition_api::{Options, ReadCloser, ReaderImpl},
};
use tracing::warn;
const _MAX_PART_SIZE: i64 = 1024 * 1024 * 1024 * 5;
@@ -21,17 +21,17 @@
use std::collections::HashMap;
use std::sync::Arc;
use crate::client::{
admin_handler_utils::AdminError,
api_put_object::PutObjectOptions,
credentials::{Credentials, SignatureType, Static, Value},
transition_api::{BucketLookupType, Options, ReadCloser, ReaderImpl, TransitionClient, TransitionCore},
};
use crate::services::tier::{
tier_config::TierHuaweicloud,
warm_backend::{WarmBackend, WarmBackendGetOpts, build_transition_put_options},
warm_backend_s3::WarmBackendS3,
};
use rustfs_s3_client::{
admin_handler_utils::AdminError,
api_put_object::PutObjectOptions,
credentials::{Credentials, SignatureType, Static, Value},
transition_api::{BucketLookupType, Options, ReadCloser, ReaderImpl, TransitionClient, TransitionCore},
};
use tracing::warn;
const MAX_MULTIPART_PUT_OBJECT_SIZE: i64 = 1024 * 1024 * 1024 * 1024 * 5;
@@ -21,17 +21,17 @@
use std::collections::HashMap;
use std::sync::Arc;
use crate::client::{
admin_handler_utils::AdminError,
api_put_object::PutObjectOptions,
credentials::{Credentials, SignatureType, Static, Value},
transition_api::{Options, ReadCloser, ReaderImpl, TransitionClient, TransitionCore},
};
use crate::services::tier::{
tier_config::TierMinIO,
warm_backend::{TransitionCandidateProbe, WarmBackend, WarmBackendGetOpts, build_transition_put_options},
warm_backend_s3::WarmBackendS3,
};
use rustfs_s3_client::{
admin_handler_utils::AdminError,
api_put_object::PutObjectOptions,
credentials::{Credentials, SignatureType, Static, Value},
transition_api::{Options, ReadCloser, ReaderImpl, TransitionClient, TransitionCore},
};
use tracing::warn;
const MAX_MULTIPART_PUT_OBJECT_SIZE: i64 = 1024 * 1024 * 1024 * 1024 * 5;
@@ -21,17 +21,17 @@
use std::collections::HashMap;
use std::sync::Arc;
use crate::client::{
admin_handler_utils::AdminError,
api_put_object::PutObjectOptions,
credentials::{Credentials, SignatureType, Static, Value},
transition_api::{Options, ReadCloser, ReaderImpl, TransitionClient, TransitionCore},
};
use crate::services::tier::{
tier_config::TierR2,
warm_backend::{TransitionCandidateProbe, WarmBackend, WarmBackendGetOpts, build_transition_put_options},
warm_backend_s3::WarmBackendS3,
};
use rustfs_s3_client::{
admin_handler_utils::AdminError,
api_put_object::PutObjectOptions,
credentials::{Credentials, SignatureType, Static, Value},
transition_api::{Options, ReadCloser, ReaderImpl, TransitionClient, TransitionCore},
};
use tracing::warn;
const MAX_MULTIPART_PUT_OBJECT_SIZE: i64 = 1024 * 1024 * 1024 * 1024 * 5;
@@ -21,17 +21,17 @@
use std::collections::HashMap;
use std::sync::Arc;
use crate::client::{
admin_handler_utils::AdminError,
api_put_object::PutObjectOptions,
credentials::{Credentials, SignatureType, Static, Value},
transition_api::{Options, ReadCloser, ReaderImpl, TransitionClient, TransitionCore},
};
use crate::services::tier::{
tier_config::TierRustFS,
warm_backend::{TransitionCandidateProbe, WarmBackend, WarmBackendGetOpts, build_transition_put_options},
warm_backend_s3::WarmBackendS3,
};
use rustfs_s3_client::{
admin_handler_utils::AdminError,
api_put_object::PutObjectOptions,
credentials::{Credentials, SignatureType, Static, Value},
transition_api::{Options, ReadCloser, ReaderImpl, TransitionClient, TransitionCore},
};
const MAX_MULTIPART_PUT_OBJECT_SIZE: i64 = 1024 * 1024 * 1024 * 1024 * 5;
const MAX_PARTS_COUNT: i64 = 10000;
@@ -22,7 +22,15 @@ use std::collections::HashMap;
use std::sync::Arc;
use url::Url;
use crate::client::{
use crate::services::tier::{
tier_config::TierS3,
warm_backend::{
TransitionCandidateIdentity, TransitionCandidateProbe, TransitionCandidateReconciler, WarmBackend, WarmBackendGetOpts,
build_transition_put_options,
},
};
use http::HeaderMap;
use rustfs_s3_client::{
api_get_options::GetObjectOptions,
api_list::ListObjectsOptions,
api_put_object::PutObjectOptions,
@@ -33,14 +41,6 @@ use crate::client::{
transition_api::{BucketLookupType, Options, TransitionClient, TransitionCore},
transition_api::{ReadCloser, ReaderImpl},
};
use crate::services::tier::{
tier_config::TierS3,
warm_backend::{
TransitionCandidateIdentity, TransitionCandidateProbe, TransitionCandidateReconciler, WarmBackend, WarmBackendGetOpts,
build_transition_put_options,
},
};
use http::HeaderMap;
use rustfs_utils::egress::validate_outbound_url;
use rustfs_utils::path::SLASH_SEPARATOR;
use s3s::dto::BucketVersioningStatus;
@@ -379,7 +379,7 @@ impl TransitionCandidateVersions {
#[cfg(test)]
mod tests {
use super::*;
use crate::client::api_s3_datatypes::{ListVersionsResult, Version};
use rustfs_s3_client::api_s3_datatypes::{ListVersionsResult, Version};
#[tokio::test]
async fn new_rejects_loopback_endpoint_before_network_setup() {
@@ -21,17 +21,17 @@
use std::collections::HashMap;
use std::sync::Arc;
use crate::client::{
admin_handler_utils::AdminError,
api_put_object::PutObjectOptions,
credentials::{Credentials, SignatureType, Static, Value},
transition_api::{BucketLookupType, Options, ReadCloser, ReaderImpl, TransitionClient, TransitionCore},
};
use crate::services::tier::{
tier_config::TierTencent,
warm_backend::{WarmBackend, WarmBackendGetOpts, build_transition_put_options},
warm_backend_s3::WarmBackendS3,
};
use rustfs_s3_client::{
admin_handler_utils::AdminError,
api_put_object::PutObjectOptions,
credentials::{Credentials, SignatureType, Static, Value},
transition_api::{BucketLookupType, Options, ReadCloser, ReaderImpl, TransitionClient, TransitionCore},
};
use tracing::warn;
const MAX_MULTIPART_PUT_OBJECT_SIZE: i64 = 1024 * 1024 * 1024 * 1024 * 5;
@@ -21,14 +21,12 @@ use std::{
use s3s::header::{X_AMZ_DELETE_MARKER, X_AMZ_VERSION_ID};
use uuid::Uuid;
use crate::{
client::transition_api::{BucketLookupType, ReadCloser, ReaderImpl},
services::tier::{
tier_config::{TierS3, TierWasabi},
warm_backend::{WarmBackend, WarmBackendGetOpts},
warm_backend_s3::WarmBackendS3,
},
use crate::services::tier::{
tier_config::{TierS3, TierWasabi},
warm_backend::{WarmBackend, WarmBackendGetOpts},
warm_backend_s3::WarmBackendS3,
};
use rustfs_s3_client::transition_api::{BucketLookupType, ReadCloser, ReaderImpl};
const WASABI_VERSIONING_DRIFT_ERROR: &str = "Wasabi tier bucket versioning changed after configuration";
@@ -184,7 +182,7 @@ impl WarmBackend for WarmBackendWasabi {
#[cfg(test)]
mod tests {
use super::*;
use crate::client::{
use rustfs_s3_client::{
credentials::{Credentials, SignatureType, Static, Value},
transition_api::{Options, TransitionCore},
};
@@ -221,7 +219,7 @@ mod tests {
async fn backend_for_endpoint(endpoint: &str, max_retries: i64) -> WarmBackendWasabi {
let client = Arc::new(
crate::client::transition_api::TransitionClient::new(
rustfs_s3_client::transition_api::TransitionClient::new(
endpoint,
Options {
creds: Credentials::new(Static(Value {
@@ -653,7 +651,7 @@ mod tests {
assert_ne!(read, 0, "connection closed before request headers were received");
request.extend_from_slice(&buffer[..read]);
}
let body = vec![b'x'; crate::client::transition_api::MAX_S3_ERROR_RESPONSE_SIZE + 1];
let body = vec![b'x'; rustfs_s3_client::transition_api::MAX_S3_ERROR_RESPONSE_SIZE + 1];
let head = format!(
"HTTP/1.1 500 Internal Server Error\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
body.len()
+179 -36
View File
@@ -22,20 +22,47 @@
//! byte-identical to the pre-move sources; only the module header
//! (`use super::*;` -> `use super::super::*;`) and item visibility change.
use super::super::*;
#[cfg(test)]
use super::super::ENV_RUSTFS_GET_METADATA_DATA_READ_EARLY_STOP_ENABLE;
#[cfg(test)]
use super::super::ENV_RUSTFS_GET_METADATA_EARLY_STOP_BOUNDED_FANOUT;
#[cfg(test)]
use super::super::ENV_RUSTFS_GET_METADATA_EARLY_STOP_ENABLE;
#[cfg(test)]
use super::super::ENV_RUSTFS_GET_METADATA_SLOWTAIL_FAULT_BUCKET;
#[cfg(test)]
use super::super::ENV_RUSTFS_GET_METADATA_SLOWTAIL_FAULT_DELAY_MS;
#[cfg(test)]
use super::super::ENV_RUSTFS_GET_METADATA_SLOWTAIL_FAULT_DISKS;
#[cfg(test)]
use super::super::ENV_RUSTFS_GET_METADATA_SLOWTAIL_FAULT_OBJECT_PREFIX;
#[cfg(test)]
use super::super::get_metadata_slowtail_fault_delay;
use super::super::{
Bytes, CHECK_PART_DISK_NOT_FOUND, DeleteOptions, DiskError, DiskStore, EVENT_SET_DISK_RENAME_TAIL_DRAIN_FAILED,
EVENT_SET_DISK_WRITE, Error, FileInfo, FileMeta, FileMetaShallowVersion, GetCodecStreamingFallbackReason,
GetObjectMetadataCacheEntry, HTTPPreconditions, HashAlgorithm, HealAdmissionResult, HealChannelPriority, HealRequestSource,
LOG_COMPONENT_ECSTORE, LOG_SUBSYSTEM_SET_DISK, MultipartWriteQuorumContext, OBJECT_OP_IGNORED_ERRS, ObjectOptions,
ObjectPartInfo, OffsetDateTime, RUSTFS_META_BUCKET, RUSTFS_META_MULTIPART_BUCKET, RawFileInfo, ReadMultipleReq,
ReadMultipleResp, ReadOptions, Result, SLASH_SEPARATOR, STORAGE_FORMAT_FILE, SetDisks, SnapshotLeaseToken, StorageError,
UpdateMetadataOpts, Uuid, build_inline_bitrot_readers_from_refs, can_try_inline_data_shards_direct,
capacity_scope_from_disks, coding, collect_inline_data_shard_fileinfos_by_index_or_reason, current_dirty_generation, debug,
disk, file_info_is_valid_for_metadata, get_metadata_slowtail_fault_request, info, inline_erasure_shard_file_offset,
inline_erasure_shard_size, is_err_object_not_found, is_err_version_not_found, is_get_metadata_data_read_early_stop_enabled,
is_get_metadata_early_stop_bounded_fanout_enabled, is_get_metadata_early_stop_enabled, is_object_dangling,
is_version_early_stop_enabled, issue3031_diag_enabled, join_all, join_errs, log_multipart_write_quorum_failure,
merge_file_meta_versions, path_join_buf, record_global_dirty_scope, reduce_read_quorum_errs, reduce_write_quorum_errs,
send_heal_request_with_admission, should_prevent_write, to_object_err, try_read_inline_data_shards_direct, warn,
};
#[cfg(test)]
use crate::bucket::lifecycle::lifecycle::TRANSITION_COMPLETE;
#[cfg(test)]
use crate::diagnostics::get::GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_IDENTITY_MISMATCH;
#[cfg(test)]
use crate::diagnostics::get::GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_MISSING_PAYLOAD;
use crate::diagnostics::get::{
GET_DIRECT_MEMORY_SUBPATH_DISK_DATA_BLOCKS, GET_DIRECT_MEMORY_SUBPATH_INLINE_BUFFERED, GET_METADATA_CACHE_DECISION_HIT,
GET_METADATA_CACHE_DECISION_MISS, GET_METADATA_CACHE_DECISION_REJECT, GET_METADATA_CACHE_DECISION_SKIP,
GET_METADATA_CACHE_REASON_DATA_MOVEMENT, GET_METADATA_CACHE_REASON_DELETE_MARKER, GET_METADATA_CACHE_REASON_DIST_ERASURE,
GET_METADATA_CACHE_REASON_INCL_FREE_VERSIONS, GET_METADATA_CACHE_REASON_INSUFFICIENT_CACHED_QUORUM,
GET_METADATA_CACHE_REASON_META_BUCKET, GET_METADATA_CACHE_REASON_NO_LOCK, GET_METADATA_CACHE_REASON_NOT_FOUND_OR_EXPIRED,
GET_METADATA_CACHE_REASON_NOT_READ_DATA, GET_METADATA_CACHE_REASON_PART_NUMBER,
GET_METADATA_CACHE_REASON_RAW_DATA_MOVEMENT_READ, GET_METADATA_CACHE_REASON_USABLE, GET_METADATA_CACHE_REASON_VERSION_ID,
GET_METADATA_CACHE_REASON_VERSION_SUSPENDED, GET_METADATA_CACHE_REASON_VERSIONED,
GET_METADATA_EARLY_STOP_REASON_CONFLICTING_METADATA, GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_BODY_VERIFY,
GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_DELETED, GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_GEOMETRY,
GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_IDENTITY_MISMATCH,
GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_MISSING_PAYLOAD,
GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_MISSING_SHARD, GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_NOT_INLINE,
GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_PART_SHAPE, GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_REMOTE,
GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_SIZE, GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_TRANSFORMED,
@@ -45,16 +72,26 @@ use crate::diagnostics::get::{
GET_METADATA_EARLY_STOP_REASON_VERSION_MATCH_QUORUM, GET_METADATA_EARLY_STOP_REASON_VERSION_NOT_FOUND,
GET_METADATA_RESPONSE_CORRUPT, GET_METADATA_RESPONSE_DISK_NOT_FOUND, GET_METADATA_RESPONSE_ERROR,
GET_METADATA_RESPONSE_IGNORED, GET_METADATA_RESPONSE_NOT_FOUND, GET_METADATA_RESPONSE_TIMEOUT, GET_METADATA_RESPONSE_VALID,
GET_METADATA_RESPONSE_VERSION_NOT_FOUND, GET_OBJECT_PATH_CODEC_STREAMING, GET_OBJECT_PATH_DIRECT_MEMORY,
GET_OBJECT_PATH_INTERNAL_META, GET_OBJECT_PATH_LEGACY_DUPLEX, GET_OBJECT_PATH_SET_DISK, GET_STAGE_DECODE,
GET_STAGE_METADATA_CACHE_LOOKUP, GET_STAGE_METADATA_RESOLVE, GET_STAGE_RANGE, GET_STAGE_READER_SETUP,
GET_STAGE_READER_SETUP_DROP_PENDING, GET_STAGE_READER_SETUP_SCHEDULE, GET_STAGE_READER_SETUP_WAIT_QUORUM,
GET_STAGE_READER_TASK_BITROT_READER_INIT, GET_STAGE_READER_TASK_FILE_OPEN, GET_STAGE_READER_TASK_READER_CONSTRUCTION,
GetObjectFailureReason, classify_disk_error, get_stage_timer_if_enabled, record_get_object_pipeline_failure,
record_get_object_pipeline_failure_for_path, record_get_stage_duration_if_enabled,
GET_METADATA_RESPONSE_VERSION_NOT_FOUND, GET_OBJECT_PATH_DIRECT_MEMORY, GET_OBJECT_PATH_INTERNAL_META,
GET_OBJECT_PATH_LEGACY_DUPLEX, GET_STAGE_READER_SETUP_DROP_PENDING, GET_STAGE_READER_SETUP_SCHEDULE,
GET_STAGE_READER_SETUP_WAIT_QUORUM, GET_STAGE_READER_TASK_BITROT_READER_INIT, GET_STAGE_READER_TASK_FILE_OPEN,
GET_STAGE_READER_TASK_READER_CONSTRUCTION, get_stage_timer_if_enabled, record_get_stage_duration_if_enabled,
};
use crate::disk::disk_store::{DiskStoreRenameDataExt, get_drive_metadata_timeout};
#[cfg(test)]
use crate::disk::CHECK_PART_FILE_NOT_FOUND;
use crate::disk::DiskAPI;
#[cfg(test)]
use crate::disk::DiskOption;
#[cfg(test)]
use crate::disk::RUSTFS_META_TMP_BUCKET;
use crate::disk::disk_store::get_drive_metadata_timeout;
#[cfg(test)]
use crate::disk::endpoint::Endpoint;
#[cfg(test)]
use crate::disk::format::FormatV3;
use crate::disk::local::DELETE_DATA_DIR_MARKER_PREFIX;
#[cfg(test)]
use crate::disk::new_disk;
use crate::disk::{
BATCH_READ_VERSION_MAX_ITEMS, BatchReadVersionItem, BatchReadVersionReq, BatchReadVersionResp, DataDirDeleteStatus, Disk,
OldCurrentSize, PART_TRANSACTION_NEW_META, PART_TRANSACTION_OLD_META, PART_TRANSACTION_ROLLBACK, PartTransactionAction,
@@ -65,9 +102,11 @@ use crate::io_support::bitrot::ShardReader;
use crate::io_support::bitrot::{
BitrotReaderStageMetrics, DeferredReaderStripeHandle, adjust_shard_read_params,
create_bitrot_reader_from_bytes_with_stage_metrics, create_deferred_bitrot_reader_with_stripe_handle,
object_mmap_read_enabled, object_mmap_read_max_length,
object_mmap_read_max_length,
};
use crate::set_disk::runtime_sources;
use crate::set_disk::shard_source::ShardReadCost;
use crate::storage_api_contracts::object::ObjectOperations;
use futures::FutureExt as _;
use futures::stream::{FuturesUnordered, StreamExt};
use metrics::counter;
@@ -281,6 +320,9 @@ async fn flush_read_version_coalescer_pending(
return;
}
// Only the #[cfg(test)] counter-recording block below reads this.
#[cfg(not(test))]
let _ = lane_key;
#[cfg(test)]
{
let mut observed_paths = HashSet::new();
@@ -1364,7 +1406,7 @@ pub(in crate::set_disk) enum ReadRepairAdmissionOutcome {
pub(in crate::set_disk) type ReadRepairAdmissionFuture = Pin<Box<dyn Future<Output = ReadRepairAdmissionOutcome> + Send>>;
pub(in crate::set_disk) type ReadRepairAdmissionSubmitter =
fn(rustfs_common::heal_channel::HealChannelRequest) -> ReadRepairAdmissionFuture;
fn(rustfs_heal_contracts::heal_channel::HealChannelRequest) -> ReadRepairAdmissionFuture;
pub(in crate::set_disk) struct ReadRepairHealSubmission<'a> {
pub(in crate::set_disk) bucket: &'a str,
@@ -1385,7 +1427,7 @@ pub(in crate::set_disk) struct ReadRepairHealSubmission<'a> {
}
pub(in crate::set_disk) fn send_read_repair_heal_request(
request: rustfs_common::heal_channel::HealChannelRequest,
request: rustfs_heal_contracts::heal_channel::HealChannelRequest,
) -> ReadRepairAdmissionFuture {
Box::pin(async {
match send_heal_request_with_admission(request).await {
@@ -1453,7 +1495,7 @@ pub(in crate::set_disk) async fn submit_read_repair_heal_with_submitter(
let _ = rustfs_common::mrf_channel::try_send_mrf_intent_typed(kind, bucket, object, version_uuid, Some(scope));
}
let mut request = rustfs_common::heal_channel::create_heal_request_with_options(
let mut request = rustfs_heal_contracts::heal_channel::create_heal_request_with_options(
bucket.to_string(),
Some(object.to_string()),
false,
@@ -1517,6 +1559,7 @@ pub(in crate::set_disk) async fn submit_read_repair_heal_with_submitter(
}
pub(in crate::set_disk) type ObjectBitrotReader = BitrotReader<ShardReader>;
pub(in crate::set_disk) type DeferredReaderReopener = crate::erasure::coding::decode::DeferredReaderReopener<ShardReader>;
pub(in crate::set_disk) type BitrotReaderTask<'a> =
Pin<Box<dyn Future<Output = (usize, std::result::Result<Option<ObjectBitrotReader>, DiskError>)> + Send + 'a>>;
@@ -1533,6 +1576,10 @@ pub(in crate::set_disk) struct BitrotReaderSetup {
/// readers. The lockstep GET decode uses them to open a parity shard
/// aligned to the stripe where a data shard failed (backlog#923).
pub(in crate::set_disk) deferred_stripe_handles: Vec<Option<DeferredReaderStripeHandle>>,
/// Factories for a fresh, stripe-aligned parity reader. CopySource hedges
/// use these disposable readers so an abandoned hedge leaves the original
/// deferred reserve untouched.
pub(in crate::set_disk) deferred_reopeners: Vec<Option<DeferredReaderReopener>>,
pub(in crate::set_disk) errors: Vec<Option<DiskError>>,
pub(in crate::set_disk) scheduled: Vec<bool>,
pub(in crate::set_disk) attempted: Vec<bool>,
@@ -1595,6 +1642,16 @@ pub(in crate::set_disk) fn get_bitrot_reader_setup_strategy(
mode: BitrotReaderSetupMode,
prefer_data_blocks_first: bool,
) -> BitrotReaderSetupStrategy {
// CopyObject holds the source reader behind a backpressured destination.
// Keep its setup demand-bound even when an operator has retained the
// legacy all-shards environment setting for ordinary GETs.
if matches!(
crate::set_disk::get_object_read_policy(),
crate::set_disk::GetObjectReadPolicy::CopySource
) {
return BitrotReaderSetupStrategy::DataBlocksFirst;
}
match mode {
BitrotReaderSetupMode::ReadQuorum
if prefer_data_blocks_first
@@ -1620,6 +1677,7 @@ impl BitrotReaderSetup {
Self {
readers: (0..shards).map(|_| None).collect(),
deferred_stripe_handles: (0..shards).map(|_| None).collect(),
deferred_reopeners: (0..shards).map(|_| None).collect(),
errors: vec![Some(DiskError::DiskNotFound); shards],
scheduled: vec![false; shards],
attempted: vec![false; shards],
@@ -1814,6 +1872,41 @@ pub(in crate::set_disk) fn next_unscheduled_reader_index(
.find(|idx| !setup.scheduled[*idx])
}
/// Build a cloneable opener for an unopened deferred shard. The returned
/// reader is aligned to the requested stripe before its first poll, while the
/// source reader created during setup remains untouched as a reserve.
#[allow(clippy::too_many_arguments)]
fn deferred_reader_reopener(
inline_data: Option<Bytes>,
disk: Option<DiskStore>,
bucket: &str,
path: &str,
read_offset: usize,
read_length: usize,
shard_size: usize,
checksum_algo: HashAlgorithm,
skip_verify_bitrot: bool,
use_mmap_read: bool,
) -> DeferredReaderReopener {
let bucket = bucket.to_owned();
let path = path.to_owned();
Arc::new(move |stripe_index| {
let (reader, handle) = create_deferred_bitrot_reader_with_stripe_handle(
inline_data.clone(),
disk.clone(),
&bucket,
&path,
read_offset,
read_length,
shard_size,
checksum_algo.clone(),
skip_verify_bitrot,
use_mmap_read,
);
handle.advance_stripes(stripe_index).then_some(reader)
})
}
#[allow(clippy::too_many_arguments)]
pub(in crate::set_disk) fn fill_deferred_bitrot_readers(
setup: &mut BitrotReaderSetup,
@@ -1836,6 +1929,15 @@ pub(in crate::set_disk) fn fill_deferred_bitrot_readers(
return;
}
// Only CopySource uses disposable, stripe-aligned reopeners. Ordinary GET
// readers use the existing deferred handle and should not retain one
// heap-allocated closure (plus cloned path/disk state) for every parity
// slot.
let copy_source_demand_bound = matches!(
crate::set_disk::get_object_read_policy(),
crate::set_disk::GetObjectReadPolicy::CopySource
);
for idx in 0..disks.len() {
if setup.attempted[idx] {
continue;
@@ -1849,6 +1951,20 @@ pub(in crate::set_disk) fn fill_deferred_bitrot_readers(
let disk = disks[idx].clone();
let data_dir = files[idx].data_dir.unwrap_or_default();
let path = format!("{object}/{data_dir}/part.{part_number}");
let reopener = copy_source_demand_bound.then(|| {
deferred_reader_reopener(
inline_data.clone(),
disk.clone(),
bucket,
&path,
read_offset,
read_length,
shard_size,
checksum_algo.clone(),
skip_verify_bitrot,
use_mmap_read,
)
});
let (reader, stripe_handle) = create_deferred_bitrot_reader_with_stripe_handle(
inline_data,
disk,
@@ -1862,6 +1978,7 @@ pub(in crate::set_disk) fn fill_deferred_bitrot_readers(
use_mmap_read,
);
setup.retain_deferred_reader(idx, reader, stripe_handle);
setup.deferred_reopeners[idx] = reopener;
}
// With the data-shards-only lockstep gate on (backlog#923), the GET decode
@@ -1887,6 +2004,20 @@ pub(in crate::set_disk) fn fill_deferred_bitrot_readers(
let disk = disks[idx].clone();
let data_dir = files[idx].data_dir.unwrap_or_default();
let path = format!("{object}/{data_dir}/part.{part_number}");
let reopener = copy_source_demand_bound.then(|| {
deferred_reader_reopener(
inline_data.clone(),
disk.clone(),
bucket,
&path,
read_offset,
read_length,
shard_size,
checksum_algo.clone(),
skip_verify_bitrot,
use_mmap_read,
)
});
let (reader, stripe_handle) = create_deferred_bitrot_reader_with_stripe_handle(
inline_data,
disk,
@@ -1901,6 +2032,7 @@ pub(in crate::set_disk) fn fill_deferred_bitrot_readers(
);
setup.readers[idx] = Some(reader);
setup.deferred_stripe_handles[idx] = Some(stripe_handle);
setup.deferred_reopeners[idx] = reopener;
}
}
@@ -2210,6 +2342,10 @@ pub(in crate::set_disk) async fn create_bitrot_readers_until_quorum_with_prefere
let strategy = get_bitrot_reader_setup_strategy(mode, prefer_data_blocks_first);
if use_mmap_read
&& !matches!(
crate::set_disk::get_object_read_policy(),
crate::set_disk::GetObjectReadPolicy::CopySource
)
&& let Some(mut setup) = try_create_bitrot_readers_via_batch_pread(
files,
disks,
@@ -3580,7 +3716,7 @@ pub(in crate::set_disk) async fn finish_rename_tail_heal<
tail_drain: tokio::task::JoinHandle<Option<RenameTailOutcome>>,
guard_release: tokio::sync::oneshot::Receiver<bool>,
guards: Guards,
request: rustfs_common::heal_channel::HealChannelRequest,
request: rustfs_heal_contracts::heal_channel::HealChannelRequest,
finalize: Finalize,
cleanup: Cleanup,
submit: Submit,
@@ -3590,7 +3726,7 @@ pub(in crate::set_disk) async fn finish_rename_tail_heal<
FinalizeFuture: Future<Output = ()> + Send,
Cleanup: FnOnce(Guards, Vec<RenameTailCleanup>) -> CleanupFuture + Send,
CleanupFuture: Future<Output = ()> + Send,
Submit: FnOnce(rustfs_common::heal_channel::HealChannelRequest) -> SubmitFuture + Send,
Submit: FnOnce(rustfs_heal_contracts::heal_channel::HealChannelRequest) -> SubmitFuture + Send,
SubmitFuture: Future<Output = ()> + Send,
{
let (needs_heal, tail_cleanup, tail_complete) = match tail_drain.await {
@@ -4939,16 +5075,17 @@ impl SetDisks {
// reclaim_orphan_data_dirs. Reuses the existing heal channel, which
// deduplicates and back-pressures via admission; failures only drop
// the return value (same shape as multipart's existing heal enqueue).
let _ =
rustfs_common::heal_channel::send_heal_request(rustfs_common::heal_channel::create_heal_request_with_options(
let _ = rustfs_heal_contracts::heal_channel::send_heal_request(
rustfs_heal_contracts::heal_channel::create_heal_request_with_options(
bucket.to_string(),
Some(object.to_string()),
false,
Some(rustfs_common::heal_channel::HealChannelPriority::Normal),
Some(rustfs_heal_contracts::heal_channel::HealChannelPriority::Normal),
Some(self.pool_index),
Some(self.set_index),
))
.await;
),
)
.await;
}
}
@@ -6667,7 +6804,7 @@ mod tests {
use super::*;
use std::io::Cursor;
use tempfile::TempDir;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::io::AsyncReadExt;
#[test]
fn write_precondition_lookup_errors_fail_closed_unless_absence_is_known() {
@@ -6800,18 +6937,24 @@ mod tests {
write_raw_file_meta_unchecked(disk, bucket, object, metadata).await;
}
fn failed_read_repair_submitter(_request: rustfs_common::heal_channel::HealChannelRequest) -> ReadRepairAdmissionFuture {
fn failed_read_repair_submitter(
_request: rustfs_heal_contracts::heal_channel::HealChannelRequest,
) -> ReadRepairAdmissionFuture {
Box::pin(async { ReadRepairAdmissionOutcome::Failed("injected submit failure".to_string()) })
}
fn accepted_read_repair_submitter(_request: rustfs_common::heal_channel::HealChannelRequest) -> ReadRepairAdmissionFuture {
fn accepted_read_repair_submitter(
_request: rustfs_heal_contracts::heal_channel::HealChannelRequest,
) -> ReadRepairAdmissionFuture {
Box::pin(async { ReadRepairAdmissionOutcome::Response(HealAdmissionResult::Accepted) })
}
fn dropped_read_repair_submitter(_request: rustfs_common::heal_channel::HealChannelRequest) -> ReadRepairAdmissionFuture {
fn dropped_read_repair_submitter(
_request: rustfs_heal_contracts::heal_channel::HealChannelRequest,
) -> ReadRepairAdmissionFuture {
Box::pin(async {
ReadRepairAdmissionOutcome::Response(HealAdmissionResult::Dropped(
rustfs_common::heal_channel::HealAdmissionDropReason::PolicyDropped,
rustfs_heal_contracts::heal_channel::HealAdmissionDropReason::PolicyDropped,
))
})
}
@@ -8853,7 +8996,7 @@ mod tests {
tail_drain,
released,
(),
rustfs_common::heal_channel::HealChannelRequest::default(),
rustfs_heal_contracts::heal_channel::HealChannelRequest::default(),
move || async move {
*finalize_captured.lock().expect("finalize recorder should not poison") = true;
},
+1 -1
View File
@@ -23,7 +23,7 @@
//! This module only establishes the borrow handle. It moves no trait impl and
//! changes no runtime behavior.
use super::*;
use super::{Arc, DiskStore, Endpoint, FormatV3, LockClient, RwLock, SetDisks};
/// Lightweight, `Copy` handle borrowing the shared [`SetDisks`] core state.
///
+12 -1
View File
@@ -12,8 +12,19 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use super::*;
use super::{
Bytes, DATA_MOVEMENT_MULTIPART_PREFIX, DiskError, DiskStore, FileInfo, HashMap, HashSet, OBJECT_OP_IGNORED_ERRS, ObjProps,
OffsetDateTime, SetDisks, Sha256, TRANSITION_COMPLETE, Uuid, debug, disk, error, file_info_is_valid_for_metadata, hex,
reduce_read_quorum_errs, warn,
};
#[cfg(test)]
use crate::disk::DiskOption;
#[cfg(test)]
use crate::disk::endpoint::Endpoint;
#[cfg(test)]
use crate::disk::new_disk;
use rustfs_utils::http;
use sha2::Digest;
#[derive(Clone, Copy)]
struct FileInfoIdentityGroup {
+146 -55
View File
@@ -38,24 +38,15 @@
//! read primitives it drives.
//! - `metadata.rs`, `replication.rs`, `shard_source.rs` — supporting helpers.
// #730: SetDisks still hosts staged read/heal/write migration helpers.
#![allow(unused_imports)]
#![allow(unused_variables)]
use crate::bucket::lifecycle::lifecycle::TRANSITION_COMPLETE;
use crate::bucket::metadata_sys;
use crate::bucket::metadata_sys::ObjectLockConfigState;
use crate::bucket::object_lock::objectlock_sys::{
check_object_lock_for_deletion_with_config, check_object_lock_for_deletion_with_state, check_retention_for_modification,
replication_write_may_pass_worm_gate,
check_object_lock_for_deletion_with_state, check_retention_for_modification, replication_write_may_pass_worm_gate,
};
use crate::bucket::replication::{
ReplicateDecision, ReplicationObjectBridge, ReplicationState, ReplicationStatusType, VersionPurgeStatusType,
replication_state_to_filemeta,
};
use crate::bucket::versioning::VersioningApi;
use crate::bucket::versioning_sys::BucketVersioningSys;
use crate::client::{object_api_utils::get_raw_etag, transition_api::ObjectReader, transition_api::ReaderImpl};
#[cfg(test)]
use crate::bucket::replication::ReplicationState;
use crate::bucket::replication::{ReplicateDecision, ReplicationObjectBridge, ReplicationStatusType, VersionPurgeStatusType};
use crate::cluster::rpc::heal_bucket_local_on_disks;
use crate::data_usage::record_compression_total_memory;
use crate::diagnostics::get::{
@@ -74,9 +65,11 @@ use crate::disk::error_reduce::{
BUCKET_OP_IGNORED_ERRS, OBJECT_OP_IGNORED_ERRS, build_write_quorum_failure_summary, count_errs, reduce_read_quorum_errs,
reduce_write_quorum_errs,
};
#[cfg(test)]
use crate::disk::has_part_err;
use crate::disk::{
self, CHECK_PART_DISK_NOT_FOUND, CHECK_PART_FILE_CORRUPT, CHECK_PART_FILE_NOT_FOUND, CHECK_PART_SUCCESS, CHECK_PART_UNKNOWN,
conv_part_err_to_int, has_part_err,
conv_part_err_to_int,
};
use crate::disk::{STORAGE_FORMAT_FILE, count_part_not_success};
use crate::erasure::codec::bridge::{
@@ -88,17 +81,17 @@ use crate::error::{GenericError, ObjectApiError, is_err_object_not_found};
use crate::io_support::bitrot::{create_bitrot_reader, create_bitrot_reader_from_bytes, create_bitrot_writer};
use crate::object_api::ObjectOptions;
use crate::object_api::get_object_body_cache_hook;
use crate::object_api::object_api_utils::get_raw_etag;
use crate::runtime::instance::{InstanceContext, bootstrap_ctx};
use crate::runtime::sources as runtime_sources;
use crate::services::batch_processor::AsyncBatchProcessor;
#[cfg(test)]
use crate::storage_api_contracts::multipart::MultipartOperations;
use crate::storage_api_contracts::{
bucket::{BucketInfo, BucketOperations, BucketOptions, DeleteBucketOptions, MakeBucketOptions},
list::{StorageListObjectVersionsInfo, StorageListObjectsV2Info, StorageObjectInfoOrErr, StorageWalkOptions},
multipart::{
CompletePart, ListMultipartsInfo, ListPartsInfo, MultipartInfo, MultipartOperations as _, MultipartUploadResult, PartInfo,
},
multipart::{CompletePart, ListMultipartsInfo, ListPartsInfo, MultipartInfo, MultipartUploadResult, PartInfo},
namespace::NamespaceLocking as _,
object::{DeleteAccounting, DeletedObject, HTTPPreconditions, ObjectIO as _, ObjectOperations as _, ObjectToDelete},
object::{DeleteAccounting, DeletedObject, HTTPPreconditions, ObjectIO as _, ObjectToDelete},
range::HTTPRangeSpec,
};
use crate::store::utils::is_reserved_or_invalid_bucket;
@@ -109,7 +102,7 @@ use crate::{
disk::{
CheckPartsResp, DeleteOptions, DiskAPI, DiskInfo, DiskInfoOptions, DiskOption, DiskStore, FileInfoVersions,
RUSTFS_META_BUCKET, RUSTFS_META_MULTIPART_BUCKET, RUSTFS_META_TMP_BUCKET, ReadMultipleReq, ReadMultipleResp, ReadOptions,
SnapshotLeaseToken, UpdateMetadataOpts, endpoint::Endpoint, error::DiskError, format::FormatV3, new_disk,
SnapshotLeaseToken, UpdateMetadataOpts, endpoint::Endpoint, error::DiskError, format::FormatV3,
},
error::{StorageError, to_object_err},
object_api::{GetObjectReader, NamespaceLockFence, ObjectInfo, ObjectLockConfigSnapshot, PutObjReader},
@@ -122,42 +115,40 @@ use crate::{
};
use bytes::Bytes;
use bytesize::ByteSize;
use chrono::Utc;
use futures::future::join_all;
use futures::task::AtomicWaker;
use glob::Pattern;
use http::HeaderMap;
use md5::{Digest as Md5Digest, Md5};
use rand::{Rng, seq::SliceRandom};
use regex::Regex;
use rustfs_common::heal_channel::{
DriveState, HealAdmissionResult, HealChannelPriority, HealItemType, HealOpts, HealRequestSource, HealScanMode,
send_heal_disk, send_heal_request_with_admission,
};
use rustfs_config::MI_B;
use rustfs_filemeta::{
FileInfo, FileMeta, FileMetaShallowVersion, MetaCacheEntries, MetaCacheEntry, MetadataResolutionParams, ObjectPartInfo,
RawFileInfo, file_info_from_raw, merge_file_meta_versions,
FileInfo, FileMeta, FileMetaShallowVersion, MetaCacheEntries, MetaCacheEntry, ObjectPartInfo, RawFileInfo,
merge_file_meta_versions,
};
use rustfs_heal_contracts::heal_channel::{
DriveState, HealAdmissionResult, HealChannelPriority, HealItemType, HealOpts, HealRequestSource, HealScanMode,
send_heal_disk, send_heal_request_with_admission,
};
use rustfs_io_metrics::{
record_object_lock_diag_acquire_duration, record_object_lock_diag_enabled, record_object_lock_diag_hold_duration,
record_object_lock_diag_slow_acquire, record_object_lock_diag_slow_hold,
};
use rustfs_lock::LockClient;
#[cfg(test)]
use rustfs_lock::LockManager;
use rustfs_lock::fast_lock::types::LockResult;
use rustfs_lock::local_lock::LocalLock;
use rustfs_lock::{FastLockGuard, LockManager, NamespaceLock, NamespaceLockGuard, NamespaceLockWrapper, ObjectKey};
use rustfs_lock::{FastLockGuard, NamespaceLock, NamespaceLockGuard, NamespaceLockWrapper, ObjectKey};
use rustfs_madmin::heal_commands::{HealDriveInfo, HealResultItem, Infos};
use rustfs_object_capacity::capacity_scope::{
CapacityScope, CapacityScopeDisk, current_dirty_generation, record_capacity_scope, record_global_dirty_scope,
};
use rustfs_s3_client::transition_api::{ObjectReader, ReaderImpl};
use rustfs_s3_types::EventName;
#[cfg(test)]
use rustfs_utils::http::SSEC_ALGORITHM_HEADER;
use rustfs_utils::http::headers::AMZ_OBJECT_TAGGING;
use rustfs_utils::http::headers::AMZ_STORAGE_CLASS;
use rustfs_utils::http::headers::{
CACHE_CONTROL, CONTENT_DISPOSITION, CONTENT_ENCODING, CONTENT_LANGUAGE, CONTENT_TYPE, EXPIRES, HeaderExt as _,
CACHE_CONTROL, CONTENT_DISPOSITION, CONTENT_ENCODING, CONTENT_LANGUAGE, CONTENT_TYPE, EXPIRES,
};
use rustfs_utils::http::{
SUFFIX_ACTUAL_OBJECT_SIZE_CAP, SUFFIX_ACTUAL_SIZE, SUFFIX_BUCKET_INCARNATION_ID, SUFFIX_COMPRESSION, SUFFIX_COMPRESSION_SIZE,
@@ -170,30 +161,29 @@ use rustfs_utils::{
path::{SLASH_SEPARATOR, encode_dir_object, has_suffix, path_join_buf},
};
use s3s::header::{X_AMZ_OBJECT_LOCK_LEGAL_HOLD, X_AMZ_OBJECT_LOCK_MODE, X_AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE, X_AMZ_RESTORE};
use sha2::{Digest, Sha256};
use std::future::Future;
use sha2::Sha256;
use std::hash::{BuildHasher, Hash, Hasher};
use std::mem::{self};
use std::pin::Pin;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, OnceLock};
use std::task::{Context, Poll};
use std::time::{Instant, SystemTime, UNIX_EPOCH};
use std::{
collections::{HashMap, HashSet},
io::{Cursor, Write},
io::Cursor,
path::Path,
time::Duration,
};
use time::OffsetDateTime;
#[cfg(test)]
use tokio::sync::mpsc;
use tokio::sync::mpsc::Sender;
#[cfg(test)]
use tokio::time::timeout;
use tokio::{
io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, BufReader, ReadBuf},
sync::{RwLock, broadcast},
};
use tokio::{
select,
sync::mpsc::{self, Sender},
time::{interval, timeout},
io::{AsyncRead, AsyncWrite, BufReader, ReadBuf},
sync::RwLock,
};
use tokio_util::sync::CancellationToken;
use tracing::error;
@@ -346,7 +336,7 @@ const ENV_RUSTFS_MULTIPART_PUT_LARGE_BATCH_MIN_SIZE_BYTES: &str = "RUSTFS_MULTIP
const DEFAULT_RUSTFS_MULTIPART_PUT_LARGE_BATCH_MIN_SIZE_BYTES: usize = 128 * 1024 * 1024;
static CACHED_MULTIPART_PUT_LARGE_BATCH_MIN_SIZE_BYTES: OnceLock<usize> = OnceLock::new();
use crate::io_support::rio::{EtagResolvable, HashReader, HashReaderMut, TryGetIndex as _};
use crate::io_support::rio::HashReader;
pub const DEFAULT_READ_BUFFER_SIZE: usize = MI_B; // 1 MiB = 1024 * 1024;
pub const MAX_PARTS_COUNT: usize = 10000;
@@ -792,6 +782,65 @@ const ENV_RUSTFS_GET_METADATA_SLOWTAIL_FAULT_OBJECT_PREFIX: &str = "RUSTFS_GET_M
const ENV_RUSTFS_GET_MULTIPART_READER_SETUP_PREFETCH: &str = "RUSTFS_GET_MULTIPART_READER_SETUP_PREFETCH";
const DEFAULT_RUSTFS_GET_MULTIPART_READER_SETUP_PREFETCH: bool = true;
/// Identifies the caller's read contract for policies that are deliberately
/// narrower than the storage API's ordinary GET contract.
///
/// Server-side copy consumes a source reader while a destination writer is
/// applying backpressure. Its source read must not speculatively open the
/// next multipart part: those extra shard streams can share an internode H2
/// connection with the current part and starve the lockstep decoder. Keep
/// this context internal so the public `ObjectOptions` and storage traits do
/// not acquire a copy-only field.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub(crate) enum GetObjectReadPolicy {
#[default]
Default,
CopySource,
}
impl GetObjectReadPolicy {
pub(crate) const fn allows_multipart_setup_prefetch(self) -> bool {
matches!(self, Self::Default)
}
}
tokio::task_local! {
static GET_OBJECT_READ_POLICY: GetObjectReadPolicy;
static GET_OBJECT_READ_CANCELLATION: tokio_util::sync::CancellationToken;
}
pub(crate) fn get_object_read_policy() -> GetObjectReadPolicy {
GET_OBJECT_READ_POLICY.try_with(|policy| *policy).unwrap_or_default()
}
pub(crate) async fn with_get_object_read_policy<F>(policy: GetObjectReadPolicy, future: F) -> F::Output
where
F: std::future::Future,
{
let decode_policy = match policy {
GetObjectReadPolicy::Default => crate::erasure::coding::decode::DecodeReadPolicy::Default,
GetObjectReadPolicy::CopySource => crate::erasure::coding::decode::DecodeReadPolicy::DemandBound,
};
crate::erasure::coding::decode::with_decode_read_policy(decode_policy, GET_OBJECT_READ_POLICY.scope(policy, future)).await
}
/// Return the request-owned cancellation token for a copy source, when one is
/// installed. The token is read before the detached legacy producer is spawned;
/// Tokio task-local values do not cross that spawn boundary on their own.
pub(crate) fn get_object_read_cancellation() -> Option<tokio_util::sync::CancellationToken> {
GET_OBJECT_READ_CANCELLATION.try_with(|token| token.clone()).ok()
}
pub(crate) async fn with_get_object_read_cancellation<F>(
cancellation: tokio_util::sync::CancellationToken,
future: F,
) -> F::Output
where
F: std::future::Future,
{
GET_OBJECT_READ_CANCELLATION.scope(cancellation, future).await
}
static OBJECT_LOCK_DIAG_ENABLED: OnceLock<bool> = OnceLock::new();
mod core;
@@ -812,7 +861,7 @@ pub(crate) use ops::object::DeleteObjectCommitBarrier;
#[cfg(feature = "test-util")]
pub(crate) use ops::object::TransitionCleanupStoreBarrier as SetDiskTransitionCleanupStoreBarrier;
pub(crate) use ops::object::body_cache_plaintext_len;
#[cfg(test)]
#[cfg(all(test, feature = "test-util"))]
pub(crate) use ops::object::cleanup_rejected_transition_upload_durably;
#[cfg(any(test, feature = "test-util"))]
pub use ops::object::{PutObjectCommitBarrier, PutObjectCommitPause};
@@ -969,8 +1018,7 @@ mod prepared_get_object_metadata_tests {
use crate::ecstore_validation_blackbox::make_local_set_disks;
use crate::object_api::{BLOCK_SIZE_V2, PutObjReader};
use crate::set_disk::core::io_primitives::{bounded_metadata_fanout_order, disk_call_counters, rename_fanout_barrier};
use crate::storage_api_contracts::bucket::{BucketOperations as _, MakeBucketOptions};
use crate::storage_api_contracts::object::{ObjectIO as _, ObjectOperations as _};
use crate::storage_api_contracts::bucket::MakeBucketOptions;
use crate::test_metrics::CapturingRecorder;
use http::HeaderMap;
use tokio::io::AsyncReadExt;
@@ -2296,6 +2344,7 @@ enum GetCodecStreamingFallbackReason {
InvalidMinSize,
ReadQuorumNotSafe,
MultipartPartLimit,
CopySourceDemandBound,
}
impl GetCodecStreamingFallbackReason {
@@ -2317,6 +2366,7 @@ impl GetCodecStreamingFallbackReason {
Self::InvalidMinSize => "invalid_min_size",
Self::ReadQuorumNotSafe => "read_quorum_not_safe",
Self::MultipartPartLimit => "multipart_part_limit",
Self::CopySourceDemandBound => "copy_source_demand_bound",
}
}
}
@@ -2623,6 +2673,17 @@ fn get_codec_streaming_reader_gate(
prefer_data_blocks_first_reader_setup: false,
};
}
if matches!(get_object_read_policy(), GetObjectReadPolicy::CopySource) {
// The codec reader has its own bounded fill worker. It may still
// request an additional stripe for a plain single-part object even
// when multipart setup prefetch is disabled, so copy sources use the
// legacy demand-bound reader for every object class.
return GetCodecStreamingGate {
object_class,
decision: GetCodecStreamingDecision::Fallback(GetCodecStreamingFallbackReason::CopySourceDemandBound),
prefer_data_blocks_first_reader_setup: false,
};
}
if !config.rollout.is_opted_in() {
return GetCodecStreamingGate {
object_class,
@@ -3111,8 +3172,9 @@ pub struct SetDisks {
#[cfg(test)]
storage_class_config_override: Arc<std::sync::RwLock<Option<Arc<storageclass::Config>>>>,
#[cfg(test)]
rename_tail_heal_capture:
Arc<std::sync::Mutex<Option<tokio::sync::mpsc::UnboundedSender<rustfs_common::heal_channel::HealChannelRequest>>>>,
rename_tail_heal_capture: Arc<
std::sync::Mutex<Option<tokio::sync::mpsc::UnboundedSender<rustfs_heal_contracts::heal_channel::HealChannelRequest>>>,
>,
}
// DistributedLock sends the raw ObjectKey to its clients; LockRegistry clones
@@ -3388,7 +3450,10 @@ impl DiskHealthEntry {
}
impl SetDisks {
pub(in crate::set_disk) async fn submit_rename_tail_heal(&self, request: rustfs_common::heal_channel::HealChannelRequest) {
pub(in crate::set_disk) async fn submit_rename_tail_heal(
&self,
request: rustfs_heal_contracts::heal_channel::HealChannelRequest,
) {
#[cfg(test)]
{
let capture = self
@@ -3402,13 +3467,13 @@ impl SetDisks {
}
}
let _ = rustfs_common::heal_channel::send_heal_request(request).await;
let _ = rustfs_heal_contracts::heal_channel::send_heal_request(request).await;
}
#[cfg(test)]
pub(in crate::set_disk) fn capture_test_rename_tail_heals(
&self,
) -> tokio::sync::mpsc::UnboundedReceiver<rustfs_common::heal_channel::HealChannelRequest> {
) -> tokio::sync::mpsc::UnboundedReceiver<rustfs_heal_contracts::heal_channel::HealChannelRequest> {
let (capture, requests) = tokio::sync::mpsc::unbounded_channel();
let mut slot = self
.rename_tail_heal_capture
@@ -4612,7 +4677,10 @@ fn check_object_lock_retention_update(bucket: &str, object: &str, obj_info: &Obj
if let Some(retention) = &opts.object_lock_retention
&& check_retention_for_modification(
&obj_info.user_defined,
retention.mode.as_deref(),
retention
.mode
.as_deref()
.and_then(crate::bucket::object_lock::types::RetentionMode::parse_exact),
retention.retain_until,
retention.bypass_governance,
)
@@ -5891,7 +5959,7 @@ mod tests {
use crate::set_disk::core::io_primitives::rename_fanout_barrier;
use crate::storage_api_contracts::{
heal::HealOperations as _, lifecycle::TransitionedObject, list::ListOperations as _, multipart::CompletePart,
namespace::NamespaceLocking as _, object::ObjectIO as _, object::ObjectOperations as _,
object::ObjectOperations as _,
};
use crate::store::init_format::save_format_file;
use crate::store::list_objects::ListPathOptions;
@@ -5908,6 +5976,29 @@ mod tests {
use tokio::fs;
use tokio::io::AsyncReadExt;
#[tokio::test]
async fn copy_source_read_policy_is_scoped_and_demand_bound() {
assert_eq!(get_object_read_policy(), GetObjectReadPolicy::Default);
assert!(GetObjectReadPolicy::Default.allows_multipart_setup_prefetch());
assert!(!GetObjectReadPolicy::CopySource.allows_multipart_setup_prefetch());
with_get_object_read_policy(GetObjectReadPolicy::CopySource, async {
assert_eq!(get_object_read_policy(), GetObjectReadPolicy::CopySource);
assert!(!get_object_read_policy().allows_multipart_setup_prefetch());
assert_eq!(
crate::erasure::coding::decode::decode_read_policy(),
crate::erasure::coding::decode::DecodeReadPolicy::DemandBound
);
})
.await;
assert_eq!(get_object_read_policy(), GetObjectReadPolicy::Default);
assert_eq!(
crate::erasure::coding::decode::decode_read_policy(),
crate::erasure::coding::decode::DecodeReadPolicy::Default
);
}
#[test]
fn complete_part_error_maps_confirmed_missing_to_invalid_part() {
for err in ["file not found", "Specified part could not be found", "part.7 not found"] {
@@ -12,7 +12,16 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use super::super::*;
use super::super::{
Cursor, DiskStore, EVENT_SET_DISK_WRITE, Error, FileInfo, HashAlgorithm, LOG_COMPONENT_ECSTORE, LOG_SUBSYSTEM_SET_DISK,
Result, join_all, warn,
};
use crate::disk::DiskAPI;
#[cfg(test)]
use crate::disk::RUSTFS_META_TMP_BUCKET;
use crate::set_disk::coding;
#[cfg(test)]
use bytes::Bytes;
/// Null out any disk whose shard writer failed (or was never created) so its
/// truncated/absent shard is not committed by the final rename, and return the
@@ -132,7 +141,6 @@ 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::disk::DiskAPI 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);
+6 -1
View File
@@ -19,7 +19,12 @@
//! `for SetDisks`, so its associated-type bounds are unchanged and runtime
//! behavior is the same.
use super::super::*;
use super::super::{
BUCKET_OP_IGNORED_ERRS, BucketInfo, BucketOperations, BucketOptions, DeleteBucketOptions, DiskError, Error, HashMap,
MakeBucketOptions, Result, SetDisks, is_reserved_or_invalid_bucket, join_all, reduce_write_quorum_errs,
};
use crate::api::bucket::metadata_sys;
use crate::disk::DiskAPI;
impl SetDisks {
pub(crate) async fn list_bucket_for_scanner(&self, _opts: &BucketOptions) -> Result<(Vec<BucketInfo>, bool)> {
+12 -3
View File
@@ -12,9 +12,18 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use super::super::*;
use super::super::{
Bytes, CHECK_PART_FILE_CORRUPT, CHECK_PART_SUCCESS, DeleteOptions, DiskError, DiskStore, DriveState, EVENT_SET_DISK_HEAL,
Error, FileInfo, HashAlgorithm, HashMap, HealDriveInfo, HealItemType, HealOpts, HealResultItem, HealScanMode, Infos,
LOG_SUBSYSTEM_SET_DISK, ObjectInfo, ObjectOptions, ObjectPartInfo, Path, RUSTFS_META_TMP_BUCKET, ReadOptions, Result,
SLASH_SEPARATOR, SetDisks, StorageError, Uuid, coding, count_errs, count_part_not_success, create_bitrot_reader,
create_bitrot_writer, debug, disk, disks_with_all_parts, encode_dir_object, error, file_info_is_valid_for_metadata,
formats_match_reference_slots, get_format_erasure_in_quorum, get_lock_acquire_timeout, has_suffix,
heal_bucket_local_on_disks, is_object_dir_dangling, join_all, load_format_erasure_all, path_join_buf, save_format_file,
should_heal_object_on_disk, stat_all_dirs, to_object_err, warn,
};
use crate::disk::DataDirDeleteStatus;
use crate::disk::disk_store::DiskStoreRenameDataExt;
use crate::disk::DiskAPI;
use crate::disk::local::DELETE_DATA_DIR_MARKER_PREFIX;
use crate::io_support::bitrot::object_mmap_read_enabled;
use crate::storage_api_contracts::namespace::NamespaceLocking as _;
@@ -2386,8 +2395,8 @@ mod heal_result_report_tests {
store::init_format::{load_format_erasure, save_format_file},
};
use bytes::Bytes;
use rustfs_common::heal_channel::{DriveState, HealOpts, HealScanMode};
use rustfs_filemeta::{BLOCK_SIZE_V2, FileInfo, ObjectPartInfo, TRANSITION_COMPLETE};
use rustfs_heal_contracts::heal_channel::{DriveState, HealOpts, HealScanMode};
use std::sync::{Arc, Mutex};
use tempfile::TempDir;
use time::OffsetDateTime;
+7 -1
View File
@@ -22,8 +22,14 @@
//! every `(object, version)` present on ANY disk, feeding each to the existing
//! per-version `SetDisks::heal_object`.
use super::super::*;
use super::super::{
Arc, CancellationToken, DiskError, ListPathRawOptions, MetaCacheEntries, MetaCacheEntry, SetDisks, debug, disk, list_path_raw,
};
#[cfg(test)]
use crate::disk::DiskAPI;
use crate::object_api::ObjectInfo;
#[cfg(test)]
use rustfs_filemeta::FileMeta;
use std::collections::HashSet;
use std::sync::Mutex;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};

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