Compare commits

..

3 Commits

Author SHA1 Message Date
overtrue 3f994c59eb fix(select): classify function argument planner errors 2026-08-11 23:14:23 +08:00
GatewayJ 7c1d9dec8f Merge branch 'main' into fix/s3-select-error-semantics 2026-08-11 14:13:53 +08:00
GatewayJ 4a8759239d fix(select): enforce typed S3 Select error semantics 2026-08-11 02:26:58 +08:00
384 changed files with 13226 additions and 53917 deletions
+5 -12
View File
@@ -34,8 +34,7 @@ e2e-vault = { max-threads = 1 }
# Reliability / fault-injection e2e tests each spawn a single-node 4-disk RustFS # Reliability / fault-injection e2e tests each spawn a single-node 4-disk RustFS
# server and manipulate its disk directories at runtime (crates/e2e_test: # server and manipulate its disk directories at runtime (crates/e2e_test:
# reliability_disk_fault_test, degraded_read_eof_regression_test / dist-13, and # reliability_disk_fault_test, degraded_read_eof_regression_test / dist-13). They
# replacement_privileged_e2e_test when explicitly run as root on Linux). They
# are correct in isolation but resource-heavy; serialize them under nextest's # are correct in isolation but resource-heavy; serialize them under nextest's
# process boundary (serial_test's #[serial] does not cross it) so several 4-disk # process boundary (serial_test's #[serial] does not cross it) so several 4-disk
# servers never run at once. ci-7's nightly picks these up via the e2e suite; # servers never run at once. ci-7's nightly picks these up via the e2e suite;
@@ -91,7 +90,7 @@ test-group = 'ecstore-serial-flaky'
# e2e-reliability test-group note above). The matching ci-profile override is at # e2e-reliability test-group note above). The matching ci-profile override is at
# the end of the file, after [profile.ci] is declared. # the end of the file, after [profile.ci] is declared.
[[profile.default.overrides]] [[profile.default.overrides]]
filter = 'package(e2e_test) & test(/^(reliability_disk_fault|degraded_read_eof_regression|replacement_privileged_e2e)_test::/)' filter = 'package(e2e_test) & test(/^(reliability_disk_fault|degraded_read_eof_regression)_test::/)'
test-group = 'e2e-reliability' test-group = 'e2e-reliability'
[[profile.default.overrides]] [[profile.default.overrides]]
@@ -156,7 +155,7 @@ retries = 2
# quarantine: no retries, just single-threaded so several 4-disk servers never # quarantine: no retries, just single-threaded so several 4-disk servers never
# run concurrently when ci-7's nightly runs the full e2e suite. # run concurrently when ci-7's nightly runs the full e2e suite.
[[profile.ci.overrides]] [[profile.ci.overrides]]
filter = 'package(e2e_test) & test(/^(reliability_disk_fault|degraded_read_eof_regression|replacement_privileged_e2e)_test::/)' filter = 'package(e2e_test) & test(/^(reliability_disk_fault|degraded_read_eof_regression)_test::/)'
test-group = 'e2e-reliability' test-group = 'e2e-reliability'
# Serialize the multipart crash-consistency scenarios under the ci profile too # Serialize the multipart crash-consistency scenarios under the ci profile too
@@ -252,16 +251,10 @@ test-group = 'ecstore-serial-flaky'
# cluster, so it keeps the lane's parallel-safe / no-external-dependency # cluster, so it keeps the lane's parallel-safe / no-external-dependency
# properties. The RustFS warm backend has no loopback guard (that guard is # properties. The RustFS warm backend has no loopback guard (that guard is
# replication-only), so it needs no opt-in env for its 127.0.0.1 tier target. # replication-only), so it needs no opt-in env for its 127.0.0.1 tier target.
#
# Disk compression (backlog#1848): the `compression` module joins the smoke
# lane so the multipart disk-compression roundtrips (restored after
# rustfs/rustfs#5169 disabled them) have PR-lane signal, not just merge-gate.
# Single-node servers on random ports with isolated temp dirs — meets the
# admission criteria unchanged.
[profile.e2e-smoke] [profile.e2e-smoke]
default-filter = """ default-filter = """
package(e2e_test) & ( package(e2e_test) & (
test(/^(delete_marker_migration_semantics|version_id_regression|list_objects_v2_pagination|list_object_versions_regression|list_objects_duplicates|list_buckets_double_slash|list_buckets_auth|list_buckets_iam_filter|leading_slash_key|special_chars|create_bucket_region|delete_objects_versioning|head_object_consistency|head_object_range|copy_object_metadata|copy_object_tagging|copy_source_invalid_date|content_encoding|compression|multipart_storage_class|storage_class_capability|ssec_copy|anonymous_access|bucket_policy_check|presigned_negative|negative_sigv4|admin_auth|notification_webhook|tls_hot_reload|console_smoke|admin_iam_crud|admin_pools|sts_query_compat)_test::|^fake_s3_target::/) test(/^(delete_marker_migration_semantics|version_id_regression|list_objects_v2_pagination|list_object_versions_regression|list_objects_duplicates|list_buckets_double_slash|list_buckets_auth|list_buckets_iam_filter|leading_slash_key|special_chars|create_bucket_region|delete_objects_versioning|head_object_consistency|head_object_range|copy_object_metadata|copy_object_tagging|copy_source_invalid_date|content_encoding|multipart_storage_class|storage_class_capability|ssec_copy|anonymous_access|bucket_policy_check|presigned_negative|negative_sigv4|admin_auth|notification_webhook|tls_hot_reload|console_smoke|admin_iam_crud|admin_pools|sts_query_compat)_test::|^fake_s3_target::/)
| test(/^replication_extension_test::(test_replication_check_succeeds_with_remote_target|test_replication_check_rejects_target_without_object_lock|test_set_remote_target_rejects_unversioned_source_bucket|test_replication_check_rejects_unversioned_source_bucket|test_replication_check_rejects_missing_replication_config|test_replication_check_rejects_invalid_bucket|test_set_remote_target_rejects_same_bucket_on_same_deployment|test_set_remote_target_rejects_unversioned_target_bucket|test_set_remote_target_update_requires_arn|test_set_remote_target_update_rejects_missing_target|test_set_remote_target_rejects_invalid_target_url|test_set_remote_target_rejects_self_signed_https_target_without_skip_tls_verify|test_set_remote_target_rejects_private_ca_https_target_without_ca_cert_pem|test_list_remote_targets_rejects_empty_bucket|test_list_remote_targets_rejects_invalid_bucket|test_remove_remote_target_rejects_missing_target|test_remove_remote_target_rejects_missing_arn|test_remove_remote_target_rejects_invalid_bucket|test_remove_remote_target_rejects_target_used_by_replication|test_delete_bucket_replication_removes_remote_target)$/) | test(/^replication_extension_test::(test_replication_check_succeeds_with_remote_target|test_replication_check_rejects_target_without_object_lock|test_set_remote_target_rejects_unversioned_source_bucket|test_replication_check_rejects_unversioned_source_bucket|test_replication_check_rejects_missing_replication_config|test_replication_check_rejects_invalid_bucket|test_set_remote_target_rejects_same_bucket_on_same_deployment|test_set_remote_target_rejects_unversioned_target_bucket|test_set_remote_target_update_requires_arn|test_set_remote_target_update_rejects_missing_target|test_set_remote_target_rejects_invalid_target_url|test_set_remote_target_rejects_self_signed_https_target_without_skip_tls_verify|test_set_remote_target_rejects_private_ca_https_target_without_ca_cert_pem|test_list_remote_targets_rejects_empty_bucket|test_list_remote_targets_rejects_invalid_bucket|test_remove_remote_target_rejects_missing_target|test_remove_remote_target_rejects_missing_arn|test_remove_remote_target_rejects_invalid_bucket|test_remove_remote_target_rejects_target_used_by_replication|test_delete_bucket_replication_removes_remote_target)$/)
| test(/^reliant::lifecycle::/) | test(/^reliant::lifecycle::/)
| test(/^reliant::tiering::/) | test(/^reliant::tiering::/)
@@ -390,7 +383,7 @@ path = "junit.xml"
# quarantine: no retries, just single-threaded so several 4-disk servers never # quarantine: no retries, just single-threaded so several 4-disk servers never
# run concurrently. # run concurrently.
[[profile.e2e-full.overrides]] [[profile.e2e-full.overrides]]
filter = 'package(e2e_test) & test(/^(reliability_disk_fault|degraded_read_eof_regression|replacement_privileged_e2e)_test::/)' filter = 'package(e2e_test) & test(/^(reliability_disk_fault|degraded_read_eof_regression)_test::/)'
test-group = 'e2e-reliability' test-group = 'e2e-reliability'
[[profile.e2e-full.overrides]] [[profile.e2e-full.overrides]]
@@ -17,11 +17,9 @@
# ============================================================================= # =============================================================================
# #
# Metric source: the KMS operation-policy choke point in # Metric source: the KMS operation-policy choke point in
# crates/kms/src/policy.rs, except KmsKeyRotationOverdue, which reads the # crates/kms/src/policy.rs. All label values are bounded static strings
# label-less key-lifecycle gauge published by the deletion worker's sweep # (operation, op_class, outcome, error_class, backend, scope); key identifiers,
# (crates/kms/src/deletion_worker.rs). All label values are bounded static # key material, and tokens never appear in labels.
# strings (operation, op_class, outcome, error_class, backend, scope); key
# identifiers, key material, and tokens never appear in labels.
# #
# Response procedures: docs/operations/kms-observability-runbook.md # Response procedures: docs/operations/kms-observability-runbook.md
# #
@@ -214,38 +212,3 @@ groups:
circuit_open until the half-open probe succeeds or returns circuit_open until the half-open probe succeeds or returns
a non-retryable failure. a non-retryable failure.
runbook_url: "https://github.com/rustfs/rustfs/blob/main/docs/operations/kms-observability-runbook.md#kmsbackendcircuitopen" runbook_url: "https://github.com/rustfs/rustfs/blob/main/docs/operations/kms-observability-runbook.md#kmsbackendcircuitopen"
# ------------------------------------------------------------------
# 7. KmsKeyRotationOverdue
# The least recently rotated usable key has gone more than 400
# days without a rotation (measured from creation for keys with
# no recorded rotation). Direct gauge state published by the
# deletion worker's sweep, so no traffic guard applies; the
# one-hour hold only bridges scrape gaps. The worker runs only
# on backends with the schedule_deletion capability, so on the
# Static backend the series never exists and this alert cannot
# fire — that backend cannot rotate either; see the rotation
# driver matrix in docs/operations/kms-backend-security.md.
# Threshold: 400 days — conservative default sitting above a
# one-year rotation policy. Align it with the rotation period
# your compliance policy requires, and with
# RUSTFS_KMS_ROTATION_MAX_AGE_SECS so the per-key rotation_due
# verdict and this aggregate alert agree.
# ------------------------------------------------------------------
- alert: KmsKeyRotationOverdue
expr: |
rustfs_kms_oldest_key_rotation_age_seconds > (400 * 86400)
for: 1h
labels:
severity: warning
component: kms
annotations:
summary: "Oldest KMS key unrotated for more than 400 days"
description: >-
The least recently rotated usable KMS key was last rotated
{{ $value | humanizeDuration }} ago (measured from creation
for keys with no recorded rotation). List keys through the
admin API and read rotation_due / rotation_due_reason for
the per-key verdict; an "unsupported" reason means the
backend cannot rotate at all.
runbook_url: "https://github.com/rustfs/rustfs/blob/main/docs/operations/kms-observability-runbook.md#kmskeyrotationoverdue"
+1 -1
View File
@@ -85,7 +85,7 @@ runs:
repo-token: ${{ github.token }} repo-token: ${{ github.token }}
- name: Install flatc - name: Install flatc
uses: Nugine/setup-flatc@698800de72a96bfb22cf60431dc21a2ff9a7e07b # v1 uses: Nugine/setup-flatc@e7855e994773ce90094a3f1626d4afc9080c23ae # v1
with: with:
version: "25.12.19" version: "25.12.19"
+1 -6
View File
@@ -182,12 +182,7 @@ jobs:
echo '```' echo '```'
} >> "$GITHUB_STEP_SUMMARY" } >> "$GITHUB_STEP_SUMMARY"
# Readers: test-and-lint-rio-v2 (per-PR), build-rustfs-debug-binary-rio-v2 # Readers: test-and-lint-rio-v2, build-rustfs-debug-binary-rio-v2.
# (weekly schedule / manual dispatch only — dormant rio-v2 variant, see
# rustfs/backlog#1835 and docs/architecture/minio-file-format-compat.md).
# The second build below stays despite the reduced cadence: it warms the
# rio-v2,e2e-test-hooks feature resolution the scheduled build restores,
# which keeps that lane inside its 30-minute timeout.
warm-ci-feat-rio: warm-ci-feat-rio:
name: Warm ci-feat-rio name: Warm ci-feat-rio
runs-on: sm-standard-4 runs-on: sm-standard-4
+1 -9
View File
@@ -533,12 +533,7 @@ jobs:
build-rustfs-debug-binary-rio-v2: build-rustfs-debug-binary-rio-v2:
name: Build RustFS Debug Binary (rio-v2) name: Build RustFS Debug Binary (rio-v2)
# Dormant rio-v2 variant (rustfs/backlog#1835): the feature ships in no if: github.event_name != 'pull_request' || github.event.action != 'closed'
# default build, so this full-suite lane runs only on the weekly schedule
# and manual dispatch. Per-PR cfg-seam coverage stays with
# test-and-lint-rio-v2. Lifecycle and the promote-or-delete condition:
# docs/architecture/minio-file-format-compat.md ("rio-v2 variant lifecycle").
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
needs: [ quick-checks ] needs: [ quick-checks ]
runs-on: sm-standard-4 runs-on: sm-standard-4
timeout-minutes: 30 timeout-minutes: 30
@@ -829,9 +824,6 @@ jobs:
e2e-tests-rio-v2: e2e-tests-rio-v2:
name: End-to-End Tests (rio-v2) name: End-to-End Tests (rio-v2)
# Inherits the schedule/dispatch-only gate through needs: on every other
# event build-rustfs-debug-binary-rio-v2 is skipped, so this job skips
# with it (see the dormant-variant comment on that job).
needs: [ build-rustfs-debug-binary-rio-v2 ] needs: [ build-rustfs-debug-binary-rio-v2 ]
runs-on: sm-standard-2 runs-on: sm-standard-2
timeout-minutes: 30 timeout-minutes: 30
-139
View File
@@ -55,142 +55,3 @@ jobs:
- name: Build RustFS - name: Build RustFS
run: cargo build --release --locked --target x86_64-unknown-linux-gnu -p rustfs --bins run: cargo build --release --locked --target x86_64-unknown-linux-gnu -p rustfs --bins
# Live-Vault lane for the rustfs-kms suite (rustfs/backlog#1774).
#
# RUSTFS_KMS_VAULT_TOKEN is the single switch that adds the Vault KV2 and
# Vault Transit backends to every for_each_backend spec in
# crates/kms/tests/behavior_*.rs (see crates/kms/AGENTS.md). rotate and
# versioning are advertised only by the Vault backends, so without this lane
# no CI run ever asserts the working half of behavior_rotation.rs — a
# rotation that silently dropped historical key versions would stay green.
# The same lane runs the dev-Vault #[ignore] tests and the two self-hosting
# live scripts (AppRole login, three-node Raft leader failover).
#
# GitHub-hosted ubuntu-latest, deliberately not the self-hosted sm-standard
# fleet: the HA failover script needs a working Docker daemon, and the
# self-hosted fleet is heterogeneous — a docker-dependent workflow has been
# burned by it before (see the banner in e2e-s3tests.yml, rustfs/backlog#1149).
kms-vault-lane:
name: KMS live Vault lane
runs-on: ubuntu-latest
timeout-minutes: 90
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
# Root token of the ephemeral loopback dev server. Not a secret: the
# server lives only for this job, listens on 127.0.0.1, and holds only
# keys the tests create. The literal value matters — the dev-Vault
# #[ignore] fixtures in crates/kms/src/backends/vault.rs hardcode it.
VAULT_LANE_TOKEN: dev-only-token
VAULT_LANE_ADDR: http://127.0.0.1:8200
# Keeps a runner-level proxy from swallowing the loopback dev-server
# traffic (see crates/kms/AGENTS.md). Actions env keys are
# case-insensitive, so only the uppercase form is set; reqwest reads
# either casing.
NO_PROXY: 127.0.0.1,localhost
steps:
- name: Checkout main branch
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
ref: main
- name: Setup Rust environment
uses: ./.github/actions/setup
with:
# Dedicated key: rust-cache cannot tell runner images apart, so
# sharing a key with an sm-standard lane would let two different
# system images overwrite each other's artifacts (same reasoning as
# ci.yml's ci-uring lane). Saved from this nightly job itself so the
# next night starts warm.
cache-shared-key: kms-vault-lane
cache-save-if: 'true'
install-build-packaging-tools: 'false'
install-test-tools: 'false'
- name: Install Vault CLI
run: |
set -euo pipefail
wget -qO- https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg
echo "deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list >/dev/null
sudo apt-get update -qq
sudo apt-get install -y -qq vault
vault version
- name: Start Vault dev server with KV2 and Transit engines
run: |
set -euo pipefail
nohup vault server -dev \
-dev-root-token-id="${VAULT_LANE_TOKEN}" \
-dev-listen-address=127.0.0.1:8200 >/tmp/vault-dev.log 2>&1 &
for _ in $(seq 1 60); do
if curl -fsS "${VAULT_LANE_ADDR}/v1/sys/health" >/dev/null 2>&1; then
break
fi
sleep 1
done
curl -fsS "${VAULT_LANE_ADDR}/v1/sys/health"
export VAULT_ADDR="${VAULT_LANE_ADDR}" VAULT_TOKEN="${VAULT_LANE_TOKEN}"
# Dev mode mounts KV v2 at secret/ by default; Transit is explicit.
# Prove both engines actually work rather than assuming the defaults.
vault secrets enable transit
vault kv put secret/rustfs-ci-lane-probe value=ok >/dev/null
vault kv get secret/rustfs-ci-lane-probe >/dev/null
vault write -f transit/keys/rustfs-ci-lane-probe >/dev/null
- name: Run rustfs-kms suite with the Vault lane on
env:
RUSTFS_KMS_VAULT_TOKEN: ${{ env.VAULT_LANE_TOKEN }}
RUSTFS_KMS_VAULT_ADDR: ${{ env.VAULT_LANE_ADDR }}
run: cargo test -p rustfs-kms --locked
- name: Run dev-Vault ignored tests
env:
RUSTFS_KMS_VAULT_TOKEN: ${{ env.VAULT_LANE_TOKEN }}
RUSTFS_KMS_VAULT_ADDR: ${{ env.VAULT_LANE_ADDR }}
# Filters select the dev-Vault-only #[ignore] tests. The AWS #[ignore]
# tests (backends::aws, service_manager) stay excluded — they need real
# AWS credentials and create billable keys. The AppRole and HA #[ignore]
# tests are excluded here because their own scripts below provision the
# Vault topology they need.
run: |
set -euo pipefail
cargo test -p rustfs-kms --locked --lib backends::contract_tests -- --ignored
cargo test -p rustfs-kms --locked --lib backends::vault -- --ignored
cargo test -p rustfs-kms --locked --test vault_fault_injection -- --ignored
- name: Run AppRole live checks (self-hosting ephemeral Vault)
run: bash scripts/test/vault_approle_kms_live.sh
- name: Show Vault dev server log on failure
if: failure()
run: tail -n 200 /tmp/vault-dev.log || true
# Three-node Raft leader failover (crates/kms/tests/vault_ha_failover_live.rs,
# first validated by rustfs/rustfs#5653). Its own job so an election-timing
# flake cannot mask the main lane's verdict, and vice versa. The script
# provisions and tears down its own Docker cluster.
kms-vault-ha-failover:
name: KMS Vault HA failover lane
runs-on: ubuntu-latest
timeout-minutes: 60
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
NO_PROXY: 127.0.0.1,localhost
steps:
- name: Checkout main branch
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
ref: main
- name: Setup Rust environment
uses: ./.github/actions/setup
with:
cache-shared-key: kms-vault-lane
cache-save-if: 'false'
install-build-packaging-tools: 'false'
install-test-tools: 'false'
- name: Run HA leader failover live checks (three-node Raft cluster in Docker)
run: bash scripts/test/vault_ha_kms_live.sh
+21 -68
View File
@@ -1,6 +1,6 @@
# ARCHITECTURE.md # ARCHITECTURE.md
> Last updated: 2026-08-12 · Revision: 3 > Last updated: 2026-07-02 · Revision: 2
> >
> This document describes the high-level architecture of RustFS. > This document describes the high-level architecture of RustFS.
> If you want to familiarize yourself with the code base, you are in the right place! > If you want to familiarize yourself with the code base, you are in the right place!
@@ -101,10 +101,7 @@ refactors.
The `rustfs` binary crate composes these libraries into the running server. The `rustfs` binary crate composes these libraries into the running server.
`ecstore` remains the storage engine at the architectural center; its internal `ecstore` remains the storage engine at the architectural center; its internal
module split is tracked under `docs/architecture/`. `rio-v2` is the module split is tracked under `docs/architecture/`.
feature-gated MinIO on-disk format compatibility I/O layer; it ships in no
default build (lifecycle:
[docs/architecture/minio-file-format-compat.md](docs/architecture/minio-file-format-compat.md)).
## Architecture Invariants ## Architecture Invariants
@@ -122,44 +119,19 @@ default build (lifecycle:
3. **Each type has exactly one definition.** Types shared across crates must be defined 3. **Each type has exactly one definition.** Types shared across crates must be defined
in one crate and re-exported or imported by others. in one crate and re-exported or imported by others.
- ⚠️ VIOLATED: `ReplicationStats` names three unrelated types - ⚠️ VIOLATED: `ReplicationStats` (4 copies), `LastMinuteLatency` (3 copies),
(`crates/data-usage/src/data_usage.rs`, `BackpressureConfig` (3 copies), `DataUsageInfo` (2 copies).
`crates/obs/src/metrics/collectors/replication.rs`,
`crates/ecstore/src/bucket/replication/replication_state.rs`) — a naming
collision, not copies; renaming is tracked in rustfs/backlog#1847.
- `LastMinuteLatency` has two deliberately different implementations: the
per-second bucketed accumulator in `crates/common/src/last_minute.rs` and
the in-memory endpoint-health sample tracker in
`crates/ecstore/src/bucket/bucket_target_sys.rs` (its doc comment explains
why it stays local).
- ✅ RESOLVED: `BackpressureConfig` and `DataUsageInfo` each have exactly one
definition (`crates/io-core/src/backpressure.rs`,
`crates/data-usage/src/data_usage.rs`). The zero-consumer
`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 4. **ecstore does not know about HTTP or S3 protocol details.** It operates on
storage-level abstractions (objects, buckets, disks, pools). 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.
5. **The `rustfs` binary crate is the only place that wires everything together.** 5. **The `rustfs` binary crate is the only place that wires everything together.**
Individual crates should be testable in isolation. Individual crates should be testable in isolation.
6. **Error types use `thiserror` with descriptive names** (e.g., `StorageError`, 6. **Error types use `thiserror` with descriptive names** (e.g., `StorageError`,
not bare `Error`). not bare `Error`).
- ✅ RESOLVED (strategy): `snafu` is gone from source - ⚠️ VIOLATED: 6 crates use `pub enum Error`; 2 crates use `snafu`;
(`rg -l snafu crates/ rustfs/` is empty) and library code no longer uses `heal` use `anyhow` in library code.
`anyhow` (remaining hits are test code and the `e2e_test` crate; `heal`
uses `thiserror`).
- ⚠️ VIOLATED (naming): 6 crates still export a bare `pub enum Error`:
`crypto`, `filemeta`, `heal`, `iam`, `policy`, and `replication`
(`src/resync.rs`) — all `thiserror`-derived.
## Known Structural Issues ## Known Structural Issues
@@ -168,25 +140,13 @@ default build (lifecycle:
### Critical ### Critical
- **scanner/data-usage duplicate `.usage-cache.bin` serialization types.** The - **common/scanner code duplication (~3K lines).** `scanner` depends on `common`
original finding ("common/scanner code duplication, ~3K lines") is resolved: but maintains its own copies of `DataUsageInfo`, `LastMinuteLatency`, and related
`scanner` imports the shared data-usage types from `rustfs-data-usage` (see types instead of importing them.
the `pub use rustfs_data_usage::…` re-exports at the top of
`crates/scanner/src/data_usage_define.rs`). What remains: `scanner` and
`data-usage` each hold their own serialization types for the scanner cache
file (`DataUsageCacheInfo`/`DataUsageEntryInfo` in
`crates/scanner/src/data_usage_define.rs` vs
`DataUsageCacheInfo`/`DataUsageEntry` in
`crates/data-usage/src/data_usage.rs`); convergence is tracked in
rustfs/backlog#1828.
- **ecstore is a monolith (265 files, ~288K lines — roughly half is inline - **ecstore is a monolith (87K lines, 163 files).** It contains disk management,
`#[cfg(test)]` code).** Measured with bucket management, erasure coding, replication, lifecycle, RPC, and configuration
`find crates/ecstore/src -name '*.rs' | xargs wc -l`. It contains disk — all in one crate. It should be decomposed along its existing subdirectories.
management, bucket management, erasure coding, replication, lifecycle, RPC,
and configuration — all in one crate. It should be decomposed along its
existing subdirectories; the split plan lives in
[docs/architecture/ecstore-module-split-plan.md](docs/architecture/ecstore-module-split-plan.md).
### High ### High
@@ -194,26 +154,19 @@ default build (lifecycle:
`common → filemeta/madmin` edges must stay removed so leaf/helper crates do `common → filemeta/madmin` edges must stay removed so leaf/helper crates do
not regain upward dependencies. not regain upward dependencies.
- **Three-layer backpressure/deadlock policy bridging** across io-core, - **Three-layer BackpressureConfig/DeadlockConfig duplication** across io-core,
concurrency, and `rustfs/src/storage`. The config types are no longer concurrency, and `rustfs/src/storage`. Storage policies now expose and consume
duplicated (`BackpressureConfig` and `DeadlockDetectorConfig` are each explicit projections into the concurrency/io-core policy shapes, and workload
defined once, in io-core). Storage policies expose and consume explicit
projections into the concurrency/io-core policy shapes, and workload
admission snapshots are composed through provider registries; later work admission snapshots are composed through provider registries; later work
should use those bridges before deleting compatibility wrappers. should use those bridges before deleting compatibility wrappers.
### Medium ### Medium
- **Bare `Error` naming.** Error-handling strategy has converged on `thiserror` - **Inconsistent error handling.** Three strategies (thiserror/snafu/anyhow) and
(no `snafu`, no `anyhow` in library code); the remaining inconsistency is the mixed naming (bare `Error` vs descriptive names).
bare `pub enum Error` naming in the 6 crates listed under Invariant 6.
- **`common` is mostly parked domain code, not shared utilities.** Of its - **Ambiguous common vs utils boundary.** Both described as "utilities and data
6,724 lines, ~83% is scanner/heal domain code stranded there to break structures." Need clear ownership rules.
dependency cycles (`metrics.rs`, ~4,810 lines of scanner-domain metrics;
`heal_channel.rs`, ~776 lines of heal-domain channel types). The
"common vs utils" naming ambiguity is secondary to moving that code to its
domain owners.
## Cross-Cutting Concerns ## Cross-Cutting Concerns
@@ -279,7 +232,7 @@ The binary (`main.rs`) boots in this order:
``` ```
┌─────────┐ ┌─────────┐
│ rustfs │ (binary + lib) │ rustfs │ (binary + lib, 75K lines)
│ main │ │ main │
└────┬────┘ └────┬────┘
@@ -302,7 +255,7 @@ The binary (`main.rs`) boots in this order:
│ │ │ │ │ │
┌─────▼──────┐ ┌──────▼──────┐ ┌──────▼──────┐ ┌─────▼──────┐ ┌──────▼──────┐ ┌──────▼──────┐
│ ecstore │ │ rio │ │ io-core │ │ ecstore │ │ rio │ │ io-core │
(core) │ │ (readers) │ │ (zero-copy) │ (87K,core) │ │ (readers) │ │ (zero-copy) │
└─────┬──────┘ └─────────────┘ └─────────────┘ └─────┬──────┘ └─────────────┘ └─────────────┘
┌─────┬──┼──┬─────┬──────┐ ┌─────┬──┼──┬─────┬──────┐
Generated
+131 -190
View File
@@ -104,12 +104,6 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "aliasable"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "250f629c0161ad8107cf89319e990051fae62832fd343083bea452d93e2205fd"
[[package]] [[package]]
name = "aligned-vec" name = "aligned-vec"
version = "0.6.4" version = "0.6.4"
@@ -272,24 +266,24 @@ checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]] [[package]]
name = "apache-avro" name = "apache-avro"
version = "0.22.0" version = "0.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "312c1ea69e5fe9966e0029fb95aca8790100b85aff4f0d3b00a9337c74069a9c" checksum = "36fa98bc79671c7981272d91a8753a928ff6a1cd8e4f20a44c45bd5d313840bf"
dependencies = [ dependencies = [
"bigdecimal", "bigdecimal",
"bon", "bon",
"digest 0.11.3", "digest 0.10.7",
"log", "log",
"miniz_oxide 0.9.1", "miniz_oxide",
"num-bigint 0.4.8", "num-bigint 0.4.8",
"ouroboros",
"quad-rand", "quad-rand",
"rand 0.10.2", "rand 0.9.5",
"regex-lite", "regex-lite",
"serde", "serde",
"serde_bytes", "serde_bytes",
"serde_json", "serde_json",
"strum", "strum 0.27.2",
"strum_macros 0.27.2",
"thiserror 2.0.20", "thiserror 2.0.20",
"uuid", "uuid",
] ]
@@ -1162,9 +1156,9 @@ dependencies = [
[[package]] [[package]]
name = "aws-smithy-eventstream" name = "aws-smithy-eventstream"
version = "0.61.2" version = "0.61.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6de526c7b567420a31bc283657a7921b45c4cafe0827fdf2490713dcc770c28f" checksum = "5a9381123ab62d20c13082b151f30f962a3b112b727345394536dfa39a482944"
dependencies = [ dependencies = [
"aws-smithy-types", "aws-smithy-types",
"bytes", "bytes",
@@ -1195,9 +1189,9 @@ dependencies = [
[[package]] [[package]]
name = "aws-smithy-http-client" name = "aws-smithy-http-client"
version = "1.3.0" version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c1c8a04cb31ba74d0115af5a890bb8c0d48fba64b52812fa13929a6ef0cc83c" checksum = "635d23afda0a6ab48d666c4d447c4873e8d1e83518a2be2093122397e50b838e"
dependencies = [ dependencies = [
"aws-smithy-async", "aws-smithy-async",
"aws-smithy-protocol-test", "aws-smithy-protocol-test",
@@ -1277,9 +1271,9 @@ dependencies = [
[[package]] [[package]]
name = "aws-smithy-runtime" name = "aws-smithy-runtime"
version = "1.13.1" version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "483b858ff67522011c4786310c5cd8fd88d0be7ea3d5f1a48328446300c4269e" checksum = "07505b34e8f4b3591a4fa69e9792b52289b95488dbbc68c3c0075b7bedb245e1"
dependencies = [ dependencies = [
"aws-smithy-async", "aws-smithy-async",
"aws-smithy-http", "aws-smithy-http",
@@ -1343,9 +1337,9 @@ dependencies = [
[[package]] [[package]]
name = "aws-smithy-types" name = "aws-smithy-types"
version = "1.6.2" version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fce83ce9abbb198d25bc7131e468d0f9fe1257125e58c39f3f9fc9f5098c9647" checksum = "d6dc683efb34b9e755675b37fedbe0103141e5b6df7bdc9eb6967756a8c167d8"
dependencies = [ dependencies = [
"base64-simd", "base64-simd",
"bytes", "bytes",
@@ -1464,7 +1458,7 @@ dependencies = [
"addr2line", "addr2line",
"cfg-if", "cfg-if",
"libc", "libc",
"miniz_oxide 0.8.9", "miniz_oxide",
"object 0.37.3", "object 0.37.3",
"rustc-demangle", "rustc-demangle",
"windows-link", "windows-link",
@@ -1807,15 +1801,6 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
[[package]]
name = "castaway"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
dependencies = [
"rustversion",
]
[[package]] [[package]]
name = "cbc" name = "cbc"
version = "0.1.2" version = "0.1.2"
@@ -2009,7 +1994,7 @@ version = "4.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061"
dependencies = [ dependencies = [
"heck 0.5.0", "heck",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 3.0.3", "syn 3.0.3",
@@ -2078,19 +2063,6 @@ dependencies = [
"unicode-width 0.2.2", "unicode-width 0.2.2",
] ]
[[package]]
name = "compact_str"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79fcda08c33bb58b97008b2cdada6622500e949e060f5913361763121abd2416"
dependencies = [
"castaway",
"cfg-if",
"itoa",
"static_assertions",
"zmij",
]
[[package]] [[package]]
name = "compression-codecs" name = "compression-codecs"
version = "0.4.38" version = "0.4.38"
@@ -4190,7 +4162,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
dependencies = [ dependencies = [
"crc32fast", "crc32fast",
"miniz_oxide 0.8.9", "miniz_oxide",
"zlib-rs", "zlib-rs",
] ]
@@ -4254,9 +4226,9 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
[[package]] [[package]]
name = "futures" name = "futures"
version = "0.3.34" version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218"
dependencies = [ dependencies = [
"futures-channel", "futures-channel",
"futures-core", "futures-core",
@@ -4269,9 +4241,9 @@ dependencies = [
[[package]] [[package]]
name = "futures-channel" name = "futures-channel"
version = "0.3.34" version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae"
dependencies = [ dependencies = [
"futures-core", "futures-core",
"futures-sink", "futures-sink",
@@ -4279,15 +4251,15 @@ dependencies = [
[[package]] [[package]]
name = "futures-core" name = "futures-core"
version = "0.3.34" version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7"
[[package]] [[package]]
name = "futures-executor" name = "futures-executor"
version = "0.3.34" version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458"
dependencies = [ dependencies = [
"futures-core", "futures-core",
"futures-task", "futures-task",
@@ -4296,9 +4268,9 @@ dependencies = [
[[package]] [[package]]
name = "futures-io" name = "futures-io"
version = "0.3.34" version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a"
[[package]] [[package]]
name = "futures-lite" name = "futures-lite"
@@ -4315,13 +4287,13 @@ dependencies = [
[[package]] [[package]]
name = "futures-macro" name = "futures-macro"
version = "0.3.34" version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 3.0.3", "syn 2.0.119",
] ]
[[package]] [[package]]
@@ -4337,21 +4309,21 @@ dependencies = [
[[package]] [[package]]
name = "futures-sink" name = "futures-sink"
version = "0.3.34" version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307"
[[package]] [[package]]
name = "futures-task" name = "futures-task"
version = "0.3.34" version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109"
[[package]] [[package]]
name = "futures-util" name = "futures-util"
version = "0.3.34" version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa"
dependencies = [ dependencies = [
"futures-channel", "futures-channel",
"futures-core", "futures-core",
@@ -4860,12 +4832,6 @@ dependencies = [
"stable_deref_trait", "stable_deref_trait",
] ]
[[package]]
name = "heck"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
[[package]] [[package]]
name = "heck" name = "heck"
version = "0.5.0" version = "0.5.0"
@@ -5025,9 +4991,9 @@ dependencies = [
[[package]] [[package]]
name = "hotpath" name = "hotpath"
version = "0.23.2" version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62e810bedda5a467ef5c9b5c8a20763fefebc89b63ef36f7ee44a143085204a2" checksum = "be80823867e0c9820c9237c38b21f9f4aa1ebb0db1f98ff25ac0b1d2c088a470"
dependencies = [ dependencies = [
"arc-swap", "arc-swap",
"async-channel", "async-channel",
@@ -5059,9 +5025,9 @@ dependencies = [
[[package]] [[package]]
name = "hotpath-macros" name = "hotpath-macros"
version = "0.23.2" version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "01bdc59bfc1a9984bee2ff5da63b2f6fccbaa57cd9a4119d709524632bddf341" checksum = "61d1fb3ee80ae7b4743d29487665766ce5a1442e959521790e86317f89dcd5a3"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@@ -5070,15 +5036,15 @@ dependencies = [
[[package]] [[package]]
name = "hotpath-macros-meta" name = "hotpath-macros-meta"
version = "0.23.2" version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9216e8a01abe1e1671c376dc8736fb1bf772d7a889538d25f9e1200120ced38" checksum = "feede71fa226b0b5d523e58e7b0a1462935c0b8a00584a6669f45d564086209d"
[[package]] [[package]]
name = "hotpath-meta" name = "hotpath-meta"
version = "0.23.2" version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f22a9d20435fb79511b19dae37b3607224cd98f342a410702d84657cc38fc72f" checksum = "424fe0a13105d3731f65237785f5b95c3e4b8bfae4a039d932f56192cd74afc0"
dependencies = [ dependencies = [
"hotpath-macros-meta", "hotpath-macros-meta",
] ]
@@ -5133,9 +5099,9 @@ dependencies = [
[[package]] [[package]]
name = "http-body-util" name = "http-body-util"
version = "0.1.5" version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2"
dependencies = [ dependencies = [
"bytes", "bytes",
"futures-core", "futures-core",
@@ -5454,9 +5420,9 @@ dependencies = [
[[package]] [[package]]
name = "io-uring" name = "io-uring"
version = "0.7.14" version = "0.7.13"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d64d8ca234d152948ceaede1f419b6a83983a5ecccaac05fb337a809c96d3aa6" checksum = "9080b15e63775b9a2ac7dca720f7050a8b955e092ea0f6020a4a80f69998cdc0"
dependencies = [ dependencies = [
"bitflags 2.13.1", "bitflags 2.13.1",
"cfg-if", "cfg-if",
@@ -5930,18 +5896,18 @@ dependencies = [
[[package]] [[package]]
name = "liblzma" name = "liblzma"
version = "0.4.8" version = "0.4.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2fe0a34ca854fd4f20c07f696fc8675aec78f87d88d29f5e10257a7490a1b2e1" checksum = "45aec2360b3933207e27908049d8e4df4e476b58180afb1e56b2a4fb72efe4ba"
dependencies = [ dependencies = [
"liblzma-sys", "liblzma-sys",
] ]
[[package]] [[package]]
name = "liblzma-sys" name = "liblzma-sys"
version = "0.4.8" version = "0.4.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0dad045e4b1b7b170be4b60b54b780cafb4490165461bac7d1cf7b703f61d5f" checksum = "a046c7f353ba30f810545151e04f63545833803f5b86ee3ddf1517247fe560a5"
dependencies = [ dependencies = [
"cc", "cc",
"libc", "libc",
@@ -5957,7 +5923,7 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]] [[package]]
name = "libmimalloc-sys" name = "libmimalloc-sys"
version = "0.1.49" version = "0.1.49"
source = "git+https://github.com/xonatius/mimalloc_rust.git?rev=6d4c41bb10c6d9da1d1b6f07b38c4cc051667f11#6d4c41bb10c6d9da1d1b6f07b38c4cc051667f11" source = "git+https://github.com/xonatius/mimalloc_rust.git?rev=ce6338661179c8be22e516b00af7483f151485a7#ce6338661179c8be22e516b00af7483f151485a7"
dependencies = [ dependencies = [
"cc", "cc",
"cty", "cty",
@@ -6259,9 +6225,9 @@ dependencies = [
[[package]] [[package]]
name = "metrique" name = "metrique"
version = "0.1.30" version = "0.1.29"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dedbf06ffeef4c37990c73636fbd993aa34fb1948afd736e6114f239220993db" checksum = "d2e394c63e2d1a30aeb3b9392ecf3439d8475d2df810a8f4f6e66d6866754017"
dependencies = [ dependencies = [
"itoa", "itoa",
"jiff", "jiff",
@@ -6289,9 +6255,9 @@ dependencies = [
[[package]] [[package]]
name = "metrique-macro" name = "metrique-macro"
version = "0.1.21" version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f4fb1f30185f53f7f6e4c9e46745c1a1350af8e77fda5a88aded44b0637a82e0" checksum = "786df1fd0abebd0db685f7e9a353c78756d4b370fb98a52376c2015fa55f141f"
dependencies = [ dependencies = [
"Inflector", "Inflector",
"darling 0.23.0", "darling 0.23.0",
@@ -6318,9 +6284,9 @@ checksum = "2faca4e4480069ff02b1763b3b79f5cec7e8628e24d9dc5b6073f53d2577a4d9"
[[package]] [[package]]
name = "metrique-writer" name = "metrique-writer"
version = "0.1.26" version = "0.1.25"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20bd17c1a3ca2719e31f19ce77a853948dc2102f35976b92276c42a64fdc5f3f" checksum = "82cdde44d241dab7fc8b7a32e0eb5dae6cd28f8de80b59f9a1e9f2f0b05e485e"
dependencies = [ dependencies = [
"ahash", "ahash",
"crossbeam-queue", "crossbeam-queue",
@@ -6339,9 +6305,9 @@ dependencies = [
[[package]] [[package]]
name = "metrique-writer-core" name = "metrique-writer-core"
version = "0.1.20" version = "0.1.19"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1a55b6aae1d85c557c729564c4e2b32a26dc65ba2d90d9647ca01f2bd4854c4" checksum = "e57379b7ee2272efaeaaa6de062503563e57333b24aadc7f2255b3d602899e8b"
dependencies = [ dependencies = [
"derive-where", "derive-where",
"itertools 0.14.0", "itertools 0.14.0",
@@ -6366,7 +6332,7 @@ dependencies = [
[[package]] [[package]]
name = "mimalloc" name = "mimalloc"
version = "0.1.52" version = "0.1.52"
source = "git+https://github.com/xonatius/mimalloc_rust.git?rev=6d4c41bb10c6d9da1d1b6f07b38c4cc051667f11#6d4c41bb10c6d9da1d1b6f07b38c4cc051667f11" source = "git+https://github.com/xonatius/mimalloc_rust.git?rev=ce6338661179c8be22e516b00af7483f151485a7#ce6338661179c8be22e516b00af7483f151485a7"
dependencies = [ dependencies = [
"libmimalloc-sys", "libmimalloc-sys",
] ]
@@ -6403,15 +6369,6 @@ dependencies = [
"simd-adler32", "simd-adler32",
] ]
[[package]]
name = "miniz_oxide"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"
dependencies = [
"adler2",
]
[[package]] [[package]]
name = "minlz" name = "minlz"
version = "1.2.3" version = "1.2.3"
@@ -6459,9 +6416,9 @@ dependencies = [
[[package]] [[package]]
name = "moka" name = "moka"
version = "0.12.16" version = "0.12.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4293f18e7567a1caf3c584855554377025c65e0aa445344d04171f5ad63d19b9" checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046"
dependencies = [ dependencies = [
"async-lock", "async-lock",
"crossbeam-channel", "crossbeam-channel",
@@ -6506,7 +6463,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4db8a44120571277accfaa3f3d91e7d3989d601d817c2fc01a9391b86135666" checksum = "a4db8a44120571277accfaa3f3d91e7d3989d601d817c2fc01a9391b86135666"
dependencies = [ dependencies = [
"darling 0.23.0", "darling 0.23.0",
"heck 0.5.0", "heck",
"manyhow", "manyhow",
"num-bigint 0.4.8", "num-bigint 0.4.8",
"proc-macro-crate", "proc-macro-crate",
@@ -6790,9 +6747,9 @@ checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
[[package]] [[package]]
name = "num-integer" name = "num-integer"
version = "0.1.47" version = "0.1.46"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
dependencies = [ dependencies = [
"num-traits", "num-traits",
] ]
@@ -6882,7 +6839,7 @@ version = "5.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d"
dependencies = [ dependencies = [
"base64 0.21.7", "base64 0.22.1",
"chrono", "chrono",
"getrandom 0.2.17", "getrandom 0.2.17",
"http 1.5.0", "http 1.5.0",
@@ -7211,30 +7168,6 @@ dependencies = [
"num-traits", "num-traits",
] ]
[[package]]
name = "ouroboros"
version = "0.18.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e0f050db9c44b97a94723127e6be766ac5c340c48f2c4bb3ffa11713744be59"
dependencies = [
"aliasable",
"ouroboros_macro",
"static_assertions",
]
[[package]]
name = "ouroboros_macro"
version = "0.18.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c7028bdd3d43083f6d8d4d5187680d0d3560d54df4cc9d752005268b41e64d0"
dependencies = [
"heck 0.4.1",
"proc-macro2",
"proc-macro2-diagnostics",
"quote",
"syn 2.0.119",
]
[[package]] [[package]]
name = "outref" name = "outref"
version = "0.5.2" version = "0.5.2"
@@ -7787,9 +7720,9 @@ dependencies = [
[[package]] [[package]]
name = "portable-atomic" name = "portable-atomic"
version = "1.15.0" version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3"
[[package]] [[package]]
name = "portable-atomic-util" name = "portable-atomic-util"
@@ -7970,19 +7903,6 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "proc-macro2-diagnostics"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
"version_check",
"yansi",
]
[[package]] [[package]]
name = "prometheus" name = "prometheus"
version = "0.14.0" version = "0.14.0"
@@ -8042,8 +7962,8 @@ version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf" checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf"
dependencies = [ dependencies = [
"heck 0.5.0", "heck",
"itertools 0.10.5", "itertools 0.14.0",
"log", "log",
"multimap", "multimap",
"once_cell", "once_cell",
@@ -8062,8 +7982,8 @@ version = "0.14.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03da047801ff44bb6a4d407d4860c05fd70bb81714e6b2f3812603d5b145b042" checksum = "03da047801ff44bb6a4d407d4860c05fd70bb81714e6b2f3812603d5b145b042"
dependencies = [ dependencies = [
"heck 0.5.0", "heck",
"itertools 0.10.5", "itertools 0.14.0",
"log", "log",
"multimap", "multimap",
"petgraph 0.8.3", "petgraph 0.8.3",
@@ -8084,7 +8004,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"itertools 0.10.5", "itertools 0.14.0",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.119", "syn 2.0.119",
@@ -8097,7 +8017,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"itertools 0.10.5", "itertools 0.14.0",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.119", "syn 2.0.119",
@@ -8154,9 +8074,9 @@ dependencies = [
[[package]] [[package]]
name = "pulldown-cmark-to-cmark" name = "pulldown-cmark-to-cmark"
version = "22.0.1" version = "22.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab1ad36992cead65f02aa399a373a42730922f1525d988172634fdefdecb8a60" checksum = "50793def1b900256624a709439404384204a5dc3a6ec580281bfaac35e882e90"
dependencies = [ dependencies = [
"pulldown-cmark", "pulldown-cmark",
] ]
@@ -8510,9 +8430,9 @@ dependencies = [
[[package]] [[package]]
name = "rcgen" name = "rcgen"
version = "0.14.9" version = "0.14.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "091e7a8e7d86e6feb87a27ce8e2cba29d49eff9507afeebefab7eeb2ca667fb4" checksum = "57f6d249aad744e274e682777a50283a225a32705394ee6d5fcc01efa25e4055"
dependencies = [ dependencies = [
"aws-lc-rs", "aws-lc-rs",
"pem", "pem",
@@ -8917,9 +8837,9 @@ dependencies = [
[[package]] [[package]]
name = "russh" name = "russh"
version = "0.62.6" version = "0.62.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b41043523e0edcbd4e31d00903e26f12994f63b21bae9904f7405c1ed92752a5" checksum = "da7c230e0ed9cbeb92fbad6c8848985d6df2a1464c0dc247a021abd666e9005e"
dependencies = [ dependencies = [
"aes 0.9.2", "aes 0.9.2",
"aws-lc-rs", "aws-lc-rs",
@@ -9201,6 +9121,7 @@ dependencies = [
"sha2 0.11.0", "sha2 0.11.0",
"shadow-rs", "shadow-rs",
"socket2", "socket2",
"starshard",
"subtle", "subtle",
"sysinfo", "sysinfo",
"temp-env", "temp-env",
@@ -9344,6 +9265,7 @@ dependencies = [
name = "rustfs-data-usage" name = "rustfs-data-usage"
version = "1.0.0-rc.1" version = "1.0.0-rc.1"
dependencies = [ dependencies = [
"async-trait",
"hotpath", "hotpath",
"rmp-serde", "rmp-serde",
"rustfs-filemeta", "rustfs-filemeta",
@@ -9620,7 +9542,9 @@ dependencies = [
"metrics", "metrics",
"metrics-util", "metrics-util",
"num_cpus", "num_cpus",
"rustfs-common",
"rustfs-s3-ops", "rustfs-s3-ops",
"rustfs-utils",
"sysinfo", "sysinfo",
"thiserror 2.0.20", "thiserror 2.0.20",
"tokio", "tokio",
@@ -9734,7 +9658,6 @@ dependencies = [
"rustfs-utils", "rustfs-utils",
"rustify", "rustify",
"serde", "serde",
"serde_ignored",
"serde_json", "serde_json",
"sha2 0.11.0", "sha2 0.11.0",
"subtle", "subtle",
@@ -9779,7 +9702,6 @@ name = "rustfs-lock"
version = "1.0.0-rc.1" version = "1.0.0-rc.1"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"compact_str",
"crossbeam-queue", "crossbeam-queue",
"futures", "futures",
"hotpath", "hotpath",
@@ -9790,6 +9712,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"smallvec", "smallvec",
"smartstring",
"thiserror 2.0.20", "thiserror 2.0.20",
"tokio", "tokio",
"tonic", "tonic",
@@ -9979,7 +9902,7 @@ dependencies = [
"rustfs-crypto", "rustfs-crypto",
"serde", "serde",
"serde_json", "serde_json",
"strum", "strum 0.28.0",
"temp-env", "temp-env",
"test-case", "test-case",
"thiserror 2.0.20", "thiserror 2.0.20",
@@ -10559,9 +10482,9 @@ dependencies = [
[[package]] [[package]]
name = "rustls-connector" name = "rustls-connector"
version = "0.23.8" version = "0.23.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1babecfcc65b139b812e74bcc7f9ec7b4e00db659fd42d99567b7e77f0c714c6" checksum = "09a5abe04eec18f8b9fbe87885bcaee6426de80bbc579958c0bc064b728ee617"
dependencies = [ dependencies = [
"futures-io", "futures-io",
"futures-rustls", "futures-rustls",
@@ -10623,9 +10546,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
[[package]] [[package]]
name = "rustls-webpki" name = "rustls-webpki"
version = "0.103.14" version = "0.103.13"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a" checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
dependencies = [ dependencies = [
"aws-lc-rs", "aws-lc-rs",
"ring", "ring",
@@ -10937,16 +10860,6 @@ dependencies = [
"syn 3.0.3", "syn 3.0.3",
] ]
[[package]]
name = "serde_ignored"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "115dffd5f3853e06e746965a20dcbae6ee747ae30b543d91b0e089668bb07798"
dependencies = [
"serde",
"serde_core",
]
[[package]] [[package]]
name = "serde_json" name = "serde_json"
version = "1.0.151" version = "1.0.151"
@@ -11015,9 +10928,9 @@ dependencies = [
[[package]] [[package]]
name = "serde_with" name = "serde_with"
version = "3.22.0" version = "3.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee78f1fbe43ac4a0e47aadb3dbd357b69eb0d3793e948624cd03dd2750ab1c0a" checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"bs58", "bs58",
@@ -11025,7 +10938,6 @@ dependencies = [
"hex", "hex",
"indexmap 1.9.3", "indexmap 1.9.3",
"indexmap 2.14.0", "indexmap 2.14.0",
"jiff",
"schemars 0.9.0", "schemars 0.9.0",
"schemars 1.2.2", "schemars 1.2.2",
"serde_core", "serde_core",
@@ -11036,9 +10948,9 @@ dependencies = [
[[package]] [[package]]
name = "serde_with_macros" name = "serde_with_macros"
version = "3.22.0" version = "3.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8705578779c2b6bd90d84d66eb2e206b708b1a4d7b9f17641b293545bf1c7e46" checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660"
dependencies = [ dependencies = [
"darling 0.23.0", "darling 0.23.0",
"proc-macro2", "proc-macro2",
@@ -11332,6 +11244,17 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "smartstring"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3fb72c633efbaa2dd666986505016c32c3044395ceaf881518399d2f4127ee29"
dependencies = [
"autocfg",
"static_assertions",
"version_check",
]
[[package]] [[package]]
name = "snafu" name = "snafu"
version = "0.6.10" version = "0.6.10"
@@ -11578,13 +11501,31 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "strum"
version = "0.27.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf"
[[package]] [[package]]
name = "strum" name = "strum"
version = "0.28.0" version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd"
dependencies = [ dependencies = [
"strum_macros", "strum_macros 0.28.0",
]
[[package]]
name = "strum_macros"
version = "0.27.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn 2.0.119",
] ]
[[package]] [[package]]
@@ -11593,7 +11534,7 @@ version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664"
dependencies = [ dependencies = [
"heck 0.5.0", "heck",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.119", "syn 2.0.119",
@@ -11800,7 +11741,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [ dependencies = [
"fastrand", "fastrand",
"getrandom 0.3.4", "getrandom 0.4.3",
"once_cell", "once_cell",
"rustix", "rustix",
"windows-sys 0.61.2", "windows-sys 0.61.2",
@@ -12866,9 +12807,9 @@ dependencies = [
[[package]] [[package]]
name = "whoami" name = "whoami"
version = "2.1.3" version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "626c4bac6755d76ffc12cb01b2eac751db1996b9e0041de9aa02c8c211ddc82c" checksum = "998767ef88740d1f5b0682a9c53c24431453923962269c2db68ee43788c5a40d"
dependencies = [ dependencies = [
"libc", "libc",
"libredox", "libredox",
+15 -16
View File
@@ -41,7 +41,7 @@ members = [
"crates/protocols", # Protocol implementations (FTPS, SFTP, etc.) "crates/protocols", # Protocol implementations (FTPS, SFTP, etc.)
"crates/protos", # Protocol buffer definitions "crates/protos", # Protocol buffer definitions
"crates/rio", # Rust I/O utilities and abstractions "crates/rio", # Rust I/O utilities and abstractions
"crates/rio-v2", # MinIO on-disk format compatibility I/O layer (feature-gated, ships in no default build) "crates/rio-v2", # Next-generation Rust I/O compatibility layer
"crates/replication", # Replication contracts and wire formats "crates/replication", # Replication contracts and wire formats
"crates/concurrency", # Concurrency management for RustFS - timeout, locking, backpressure, and I/O scheduling "crates/concurrency", # Concurrency management for RustFS - timeout, locking, backpressure, and I/O scheduling
"crates/s3-types", # S3 event type definitions "crates/s3-types", # S3 event type definitions
@@ -142,10 +142,10 @@ async-recursion = "1.1.1"
async-trait = "0.1.92" async-trait = "0.1.92"
async-nats = { version = "0.50.0", default-features = false } async-nats = { version = "0.50.0", default-features = false }
axum = "0.8.9" axum = "0.8.9"
futures = "0.3.34" futures = "0.3.33"
futures-core = "0.3.34" futures-core = "0.3.33"
futures-lite = "2.6.1" futures-lite = "2.6.1"
futures-util = "0.3.34" futures-util = "0.3.33"
pollster = "1.0.1" pollster = "1.0.1"
pulsar = { default-features = false, version = "6.8.0" } pulsar = { default-features = false, version = "6.8.0" }
lapin = { default-features = false, version = "4.10.0" } lapin = { default-features = false, version = "4.10.0" }
@@ -154,7 +154,7 @@ hyper-rustls = { default-features = false, version = "0.27.9" }
hyper-util = { version = "0.1.20" } hyper-util = { version = "0.1.20" }
http = "1.5.0" http = "1.5.0"
http-body = "1.1.0" http-body = "1.1.0"
http-body-util = "0.1.5" http-body-util = "0.1.4"
minlz = "1.2.3" minlz = "1.2.3"
reqwest = "0.13.4" reqwest = "0.13.4"
rustfs-kafka-async = { version = "1.2.0" } rustfs-kafka-async = { version = "1.2.0" }
@@ -171,7 +171,7 @@ tower = { version = "0.5.3" }
tower-http = { version = "0.7.0" } tower-http = { version = "0.7.0" }
# Serialization and Data Formats # Serialization and Data Formats
apache-avro = "0.22.0" apache-avro = "0.21.0"
bytes = { version = "1.12.1" } bytes = { version = "1.12.1" }
bytesize = "2.7.0" bytesize = "2.7.0"
byteorder = "1.5.0" byteorder = "1.5.0"
@@ -182,7 +182,6 @@ quick-xml = "0.41.0"
rmp = { version = "0.8.15" } rmp = { version = "0.8.15" }
rmp-serde = { version = "1.3.1" } rmp-serde = { version = "1.3.1" }
serde = { version = "1.0.229" } serde = { version = "1.0.229" }
serde_ignored = { version = "0.1" }
serde_json = { version = "1.0.151" } serde_json = { version = "1.0.151" }
serde_urlencoded = "0.7.1" serde_urlencoded = "0.7.1"
@@ -231,9 +230,9 @@ aws-credential-types = { version = "1.3.0" }
aws-sdk-kms = { default-features = false, version = "1.114.0" } aws-sdk-kms = { default-features = false, version = "1.114.0" }
aws-sdk-s3 = { default-features = false, version = "1.141.0" } aws-sdk-s3 = { default-features = false, version = "1.141.0" }
aws-sdk-sts = { default-features = false, version = "1.110.0" } aws-sdk-sts = { default-features = false, version = "1.110.0" }
aws-smithy-http-client = { default-features = false, version = "1.3.0" } aws-smithy-http-client = { default-features = false, version = "1.2.0" }
aws-smithy-runtime-api = { version = "1.14.0" } aws-smithy-runtime-api = { version = "1.14.0" }
aws-smithy-types = { version = "1.6.2" } aws-smithy-types = { version = "1.6.1" }
base64 = "0.23.1" base64 = "0.23.1"
base64-simd = "0.8.0" base64-simd = "0.8.0"
brotli = "8.0.4" brotli = "8.0.4"
@@ -269,7 +268,7 @@ lz4 = "1.28.1"
matchit = "0.9.2" matchit = "0.9.2"
md-5 = "0.11.0" md-5 = "0.11.0"
mime_guess = "2.0.5" mime_guess = "2.0.5"
moka = { version = "0.12.16" } moka = { version = "0.12.15" }
netif = "0.1.6" netif = "0.1.6"
num_cpus = { version = "1.17.0" } num_cpus = { version = "1.17.0" }
nvml-wrapper = "0.12.1" nvml-wrapper = "0.12.1"
@@ -295,7 +294,7 @@ serial_test = "4.0.1"
shadow-rs = { default-features = false, version = "2.0.0" } shadow-rs = { default-features = false, version = "2.0.0" }
siphasher = "1.0.3" siphasher = "1.0.3"
smallvec = { version = "1.15.2" } smallvec = { version = "1.15.2" }
compact_str = "0.10.0" smartstring = "1.0.1"
snap = "1.1.2" snap = "1.1.2"
starshard = { version = "2.2.2" } starshard = { version = "2.2.2" }
strum = { version = "0.28.0" } strum = { version = "0.28.0" }
@@ -340,17 +339,17 @@ pyroscope = { version = "2.1.1" }
libunftp = { version = "0.23.0" } libunftp = { version = "0.23.0" }
unftp-core = "0.1.0" unftp-core = "0.1.0"
suppaftp = { version = "10.0.1" } suppaftp = { version = "10.0.1" }
rcgen = { version = "0.14.9", default-features = false, features = ["aws_lc_rs", "crypto", "pem"] } rcgen = { version = "0.14.8", default-features = false, features = ["aws_lc_rs", "crypto", "pem"] }
russh = { version = "0.62.6" } russh = { version = "0.62.5" }
russh-sftp = "2.4.0" russh-sftp = "2.4.0"
# WebDAV # WebDAV
dav-server = "0.11.0" dav-server = "0.11.0"
# Performance Analysis and Memory Profiling # Performance Analysis and Memory Profiling
mimalloc = { version = "0.1.52", git = "https://github.com/xonatius/mimalloc_rust.git", rev = "6d4c41bb10c6d9da1d1b6f07b38c4cc051667f11" } mimalloc = { version = "0.1.52", git = "https://github.com/xonatius/mimalloc_rust.git", rev = "ce6338661179c8be22e516b00af7483f151485a7" }
libmimalloc-sys = { version = "0.1.49", git = "https://github.com/xonatius/mimalloc_rust.git", rev = "6d4c41bb10c6d9da1d1b6f07b38c4cc051667f11", features = ["extended"] } libmimalloc-sys = { version = "0.1.49", git = "https://github.com/xonatius/mimalloc_rust.git", rev = "ce6338661179c8be22e516b00af7483f151485a7", features = ["extended"] }
hotpath = { version = "0.23.2", default-features = false } hotpath = { version = "0.23.1", default-features = false }
# Snapshot testing for output format regression detection # Snapshot testing for output format regression detection
insta = { version = "1.48" } insta = { version = "1.48" }
-7
View File
@@ -21,13 +21,6 @@ use crate::{
Xxhash3, Xxhash64, Xxhash128, Xxhash3, Xxhash64, Xxhash128,
}; };
// DELIBERATE DUPLICATION of the x-amz-checksum-* names that also exist as
// AMZ_CHECKSUM_* in rustfs-utils' headers module (crates/utils/src/http/
// headers.rs): this crate is a zero-internal-dependency leaf, so it cannot
// import them, and it additionally owns the RustFS extension names
// (sha512/xxhash*) that utils does not carry. Values are pinned by the S3
// wire protocol; do not merge without a maintainer decision on the leaf
// boundary (backlog#1833).
pub const CRC_32_HEADER_NAME: &str = "x-amz-checksum-crc32"; pub const CRC_32_HEADER_NAME: &str = "x-amz-checksum-crc32";
pub const CRC_32_C_HEADER_NAME: &str = "x-amz-checksum-crc32c"; pub const CRC_32_C_HEADER_NAME: &str = "x-amz-checksum-crc32c";
pub const SHA_1_HEADER_NAME: &str = "x-amz-checksum-sha1"; pub const SHA_1_HEADER_NAME: &str = "x-amz-checksum-sha1";
-8
View File
@@ -41,14 +41,6 @@ pub const XXHASH_64_NAME: &str = "xxhash64";
pub const XXHASH_128_NAME: &str = "xxhash128"; pub const XXHASH_128_NAME: &str = "xxhash128";
pub const MD5_NAME: &str = "md5"; 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.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[non_exhaustive] #[non_exhaustive]
pub enum ChecksumAlgorithm { pub enum ChecksumAlgorithm {
+87
View File
@@ -0,0 +1,87 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use crate::last_minute::{self};
use std::collections::HashMap;
pub struct ReplicationLatency {
// Delays for single and multipart PUT requests
upload_histogram: last_minute::LastMinuteHistogram,
}
impl ReplicationLatency {
// Merge two ReplicationLatency
pub fn merge(&mut self, other: &mut ReplicationLatency) -> &ReplicationLatency {
self.upload_histogram.merge(&other.upload_histogram);
self
}
// Get upload delay (categorized by object size interval)
pub fn get_upload_latency(&mut self) -> HashMap<String, u64> {
let mut ret = HashMap::new();
let avg = self.upload_histogram.get_avg_data();
for (i, v) in avg.iter().enumerate() {
let avg_duration = v.avg();
ret.insert(self.size_tag_to_string(i), avg_duration.as_millis() as u64);
}
ret
}
pub fn update(&mut self, size: i64, during: std::time::Duration) {
self.upload_histogram.add(size, during);
}
// Simulate the conversion from size tag to string
fn size_tag_to_string(&self, tag: usize) -> String {
match tag {
0 => String::from("Size < 1 KiB"),
1 => String::from("Size < 1 MiB"),
2 => String::from("Size < 10 MiB"),
3 => String::from("Size < 100 MiB"),
4 => String::from("Size < 1 GiB"),
_ => String::from("Size > 1 GiB"),
}
}
}
// #[derive(Debug, Clone, Default)]
// pub struct ReplicationLastMinute {
// pub last_minute: LastMinuteLatency,
// }
// impl ReplicationLastMinute {
// pub fn merge(&mut self, other: ReplicationLastMinute) -> ReplicationLastMinute {
// let mut nl = ReplicationLastMinute::default();
// nl.last_minute = self.last_minute.merge(&mut other.last_minute);
// nl
// }
// pub fn add_size(&mut self, n: i64) {
// let t = SystemTime::now()
// .duration_since(UNIX_EPOCH)
// .expect("Time went backwards")
// .as_secs();
// self.last_minute.add_all(t - 1, &AccElem { total: t - 1, size: n as u64, n: 1 });
// }
// pub fn get_total(&self) -> AccElem {
// self.last_minute.get_total()
// }
// }
// impl fmt::Display for ReplicationLastMinute {
// fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
// let t = self.last_minute.get_total();
// write!(f, "ReplicationLastMinute sz= {}, n= {}, dur= {}", t.size, t.n, t.total)
// }
// }
+41
View File
@@ -572,3 +572,44 @@ mod tests {
assert_eq!(total.n, 6); assert_eq!(total.n, 6);
} }
} }
const SIZE_LAST_ELEM_MARKER: usize = 10; // Assumed marker size is 10, modify according to actual situation
#[allow(dead_code)]
#[derive(Debug, Default)]
pub struct LastMinuteHistogram {
histogram: Vec<LastMinuteLatency>,
size: u32,
}
impl LastMinuteHistogram {
pub fn merge(&mut self, other: &LastMinuteHistogram) {
for i in 0..self.histogram.len() {
self.histogram[i].merge(&other.histogram[i]);
}
}
pub fn add(&mut self, size: i64, t: Duration) {
let index = size_to_tag(size);
self.histogram[index].add(&t);
}
pub fn get_avg_data(&mut self) -> [AccElem; SIZE_LAST_ELEM_MARKER] {
let mut res = [AccElem::default(); SIZE_LAST_ELEM_MARKER];
for (i, elem) in self.histogram.iter_mut().enumerate() {
res[i] = elem.get_total();
}
res
}
}
fn size_to_tag(size: i64) -> usize {
match size {
_ if size < 1024 => 0, // sizeLessThan1KiB
_ if size < 1024 * 1024 => 1, // sizeLessThan1MiB
_ if size < 10 * 1024 * 1024 => 2, // sizeLessThan10MiB
_ if size < 100 * 1024 * 1024 => 3, // sizeLessThan100MiB
_ if size < 1024 * 1024 * 1024 => 4, // sizeLessThan1GiB
_ => 5, // sizeGreaterThan1GiB
}
}
+1 -1
View File
@@ -12,13 +12,13 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
pub mod bucket_stats;
// pub mod error; // pub mod error;
pub mod globals; pub mod globals;
pub mod heal_channel; pub mod heal_channel;
pub mod last_minute; pub mod last_minute;
pub mod metrics; pub mod metrics;
mod readiness; mod readiness;
pub mod table_catalog;
pub use globals::*; pub use globals::*;
pub use readiness::{GlobalReadiness, SystemStage}; pub use readiness::{GlobalReadiness, SystemStage};
-34
View File
@@ -915,13 +915,11 @@ const SCAN_CYCLE_RESULT_SUCCESS: u8 = 1;
const SCAN_CYCLE_RESULT_ERROR: u8 = 2; const SCAN_CYCLE_RESULT_ERROR: u8 = 2;
const SCAN_CYCLE_RESULT_PARTIAL: u8 = 3; const SCAN_CYCLE_RESULT_PARTIAL: u8 = 3;
const SCAN_CYCLE_RESULT_SUPERSEDED: u8 = 4; const SCAN_CYCLE_RESULT_SUPERSEDED: u8 = 4;
const SCAN_CYCLE_RESULT_DEFERRED: u8 = 5;
const SCAN_CYCLE_RESULT_UNKNOWN_LABEL: &str = "unknown"; const SCAN_CYCLE_RESULT_UNKNOWN_LABEL: &str = "unknown";
const SCAN_CYCLE_RESULT_SUCCESS_LABEL: &str = "success"; const SCAN_CYCLE_RESULT_SUCCESS_LABEL: &str = "success";
const SCAN_CYCLE_RESULT_ERROR_LABEL: &str = "error"; const SCAN_CYCLE_RESULT_ERROR_LABEL: &str = "error";
const SCAN_CYCLE_RESULT_PARTIAL_LABEL: &str = "partial"; const SCAN_CYCLE_RESULT_PARTIAL_LABEL: &str = "partial";
const SCAN_CYCLE_RESULT_SUPERSEDED_LABEL: &str = "superseded"; const SCAN_CYCLE_RESULT_SUPERSEDED_LABEL: &str = "superseded";
const SCAN_CYCLE_RESULT_DEFERRED_LABEL: &str = "deferred";
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum ScanCyclePartialReason { pub enum ScanCyclePartialReason {
@@ -1426,7 +1424,6 @@ fn scan_cycle_result_label(result: u8) -> &'static str {
SCAN_CYCLE_RESULT_ERROR => SCAN_CYCLE_RESULT_ERROR_LABEL, SCAN_CYCLE_RESULT_ERROR => SCAN_CYCLE_RESULT_ERROR_LABEL,
SCAN_CYCLE_RESULT_PARTIAL => SCAN_CYCLE_RESULT_PARTIAL_LABEL, SCAN_CYCLE_RESULT_PARTIAL => SCAN_CYCLE_RESULT_PARTIAL_LABEL,
SCAN_CYCLE_RESULT_SUPERSEDED => SCAN_CYCLE_RESULT_SUPERSEDED_LABEL, SCAN_CYCLE_RESULT_SUPERSEDED => SCAN_CYCLE_RESULT_SUPERSEDED_LABEL,
SCAN_CYCLE_RESULT_DEFERRED => SCAN_CYCLE_RESULT_DEFERRED_LABEL,
_ => SCAN_CYCLE_RESULT_UNKNOWN_LABEL, _ => SCAN_CYCLE_RESULT_UNKNOWN_LABEL,
} }
} }
@@ -1755,11 +1752,6 @@ pub fn emit_scan_cycle_superseded(duration: Duration) {
metrics::counter!(OTEL_SCANNER_CYCLES, "result" => SCAN_CYCLE_RESULT_SUPERSEDED_LABEL).increment(1); metrics::counter!(OTEL_SCANNER_CYCLES, "result" => SCAN_CYCLE_RESULT_SUPERSEDED_LABEL).increment(1);
} }
pub fn emit_scan_cycle_deferred(duration: Duration) {
global_metrics().record_scan_cycle_deferred(duration);
metrics::counter!(OTEL_SCANNER_CYCLES, "result" => SCAN_CYCLE_RESULT_DEFERRED_LABEL).increment(1);
}
pub fn emit_scan_bucket_drive_complete(success: bool, bucket: &str, disk: &str, duration: Duration) { pub fn emit_scan_bucket_drive_complete(success: bool, bucket: &str, disk: &str, duration: Duration) {
let result = if success { "success" } else { "error" }; let result = if success { "success" } else { "error" };
global_metrics().record_scanner_bucket_drive_result(bucket, disk, result); global_metrics().record_scanner_bucket_drive_result(bucket, disk, result);
@@ -2557,17 +2549,6 @@ impl Metrics {
.store(duration_millis_saturated(duration), Ordering::Relaxed); .store(duration_millis_saturated(duration), Ordering::Relaxed);
} }
pub fn record_scan_cycle_deferred(&self, duration: Duration) {
self.record_scanner_cycle_end_time();
self.last_scan_cycle_result
.store(SCAN_CYCLE_RESULT_DEFERRED, Ordering::Relaxed);
self.last_scan_cycle_partial_reason
.store(ScanCyclePartialReason::Unknown as u8, Ordering::Relaxed);
self.last_scan_cycle_partial_source.store(0, Ordering::Relaxed);
self.last_scan_cycle_duration_millis
.store(duration_millis_saturated(duration), Ordering::Relaxed);
}
pub fn record_scan_cycle_partial(&self, duration: Duration, reason: ScanCyclePartialReason) { pub fn record_scan_cycle_partial(&self, duration: Duration, reason: ScanCyclePartialReason) {
self.record_scan_cycle_partial_with_source(duration, reason, None); self.record_scan_cycle_partial_with_source(duration, reason, None);
} }
@@ -4283,21 +4264,6 @@ mod tests {
assert_eq!(report.partial_cycles, 0); assert_eq!(report.partial_cycles, 0);
} }
#[tokio::test]
async fn report_tracks_deferred_cycle_without_failed_increment() {
let metrics = Metrics::new();
metrics.record_scan_cycle_deferred(Duration::from_millis(250));
let report = metrics.report().await;
assert_eq!(report.last_cycle_result, SCAN_CYCLE_RESULT_DEFERRED_LABEL);
assert_eq!(report.last_cycle_result_code, u64::from(SCAN_CYCLE_RESULT_DEFERRED));
assert_eq!(report.last_cycle_duration_seconds, 0.25);
assert_eq!(report.failed_cycles, 0);
assert_eq!(report.superseded_cycles, 0);
assert_eq!(report.partial_cycles, 0);
}
#[tokio::test] #[tokio::test]
async fn report_tracks_successful_scan_cycle_without_failed_increment() { async fn report_tracks_successful_scan_cycle_without_failed_increment() {
let metrics = Metrics::new(); let metrics = Metrics::new();
-5
View File
@@ -353,11 +353,6 @@ pub const DEFAULT_OBS_TRACES_EXPORT_ENABLED: bool = true;
/// Environment variable: RUSTFS_OBS_METRICS_EXPORT_ENABLED /// Environment variable: RUSTFS_OBS_METRICS_EXPORT_ENABLED
pub const DEFAULT_OBS_METRICS_EXPORT_ENABLED: bool = true; pub const DEFAULT_OBS_METRICS_EXPORT_ENABLED: bool = true;
/// Default detailed PUT stage metrics enabled
/// Default value: false
/// Environment variable: RUSTFS_OBS_PUT_STAGE_METRICS_ENABLED
pub const DEFAULT_OBS_PUT_STAGE_METRICS_ENABLED: bool = false;
/// Default logs export enabled /// Default logs export enabled
/// It is used to enable or disable exporting logs /// It is used to enable or disable exporting logs
/// Default value: true /// Default value: true
-49
View File
@@ -137,37 +137,6 @@ pub const DEFAULT_TIER_REMOTE_VERSION_STATE_FLEET_CONFIRMED: bool = false;
const _: () = assert!(!DEFAULT_TIER_REMOTE_VERSION_STATE_WRITE); const _: () = assert!(!DEFAULT_TIER_REMOTE_VERSION_STATE_WRITE);
const _: () = assert!(!DEFAULT_TIER_REMOTE_VERSION_STATE_FLEET_CONFIRMED); const _: () = assert!(!DEFAULT_TIER_REMOTE_VERSION_STATE_FLEET_CONFIRMED);
/// Request the object-transaction fencing contract used by storage-owned
/// cleanup receipts and lock-window optimizations.
///
/// This is fail-closed: enabling the writer without a live fleet proof rejects
/// the commit rather than silently using a legacy-safe path.
pub const ENV_OBJECT_TRANSACTION_FENCING_WRITE: &str = "RUSTFS_OBJECT_TRANSACTION_FENCING_WRITE";
pub const DEFAULT_OBJECT_TRANSACTION_FENCING_WRITE: bool = false;
/// Operator-attested confirmation that every serving node understands the
/// object transaction fencing contract.
pub const ENV_OBJECT_TRANSACTION_FENCING_FLEET_CONFIRMED: &str = "RUSTFS_OBJECT_TRANSACTION_FENCING_FLEET_CONFIRMED";
pub const DEFAULT_OBJECT_TRANSACTION_FENCING_FLEET_CONFIRMED: bool = false;
const _: () = assert!(!DEFAULT_OBJECT_TRANSACTION_FENCING_WRITE);
const _: () = assert!(!DEFAULT_OBJECT_TRANSACTION_FENCING_FLEET_CONFIRMED);
/// Request preserving legacy per-part checksum metadata during data movement.
///
/// This remains ineffective until
/// [`ENV_DATA_MOVEMENT_PART_CHECKSUMS_FLEET_CONFIRMED`] is also enabled.
pub const ENV_DATA_MOVEMENT_PART_CHECKSUMS_WRITE: &str = "RUSTFS_DATA_MOVEMENT_PART_CHECKSUMS_WRITE";
pub const DEFAULT_DATA_MOVEMENT_PART_CHECKSUMS_WRITE: bool = false;
/// Operator-attested confirmation that every serving node understands the
/// data-movement per-part checksum sidecar.
pub const ENV_DATA_MOVEMENT_PART_CHECKSUMS_FLEET_CONFIRMED: &str = "RUSTFS_DATA_MOVEMENT_PART_CHECKSUMS_FLEET_CONFIRMED";
pub const DEFAULT_DATA_MOVEMENT_PART_CHECKSUMS_FLEET_CONFIRMED: bool = false;
const _: () = assert!(!DEFAULT_DATA_MOVEMENT_PART_CHECKSUMS_WRITE);
const _: () = assert!(!DEFAULT_DATA_MOVEMENT_PART_CHECKSUMS_FLEET_CONFIRMED);
// ============================================================================= // =============================================================================
// Concurrent Request Fix - Timeout and Backpressure Configuration // Concurrent Request Fix - Timeout and Backpressure Configuration
// ============================================================================= // =============================================================================
@@ -680,22 +649,4 @@ mod remote_version_state_tests {
"RUSTFS_TIER_REMOTE_VERSION_STATE_FLEET_CONFIRMED" "RUSTFS_TIER_REMOTE_VERSION_STATE_FLEET_CONFIRMED"
); );
} }
#[test]
fn data_movement_part_checksum_gate_uses_stable_environment_names() {
assert_eq!(super::ENV_DATA_MOVEMENT_PART_CHECKSUMS_WRITE, "RUSTFS_DATA_MOVEMENT_PART_CHECKSUMS_WRITE");
assert_eq!(
super::ENV_DATA_MOVEMENT_PART_CHECKSUMS_FLEET_CONFIRMED,
"RUSTFS_DATA_MOVEMENT_PART_CHECKSUMS_FLEET_CONFIRMED"
);
}
#[test]
fn object_transaction_fencing_gate_uses_stable_environment_names() {
assert_eq!(super::ENV_OBJECT_TRANSACTION_FENCING_WRITE, "RUSTFS_OBJECT_TRANSACTION_FENCING_WRITE");
assert_eq!(
super::ENV_OBJECT_TRANSACTION_FENCING_FLEET_CONFIRMED,
"RUSTFS_OBJECT_TRANSACTION_FENCING_FLEET_CONFIRMED"
);
}
} }
-3
View File
@@ -81,9 +81,6 @@ pub const ENV_TEST_IAM_FAIL_INIT_ATTEMPTS: &str = "RUSTFS_TEST_IAM_FAIL_INIT_ATT
pub const ENV_TEST_IAM_RETRY_INTERVAL_MS: &str = "RUSTFS_TEST_IAM_RETRY_INTERVAL_MS"; pub const ENV_TEST_IAM_RETRY_INTERVAL_MS: &str = "RUSTFS_TEST_IAM_RETRY_INTERVAL_MS";
/// Runtime env var controlling the transition worker count. /// Runtime env var controlling the transition worker count.
pub const ENV_TRANSITION_WORKERS: &str = "RUSTFS_MAX_TRANSITION_WORKERS"; pub const ENV_TRANSITION_WORKERS: &str = "RUSTFS_MAX_TRANSITION_WORKERS";
/// Runtime env var controlling the ILM expiry worker count. A set, parsable,
/// non-zero value wins; anything else falls back to `min(cpus, 16)`.
pub const ENV_MAX_EXPIRY_WORKERS: &str = "RUSTFS_MAX_EXPIRY_WORKERS";
/// Runtime env var controlling the absolute maximum transition workers. /// Runtime env var controlling the absolute maximum transition workers.
pub const ENV_TRANSITION_WORKERS_ABSOLUTE_MAX: &str = "RUSTFS_ABSOLUTE_MAX_WORKERS"; pub const ENV_TRANSITION_WORKERS_ABSOLUTE_MAX: &str = "RUSTFS_ABSOLUTE_MAX_WORKERS";
/// Runtime env var controlling the transition queue capacity. /// Runtime env var controlling the transition queue capacity.
-5
View File
@@ -44,10 +44,6 @@ pub const ENV_OBS_METRICS_EXPORT_ENABLED: &str = "RUSTFS_OBS_METRICS_EXPORT_ENAB
pub const ENV_OBS_LOGS_EXPORT_ENABLED: &str = "RUSTFS_OBS_LOGS_EXPORT_ENABLED"; pub const ENV_OBS_LOGS_EXPORT_ENABLED: &str = "RUSTFS_OBS_LOGS_EXPORT_ENABLED";
pub const ENV_OBS_PROFILING_EXPORT_ENABLED: &str = "RUSTFS_OBS_PROFILING_EXPORT_ENABLED"; pub const ENV_OBS_PROFILING_EXPORT_ENABLED: &str = "RUSTFS_OBS_PROFILING_EXPORT_ENABLED";
/// Enables detailed per-stage PUT metrics. Disabled by default because each
/// PUT records multiple timers and histograms when attribution is active.
pub const ENV_OBS_PUT_STAGE_METRICS_ENABLED: &str = "RUSTFS_OBS_PUT_STAGE_METRICS_ENABLED";
pub const ENV_OBS_LOGGER_LEVEL: &str = "RUSTFS_OBS_LOGGER_LEVEL"; pub const ENV_OBS_LOGGER_LEVEL: &str = "RUSTFS_OBS_LOGGER_LEVEL";
pub const ENV_OBS_LOG_STDOUT_ENABLED: &str = "RUSTFS_OBS_LOG_STDOUT_ENABLED"; pub const ENV_OBS_LOG_STDOUT_ENABLED: &str = "RUSTFS_OBS_LOG_STDOUT_ENABLED";
pub const ENV_OBS_LOG_DIRECTORY: &str = "RUSTFS_OBS_LOG_DIRECTORY"; pub const ENV_OBS_LOG_DIRECTORY: &str = "RUSTFS_OBS_LOG_DIRECTORY";
@@ -145,7 +141,6 @@ mod tests {
assert_eq!(ENV_OBS_METRICS_EXPORT_ENABLED, "RUSTFS_OBS_METRICS_EXPORT_ENABLED"); assert_eq!(ENV_OBS_METRICS_EXPORT_ENABLED, "RUSTFS_OBS_METRICS_EXPORT_ENABLED");
assert_eq!(ENV_OBS_LOGS_EXPORT_ENABLED, "RUSTFS_OBS_LOGS_EXPORT_ENABLED"); assert_eq!(ENV_OBS_LOGS_EXPORT_ENABLED, "RUSTFS_OBS_LOGS_EXPORT_ENABLED");
assert_eq!(ENV_OBS_PROFILING_EXPORT_ENABLED, "RUSTFS_OBS_PROFILING_EXPORT_ENABLED"); assert_eq!(ENV_OBS_PROFILING_EXPORT_ENABLED, "RUSTFS_OBS_PROFILING_EXPORT_ENABLED");
assert_eq!(ENV_OBS_PUT_STAGE_METRICS_ENABLED, "RUSTFS_OBS_PUT_STAGE_METRICS_ENABLED");
// Test log cleanup related env keys // Test log cleanup related env keys
assert_eq!(ENV_OBS_LOG_MAX_TOTAL_SIZE_BYTES, "RUSTFS_OBS_LOG_MAX_TOTAL_SIZE_BYTES"); assert_eq!(ENV_OBS_LOG_MAX_TOTAL_SIZE_BYTES, "RUSTFS_OBS_LOG_MAX_TOTAL_SIZE_BYTES");
assert_eq!(ENV_OBS_LOG_MAX_SINGLE_FILE_SIZE_BYTES, "RUSTFS_OBS_LOG_MAX_SINGLE_FILE_SIZE_BYTES"); assert_eq!(ENV_OBS_LOG_MAX_SINGLE_FILE_SIZE_BYTES, "RUSTFS_OBS_LOG_MAX_SINGLE_FILE_SIZE_BYTES");
+1
View File
@@ -37,6 +37,7 @@ hotpath-cpu = ["hotpath", "hotpath/hotpath-cpu", "rustfs-filemeta/hotpath-cpu"]
hotpath.workspace = true hotpath.workspace = true
serde = { workspace = true, features = ["derive"] } serde = { workspace = true, features = ["derive"] }
rmp-serde = { workspace = true } rmp-serde = { workspace = true }
async-trait = { workspace = true }
rustfs-filemeta = { workspace = true } rustfs-filemeta = { workspace = true }
[lib] [lib]
+25 -91
View File
@@ -846,15 +846,8 @@ impl DataUsageEntry {
} }
} }
/// Read-only projection of the scanner's `.usage-cache.bin` info block. /// Data usage cache info
/// #[derive(Clone, Debug, Default, Serialize, Deserialize)]
/// The canonical wire format is written by the hand-written map-encoded
/// `Serialize` on the scanner-side `DataUsageCacheInfo`
/// (`crates/scanner/src/data_usage_define.rs`), which carries 16 fields.
/// This type decodes only the shared subset and is deliberately not
/// `Serialize`: a derived (array) encoding of this 6-field subset would
/// corrupt the cache for scanner readers, so no write path may exist here.
#[derive(Clone, Debug, Default, Deserialize)]
pub struct DataUsageCacheInfo { pub struct DataUsageCacheInfo {
pub name: String, pub name: String,
pub next_cycle: u64, pub next_cycle: u64,
@@ -870,12 +863,8 @@ pub struct DataUsageCacheInfo {
pub snapshot_complete: bool, pub snapshot_complete: bool,
} }
/// Read-only projection of a scanner-written `.usage-cache.bin` file. /// Data usage cache
/// #[derive(Clone, Debug, Default, Serialize, Deserialize)]
/// The scanner-side `DataUsageCache` (`crates/scanner/src/data_usage_define.rs`)
/// owns the persisted format; this type only decodes it (see
/// [`DataUsageCacheInfo`]) and must never grow a serialization path.
#[derive(Clone, Debug, Default, Deserialize)]
pub struct DataUsageCache { pub struct DataUsageCache {
pub info: DataUsageCacheInfo, pub info: DataUsageCacheInfo,
pub cache: HashMap<String, DataUsageEntry>, pub cache: HashMap<String, DataUsageEntry>,
@@ -1197,10 +1186,31 @@ impl DataUsageCache {
} }
} }
pub fn marshal_msg(&self) -> Result<Vec<u8>, Box<dyn std::error::Error + Send + Sync>> {
let mut buf = Vec::new();
self.serialize(&mut rmp_serde::Serializer::new(&mut buf))?;
Ok(buf)
}
pub fn unmarshal(buf: &[u8]) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> { pub fn unmarshal(buf: &[u8]) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
let t: Self = rmp_serde::from_slice(buf)?; let t: Self = rmp_serde::from_slice(buf)?;
Ok(t) Ok(t)
} }
// Note: load and save methods are storage-specific and should be implemented
// in the ecstore crate where storage access is available
}
/// Trait for storage-specific operations on DataUsageCache
#[async_trait::async_trait]
pub trait DataUsageCacheStorage {
/// Load data usage cache from backend storage
async fn load(store: &dyn std::any::Any, name: &str) -> Result<Self, Box<dyn std::error::Error + Send + Sync>>
where
Self: Sized;
/// Save data usage cache to backend storage
async fn save(&self, name: &str) -> Result<(), Box<dyn std::error::Error + Send + Sync>>;
} }
// Helper structs and functions for cache operations // Helper structs and functions for cache operations
@@ -1822,82 +1832,6 @@ mod tests {
assert!(decoded.all_tier_stats.is_none()); assert!(decoded.all_tier_stats.is_none());
} }
/// Scanner-written `.usage-cache.bin` bytes: a 2-element array of the
/// canonical 16-field map-encoded info block and one map-encoded entry.
/// Captured from the canonical writer's `marshal_msg` — see
/// `usage_cache_wire_format_is_pinned` in
/// `crates/scanner/src/data_usage_define.rs`, which pins these exact
/// bytes and documents regeneration. Hardcoded here because a
/// dev-dependency on rustfs-scanner would pull the whole ecstore tree
/// into this crate's test build, and a fixture generated at test runtime
/// could not detect writer drift anyway.
const SCANNER_USAGE_CACHE_WIRE_FIXTURE: &[u8] = &[
0x92, 0xde, 0x00, 0x10, 0xa4, 0x6e, 0x61, 0x6d, 0x65, 0xab, 0x77, 0x69, 0x72, 0x65, 0x2d, 0x62, 0x75, 0x63, 0x6b, 0x65,
0x74, 0xaa, 0x6e, 0x65, 0x78, 0x74, 0x5f, 0x63, 0x79, 0x63, 0x6c, 0x65, 0x07, 0xac, 0x6c, 0x65, 0x61, 0x64, 0x65, 0x72,
0x5f, 0x65, 0x70, 0x6f, 0x63, 0x68, 0x09, 0xab, 0x6c, 0x61, 0x73, 0x74, 0x5f, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x92,
0xce, 0x65, 0x53, 0xf1, 0x00, 0x00, 0xac, 0x73, 0x6b, 0x69, 0x70, 0x5f, 0x68, 0x65, 0x61, 0x6c, 0x69, 0x6e, 0x67, 0xc3,
0xa9, 0x6c, 0x69, 0x66, 0x65, 0x63, 0x79, 0x63, 0x6c, 0x65, 0xc0, 0xab, 0x72, 0x65, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x74,
0x69, 0x6f, 0x6e, 0xc0, 0xae, 0x66, 0x61, 0x69, 0x6c, 0x65, 0x64, 0x5f, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x81,
0xb0, 0x77, 0x69, 0x72, 0x65, 0x2d, 0x62, 0x75, 0x63, 0x6b, 0x65, 0x74, 0x2f, 0x6c, 0x6f, 0x73, 0x74, 0x0b, 0xb1, 0x73,
0x63, 0x61, 0x6e, 0x5f, 0x72, 0x65, 0x73, 0x75, 0x6d, 0x65, 0x5f, 0x61, 0x66, 0x74, 0x65, 0x72, 0xb2, 0x77, 0x69, 0x72,
0x65, 0x2d, 0x62, 0x75, 0x63, 0x6b, 0x65, 0x74, 0x2f, 0x72, 0x65, 0x73, 0x75, 0x6d, 0x65, 0xaf, 0x73, 0x63, 0x61, 0x6e,
0x5f, 0x63, 0x68, 0x65, 0x63, 0x6b, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0xc0, 0xad, 0x70, 0x65, 0x6e, 0x64, 0x69, 0x6e, 0x67,
0x5f, 0x68, 0x65, 0x61, 0x6c, 0x73, 0x91, 0x9a, 0xa6, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0xab, 0x77, 0x69, 0x72, 0x65,
0x2d, 0x62, 0x75, 0x63, 0x6b, 0x65, 0x74, 0xa6, 0x62, 0x72, 0x6f, 0x6b, 0x65, 0x6e, 0xc0, 0x01, 0x64, 0xcc, 0xc8, 0x03,
0xa8, 0x64, 0x65, 0x66, 0x65, 0x72, 0x72, 0x65, 0x64, 0xa6, 0x62, 0x75, 0x64, 0x67, 0x65, 0x74, 0xab, 0x6f, 0x62, 0x6a,
0x65, 0x63, 0x74, 0x5f, 0x6c, 0x6f, 0x63, 0x6b, 0xc0, 0xa6, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x92, 0x01, 0x02, 0xb1,
0x73, 0x6e, 0x61, 0x70, 0x73, 0x68, 0x6f, 0x74, 0x5f, 0x63, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0xc3, 0xb0, 0x73,
0x63, 0x61, 0x6e, 0x5f, 0x70, 0x6c, 0x61, 0x6e, 0x5f, 0x64, 0x69, 0x67, 0x65, 0x73, 0x74, 0xdc, 0x00, 0x20, 0x03, 0x03,
0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03,
0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0xb0, 0x63, 0x61, 0x63, 0x68, 0x65, 0x5f, 0x6b, 0x65, 0x79,
0x5f, 0x66, 0x6f, 0x72, 0x6d, 0x61, 0x74, 0x01, 0x81, 0xab, 0x77, 0x69, 0x72, 0x65, 0x2d, 0x62, 0x75, 0x63, 0x6b, 0x65,
0x74, 0x8b, 0xa8, 0x63, 0x68, 0x69, 0x6c, 0x64, 0x72, 0x65, 0x6e, 0x90, 0xa4, 0x73, 0x69, 0x7a, 0x65, 0xcd, 0x10, 0x00,
0xa7, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x03, 0xa8, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x05, 0xae,
0x64, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x5f, 0x6d, 0x61, 0x72, 0x6b, 0x65, 0x72, 0x73, 0x01, 0xa9, 0x6f, 0x62, 0x6a, 0x5f,
0x73, 0x69, 0x7a, 0x65, 0x73, 0x9b, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xac, 0x6f, 0x62,
0x6a, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x97, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xb1, 0x72,
0x65, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x73, 0x74, 0x61, 0x74, 0x73, 0xc0, 0xa9, 0x63, 0x6f,
0x6d, 0x70, 0x61, 0x63, 0x74, 0x65, 0x64, 0xc3, 0xae, 0x66, 0x61, 0x69, 0x6c, 0x65, 0x64, 0x5f, 0x6f, 0x62, 0x6a, 0x65,
0x63, 0x74, 0x73, 0x02, 0xae, 0x61, 0x6c, 0x6c, 0x5f, 0x74, 0x69, 0x65, 0x72, 0x5f, 0x73, 0x74, 0x61, 0x74, 0x73, 0x91,
0x81, 0xa4, 0x57, 0x41, 0x52, 0x4d, 0x93, 0xcd, 0x08, 0x00, 0x02, 0x01,
];
#[test]
fn thin_usage_cache_decodes_scanner_wire_fixture() {
let decoded =
DataUsageCache::unmarshal(SCANNER_USAGE_CACHE_WIRE_FIXTURE).expect("thin projection decodes a scanner-written cache");
// The six fields shared with the scanner's 16-field info block; the
// remaining ten (lifecycle, replication, checkpoint, heals, ...) must
// be skipped, not error.
assert_eq!(decoded.info.name, "wire-bucket");
assert_eq!(decoded.info.next_cycle, 7);
assert_eq!(
decoded.info.last_update,
Some(SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000))
);
assert!(decoded.info.skip_healing);
assert_eq!(decoded.info.failed_objects.get("wire-bucket/lost"), Some(&11));
assert!(decoded.info.snapshot_complete);
// Entries use the shared canonical map-encoded type end to end.
let entry = decoded.cache.get("wire-bucket").expect("fixture entry decodes");
assert_eq!(entry.size, 4096);
assert_eq!(entry.objects, 3);
assert_eq!(entry.versions, 5);
assert_eq!(entry.delete_markers, 1);
assert!(entry.compacted);
assert_eq!(entry.failed_objects, 2);
assert_eq!(
entry.all_tier_stats.as_ref().and_then(|tiers| tiers.tiers.get("WARM")),
Some(&TierStats {
total_size: 2048,
num_versions: 2,
num_objects: 1,
})
);
}
#[test] #[test]
fn hash_path_uses_portable_slash_semantics() { fn hash_path_uses_portable_slash_semantics() {
for (input, expected) in [ for (input, expected) in [
+18 -102
View File
@@ -40,8 +40,7 @@ use http::header::{CONTENT_TYPE, HOST};
use rustfs_signer::constants::UNSIGNED_PAYLOAD; use rustfs_signer::constants::UNSIGNED_PAYLOAD;
use rustfs_signer::sign_v4; use rustfs_signer::sign_v4;
use s3s::Body; use s3s::Body;
use sha2::{Digest, Sha256}; use std::collections::BTreeSet;
use std::collections::{BTreeMap, BTreeSet};
use std::error::Error; use std::error::Error;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use tracing::info; use tracing::info;
@@ -60,26 +59,13 @@ pub(crate) struct VersionShardCensus {
pub version_id: Option<String>, pub version_id: Option<String>,
pub has_xl_meta: bool, pub has_xl_meta: bool,
pub data_dir: Option<String>, pub data_dir: Option<String>,
pub erasure_index: Option<usize>,
pub expected_part_numbers: BTreeSet<usize>, pub expected_part_numbers: BTreeSet<usize>,
pub present_part_fingerprints: BTreeMap<usize, PartShardFingerprint>, pub present_part_numbers: BTreeSet<usize>,
pub inline_data_fingerprint: Option<PartShardFingerprint>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct PartShardFingerprint {
pub size: u64,
pub sha256: String,
} }
impl VersionShardCensus { impl VersionShardCensus {
pub(crate) fn is_complete(&self) -> bool { pub(crate) fn is_complete(&self) -> bool {
self.has_xl_meta self.has_xl_meta && self.expected_part_numbers == self.present_part_numbers
&& self.expected_part_numbers.len() == self.present_part_fingerprints.len()
&& self
.expected_part_numbers
.iter()
.all(|part_number| self.present_part_fingerprints.contains_key(part_number))
} }
pub(crate) fn matches_manifest(&self, manifest: &Self) -> bool { pub(crate) fn matches_manifest(&self, manifest: &Self) -> bool {
@@ -87,25 +73,10 @@ impl VersionShardCensus {
&& self.is_complete() && self.is_complete()
&& manifest.is_complete() && manifest.is_complete()
&& self.data_dir == manifest.data_dir && self.data_dir == manifest.data_dir
&& self.erasure_index == manifest.erasure_index
&& self.expected_part_numbers == manifest.expected_part_numbers && self.expected_part_numbers == manifest.expected_part_numbers
&& self.present_part_fingerprints == manifest.present_part_fingerprints
&& self.inline_data_fingerprint == manifest.inline_data_fingerprint
} }
} }
fn sha256_hex(data: &[u8]) -> String {
let digest = Sha256::digest(data);
digest.iter().map(|byte| format!("{byte:02x}")).collect()
}
fn shard_fingerprint(data: &[u8]) -> ChaosResult<PartShardFingerprint> {
Ok(PartShardFingerprint {
size: u64::try_from(data.len())?,
sha256: sha256_hex(data),
})
}
/// Single-node RustFS server with `disk_count` local volume directories that /// Single-node RustFS server with `disk_count` local volume directories that
/// can be faulted individually while the server is running. /// can be faulted individually while the server is running.
pub struct DiskFaultHarness { pub struct DiskFaultHarness {
@@ -312,10 +283,8 @@ pub(crate) fn census_object_version_on_disk(
version_id, version_id,
has_xl_meta: false, has_xl_meta: false,
data_dir: None, data_dir: None,
erasure_index: None,
expected_part_numbers: BTreeSet::new(), expected_part_numbers: BTreeSet::new(),
present_part_fingerprints: BTreeMap::new(), present_part_numbers: BTreeSet::new(),
inline_data_fingerprint: None,
}); });
} }
@@ -327,31 +296,20 @@ pub(crate) fn census_object_version_on_disk(
file_info.parts.iter().map(|part| part.number).collect() file_info.parts.iter().map(|part| part.number).collect()
}; };
let data_dir = file_info.data_dir.map(|id| id.to_string()); let data_dir = file_info.data_dir.map(|id| id.to_string());
let erasure_index = Some(file_info.erasure.index);
let inline_data_fingerprint = file_info.data.as_deref().map(shard_fingerprint).transpose()?;
let part_dir = data_dir.as_ref().map_or_else(|| object_dir.clone(), |id| object_dir.join(id)); let part_dir = data_dir.as_ref().map_or_else(|| object_dir.clone(), |id| object_dir.join(id));
let present_part_fingerprints = match std::fs::read_dir(&part_dir) { let present_part_numbers = match std::fs::read_dir(&part_dir) {
Ok(entries) => { Ok(entries) => entries
let mut fingerprints = BTreeMap::new(); .filter_map(Result::ok)
for entry in entries { .filter_map(|entry| {
let entry = entry?; entry
if !entry.file_type()?.is_file() { .file_type()
continue; .ok()
} .filter(|kind| kind.is_file())
let file_name = entry.file_name(); .and_then(|_| entry.file_name().to_str().map(str::to_owned))
let Some(part_number) = file_name })
.to_str() .filter_map(|name| name.strip_prefix("part.").and_then(|number| number.parse::<usize>().ok()))
.and_then(|name| name.strip_prefix("part.")) .collect(),
.and_then(|number| number.parse::<usize>().ok()) Err(error) if error.kind() == std::io::ErrorKind::NotFound => BTreeSet::new(),
else {
continue;
};
let data = std::fs::read(entry.path())?;
fingerprints.insert(part_number, shard_fingerprint(&data)?);
}
fingerprints
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => BTreeMap::new(),
Err(error) => return Err(error.into()), Err(error) => return Err(error.into()),
}; };
@@ -359,10 +317,8 @@ pub(crate) fn census_object_version_on_disk(
version_id, version_id,
has_xl_meta: true, has_xl_meta: true,
data_dir, data_dir,
erasure_index,
expected_part_numbers, expected_part_numbers,
present_part_fingerprints, present_part_numbers,
inline_data_fingerprint,
}) })
} }
@@ -402,43 +358,3 @@ pub async fn signed_admin_post(url: &str, body: Option<&str>, access_key: &str,
Ok(body) Ok(body)
} }
#[cfg(test)]
mod tests {
use super::*;
fn complete_census() -> VersionShardCensus {
VersionShardCensus {
version_id: Some("version".to_string()),
has_xl_meta: true,
data_dir: Some("data-dir".to_string()),
erasure_index: Some(3),
expected_part_numbers: BTreeSet::from([1]),
present_part_fingerprints: BTreeMap::from([(1, shard_fingerprint(b"part").unwrap())]),
inline_data_fingerprint: None,
}
}
#[test]
fn shard_fingerprint_uses_physical_length_and_sha256() {
assert_eq!(
shard_fingerprint(b"abc").unwrap(),
PartShardFingerprint {
size: 3,
sha256: "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad".to_string(),
}
);
}
#[test]
fn manifest_requires_matching_inline_payload() {
let mut expected = complete_census();
expected.expected_part_numbers.clear();
expected.present_part_fingerprints.clear();
expected.inline_data_fingerprint = Some(shard_fingerprint(b"expected").unwrap());
let mut changed = expected.clone();
changed.inline_data_fingerprint = Some(shard_fingerprint(b"changed").unwrap());
assert!(expected.matches_manifest(&expected));
assert!(!changed.matches_manifest(&expected));
}
}
+7 -44
View File
@@ -67,19 +67,6 @@ fn configured_capture_log_path(temp_dir: &str) -> Option<String> {
capture_log_path(Path::new(&log_dir), temp_dir).map(|path| path.to_string_lossy().into_owned()) capture_log_path(Path::new(&log_dir), temp_dir).map(|path| path.to_string_lossy().into_owned())
} }
pub(crate) fn capture_command_logs(
command: &mut Command,
log_path: Option<&str>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let Some(log_path) = log_path else {
return Ok(());
};
let file = stdfs::OpenOptions::new().create(true).append(true).open(log_path)?;
let stderr_file = file.try_clone()?;
command.stdout(Stdio::from(file)).stderr(Stdio::from(stderr_file));
Ok(())
}
pub(crate) fn build_test_s3_config( pub(crate) fn build_test_s3_config(
endpoint_url: &str, endpoint_url: &str,
access_key: &str, access_key: &str,
@@ -570,7 +557,13 @@ impl RustFSTestEnvironment {
for (key, value) in extra_env { for (key, value) in extra_env {
command.env(key, value); command.env(key, value);
} }
capture_command_logs(&mut command, self.capture_log_path.as_deref())?; // Optionally capture the child's stdout+stderr to a file so the test can
// grep server logs (e.g. to confirm which GET reader path was taken).
if let Some(log_path) = &self.capture_log_path {
let file = stdfs::OpenOptions::new().create(true).append(true).open(log_path)?;
let stderr_file = file.try_clone()?;
command.stdout(Stdio::from(file)).stderr(Stdio::from(stderr_file));
}
let process = command.args(&args).spawn()?; let process = command.args(&args).spawn()?;
self.process = Some(process); self.process = Some(process);
@@ -1058,7 +1051,6 @@ pub struct RustFSTestClusterEnvironment {
pub secret_key: String, pub secret_key: String,
pub extra_env: Vec<(String, String)>, pub extra_env: Vec<(String, String)>,
pub node_extra_env: Vec<Vec<(String, String)>>, pub node_extra_env: Vec<Vec<(String, String)>>,
pub node_capture_log_paths: Vec<Option<String>>,
pub topology: ClusterTopology, pub topology: ClusterTopology,
} }
@@ -1158,7 +1150,6 @@ impl RustFSTestClusterEnvironment {
secret_key: "rustfs-cluster-test-secret".to_string(), secret_key: "rustfs-cluster-test-secret".to_string(),
extra_env, extra_env,
node_extra_env: vec![Vec::new(); topology.node_count], node_extra_env: vec![Vec::new(); topology.node_count],
node_capture_log_paths: vec![None; topology.node_count],
topology, topology,
}) })
} }
@@ -1188,20 +1179,6 @@ impl RustFSTestClusterEnvironment {
Ok(()) Ok(())
} }
/// Capture stdout+stderr for a single cluster node process.
pub fn set_node_capture_log_path<P>(
&mut self,
node_idx: usize,
path: P,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>>
where
P: Into<String>,
{
self.ensure_node_index(node_idx)?;
self.node_capture_log_paths[node_idx] = Some(path.into());
Ok(())
}
fn ensure_node_index(&self, node_idx: usize) -> Result<(), Box<dyn std::error::Error + Send + Sync>> { fn ensure_node_index(&self, node_idx: usize) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
if node_idx >= self.nodes.len() { if node_idx >= self.nodes.len() {
return Err(format!("node_idx {node_idx} is invalid").into()); return Err(format!("node_idx {node_idx} is invalid").into());
@@ -1291,7 +1268,6 @@ impl RustFSTestClusterEnvironment {
for (key, value) in &self.node_extra_env[i] { for (key, value) in &self.node_extra_env[i] {
command.env(key, value); command.env(key, value);
} }
capture_command_logs(&mut command, self.node_capture_log_paths[i].as_deref())?;
let process = command.current_dir(&node.data_dir).spawn()?; let process = command.current_dir(&node.data_dir).spawn()?;
@@ -1318,7 +1294,6 @@ impl RustFSTestClusterEnvironment {
let binary_path = rustfs_binary_path(); let binary_path = rustfs_binary_path();
let volumes_arg = self.build_volumes_arg(); let volumes_arg = self.build_volumes_arg();
let log_path = self.node_capture_log_paths[node_idx].clone();
let node = &mut self.nodes[node_idx]; let node = &mut self.nodes[node_idx];
info!("Starting cluster node {} on {}", node_idx, node.address); info!("Starting cluster node {} on {}", node_idx, node.address);
@@ -1337,7 +1312,6 @@ impl RustFSTestClusterEnvironment {
for (key, value) in &self.node_extra_env[node_idx] { for (key, value) in &self.node_extra_env[node_idx] {
command.env(key, value); command.env(key, value);
} }
capture_command_logs(&mut command, log_path.as_deref())?;
let process = command.current_dir(&node.data_dir).spawn()?; let process = command.current_dir(&node.data_dir).spawn()?;
node.process = Some(process); node.process = Some(process);
@@ -1589,7 +1563,6 @@ mod tests {
secret_key: DEFAULT_SECRET_KEY.to_string(), secret_key: DEFAULT_SECRET_KEY.to_string(),
extra_env: Vec::new(), extra_env: Vec::new(),
node_extra_env: vec![Vec::new(); topology.node_count], node_extra_env: vec![Vec::new(); topology.node_count],
node_capture_log_paths: vec![None; topology.node_count],
topology, topology,
} }
} }
@@ -1685,16 +1658,6 @@ mod tests {
); );
} }
#[test]
fn cluster_node_log_capture_supports_per_node_paths() {
let mut env = fake_cluster(ClusterTopology::single_pool(3));
env.set_node_capture_log_path(1, "/tmp/node1.log").unwrap();
assert_eq!(env.node_capture_log_paths[0], None);
assert_eq!(env.node_capture_log_paths[1], Some("/tmp/node1.log".to_string()));
assert_eq!(env.node_capture_log_paths[2], None);
assert!(env.set_node_capture_log_path(3, "/tmp/invalid.log").is_err());
}
#[test] #[test]
fn cluster_node_env_rejects_invalid_index() { fn cluster_node_env_rejects_invalid_index() {
let mut env = fake_cluster(ClusterTopology::single_pool(4)); let mut env = fake_cluster(ClusterTopology::single_pool(4));
+3 -663
View File
@@ -2,7 +2,6 @@
use crate::common::{RustFSTestEnvironment, init_logging, rustfs_binary_path}; use crate::common::{RustFSTestEnvironment, init_logging, rustfs_binary_path};
use aws_sdk_s3::primitives::ByteStream; use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::{CompletedMultipartUpload, CompletedPart};
use serial_test::serial; use serial_test::serial;
use std::fs; use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
@@ -26,15 +25,6 @@ fn generate_compressible_data(size: usize) -> Vec<u8> {
data data
} }
/// Deterministic 2048-byte-period binary pattern that compresses extremely well: every part
/// yields many compressed blocks, which is exactly the shape that reproduced the mid-payload
/// Pending truncation (rustfs/rustfs#5957).
fn generate_high_ratio_binary_data(size: usize, seed: u8) -> Vec<u8> {
(0..size)
.map(|i| ((i as u64).wrapping_mul(2_654_435_761).wrapping_add(seed as u64) >> 3) as u8)
.collect()
}
fn find_part_files(temp_dir: &str, bucket: &str, object_key: &str) -> Vec<PathBuf> { fn find_part_files(temp_dir: &str, bucket: &str, object_key: &str) -> Vec<PathBuf> {
let bucket_path = PathBuf::from(temp_dir).join(bucket); let bucket_path = PathBuf::from(temp_dir).join(bucket);
let mut part_files = Vec::new(); let mut part_files = Vec::new();
@@ -65,14 +55,9 @@ async fn start_rustfs_with_compression(env: &mut RustFSTestEnvironment) -> Resul
env.cleanup_existing_processes().await?; env.cleanup_existing_processes().await?;
let binary_path = rustfs_binary_path(); let binary_path = rustfs_binary_path();
// Route the child's stdout/stderr through the shared RUSTFS_E2E_LOG_DIR let process = Command::new(&binary_path)
// capture (survives the temp-dir cleanup on Drop and is uploaded as a CI
// artifact); without the env var the child inherits stdio as before.
let mut command = Command::new(&binary_path);
command
.env("RUSTFS_CONSOLE_ENABLE", "false") .env("RUSTFS_CONSOLE_ENABLE", "false")
.env("RUSTFS_COMPRESSION_ENABLED", "true") .env("RUSTFS_COMPRESSION_ENABLED", "true")
.env("RUSTFS_COMPRESSION_MULTIPART_ENABLED", "true")
.args([ .args([
"--address", "--address",
&env.address, &env.address,
@@ -81,9 +66,8 @@ async fn start_rustfs_with_compression(env: &mut RustFSTestEnvironment) -> Resul
"--secret-key", "--secret-key",
&env.secret_key, &env.secret_key,
&env.temp_dir, &env.temp_dir,
]); ])
crate::common::capture_command_logs(&mut command, env.capture_log_path.as_deref())?; .spawn()?;
let process = command.spawn()?;
env.process = Some(process); env.process = Some(process);
@@ -170,647 +154,3 @@ async fn test_compression_roundtrip() -> Result<(), Box<dyn std::error::Error +
env.stop_server(); env.stop_server();
Ok(()) Ok(())
} }
const MULTIPART_COMPRESSION_BUCKET: &str = "compression-multipart-bucket";
const MPU_PART1_SIZE: usize = 5 * 1024 * 1024;
const MPU_PART2_SIZE: usize = 1024 * 1024;
async fn multipart_upload(
client: &aws_sdk_s3::Client,
bucket: &str,
key: &str,
parts: &[&[u8]],
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let create = client.create_multipart_upload().bucket(bucket).key(key).send().await?;
let upload_id = create.upload_id().ok_or("missing upload id")?.to_string();
let mut completed_parts = Vec::with_capacity(parts.len());
for (i, part) in parts.iter().enumerate() {
let part_number = (i + 1) as i32;
let upload = client
.upload_part()
.bucket(bucket)
.key(key)
.upload_id(&upload_id)
.part_number(part_number)
.body(ByteStream::from(part.to_vec()))
.send()
.await?;
completed_parts.push(
CompletedPart::builder()
.part_number(part_number)
.e_tag(upload.e_tag().unwrap_or_default())
.build(),
);
}
client
.complete_multipart_upload()
.bucket(bucket)
.key(key)
.upload_id(&upload_id)
.multipart_upload(CompletedMultipartUpload::builder().set_parts(Some(completed_parts)).build())
.send()
.await?;
Ok(())
}
async fn fetch_range(
client: &aws_sdk_s3::Client,
bucket: &str,
key: &str,
range: &str,
) -> Result<Vec<u8>, Box<dyn std::error::Error + Send + Sync>> {
let response = client.get_object().bucket(bucket).key(key).range(range).send().await?;
Ok(response.body.collect().await?.into_bytes().to_vec())
}
/// Multipart disk compression roundtrip: parts are written as independent
/// compressed streams and every GET shape must reassemble the original bytes
/// (rustfs/rustfs#5957: multipart uploads previously bypassed disk compression
/// entirely).
#[tokio::test]
#[serial]
async fn test_compression_multipart_roundtrip() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
info!("Starting multipart compression roundtrip test");
let mut env = RustFSTestEnvironment::new().await?;
start_rustfs_with_compression(&mut env).await?;
let client = env.create_s3_client();
env.create_test_bucket(MULTIPART_COMPRESSION_BUCKET).await?;
let object_key = "multipart-compressible.txt";
let part1 = generate_compressible_data(MPU_PART1_SIZE);
let part2 = generate_compressible_data(MPU_PART2_SIZE);
let mut original_data = part1.clone();
original_data.extend_from_slice(&part2);
let total_size = original_data.len();
multipart_upload(&client, MULTIPART_COMPRESSION_BUCKET, object_key, &[&part1, &part2]).await?;
let head_response = client
.head_object()
.bucket(MULTIPART_COMPRESSION_BUCKET)
.key(object_key)
.send()
.await?;
assert_eq!(
head_response.content_length().unwrap_or(0) as usize,
total_size,
"Content-Length should be the logical object size"
);
let part_files = find_part_files(&env.temp_dir, MULTIPART_COMPRESSION_BUCKET, object_key);
assert!(!part_files.is_empty(), "expected on-disk part files for the multipart object");
let total_physical_size: u64 = part_files.iter().filter_map(|p| fs::metadata(p).ok()).map(|m| m.len()).sum();
assert!(
total_physical_size < (total_size / 2) as u64,
"Physical size {total_physical_size} should be well below original size {total_size} (multipart compression applied)"
);
info!("Multipart physical storage size: {total_physical_size} bytes (compressed from {total_size} bytes)");
// Full GET must reassemble both independently compressed parts.
let get_response = client
.get_object()
.bucket(MULTIPART_COMPRESSION_BUCKET)
.key(object_key)
.send()
.await?;
let downloaded = get_response.body.collect().await?.into_bytes();
assert_eq!(downloaded.len(), total_size);
assert_eq!(&downloaded[..], &original_data[..], "full GET data mismatch");
// Range fully inside part 1.
let range_inside_part1 = fetch_range(&client, MULTIPART_COMPRESSION_BUCKET, object_key, "bytes=1024-999423").await?;
assert_eq!(&range_inside_part1[..], &original_data[1024..999424], "part-1 range mismatch");
// Range crossing the part boundary.
let boundary_start = MPU_PART1_SIZE - 128 * 1024;
let boundary_end = MPU_PART1_SIZE + 128 * 1024 - 1;
let range_crossing = fetch_range(
&client,
MULTIPART_COMPRESSION_BUCKET,
object_key,
&format!("bytes={boundary_start}-{boundary_end}"),
)
.await?;
assert_eq!(
&range_crossing[..],
&original_data[boundary_start..boundary_end + 1],
"boundary-crossing range mismatch"
);
// Range fully inside part 2.
let part2_start = MPU_PART1_SIZE + 4096;
let part2_end = MPU_PART1_SIZE + 256 * 1024 - 1;
let range_inside_part2 = fetch_range(
&client,
MULTIPART_COMPRESSION_BUCKET,
object_key,
&format!("bytes={part2_start}-{part2_end}"),
)
.await?;
assert_eq!(
&range_inside_part2[..],
&original_data[part2_start..part2_end + 1],
"part-2 range mismatch"
);
// Suffix range (last 128 KiB, entirely in part 2).
let suffix_len = 128 * 1024;
let suffix = fetch_range(&client, MULTIPART_COMPRESSION_BUCKET, object_key, &format!("bytes=-{suffix_len}")).await?;
assert_eq!(&suffix[..], &original_data[total_size - suffix_len..], "suffix range mismatch");
// partNumber GETs must return each original part.
for (part_number, expected) in [(1, &part1), (2, &part2)] {
let response = client
.get_object()
.bucket(MULTIPART_COMPRESSION_BUCKET)
.key(object_key)
.part_number(part_number)
.send()
.await?;
let body = response.body.collect().await?.into_bytes();
assert_eq!(&body[..], &expected[..], "partNumber={part_number} GET mismatch");
}
info!("Multipart compression roundtrip test passed");
env.delete_test_bucket(MULTIPART_COMPRESSION_BUCKET).await?;
env.stop_server();
Ok(())
}
const MPU_HIGH_RATIO_BUCKET: &str = "compression-mpu-high-ratio-bucket";
/// High-ratio binary multipart payload: the object key is on the compression allow-list, so the
/// disk-compression path runs and each part is stored as many compressed blocks — the shape that
/// reproduced the mid-payload Pending truncation (rustfs/rustfs#5957). Every GET shape must return
/// the exact original bytes, and the stored size must show the data really was compressed.
#[tokio::test]
#[serial]
async fn test_compression_multipart_high_ratio_binary_roundtrip() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
info!("Starting multipart high-ratio binary compression roundtrip test");
let mut env = RustFSTestEnvironment::new().await?;
start_rustfs_with_compression(&mut env).await?;
let client = env.create_s3_client();
env.create_test_bucket(MPU_HIGH_RATIO_BUCKET).await?;
let object_key = "multipart-high-ratio.txt";
let part1 = generate_high_ratio_binary_data(MPU_PART1_SIZE, 7);
let part2 = generate_high_ratio_binary_data(MPU_PART2_SIZE, 61);
let mut original_data = part1.clone();
original_data.extend_from_slice(&part2);
let total_size = original_data.len();
multipart_upload(&client, MPU_HIGH_RATIO_BUCKET, object_key, &[&part1, &part2]).await?;
let head_response = client
.head_object()
.bucket(MPU_HIGH_RATIO_BUCKET)
.key(object_key)
.send()
.await?;
assert_eq!(
head_response.content_length().unwrap_or(0) as usize,
total_size,
"Content-Length should be the logical object size"
);
// This pattern compresses to roughly 1/50 of its logical size, so a comfortably loose 2x
// margin still proves the parts were stored compressed rather than raw or double-encoded.
let part_files = find_part_files(&env.temp_dir, MPU_HIGH_RATIO_BUCKET, object_key);
assert!(!part_files.is_empty(), "expected on-disk part files for the multipart object");
let total_physical_size: u64 = part_files.iter().filter_map(|p| fs::metadata(p).ok()).map(|m| m.len()).sum();
assert!(
total_physical_size < (total_size as u64) / 2,
"Physical size {total_physical_size} should be far below the logical size {total_size} for high-ratio data"
);
info!("High-ratio multipart physical storage size: {total_physical_size} bytes (logical {total_size} bytes)");
info!("step: full GET");
let get_response = client
.get_object()
.bucket(MPU_HIGH_RATIO_BUCKET)
.key(object_key)
.send()
.await?;
let downloaded = get_response.body.collect().await?.into_bytes();
assert_eq!(downloaded.len(), total_size);
assert_eq!(&downloaded[..], &original_data[..], "full GET data mismatch");
// Range crossing the part boundary.
info!("step: boundary range GET");
let boundary_start = MPU_PART1_SIZE - 128 * 1024;
let boundary_end = MPU_PART1_SIZE + 128 * 1024 - 1;
let range_crossing = fetch_range(
&client,
MPU_HIGH_RATIO_BUCKET,
object_key,
&format!("bytes={boundary_start}-{boundary_end}"),
)
.await?;
assert_eq!(
&range_crossing[..],
&original_data[boundary_start..boundary_end + 1],
"boundary-crossing range mismatch"
);
// partNumber GET for the trailing part.
info!("step: partNumber GET");
let part2_response = client
.get_object()
.bucket(MPU_HIGH_RATIO_BUCKET)
.key(object_key)
.part_number(2)
.send()
.await?;
let part2_body = part2_response.body.collect().await?.into_bytes();
assert_eq!(&part2_body[..], &part2[..], "partNumber=2 GET mismatch");
info!("Multipart high-ratio binary compression roundtrip test passed");
env.delete_test_bucket(MPU_HIGH_RATIO_BUCKET).await?;
env.stop_server();
Ok(())
}
const MPU_COPY_COMPRESSION_BUCKET: &str = "compression-mpu-copy-bucket";
const MPU_COPY_SOURCE_SIZE: usize = 6 * 1024 * 1024;
const MPU_COPY_RANGE_LEN: usize = 5 * 1024 * 1024;
/// UploadPartCopy feeds a part from an already stored (and already compressed) object. The copied
/// range must be decompressed on read and re-compressed into the destination part, so the final
/// object has to match "source prefix + uploaded tail" byte for byte.
#[tokio::test]
#[serial]
async fn test_compression_multipart_upload_part_copy_roundtrip() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
info!("Starting multipart upload-part-copy compression roundtrip test");
let mut env = RustFSTestEnvironment::new().await?;
start_rustfs_with_compression(&mut env).await?;
let client = env.create_s3_client();
env.create_test_bucket(MPU_COPY_COMPRESSION_BUCKET).await?;
// Source object: a plain PUT that goes through the single-stream compression path.
let source_key = "copy-source.txt";
let source_data = generate_compressible_data(MPU_COPY_SOURCE_SIZE);
client
.put_object()
.bucket(MPU_COPY_COMPRESSION_BUCKET)
.key(source_key)
.body(ByteStream::from(source_data.clone()))
.send()
.await?;
// Destination object: part 1 copied from the source, part 2 uploaded directly.
let target_key = "copy-target.txt";
let part2 = generate_compressible_data(MPU_PART2_SIZE);
let mut expected_data = source_data[..MPU_COPY_RANGE_LEN].to_vec();
expected_data.extend_from_slice(&part2);
let total_size = expected_data.len();
let create = client
.create_multipart_upload()
.bucket(MPU_COPY_COMPRESSION_BUCKET)
.key(target_key)
.send()
.await?;
let upload_id = create.upload_id().ok_or("missing upload id")?.to_string();
let copy_part = client
.upload_part_copy()
.bucket(MPU_COPY_COMPRESSION_BUCKET)
.key(target_key)
.upload_id(&upload_id)
.part_number(1)
.copy_source(format!("{MPU_COPY_COMPRESSION_BUCKET}/{source_key}"))
.copy_source_range(format!("bytes=0-{}", MPU_COPY_RANGE_LEN - 1))
.send()
.await?;
let copy_etag = copy_part
.copy_part_result()
.and_then(|r| r.e_tag())
.ok_or("missing copy part etag")?
.to_string();
let uploaded_part = client
.upload_part()
.bucket(MPU_COPY_COMPRESSION_BUCKET)
.key(target_key)
.upload_id(&upload_id)
.part_number(2)
.body(ByteStream::from(part2.clone()))
.send()
.await?;
client
.complete_multipart_upload()
.bucket(MPU_COPY_COMPRESSION_BUCKET)
.key(target_key)
.upload_id(&upload_id)
.multipart_upload(
CompletedMultipartUpload::builder()
.parts(CompletedPart::builder().part_number(1).e_tag(copy_etag).build())
.parts(
CompletedPart::builder()
.part_number(2)
.e_tag(uploaded_part.e_tag().unwrap_or_default())
.build(),
)
.build(),
)
.send()
.await?;
let head_response = client
.head_object()
.bucket(MPU_COPY_COMPRESSION_BUCKET)
.key(target_key)
.send()
.await?;
assert_eq!(
head_response.content_length().unwrap_or(0) as usize,
total_size,
"Content-Length should be the logical object size"
);
let part_files = find_part_files(&env.temp_dir, MPU_COPY_COMPRESSION_BUCKET, target_key);
assert!(!part_files.is_empty(), "expected on-disk part files for the copied object");
let total_physical_size: u64 = part_files.iter().filter_map(|p| fs::metadata(p).ok()).map(|m| m.len()).sum();
assert!(
total_physical_size < (total_size / 2) as u64,
"Physical size {total_physical_size} should be well below original size {total_size} (copied part compression applied)"
);
let get_response = client
.get_object()
.bucket(MPU_COPY_COMPRESSION_BUCKET)
.key(target_key)
.send()
.await?;
let downloaded = get_response.body.collect().await?.into_bytes();
assert_eq!(downloaded.len(), total_size);
assert_eq!(&downloaded[..], &expected_data[..], "copied multipart GET data mismatch");
info!("Multipart upload-part-copy compression roundtrip test passed");
env.delete_test_bucket(MPU_COPY_COMPRESSION_BUCKET).await?;
env.stop_server();
Ok(())
}
const MPU_THREE_PARTS_BUCKET: &str = "compression-mpu-three-parts-bucket";
const MPU_THREE_PARTS_TAIL_SIZE: usize = 512 * 1024;
/// Three-part upload with uneven part sizes: each partNumber GET must map back to exactly one
/// compressed part stream, and a suffix range must resolve inside the trailing part.
#[tokio::test]
#[serial]
async fn test_compression_multipart_three_parts_part_number_gets() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
info!("Starting three-part multipart compression partNumber test");
let mut env = RustFSTestEnvironment::new().await?;
start_rustfs_with_compression(&mut env).await?;
let client = env.create_s3_client();
env.create_test_bucket(MPU_THREE_PARTS_BUCKET).await?;
let object_key = "multipart-three-parts.txt";
let part1 = generate_compressible_data(MPU_PART1_SIZE);
let part2 = generate_compressible_data(MPU_PART1_SIZE);
let part3 = generate_compressible_data(MPU_THREE_PARTS_TAIL_SIZE);
let mut original_data = part1.clone();
original_data.extend_from_slice(&part2);
original_data.extend_from_slice(&part3);
let total_size = original_data.len();
multipart_upload(&client, MPU_THREE_PARTS_BUCKET, object_key, &[&part1, &part2, &part3]).await?;
let head_response = client
.head_object()
.bucket(MPU_THREE_PARTS_BUCKET)
.key(object_key)
.send()
.await?;
assert_eq!(
head_response.content_length().unwrap_or(0) as usize,
total_size,
"Content-Length should be the logical object size"
);
let part_files = find_part_files(&env.temp_dir, MPU_THREE_PARTS_BUCKET, object_key);
assert!(!part_files.is_empty(), "expected on-disk part files for the multipart object");
let total_physical_size: u64 = part_files.iter().filter_map(|p| fs::metadata(p).ok()).map(|m| m.len()).sum();
assert!(
total_physical_size < (total_size / 2) as u64,
"Physical size {total_physical_size} should be well below original size {total_size} (multipart compression applied)"
);
// Every partNumber GET must return exactly the bytes of the corresponding uploaded part.
for (part_number, expected) in [(1, &part1), (2, &part2), (3, &part3)] {
let response = client
.get_object()
.bucket(MPU_THREE_PARTS_BUCKET)
.key(object_key)
.part_number(part_number)
.send()
.await?;
let body = response.body.collect().await?.into_bytes();
assert_eq!(&body[..], &expected[..], "partNumber={part_number} GET mismatch");
}
// Suffix range (last 64 KiB) resolves inside the trailing part.
let suffix_len = 64 * 1024;
let suffix = fetch_range(&client, MPU_THREE_PARTS_BUCKET, object_key, &format!("bytes=-{suffix_len}")).await?;
assert_eq!(&suffix[..], &original_data[total_size - suffix_len..], "suffix range mismatch");
info!("Three-part multipart compression partNumber test passed");
env.delete_test_bucket(MPU_THREE_PARTS_BUCKET).await?;
env.stop_server();
Ok(())
}
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]);
// 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))?;
let server_log_err = server_log.try_clone()?;
let process = Command::new(&binary_path)
.env("RUSTFS_CONSOLE_ENABLE", "false")
.env("RUSTFS_COMPRESSION_ENABLED", "true")
.env("RUSTFS_COMPRESSION_MULTIPART_ENABLED", "true")
.env("RUSTFS_SSE_S3_MASTER_KEY", master_key)
.env("RUST_LOG", "rustfs=info,rustfs_ecstore=info")
.stdout(std::process::Stdio::from(server_log))
.stderr(std::process::Stdio::from(server_log_err))
.args([
"--address",
&env.address,
"--access-key",
&env.access_key,
"--secret-key",
&env.secret_key,
&env.temp_dir,
])
.spawn()?;
env.process = Some(process);
info!("Waiting for RustFS server with compression + SSE-S3 enabled on {}", env.address);
for i in 0..30 {
if TcpStream::connect(&env.address).await.is_ok() {
info!("RustFS server is ready after {} attempts", i + 1);
return Ok(());
}
if i == 29 {
return Err("RustFS server failed to become ready".into());
}
sleep(Duration::from_secs(1)).await;
}
Ok(())
}
/// SSE-S3 + disk compression multipart: each part is compressed and then encrypted, and every GET
/// shape must still return the original plaintext bytes. Physical size must shrink because the
/// compression runs before encryption.
#[tokio::test]
#[serial]
async fn test_compression_multipart_sse_s3_roundtrip() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
use aws_sdk_s3::types::ServerSideEncryption;
init_logging();
info!("Starting SSE-S3 multipart compression roundtrip test");
let mut env = RustFSTestEnvironment::new().await?;
start_rustfs_with_compression_and_sse(&mut env).await?;
let client = env.create_s3_client();
env.create_test_bucket(MPU_SSE_COMPRESSION_BUCKET).await?;
let object_key = "multipart-sse-compressible.txt";
let part1 = generate_compressible_data(MPU_PART1_SIZE);
let part2 = generate_compressible_data(MPU_PART2_SIZE);
let mut original_data = part1.clone();
original_data.extend_from_slice(&part2);
let total_size = original_data.len();
let create = client
.create_multipart_upload()
.bucket(MPU_SSE_COMPRESSION_BUCKET)
.key(object_key)
.server_side_encryption(ServerSideEncryption::Aes256)
.send()
.await?;
let upload_id = create.upload_id().ok_or("missing upload id")?.to_string();
let mut completed_parts = Vec::new();
for (i, part) in [&part1, &part2].into_iter().enumerate() {
let part_number = (i + 1) as i32;
let upload = client
.upload_part()
.bucket(MPU_SSE_COMPRESSION_BUCKET)
.key(object_key)
.upload_id(&upload_id)
.part_number(part_number)
.body(ByteStream::from(part.clone()))
.send()
.await?;
completed_parts.push(
CompletedPart::builder()
.part_number(part_number)
.e_tag(upload.e_tag().unwrap_or_default())
.build(),
);
}
client
.complete_multipart_upload()
.bucket(MPU_SSE_COMPRESSION_BUCKET)
.key(object_key)
.upload_id(&upload_id)
.multipart_upload(CompletedMultipartUpload::builder().set_parts(Some(completed_parts)).build())
.send()
.await?;
let head_response = client
.head_object()
.bucket(MPU_SSE_COMPRESSION_BUCKET)
.key(object_key)
.send()
.await?;
assert_eq!(
head_response.content_length().unwrap_or(0) as usize,
total_size,
"Content-Length should be the logical object size"
);
assert_eq!(
head_response.server_side_encryption(),
Some(&ServerSideEncryption::Aes256),
"HEAD must report SSE-S3"
);
let part_files = find_part_files(&env.temp_dir, MPU_SSE_COMPRESSION_BUCKET, object_key);
assert!(!part_files.is_empty(), "expected on-disk part files for the multipart object");
let total_physical_size: u64 = part_files.iter().filter_map(|p| fs::metadata(p).ok()).map(|m| m.len()).sum();
assert!(
total_physical_size < (total_size / 2) as u64,
"Physical size {total_physical_size} should be well below original size {total_size} (compress-then-encrypt applied)"
);
let get_response = client
.get_object()
.bucket(MPU_SSE_COMPRESSION_BUCKET)
.key(object_key)
.send()
.await?;
let downloaded = get_response.body.collect().await?.into_bytes();
assert_eq!(downloaded.len(), total_size);
assert_eq!(&downloaded[..], &original_data[..], "SSE-S3 multipart full GET data mismatch");
// Range crossing the part boundary must decrypt and decompress across parts.
let boundary_start = MPU_PART1_SIZE - 64 * 1024;
let boundary_end = MPU_PART1_SIZE + 64 * 1024 - 1;
let range_crossing = fetch_range(
&client,
MPU_SSE_COMPRESSION_BUCKET,
object_key,
&format!("bytes={boundary_start}-{boundary_end}"),
)
.await?;
assert_eq!(
&range_crossing[..],
&original_data[boundary_start..boundary_end + 1],
"SSE-S3 boundary-crossing range mismatch"
);
// partNumber GET for the trailing part.
let part2_response = client
.get_object()
.bucket(MPU_SSE_COMPRESSION_BUCKET)
.key(object_key)
.part_number(2)
.send()
.await?;
let part2_body = part2_response.body.collect().await?.into_bytes();
assert_eq!(&part2_body[..], &part2[..], "SSE-S3 partNumber=2 GET mismatch");
info!("SSE-S3 multipart compression roundtrip test passed");
env.delete_test_bucket(MPU_SSE_COMPRESSION_BUCKET).await?;
env.stop_server();
Ok(())
}
@@ -189,6 +189,8 @@ mod tests {
("RUSTFS_GET_CODEC_STREAMING_ROLLOUT_PCT", "100"), ("RUSTFS_GET_CODEC_STREAMING_ROLLOUT_PCT", "100"),
("RUSTFS_GET_CODEC_STREAMING_BODY_COMPAT_CONFIRMED", "true"), ("RUSTFS_GET_CODEC_STREAMING_BODY_COMPAT_CONFIRMED", "true"),
("RUSTFS_GET_CODEC_STREAMING_HEADER_COMPAT_CONFIRMED", "true"), ("RUSTFS_GET_CODEC_STREAMING_HEADER_COMPAT_CONFIRMED", "true"),
// Lower the min-size floor so every non-inline object below is eligible.
("RUSTFS_GET_CODEC_STREAMING_MIN_SIZE", "4096"),
// Route multipart objects through per-part codec streaming too. // Route multipart objects through per-part codec streaming too.
("RUSTFS_GET_CODEC_STREAMING_MULTIPART_ENABLE", "true"), ("RUSTFS_GET_CODEC_STREAMING_MULTIPART_ENABLE", "true"),
// Lock optimization is on by default, but pin it so the gate's // Lock optimization is on by default, but pin it so the gate's
@@ -313,13 +315,6 @@ mod tests {
}, },
payload(64 * 1024, 2), payload(64 * 1024, 2),
), ),
(
Shape {
key: "small-non-inline-256kib-plus",
expect_large: true,
},
payload(256 * 1024 + 1, 6),
),
( (
Shape { Shape {
key: "mid-1_5mib", key: "mid-1_5mib",
+1 -51
View File
@@ -14,7 +14,7 @@
//! E2E tests for group management (fixes #2028). //! E2E tests for group management (fixes #2028).
use crate::common::{RustFSTestEnvironment, admin_request, awscurl_delete, awscurl_get, awscurl_put, init_logging}; use crate::common::{RustFSTestEnvironment, awscurl_delete, awscurl_get, awscurl_put, init_logging};
use aws_sdk_s3::config::{Credentials, Region}; use aws_sdk_s3::config::{Credentials, Region};
use aws_sdk_s3::{Client, Config}; use aws_sdk_s3::{Client, Config};
use serial_test::serial; use serial_test::serial;
@@ -32,56 +32,6 @@ fn create_user_s3_client(env: &RustFSTestEnvironment, access_key: &str, secret_k
Client::from_conf(config) Client::from_conf(config)
} }
#[tokio::test(flavor = "multi_thread")]
async fn update_group_members_rejects_invalid_new_group_names() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let invalid_groups = [
("test group", "group name contains whitespace"),
("test=group", "group name contains reserved characters =,"),
("test,group", "group name contains reserved characters =,"),
];
for (group, expected_message) in invalid_groups {
let body = serde_json::json!({
"group": group,
"members": [],
"isRemove": false,
"groupStatus": "enabled"
})
.to_string();
let (status, response_body) = admin_request(
&env.url,
http::Method::PUT,
"/rustfs/admin/v3/update-group-members",
Some(body),
&env.access_key,
&env.secret_key,
)
.await?;
assert_eq!(
status,
reqwest::StatusCode::BAD_REQUEST,
"invalid group {group:?} must return HTTP 400, body: {response_body}"
);
assert!(
response_body.contains("<Code>InvalidArgument</Code>"),
"invalid group {group:?} must return InvalidArgument, body: {response_body}"
);
assert!(
response_body.contains(&format!("<Message>{expected_message}</Message>")),
"invalid group {group:?} returned an unexpected message: {response_body}"
);
}
env.stop_server();
Ok(())
}
/// Test that deleting a group with members fails, and deleting an empty group succeeds. /// Test that deleting a group with members fails, and deleting an empty group succeeds.
#[tokio::test(flavor = "multi_thread")] #[tokio::test(flavor = "multi_thread")]
#[serial] #[serial]
@@ -1828,36 +1828,33 @@ async fn four_node_compressed_inline_fallback() -> TestResult {
Ok(()) Ok(())
} }
/// Multipart disk compression is live again, so a compression-enabled cluster classifies multipart objects as compressed and the roundtrip (full GET plus partNumber GET) must still return the original bytes.
/// Reverting the multipart compression fix must fail this test.
#[tokio::test] #[tokio::test]
#[serial] #[serial]
async fn four_node_multipart_disk_compression_roundtrip() -> TestResult { async fn four_node_multipart_ignores_disk_compression_fallback() -> TestResult {
init_logging(); init_logging();
let collector = OtlpMetricCollector::start().await?; let collector = OtlpMetricCollector::start().await?;
let mut cluster = RustFSTestClusterEnvironment::new(4).await?; let mut cluster = RustFSTestClusterEnvironment::new(4).await?;
configure_reader_metric_cluster(&mut cluster, &collector); configure_reader_metric_cluster(&mut cluster, &collector);
cluster.set_env("RUSTFS_COMPRESSION_ENABLED", "true"); cluster.set_env("RUSTFS_COMPRESSION_ENABLED", "true");
cluster.set_env("RUSTFS_COMPRESSION_MULTIPART_ENABLED", "true");
cluster.start().await?; cluster.start().await?;
let bucket = "inline-multipart-compression-roundtrip"; let bucket = "inline-multipart-compression-fallback";
cluster.create_test_bucket(bucket).await?; cluster.create_test_bucket(bucket).await?;
let client = cluster.create_s3_client(0)?; let client = cluster.create_s3_client(0)?;
let key = "multipart/compressed.txt"; let key = "multipart/compression-disabled.txt";
let (body, second_part, etag) = put_two_part_multipart(&client, bucket, key).await?; let (body, second_part, etag) = put_two_part_multipart(&client, bucket, key).await?;
assert_reader_path( assert_reader_path(
&collector, &collector,
&client, &client,
ReaderPathExpectation::for_class(ReaderObject::new(bucket, key, &body, etag.as_deref(), None), LEGACY_DUPLEX, COMPRESSED), ReaderPathExpectation::for_class(ReaderObject::new(bucket, key, &body, etag.as_deref(), None), LEGACY_DUPLEX, MULTIPART),
) )
.await?; .await?;
assert_part_number_reader_path( assert_part_number_reader_path(
&collector, &collector,
&client, &client,
PartNumberReaderPathExpectation::new(bucket, key, &second_part, body.len(), COMPRESSED, LEGACY_DUPLEX), PartNumberReaderPathExpectation::new(bucket, key, &second_part, body.len(), MULTIPART, LEGACY_DUPLEX),
) )
.await?; .await?;
@@ -1874,7 +1871,6 @@ async fn four_node_mixed_msgpack_compat_mode_preserves_fallback_controls() -> Te
let sse_master_key = base64::engine::general_purpose::STANDARD.encode([0x42u8; 32]); let sse_master_key = base64::engine::general_purpose::STANDARD.encode([0x42u8; 32]);
cluster.set_env("RUSTFS_SSE_S3_MASTER_KEY", sse_master_key); cluster.set_env("RUSTFS_SSE_S3_MASTER_KEY", sse_master_key);
cluster.set_env("RUSTFS_COMPRESSION_ENABLED", "true"); cluster.set_env("RUSTFS_COMPRESSION_ENABLED", "true");
cluster.set_env("RUSTFS_COMPRESSION_MULTIPART_ENABLED", "true");
configure_mixed_msgpack_cluster(&mut cluster, &collector)?; configure_mixed_msgpack_cluster(&mut cluster, &collector)?;
cluster.start().await?; cluster.start().await?;
@@ -1894,21 +1890,14 @@ async fn four_node_mixed_msgpack_compat_mode_preserves_fallback_controls() -> Te
ReaderPathExpectation::for_class( ReaderPathExpectation::for_class(
ReaderObject::new(bucket, multipart_key, &multipart_body, multipart_etag.as_deref(), None), ReaderObject::new(bucket, multipart_key, &multipart_body, multipart_etag.as_deref(), None),
LEGACY_DUPLEX, LEGACY_DUPLEX,
COMPRESSED, MULTIPART,
), ),
) )
.await?; .await?;
assert_part_number_reader_path( assert_part_number_reader_path(
&collector, &collector,
&client, &client,
PartNumberReaderPathExpectation::new( PartNumberReaderPathExpectation::new(bucket, multipart_key, &second_part, multipart_body.len(), MULTIPART, LEGACY_DUPLEX),
bucket,
multipart_key,
&second_part,
multipart_body.len(),
COMPRESSED,
LEGACY_DUPLEX,
),
) )
.await?; .await?;
assert_msgpack_decode_observed(&collector, &decode_before).await?; assert_msgpack_decode_observed(&collector, &decode_before).await?;
@@ -2364,11 +2353,7 @@ async fn four_node_mixed_msgpack_compat_mode_preserves_fallback_controls_during_
hot_client.create_bucket().bucket(bucket).send().await?; hot_client.create_bucket().bucket(bucket).send().await?;
put_lifecycle_with_transition_retry(&hot_client, bucket, &tier_name).await?; put_lifecycle_with_transition_retry(&hot_client, bucket, &tier_name).await?;
// `.zip` sits on the disk-compression exclusion list: this test pins let key = "transition/mixed-multipart.bin";
// msgpack compat controls across ILM transition, and a compressed object
// would classify as `compressed` instead of `remote` (and the warm-tier
// read path does not decode compression — tracked separately).
let key = "transition/mixed-multipart.zip";
let (body, second_part, etag) = put_two_part_multipart(&hot_client, bucket, key).await?; let (body, second_part, etag) = put_two_part_multipart(&hot_client, bucket, key).await?;
wait_for_transition(&hot_client, bucket, key, &tier_name).await?; wait_for_transition(&hot_client, bucket, key, &tier_name).await?;
assert!( assert!(
@@ -1,611 +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.
//! ILM on SSE-KMS buckets while per-key SSE authorization is enforced (backlog#1582).
//!
//! Per-key KMS authorization (`RUSTFS_KMS_ENFORCE_SSE_KEY_POLICY=true`) scopes the
//! SSE-KMS data path to the requesting principal's `kms:GenerateDataKey` /
//! `kms:Decrypt` grants. Internal callers — the lifecycle scanner's expiry deletes
//! and the tier transition worker's reads — carry no request principal, and
//! `authorize_sse_kms_key` (rustfs/src/storage/sse.rs) exempts a `None` principal
//! so background maintenance keeps working on encrypted buckets.
//!
//! These tests pin that exemption end to end. If enforcement ever starts applying
//! to the scanner's internal operations, expiry stops happening on SSE-KMS buckets
//! and [`ilm_expiration_on_sse_kms_bucket_under_enforcement`] times out; if it
//! starts applying to the transition worker or the read-through path,
//! [`ilm_transition_on_sse_kms_bucket_under_enforcement_reads_back`] fails at the
//! transition wait or the plaintext round-trip.
//!
//! The replication half of the same acceptance item lives in
//! `crates/e2e_test/src/replication_extension_test.rs`
//! (`test_bucket_replication_sse_kms_failure_contract`); ILM had no coverage
//! before this file.
//!
//! Deployment constraint pinned by the transition test's setup: the RustFS warm
//! backend forwards the object's stored `x-amz-server-side-encryption*` metadata
//! as raw headers on the tier data PUT (`build_transition_put_options` +
//! `api_put_object.rs` header mapping), so a RustFS tier target must itself have
//! KMS enabled and hold the named key or it rejects every transition upload with
//! 400 InvalidRequest. That rejection is independent of the enforcement switch;
//! the cold server here therefore runs its own Local KMS with the same key id.
use super::common::{LocalKMSTestEnvironment, create_key_with_specific_id};
use crate::common::{RustFSTestEnvironment, admin_request, init_logging};
use aws_sdk_s3::Client;
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::{
BucketLifecycleConfiguration, ExpirationStatus, LifecycleExpiration, LifecycleRule, LifecycleRuleFilter, RestoreRequest,
ServerSideEncryption, ServerSideEncryptionByDefault, ServerSideEncryptionConfiguration, ServerSideEncryptionRule, Transition,
TransitionStorageClass,
};
use serde::Deserialize;
use serial_test::serial;
use std::time::{Duration as StdDuration, Instant};
use tracing::info;
type TestResult = Result<(), Box<dyn std::error::Error + Send + Sync>>;
const SSE_KEY: &str = "kms-ilm-sse-key";
const PAYLOAD: &[u8] = b"kms ilm sse payload: survives enforcement, expires and transitions on schedule";
const EXPIRY_BUCKET: &str = "kms-ilm-expiry";
const EXPIRE_KEY: &str = "expire/object.bin";
const SURVIVOR_KEY: &str = "keep/object.bin";
const TIER_NAME: &str = "KMSCOLD";
const TIER_BUCKET: &str = "kms-ilm-cold-tier";
const TIER_PREFIX: &str = "tiered";
const TRANSITION_BUCKET: &str = "kms-ilm-transition";
const TRANSITION_KEY: &str = "tier/object.bin";
/// Generous CI safety net; with a 1s scanner cycle and 2s lifecycle days the
/// terminal state normally lands within a few seconds.
const ILM_DEADLINE: StdDuration = StdDuration::from_secs(90);
/// Start a Local-KMS server with per-key SSE authorization enforced and the
/// lifecycle clock accelerated.
///
/// KMS wiring matches `kms_authorization_negative_matrix_test.rs` (local backend,
/// `--kms-default-key-id`, insecure dev defaults). The lifecycle env matches
/// `reliant/lifecycle.rs::fast_lifecycle_env` plus `RUSTFS_ILM_DEBUG_DAY_SECS=2`,
/// so a `Days=1` rule is due about two seconds after the write.
async fn start_enforcing_ilm_server(env: &mut LocalKMSTestEnvironment) -> TestResult {
create_key_with_specific_id(&env.kms_keys_dir, SSE_KEY).await?;
let key_dir = env.kms_keys_dir.clone();
let args = vec![
"--kms-enable",
"--kms-backend",
"local",
"--kms-key-dir",
key_dir.as_str(),
"--kms-default-key-id",
SSE_KEY,
];
let envs = [
("RUSTFS_KMS_ALLOW_INSECURE_DEV_DEFAULTS", "true"),
("RUSTFS_KMS_ENFORCE_SSE_KEY_POLICY", "true"),
("RUSTFS_SCANNER_CYCLE", "1"),
("RUSTFS_ILM_PROCESS_TIME", "1"),
("RUSTFS_ILM_DEBUG_DAY_SECS", "2"),
];
env.base_env.start_rustfs_server_with_env(args, &envs).await?;
Ok(())
}
/// Set the bucket's default encryption to SSE-KMS under [`SSE_KEY`], so plain
/// PUTs (and internal rewrites) are encrypted without per-request SSE headers.
async fn set_bucket_default_sse_kms(client: &Client, bucket: &str) -> TestResult {
let encryption_config = ServerSideEncryptionConfiguration::builder()
.rules(
ServerSideEncryptionRule::builder()
.apply_server_side_encryption_by_default(
ServerSideEncryptionByDefault::builder()
.sse_algorithm(ServerSideEncryption::AwsKms)
.kms_master_key_id(SSE_KEY)
.build()?,
)
.build(),
)
.build()?;
client
.put_bucket_encryption()
.bucket(bucket)
.server_side_encryption_configuration(encryption_config)
.send()
.await?;
Ok(())
}
/// Assert via `HeadObject` that the stored object is SSE-KMS encrypted under
/// [`SSE_KEY`]. Without this, a bucket-default misconfiguration would let the
/// tests pass on an unencrypted object and prove nothing about KMS.
async fn assert_head_sse_kms(client: &Client, bucket: &str, key: &str) -> TestResult {
let head = client.head_object().bucket(bucket).key(key).send().await?;
assert_eq!(
head.server_side_encryption(),
Some(&ServerSideEncryption::AwsKms),
"{bucket}/{key} must be SSE-KMS encrypted via the bucket default"
);
assert_eq!(
head.ssekms_key_id(),
Some(SSE_KEY),
"{bucket}/{key} must be wrapped under the configured KMS key"
);
Ok(())
}
/// Returns `true` once `GET bucket/key` fails with `NoSuchKey`, `false` while it
/// still succeeds. Any other error is surfaced. (Copied from
/// `reliant/lifecycle.rs`; that helper is private to the reliant module.)
async fn object_is_gone(client: &Client, bucket: &str, key: &str) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
match client.get_object().bucket(bucket).key(key).send().await {
Ok(output) => {
output.body.collect().await?;
Ok(false)
}
Err(e) => {
if let Some(service_error) = e.as_service_error() {
if service_error.is_no_such_key() {
return Ok(true);
}
return Err(format!("expected NoSuchKey, got: {e:?}").into());
}
Err(format!("expected a service error, got: {e:?}").into())
}
}
}
/// Poll until `GET bucket/key` returns `NoSuchKey`, or fail after `deadline`.
async fn wait_for_object_expired(client: &Client, bucket: &str, key: &str, deadline: StdDuration) -> TestResult {
let start = Instant::now();
loop {
if object_is_gone(client, bucket, key).await? {
return Ok(());
}
if start.elapsed() >= deadline {
return Err(format!(
"object {bucket}/{key} was not expired by the lifecycle scanner within {}s; \
SSE key-policy enforcement may have started blocking the scanner's internal deletes",
deadline.as_secs()
)
.into());
}
tokio::time::sleep(StdDuration::from_millis(500)).await;
}
}
/// Install a prefix-scoped `Days`-based expiration rule.
async fn put_expiration_rule(client: &Client, bucket: &str, id: &str, prefix: &str, days: i32) -> TestResult {
let rule = LifecycleRule::builder()
.id(id)
.filter(LifecycleRuleFilter::builder().prefix(prefix).build())
.expiration(LifecycleExpiration::builder().days(days).build())
.status(ExpirationStatus::Enabled)
.build()?;
let lifecycle = BucketLifecycleConfiguration::builder().rules(rule).build()?;
client
.put_bucket_lifecycle_configuration()
.bucket(bucket)
.lifecycle_configuration(lifecycle)
.send()
.await?;
Ok(())
}
/// Install a prefix-scoped `Days`-based transition rule targeting [`TIER_NAME`].
async fn put_transition_rule(client: &Client, bucket: &str, id: &str, prefix: &str, days: i32) -> TestResult {
let rule = LifecycleRule::builder()
.id(id)
.filter(LifecycleRuleFilter::builder().prefix(prefix).build())
.transitions(
Transition::builder()
.days(days)
.storage_class(TransitionStorageClass::from(TIER_NAME))
.build(),
)
.status(ExpirationStatus::Enabled)
.build()?;
let lifecycle = BucketLifecycleConfiguration::builder().rules(rule).build()?;
client
.put_bucket_lifecycle_configuration()
.bucket(bucket)
.lifecycle_configuration(lifecycle)
.send()
.await?;
Ok(())
}
/// Start a plain Local-KMS server (no enforcement, no lifecycle acceleration)
/// holding [`SSE_KEY`], to serve as the cold tier target.
///
/// The RustFS warm backend forwards the stored SSE-KMS headers on the tier data
/// PUT, so the target re-applies managed SSE-KMS under the named key and must
/// be able to resolve it; without KMS it answers 400 InvalidRequest and the
/// transition can never complete. Enforcement stays off here: the tier writes
/// arrive under `cold`'s root credentials, and one enforcing side is enough to
/// pin the exemption.
async fn start_cold_tier_kms_server(env: &mut LocalKMSTestEnvironment) -> TestResult {
create_key_with_specific_id(&env.kms_keys_dir, SSE_KEY).await?;
let key_dir = env.kms_keys_dir.clone();
let args = vec![
"--kms-enable",
"--kms-backend",
"local",
"--kms-key-dir",
key_dir.as_str(),
"--kms-default-key-id",
SSE_KEY,
];
env.base_env
.start_rustfs_server_with_env(args, &[("RUSTFS_KMS_ALLOW_INSECURE_DEV_DEFAULTS", "true")])
.await?;
Ok(())
}
/// The subset of the manual transition run report these tests assert on.
///
/// Unknown fields are ignored, so this stays compatible with report growth; the
/// full shape is pinned by `reliant/tiering.rs`.
#[derive(Debug, Deserialize)]
struct ManualTransitionRunReport {
#[serde(default)]
scanned: u64,
#[serde(default)]
enqueued: u64,
#[serde(default)]
skipped_already_in_flight: u64,
#[serde(default)]
skipped_tier: u64,
}
#[derive(Debug, Deserialize)]
struct ManualTransitionRunResponse {
state: String,
report: ManualTransitionRunReport,
}
/// One synchronous (enqueue-only) manual transition run over `bucket/prefix`,
/// via the same admin endpoint `reliant/tiering.rs` drives.
async fn manual_transition_run(
hot: &RustFSTestEnvironment,
bucket: &str,
prefix: &str,
) -> Result<ManualTransitionRunResponse, Box<dyn std::error::Error + Send + Sync>> {
let bucket = urlencoding::encode(bucket);
let prefix = urlencoding::encode(prefix);
let tier = urlencoding::encode(TIER_NAME);
let path =
format!("/rustfs/admin/v3/ilm/transition/run?bucket={bucket}&prefix={prefix}&tier={tier}&dryRun=false&maxObjects=10");
let (status, body) = admin_request(&hot.url, http::Method::POST, &path, None, &hot.access_key, &hot.secret_key).await?;
if !status.is_success() {
return Err(format!("manual transition run failed: status={status}, body={body}").into());
}
Ok(serde_json::from_str(&body)?)
}
/// Drive manual transition runs until one reports the object as processed.
///
/// The `Days=1` rule becomes due about two seconds after the write
/// (`RUSTFS_ILM_DEBUG_DAY_SECS=2`), so early runs may legitimately report the
/// object as not yet eligible; the loop keeps running the endpoint until it
/// either enqueues the transition, sees it already in flight (the 1s scanner
/// backstop got there first), or finds it already on the tier.
async fn run_manual_transition_until_processed(
hot: &RustFSTestEnvironment,
bucket: &str,
prefix: &str,
deadline: StdDuration,
) -> TestResult {
let start = Instant::now();
loop {
let run = manual_transition_run(hot, bucket, prefix).await?;
assert_eq!(run.report.scanned, 1, "manual transition run must scan the object: {run:#?}");
if run.report.enqueued + run.report.skipped_already_in_flight + run.report.skipped_tier >= 1 {
info!(state = %run.state, report = ?run.report, "manual transition run processed the SSE-KMS object");
return Ok(());
}
if start.elapsed() >= deadline {
return Err(format!(
"manual transition runs never processed {bucket}/{prefix} within {}s; last report: {run:#?}",
deadline.as_secs()
)
.into());
}
tokio::time::sleep(StdDuration::from_millis(500)).await;
}
}
/// Wire `hot` -> `cold` as a `TierType::RustFS` remote tier via `AddTier`.
///
/// No `force`, so the server runs the real connectivity probe against `cold`
/// (the tier bucket must already exist there). Mirrors
/// `reliant/tiering.rs::add_rustfs_tier`, which is private to that module.
async fn add_rustfs_tier(hot: &RustFSTestEnvironment, cold: &RustFSTestEnvironment) -> TestResult {
let body = serde_json::json!({
"type": "rustfs",
"rustfs": {
"name": TIER_NAME,
"endpoint": cold.url.as_str(),
"accessKey": cold.access_key.as_str(),
"secretKey": cold.secret_key.as_str(),
"bucket": TIER_BUCKET,
"prefix": TIER_PREFIX,
"region": "us-east-1",
"storageClass": ""
}
})
.to_string();
let (status, resp) = admin_request(
&hot.url,
http::Method::PUT,
"/rustfs/admin/v3/tier",
Some(body),
&hot.access_key,
&hot.secret_key,
)
.await?;
if !status.is_success() {
return Err(format!("AddTier(RustFS) failed: status={status}, body={resp}").into());
}
Ok(())
}
/// Poll `HEAD` until the object's storage class is the tier name (transition
/// complete), or fail after `deadline`. (From `reliant/tiering.rs`.)
async fn wait_for_transition(client: &Client, bucket: &str, key: &str, deadline: StdDuration) -> TestResult {
let start = Instant::now();
loop {
let head = client.head_object().bucket(bucket).key(key).send().await?;
if head.storage_class().map(|sc| sc.as_str()) == Some(TIER_NAME) {
return Ok(());
}
if start.elapsed() >= deadline {
return Err(format!(
"object {bucket}/{key} was not transitioned to {TIER_NAME} within {}s (storage_class={:?}); \
SSE key-policy enforcement may have started blocking the transition worker's internal reads",
deadline.as_secs(),
head.storage_class()
)
.into());
}
tokio::time::sleep(StdDuration::from_millis(500)).await;
}
}
/// Poll `HEAD` until `x-amz-restore` reports a finished restore
/// (`ongoing-request="false"`), or fail after `deadline`.
async fn wait_for_restore_complete(client: &Client, bucket: &str, key: &str, deadline: StdDuration) -> TestResult {
let start = Instant::now();
loop {
let head = client.head_object().bucket(bucket).key(key).send().await?;
if head.restore().is_some_and(|r| r.contains("ongoing-request=\"false\"")) {
return Ok(());
}
if start.elapsed() >= deadline {
return Err(format!(
"object {bucket}/{key} restore did not complete within {}s (restore={:?}); \
SSE key-policy enforcement may have started blocking the restore copy-back's internal reads",
deadline.as_secs(),
head.restore()
)
.into());
}
tokio::time::sleep(StdDuration::from_millis(500)).await;
}
}
/// ILM expiration keeps working on an SSE-KMS bucket while per-key SSE
/// authorization is enforced.
///
/// The lifecycle scanner deletes expired objects with an internal (no-principal)
/// identity that holds no `kms` grant. If enforcement ever starts applying to
/// those internal deletes (or to the scanner's metadata reads) on encrypted
/// buckets, expiry stops happening and this test times out.
///
/// A survivor object under a non-matching prefix isolates the rule's prefix
/// filter as the cause of the deletion and proves the encrypted bucket stays
/// readable end to end after the scanner has run.
#[tokio::test]
#[serial]
async fn ilm_expiration_on_sse_kms_bucket_under_enforcement() -> TestResult {
init_logging();
let mut env = LocalKMSTestEnvironment::new().await?;
start_enforcing_ilm_server(&mut env).await?;
env.base_env.create_test_bucket(EXPIRY_BUCKET).await?;
let client = env.base_env.create_s3_client();
set_bucket_default_sse_kms(&client, EXPIRY_BUCKET).await?;
for key in [EXPIRE_KEY, SURVIVOR_KEY] {
client
.put_object()
.bucket(EXPIRY_BUCKET)
.key(key)
.body(ByteStream::from_static(PAYLOAD))
.send()
.await?;
assert_head_sse_kms(&client, EXPIRY_BUCKET, key).await?;
}
info!("both objects stored SSE-KMS encrypted under enforcement");
put_expiration_rule(&client, EXPIRY_BUCKET, "kms-ilm-expire", "expire/", 1).await?;
// The regression this pins: the scanner's internal delete must stay exempt
// from per-key SSE authorization, so the encrypted object actually expires.
wait_for_object_expired(&client, EXPIRY_BUCKET, EXPIRE_KEY, ILM_DEADLINE).await?;
info!("SSE-KMS object expired by the lifecycle scanner under enforcement");
// Negative control: same bucket, same encryption, non-matching prefix. It
// must survive the scanner and still decrypt for the requesting principal.
assert!(
!object_is_gone(&client, EXPIRY_BUCKET, SURVIVOR_KEY).await?,
"non-matching-prefix object must not be expired by a prefix-scoped rule"
);
let survivor = client.get_object().bucket(EXPIRY_BUCKET).key(SURVIVOR_KEY).send().await?;
assert_eq!(
survivor.body.collect().await?.into_bytes().as_ref(),
PAYLOAD,
"surviving SSE-KMS object must still decrypt after the scanner has run"
);
Ok(())
}
/// ILM transition to a remote tier keeps working on an SSE-KMS bucket while
/// per-key SSE authorization is enforced, and the transitioned object reads
/// back as plaintext.
///
/// The transition worker moves the stored (encrypted) bytes to the cold tier
/// with an internal (no-principal) identity; the read-through `GET` then
/// decrypts the envelope for the requesting principal. If enforcement ever
/// starts applying to the worker's internal reads, the transition wait times
/// out; if the stored envelope is mishandled across the tier round trip, the
/// plaintext comparison fails.
///
/// The transition is driven through the manual transition-run admin endpoint
/// (the mechanism `reliant/tiering.rs` established), so the test does not
/// depend on scanner scheduling; the 1s scanner cycle stays on as a backstop.
#[tokio::test]
#[serial]
async fn ilm_transition_on_sse_kms_bucket_under_enforcement_reads_back() -> TestResult {
init_logging();
// Cold-tier server: independent credentials, its own Local KMS holding the
// same key id (see the module docs for why the tier target needs KMS).
// Started first; each server's startup cleanup only matches its own unique
// address and temp dir, so the two instances coexist.
let mut cold = LocalKMSTestEnvironment::new().await?;
cold.base_env.access_key = "kmscoldtieradmin".to_string();
cold.base_env.secret_key = "kmscoldtiersecret".to_string();
start_cold_tier_kms_server(&mut cold).await?;
let cold_client = cold.base_env.create_s3_client();
cold_client.create_bucket().bucket(TIER_BUCKET).send().await?;
// Hot server: Local KMS + enforcement + accelerated lifecycle clock.
let mut env = LocalKMSTestEnvironment::new().await?;
start_enforcing_ilm_server(&mut env).await?;
let hot_client = env.base_env.create_s3_client();
add_rustfs_tier(&env.base_env, &cold.base_env).await?;
env.base_env.create_test_bucket(TRANSITION_BUCKET).await?;
set_bucket_default_sse_kms(&hot_client, TRANSITION_BUCKET).await?;
hot_client
.put_object()
.bucket(TRANSITION_BUCKET)
.key(TRANSITION_KEY)
.body(ByteStream::from_static(PAYLOAD))
.send()
.await?;
assert_head_sse_kms(&hot_client, TRANSITION_BUCKET, TRANSITION_KEY).await?;
info!("object stored SSE-KMS encrypted under enforcement");
// Days=1 is due ~2s after the write with RUSTFS_ILM_DEBUG_DAY_SECS=2.
put_transition_rule(&hot_client, TRANSITION_BUCKET, "kms-ilm-transition", "tier/", 1).await?;
// Drive the transition deterministically via the manual run endpoint, then
// wait for HEAD to report the tier as the object's storage class.
run_manual_transition_until_processed(&env.base_env, TRANSITION_BUCKET, "tier/", ILM_DEADLINE).await?;
wait_for_transition(&hot_client, TRANSITION_BUCKET, TRANSITION_KEY, ILM_DEADLINE).await?;
info!("SSE-KMS object transitioned to the remote tier under enforcement");
let head = hot_client
.head_object()
.bucket(TRANSITION_BUCKET)
.key(TRANSITION_KEY)
.send()
.await?;
assert!(
head.restore().is_none(),
"a freshly transitioned object must not advertise x-amz-restore, got {:?}",
head.restore()
);
// The remote copy exists on the cold tier. The payload the tier holds is the
// hot server's stored ciphertext, wrapped once more under the cold server's
// own managed SSE-KMS layer (the forwarded headers re-request encryption).
let remote = cold_client.list_objects_v2().bucket(TIER_BUCKET).send().await?;
assert!(!remote.contents().is_empty(), "cold-tier bucket must hold the transitioned object's data");
// Read-through GET under enforcement must succeed (not AccessDenied) and
// keep advertising SSE-KMS. Its BODY is deliberately not compared here:
// the transitioned read path skips managed-SSE decryption — a product gap
// unrelated to enforcement — so a direct GET streams the stored ciphertext
// (`new_getobjectreader` in crates/ecstore/src/client/object_api_utils.rs
// hardcodes `is_encrypted = false` and never applies the
// `ReadTransform::Encrypted` wrapping the hot-read path builds in
// crates/ecstore/src/object_api/readers.rs). Plaintext recovery is pinned
// through restore semantics below; when the read-through gap is fixed, a
// byte assertion can be added here too.
let read_through = hot_client
.get_object()
.bucket(TRANSITION_BUCKET)
.key(TRANSITION_KEY)
.send()
.await?;
assert_eq!(
read_through.server_side_encryption(),
Some(&ServerSideEncryption::AwsKms),
"transitioned object must still report SSE-KMS on read-through"
);
let read_through_body = read_through.body.collect().await?.into_bytes();
assert_eq!(
read_through_body.len(),
PAYLOAD.len(),
"read-through GET must stream the object's full logical size under enforcement"
);
// RestoreObject copies the ciphertext back from the tier under the original
// envelope metadata; the restored copy is then served by the normal
// decrypting read path. The copy-back runs with an internal (no-principal)
// identity, so this also pins the exemption on the restore path. Days=300
// because RUSTFS_ILM_DEBUG_DAY_SECS=2 accelerates the restored copy's
// expiry as well (300 accelerated days == 600s of validity).
hot_client
.restore_object()
.bucket(TRANSITION_BUCKET)
.key(TRANSITION_KEY)
.restore_request(RestoreRequest::builder().days(300).build())
.send()
.await?;
wait_for_restore_complete(&hot_client, TRANSITION_BUCKET, TRANSITION_KEY, ILM_DEADLINE).await?;
info!("SSE-KMS object restored from the remote tier under enforcement");
// The KMS-relevant half: the restored envelope decrypts back to the exact
// plaintext for the requesting principal.
let restored = hot_client
.get_object()
.bucket(TRANSITION_BUCKET)
.key(TRANSITION_KEY)
.send()
.await?;
assert_eq!(
restored.server_side_encryption(),
Some(&ServerSideEncryption::AwsKms),
"restored object must still report SSE-KMS"
);
let body = restored.body.collect().await?.into_bytes();
assert_eq!(body.as_ref(), PAYLOAD, "restored SSE-KMS object must round-trip byte-identical plaintext");
Ok(())
}
-3
View File
@@ -59,6 +59,3 @@ mod configured_roundtrip_test;
#[cfg(test)] #[cfg(test)]
mod kms_authorization_negative_matrix_test; mod kms_authorization_negative_matrix_test;
#[cfg(test)]
mod kms_ilm_sse_kms_test;
-4
View File
@@ -39,10 +39,6 @@ pub mod fault_proxy;
#[cfg(test)] #[cfg(test)]
mod reliability_disk_fault_test; mod reliability_disk_fault_test;
// Privileged Linux-only 3x4 replacement rebuild proof for rustfs#5869/#1791.
#[cfg(all(test, target_os = "linux"))]
mod replacement_privileged_e2e_test;
// dist-13 (backlog#1150/#1155): e2e regression net proving a large-object // dist-13 (backlog#1150/#1155): e2e regression net proving a large-object
// degraded EC read never returns a silently truncated body (rustfs#4594/#4560/#4585). // degraded EC read never returns a silently truncated body (rustfs#4594/#4560/#4585).
#[cfg(test)] #[cfg(test)]
File diff suppressed because it is too large Load Diff
@@ -2854,7 +2854,7 @@ pub(crate) mod cmptst_30 {
result result
} }
#[ignore = "timing-sensitive backend-pressure latency probe; run explicitly with --ignored"] #[ignore]
#[tokio::test] #[tokio::test]
async fn regression() -> Result<(), Box<dyn std::error::Error + Send + Sync>> { async fn regression() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
crate::common::init_logging(); crate::common::init_logging();
+1 -20
View File
@@ -252,7 +252,6 @@ impl QuotaTestEnv {
#[cfg(test)] #[cfg(test)]
mod integration_tests { mod integration_tests {
use super::*; use super::*;
use aws_sdk_s3::error::ProvideErrorMetadata;
#[tokio::test] #[tokio::test]
#[serial] #[serial]
@@ -964,27 +963,9 @@ mod integration_tests {
.send() .send()
.await; .await;
let complete_error = complete_result.expect_err("multipart completion above quota must be rejected"); assert!(complete_result.is_err());
assert_eq!(complete_error.as_service_error().and_then(|error| error.code()), Some("InvalidRequest"));
assert!(!env.object_exists("over_quota.txt").await?); assert!(!env.object_exists("over_quota.txt").await?);
let staged_parts = env
.client
.list_parts()
.bucket(&env.bucket_name)
.key("over_quota.txt")
.upload_id(upload_id2)
.send()
.await?;
assert_eq!(staged_parts.parts().len(), 2, "quota rejection must preserve the multipart upload");
env.client
.abort_multipart_upload()
.bucket(&env.bucket_name)
.key("over_quota.txt")
.upload_id(upload_id2)
.send()
.await?;
env.cleanup_bucket().await?; env.cleanup_bucket().await?;
Ok(()) Ok(())
@@ -349,32 +349,11 @@ mod tests {
.send() .send()
.await?; .await?;
let first_inline = client
.put_object()
.bucket(bucket)
.key("versions/inline.bin")
.body(ByteStream::from(payload(8 * 1024, 40)))
.send()
.await?;
let first_inline_version = first_inline
.version_id()
.ok_or("first inline PUT did not return a version ID")?;
let second_inline = client
.put_object()
.bucket(bucket)
.key("versions/inline.bin")
.body(ByteStream::from(payload(8 * 1024, 41)))
.send()
.await?;
let second_inline_version = second_inline
.version_id()
.ok_or("second inline PUT did not return a version ID")?;
let first = client let first = client
.put_object() .put_object()
.bucket(bucket) .bucket(bucket)
.key(key) .key(key)
.body(ByteStream::from(payload(128 * 1024, 41))) .body(ByteStream::from(payload(256 * 1024, 41)))
.send() .send()
.await?; .await?;
let first_version = first.version_id().ok_or("first PUT did not return a version ID")?; let first_version = first.version_id().ok_or("first PUT did not return a version ID")?;
@@ -382,36 +361,16 @@ mod tests {
.put_object() .put_object()
.bucket(bucket) .bucket(bucket)
.key(key) .key(key)
.body(ByteStream::from(payload(3 * 1024 * 1024, 42))) .body(ByteStream::from(payload(256 * 1024, 42)))
.send() .send()
.await?; .await?;
let second_version = second.version_id().ok_or("second PUT did not return a version ID")?; let second_version = second.version_id().ok_or("second PUT did not return a version ID")?;
let delete = client.delete_object().bucket(bucket).key(key).send().await?; let delete = client.delete_object().bucket(bucket).key(key).send().await?;
let delete_version = delete.version_id().ok_or("delete marker did not return a version ID")?; let delete_version = delete.version_id().ok_or("delete marker did not return a version ID")?;
let first_inline_census = harness.census_object_version(0, bucket, "versions/inline.bin", Some(first_inline_version))?;
let second_inline_census =
harness.census_object_version(0, bucket, "versions/inline.bin", Some(second_inline_version))?;
let first_census = harness.census_object_version(0, bucket, key, Some(first_version))?; let first_census = harness.census_object_version(0, bucket, key, Some(first_version))?;
let first_other_disk_census = harness.census_object_version(1, bucket, key, Some(first_version))?;
let second_census = harness.census_object_version(0, bucket, key, Some(second_version))?; let second_census = harness.census_object_version(0, bucket, key, Some(second_version))?;
let delete_census = harness.census_object_version(0, bucket, key, Some(delete_version))?; let delete_census = harness.census_object_version(0, bucket, key, Some(delete_version))?;
assert!(
first_inline_census.is_complete() && second_inline_census.is_complete(),
"inline version physical census is incomplete: first={first_inline_census:?} second={second_inline_census:?}"
);
assert!(
first_inline_census.present_part_fingerprints.is_empty() && second_inline_census.present_part_fingerprints.is_empty(),
"inline versions must not select external shard files: first={first_inline_census:?} second={second_inline_census:?}"
);
assert!(
first_inline_census.inline_data_fingerprint.is_some() && second_inline_census.inline_data_fingerprint.is_some(),
"inline versions must fingerprint payload bytes stored in xl.meta"
);
assert_ne!(
first_inline_census.inline_data_fingerprint, second_inline_census.inline_data_fingerprint,
"same-size inline versions with different payloads must retain distinct xl.meta fingerprints"
);
assert!( assert!(
first_census.is_complete(), first_census.is_complete(),
"first version physical census is incomplete: {first_census:?}" "first version physical census is incomplete: {first_census:?}"
@@ -420,14 +379,6 @@ mod tests {
second_census.is_complete(), second_census.is_complete(),
"second version physical census is incomplete: {second_census:?}" "second version physical census is incomplete: {second_census:?}"
); );
assert!(
first_other_disk_census.is_complete(),
"first version physical census on the second disk is incomplete: {first_other_disk_census:?}"
);
assert_ne!(
first_census.erasure_index, first_other_disk_census.erasure_index,
"physical census must preserve each disk's erasure index"
);
assert_ne!( assert_ne!(
first_census.data_dir, second_census.data_dir, first_census.data_dir, second_census.data_dir,
"distinct object versions must select distinct physical data directories" "distinct object versions must select distinct physical data directories"
@@ -436,24 +387,6 @@ mod tests {
first_census.expected_part_numbers, second_census.expected_part_numbers, first_census.expected_part_numbers, second_census.expected_part_numbers,
"same single-part shape should expose the same part numbers" "same single-part shape should expose the same part numbers"
); );
let first_part = first_census
.present_part_fingerprints
.values()
.next()
.ok_or("first version did not expose a physical part fingerprint")?;
let second_part = second_census
.present_part_fingerprints
.values()
.next()
.ok_or("second version did not expose a physical part fingerprint")?;
assert_ne!(
first_part.size, second_part.size,
"different shard lengths must retain their physical sizes"
);
assert_ne!(
first_part.sha256, second_part.sha256,
"different shard contents must retain their physical hashes"
);
assert!( assert!(
delete_census.is_complete(), delete_census.is_complete(),
"delete marker physical census is incomplete: {delete_census:?}" "delete marker physical census is incomplete: {delete_census:?}"
@@ -463,7 +396,7 @@ mod tests {
"delete marker must not declare object shards: {delete_census:?}" "delete marker must not declare object shards: {delete_census:?}"
); );
assert!( assert!(
delete_census.present_part_fingerprints.is_empty(), delete_census.present_part_numbers.is_empty(),
"delete marker must not select stale object shards: {delete_census:?}" "delete marker must not select stale object shards: {delete_census:?}"
); );
Ok(()) Ok(())
File diff suppressed because it is too large Load Diff
@@ -2401,20 +2401,15 @@ async fn wait_for_site_replication_info<F>(
where where
F: Fn(&SiteReplicationInfo) -> bool, F: Fn(&SiteReplicationInfo) -> bool,
{ {
// 30s to match wait_for_replication_state: the three-node site tests run for _ in 0..40 {
// several full rustfs processes on one runner, so peer-state propagation
// can take well over 10s under CI load.
let deadline = tokio::time::Instant::now() + Duration::from_secs(30);
loop {
let info = site_replication_info(env).await?; let info = site_replication_info(env).await?;
if predicate(&info) { if predicate(&info) {
return Ok(info); return Ok(info);
} }
if tokio::time::Instant::now() >= deadline {
return Err(format!("site replication info did not reach expected state on {}", env.address).into());
}
sleep(Duration::from_millis(250)).await; sleep(Duration::from_millis(250)).await;
} }
Err(format!("site replication info did not reach expected state on {}", env.address).into())
} }
async fn wait_for_site_replication_status<F>( async fn wait_for_site_replication_status<F>(
@@ -2425,19 +2420,15 @@ async fn wait_for_site_replication_status<F>(
where where
F: Fn(&SRStatusInfo) -> bool, F: Fn(&SRStatusInfo) -> bool,
{ {
// Same 30s ceiling as wait_for_site_replication_info: the status probes for _ in 0..40 {
// fan out to every peer, so they see the same multi-process CI load.
let deadline = tokio::time::Instant::now() + Duration::from_secs(30);
loop {
let status = site_replication_status(env, query).await?; let status = site_replication_status(env, query).await?;
if predicate(&status) { if predicate(&status) {
return Ok(status); return Ok(status);
} }
if tokio::time::Instant::now() >= deadline {
return Err(format!("site replication status did not reach expected state on {}", env.address).into());
}
sleep(Duration::from_millis(250)).await; sleep(Duration::from_millis(250)).await;
} }
Err(format!("site replication status did not reach expected state on {}", env.address).into())
} }
async fn wait_for_replication_reset_target<F>( async fn wait_for_replication_reset_target<F>(
@@ -4244,49 +4235,37 @@ async fn test_bucket_replication_acceptance_matrix_local_dual_targets() -> TestR
"tag rule with disabled delete-marker replication created a marker: {tagged_state:?}" "tag rule with disabled delete-marker replication created a marker: {tagged_state:?}"
); );
// AWS S3 and MinIO both reject suspending versioning on a bucket that set_bucket_versioning(&source_env, source_bucket, BucketVersioningStatus::Suspended).await?;
// carries a replication configuration (InvalidBucketState): suspension set_bucket_versioning(&target_env_a, target_bucket_a, BucketVersioningStatus::Suspended).await?;
// would mint null versions that versioned replication can never converge. let null_put = source_client
let suspend_err = source_client
.put_bucket_versioning()
.bucket(source_bucket)
.versioning_configuration(
VersioningConfiguration::builder()
.status(BucketVersioningStatus::Suspended)
.build(),
)
.send()
.await
.expect_err("suspending versioning on a replication source must be rejected");
assert_eq!(
suspend_err.as_service_error().and_then(|error| error.code()),
Some("InvalidBucketState"),
"suspension on a replication source must fail with InvalidBucketState: {suspend_err:?}"
);
// The rejected suspension must leave the versioning + replication state
// fully intact: a fresh matched PUT still replicates with a real version.
let post_reject_put = source_client
.put_object() .put_object()
.bucket(source_bucket) .bucket(source_bucket)
.key("prefix/after-rejected-suspend.txt") .key("prefix/null.txt")
.body(ByteStream::from_static(b"still replicating")) .body(ByteStream::from_static(b"null version"))
.send() .send()
.await?; .await?;
let post_reject_version_id = post_reject_put assert!(null_put.version_id().is_none(), "suspended source PUT must create a null version");
.version_id() wait_for_replication_state(&target_client_a, target_bucket_a, "null version did not replicate", |state| {
.ok_or("PUT after rejected suspension omitted version ID")? state
.to_string(); .iter()
wait_for_replication_state( .any(|entry| entry.key == "prefix/null.txt" && entry.version_id == "null" && !entry.delete_marker)
&target_client_a, })
target_bucket_a, .await?;
"replication stopped after rejected versioning suspension", let null_delete = source_client
|state| { .delete_object()
state .bucket(source_bucket)
.iter() .key("prefix/null.txt")
.any(|entry| entry.key == "prefix/after-rejected-suspend.txt" && entry.version_id == post_reject_version_id) .send()
}, .await?;
) assert!(
null_delete.version_id().is_none(),
"suspended source DELETE must create a null delete marker"
);
wait_for_replication_state(&target_client_a, target_bucket_a, "null delete marker did not replicate", |state| {
state
.iter()
.any(|entry| entry.key == "prefix/null.txt" && entry.version_id == "null" && entry.delete_marker)
})
.await?; .await?;
Ok(()) Ok(())
-5
View File
@@ -32,11 +32,6 @@ workspace = true
[features] [features]
default = [] default = []
# Compiles the controlled list-objects namespace-journal chaos injector into a
# production binary (it is always available to tests). Off by default so the
# RUSTFS_LIST_OBJECTS_NAMESPACE_JOURNAL_CHAOS_* env vars cannot rewrite journal
# state in a stock build (backlog#1832).
list-chaos = []
rio-v2 = ["dep:rustfs-rio-v2"] rio-v2 = ["dep:rustfs-rio-v2"]
hotpath = [ hotpath = [
"hotpath/hotpath", "hotpath/hotpath",
@@ -69,7 +69,6 @@ fn build_non_inline_writers(config: &BenchConfig) -> Vec<Option<BitrotWriterWrap
fn bench_single_block_non_inline_fast_path(c: &mut Criterion) { fn bench_single_block_non_inline_fast_path(c: &mut Criterion) {
let configs = vec![ let configs = vec![
BenchConfig::new(4 * 1024, 4, 2, 128 * 1024), BenchConfig::new(4 * 1024, 4, 2, 128 * 1024),
BenchConfig::new(16 * 1024, 4, 2, 128 * 1024),
BenchConfig::new(64 * 1024, 4, 2, 128 * 1024), BenchConfig::new(64 * 1024, 4, 2, 128 * 1024),
BenchConfig::new(128 * 1024, 4, 2, 128 * 1024), BenchConfig::new(128 * 1024, 4, 2, 128 * 1024),
]; ];
@@ -113,12 +112,7 @@ fn bench_single_block_non_inline_fast_path(c: &mut Criterion) {
rt.block_on(async { rt.block_on(async {
erasure erasure
.clone() .clone()
.encode_single_block_non_inline_with_size_hint( .encode_single_block_non_inline(reader, &mut writers, config.data_shards)
reader,
&mut writers,
config.data_shards,
config.payload_size,
)
.await .await
.expect("single block candidate benchmark"); .expect("single block candidate benchmark");
}); });
+13 -25
View File
@@ -61,11 +61,9 @@ pub mod bucket {
delete_manual_transition_scope_admission_if_current, load_manual_transition_job_record, delete_manual_transition_scope_admission_if_current, load_manual_transition_job_record,
load_manual_transition_job_record_with_etag, load_manual_transition_scope_admission, load_manual_transition_job_record_with_etag, load_manual_transition_scope_admission,
manual_transition_job_lease_expired, manual_transition_scope_admission_lease_expired, manual_transition_job_lease_expired, manual_transition_scope_admission_lease_expired,
manual_transition_scope_key, persist_manual_transition_job_progress, manual_transition_scope_key, persist_manual_transition_job_progress, renew_manual_transition_job_lease,
persist_manual_transition_job_progress_if_owned, renew_manual_transition_job_lease, request_manual_transition_job_cancel, save_manual_transition_job_record,
renew_manual_transition_job_lease_if_owned, request_manual_transition_job_cancel, save_manual_transition_job_record_if_current, save_manual_transition_scope_admission_if_absent,
save_manual_transition_job_record, save_manual_transition_job_record_if_current,
save_manual_transition_scope_admission_if_absent, update_manual_transition_job_record,
}; };
} }
@@ -131,8 +129,6 @@ pub mod bucket {
} }
pub mod metadata_sys { pub mod metadata_sys {
#[cfg(feature = "test-util")]
pub use crate::bucket::metadata_sys::ConfigWriteLockProbe;
pub use crate::bucket::metadata_sys::{ pub use crate::bucket::metadata_sys::{
BucketMetadataMutationGuard, BucketMetadataSys, ObjectLockConfigState, acquire_bucket_metadata_transaction_lock, BucketMetadataMutationGuard, BucketMetadataSys, ObjectLockConfigState, acquire_bucket_metadata_transaction_lock,
capture_bucket_metadata_incarnation, delete, delete_if_incarnation, get, get_accelerate_config, get_bucket_policy, capture_bucket_metadata_incarnation, delete, delete_if_incarnation, get, get_accelerate_config, get_bucket_policy,
@@ -142,7 +138,7 @@ pub mod bucket {
get_replication_config, get_request_payment_config, get_sse_config, get_tagging_config, get_versioning_config, get_replication_config, get_request_payment_config, get_sse_config, get_tagging_config, get_versioning_config,
get_website_config, init_bucket_metadata_sys, list_bucket_targets, reload_bucket_metadata, remove_bucket_metadata, get_website_config, init_bucket_metadata_sys, list_bucket_targets, reload_bucket_metadata, remove_bucket_metadata,
set_bucket_metadata, update, update_bucket_targets_under_transaction_lock, update_config_with, update_if_incarnation, set_bucket_metadata, update, update_bucket_targets_under_transaction_lock, update_config_with, update_if_incarnation,
update_quota_if_incarnation, update_under_transaction_lock, update_under_transaction_lock,
}; };
} }
@@ -278,9 +274,7 @@ pub mod cluster {
} }
pub mod compression { pub mod compression {
pub use crate::io_support::compress::{ pub use crate::io_support::compress::{MIN_DISK_COMPRESSIBLE_SIZE, is_disk_compressible, is_disk_compression_enabled};
MIN_DISK_COMPRESSIBLE_SIZE, is_disk_compressible, is_disk_compression_enabled, is_multipart_disk_compression_enabled,
};
} }
pub mod config { pub mod config {
@@ -314,13 +308,11 @@ pub mod config {
} }
pub mod data_usage { pub mod data_usage {
#[cfg(feature = "test-util")]
pub use crate::data_usage::seed_bucket_usage_memory_for_test;
pub use crate::data_usage::{ pub use crate::data_usage::{
DATA_USAGE_CACHE_NAME, apply_bucket_usage_memory_overlay, compute_bucket_usage, DATA_USAGE_CACHE_NAME, apply_bucket_usage_memory_overlay, compute_bucket_usage,
init_compression_total_memory_from_backend, invalidate_admin_data_usage_snapshot_cache, init_compression_total_memory_from_backend, invalidate_admin_data_usage_snapshot_cache,
invalidate_data_usage_snapshot_cache, live_bucket_usage_computations, load_admin_data_usage_from_backend_cached, invalidate_data_usage_snapshot_cache, live_bucket_usage_computations, load_admin_data_usage_from_backend_cached,
load_compression_total_from_memory, load_data_usage_from_backend, load_data_usage_from_backend_cached, quota_object_size, load_compression_total_from_memory, load_data_usage_from_backend, load_data_usage_from_backend_cached,
record_bucket_delete_marker_memory, record_bucket_object_delete_memory, record_bucket_object_version_write_memory, record_bucket_delete_marker_memory, record_bucket_object_delete_memory, record_bucket_object_version_write_memory,
record_bucket_object_write_memory, record_bucket_object_write_unknown_previous_memory, record_compression_total_memory, record_bucket_object_write_memory, record_bucket_object_write_unknown_previous_memory, record_compression_total_memory,
refresh_bucket_usage_from_object_layer, refresh_versioned_bucket_usage_from_object_layer, refresh_bucket_usage_from_object_layer, refresh_versioned_bucket_usage_from_object_layer,
@@ -350,7 +342,7 @@ pub mod disk {
} }
pub mod error { pub mod error {
pub use crate::disk::error::{DiskError, Error, FileAccessDeniedWithContext, Result}; pub use crate::disk::error::{BitrotErrorType, DiskError, Error, FileAccessDeniedWithContext, Result};
} }
pub mod error_reduce { pub mod error_reduce {
@@ -407,11 +399,8 @@ pub mod metrics {
} }
pub mod notification { pub mod notification {
#[cfg(any(test, feature = "test-util"))]
pub use crate::services::notification_sys::rotate_cross_pool_fence_fleet_proof_for_test;
pub use crate::services::notification_sys::{ pub use crate::services::notification_sys::{
CrossPoolFenceFleetProofToken, NotificationPeerErr, NotificationSys, acquire_cross_pool_fence_fleet_proof, NotificationPeerErr, NotificationSys, get_global_notification_sys, new_global_notification_sys,
cross_pool_fence_fleet_proof_matches, get_global_notification_sys, new_global_notification_sys,
start_remote_version_state_fleet_probe, start_remote_version_state_fleet_probe,
}; };
} }
@@ -420,10 +409,10 @@ pub mod object {
pub use crate::object_api::{ pub use crate::object_api::{
BLOCK_SIZE_V2, ERASURE_ALGORITHM, EncryptionResolutionError, EncryptionResolutionErrorKind, GetObjectBodyCacheHook, BLOCK_SIZE_V2, ERASURE_ALGORITHM, EncryptionResolutionError, EncryptionResolutionErrorKind, GetObjectBodyCacheHook,
GetObjectBodyCacheHookLookup, GetObjectBodySource, GetObjectReader, NamespaceLockFence, ObjectEncryptionResolver, GetObjectBodyCacheHookLookup, GetObjectBodySource, GetObjectReader, NamespaceLockFence, ObjectEncryptionResolver,
ObjectInfo, ObjectLockConfigSnapshot, ObjectMutationHook, ObjectOptions, PutObjReader, QuotaAdmission, ObjectInfo, ObjectLockConfigSnapshot, ObjectMutationHook, ObjectOptions, PutObjReader, RangedDecompressReader,
RangedDecompressReader, ReadEncryptionMaterial, ReadEncryptionMode, ReadEncryptionRequest, StreamConsumer, ReadEncryptionMaterial, ReadEncryptionMode, ReadEncryptionRequest, StreamConsumer, get_object_body_cache_plaintext_len,
get_object_body_cache_plaintext_len, lookup_get_object_body_cache_hook, register_get_object_body_cache_hook, lookup_get_object_body_cache_hook, register_get_object_body_cache_hook, register_object_mutation_hook,
register_object_mutation_hook, unregister_get_object_body_cache_hook, unregister_object_mutation_hook, unregister_get_object_body_cache_hook, unregister_object_mutation_hook,
}; };
pub use crate::store::{ pub use crate::store::{
PrepareSelectObjectSnapshotError, PreparedGetObjectReader, SelectObjectSnapshot, SelectObjectSnapshotReadError, PrepareSelectObjectSnapshotError, PreparedGetObjectReader, SelectObjectSnapshot, SelectObjectSnapshotReadError,
@@ -471,8 +460,7 @@ pub mod set_disk {
#[cfg(feature = "test-util")] #[cfg(feature = "test-util")]
pub mod test_util { pub mod test_util {
pub use crate::bucket::quota::reservation::fail_next_quota_ledger_save_for_test; pub use crate::set_disk::{PutObjectCommitBarrier, PutObjectCommitPause};
pub use crate::set_disk::{MultipartCommitBarrier, MultipartCommitPause, PutObjectCommitBarrier, PutObjectCommitPause};
} }
} }
-60
View File
@@ -64,41 +64,10 @@ impl BucketDurabilityConfig {
} }
} }
/// Default durability tier seeded into a newly created bucket's metadata
/// (rustfs/backlog#1811). `relaxed` aligns new buckets with MinIO's default
/// posture: object data is still fdatasynced, while xl.meta and directory-entry
/// fsyncs follow the relaxed durability gate.
pub const ENV_NEW_BUCKET_DURABILITY_MODE: &str = "RUSTFS_NEW_BUCKET_DURABILITY_MODE";
pub const DEFAULT_NEW_BUCKET_DURABILITY_MODE: &str = BUCKET_DURABILITY_MODE_RELAXED;
/// The `durability.json` bytes to seed into a freshly created bucket's metadata.
/// Empty means "no override" (the bucket then follows the global
/// `RUSTFS_DURABILITY_MODE`); otherwise the serialized chosen tier. Operators
/// can set `inherit` to disable the new-bucket override. Invalid values also
/// fail closed to inherit the global mode instead of seeding a surprising tier.
pub fn new_bucket_durability_config_json() -> Vec<u8> {
let raw = std::env::var(ENV_NEW_BUCKET_DURABILITY_MODE).unwrap_or_else(|_| DEFAULT_NEW_BUCKET_DURABILITY_MODE.to_string());
let mode = raw.trim();
if mode.eq_ignore_ascii_case("inherit") || mode.is_empty() || !BucketDurabilityConfig::is_valid_mode(mode) {
return Vec::new();
}
serde_json::to_vec(&BucketDurabilityConfig::new(mode)).expect("BucketDurabilityConfig serialization cannot fail")
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
fn new_bucket_seeded_mode() -> Option<String> {
let json = new_bucket_durability_config_json();
if json.is_empty() {
return None;
}
serde_json::from_slice::<BucketDurabilityConfig>(&json)
.expect("new-bucket durability config must serialize")
.normalized_mode()
}
#[test] #[test]
fn valid_modes_are_recognized() { fn valid_modes_are_recognized() {
assert!(BucketDurabilityConfig::is_valid_mode("strict")); assert!(BucketDurabilityConfig::is_valid_mode("strict"));
@@ -130,33 +99,4 @@ mod tests {
let empty: BucketDurabilityConfig = serde_json::from_slice(b"{}").expect("deserialize empty"); let empty: BucketDurabilityConfig = serde_json::from_slice(b"{}").expect("deserialize empty");
assert_eq!(empty.normalized_mode(), None); assert_eq!(empty.normalized_mode(), None);
} }
#[test]
fn new_bucket_default_seeds_relaxed_when_unset() {
temp_env::with_var_unset(ENV_NEW_BUCKET_DURABILITY_MODE, || {
assert_eq!(new_bucket_seeded_mode().as_deref(), Some(BUCKET_DURABILITY_MODE_RELAXED));
});
}
#[test]
fn new_bucket_default_honors_explicit_tiers() {
for mode in [
BUCKET_DURABILITY_MODE_STRICT,
BUCKET_DURABILITY_MODE_RELAXED,
BUCKET_DURABILITY_MODE_NONE,
] {
temp_env::with_var(ENV_NEW_BUCKET_DURABILITY_MODE, Some(mode), || {
assert_eq!(new_bucket_seeded_mode().as_deref(), Some(mode));
});
}
}
#[test]
fn new_bucket_default_can_inherit_global_mode() {
for mode in ["inherit", "", "bogus"] {
temp_env::with_var(ENV_NEW_BUCKET_DURABILITY_MODE, Some(mode), || {
assert_eq!(new_bucket_seeded_mode(), None);
});
}
}
} }
File diff suppressed because it is too large Load Diff
@@ -86,21 +86,6 @@ where
com::save_config_with_opts(api, file, data, opts).await com::save_config_with_opts(api, file, data, opts).await
} }
pub(crate) async fn save_config_with_opts_quiet<S>(api: Arc<S>, file: &str, data: Vec<u8>, opts: &ObjectOptions) -> Result<()>
where
S: ObjectIO<
Error = Error,
RangeSpec = HTTPRangeSpec,
HeaderMap = HeaderMap,
ObjectOptions = ObjectOptions,
ObjectInfo = ObjectInfo,
GetObjectReader = GetObjectReader,
PutObjectReader = PutObjReader,
>,
{
com::save_config_with_opts_quiet(api, file, data, opts).await
}
pub(crate) async fn delete_config<S>(api: Arc<S>, file: &str) -> Result<()> pub(crate) async fn delete_config<S>(api: Arc<S>, file: &str) -> Result<()>
where where
S: ObjectOperations< S: ObjectOperations<
@@ -45,104 +45,6 @@ const MANUAL_TRANSITION_JOB_LEASE_SECONDS: i128 = 60;
const MANUAL_TRANSITION_LEGACY_SCOPE_SCAN_LIMIT: i32 = 1000; const MANUAL_TRANSITION_LEGACY_SCOPE_SCAN_LIMIT: i32 = 1000;
const MANUAL_TRANSITION_TASK_SCAN_LIMIT: i32 = 1000; const MANUAL_TRANSITION_TASK_SCAN_LIMIT: i32 = 1000;
const MANUAL_TRANSITION_WORKER_RESULT_SCAN_LIMIT: i32 = 1000; const MANUAL_TRANSITION_WORKER_RESULT_SCAN_LIMIT: i32 = 1000;
const MANUAL_TRANSITION_JOB_CAS_RETRIES: usize = 4;
#[cfg(test)]
struct ManualTransitionJobCasBarrierState {
job_id: Uuid,
paused: std::sync::atomic::AtomicBool,
arrived: tokio::sync::Notify,
release: tokio::sync::Semaphore,
}
#[cfg(test)]
pub(crate) struct ManualTransitionJobCasBarrier {
state: Arc<ManualTransitionJobCasBarrierState>,
}
#[cfg(test)]
static MANUAL_TRANSITION_JOB_CAS_BARRIER: std::sync::OnceLock<std::sync::Mutex<Option<Arc<ManualTransitionJobCasBarrierState>>>> =
std::sync::OnceLock::new();
#[cfg(test)]
impl ManualTransitionJobCasBarrier {
pub(crate) fn install(job_id: Uuid) -> Self {
let state = Arc::new(ManualTransitionJobCasBarrierState {
job_id,
paused: std::sync::atomic::AtomicBool::new(false),
arrived: tokio::sync::Notify::new(),
release: tokio::sync::Semaphore::new(0),
});
let mut slot = MANUAL_TRANSITION_JOB_CAS_BARRIER
.get_or_init(|| std::sync::Mutex::new(None))
.lock()
.expect("manual transition progress CAS barrier mutex should not poison");
assert!(
slot.is_none(),
"manual transition job CAS barrier must be installed by one test at a time"
);
*slot = Some(Arc::clone(&state));
drop(slot);
Self { state }
}
pub(crate) async fn wait_until_paused(&self) {
tokio::time::timeout(std::time::Duration::from_secs(30), async {
loop {
let arrived = self.state.arrived.notified();
if self.state.paused.load(std::sync::atomic::Ordering::Acquire) {
return;
}
arrived.await;
}
})
.await
.expect("manual transition job update should reach the deterministic CAS barrier");
}
pub(crate) fn release(&self) {
self.state.release.add_permits(1);
}
}
#[cfg(test)]
impl Drop for ManualTransitionJobCasBarrier {
fn drop(&mut self) {
self.release();
let mut slot = MANUAL_TRANSITION_JOB_CAS_BARRIER
.get_or_init(|| std::sync::Mutex::new(None))
.lock()
.expect("manual transition progress CAS barrier mutex should not poison");
if slot.as_ref().is_some_and(|state| Arc::ptr_eq(state, &self.state)) {
*slot = None;
}
}
}
#[cfg(test)]
async fn pause_manual_transition_job_before_first_cas(job_id: Uuid) {
let barrier = MANUAL_TRANSITION_JOB_CAS_BARRIER
.get_or_init(|| std::sync::Mutex::new(None))
.lock()
.expect("manual transition progress CAS barrier mutex should not poison")
.as_ref()
.filter(|barrier| barrier.job_id == job_id)
.cloned();
if let Some(barrier) = barrier
&& barrier
.paused
.compare_exchange(false, true, std::sync::atomic::Ordering::AcqRel, std::sync::atomic::Ordering::Acquire)
.is_ok()
{
barrier.arrived.notify_one();
barrier
.release
.acquire()
.await
.expect("manual transition job CAS barrier should remain open")
.forget();
}
}
fn is_false(value: &bool) -> bool { fn is_false(value: &bool) -> bool {
!*value !*value
@@ -246,6 +148,7 @@ impl ManualTransitionJobRecord {
pub fn fail(&mut self, error: impl Into<String>) { pub fn fail(&mut self, error: impl Into<String>) {
self.state = ManualTransitionJobState::Failed; self.state = ManualTransitionJobState::Failed;
self.report.tier_failure = self.report.tier_failure.saturating_add(1);
self.error = Some(error.into()); self.error = Some(error.into());
self.mark_updated_terminal(); self.mark_updated_terminal();
} }
@@ -1137,7 +1040,7 @@ pub async fn save_manual_transition_job_record_if_current(
} }
let object = manual_transition_job_record_object_name(job.job_id).map_err(manual_transition_job_store_error)?; let object = manual_transition_job_record_object_name(job.job_id).map_err(manual_transition_job_store_error)?;
let data = job.encode().map_err(manual_transition_job_store_error)?; let data = job.encode().map_err(manual_transition_job_store_error)?;
config_boundary::save_config_with_opts_quiet( config_boundary::save_config_with_opts(
api, api,
&object, &object,
data, data,
@@ -1153,54 +1056,6 @@ pub async fn save_manual_transition_job_record_if_current(
.await .await
} }
/// Applies a job-record mutation with optimistic concurrency control.
///
/// The mutation returns whether the record needs to be persisted. When a lease
/// is supplied, ownership is checked again after every conflicting write.
pub async fn update_manual_transition_job_record<F>(
api: Arc<ECStore>,
job_id: Uuid,
expected_lease_id: Option<Uuid>,
update: F,
) -> EcstoreResult<ManualTransitionJobRecord>
where
F: FnMut(&mut ManualTransitionJobRecord) -> bool,
{
update_manual_transition_job_record_from(api, job_id, expected_lease_id, None, update).await
}
async fn update_manual_transition_job_record_from<F>(
api: Arc<ECStore>,
job_id: Uuid,
expected_lease_id: Option<Uuid>,
mut current: Option<(ManualTransitionJobRecord, String)>,
mut update: F,
) -> EcstoreResult<ManualTransitionJobRecord>
where
F: FnMut(&mut ManualTransitionJobRecord) -> bool,
{
for _ in 0..MANUAL_TRANSITION_JOB_CAS_RETRIES {
let (mut record, etag) = match current.take() {
Some(current) => current,
None => load_manual_transition_job_record_with_etag(api.clone(), job_id).await?,
};
if expected_lease_id.is_some_and(|lease_id| record.lease_id != lease_id) {
return Err(Error::PreconditionFailed);
}
if !update(&mut record) {
return Ok(record);
}
#[cfg(test)]
pause_manual_transition_job_before_first_cas(job_id).await;
match save_manual_transition_job_record_if_current(api.clone(), &record, &etag).await {
Ok(()) => return Ok(record),
Err(Error::PreconditionFailed) => continue,
Err(err) => return Err(err),
}
}
Err(Error::PreconditionFailed)
}
pub(crate) async fn save_manual_transition_worker_result_if_absent( pub(crate) async fn save_manual_transition_worker_result_if_absent(
api: Arc<ECStore>, api: Arc<ECStore>,
record: &ManualTransitionWorkerResultRecord, record: &ManualTransitionWorkerResultRecord,
@@ -1459,113 +1314,99 @@ pub async fn reconcile_manual_transition_worker_results(
api: Arc<ECStore>, api: Arc<ECStore>,
job_id: Uuid, job_id: Uuid,
queue_snapshot: ManualTransitionQueueSnapshot, queue_snapshot: ManualTransitionQueueSnapshot,
) -> EcstoreResult<ManualTransitionJobRecord> {
reconcile_manual_transition_worker_results_inner(api, job_id, None, queue_snapshot, false).await
}
pub(crate) async fn reconcile_manual_transition_worker_results_if_owned(
api: Arc<ECStore>,
job_id: Uuid,
expected_lease_id: Uuid,
queue_snapshot: ManualTransitionQueueSnapshot,
) -> EcstoreResult<ManualTransitionJobRecord> {
reconcile_manual_transition_worker_results_inner(api, job_id, Some(expected_lease_id), queue_snapshot, false).await
}
async fn reconcile_manual_transition_worker_results_inner(
api: Arc<ECStore>,
job_id: Uuid,
expected_lease_id: Option<Uuid>,
queue_snapshot: ManualTransitionQueueSnapshot,
mark_missing_results_unknown: bool,
) -> EcstoreResult<ManualTransitionJobRecord> { ) -> EcstoreResult<ManualTransitionJobRecord> {
let task_stats = match scan_manual_transition_task_journal(api.clone(), job_id).await? { let task_stats = match scan_manual_transition_task_journal(api.clone(), job_id).await? {
ManualTransitionTaskJournal::Stats(stats) => stats, ManualTransitionTaskJournal::Stats(stats) => stats,
ManualTransitionTaskJournal::Corrupt(error) => { ManualTransitionTaskJournal::Corrupt(error) => {
return mark_manual_transition_job_unknown_for_task_journal_error( return mark_manual_transition_job_unknown_for_task_journal_error(api, job_id, error, queue_snapshot).await;
api,
job_id,
expected_lease_id,
error,
queue_snapshot,
)
.await;
} }
}; };
let stats = match scan_manual_transition_worker_result_journal(api.clone(), job_id).await? { let stats = match scan_manual_transition_worker_result_journal(api.clone(), job_id).await? {
ManualTransitionWorkerResultJournal::Stats(stats) => stats, ManualTransitionWorkerResultJournal::Stats(stats) => stats,
ManualTransitionWorkerResultJournal::Corrupt(error) => { ManualTransitionWorkerResultJournal::Corrupt(error) => {
return mark_manual_transition_job_unknown_for_worker_result_journal_error( return mark_manual_transition_job_unknown_for_worker_result_journal_error(api, job_id, error, queue_snapshot).await;
api,
job_id,
expected_lease_id,
error,
queue_snapshot,
)
.await;
} }
}; };
let mut changed = false; for _ in 0..4 {
let record = update_manual_transition_job_record(api.clone(), job_id, expected_lease_id, |record| { let (mut record, etag) = load_manual_transition_job_record_with_etag(api.clone(), job_id).await?;
let counts_changed = record.apply_worker_result_counts( let changed = record.apply_worker_result_counts(
stats.stats.completed, stats.stats.completed,
stats.stats.failed, stats.stats.failed,
&stats.stats.tier_failure_by_reason, &stats.stats.tier_failure_by_reason,
task_stats.queued, task_stats.queued,
queue_snapshot, queue_snapshot,
); );
let became_unknown = mark_missing_results_unknown && record.mark_unknown_if_worker_results_lost(queue_snapshot); if !changed {
changed = counts_changed || became_unknown; return Ok(record);
changed }
}) match save_manual_transition_job_record_if_current(api.clone(), &record, &etag).await {
.await?; Ok(()) => {
if !changed { if record.is_terminal() {
return Ok(record); delete_manual_transition_scope_admission_if_current(
api.clone(),
&record.scope_key,
record.job_id,
record.lease_id,
)
.await?;
} else {
renew_manual_transition_scope_admission_from_job(api, &record).await?;
}
return Ok(record);
}
Err(Error::PreconditionFailed) => continue,
Err(err) => return Err(err),
}
} }
if record.is_terminal() { Err(Error::PreconditionFailed)
delete_manual_transition_scope_admission_if_current(api, &record.scope_key, record.job_id, record.lease_id).await?;
} else {
renew_manual_transition_scope_admission_from_job(api, &record).await?;
}
Ok(record)
} }
async fn mark_manual_transition_job_unknown_for_task_journal_error( async fn mark_manual_transition_job_unknown_for_task_journal_error(
api: Arc<ECStore>, api: Arc<ECStore>,
job_id: Uuid, job_id: Uuid,
expected_lease_id: Option<Uuid>,
error: String, error: String,
queue_snapshot: ManualTransitionQueueSnapshot, queue_snapshot: ManualTransitionQueueSnapshot,
) -> EcstoreResult<ManualTransitionJobRecord> { ) -> EcstoreResult<ManualTransitionJobRecord> {
let mut changed = false; for _ in 0..4 {
let record = update_manual_transition_job_record(api.clone(), job_id, expected_lease_id, |record| { let (mut record, etag) = load_manual_transition_job_record_with_etag(api.clone(), job_id).await?;
changed = record.mark_unknown_for_task_journal_error(error.clone(), queue_snapshot); if !record.mark_unknown_for_task_journal_error(error.clone(), queue_snapshot) {
changed return Ok(record);
}) }
.await?; match save_manual_transition_job_record_if_current(api.clone(), &record, &etag).await {
if changed && record.is_terminal() { Ok(()) => {
delete_manual_transition_scope_admission_if_current(api, &record.scope_key, record.job_id, record.lease_id).await?; delete_manual_transition_scope_admission_if_current(api, &record.scope_key, record.job_id, record.lease_id)
.await?;
return Ok(record);
}
Err(Error::PreconditionFailed) => continue,
Err(err) => return Err(err),
}
} }
Ok(record) Err(Error::PreconditionFailed)
} }
async fn mark_manual_transition_job_unknown_for_worker_result_journal_error( async fn mark_manual_transition_job_unknown_for_worker_result_journal_error(
api: Arc<ECStore>, api: Arc<ECStore>,
job_id: Uuid, job_id: Uuid,
expected_lease_id: Option<Uuid>,
error: String, error: String,
queue_snapshot: ManualTransitionQueueSnapshot, queue_snapshot: ManualTransitionQueueSnapshot,
) -> EcstoreResult<ManualTransitionJobRecord> { ) -> EcstoreResult<ManualTransitionJobRecord> {
let mut changed = false; for _ in 0..4 {
let record = update_manual_transition_job_record(api.clone(), job_id, expected_lease_id, |record| { let (mut record, etag) = load_manual_transition_job_record_with_etag(api.clone(), job_id).await?;
changed = record.mark_unknown_for_worker_result_journal_error(error.clone(), queue_snapshot); if !record.mark_unknown_for_worker_result_journal_error(error.clone(), queue_snapshot) {
changed return Ok(record);
}) }
.await?; match save_manual_transition_job_record_if_current(api.clone(), &record, &etag).await {
if changed && record.is_terminal() { Ok(()) => {
delete_manual_transition_scope_admission_if_current(api, &record.scope_key, record.job_id, record.lease_id).await?; delete_manual_transition_scope_admission_if_current(api, &record.scope_key, record.job_id, record.lease_id)
.await?;
return Ok(record);
}
Err(Error::PreconditionFailed) => continue,
Err(err) => return Err(err),
}
} }
Ok(record) Err(Error::PreconditionFailed)
} }
pub async fn save_manual_transition_scope_admission_if_absent( pub async fn save_manual_transition_scope_admission_if_absent(
@@ -1762,14 +1603,19 @@ async fn find_active_legacy_manual_transition_scope_conflict(
} }
pub async fn request_manual_transition_job_cancel(api: Arc<ECStore>, job_id: Uuid) -> EcstoreResult<ManualTransitionJobRecord> { pub async fn request_manual_transition_job_cancel(api: Arc<ECStore>, job_id: Uuid) -> EcstoreResult<ManualTransitionJobRecord> {
update_manual_transition_job_record(api, job_id, None, |record| { for _ in 0..4 {
let (mut record, etag) = load_manual_transition_job_record_with_etag(api.clone(), job_id).await?;
if record.is_terminal() || record.cancel_requested { if record.is_terminal() || record.cancel_requested {
return false; return Ok(record);
} }
record.mark_cancel_requested(); record.mark_cancel_requested();
true match save_manual_transition_job_record_if_current(api.clone(), &record, &etag).await {
}) Ok(()) => return Ok(record),
.await Err(Error::PreconditionFailed) => continue,
Err(err) => return Err(err),
}
}
Err(Error::PreconditionFailed)
} }
pub async fn persist_manual_transition_job_progress( pub async fn persist_manual_transition_job_progress(
@@ -1778,39 +1624,10 @@ pub async fn persist_manual_transition_job_progress(
report: &ManualTransitionRunReport, report: &ManualTransitionRunReport,
queue_snapshot: ManualTransitionQueueSnapshot, queue_snapshot: ManualTransitionQueueSnapshot,
) -> EcstoreResult<ManualTransitionJobRecord> { ) -> EcstoreResult<ManualTransitionJobRecord> {
let current = load_manual_transition_job_record_with_etag(api.clone(), job_id).await?; let (mut record, etag) = load_manual_transition_job_record_with_etag(api.clone(), job_id).await?;
persist_manual_transition_job_progress_inner(api, job_id, current.0.lease_id, Some(current), report, queue_snapshot).await record.update_running_progress(report.clone(), queue_snapshot);
} save_manual_transition_job_record_if_current(api.clone(), &record, &etag).await?;
renew_manual_transition_scope_admission_from_job(api, &record).await?;
pub async fn persist_manual_transition_job_progress_if_owned(
api: Arc<ECStore>,
job_id: Uuid,
expected_lease_id: Uuid,
report: &ManualTransitionRunReport,
queue_snapshot: ManualTransitionQueueSnapshot,
) -> EcstoreResult<ManualTransitionJobRecord> {
persist_manual_transition_job_progress_inner(api, job_id, expected_lease_id, None, report, queue_snapshot).await
}
async fn persist_manual_transition_job_progress_inner(
api: Arc<ECStore>,
job_id: Uuid,
expected_lease_id: Uuid,
current: Option<(ManualTransitionJobRecord, String)>,
report: &ManualTransitionRunReport,
queue_snapshot: ManualTransitionQueueSnapshot,
) -> EcstoreResult<ManualTransitionJobRecord> {
let record = update_manual_transition_job_record_from(api.clone(), job_id, Some(expected_lease_id), current, |record| {
if record.state != ManualTransitionJobState::Running {
return false;
}
record.update_running_progress(report.clone(), queue_snapshot);
true
})
.await?;
if record.state == ManualTransitionJobState::Running {
renew_manual_transition_scope_admission_from_job(api, &record).await?;
}
Ok(record) Ok(record)
} }
@@ -1844,58 +1661,25 @@ pub async fn renew_manual_transition_job_lease(
job_id: Uuid, job_id: Uuid,
queue_snapshot: ManualTransitionQueueSnapshot, queue_snapshot: ManualTransitionQueueSnapshot,
) -> EcstoreResult<ManualTransitionJobRecord> { ) -> EcstoreResult<ManualTransitionJobRecord> {
let current = load_manual_transition_job_record_with_etag(api.clone(), job_id).await?; let (mut record, mut etag) = load_manual_transition_job_record_with_etag(api.clone(), job_id).await?;
renew_manual_transition_job_lease_inner(api, job_id, current.0.lease_id, Some(current), queue_snapshot).await if record.state == ManualTransitionJobState::Running {
} if record.scan_completed && queue_snapshot.queued == 0 && queue_snapshot.active == 0 {
record = reconcile_manual_transition_worker_results(api.clone(), job_id, queue_snapshot).await?;
pub async fn renew_manual_transition_job_lease_if_owned( if record.is_terminal() || !record.report.worker_transition_pending() {
api: Arc<ECStore>, return Ok(record);
job_id: Uuid,
expected_lease_id: Uuid,
queue_snapshot: ManualTransitionQueueSnapshot,
) -> EcstoreResult<ManualTransitionJobRecord> {
renew_manual_transition_job_lease_inner(api, job_id, expected_lease_id, None, queue_snapshot).await
}
async fn renew_manual_transition_job_lease_inner(
api: Arc<ECStore>,
job_id: Uuid,
expected_lease_id: Uuid,
current: Option<(ManualTransitionJobRecord, String)>,
queue_snapshot: ManualTransitionQueueSnapshot,
) -> EcstoreResult<ManualTransitionJobRecord> {
let (current, current_etag) = match current {
Some(current) => current,
None => load_manual_transition_job_record_with_etag(api.clone(), job_id).await?,
};
if current.lease_id != expected_lease_id {
return Err(Error::PreconditionFailed);
}
if current.state != ManualTransitionJobState::Running {
return Ok(current);
}
if current.scan_completed && queue_snapshot.queued == 0 && queue_snapshot.active == 0 {
return reconcile_manual_transition_worker_results_inner(api, job_id, Some(expected_lease_id), queue_snapshot, true)
.await;
}
let record = update_manual_transition_job_record_from(
api.clone(),
job_id,
Some(expected_lease_id),
Some((current, current_etag)),
|record| {
if record.state != ManualTransitionJobState::Running {
return false;
} }
(record, etag) = load_manual_transition_job_record_with_etag(api.clone(), job_id).await?;
}
let became_terminal = record.mark_unknown_if_worker_results_lost(queue_snapshot);
if !became_terminal {
record.renew_lease(queue_snapshot); record.renew_lease(queue_snapshot);
true }
}, save_manual_transition_job_record_if_current(api.clone(), &record, &etag).await?;
) if became_terminal {
.await?; delete_manual_transition_scope_admission_if_current(api, &record.scope_key, record.job_id, record.lease_id).await?;
if record.is_terminal() { } else {
delete_manual_transition_scope_admission_if_current(api, &record.scope_key, record.job_id, record.lease_id).await?; renew_manual_transition_scope_admission_from_job(api, &record).await?;
} else if record.state == ManualTransitionJobState::Running { }
renew_manual_transition_scope_admission_from_job(api, &record).await?;
} }
Ok(record) Ok(record)
} }
@@ -1904,31 +1688,15 @@ async fn renew_manual_transition_scope_admission_from_job(
api: Arc<ECStore>, api: Arc<ECStore>,
record: &ManualTransitionJobRecord, record: &ManualTransitionJobRecord,
) -> EcstoreResult<()> { ) -> EcstoreResult<()> {
for _ in 0..MANUAL_TRANSITION_JOB_CAS_RETRIES { if let Ok((admission, admission_etag)) =
let (admission, admission_etag) = load_manual_transition_scope_admission_with_etag(api.clone(), &record.scope_key).await
match load_manual_transition_scope_admission_with_etag(api.clone(), &record.scope_key).await { && admission.job_id == record.job_id
Ok(admission) => admission, && admission.lease_id == record.lease_id
Err(Error::ConfigNotFound) => return Ok(()), {
Err(err) => return Err(err), let renewed_admission = ManualTransitionScopeAdmission::from_job(record);
}; save_manual_transition_scope_admission_if_current(api, &renewed_admission, &admission_etag).await?;
if admission.job_id != record.job_id || admission.lease_id != record.lease_id {
return Err(Error::PreconditionFailed);
}
let mut renewed_admission = ManualTransitionScopeAdmission::from_job(record);
renewed_admission.lease_expires_at_unix_nanos = renewed_admission
.lease_expires_at_unix_nanos
.max(admission.lease_expires_at_unix_nanos);
renewed_admission.updated_at_unix_nanos = renewed_admission.updated_at_unix_nanos.max(admission.updated_at_unix_nanos);
if renewed_admission == admission {
return Ok(());
}
match save_manual_transition_scope_admission_if_current(api.clone(), &renewed_admission, &admission_etag).await {
Ok(()) => return Ok(()),
Err(Error::PreconditionFailed) => continue,
Err(err) => return Err(err),
}
} }
Err(Error::PreconditionFailed) Ok(())
} }
pub async fn delete_manual_transition_scope_admission_if_current( pub async fn delete_manual_transition_scope_admission_if_current(
@@ -2618,14 +2386,14 @@ mod tests {
} }
#[test] #[test]
fn manual_transition_job_record_control_plane_failure_does_not_count_tier_failure() { fn manual_transition_job_record_failure_counts_tier_failure() {
let options = ManualTransitionRunOptions::default(); let options = ManualTransitionRunOptions::default();
let mut record = ManualTransitionJobRecord::new(Uuid::new_v4(), "bucket", &options, TEST_OWNER); let mut record = ManualTransitionJobRecord::new(Uuid::new_v4(), "bucket", &options, TEST_OWNER);
record.fail("missing tier"); record.fail("missing tier");
assert_eq!(record.state, ManualTransitionJobState::Failed); assert_eq!(record.state, ManualTransitionJobState::Failed);
assert_eq!(record.report.tier_failure, 0); assert_eq!(record.report.tier_failure, 1);
assert_eq!(record.error.as_deref(), Some("missing tier")); assert_eq!(record.error.as_deref(), Some("missing tier"));
} }
@@ -18,7 +18,6 @@ use s3s::dto::{BucketLifecycleConfiguration, ObjectLockConfiguration};
use time::OffsetDateTime; use time::OffsetDateTime;
use uuid::Uuid; use uuid::Uuid;
use crate::bucket::metadata::BucketMetadata;
use crate::bucket::metadata_sys::{self, ObjectLockConfigState}; use crate::bucket::metadata_sys::{self, ObjectLockConfigState};
use crate::error::{Error, Result}; use crate::error::{Error, Result};
@@ -27,37 +26,16 @@ pub(crate) struct LifecycleExpiryConfigs {
pub(crate) lifecycle: Option<Arc<BucketLifecycleConfiguration>>, pub(crate) lifecycle: Option<Arc<BucketLifecycleConfiguration>>,
pub(crate) object_lock: Option<Arc<ObjectLockConfiguration>>, pub(crate) object_lock: Option<Arc<ObjectLockConfiguration>>,
pub(crate) bucket_incarnation_id: Uuid, pub(crate) bucket_incarnation_id: Uuid,
pub(crate) table_bucket_enabled: bool,
} }
async fn get_authoritative_metadata( pub(crate) async fn get_expiry_configs(api: &crate::store::ECStore, bucket: &str) -> Result<LifecycleExpiryConfigs> {
api: &crate::store::ECStore, let bucket_incarnation_id = api.bucket_incarnation_id_from_disk(bucket).await?;
bucket: &str,
bucket_incarnation_id: Uuid,
) -> Result<Arc<BucketMetadata>> {
let sys = metadata_sys::bucket_metadata_sys_of(&api.ctx)?; let sys = metadata_sys::bucket_metadata_sys_of(&api.ctx)?;
let sys = sys.read().await.clone(); let sys = sys.read().await.clone();
let metadata = sys.get_authoritative_metadata(bucket).await?; let metadata = sys.get_authoritative_metadata(bucket).await?;
if !metadata.bucket_incarnation_sidecar || metadata.bucket_incarnation_id != bucket_incarnation_id { if !metadata.bucket_incarnation_sidecar || metadata.bucket_incarnation_id != bucket_incarnation_id {
return Err(Error::other(format!("bucket lifecycle metadata is not authoritative: {bucket}"))); return Err(Error::other(format!("bucket lifecycle metadata is not authoritative: {bucket}")));
} }
Ok(metadata)
}
pub(crate) async fn lifecycle_expiry_allowed(
api: &crate::store::ECStore,
bucket: &str,
bucket_incarnation_id: Uuid,
) -> Result<bool> {
Ok(!get_authoritative_metadata(api, bucket, bucket_incarnation_id)
.await?
.table_bucket_enabled())
}
pub(crate) async fn get_expiry_configs(api: &crate::store::ECStore, bucket: &str) -> Result<LifecycleExpiryConfigs> {
let bucket_incarnation_id = api.bucket_incarnation_id_from_disk(bucket).await?;
let metadata = get_authoritative_metadata(api, bucket, bucket_incarnation_id).await?;
let table_bucket_enabled = metadata.table_bucket_enabled();
let lifecycle = if metadata.lifecycle_config.is_none() && !metadata.lifecycle_config_xml.is_empty() { let lifecycle = if metadata.lifecycle_config.is_none() && !metadata.lifecycle_config_xml.is_empty() {
return Err(Error::other("persisted bucket lifecycle configuration is invalid")); return Err(Error::other("persisted bucket lifecycle configuration is invalid"));
@@ -73,7 +51,6 @@ pub(crate) async fn get_expiry_configs(api: &crate::store::ECStore, bucket: &str
lifecycle: None, lifecycle: None,
object_lock: None, object_lock: None,
bucket_incarnation_id, bucket_incarnation_id,
table_bucket_enabled,
}); });
} }
let object_lock = match metadata_sys::object_lock_config_state_from_authoritative_metadata(&metadata)? { let object_lock = match metadata_sys::object_lock_config_state_from_authoritative_metadata(&metadata)? {
@@ -88,7 +65,6 @@ pub(crate) async fn get_expiry_configs(api: &crate::store::ECStore, bucket: &str
lifecycle, lifecycle,
object_lock, object_lock,
bucket_incarnation_id, bucket_incarnation_id,
table_bucket_enabled,
}) })
} }
@@ -149,7 +125,6 @@ mod tests {
let lifecycle = lifecycle_config(); let lifecycle = lifecycle_config();
metadata.lifecycle_config_xml = crate::bucket::utils::serialize(&lifecycle).unwrap(); metadata.lifecycle_config_xml = crate::bucket::utils::serialize(&lifecycle).unwrap();
metadata.lifecycle_config = Some(lifecycle); metadata.lifecycle_config = Some(lifecycle);
metadata.table_bucket_config_json = br#"{"enabled":true}"#.to_vec();
metadata_sys::set_new_bucket_metadata_in(&store_a.ctx, metadata) metadata_sys::set_new_bucket_metadata_in(&store_a.ctx, metadata)
.await .await
.unwrap(); .unwrap();
@@ -157,14 +132,7 @@ mod tests {
.await .await
.unwrap(); .unwrap();
let configs = get_expiry_configs(&store_a, bucket).await.unwrap(); assert!(get_expiry_configs(&store_a, bucket).await.unwrap().lifecycle.is_some());
assert!(configs.lifecycle.is_some());
assert!(configs.table_bucket_enabled);
assert!(
!lifecycle_expiry_allowed(&store_a, bucket, configs.bucket_incarnation_id)
.await
.unwrap()
);
assert!(get_expiry_configs(&store_b, bucket).await.unwrap().lifecycle.is_none()); assert!(get_expiry_configs(&store_b, bucket).await.unwrap().lifecycle.is_none());
} }
} }
+2 -48
View File
@@ -425,15 +425,6 @@ impl BucketMetadata {
} }
} }
/// Metadata for a physically new user bucket. Existing or fabricated legacy
/// metadata must use [`Self::new`] so upgrades do not rewrite their
/// durability posture.
pub fn new_with_default_durability(name: &str) -> Self {
let mut metadata = Self::new(name);
metadata.durability_config_json = super::durability::new_bucket_durability_config_json();
metadata
}
pub fn save_file_path(&self) -> String { pub fn save_file_path(&self) -> String {
format!("{}/{}/{}", BUCKET_META_PREFIX, self.name.as_str(), BUCKET_METADATA_FILE) format!("{}/{}/{}", BUCKET_META_PREFIX, self.name.as_str(), BUCKET_METADATA_FILE)
} }
@@ -1311,7 +1302,7 @@ mod test {
assert!(bm.object_locking(), "object lock active via parsed config"); assert!(bm.object_locking(), "object lock active via parsed config");
} }
/// backlog#580: KNOWN GAP (flagged 2026-03-06: "inline_data 前缀不同"). RustFS's /// backlog#580: KNOWN GAP (weisd 2026-03-06 "inline_data 前缀不同"). RustFS's
/// inline-data extraction does not yet recover the object body from a /// inline-data extraction does not yet recover the object body from a
/// MinIO-written bucket-metadata object: `into_fileinfo(read_data=true).data` /// MinIO-written bucket-metadata object: `into_fileinfo(read_data=true).data`
/// returns bytes that are not the `.metadata.bin` blob (no `format|version` /// returns bytes that are not the `.metadata.bin` blob (no `format|version`
@@ -1319,7 +1310,7 @@ mod test {
/// inline-data framing is handled on the read path. /// inline-data framing is handled on the read path.
/// backlog#580: prove RustFS reads a MinIO-written **inlined** bucket-metadata /// backlog#580: prove RustFS reads a MinIO-written **inlined** bucket-metadata
/// object end-to-end. MinIO stores inline data as `[bitrot hash][object body]` /// object end-to-end. MinIO stores inline data as `[bitrot hash][object body]`
/// (the "`inline_data` 前缀不同" gap flagged on 2026-03-06 is that /// (the "`inline_data` 前缀不同" that weisd flagged on 2026-03-06 is that
/// bitrot prefix, not a format incompatibility). Running the raw inline shard /// bitrot prefix, not a format incompatibility). Running the raw inline shard
/// through RustFS's `BitrotReader` with the default `HighwayHash256S` must /// through RustFS's `BitrotReader` with the default `HighwayHash256S` must
/// verify the checksum and yield the exact `.metadata.bin` blob. /// verify the checksum and yield the exact `.metadata.bin` blob.
@@ -1387,43 +1378,6 @@ mod test {
assert_ne!(old.bucket_incarnation_id, new.bucket_incarnation_id); assert_ne!(old.bucket_incarnation_id, new.bucket_incarnation_id);
} }
#[test]
fn regular_bucket_metadata_constructor_does_not_seed_durability() {
temp_env::with_var_unset(crate::bucket::durability::ENV_NEW_BUCKET_DURABILITY_MODE, || {
let metadata = BucketMetadata::new("legacy-or-fabricated");
assert!(metadata.durability_config_json.is_empty());
assert!(metadata.durability_config().is_none());
});
}
#[test]
fn new_bucket_metadata_constructor_seeds_default_durability() {
temp_env::with_var_unset(crate::bucket::durability::ENV_NEW_BUCKET_DURABILITY_MODE, || {
let metadata = BucketMetadata::new_with_default_durability("new-user-bucket");
assert_eq!(
metadata.durability_config().and_then(|cfg| cfg.normalized_mode()).as_deref(),
Some(crate::bucket::durability::BUCKET_DURABILITY_MODE_RELAXED)
);
let encoded = metadata.marshal_msg().expect("marshal metadata");
let decoded = BucketMetadata::unmarshal(&encoded).expect("unmarshal metadata");
assert_eq!(decoded.durability_config_json, metadata.durability_config_json);
assert_eq!(
decoded.durability_config().and_then(|cfg| cfg.normalized_mode()).as_deref(),
Some(crate::bucket::durability::BUCKET_DURABILITY_MODE_RELAXED)
);
});
}
#[test]
fn new_bucket_metadata_constructor_can_inherit_global_durability() {
temp_env::with_var(crate::bucket::durability::ENV_NEW_BUCKET_DURABILITY_MODE, Some("inherit"), || {
let metadata = BucketMetadata::new_with_default_durability("strict-fleet-new-bucket");
assert!(metadata.durability_config_json.is_empty());
assert!(metadata.durability_config().is_none());
});
}
#[test] #[test]
fn site_replication_config_updates_cannot_replace_bucket_incarnation() { fn site_replication_config_updates_cannot_replace_bucket_incarnation() {
let mut metadata = BucketMetadata::new("site-replication-update"); let mut metadata = BucketMetadata::new("site-replication-update");
+1 -158
View File
@@ -50,72 +50,6 @@ use uuid::Uuid;
const BUCKET_METADATA_REFRESH_INTERVAL: Duration = Duration::from_secs(15 * 60); const BUCKET_METADATA_REFRESH_INTERVAL: Duration = Duration::from_secs(15 * 60);
#[cfg(any(test, feature = "test-util"))]
struct ConfigWriteLockProbeState {
bucket: String,
arrived: tokio::sync::Notify,
}
#[cfg(any(test, feature = "test-util"))]
static CONFIG_WRITE_LOCK_PROBES: std::sync::OnceLock<StdMutex<Vec<Arc<ConfigWriteLockProbeState>>>> = std::sync::OnceLock::new();
#[cfg(any(test, feature = "test-util"))]
pub struct ConfigWriteLockProbe {
state: Arc<ConfigWriteLockProbeState>,
}
#[cfg(any(test, feature = "test-util"))]
impl ConfigWriteLockProbe {
pub fn install(bucket: &str) -> Self {
let state = Arc::new(ConfigWriteLockProbeState {
bucket: bucket.to_string(),
arrived: tokio::sync::Notify::new(),
});
let mut probes = CONFIG_WRITE_LOCK_PROBES
.get_or_init(|| StdMutex::new(Vec::new()))
.lock()
.expect("config write lock probe mutex should not poison");
assert!(
!probes.iter().any(|current| current.bucket == state.bucket),
"config write lock probe must be unique for a bucket"
);
probes.push(Arc::clone(&state));
drop(probes);
Self { state }
}
pub async fn wait_until_attempted(&self) {
tokio::time::timeout(Duration::from_secs(30), self.state.arrived.notified())
.await
.expect("bucket config update should attempt the transaction lock");
}
}
#[cfg(any(test, feature = "test-util"))]
impl Drop for ConfigWriteLockProbe {
fn drop(&mut self) {
let mut probes = CONFIG_WRITE_LOCK_PROBES
.get_or_init(|| StdMutex::new(Vec::new()))
.lock()
.expect("config write lock probe mutex should not poison");
probes.retain(|state| !Arc::ptr_eq(state, &self.state));
}
}
#[cfg(any(test, feature = "test-util"))]
fn notify_config_write_lock_attempt(bucket: &str) {
let probe = CONFIG_WRITE_LOCK_PROBES
.get_or_init(|| StdMutex::new(Vec::new()))
.lock()
.expect("config write lock probe mutex should not poison")
.iter()
.find(|probe| probe.bucket == bucket)
.cloned();
if let Some(probe) = probe {
probe.arrived.notify_one();
}
}
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
enum MetadataLoadMode { enum MetadataLoadMode {
Initial, Initial,
@@ -354,13 +288,6 @@ pub(crate) fn bucket_metadata_sys_of(ctx: &crate::runtime::instance::InstanceCon
get_bucket_metadata_sys() get_bucket_metadata_sys()
} }
pub(crate) fn require_bucket_metadata_sys_in(
ctx: &crate::runtime::instance::InstanceContext,
) -> Result<Arc<RwLock<BucketMetadataSys>>> {
ctx.bucket_metadata_sys()
.ok_or_else(|| Error::other("bucket metadata sys not initialized for this instance"))
}
pub(crate) async fn object_store_in(ctx: &crate::runtime::instance::InstanceContext) -> Result<Arc<ECStore>> { pub(crate) async fn object_store_in(ctx: &crate::runtime::instance::InstanceContext) -> Result<Arc<ECStore>> {
let sys = bucket_metadata_sys_of(ctx)?; let sys = bucket_metadata_sys_of(ctx)?;
Ok(sys.read().await.api.clone()) Ok(sys.read().await.api.clone())
@@ -449,15 +376,6 @@ pub async fn update(bucket: &str, config_file: &str, data: Vec<u8>) -> Result<Of
Box::pin(update_with_sys(get_bucket_metadata_sys()?, bucket, config_file, data)).await Box::pin(update_with_sys(get_bucket_metadata_sys()?, bucket, config_file, data)).await
} }
pub(crate) async fn update_in(
ctx: &crate::runtime::instance::InstanceContext,
bucket: &str,
config_file: &str,
data: Vec<u8>,
) -> Result<OffsetDateTime> {
Box::pin(update_with_sys(require_bucket_metadata_sys_in(ctx)?, bucket, config_file, data)).await
}
pub async fn delete(bucket: &str, config_file: &str) -> Result<OffsetDateTime> { pub async fn delete(bucket: &str, config_file: &str) -> Result<OffsetDateTime> {
delete_with_sys(get_bucket_metadata_sys()?, bucket, config_file).await delete_with_sys(get_bucket_metadata_sys()?, bucket, config_file).await
} }
@@ -656,31 +574,6 @@ pub async fn update_under_transaction_lock(
update_under_config_write_guard(get_bucket_metadata_sys()?, guard, config_file, data).await update_under_config_write_guard(get_bucket_metadata_sys()?, guard, config_file, data).await
} }
pub async fn update_quota_if_incarnation(
bucket: &str,
data: Vec<u8>,
expected_incarnation_id: Uuid,
proof: &crate::services::notification_sys::CrossPoolFenceFleetProofToken,
) -> Result<OffsetDateTime> {
let sys = get_bucket_metadata_sys()?;
let guard = Box::pin(acquire_config_write_guard_for_incarnation(
sys.clone(),
bucket,
Some(expected_incarnation_id),
))
.await?;
if !crate::services::notification_sys::cross_pool_fence_fleet_proof_matches(proof) {
return Err(Error::NamespaceLockQuorumUnavailable {
mode: "quota_capability",
bucket: bucket.to_string(),
object: rustfs_config::QUOTA_CONFIG_FILE.to_string(),
required: 1,
achieved: 0,
});
}
update_under_config_write_guard(sys, &guard, rustfs_config::QUOTA_CONFIG_FILE, data).await
}
pub async fn update_bucket_targets_under_transaction_lock( pub async fn update_bucket_targets_under_transaction_lock(
guard: &BucketMetadataMutationGuard, guard: &BucketMetadataMutationGuard,
bucket: &str, bucket: &str,
@@ -825,26 +718,7 @@ async fn acquire_transaction_lock_with_sys(
let lock = api let lock = api
.new_ns_lock(RUSTFS_META_BUCKET, &bucket_metadata_transaction_lock_key(bucket)) .new_ns_lock(RUSTFS_META_BUCKET, &bucket_metadata_transaction_lock_key(bucket))
.await?; .await?;
let acquire = lock.get_write_lock(crate::set_disk::get_lock_acquire_timeout()); Ok(lock.get_write_lock(crate::set_disk::get_lock_acquire_timeout()).await?)
#[cfg(any(test, feature = "test-util"))]
{
tokio::pin!(acquire);
let mut notified = false;
let guard = futures::future::poll_fn(|cx| match std::future::Future::poll(acquire.as_mut(), cx) {
std::task::Poll::Pending => {
if !notified {
notify_config_write_lock_attempt(bucket);
notified = true;
}
std::task::Poll::Pending
}
std::task::Poll::Ready(result) => std::task::Poll::Ready(result),
})
.await?;
Ok(guard)
}
#[cfg(not(any(test, feature = "test-util")))]
Ok(acquire.await?)
} }
/// The lock resource name is deliberately still the `bucket-targets` one it /// The lock resource name is deliberately still the `bucket-targets` one it
@@ -999,37 +873,6 @@ pub(crate) async fn get_object_lock_config_and_incarnation_from_disk_in(
} }
} }
/// Re-read the quota configuration and bucket incarnation from the same
/// authoritative metadata blob while the caller holds the bucket metadata
/// transaction read lock.
pub(crate) async fn get_quota_config_and_incarnation_from_disk_in(
ctx: &crate::runtime::instance::InstanceContext,
bucket: &str,
) -> Result<(Option<BucketQuota>, Uuid, OffsetDateTime)> {
let bucket_meta_sys_lock = bucket_metadata_sys_of(ctx)?;
let bucket_meta_sys = bucket_meta_sys_lock.read().await.clone();
match bucket_meta_sys
.read_authoritative_metadata_from_disk_under_transaction_lock(bucket)
.await?
{
BucketMetadataAuthority::Authoritative(metadata)
if metadata.bucket_incarnation_sidecar && !metadata.bucket_incarnation_id.is_nil() =>
{
Ok((
metadata.quota_config.clone(),
metadata.bucket_incarnation_id,
metadata.quota_config_updated_at,
))
}
BucketMetadataAuthority::Authoritative(_) => {
Err(Error::other(format!("bucket incarnation metadata is not authoritative: {bucket}")))
}
BucketMetadataAuthority::MissingBucket => Err(Error::BucketNotFound(bucket.to_string())),
BucketMetadataAuthority::Fabricated => Err(Error::other(format!("bucket quota metadata is not authoritative: {bucket}"))),
}
}
pub async fn get_replication_config(bucket: &str) -> Result<(ReplicationConfiguration, OffsetDateTime)> { pub async fn get_replication_config(bucket: &str) -> Result<(ReplicationConfiguration, OffsetDateTime)> {
let bucket_meta_sys_lock = get_bucket_metadata_sys()?; let bucket_meta_sys_lock = get_bucket_metadata_sys()?;
let bucket_meta_sys = bucket_meta_sys_lock.read().await; let bucket_meta_sys = bucket_meta_sys_lock.read().await;
-5
View File
@@ -14,7 +14,6 @@
use super::metadata_sys::get_bucket_metadata_sys; use super::metadata_sys::get_bucket_metadata_sys;
use crate::error::{Result, StorageError}; use crate::error::{Result, StorageError};
use crate::store::ECStore;
use rustfs_policy::policy::{BucketPolicy, BucketPolicyArgs}; use rustfs_policy::policy::{BucketPolicy, BucketPolicyArgs};
pub struct PolicySys {} pub struct PolicySys {}
@@ -28,10 +27,6 @@ impl PolicySys {
Self::is_allowed_with_policy(args, Self::get(args.bucket).await).await Self::is_allowed_with_policy(args, Self::get(args.bucket).await).await
} }
pub async fn try_is_allowed_for_store(store: &ECStore, args: &BucketPolicyArgs<'_>) -> Result<bool> {
Self::is_allowed_with_policy(args, store.get_bucket_policy(args.bucket).await.map(|(policy, _)| policy)).await
}
async fn is_allowed_with_policy(args: &BucketPolicyArgs<'_>, policy: Result<BucketPolicy>) -> Result<bool> { async fn is_allowed_with_policy(args: &BucketPolicyArgs<'_>, policy: Result<BucketPolicy>) -> Result<bool> {
match policy { match policy {
Ok(policy) => Ok(policy.is_allowed(args).await), Ok(policy) => Ok(policy.is_allowed(args).await),
+2 -38
View File
@@ -52,7 +52,6 @@ impl QuotaChecker {
) -> Result<QuotaCheckResult, QuotaError> { ) -> Result<QuotaCheckResult, QuotaError> {
let start_time = Instant::now(); let start_time = Instant::now();
let quota_config = self.get_quota_config(bucket).await?; let quota_config = self.get_quota_config(bucket).await?;
let uses_durable_reservations = quota_config.uses_durable_reservations();
// If no quota limit is set, allow operation // If no quota limit is set, allow operation
let quota_limit = match quota_config.quota { let quota_limit = match quota_config.quota {
@@ -68,7 +67,6 @@ impl QuotaChecker {
quota_limit: None, quota_limit: None,
operation_size, operation_size,
remaining: None, remaining: None,
uses_durable_reservations,
}); });
} }
Some(q) => q, Some(q) => q,
@@ -76,17 +74,14 @@ impl QuotaChecker {
let current_usage = self.get_real_time_usage(bucket).await?; let current_usage = self.get_real_time_usage(bucket).await?;
let admission_size = if uses_durable_reservations { 0 } else { operation_size };
let expected_usage = match operation { let expected_usage = match operation {
QuotaOperation::PutObject | QuotaOperation::PostObject | QuotaOperation::CopyObject => { QuotaOperation::PutObject | QuotaOperation::PostObject | QuotaOperation::CopyObject => current_usage + operation_size,
current_usage.saturating_add(admission_size)
}
QuotaOperation::DeleteObject => current_usage.saturating_sub(operation_size), QuotaOperation::DeleteObject => current_usage.saturating_sub(operation_size),
}; };
let allowed = match operation { let allowed = match operation {
QuotaOperation::PutObject | QuotaOperation::PostObject | QuotaOperation::CopyObject => { QuotaOperation::PutObject | QuotaOperation::PostObject | QuotaOperation::CopyObject => {
quota_config.check_operation_allowed(current_usage, admission_size) quota_config.check_operation_allowed(current_usage, operation_size)
} }
QuotaOperation::DeleteObject => true, QuotaOperation::DeleteObject => true,
}; };
@@ -110,7 +105,6 @@ impl QuotaChecker {
quota_limit: Some(quota_limit), quota_limit: Some(quota_limit),
operation_size, operation_size,
remaining, remaining,
uses_durable_reservations,
}; };
let duration = start_time.elapsed(); let duration = start_time.elapsed();
@@ -164,26 +158,6 @@ impl QuotaChecker {
.await .await
} }
pub async fn set_durable_quota_config_if_incarnation(
&mut self,
bucket: &str,
quota: BucketQuota,
expected_incarnation_id: uuid::Uuid,
proof: &crate::services::notification_sys::CrossPoolFenceFleetProofToken,
) -> Result<OffsetDateTime, QuotaError> {
let json_data = serde_json::to_vec(&quota).map_err(|e| QuotaError::InvalidConfig {
reason: format!("Failed to serialize quota config: {}", e),
})?;
let start_time = Instant::now();
let updated_at =
crate::bucket::metadata_sys::update_quota_if_incarnation(bucket, json_data, expected_incarnation_id, proof)
.await
.map_err(QuotaError::StorageError)?;
rustfs_common::metrics::Metrics::inc_time(Metric::QuotaSync, start_time.elapsed());
Ok(updated_at)
}
async fn set_quota_config_for_incarnation( async fn set_quota_config_for_incarnation(
&mut self, &mut self,
bucket: &str, bucket: &str,
@@ -381,7 +355,6 @@ mod tests {
quota_limit: None, quota_limit: None,
operation_size: 1024, operation_size: 1024,
remaining: None, remaining: None,
uses_durable_reservations: false,
}; };
assert!(result.allowed); assert!(result.allowed);
@@ -405,13 +378,4 @@ mod tests {
let allowed = quota.check_operation_allowed(512, 1024); let allowed = quota.check_operation_allowed(512, 1024);
assert!(!allowed); assert!(!allowed);
} }
#[test]
fn legacy_quota_rejects_full_operation_while_v1_defers_net_growth() {
let legacy: BucketQuota = serde_json::from_str(r#"{"quota":5}"#).expect("legacy quota should parse");
let durable = BucketQuota::new(Some(5));
assert!(!legacy.check_operation_allowed(4, 2));
assert!(durable.uses_durable_reservations());
}
} }
+8 -134
View File
@@ -13,98 +13,38 @@
// limitations under the License. // limitations under the License.
pub mod checker; pub mod checker;
pub(crate) mod reservation;
use crate::error::Result; use crate::error::Result;
use rustfs_config::{ use rustfs_config::{
QUOTA_API_PATH, QUOTA_EXCEEDED_ERROR_CODE, QUOTA_INTERNAL_ERROR_CODE, QUOTA_INVALID_CONFIG_ERROR_CODE, QUOTA_API_PATH, QUOTA_EXCEEDED_ERROR_CODE, QUOTA_INTERNAL_ERROR_CODE, QUOTA_INVALID_CONFIG_ERROR_CODE,
QUOTA_NOT_FOUND_ERROR_CODE, QUOTA_NOT_FOUND_ERROR_CODE,
}; };
use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as _}; use serde::{Deserialize, Serialize};
use thiserror::Error; use thiserror::Error;
use time::OffsetDateTime; use time::OffsetDateTime;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum QuotaType { pub enum QuotaType {
/// Hard quota accounting. /// Hard quota: reject immediately when exceeded
#[default] #[default]
#[serde(alias = "HARD", alias = "hard")] #[serde(alias = "HARD", alias = "hard")]
Hard, Hard,
} }
pub(crate) const QUOTA_RESERVATION_PROTOCOL_V1: u32 = 1;
/// Bucket quota configuration. quota_type defaults to Hard when omitted. /// Bucket quota configuration. quota_type defaults to Hard when omitted.
#[derive(Debug, Default, Clone, PartialEq)] #[derive(Debug, Deserialize, Serialize, Default, Clone, PartialEq)]
pub struct BucketQuota { pub struct BucketQuota {
#[serde(default)]
pub quota: Option<u64>, pub quota: Option<u64>,
/// Defaults to Hard when missing. /// Defaults to Hard when missing.
#[serde(default)]
pub quota_type: QuotaType, pub quota_type: QuotaType,
/// Optional durable reservation protocol. The wire format gives older
/// nodes a zero hard quota so a mixed-version fleet fails closed.
pub reservation_protocol: Option<u32>,
/// Timestamp when this quota configuration was set (for audit purposes) /// Timestamp when this quota configuration was set (for audit purposes)
#[serde(default, with = "time::serde::rfc3339::option")]
pub created_at: Option<OffsetDateTime>, pub created_at: Option<OffsetDateTime>,
/// Accept updated_at for compatibility; not used. /// Accept updated_at for compatibility; not used.
pub updated_at: Option<OffsetDateTime>,
}
#[derive(Deserialize, Serialize)]
struct BucketQuotaWire {
#[serde(default)]
quota: Option<u64>,
#[serde(default)]
quota_type: QuotaType,
#[serde(default, skip_serializing_if = "Option::is_none")]
reservation_protocol: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
reservation_quota: Option<u64>,
#[serde(default, with = "time::serde::rfc3339::option")]
created_at: Option<OffsetDateTime>,
#[serde(default, with = "time::serde::rfc3339::option", skip_serializing_if = "Option::is_none")] #[serde(default, with = "time::serde::rfc3339::option", skip_serializing_if = "Option::is_none")]
updated_at: Option<OffsetDateTime>, pub updated_at: Option<OffsetDateTime>,
}
impl Serialize for BucketQuota {
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: Serializer,
{
let durable = self.uses_durable_reservations();
BucketQuotaWire {
quota: if durable { Some(0) } else { self.quota },
quota_type: self.quota_type.clone(),
reservation_protocol: self.reservation_protocol,
reservation_quota: if durable { self.quota } else { None },
created_at: self.created_at,
updated_at: self.updated_at,
}
.serialize(serializer)
}
}
impl<'de> Deserialize<'de> for BucketQuota {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let wire = BucketQuotaWire::deserialize(deserializer)?;
let quota = if wire.reservation_protocol == Some(QUOTA_RESERVATION_PROTOCOL_V1) {
Some(
wire.reservation_quota
.ok_or_else(|| D::Error::custom("reservation_quota is required for reservation protocol v1"))?,
)
} else {
wire.quota
};
Ok(Self {
quota,
quota_type: wire.quota_type,
reservation_protocol: wire.reservation_protocol,
created_at: wire.created_at,
updated_at: wire.updated_at,
})
}
} }
impl BucketQuota { impl BucketQuota {
@@ -123,7 +63,6 @@ impl BucketQuota {
Self { Self {
quota, quota,
quota_type: QuotaType::Hard, quota_type: QuotaType::Hard,
reservation_protocol: quota.map(|_| QUOTA_RESERVATION_PROTOCOL_V1),
created_at: Some(now), created_at: Some(now),
updated_at: None, updated_at: None,
} }
@@ -133,19 +72,7 @@ impl BucketQuota {
self.quota self.quota
} }
pub fn uses_durable_reservations(&self) -> bool {
self.reservation_protocol == Some(QUOTA_RESERVATION_PROTOCOL_V1)
}
pub fn has_unsupported_reservation_protocol(&self) -> bool {
self.reservation_protocol
.is_some_and(|version| version != QUOTA_RESERVATION_PROTOCOL_V1)
}
pub fn check_operation_allowed(&self, current_usage: u64, operation_size: u64) -> bool { pub fn check_operation_allowed(&self, current_usage: u64, operation_size: u64) -> bool {
if operation_size == 0 {
return true;
}
if let Some(quota_limit) = self.quota { if let Some(quota_limit) = self.quota {
current_usage.saturating_add(operation_size) <= quota_limit current_usage.saturating_add(operation_size) <= quota_limit
} else { } else {
@@ -167,7 +94,6 @@ pub struct QuotaCheckResult {
pub quota_limit: Option<u64>, pub quota_limit: Option<u64>,
pub operation_size: u64, pub operation_size: u64,
pub remaining: Option<u64>, pub remaining: Option<u64>,
pub uses_durable_reservations: bool,
} }
#[derive(Debug)] #[derive(Debug)]
@@ -284,59 +210,7 @@ mod tests {
let buf = q.marshal_msg().expect("marshal"); let buf = q.marshal_msg().expect("marshal");
let restored = BucketQuota::unmarshal(&buf).expect("unmarshal"); let restored = BucketQuota::unmarshal(&buf).expect("unmarshal");
assert_eq!(q.quota, restored.quota); assert_eq!(q.quota, restored.quota);
assert_eq!(restored.quota_type, QuotaType::Hard); assert_eq!(q.quota_type, restored.quota_type);
assert_eq!(restored.reservation_protocol, Some(QUOTA_RESERVATION_PROTOCOL_V1));
}
#[test]
fn clearing_quota_keeps_the_legacy_compatible_type() {
let quota = BucketQuota::new(None);
assert_eq!(quota.quota_type, QuotaType::Hard);
assert_eq!(quota.reservation_protocol, None);
assert!(!quota.uses_durable_reservations());
}
#[test]
fn durable_quota_makes_legacy_nodes_fail_closed() {
let json = serde_json::to_vec(&BucketQuota::new(Some(2048))).expect("durable quota should serialize");
let quota: BucketQuota = serde_json::from_slice(&json).expect("current quota version should parse");
assert!(quota.uses_durable_reservations());
assert_eq!(quota.quota, Some(2048));
#[derive(Deserialize)]
enum LegacyQuotaType {
Hard,
}
#[derive(Deserialize)]
struct LegacyBucketQuota {
#[allow(dead_code)]
quota: Option<u64>,
#[allow(dead_code)]
quota_type: LegacyQuotaType,
}
let legacy = serde_json::from_slice::<LegacyBucketQuota>(&json)
.expect("legacy readers should ignore the reservation protocol field");
assert_eq!(legacy.quota, Some(0));
assert!(matches!(legacy.quota_type, LegacyQuotaType::Hard));
}
#[test]
fn unknown_reservation_protocol_does_not_activate_v1() {
let quota: BucketQuota =
serde_json::from_str(r#"{"quota":0,"quota_type":"Hard","reservation_protocol":2,"reservation_quota":2048}"#)
.expect("future protocol should remain parseable");
assert!(!quota.uses_durable_reservations());
assert!(quota.has_unsupported_reservation_protocol());
}
#[test]
fn reservation_protocol_v1_requires_reservation_quota() {
let err = serde_json::from_str::<BucketQuota>(r#"{"quota":0,"quota_type":"Hard","reservation_protocol":1}"#)
.expect_err("v1 without its authoritative quota must fail closed");
assert!(err.to_string().contains("reservation_quota is required"));
} }
/// unmarshal accepts format without quota_type /// unmarshal accepts format without quota_type
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,171 @@
// 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.
#![allow(unused_imports)]
#![allow(unused_variables)]
#![allow(unused_mut)]
#![allow(unused_assignments)]
#![allow(unused_must_use)]
#![allow(clippy::all)]
use http::{HeaderMap, StatusCode};
use http_body_util::BodyExt;
use hyper::body::Body;
use hyper::body::Bytes;
use std::collections::HashMap;
use crate::client::{
api_error_response::http_resp_to_error_response,
transition_api::{ReaderImpl, RequestMetadata, TransitionClient},
};
use rustfs_utils::hash::EMPTY_STRING_SHA256_HASH;
impl TransitionClient {
pub async fn set_bucket_policy(&self, bucket_name: &str, policy: &str) -> Result<(), std::io::Error> {
if policy == "" {
return self.remove_bucket_policy(bucket_name).await;
}
self.put_bucket_policy(bucket_name, policy).await
}
pub async fn put_bucket_policy(&self, bucket_name: &str, policy: &str) -> Result<(), std::io::Error> {
let mut url_values = HashMap::new();
url_values.insert("policy".to_string(), "".to_string());
let mut req_metadata = RequestMetadata {
bucket_name: bucket_name.to_string(),
query_values: url_values,
content_body: ReaderImpl::Body(Bytes::from(policy.as_bytes().to_vec())),
content_length: policy.len() as i64,
object_name: "".to_string(),
custom_header: HeaderMap::new(),
content_md5_base64: "".to_string(),
content_sha256_hex: "".to_string(),
stream_sha256: false,
trailer: HeaderMap::new(),
pre_sign_url: Default::default(),
add_crc: Default::default(),
extra_pre_sign_header: Default::default(),
bucket_location: Default::default(),
expires: Default::default(),
};
let resp = self.execute_method(http::Method::PUT, &mut req_metadata).await?;
//defer closeResponse(resp)
let resp_status = resp.status();
let h = resp.headers().clone();
//if resp != nil {
if resp_status != StatusCode::NO_CONTENT && resp.status() != StatusCode::OK {
return Err(std::io::Error::other(http_resp_to_error_response(
resp_status,
&h,
vec![],
bucket_name,
"",
)));
}
//}
Ok(())
}
pub async fn remove_bucket_policy(&self, bucket_name: &str) -> Result<(), std::io::Error> {
let mut url_values = HashMap::new();
url_values.insert("policy".to_string(), "".to_string());
let resp = self
.execute_method(
http::Method::DELETE,
&mut RequestMetadata {
bucket_name: bucket_name.to_string(),
query_values: url_values,
content_sha256_hex: EMPTY_STRING_SHA256_HASH.to_string(),
object_name: "".to_string(),
custom_header: HeaderMap::new(),
content_body: ReaderImpl::Body(Bytes::new()),
content_length: 0,
content_md5_base64: "".to_string(),
stream_sha256: false,
trailer: HeaderMap::new(),
pre_sign_url: Default::default(),
add_crc: Default::default(),
extra_pre_sign_header: Default::default(),
bucket_location: Default::default(),
expires: Default::default(),
},
)
.await?;
//defer closeResponse(resp)
let resp_status = resp.status();
let h = resp.headers().clone();
if resp_status != StatusCode::NO_CONTENT {
return Err(std::io::Error::other(http_resp_to_error_response(
resp_status,
&h,
vec![],
bucket_name,
"",
)));
}
Ok(())
}
pub async fn get_bucket_policy(&self, bucket_name: &str) -> Result<String, std::io::Error> {
let bucket_policy = self.get_bucket_policy_inner(bucket_name).await?;
Ok(bucket_policy)
}
pub async fn get_bucket_policy_inner(&self, bucket_name: &str) -> Result<String, std::io::Error> {
let mut url_values = HashMap::new();
url_values.insert("policy".to_string(), "".to_string());
let resp = self
.execute_method(
http::Method::GET,
&mut RequestMetadata {
bucket_name: bucket_name.to_string(),
query_values: url_values,
content_sha256_hex: EMPTY_STRING_SHA256_HASH.to_string(),
object_name: "".to_string(),
custom_header: HeaderMap::new(),
content_body: ReaderImpl::Body(Bytes::new()),
content_length: 0,
content_md5_base64: "".to_string(),
stream_sha256: false,
trailer: HeaderMap::new(),
pre_sign_url: Default::default(),
add_crc: Default::default(),
extra_pre_sign_header: Default::default(),
bucket_location: Default::default(),
expires: Default::default(),
},
)
.await?;
let mut body_vec = Vec::new();
let mut body = resp.into_body();
while let Some(frame) = body.frame().await {
let frame = frame.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?;
if let Some(data) = frame.data_ref() {
body_vec.extend_from_slice(data);
}
}
let policy = String::from_utf8_lossy(&body_vec).to_string();
Ok(policy)
}
}
@@ -0,0 +1,199 @@
// 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.
#![allow(unused_imports)]
#![allow(unused_variables)]
#![allow(unused_mut)]
#![allow(unused_assignments)]
#![allow(unused_must_use)]
#![allow(clippy::all)]
use crate::client::{
api_error_response::http_resp_to_error_response,
api_get_options::GetObjectOptions,
transition_api::{ObjectInfo, ReaderImpl, RequestMetadata, TransitionClient},
};
use bytes::Bytes;
use http::{HeaderMap, HeaderValue};
use http_body_util::BodyExt;
use rustfs_config::MAX_S3_CLIENT_RESPONSE_SIZE;
use rustfs_utils::EMPTY_STRING_SHA256_HASH;
use s3s::dto::Owner;
use std::collections::HashMap;
#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
pub struct Grantee {
pub id: String,
pub display_name: String,
pub uri: String,
}
#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
pub struct Grant {
pub grantee: Grantee,
pub permission: String,
}
#[derive(Debug, Default, serde::Serialize, serde::Deserialize)]
pub struct AccessControlList {
pub grant: Vec<Grant>,
pub permission: String,
}
#[derive(Debug, Default, serde::Deserialize)]
pub struct AccessControlPolicy {
#[serde(skip)]
owner: Owner,
pub access_control_list: AccessControlList,
}
impl TransitionClient {
pub async fn get_object_acl(&self, bucket_name: &str, object_name: &str) -> Result<ObjectInfo, std::io::Error> {
let mut url_values = HashMap::new();
url_values.insert("acl".to_string(), "".to_string());
let mut resp = self
.execute_method(
http::Method::GET,
&mut RequestMetadata {
bucket_name: bucket_name.to_string(),
object_name: object_name.to_string(),
query_values: url_values,
custom_header: HeaderMap::new(),
content_sha256_hex: EMPTY_STRING_SHA256_HASH.to_string(),
content_body: ReaderImpl::Body(Bytes::new()),
content_length: 0,
content_md5_base64: "".to_string(),
stream_sha256: false,
trailer: HeaderMap::new(),
pre_sign_url: Default::default(),
add_crc: Default::default(),
extra_pre_sign_header: Default::default(),
bucket_location: Default::default(),
expires: Default::default(),
},
)
.await?;
let resp_status = resp.status();
let h = resp.headers().clone();
let mut body_vec = Vec::new();
let mut body = resp.into_body();
while let Some(frame) = body.frame().await {
let frame = frame.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?;
if let Some(data) = frame.data_ref() {
body_vec.extend_from_slice(data);
}
}
if resp_status != http::StatusCode::OK {
return Err(std::io::Error::other(http_resp_to_error_response(
resp_status,
&h,
body_vec,
bucket_name,
object_name,
)));
}
let mut res = match quick_xml::de::from_str::<AccessControlPolicy>(&String::from_utf8(body_vec).unwrap()) {
Ok(result) => result,
Err(err) => {
return Err(std::io::Error::other(err.to_string()));
}
};
let mut obj_info = self
.stat_object(bucket_name, object_name, &GetObjectOptions::default())
.await?;
obj_info.owner.display_name = res.owner.display_name.clone();
obj_info.owner.id = res.owner.id.clone();
//obj_info.grant.extend(res.access_control_list.grant);
let canned_acl = get_canned_acl(&res);
if canned_acl != "" {
obj_info
.metadata
.insert("X-Amz-Acl", HeaderValue::from_str(&canned_acl).unwrap());
return Ok(obj_info);
}
let grant_acl = get_amz_grant_acl(&res);
/*for (k, v) in grant_acl {
obj_info.metadata.insert(HeaderName::from_bytes(k.as_bytes()).unwrap(), HeaderValue::from_str(&v.to_string()).unwrap());
}*/
Ok(obj_info)
}
}
fn get_canned_acl(ac_policy: &AccessControlPolicy) -> String {
let grants = ac_policy.access_control_list.grant.clone();
if grants.len() == 1 {
if grants[0].grantee.uri == "" && grants[0].permission == "FULL_CONTROL" {
return "private".to_string();
}
} else if grants.len() == 2 {
for g in grants {
if g.grantee.uri == "http://acs.amazonaws.com/groups/global/AuthenticatedUsers" && &g.permission == "READ" {
return "authenticated-read".to_string();
}
if g.grantee.uri == "http://acs.amazonaws.com/groups/global/AllUsers" && &g.permission == "READ" {
return "public-read".to_string();
}
if g.permission == "READ" && g.grantee.id == ac_policy.owner.id.clone().unwrap() {
return "bucket-owner-read".to_string();
}
}
} else if grants.len() == 3 {
for g in grants {
if g.grantee.uri == "http://acs.amazonaws.com/groups/global/AllUsers" && g.permission == "WRITE" {
return "public-read-write".to_string();
}
}
}
"".to_string()
}
pub fn get_amz_grant_acl(ac_policy: &AccessControlPolicy) -> HashMap<String, Vec<String>> {
let grants = ac_policy.access_control_list.grant.clone();
let mut res = HashMap::<String, Vec<String>>::new();
for g in grants {
let mut id = "id=".to_string();
id.push_str(&g.grantee.id);
let permission: &str = &g.permission;
match permission {
"READ" => {
res.entry("X-Amz-Grant-Read".to_string()).or_insert(vec![]).push(id);
}
"WRITE" => {
res.entry("X-Amz-Grant-Write".to_string()).or_insert(vec![]).push(id);
}
"READ_ACP" => {
res.entry("X-Amz-Grant-Read-Acp".to_string()).or_insert(vec![]).push(id);
}
"WRITE_ACP" => {
res.entry("X-Amz-Grant-Write-Acp".to_string()).or_insert(vec![]).push(id);
}
"FULL_CONTROL" => {
res.entry("X-Amz-Grant-Full-Control".to_string()).or_insert(vec![]).push(id);
}
_ => (),
}
}
res
}
@@ -0,0 +1,266 @@
// 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.
#![allow(unused_imports)]
#![allow(unused_variables)]
#![allow(unused_mut)]
#![allow(unused_assignments)]
#![allow(unused_must_use)]
#![allow(clippy::all)]
use http::{HeaderMap, HeaderValue};
use std::collections::HashMap;
use time::OffsetDateTime;
use crate::client::constants::{GET_OBJECT_ATTRIBUTES_MAX_PARTS, GET_OBJECT_ATTRIBUTES_TAGS, ISO8601_DATEFORMAT};
use crate::client::{
api_get_object_acl::AccessControlPolicy,
transition_api::{ReaderImpl, RequestMetadata, TransitionClient},
};
use http_body_util::BodyExt;
use hyper::body::Body;
use hyper::body::Bytes;
use hyper::body::Incoming;
use rustfs_config::MAX_S3_CLIENT_RESPONSE_SIZE;
use rustfs_utils::EMPTY_STRING_SHA256_HASH;
use s3s::header::{X_AMZ_MAX_PARTS, X_AMZ_OBJECT_ATTRIBUTES, X_AMZ_PART_NUMBER_MARKER, X_AMZ_VERSION_ID};
pub struct ObjectAttributesOptions {
pub max_parts: i64,
pub version_id: String,
pub part_number_marker: i64,
//server_side_encryption: encrypt::ServerSide,
}
pub struct ObjectAttributes {
pub version_id: String,
pub last_modified: OffsetDateTime,
pub object_attributes_response: ObjectAttributesResponse,
}
impl ObjectAttributes {
fn new() -> Self {
Self {
version_id: "".to_string(),
last_modified: OffsetDateTime::now_utc(),
object_attributes_response: ObjectAttributesResponse::new(),
}
}
}
#[derive(Debug, Default, serde::Deserialize)]
pub struct Checksum {
checksum_crc32: String,
checksum_crc32c: String,
checksum_sha1: String,
checksum_sha256: String,
}
impl Checksum {
fn new() -> Self {
Self {
checksum_crc32: "".to_string(),
checksum_crc32c: "".to_string(),
checksum_sha1: "".to_string(),
checksum_sha256: "".to_string(),
}
}
}
#[derive(Debug, Default, serde::Deserialize)]
pub struct ObjectParts {
pub parts_count: i64,
pub part_number_marker: i64,
pub next_part_number_marker: i64,
pub max_parts: i64,
is_truncated: bool,
parts: Vec<ObjectAttributePart>,
}
impl ObjectParts {
fn new() -> Self {
Self {
parts_count: 0,
part_number_marker: 0,
next_part_number_marker: 0,
max_parts: 0,
is_truncated: false,
parts: Vec::new(),
}
}
}
#[derive(Debug, Default, serde::Deserialize)]
pub struct ObjectAttributesResponse {
pub etag: String,
pub storage_class: String,
pub object_size: i64,
pub checksum: Checksum,
pub object_parts: ObjectParts,
}
impl ObjectAttributesResponse {
fn new() -> Self {
Self {
etag: "".to_string(),
storage_class: "".to_string(),
object_size: 0,
checksum: Checksum::new(),
object_parts: ObjectParts::new(),
}
}
}
#[derive(Debug, Default, serde::Deserialize)]
struct ObjectAttributePart {
checksum_crc32: String,
checksum_crc32c: String,
checksum_sha1: String,
checksum_sha256: String,
part_number: i64,
size: i64,
}
impl ObjectAttributes {
pub async fn parse_response(&mut self, h: &HeaderMap, body_vec: Vec<u8>) -> Result<(), std::io::Error> {
let last_modified = h
.get("Last-Modified")
.ok_or_else(|| std::io::Error::other("missing Last-Modified header"))?
.to_str()
.map_err(|e| std::io::Error::other(format!("invalid Last-Modified header: {e}")))?;
let mod_time = OffsetDateTime::parse(last_modified, ISO8601_DATEFORMAT)
.map_err(|e| std::io::Error::other(format!("invalid Last-Modified date: {e}")))?;
self.last_modified = mod_time;
let version_id = h
.get(X_AMZ_VERSION_ID)
.ok_or_else(|| std::io::Error::other("missing version ID header"))?
.to_str()
.map_err(|e| std::io::Error::other(format!("invalid version ID header: {e}")))?;
self.version_id = version_id.to_string();
let body_str = String::from_utf8(body_vec).map_err(|e| std::io::Error::other(format!("invalid UTF-8 body: {e}")))?;
let mut response = match quick_xml::de::from_str::<ObjectAttributesResponse>(&body_str) {
Ok(result) => result,
Err(err) => {
return Err(std::io::Error::other(err.to_string()));
}
};
self.object_attributes_response = response;
Ok(())
}
}
impl TransitionClient {
pub async fn get_object_attributes(
&self,
bucket_name: &str,
object_name: &str,
opts: ObjectAttributesOptions,
) -> Result<ObjectAttributes, std::io::Error> {
let mut url_values = HashMap::new();
url_values.insert("attributes".to_string(), "".to_string());
if opts.version_id != "" {
url_values.insert("versionId".to_string(), opts.version_id);
}
let mut headers = HeaderMap::new();
headers.insert(
X_AMZ_OBJECT_ATTRIBUTES,
HeaderValue::from_str(GET_OBJECT_ATTRIBUTES_TAGS).expect("valid header value"),
);
if opts.part_number_marker > 0 {
headers.insert(
X_AMZ_PART_NUMBER_MARKER,
HeaderValue::from_str(&opts.part_number_marker.to_string()).expect("valid header value"),
);
}
if opts.max_parts > 0 {
headers.insert(
X_AMZ_MAX_PARTS,
HeaderValue::from_str(&opts.max_parts.to_string()).expect("valid header value"),
);
} else {
headers.insert(
X_AMZ_MAX_PARTS,
HeaderValue::from_str(&GET_OBJECT_ATTRIBUTES_MAX_PARTS.to_string()).expect("valid header value"),
);
}
/*if opts.server_side_encryption.is_some() {
opts.server_side_encryption.Marshal(headers);
}*/
let mut resp = self
.execute_method(
http::Method::HEAD,
&mut RequestMetadata {
bucket_name: bucket_name.to_string(),
object_name: object_name.to_string(),
query_values: url_values,
custom_header: headers,
content_sha256_hex: EMPTY_STRING_SHA256_HASH.to_string(),
content_md5_base64: "".to_string(),
content_body: ReaderImpl::Body(Bytes::new()),
content_length: 0,
stream_sha256: false,
trailer: HeaderMap::new(),
pre_sign_url: Default::default(),
add_crc: Default::default(),
extra_pre_sign_header: Default::default(),
bucket_location: Default::default(),
expires: Default::default(),
},
)
.await?;
let resp_status = resp.status();
let h = resp.headers().clone();
let has_etag = h.get("ETag").and_then(|v| v.to_str().ok()).unwrap_or("");
if !has_etag.is_empty() {
return Err(std::io::Error::other(
"get_object_attributes is not supported by the current endpoint version",
));
}
let mut body_vec = Vec::new();
let mut body = resp.into_body();
while let Some(frame) = body.frame().await {
let frame = frame.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?;
if let Some(data) = frame.data_ref() {
body_vec.extend_from_slice(data);
}
}
if resp_status != http::StatusCode::OK {
let err_body =
String::from_utf8(body_vec).map_err(|e| std::io::Error::other(format!("invalid UTF-8 error body: {e}")))?;
let mut er = match quick_xml::de::from_str::<AccessControlPolicy>(&err_body) {
Ok(result) => result,
Err(err) => {
return Err(std::io::Error::other(err.to_string()));
}
};
return Err(std::io::Error::other(er.access_control_list.permission));
}
let mut oa = ObjectAttributes::new();
oa.parse_response(&h, body_vec).await?;
Ok(oa)
}
}
@@ -0,0 +1,159 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use std::io;
use std::path::{Path, PathBuf};
#[cfg(not(windows))]
use std::os::unix::fs::PermissionsExt;
use tokio::fs::{self, OpenOptions};
use tokio::io::{AsyncSeekExt, AsyncWriteExt, SeekFrom};
use crate::client::{
api_error_response::err_invalid_argument, api_get_options::GetObjectOptions, transition_api::TransitionClient,
};
async fn prepare_download_target(file_path: &Path) -> io::Result<()> {
match fs::metadata(file_path).await {
Ok(metadata) if metadata.is_dir() => {
return Err(io::Error::other(err_invalid_argument("filename is a directory.")));
}
Ok(_) => {}
Err(err) if err.kind() == io::ErrorKind::NotFound => {}
Err(err) => return Err(err),
}
if let Some(parent) = file_path.parent()
&& !parent.as_os_str().is_empty()
{
fs::create_dir_all(parent).await?;
#[cfg(not(windows))]
{
let mut permissions = fs::metadata(parent).await?.permissions();
permissions.set_mode(0o700);
fs::set_permissions(parent, permissions).await?;
}
}
Ok(())
}
fn build_part_path(file_path: &Path) -> PathBuf {
PathBuf::from(format!("{}.part.rustfs", file_path.display()))
}
async fn open_download_part_file(file_part_path: &Path) -> io::Result<tokio::fs::File> {
let mut options = OpenOptions::new();
options.create(true).truncate(false).read(true).write(true);
#[cfg(not(windows))]
options.mode(0o600);
options.open(file_part_path).await
}
async fn cleanup_part_file(file_part_path: &Path) {
let _ = fs::remove_file(file_part_path).await;
}
impl TransitionClient {
pub async fn fget_object(
&self,
bucket_name: &str,
object_name: &str,
file_path: &str,
mut opts: GetObjectOptions,
) -> Result<(), io::Error> {
let file_path = Path::new(file_path);
prepare_download_target(file_path).await?;
let file_part_path = build_part_path(file_path);
let mut file_part = open_download_part_file(&file_part_path).await?;
let existing_len = file_part.metadata().await?.len();
if existing_len > 0 {
opts.set_range(existing_len as i64, 0)?;
file_part.seek(SeekFrom::Start(existing_len)).await?;
}
let (_object_info, _headers, mut object_reader) = self.get_object_inner(bucket_name, object_name, &opts).await?;
if let Err(err) = tokio::io::copy(&mut object_reader, &mut file_part).await {
cleanup_part_file(&file_part_path).await;
return Err(err);
}
if let Err(err) = file_part.flush().await {
cleanup_part_file(&file_part_path).await;
return Err(err);
}
drop(file_part);
if let Err(err) = fs::rename(&file_part_path, file_path).await {
cleanup_part_file(&file_part_path).await;
return Err(err);
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[tokio::test]
async fn prepare_download_target_allows_missing_file_and_creates_parent_dirs() {
let dir = tempdir().expect("temp dir");
let target = dir.path().join("nested").join("object.bin");
prepare_download_target(&target)
.await
.expect("missing target should be accepted");
assert!(target.parent().expect("parent").exists(), "parent directory should be created");
assert!(
fs::metadata(&target).await.is_err(),
"preparing the target should not create the final file eagerly"
);
}
#[tokio::test]
async fn prepare_download_target_rejects_directory_paths() {
let dir = tempdir().expect("temp dir");
let target_dir = dir.path().join("download-dir");
fs::create_dir_all(&target_dir).await.expect("target dir");
let err = prepare_download_target(&target_dir)
.await
.expect_err("directory targets must be rejected");
assert!(err.to_string().contains("directory"), "unexpected error for directory target: {err}");
}
#[tokio::test]
async fn open_download_part_file_creates_part_file() {
let dir = tempdir().expect("temp dir");
let target = dir.path().join("object.bin");
let part_path = build_part_path(&target);
let file = open_download_part_file(&part_path)
.await
.expect("part file should be created");
drop(file);
assert!(part_path.exists(), "part file should exist after creation");
}
}
+134
View File
@@ -0,0 +1,134 @@
// 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.
#![allow(unused_imports)]
#![allow(unused_variables)]
#![allow(unused_mut)]
#![allow(unused_assignments)]
#![allow(unused_must_use)]
#![allow(clippy::all)]
use crate::client::{
api_error_response::{err_invalid_argument, http_resp_to_error_response},
api_get_object_acl::AccessControlList,
api_get_options::GetObjectOptions,
transition_api::{ObjectInfo, ReadCloser, ReaderImpl, RequestMetadata, TransitionClient, to_object_info},
};
use http::HeaderMap;
use http_body_util::BodyExt;
use hyper::body::Body;
use hyper::body::Bytes;
use s3s::dto::RestoreRequest;
use std::collections::HashMap;
use std::io::Cursor;
use tokio::io::BufReader;
const TIER_STANDARD: &str = "Standard";
const TIER_BULK: &str = "Bulk";
const TIER_EXPEDITED: &str = "Expedited";
#[derive(Debug, Default, serde::Serialize, serde::Deserialize)]
pub struct Encryption {
pub encryption_type: String,
pub kms_context: String,
pub kms_key_id: String,
}
#[derive(Debug, Default, serde::Serialize, serde::Deserialize)]
pub struct MetadataEntry {
pub name: String,
pub value: String,
}
#[derive(Debug, Default, serde::Serialize)]
pub struct S3 {
pub access_control_list: AccessControlList,
pub bucket_name: String,
pub prefix: String,
pub canned_acl: String,
pub encryption: Encryption,
pub storage_class: String,
//tagging: Tags,
pub user_metadata: MetadataEntry,
}
impl TransitionClient {
pub async fn restore_object(
&self,
bucket_name: &str,
object_name: &str,
version_id: &str,
restore_req: &RestoreRequest,
) -> Result<(), std::io::Error> {
/*let restore_request = match quick_xml::se::to_string(restore_req) {
Ok(buf) => buf,
Err(e) => {
return Err(std::io::Error::other(e));
}
};*/
let restore_request = "".to_string();
let restore_request_bytes = restore_request.as_bytes().to_vec();
let mut url_values = HashMap::new();
url_values.insert("restore".to_string(), "".to_string());
if version_id != "" {
url_values.insert("versionId".to_string(), version_id.to_string());
}
let restore_request_buffer = Bytes::from(restore_request_bytes.clone());
let resp = self
.execute_method(
http::Method::HEAD,
&mut RequestMetadata {
bucket_name: bucket_name.to_string(),
object_name: object_name.to_string(),
query_values: url_values,
custom_header: HeaderMap::new(),
content_sha256_hex: "".to_string(), //sum_sha256_hex(&restore_request_bytes),
content_md5_base64: "".to_string(), //sum_md5_base64(&restore_request_bytes),
content_body: ReaderImpl::Body(restore_request_buffer),
content_length: restore_request_bytes.len() as i64,
stream_sha256: false,
trailer: HeaderMap::new(),
pre_sign_url: Default::default(),
add_crc: Default::default(),
extra_pre_sign_header: Default::default(),
bucket_location: Default::default(),
expires: Default::default(),
},
)
.await?;
let resp_status = resp.status();
let h = resp.headers().clone();
let mut body_vec = Vec::new();
let mut body = resp.into_body();
while let Some(frame) = body.frame().await {
let frame = frame.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?;
if let Some(data) = frame.data_ref() {
body_vec.extend_from_slice(data);
}
}
if resp_status != http::StatusCode::ACCEPTED && resp_status != http::StatusCode::OK {
return Err(std::io::Error::other(http_resp_to_error_response(
resp_status,
&h,
body_vec,
bucket_name,
"",
)));
}
Ok(())
}
}
+2 -12
View File
@@ -27,24 +27,12 @@ use crate::client::utils::base64_decode;
use crate::client::utils::base64_encode; use crate::client::utils::base64_encode;
use crate::client::{api_put_object::PutObjectOptions, api_s3_datatypes::ObjectPart}; use crate::client::{api_put_object::PutObjectOptions, api_s3_datatypes::ObjectPart};
use crate::{disk::DiskAPI, object_api::GetObjectReader}; use crate::{disk::DiskAPI, object_api::GetObjectReader};
// s3s::header has no CRC64NVME constant yet; the canonical RustFS copy lives
// in rustfs-utils' headers module.
use rustfs_utils::http::headers::AMZ_CHECKSUM_CRC64NVME;
use s3s::header::{ use s3s::header::{
X_AMZ_CHECKSUM_ALGORITHM, X_AMZ_CHECKSUM_CRC32, X_AMZ_CHECKSUM_CRC32C, X_AMZ_CHECKSUM_SHA1, X_AMZ_CHECKSUM_SHA256, X_AMZ_CHECKSUM_ALGORITHM, X_AMZ_CHECKSUM_CRC32, X_AMZ_CHECKSUM_CRC32C, X_AMZ_CHECKSUM_SHA1, X_AMZ_CHECKSUM_SHA256,
}; };
use enumset::{EnumSet, EnumSetType, enum_set}; use enumset::{EnumSet, EnumSetType, enum_set};
/// One of three deliberately separate checksum registries (backlog#1833):
/// this enum is the MinIO-port client's wire vocabulary and stops at the
/// standard S3 set (CRC64NVME is its newest member; the RustFS extensions do
/// not exist on this client path). The streaming-hash registry lives in
/// `rustfs_checksums::ChecksumAlgorithm` (crates/checksums/src/lib.rs) and
/// the on-disk xl.meta bitset in `rustfs_rio::ChecksumType`
/// (crates/rio/src/checksum.rs, varint bits are append-only). When adding an
/// algorithm, extend all three (or record why not) — they do not derive from
/// each other.
#[derive(Debug, EnumSetType, Default)] #[derive(Debug, EnumSetType, Default)]
#[enumset(repr = "u8")] #[enumset(repr = "u8")]
pub enum ChecksumMode { pub enum ChecksumMode {
@@ -69,6 +57,8 @@ lazy_static! {
static ref C_ChecksumFullObjectCRC32C: EnumSet<ChecksumMode> = static ref C_ChecksumFullObjectCRC32C: EnumSet<ChecksumMode> =
enum_set!(ChecksumMode::ChecksumCRC32C | ChecksumMode::ChecksumFullObject); enum_set!(ChecksumMode::ChecksumCRC32C | ChecksumMode::ChecksumFullObject);
} }
const AMZ_CHECKSUM_CRC64NVME: &str = "x-amz-checksum-crc64nvme";
impl ChecksumMode { impl ChecksumMode {
//pub const CRC64_NVME_POLYNOMIAL: i64 = 0xad93d23594c93659; //pub const CRC64_NVME_POLYNOMIAL: i64 = 0xad93d23594c93659;
+3
View File
@@ -37,3 +37,6 @@ pub const TOTAL_WORKERS: i64 = 4;
pub const SIGN_V4_ALGORITHM: &str = "AWS4-HMAC-SHA256"; pub const SIGN_V4_ALGORITHM: &str = "AWS4-HMAC-SHA256";
pub const ISO8601_DATEFORMAT: &[FormatItem<'_>] = pub const ISO8601_DATEFORMAT: &[FormatItem<'_>] =
format_description!("[year]-[month]-[day]T[hour]:[minute]:[second].[subsecond]Z"); format_description!("[year]-[month]-[day]T[hour]:[minute]:[second].[subsecond]Z");
pub const GET_OBJECT_ATTRIBUTES_TAGS: &str = "ETag,Checksum,StorageClass,ObjectSize,ObjectParts";
pub const GET_OBJECT_ATTRIBUTES_MAX_PARTS: i64 = 1000;
+5
View File
@@ -16,8 +16,12 @@
#![allow(dead_code)] #![allow(dead_code)]
pub mod admin_handler_utils; pub mod admin_handler_utils;
pub mod api_bucket_policy;
pub mod api_error_response; pub mod api_error_response;
pub mod api_get_object; pub mod api_get_object;
pub mod api_get_object_acl;
pub mod api_get_object_attributes;
pub mod api_get_object_file;
pub mod api_get_options; pub mod api_get_options;
pub mod api_list; pub mod api_list;
pub mod api_put_object; pub mod api_put_object;
@@ -25,6 +29,7 @@ pub mod api_put_object_common;
pub mod api_put_object_multipart; pub mod api_put_object_multipart;
pub mod api_put_object_streaming; pub mod api_put_object_streaming;
pub mod api_remove; pub mod api_remove;
pub mod api_restore;
pub mod api_s3_datatypes; pub mod api_s3_datatypes;
pub mod api_stat; pub mod api_stat;
pub mod bucket_cache; pub mod bucket_cache;
@@ -1006,6 +1006,16 @@ impl TransitionCore {
client.abort_multipart_upload(bucket_name, object, upload_id).await client.abort_multipart_upload(bucket_name, object, upload_id).await
} }
pub async fn get_bucket_policy(&self, bucket_name: &str) -> Result<String, std::io::Error> {
let client = self.0.clone();
client.get_bucket_policy(bucket_name).await
}
pub async fn put_bucket_policy(&self, bucket_name: &str, bucket_policy: &str) -> Result<(), std::io::Error> {
let client = self.0.clone();
client.put_bucket_policy(bucket_name, bucket_policy).await
}
pub async fn get_object( pub async fn get_object(
&self, &self,
bucket_name: &str, bucket_name: &str,
+1
View File
@@ -13,6 +13,7 @@
// limitations under the License. // limitations under the License.
// #730: cluster/RPC migration leaves transport capabilities staged for upcoming owners. // #730: cluster/RPC migration leaves transport capabilities staged for upcoming owners.
#![allow(dead_code)]
mod control_plane; mod control_plane;
pub(crate) mod rpc; pub(crate) mod rpc;
-1
View File
@@ -256,7 +256,6 @@ impl<S> ReplayScopeChannel<S> {
} }
} }
#[allow(dead_code, reason = "replay-state probe asserted by this file's tests (backlog#1823)")]
fn peer_replay_state(audience: &str) -> PeerReplayState { fn peer_replay_state(audience: &str) -> PeerReplayState {
PEER_REPLAY_STATES PEER_REPLAY_STATES
.lock() .lock()
@@ -31,7 +31,7 @@ use rustfs_config::{
DEFAULT_INTERNODE_DATA_TRANSPORT, ENV_RUSTFS_INTERNODE_DATA_TRANSPORT, INTERNODE_DATA_TRANSPORT_TCP, DEFAULT_INTERNODE_DATA_TRANSPORT, ENV_RUSTFS_INTERNODE_DATA_TRANSPORT, INTERNODE_DATA_TRANSPORT_TCP,
KNOWN_INTERNODE_DATA_TRANSPORT_BACKENDS, KNOWN_INTERNODE_DATA_TRANSPORT_BACKENDS,
}; };
use rustfs_rio::{ChunkReaderBox, HttpChunkReader, HttpReader, HttpWriter}; use rustfs_rio::{HttpReader, HttpWriter};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use std::collections::HashMap; use std::collections::HashMap;
use std::future::Future; use std::future::Future;
@@ -43,10 +43,6 @@ use tokio::io::{AsyncReadExt, AsyncWrite};
use tokio::sync::OnceCell; use tokio::sync::OnceCell;
use uuid::Uuid; use uuid::Uuid;
#[allow(
dead_code,
reason = "live in the cfg(not(test)) half of build_internode_data_transport_from_env (backlog#1823)"
)]
static INTERNODE_DATA_TRANSPORT: OnceLock<std::result::Result<Arc<dyn InternodeDataTransport>, String>> = OnceLock::new(); static INTERNODE_DATA_TRANSPORT: OnceLock<std::result::Result<Arc<dyn InternodeDataTransport>, String>> = OnceLock::new();
const READ_FILE_STREAM_PATH: &str = "/rustfs/rpc/read_file_stream"; const READ_FILE_STREAM_PATH: &str = "/rustfs/rpc/read_file_stream";
@@ -138,10 +134,6 @@ fn put_file_capability_status_is_legacy(status: u16) -> bool {
} }
#[derive(Debug, Clone, Copy, Eq, PartialEq)] #[derive(Debug, Clone, Copy, Eq, PartialEq)]
#[allow(
dead_code,
reason = "capability-negotiation seam; constructed only by transport test doubles (backlog#1823)"
)]
pub struct InternodeDataTransportCapabilities { pub struct InternodeDataTransportCapabilities {
/// Backend can open a streaming remote disk reader. /// Backend can open a streaming remote disk reader.
pub streaming_read: bool, pub streaming_read: bool,
@@ -158,10 +150,6 @@ pub struct InternodeDataTransportCapabilities {
} }
impl InternodeDataTransportCapabilities { impl InternodeDataTransportCapabilities {
#[allow(
dead_code,
reason = "capability-negotiation seam; used by transport test doubles (backlog#1823)"
)]
pub const fn tcp_http() -> Self { pub const fn tcp_http() -> Self {
Self { Self {
streaming_read: true, streaming_read: true,
@@ -233,11 +221,6 @@ pub struct NsScannerCapabilityRequest {
#[async_trait] #[async_trait]
pub trait InternodeDataTransport: Send + Sync + std::fmt::Debug { pub trait InternodeDataTransport: Send + Sync + std::fmt::Debug {
async fn open_read(&self, request: ReadStreamRequest) -> Result<FileReader>; async fn open_read(&self, request: ReadStreamRequest) -> Result<FileReader>;
/// Opens an owned-chunk stream when this transport can retain receive-buffer
/// ownership. `None` preserves the established `open_read` fallback.
async fn open_read_chunks(&self, _request: ReadStreamRequest) -> Result<Option<ChunkReaderBox>> {
Ok(None)
}
async fn open_write(&self, request: WriteStreamRequest) -> Result<FileWriter>; async fn open_write(&self, request: WriteStreamRequest) -> Result<FileWriter>;
async fn open_walk_dir(&self, request: WalkDirStreamRequest) -> Result<FileReader>; async fn open_walk_dir(&self, request: WalkDirStreamRequest) -> Result<FileReader>;
async fn open_ns_scanner(&self, _request: NsScannerStreamRequest) -> Result<FileReader> { async fn open_ns_scanner(&self, _request: NsScannerStreamRequest) -> Result<FileReader> {
@@ -246,12 +229,7 @@ pub trait InternodeDataTransport: Send + Sync + std::fmt::Debug {
async fn probe_ns_scanner(&self, _request: NsScannerCapabilityRequest) -> Result<Uuid> { async fn probe_ns_scanner(&self, _request: NsScannerCapabilityRequest) -> Result<Uuid> {
Err(Error::MethodNotAllowed) Err(Error::MethodNotAllowed)
} }
// Interface facet nobody calls yet: every transport implements both, but no
// caller negotiates on them. Kept for the internode transport split
// (backlog#1350); deleting them would delete the seam and six impls.
#[allow(dead_code, reason = "unused capability-negotiation facet (backlog#1823)")]
fn name(&self) -> &'static str; fn name(&self) -> &'static str;
#[allow(dead_code, reason = "unused capability-negotiation facet (backlog#1823)")]
fn capabilities(&self) -> InternodeDataTransportCapabilities; fn capabilities(&self) -> InternodeDataTransportCapabilities;
} }
@@ -269,15 +247,6 @@ impl InternodeDataTransport for TcpHttpInternodeDataTransport {
)) ))
} }
async fn open_read_chunks(&self, request: ReadStreamRequest) -> Result<Option<ChunkReaderBox>> {
let url = build_read_file_stream_url(&request);
let mut headers = json_headers();
build_auth_headers(&url, &Method::GET, &mut headers)?;
Ok(Some(Box::new(
HttpChunkReader::new_with_stall_timeout(url, Method::GET, headers, None, request.stall_timeout).await?,
)))
}
async fn open_write(&self, request: WriteStreamRequest) -> Result<FileWriter> { async fn open_write(&self, request: WriteStreamRequest) -> Result<FileWriter> {
let server_epoch = self.put_file_auth_capability(&request.endpoint).await?; let server_epoch = self.put_file_auth_capability(&request.endpoint).await?;
let nonce = server_epoch.map(|_| Uuid::new_v4()); let nonce = server_epoch.map(|_| Uuid::new_v4());
@@ -687,10 +656,6 @@ fn build_internode_data_transport_result(
} }
} }
#[allow(
dead_code,
reason = "live in the cfg(test) half of build_internode_data_transport_from_env, which bypasses the process static (backlog#1823)"
)]
pub fn build_internode_data_transport(configured_transport: Option<&str>) -> Result<Arc<dyn InternodeDataTransport>> { pub fn build_internode_data_transport(configured_transport: Option<&str>) -> Result<Arc<dyn InternodeDataTransport>> {
build_internode_data_transport_result(configured_transport).map_err(Error::other) build_internode_data_transport_result(configured_transport).map_err(Error::other)
} }
@@ -248,16 +248,6 @@ fn decode_remote_version_state_capability(expected_member: &str, result: &[u8])
Ok(server_epoch) Ok(server_epoch)
} }
fn decode_cross_pool_fence_capability(expected_member: &str, result: &[u8]) -> Result<(u32, Uuid)> {
let version = result
.get(..4)
.and_then(|value| value.try_into().ok())
.map(u32::from_be_bytes)
.ok_or_else(|| Error::other("peer returned an invalid cross-pool fence capability version"))?;
let epoch = decode_remote_version_state_capability(expected_member, &result[4..])?;
Ok((version, epoch))
}
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct PeerLiveEventsBatch { pub struct PeerLiveEventsBatch {
pub events: Vec<u8>, pub events: Vec<u8>,
@@ -1298,16 +1288,6 @@ impl PeerRestClient {
Ok((self.topology_member.clone(), epoch)) Ok((self.topology_member.clone(), epoch))
} }
pub async fn probe_cross_pool_fence(&self, topology_fingerprint: String) -> Result<(String, u32, Uuid)> {
let mut probe = rustfs_protos::CROSS_POOL_FENCE_CAPABILITY_PROBE_PREFIX.to_vec();
probe.extend_from_slice(Uuid::new_v4().as_bytes());
let result = self
.heal_control(rustfs_protos::HEAL_CONTROL_PROTOCOL_VERSION, topology_fingerprint, probe)
.await?;
let (supported_version, epoch) = decode_cross_pool_fence_capability(&self.topology_member, &result)?;
Ok((self.topology_member.clone(), supported_version, epoch))
}
pub async fn load_bucket_metadata(&self, bucket: &str, scanner_maintenance_change: bool) -> Result<()> { pub async fn load_bucket_metadata(&self, bucket: &str, scanner_maintenance_change: bool) -> Result<()> {
self.finalize_result( self.finalize_result(
async { async {
@@ -2758,24 +2738,6 @@ mod tests {
assert!(decode_remote_version_state_capability("node-a:9000", &nil).is_err()); assert!(decode_remote_version_state_capability("node-a:9000", &nil).is_err());
} }
#[test]
fn cross_pool_fence_capability_decoder_fails_closed() {
let epoch = Uuid::new_v4();
let result = rustfs_protos::encode_cross_pool_fence_capability(1, "node-a:9000", epoch.as_bytes())
.expect("small capability response should encode");
assert_eq!(
decode_cross_pool_fence_capability("node-a:9000", &result).expect("valid capability should decode"),
(1, epoch)
);
for malformed in [&[][..], &[0, 0, 0][..], &result[..result.len() - 1]] {
assert!(decode_cross_pool_fence_capability("node-a:9000", malformed).is_err());
}
assert!(decode_cross_pool_fence_capability("node-b:9000", &result).is_err());
let nil = rustfs_protos::encode_cross_pool_fence_capability(1, "node-a:9000", Uuid::nil().as_bytes())
.expect("small capability response should encode");
assert!(decode_cross_pool_fence_capability("node-a:9000", &nil).is_err());
}
struct TierMutationResponseFixture<'a> { struct TierMutationResponseFixture<'a> {
version: u32, version: u32,
phase: TierMutationRpcPhase, phase: TierMutationRpcPhase,
@@ -854,6 +854,7 @@ impl PeerS3Client for LocalPeerS3Client {
#[derive(Debug)] #[derive(Debug)]
pub struct RemotePeerS3Client { pub struct RemotePeerS3Client {
pub node: Option<Node>,
pub pools: Option<Vec<usize>>, pub pools: Option<Vec<usize>>,
addr: String, addr: String,
/// Health tracker for connection monitoring /// Health tracker for connection monitoring
@@ -885,6 +886,7 @@ impl RemotePeerS3Client {
pub fn new(node: Option<Node>, pools: Option<Vec<usize>>) -> Self { pub fn new(node: Option<Node>, pools: Option<Vec<usize>>) -> Self {
let addr = node.as_ref().map(|v| v.url.to_string()).unwrap_or_default(); let addr = node.as_ref().map(|v| v.url.to_string()).unwrap_or_default();
let client = Self { let client = Self {
node,
pools, pools,
addr, addr,
health: Arc::new(DiskHealthTracker::new()), health: Arc::new(DiskHealthTracker::new()),
@@ -903,6 +905,10 @@ impl RemotePeerS3Client {
.map_err(|err| Error::other(format!("can not get client, err: {err}"))) .map_err(|err| Error::other(format!("can not get client, err: {err}")))
} }
pub fn get_addr(&self) -> String {
self.addr.clone()
}
/// Start health monitoring for the remote peer /// Start health monitoring for the remote peer
fn start_health_monitoring(&self) { fn start_health_monitoring(&self) {
let health = Arc::clone(&self.health); let health = Arc::clone(&self.health);
@@ -1202,10 +1208,6 @@ impl PeerS3Client for RemotePeerS3Client {
} }
} }
#[allow(
dead_code,
reason = "local bucket-heal path reached only by this file's tests (backlog#1823)"
)]
pub async fn heal_bucket_local(bucket: &str, opts: &HealOpts) -> Result<HealResultItem> { pub async fn heal_bucket_local(bucket: &str, opts: &HealOpts) -> Result<HealResultItem> {
let disks = clone_drives().await; let disks = clone_drives().await;
heal_bucket_local_on_disks(bucket, opts, disks).await heal_bucket_local_on_disks(bucket, opts, disks).await
@@ -1402,10 +1404,6 @@ pub(crate) async fn heal_bucket_local_on_disks(
} }
} }
#[allow(
dead_code,
reason = "reached only through heal_bucket_local, which only tests call (backlog#1823)"
)]
async fn clone_drives() -> Vec<Option<DiskStore>> { async fn clone_drives() -> Vec<Option<DiskStore>> {
runtime_sources::local_disk_entries().await runtime_sources::local_disk_entries().await
} }
@@ -1587,7 +1585,15 @@ mod tests {
} }
fn test_remote_peer(addr: &str) -> RemotePeerS3Client { fn test_remote_peer(addr: &str) -> RemotePeerS3Client {
let node = Node {
url: url::Url::parse(addr).expect("test peer URL should parse"),
pools: vec![0],
is_local: false,
grid_host: addr.to_string(),
};
RemotePeerS3Client { RemotePeerS3Client {
node: Some(node),
pools: Some(vec![0]), pools: Some(vec![0]),
addr: addr.to_string(), addr: addr.to_string(),
health: Arc::new(DiskHealthTracker::new()), health: Arc::new(DiskHealthTracker::new()),
+78 -661
View File
@@ -522,33 +522,6 @@ impl RemoteDisk {
} }
} }
async fn open_read_chunks_with_retry(&self, request: ReadStreamRequest) -> Result<Option<rustfs_rio::ChunkReaderBox>> {
let mut attempt = 1;
let mut last_retry_classification = None;
loop {
match self.data_transport.open_read_chunks(request.clone()).await {
Ok(reader) => {
if attempt > 1
&& let Some(classification) = last_retry_classification
{
crate::cluster::rpc::runtime_sources::record_remote_disk_open_read_retry_success(classification);
}
return Ok(reader);
}
Err(err) if attempt < REMOTE_DISK_OPEN_READ_MAX_ATTEMPTS && Self::is_retryable_open_read_error(&err) => {
if let Some(classification) = err.internode_http_error_kind() {
let classification = classification.metric_label();
crate::cluster::rpc::runtime_sources::record_remote_disk_open_read_retry(classification);
last_retry_classification = Some(classification);
}
tokio::time::sleep(REMOTE_DISK_OPEN_READ_RETRY_BACKOFF).await;
attempt += 1;
}
Err(err) => return Err(err),
}
}
}
pub fn record_capacity_probe(&self, total: u64, used: u64, free: u64) { pub fn record_capacity_probe(&self, total: u64, used: u64, free: u64) {
self.health.record_capacity_probe(total, used, free); self.health.record_capacity_probe(total, used, free);
} }
@@ -873,49 +846,31 @@ impl RemoteDisk {
/// default to 1 (see [`internode_idempotent_read_retries`]). MUST NOT be used for write/lock /// default to 1 (see [`internode_idempotent_read_retries`]). MUST NOT be used for write/lock
/// RPCs — those must never auto-retry (quorum/idempotency safety). The `operation` closure is /// RPCs — those must never auto-retry (quorum/idempotency safety). The `operation` closure is
/// re-invoked per attempt, so it must be `Fn` (rebuild the request from borrowed inputs, do not /// re-invoked per attempt, so it must be `Fn` (rebuild the request from borrowed inputs, do not
/// move captured state out). Attempts and backoff share one total timeout budget. /// move captured state out).
async fn execute_read_with_retry<T, F, Fut>(&self, op: &'static str, operation: F, timeout_duration: Duration) -> Result<T> async fn execute_read_with_retry<T, F, Fut>(&self, op: &'static str, operation: F, timeout_duration: Duration) -> Result<T>
where where
F: Fn() -> Fut, F: Fn() -> Fut,
Fut: std::future::Future<Output = Result<T>>, Fut: std::future::Future<Output = Result<T>>,
{ {
let deadline = (!timeout_duration.is_zero()).then(|| {
time::Instant::now()
.checked_add(timeout_duration)
.unwrap_or_else(|| time::sleep(timeout_duration).deadline())
});
let max_retries = internode_idempotent_read_retries(); let max_retries = internode_idempotent_read_retries();
let mut attempt = 0usize; let mut attempt = 0usize;
loop { loop {
let attempt_timeout = deadline // Only the final attempt marks the disk faulty / evicts the channel. Earlier retries
.map(|deadline| deadline.saturating_duration_since(time::Instant::now())) // ignore the failure, so a transient error cannot flip the disk into a faulty
.unwrap_or(Duration::ZERO); // short-circuit (which would defeat the retry) or over-count failures.
if deadline.is_some() && attempt_timeout.is_zero() {
self.record_timeout(op, timeout_duration);
return Err(DiskError::Timeout);
}
let health_action = if attempt >= max_retries { let health_action = if attempt >= max_retries {
FailureHealthAction::MarkFailure FailureHealthAction::MarkFailure
} else { } else {
FailureHealthAction::IgnoreFailure FailureHealthAction::IgnoreFailure
}; };
match self match self
.execute_with_timeout_for_op_and_health_action(op, &operation, attempt_timeout, health_action) .execute_with_timeout_for_op_and_health_action(op, &operation, timeout_duration, health_action)
.await .await
{ {
Err(err) if attempt < max_retries && is_network_like_disk_error(&err) => { Err(err) if attempt < max_retries && is_network_like_disk_error(&err) => {
if matches!(err, DiskError::Timeout) && deadline.is_some_and(|deadline| time::Instant::now() >= deadline) {
self.mark_faulty("read_operation_deadline");
return Err(err);
}
attempt += 1; attempt += 1;
let backoff = REMOTE_DISK_READ_RETRY_BASE_BACKOFF let backoff = REMOTE_DISK_READ_RETRY_BASE_BACKOFF
.saturating_mul(1u32 << u32::try_from(attempt - 1).unwrap_or(4).min(4)); .saturating_mul(1u32 << u32::try_from(attempt - 1).unwrap_or(4).min(4));
if deadline.is_some_and(|deadline| deadline.saturating_duration_since(time::Instant::now()) <= backoff) {
attempt = max_retries;
continue;
}
debug!( debug!(
endpoint = %self.endpoint, endpoint = %self.endpoint,
addr = %self.addr, addr = %self.addr,
@@ -923,17 +878,7 @@ impl RemoteDisk {
attempt, attempt,
"retrying idempotent read-only RPC after transient network error" "retrying idempotent read-only RPC after transient network error"
); );
if let Some(deadline) = deadline { tokio::time::sleep(backoff).await;
if time::timeout_at(deadline, time::sleep(backoff)).await.is_err() {
self.record_timeout(op, timeout_duration);
return Err(DiskError::Timeout);
}
} else {
time::sleep(backoff).await;
}
if self.health.is_faulty() {
return Err(DiskError::FaultyDisk);
}
} }
other => return other, other => return other,
} }
@@ -1012,35 +957,32 @@ impl RemoteDisk {
operation_result operation_result
} }
Err(_) => { Err(_) => {
self.record_timeout(op, timeout_duration); // Timeout occurred, mark disk as potentially faulty
counter!(
"rustfs_drive_op_timeout_total",
"endpoint" => self.endpoint.to_string(),
"op" => op.to_string()
)
.increment(1);
if failure_health_action == FailureHealthAction::MarkFailure { if failure_health_action == FailureHealthAction::MarkFailure {
self.mark_faulty_and_evict("operation_timeout").await; self.mark_faulty_and_evict("operation_timeout").await;
} }
warn!(
event = EVENT_REMOTE_DISK_RPC,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_REMOTE_DISK,
endpoint = %self.endpoint,
addr = %self.addr,
op,
timeout_ms = timeout_duration.as_millis(),
state = "timeout",
"Remote disk operation timed out"
);
Err(DiskError::Timeout) Err(DiskError::Timeout)
} }
} }
} }
fn record_timeout(&self, op: &'static str, timeout_duration: Duration) {
counter!(
"rustfs_drive_op_timeout_total",
"endpoint" => self.endpoint.to_string(),
"op" => op.to_string()
)
.increment(1);
warn!(
event = EVENT_REMOTE_DISK_RPC,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_REMOTE_DISK,
endpoint = %self.endpoint,
addr = %self.addr,
op,
timeout_ms = timeout_duration.as_millis(),
state = "timeout",
"Remote disk operation timed out"
);
}
async fn handle_network_like_error<T>( async fn handle_network_like_error<T>(
&self, &self,
op: &'static str, op: &'static str,
@@ -1074,7 +1016,7 @@ impl RemoteDisk {
} }
} }
fn mark_faulty(&self, reason: &'static str) -> bool { async fn mark_faulty_and_evict(&self, reason: &'static str) {
let previous_state = self.runtime_state(); let previous_state = self.runtime_state();
let transitioned_to_offline = self.mark_suspect_or_offline(reason); let transitioned_to_offline = self.mark_suspect_or_offline(reason);
let state = self.runtime_state(); let state = self.runtime_state();
@@ -1111,12 +1053,6 @@ impl RemoteDisk {
"Remote disk marked suspect" "Remote disk marked suspect"
); );
} }
}
state != previous_state
}
async fn mark_faulty_and_evict(&self, reason: &'static str) {
if self.mark_faulty(reason) {
counter!( counter!(
"rustfs_drive_connection_evict_total", "rustfs_drive_connection_evict_total",
"endpoint" => self.endpoint.to_string(), "endpoint" => self.endpoint.to_string(),
@@ -1359,71 +1295,6 @@ fn validate_decoded_file_info(file_info: &FileInfo) -> Result<()> {
file_info.validate_for_metadata_read().map_err(Into::into) file_info.validate_for_metadata_read().map_err(Into::into)
} }
impl RemoteDisk {
#[tracing::instrument(level = "trace", skip_all)]
pub(crate) async fn rename_data_borrowed(
&self,
src_volume: &str,
src_path: &str,
fi: &FileInfo,
dst_volume: &str,
dst_path: &str,
) -> Result<RenameDataResp> {
trace!(
event = EVENT_REMOTE_DISK_RPC,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_REMOTE_DISK,
endpoint = %self.endpoint,
src_volume,
src_path,
dst_volume,
dst_path,
op = "rename_data",
state = "started",
"Remote disk RPC started"
);
self.execute_with_timeout_for_op(
"rename_data",
|| async {
let file_info = compat_json(fi)?;
let file_info_bin = encode_file_info_msgpack(fi)?;
let mut client = self
.get_client()
.await
.map_err(|err| Error::other(format!("can not get client, err: {err}")))?;
let mut request = Request::new(RenameDataRequest {
disk: self.endpoint.to_string(),
src_volume: src_volume.to_string(),
src_path: src_path.to_string(),
file_info,
dst_volume: dst_volume.to_string(),
dst_path: dst_path.to_string(),
file_info_bin: file_info_bin.into(),
});
let canonical_body = rustfs_protos::canonical_rename_data_request_body(request.get_ref());
attach_mutation_body_digest(&mut request, canonical_body, "rename_data")?;
let response = client.rename_data(request).await?.into_inner();
if !response.success {
return Err(response.error.unwrap_or_default().into());
}
let rename_data_resp = decode_msgpack_or_json::<RenameDataResp>(
&response.rename_data_resp_bin,
&response.rename_data_resp,
"RenameDataResp",
)?;
Ok(rename_data_resp)
},
get_max_timeout_duration(),
)
.await
}
}
#[async_trait::async_trait] #[async_trait::async_trait]
impl DiskAPI for RemoteDisk { impl DiskAPI for RemoteDisk {
#[tracing::instrument(level = "trace", skip_all)] #[tracing::instrument(level = "trace", skip_all)]
@@ -2197,7 +2068,7 @@ impl DiskAPI for RemoteDisk {
Ok(file_info) Ok(file_info)
}, },
get_drive_metadata_timeout(), get_max_timeout_duration(),
) )
.await .await
} }
@@ -2349,8 +2220,58 @@ impl DiskAPI for RemoteDisk {
dst_volume: &str, dst_volume: &str,
dst_path: &str, dst_path: &str,
) -> Result<RenameDataResp> { ) -> Result<RenameDataResp> {
self.rename_data_borrowed(src_volume, src_path, &fi, dst_volume, dst_path) trace!(
.await event = EVENT_REMOTE_DISK_RPC,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_REMOTE_DISK,
endpoint = %self.endpoint,
src_volume,
src_path,
dst_volume,
dst_path,
op = "rename_data",
state = "started",
"Remote disk RPC started"
);
self.execute_with_timeout_for_op(
"rename_data",
|| async {
let file_info = compat_json(&fi)?;
let file_info_bin = encode_file_info_msgpack(&fi)?;
let mut client = self
.get_client()
.await
.map_err(|err| Error::other(format!("can not get client, err: {err}")))?;
let mut request = Request::new(RenameDataRequest {
disk: self.endpoint.to_string(),
src_volume: src_volume.to_string(),
src_path: src_path.to_string(),
file_info,
dst_volume: dst_volume.to_string(),
dst_path: dst_path.to_string(),
file_info_bin: file_info_bin.into(),
});
let canonical_body = rustfs_protos::canonical_rename_data_request_body(request.get_ref());
attach_mutation_body_digest(&mut request, canonical_body, "rename_data")?;
let response = client.rename_data(request).await?.into_inner();
if !response.success {
return Err(response.error.unwrap_or_default().into());
}
let rename_data_resp = decode_msgpack_or_json::<RenameDataResp>(
&response.rename_data_resp_bin,
&response.rename_data_resp,
"RenameDataResp",
)?;
Ok(rename_data_resp)
},
get_max_timeout_duration(),
)
.await
} }
#[tracing::instrument(level = "trace", skip_all)] #[tracing::instrument(level = "trace", skip_all)]
@@ -2496,30 +2417,6 @@ impl DiskAPI for RemoteDisk {
.await .await
} }
async fn read_file_stream_chunks(
&self,
volume: &str,
path: &str,
offset: usize,
length: usize,
) -> Result<Option<rustfs_rio::ChunkReaderBox>> {
if self.health.is_faulty() {
return Err(DiskError::FaultyDisk);
}
let disk = self.disk_ref().await;
let stall_timeout = get_object_disk_read_timeout();
self.open_read_chunks_with_retry(ReadStreamRequest {
endpoint: self.endpoint.grid_host(),
disk,
volume: volume.to_string(),
path: path.to_string(),
offset,
length,
stall_timeout: (!stall_timeout.is_zero()).then_some(stall_timeout),
})
.await
}
/// Buffered read for remote disks. /// Buffered read for remote disks.
/// The transport stream is collected into owned Bytes for caller sharing. /// The transport stream is collected into owned Bytes for caller sharing.
#[tracing::instrument(level = "trace", skip_all)] #[tracing::instrument(level = "trace", skip_all)]
@@ -5197,452 +5094,6 @@ mod tests {
); );
} }
#[tokio::test(start_paused = true)]
#[serial(remote_disk_read_retry)]
async fn execute_read_with_retry_reset_during_backoff_preserves_recovery() {
let remote_disk = Arc::new(new_remote_disk_with_transport(Arc::new(RecordingInternodeDataTransport::default())).await);
let attempts = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let first_attempt = Arc::new(tokio::sync::Notify::new());
let started = time::Instant::now();
let task_disk = Arc::clone(&remote_disk);
let task_attempts = Arc::clone(&attempts);
let task_first_attempt = Arc::clone(&first_attempt);
let task = tokio::spawn(async move {
task_disk
.execute_read_with_retry(
"read_version",
move || {
let attempt = task_attempts.fetch_add(1, Ordering::SeqCst);
let first_attempt = Arc::clone(&task_first_attempt);
async move {
if attempt == 0 {
time::sleep(Duration::from_millis(20)).await;
first_attempt.notify_one();
return Err::<(), Error>(DiskError::Io(std_io::Error::new(
std_io::ErrorKind::ConnectionRefused,
"connection refused",
)));
}
Ok(())
}
},
Duration::from_millis(100),
)
.await
});
first_attempt.notified().await;
tokio::task::yield_now().await;
remote_disk.health.reset_for_store_init_retry(&remote_disk.endpoint);
let channel = TonicEndpoint::from_shared(remote_disk.addr.clone())
.expect("remote disk address should parse")
.connect_lazy();
runtime_sources::cache_test_node_channel(remote_disk.addr.clone(), channel).await;
task.await
.expect("retry task should finish")
.expect("the retry should succeed after the health reset");
assert_eq!(attempts.load(Ordering::SeqCst), 2);
assert_eq!(started.elapsed(), Duration::from_millis(70));
assert_eq!(
remote_disk.health.waiting_count(),
0,
"health reset must not underflow the waiting counter"
);
assert_eq!(remote_disk.runtime_state(), RuntimeDriveHealthState::Online);
assert!(
runtime_sources::test_node_channel_is_cached(&remote_disk.addr).await,
"a recovered channel must survive the retry backoff"
);
remote_disk.cancel_token.cancel();
}
#[tokio::test(start_paused = true)]
#[serial(remote_disk_read_retry)]
async fn execute_read_with_retry_still_retries_within_shared_deadline() {
let remote_disk = new_remote_disk_with_transport(Arc::new(RecordingInternodeDataTransport::default())).await;
let attempts = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let channel = TonicEndpoint::from_shared(remote_disk.addr.clone())
.expect("remote disk address should parse")
.connect_lazy();
runtime_sources::cache_test_node_channel(remote_disk.addr.clone(), channel).await;
remote_disk
.execute_read_with_retry(
"read_version",
|| {
let attempt = attempts.fetch_add(1, Ordering::SeqCst);
async move {
if attempt == 0 {
return Err::<(), Error>(DiskError::Io(std_io::Error::new(
std_io::ErrorKind::ConnectionReset,
"connection reset",
)));
}
Ok(())
}
},
Duration::from_millis(100),
)
.await
.expect("a retry that fits the shared deadline should succeed");
assert_eq!(attempts.load(Ordering::SeqCst), 2);
assert_eq!(remote_disk.runtime_state(), RuntimeDriveHealthState::Online);
assert!(runtime_sources::test_node_channel_is_cached(&remote_disk.addr).await);
remote_disk.cancel_token.cancel();
}
#[tokio::test(start_paused = true)]
#[serial(remote_disk_read_retry)]
async fn execute_read_with_retry_uses_remaining_budget_for_final_attempt() {
let remote_disk = new_remote_disk_with_transport(Arc::new(RecordingInternodeDataTransport::default())).await;
let attempts = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let started = time::Instant::now();
let channel = TonicEndpoint::from_shared(remote_disk.addr.clone())
.expect("remote disk address should parse")
.connect_lazy();
runtime_sources::cache_test_node_channel(remote_disk.addr.clone(), channel).await;
let err = remote_disk
.execute_read_with_retry(
"read_version",
|| {
let attempt = attempts.fetch_add(1, Ordering::SeqCst);
async move {
if attempt == 0 {
time::sleep(Duration::from_millis(20)).await;
return Err::<(), Error>(DiskError::Io(std_io::Error::new(
std_io::ErrorKind::ConnectionRefused,
"connection refused",
)));
}
std::future::pending::<Result<()>>().await
}
},
Duration::from_millis(100),
)
.await
.expect_err("the final retry should consume only the remaining total budget");
assert_eq!(err, DiskError::Timeout);
assert_eq!(attempts.load(Ordering::SeqCst), 2);
assert_eq!(started.elapsed(), Duration::from_millis(100));
assert_eq!(remote_disk.runtime_state(), RuntimeDriveHealthState::Suspect);
assert!(!runtime_sources::test_node_channel_is_cached(&remote_disk.addr).await);
remote_disk.cancel_token.cancel();
}
#[tokio::test(start_paused = true)]
#[serial(remote_disk_read_retry)]
async fn execute_read_with_retry_uses_final_attempt_at_exact_backoff_boundary() {
let remote_disk = new_remote_disk_with_transport(Arc::new(RecordingInternodeDataTransport::default())).await;
let attempts = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let started = time::Instant::now();
let err = remote_disk
.execute_read_with_retry(
"read_version",
|| {
let attempt = attempts.fetch_add(1, Ordering::SeqCst);
async move {
if attempt == 0 {
time::sleep(Duration::from_millis(50)).await;
return Err::<(), Error>(DiskError::Io(std_io::Error::new(
std_io::ErrorKind::ConnectionRefused,
"connection refused",
)));
}
std::future::pending::<Result<()>>().await
}
},
Duration::from_millis(100),
)
.await
.expect_err("the exact backoff boundary should be reserved for a final attempt");
assert_eq!(err, DiskError::Timeout);
assert_eq!(attempts.load(Ordering::SeqCst), 2);
assert_eq!(started.elapsed(), Duration::from_millis(100));
assert_eq!(remote_disk.runtime_state(), RuntimeDriveHealthState::Suspect);
remote_disk.cancel_token.cancel();
}
#[tokio::test(start_paused = true)]
#[serial(remote_disk_read_retry)]
async fn execute_read_with_retry_uses_final_attempt_below_backoff_budget() {
let remote_disk = new_remote_disk_with_transport(Arc::new(RecordingInternodeDataTransport::default())).await;
let attempts = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let started = time::Instant::now();
let err = remote_disk
.execute_read_with_retry(
"read_version",
|| {
let attempt = attempts.fetch_add(1, Ordering::SeqCst);
async move {
if attempt == 0 {
time::sleep(Duration::from_millis(80)).await;
return Err::<(), Error>(DiskError::Io(std_io::Error::new(
std_io::ErrorKind::ConnectionRefused,
"connection refused",
)));
}
std::future::pending::<Result<()>>().await
}
},
Duration::from_millis(100),
)
.await
.expect_err("remaining budget below backoff should be reserved for a final attempt");
assert_eq!(err, DiskError::Timeout);
assert_eq!(attempts.load(Ordering::SeqCst), 2);
assert_eq!(started.elapsed(), Duration::from_millis(100));
assert_eq!(remote_disk.runtime_state(), RuntimeDriveHealthState::Suspect);
remote_disk.cancel_token.cancel();
}
#[tokio::test(start_paused = true)]
#[serial(remote_disk_read_retry)]
async fn execute_read_with_retry_zero_timeout_disables_the_deadline() {
let remote_disk = new_remote_disk_with_transport(Arc::new(RecordingInternodeDataTransport::default())).await;
let attempts = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let started = time::Instant::now();
remote_disk
.execute_read_with_retry(
"read_version",
|| {
let attempt = attempts.fetch_add(1, Ordering::SeqCst);
async move {
if attempt == 0 {
return Err::<(), Error>(DiskError::Io(std_io::Error::new(
std_io::ErrorKind::ConnectionReset,
"connection reset",
)));
}
Ok(())
}
},
Duration::ZERO,
)
.await
.expect("zero timeout should allow a retry without a deadline");
assert_eq!(attempts.load(Ordering::SeqCst), 2);
assert_eq!(started.elapsed(), REMOTE_DISK_READ_RETRY_BASE_BACKOFF);
remote_disk.cancel_token.cancel();
}
#[tokio::test]
#[serial(remote_disk_read_retry)]
async fn execute_read_with_retry_accepts_max_metadata_timeout() {
temp_env::async_with_vars([(rustfs_config::ENV_DRIVE_METADATA_TIMEOUT_SECS, Some(u64::MAX.to_string()))], async {
let remote_disk = new_remote_disk_with_transport(Arc::new(RecordingInternodeDataTransport::default())).await;
remote_disk
.execute_read_with_retry("read_version", || async { Ok::<(), Error>(()) }, get_drive_metadata_timeout())
.await
.expect("the maximum configured metadata timeout must not panic");
remote_disk.cancel_token.cancel();
})
.await;
}
#[tokio::test(start_paused = true)]
#[serial(remote_disk_read_retry)]
async fn execute_read_with_retry_zero_retries_runs_once() {
temp_env::async_with_vars([(rustfs_config::ENV_INTERNODE_IDEMPOTENT_READ_RETRIES, Some("0"))], async {
let remote_disk = new_remote_disk_with_transport(Arc::new(RecordingInternodeDataTransport::default())).await;
let attempts = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let started = time::Instant::now();
let channel = TonicEndpoint::from_shared(remote_disk.addr.clone())
.expect("remote disk address should parse")
.connect_lazy();
runtime_sources::cache_test_node_channel(remote_disk.addr.clone(), channel).await;
let err = remote_disk
.execute_read_with_retry(
"read_version",
|| {
attempts.fetch_add(1, Ordering::SeqCst);
async {
Err::<(), Error>(DiskError::Io(std_io::Error::new(
std_io::ErrorKind::ConnectionReset,
"connection reset",
)))
}
},
Duration::from_secs(1),
)
.await
.expect_err("zero retries should return the first network error");
assert!(matches!(err, DiskError::Io(ref io_err) if io_err.kind() == std_io::ErrorKind::ConnectionReset));
assert_eq!(attempts.load(Ordering::SeqCst), 1);
assert_eq!(started.elapsed(), Duration::ZERO);
assert_eq!(remote_disk.runtime_state(), RuntimeDriveHealthState::Suspect);
assert!(!runtime_sources::test_node_channel_is_cached(&remote_disk.addr).await);
remote_disk.cancel_token.cancel();
})
.await;
}
#[tokio::test(start_paused = true)]
#[serial(remote_disk_read_retry)]
async fn execute_read_with_retry_attempt_timeout_marks_health_without_evicting() {
let remote_disk = new_remote_disk_with_transport(Arc::new(RecordingInternodeDataTransport::default())).await;
let recorder = crate::test_metrics::CapturingRecorder::default();
let _recorder_guard = metrics::set_default_local_recorder(&recorder);
let attempts = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let channel = TonicEndpoint::from_shared(remote_disk.addr.clone())
.expect("remote disk address should parse")
.connect_lazy();
runtime_sources::cache_test_node_channel(remote_disk.addr.clone(), channel).await;
let err = remote_disk
.execute_read_with_retry(
"read_version",
|| {
attempts.fetch_add(1, Ordering::SeqCst);
std::future::pending::<Result<()>>()
},
Duration::from_millis(100),
)
.await
.expect_err("an in-flight attempt that consumes the deadline should time out");
assert_eq!(err, DiskError::Timeout);
assert_eq!(attempts.load(Ordering::SeqCst), 1);
assert_eq!(remote_disk.runtime_state(), RuntimeDriveHealthState::Suspect);
assert!(runtime_sources::test_node_channel_is_cached(&remote_disk.addr).await);
assert_eq!(
recorder.counter_value(
"rustfs_drive_op_timeout_total",
&[
("endpoint", remote_disk.endpoint.to_string().as_str()),
("op", "read_version")
]
),
1
);
remote_disk.cancel_token.cancel();
}
#[tokio::test(start_paused = true)]
#[serial(remote_disk_read_retry)]
async fn execute_read_with_retry_does_not_retry_business_errors() {
let remote_disk = new_remote_disk_with_transport(Arc::new(RecordingInternodeDataTransport::default())).await;
let attempts = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let err = remote_disk
.execute_read_with_retry(
"read_version",
|| {
attempts.fetch_add(1, Ordering::SeqCst);
async { Err::<(), Error>(DiskError::FileNotFound) }
},
Duration::from_secs(1),
)
.await
.expect_err("business errors should be returned directly");
assert_eq!(err, DiskError::FileNotFound);
assert_eq!(attempts.load(Ordering::SeqCst), 1);
remote_disk.cancel_token.cancel();
}
#[tokio::test(start_paused = true)]
#[serial(remote_disk_read_retry)]
async fn execute_read_with_retry_honors_configured_retry_count() {
temp_env::async_with_vars([(rustfs_config::ENV_INTERNODE_IDEMPOTENT_READ_RETRIES, Some("2"))], async {
let remote_disk = new_remote_disk_with_transport(Arc::new(RecordingInternodeDataTransport::default())).await;
let attempts = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let started = time::Instant::now();
let channel = TonicEndpoint::from_shared(remote_disk.addr.clone())
.expect("remote disk address should parse")
.connect_lazy();
runtime_sources::cache_test_node_channel(remote_disk.addr.clone(), channel).await;
let err = remote_disk
.execute_read_with_retry(
"read_version",
|| {
attempts.fetch_add(1, Ordering::SeqCst);
async {
Err::<(), Error>(DiskError::Io(std_io::Error::new(
std_io::ErrorKind::ConnectionReset,
"connection reset",
)))
}
},
Duration::from_secs(1),
)
.await
.expect_err("exhausted retries should return the last network error");
assert!(matches!(err, DiskError::Io(ref io_err) if io_err.kind() == std_io::ErrorKind::ConnectionReset));
assert_eq!(attempts.load(Ordering::SeqCst), 3);
assert_eq!(started.elapsed(), Duration::from_millis(150));
assert_eq!(remote_disk.runtime_state(), RuntimeDriveHealthState::Suspect);
assert!(!runtime_sources::test_node_channel_is_cached(&remote_disk.addr).await);
remote_disk.cancel_token.cancel();
})
.await;
}
#[tokio::test(start_paused = true)]
#[serial(remote_disk_read_retry)]
async fn execute_read_with_retry_stops_when_disk_turns_offline_during_backoff() {
let remote_disk = Arc::new(new_remote_disk_with_transport(Arc::new(RecordingInternodeDataTransport::default())).await);
let attempts = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let first_attempt = Arc::new(tokio::sync::Notify::new());
let task_disk = Arc::clone(&remote_disk);
let task_attempts = Arc::clone(&attempts);
let task_first_attempt = Arc::clone(&first_attempt);
let task = tokio::spawn(async move {
task_disk
.execute_read_with_retry(
"read_version",
move || {
let attempt = task_attempts.fetch_add(1, Ordering::SeqCst);
let first_attempt = Arc::clone(&task_first_attempt);
async move {
if attempt == 0 {
first_attempt.notify_one();
return Err::<(), Error>(DiskError::Io(std_io::Error::new(
std_io::ErrorKind::ConnectionReset,
"connection reset",
)));
}
Ok(())
}
},
Duration::from_secs(1),
)
.await
});
first_attempt.notified().await;
tokio::task::yield_now().await;
remote_disk
.health
.force_runtime_state_for_test(RuntimeDriveHealthState::Offline);
time::advance(REMOTE_DISK_READ_RETRY_BASE_BACKOFF).await;
let err = task
.await
.expect("retry task should finish")
.expect_err("an offline disk must stop before the next attempt");
assert_eq!(err, DiskError::FaultyDisk);
assert_eq!(attempts.load(Ordering::SeqCst), 1);
remote_disk.cancel_token.cancel();
}
#[tokio::test] #[tokio::test]
async fn test_execute_with_timeout_evicts_cached_connection() { async fn test_execute_with_timeout_evicts_cached_connection() {
let addr = "http://127.0.0.1:59991".to_string(); let addr = "http://127.0.0.1:59991".to_string();
@@ -6152,40 +5603,6 @@ mod tests {
accept_task.abort(); accept_task.abort();
} }
#[tokio::test]
async fn read_version_uses_the_metadata_timeout_on_a_stalled_peer() {
runtime_sources::ensure_test_rpc_secret();
let Some((base_addr, accept_task)) = spawn_stalled_grpc_peer().await else {
return;
};
let remote_disk = remote_disk_for_addr(&base_addr).await;
temp_env::async_with_vars(
[
(rustfs_config::ENV_DRIVE_METADATA_TIMEOUT_SECS, Some("1")),
(rustfs_config::ENV_DRIVE_MAX_TIMEOUT_DURATION, Some("10")),
],
async {
let started = time::Instant::now();
let err = tokio::time::timeout(
Duration::from_secs(5),
remote_disk.read_version("bucket", "bucket", "object", "", &ReadOptions::default()),
)
.await
.expect("read_version must use the shorter metadata deadline")
.expect_err("a stalled peer must fail read_version");
assert!(matches!(err, DiskError::Timeout), "expected the metadata deadline to fire, got {err:?}");
assert!(started.elapsed() >= Duration::from_millis(900));
assert!(started.elapsed() < Duration::from_secs(2));
},
)
.await;
remote_disk.cancel_token.cancel();
accept_task.abort();
}
#[tokio::test] #[tokio::test]
async fn delete_volume_bounds_the_wait_on_a_stalled_peer() { async fn delete_volume_bounds_the_wait_on_a_stalled_peer() {
runtime_sources::ensure_test_rpc_secret(); runtime_sources::ensure_test_rpc_secret();
@@ -48,6 +48,10 @@ impl RemoteClient {
Self { addr: endpoint } Self { addr: endpoint }
} }
pub fn from_url(url: url::Url) -> Self {
Self { addr: url.to_string() }
}
fn build_ping_request() -> PingRequest { fn build_ping_request() -> PingRequest {
let mut fbb = flatbuffers::FlatBufferBuilder::new(); let mut fbb = flatbuffers::FlatBufferBuilder::new();
let payload = fbb.create_vector(b"health-check"); let payload = fbb.create_vector(b"health-check");
+3
View File
@@ -46,6 +46,7 @@ use rustfs_config::{
SCANNER_SUB_SYS, SCANNER_SUB_SYS,
}; };
use rustfs_filemeta::FileInfo; use rustfs_filemeta::FileInfo;
use rustfs_utils::path::SLASH_SEPARATOR;
use serde_json::{Map, Value}; use serde_json::{Map, Value};
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::sync::LazyLock; use std::sync::LazyLock;
@@ -199,6 +200,8 @@ pub const STORAGE_CLASS_SUB_SYS: &str = "storage_class";
pub const COMMA_SEPARATED_LISTS: &[&str] = &[rustfs_config::oidc::OIDC_SCOPES, rustfs_config::oidc::OIDC_OTHER_AUDIENCES]; pub const COMMA_SEPARATED_LISTS: &[&str] = &[rustfs_config::oidc::OIDC_SCOPES, rustfs_config::oidc::OIDC_OTHER_AUDIENCES];
static CONFIG_BUCKET: LazyLock<String> = LazyLock::new(|| format!("{RUSTFS_META_BUCKET}{SLASH_SEPARATOR}{CONFIG_PREFIX}"));
type ServerConfigDecryptFn = crate::bucket::migration::LegacyBlobDecryptFn; type ServerConfigDecryptFn = crate::bucket::migration::LegacyBlobDecryptFn;
static SERVER_CONFIG_DECRYPT_FN: LazyLock<RwLock<Option<ServerConfigDecryptFn>>> = LazyLock::new(|| RwLock::new(None)); static SERVER_CONFIG_DECRYPT_FN: LazyLock<RwLock<Option<ServerConfigDecryptFn>>> = LazyLock::new(|| RwLock::new(None));
+1
View File
@@ -13,6 +13,7 @@
// limitations under the License. // limitations under the License.
// #730: configuration migration keeps legacy subsystem definitions available behind this module. // #730: configuration migration keeps legacy subsystem definitions available behind this module.
#![allow(dead_code)]
mod audit; mod audit;
pub mod com; pub mod com;
+20 -70
View File
@@ -101,7 +101,6 @@ const DEFAULT_RRS_STORAGE_CLASS: &str = "EC:1";
const ZERO_SET_DRIVE_COUNT_ERROR: &str = "set drive count must be greater than zero"; const ZERO_SET_DRIVE_COUNT_ERROR: &str = "set drive count must be greater than zero";
pub static DEFAULT_INLINE_BLOCK: usize = 128 * 1024; pub static DEFAULT_INLINE_BLOCK: usize = 128 * 1024;
const DEFAULT_INLINE_OBJECT_BUDGET: usize = 2 * DEFAULT_INLINE_BLOCK;
pub static DEFAULT_KVS: LazyLock<KVS> = LazyLock::new(|| { pub static DEFAULT_KVS: LazyLock<KVS> = LazyLock::new(|| {
let kvs = vec![ let kvs = vec![
@@ -151,8 +150,6 @@ pub struct Config {
optimize: Option<String>, optimize: Option<String>,
inline_block: usize, inline_block: usize,
initialized: bool, initialized: bool,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
inline_block_explicit: bool,
#[serde(skip)] #[serde(skip)]
standard_parities: Vec<PoolParity>, standard_parities: Vec<PoolParity>,
#[serde(skip)] #[serde(skip)]
@@ -189,10 +186,6 @@ impl Config {
/// A topology-bound lookup fails closed for unknown drive counts and for /// A topology-bound lookup fails closed for unknown drive counts and for
/// deserialized legacy configurations that have no pool topology. Legacy /// deserialized legacy configurations that have no pool topology. Legacy
/// callers retain scalar compatibility through [`Self::get_parity_for_sc`]. /// callers retain scalar compatibility through [`Self::get_parity_for_sc`].
#[allow(
dead_code,
reason = "per-set parity resolution asserted by this file's tests (backlog#1823)"
)]
pub(crate) fn parity_for_sc(&self, sc: &str, drives_per_set: usize) -> Option<usize> { pub(crate) fn parity_for_sc(&self, sc: &str, drives_per_set: usize) -> Option<usize> {
if !self.initialized { if !self.initialized {
return None; return None;
@@ -240,19 +233,17 @@ impl Config {
.map(|(pool_index, pool)| (pool_index, pool.drives_per_set)) .map(|(pool_index, pool)| (pool_index, pool.drives_per_set))
} }
pub fn should_inline(&self, shard_size: i64, data_shards: usize, versioned: bool) -> bool { pub fn should_inline(&self, shard_size: i64, versioned: bool) -> bool {
if shard_size < 0 || data_shards == 0 { if shard_size < 0 {
return false; return false;
} }
let shard_size = shard_size as usize; let shard_size = shard_size as usize;
// Keep the historical two-data-shard object budget while preventing
// wider EC layouts from multiplying the maximum inline object size. let mut inline_block = DEFAULT_INLINE_BLOCK;
let inline_block = if self.initialized && self.inline_block_explicit { if self.initialized {
self.inline_block inline_block = self.inline_block;
} else { }
(DEFAULT_INLINE_OBJECT_BUDGET / data_shards).min(DEFAULT_INLINE_BLOCK)
};
if versioned { if versioned {
shard_size <= inline_block / 8 shard_size <= inline_block / 8
@@ -401,7 +392,6 @@ fn lookup_config_for_pools_with_env(
} }
let optimize = overrides.optimize; let optimize = overrides.optimize;
let inline_block_explicit = overrides.inline_block.is_some();
let inline_block = if let Some(value) = overrides.inline_block { let inline_block = if let Some(value) = overrides.inline_block {
let block = value let block = value
.parse::<bytesize::ByteSize>() .parse::<bytesize::ByteSize>()
@@ -434,7 +424,6 @@ fn lookup_config_for_pools_with_env(
optimize, optimize,
inline_block, inline_block,
initialized: true, initialized: true,
inline_block_explicit,
standard_parities, standard_parities,
rrs_parities, rrs_parities,
}) })
@@ -552,26 +541,22 @@ mod tests {
} }
#[test] #[test]
fn should_inline_scales_default_threshold_by_data_shards() { fn should_inline_preserves_exact_default_shard_boundaries() {
let config = lookup_config_for_pools_with_env(&KVS::new(), &[3, 12], no_env_overrides()) let config = Config::default();
.expect("default inline policy should resolve for EC2+1 and EC8+4");
for (case, shard_size, data_shards, versioned, expected) in [ for (case, shard_size, versioned, expected) in [
("EC2+1 unversioned exact", 128 * 1024, 2, false, true), ("unversioned below", 128 * 1024 - 1, false, true),
("EC2+1 unversioned above", 128 * 1024 + 1, 2, false, false), ("unversioned exact", 128 * 1024, false, true),
("EC2+1 versioned exact", 16 * 1024, 2, true, true), ("unversioned above", 128 * 1024 + 1, false, false),
("EC2+1 versioned above", 16 * 1024 + 1, 2, true, false), ("versioned below", 16 * 1024 - 1, true, true),
("EC8+4 unversioned exact", 32 * 1024, 8, false, true), ("versioned exact", 16 * 1024, true, true),
("EC8+4 unversioned above", 32 * 1024 + 1, 8, false, false), ("versioned above", 16 * 1024 + 1, true, false),
("EC8+4 versioned exact", 4 * 1024, 8, true, true), ("negative", -1, false, false),
("EC8+4 versioned above", 4 * 1024 + 1, 8, true, false),
("negative", -1, 2, false, false),
("zero data shards", 0, 0, false, false),
] { ] {
assert_eq!( assert_eq!(
config.should_inline(shard_size, data_shards, versioned), config.should_inline(shard_size, versioned),
expected, expected,
"{case}: shard_size={shard_size}, data_shards={data_shards}, versioned={versioned}" "{case}: shard_size={shard_size}, versioned={versioned}"
); );
} }
} }
@@ -592,28 +577,13 @@ mod tests {
let shard_size = erasure.shard_file_size(object_size); let shard_size = erasure.shard_file_size(object_size);
assert_eq!(shard_size, expected_shard_size, "{case}: object_size={object_size}"); assert_eq!(shard_size, expected_shard_size, "{case}: object_size={object_size}");
assert_eq!( assert_eq!(
config.should_inline(shard_size, erasure.data_shards, versioned), config.should_inline(shard_size, versioned),
expected, expected,
"{case}: object_size={object_size}, shard_size={shard_size}, versioned={versioned}" "{case}: object_size={object_size}, shard_size={shard_size}, versioned={versioned}"
); );
} }
} }
#[test]
fn explicit_inline_block_preserves_fixed_per_shard_rollback() {
let overrides = StorageClassEnvOverrides {
inline_block: Some("128KiB".to_string()),
..Default::default()
};
let config = lookup_config_for_pools_with_env(&KVS::new(), &[12], overrides)
.expect("explicit inline block should resolve for EC8+4");
assert!(config.should_inline(128 * 1024, 8, false));
assert!(!config.should_inline(128 * 1024 + 1, 8, false));
assert!(config.should_inline(16 * 1024, 8, true));
assert!(!config.should_inline(16 * 1024 + 1, 8, true));
}
#[test] #[test]
fn write_capability_contract_only_accepts_implemented_layouts() { fn write_capability_contract_only_accepts_implemented_layouts() {
assert_eq!(SUPPORTED_WRITE_CLASSES, [STANDARD, RRS]); assert_eq!(SUPPORTED_WRITE_CLASSES, [STANDARD, RRS]);
@@ -807,7 +777,6 @@ mod tests {
let encoded = serde_json::to_string(&cfg).expect("config should serialize"); let encoded = serde_json::to_string(&cfg).expect("config should serialize");
assert!(!encoded.contains("standard_parities")); assert!(!encoded.contains("standard_parities"));
assert!(!encoded.contains("rrs_parities")); assert!(!encoded.contains("rrs_parities"));
assert!(!encoded.contains("inline_block_explicit"));
let decoded: Config = serde_json::from_str(&encoded).expect("legacy scalar config should deserialize"); let decoded: Config = serde_json::from_str(&encoded).expect("legacy scalar config should deserialize");
assert_eq!(decoded.get_parity_for_sc(STANDARD), Some(2)); assert_eq!(decoded.get_parity_for_sc(STANDARD), Some(2));
@@ -817,25 +786,6 @@ mod tests {
assert!(validate_parity(0, 0).is_err()); assert!(validate_parity(0, 0).is_err());
} }
#[test]
fn explicit_inline_block_survives_config_round_trip() {
let cfg = lookup_config_for_pools_with_env(
&KVS::new(),
&[12],
StorageClassEnvOverrides {
inline_block: Some("128KiB".to_string()),
..Default::default()
},
)
.expect("explicit inline block should resolve");
assert!(cfg.should_inline(100 * 1024, 8, false));
let encoded = serde_json::to_string(&cfg).expect("config should serialize");
assert!(encoded.contains("\"inline_block_explicit\":true"));
let decoded: Config = serde_json::from_str(&encoded).expect("explicit inline config should deserialize");
assert!(decoded.should_inline(100 * 1024, 8, false));
}
#[test] #[test]
fn lookup_config_reads_rrs_from_class_rrs_key() { fn lookup_config_reads_rrs_from_class_rrs_key() {
// Regression: kvs.get(RRS) used RRS="REDUCED_REDUNDANCY" instead of // Regression: kvs.get(RRS) used RRS="REDUCED_REDUNDANCY" instead of
+1
View File
@@ -13,6 +13,7 @@
// limitations under the License. // limitations under the License.
// #730: pool coordination helpers are being migrated behind runtime owners. // #730: pool coordination helpers are being migrated behind runtime owners.
#![allow(dead_code)]
pub(crate) mod pools; pub(crate) mod pools;
pub(crate) mod sets; pub(crate) mod sets;
+9 -85
View File
@@ -226,7 +226,6 @@ fn ensure_decommission_start_rebalance_meta_allowed(meta: Option<&RebalanceMeta>
ensure_decommission_not_rebalancing(meta.is_some_and(is_rebalance_conflicting_with_decommission)) ensure_decommission_not_rebalancing(meta.is_some_and(is_rebalance_conflicting_with_decommission))
} }
#[allow(dead_code, reason = "leader precondition asserted by this file's tests (backlog#1823)")]
fn ensure_local_decommission_pool_leaders(endpoints: &EndpointServerPools, indices: &[usize]) -> Result<()> { fn ensure_local_decommission_pool_leaders(endpoints: &EndpointServerPools, indices: &[usize]) -> Result<()> {
for idx in indices { for idx in indices {
ensure_local_decommission_pool_leader(endpoints, *idx)?; ensure_local_decommission_pool_leader(endpoints, *idx)?;
@@ -1059,19 +1058,11 @@ fn should_cleanup_decommission_source_entry(decommissioned: usize, total_version
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[allow(
dead_code,
reason = "terminal-state classification asserted by this file's tests (backlog#1823)"
)]
enum DecommissionTerminalState { enum DecommissionTerminalState {
Completed, Completed,
Failed, Failed,
} }
#[allow(
dead_code,
reason = "terminal-state classification asserted by this file's tests (backlog#1823)"
)]
fn classify_decommission_terminal_state(failed_items_present: bool) -> DecommissionTerminalState { fn classify_decommission_terminal_state(failed_items_present: bool) -> DecommissionTerminalState {
if failed_items_present { if failed_items_present {
DecommissionTerminalState::Failed DecommissionTerminalState::Failed
@@ -2275,19 +2266,15 @@ fn decommission_delete_marker_opts(
version: &rustfs_filemeta::FileInfo, version: &rustfs_filemeta::FileInfo,
version_id: Option<String>, version_id: Option<String>,
src_pool_idx: usize, src_pool_idx: usize,
expected_bucket_incarnation_id: Option<uuid::Uuid>,
) -> ObjectOptions { ) -> ObjectOptions {
let version_suspended = version.version_id.is_none() && version_id.is_none();
ObjectOptions { ObjectOptions {
versioned: !version_suspended, versioned: true,
version_suspended, version_id,
version_id: version_id.or_else(|| version_suspended.then(|| uuid::Uuid::nil().to_string())),
mod_time: version.mod_time, mod_time: version.mod_time,
src_pool_idx, src_pool_idx,
data_movement: true, data_movement: true,
delete_marker: true, delete_marker: true,
skip_decommissioned: true, skip_decommissioned: true,
expected_bucket_incarnation_id,
delete_replication: version delete_replication: version
.replication_state_internal .replication_state_internal
.as_ref() .as_ref()
@@ -2312,7 +2299,6 @@ fn decommission_remote_tiered_opts(
version: &rustfs_filemeta::FileInfo, version: &rustfs_filemeta::FileInfo,
version_id: Option<String>, version_id: Option<String>,
src_pool_idx: usize, src_pool_idx: usize,
expected_bucket_incarnation_id: Option<uuid::Uuid>,
) -> ObjectOptions { ) -> ObjectOptions {
ObjectOptions { ObjectOptions {
versioned: version_id.is_some(), versioned: version_id.is_some(),
@@ -2321,9 +2307,6 @@ fn decommission_remote_tiered_opts(
user_defined: version.metadata.clone(), user_defined: version.metadata.clone(),
src_pool_idx, src_pool_idx,
data_movement: true, data_movement: true,
include_part_checksums: true,
http_preconditions: Some(crate::data_movement::data_movement_target_precondition()),
expected_bucket_incarnation_id,
..Default::default() ..Default::default()
} }
} }
@@ -2822,7 +2805,6 @@ impl ECStore {
lifecycle_config: Option<BucketLifecycleConfiguration>, lifecycle_config: Option<BucketLifecycleConfiguration>,
object_lock_config: Option<ObjectLockConfiguration>, object_lock_config: Option<ObjectLockConfiguration>,
replication_config: Option<(ReplicationConfiguration, OffsetDateTime)>, replication_config: Option<(ReplicationConfiguration, OffsetDateTime)>,
expected_bucket_incarnation_id: Option<uuid::Uuid>,
) -> Result<()> { ) -> Result<()> {
debug!( debug!(
event = EVENT_DECOMMISSION_ENTRY, event = EVENT_DECOMMISSION_ENTRY,
@@ -2852,11 +2834,6 @@ impl ECStore {
} }
decommission_cancel_signal_result(rx.is_cancelled())?; decommission_cancel_signal_result(rx.is_cancelled())?;
let bucket_incarnation_fence = match expected_bucket_incarnation_id {
Some(expected) => Some(self.acquire_bucket_incarnation_fence(&bucket, expected).await?),
None => None,
};
let mut fivs = load_decommission_entry_exact_versions(&set, &entry, &bucket, "file_info_versions").await?; let mut fivs = load_decommission_entry_exact_versions(&set, &entry, &bucket, "file_info_versions").await?;
fivs.versions fivs.versions
@@ -2917,7 +2894,7 @@ impl ECStore {
.delete_object( .delete_object(
bucket.as_str(), bucket.as_str(),
&version.name, &version.name,
decommission_delete_marker_opts(version, version_id.clone(), idx, expected_bucket_incarnation_id), decommission_delete_marker_opts(version, version_id.clone(), idx),
) )
.await .await
{ {
@@ -3007,7 +2984,7 @@ impl ECStore {
bucket.as_str(), bucket.as_str(),
&version.name, &version.name,
version, version,
&decommission_remote_tiered_opts(version, version_id.clone(), idx, expected_bucket_incarnation_id), &decommission_remote_tiered_opts(version, version_id.clone(), idx),
) )
.await .await
{ {
@@ -3079,11 +3056,7 @@ impl ECStore {
) )
.await?; .await?;
if let Err(err) = self if let Err(err) = self.clone().decommission_object(idx, bucket, rd).await {
.clone()
.decommission_object(idx, bucket, rd, expected_bucket_incarnation_id)
.await
{
if is_decommission_copy_cleanup_safe_error(&err) { if is_decommission_copy_cleanup_safe_error(&err) {
ignore = true; ignore = true;
cleanup_ignored = true; cleanup_ignored = true;
@@ -3160,9 +3133,6 @@ impl ECStore {
} }
if should_cleanup_decommission_source_entry(decommissioned, fivs.versions.len(), expired) { if should_cleanup_decommission_source_entry(decommissioned, fivs.versions.len(), expired) {
if bucket_incarnation_fence.as_ref().is_some_and(|guard| guard.is_lock_lost()) {
return Err(Error::other("decommission bucket incarnation fence was lost before source cleanup"));
}
decommission_cancel_signal_result(rx.is_cancelled())?; decommission_cancel_signal_result(rx.is_cancelled())?;
self.save_decommission_entry_progress_stage( self.save_decommission_entry_progress_stage(
@@ -3187,12 +3157,6 @@ impl ECStore {
entry.name.as_str(), entry.name.as_str(),
&fivs, &fivs,
&cleanup_preflight_allowed_missing, &cleanup_preflight_allowed_missing,
data_movement::SourceCleanupBucketFence {
expected_incarnation_id: expected_bucket_incarnation_id,
lifecycle_guard: bucket_incarnation_fence
.as_ref()
.and_then(|guard| guard.namespace_lock_guard()),
},
"decommission", "decommission",
) )
.await .await
@@ -3304,11 +3268,6 @@ impl ECStore {
let mut lifecycle_config = None; let mut lifecycle_config = None;
let mut object_lock_config = None; let mut object_lock_config = None;
let mut replication_config = None; let mut replication_config = None;
let expected_bucket_incarnation_id = if bi.name == RUSTFS_META_BUCKET {
None
} else {
Some(self.bucket_incarnation_id_from_disk(&bi.name).await?)
};
if bi.name != RUSTFS_META_BUCKET { if bi.name != RUSTFS_META_BUCKET {
let _ = resolve_decommission_optional_bucket_config_result( let _ = resolve_decommission_optional_bucket_config_result(
@@ -3362,7 +3321,6 @@ impl ECStore {
let lifecycle_config = lifecycle_config.clone(); let lifecycle_config = lifecycle_config.clone();
let object_lock_config = object_lock_config.clone(); let object_lock_config = object_lock_config.clone();
let replication_config = replication_config.clone(); let replication_config = replication_config.clone();
let expected_bucket_incarnation_id = expected_bucket_incarnation_id;
let entry_error = entry_error.clone(); let entry_error = entry_error.clone();
let callback_rx = callback_rx.clone(); let callback_rx = callback_rx.clone();
@@ -3425,7 +3383,6 @@ impl ECStore {
lifecycle_config, lifecycle_config,
object_lock_config, object_lock_config,
replication_config, replication_config,
expected_bucket_incarnation_id,
) )
.await .await
{ {
@@ -4211,24 +4168,10 @@ impl ECStore {
} }
#[tracing::instrument(skip(self, rd))] #[tracing::instrument(skip(self, rd))]
async fn decommission_object( async fn decommission_object(self: Arc<Self>, pool_idx: usize, bucket: String, rd: GetObjectReader) -> Result<()> {
self: Arc<Self>,
pool_idx: usize,
bucket: String,
rd: GetObjectReader,
expected_bucket_incarnation_id: Option<uuid::Uuid>,
) -> Result<()> {
warn!("decommission_object: start {} {}", &bucket, &rd.object_info.name); warn!("decommission_object: start {} {}", &bucket, &rd.object_info.name);
let object_name = rd.object_info.name.clone(); let object_name = rd.object_info.name.clone();
let result = data_movement::migrate_object( let result = data_movement::migrate_object(self, pool_idx, bucket.clone(), rd, "decommission_object").await;
self,
pool_idx,
bucket.clone(),
rd,
expected_bucket_incarnation_id,
"decommission_object",
)
.await;
if result.is_ok() { if result.is_ok() {
warn!("decommission_object: migrated {} {}", &bucket, &object_name); warn!("decommission_object: migrated {} {}", &bucket, &object_name);
} }
@@ -4404,8 +4347,7 @@ mod tests {
..Default::default() ..Default::default()
}; };
let incarnation = uuid::Uuid::new_v4(); let opts = decommission_delete_marker_opts(&version, Some("version-id".to_string()), 7);
let opts = decommission_delete_marker_opts(&version, Some("version-id".to_string()), 7, Some(incarnation));
let replication = opts.delete_replication.expect("replication state should be preserved"); let replication = opts.delete_replication.expect("replication state should be preserved");
assert!(opts.versioned); assert!(opts.versioned);
@@ -4415,25 +4357,11 @@ mod tests {
assert_eq!(opts.src_pool_idx, 7); assert_eq!(opts.src_pool_idx, 7);
assert_eq!(opts.version_id.as_deref(), Some("version-id")); assert_eq!(opts.version_id.as_deref(), Some("version-id"));
assert_eq!(opts.mod_time, Some(mod_time)); assert_eq!(opts.mod_time, Some(mod_time));
assert_eq!(opts.expected_bucket_incarnation_id, Some(incarnation));
assert_eq!(replication.replica_status, ReplicationStatusType::Replica); assert_eq!(replication.replica_status, ReplicationStatusType::Replica);
assert!(replication.delete_marker); assert!(replication.delete_marker);
assert_eq!(replication.replicate_decision_str, "existing"); assert_eq!(replication.replicate_decision_str, "existing");
} }
#[test]
fn decommission_delete_marker_opts_preserves_suspended_null_version() {
let version = rustfs_filemeta::FileInfo {
deleted: true,
..Default::default()
};
let opts = decommission_delete_marker_opts(&version, None, 7, None);
assert!(!opts.versioned);
assert!(opts.version_suspended);
assert_eq!(opts.version_id.as_deref(), Some(uuid::Uuid::nil().to_string().as_str()));
}
#[test] #[test]
fn test_decommission_object_migration_read_opts_are_raw_data_movement() { fn test_decommission_object_migration_read_opts_are_raw_data_movement() {
let opts = decommission_object_migration_read_opts(Some("vid-1".to_string())); let opts = decommission_object_migration_read_opts(Some("vid-1".to_string()));
@@ -4455,8 +4383,7 @@ mod tests {
..Default::default() ..Default::default()
}; };
let incarnation = uuid::Uuid::new_v4(); let opts = decommission_remote_tiered_opts(&version, Some("version-id".to_string()), 9);
let opts = decommission_remote_tiered_opts(&version, Some("version-id".to_string()), 9, Some(incarnation));
assert!(opts.versioned); assert!(opts.versioned);
assert!(opts.data_movement); assert!(opts.data_movement);
@@ -4464,9 +4391,6 @@ mod tests {
assert_eq!(opts.version_id.as_deref(), Some("version-id")); assert_eq!(opts.version_id.as_deref(), Some("version-id"));
assert_eq!(opts.mod_time, Some(mod_time)); assert_eq!(opts.mod_time, Some(mod_time));
assert_eq!(opts.user_defined.get("x-amz-meta-key").map(String::as_str), Some("value")); assert_eq!(opts.user_defined.get("x-amz-meta-key").map(String::as_str), Some("value"));
assert!(opts.include_part_checksums);
assert!(opts.http_preconditions.is_some());
assert_eq!(opts.expected_bucket_incarnation_id, Some(incarnation));
} }
#[test] #[test]
File diff suppressed because it is too large Load Diff
@@ -12,16 +12,6 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
//! Per-disk usage snapshots persisted under the metadata bucket.
//!
//! **Nothing calls into this module.** It landed complete with tests in #5307
//! (2026-07-27) and its aggregation entry point,
//! [`crate::data_usage::aggregate_local_snapshots`], has never had a caller in
//! the tree's history. The live data-usage path is
//! `load_data_usage_from_backend` / `store_data_usage_in_backend`. The items
//! below therefore carry individual `dead_code` allows rather than a module
//! blanket, so the gap stays greppable until it is either wired up or removed.
use crate::data_usage::BucketUsageInfo; use crate::data_usage::BucketUsageInfo;
use crate::disk::RUSTFS_META_BUCKET; use crate::disk::RUSTFS_META_BUCKET;
use crate::error::{Error, Result}; use crate::error::{Error, Result};
@@ -36,12 +26,10 @@ pub const DATA_USAGE_DIR: &str = "datausage";
/// Directory used to store incremental scan state files under the metadata bucket. /// Directory used to store incremental scan state files under the metadata bucket.
pub const DATA_USAGE_STATE_DIR: &str = "datausage/state"; pub const DATA_USAGE_STATE_DIR: &str = "datausage/state";
/// Snapshot file format version, allows forward compatibility if the structure evolves. /// Snapshot file format version, allows forward compatibility if the structure evolves.
#[allow(dead_code, reason = "unwired local usage-snapshot feature; see module docs (backlog#1823)")]
pub const LOCAL_USAGE_SNAPSHOT_VERSION: u32 = 1; pub const LOCAL_USAGE_SNAPSHOT_VERSION: u32 = 1;
/// Additional metadata describing which disk produced the snapshot. /// Additional metadata describing which disk produced the snapshot.
#[derive(Debug, Clone, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[allow(dead_code, reason = "unwired local usage-snapshot feature; see module docs (backlog#1823)")]
pub struct LocalUsageSnapshotMeta { pub struct LocalUsageSnapshotMeta {
/// Disk UUID stored as a string for simpler serialization. /// Disk UUID stored as a string for simpler serialization.
pub disk_id: String, pub disk_id: String,
@@ -55,7 +43,6 @@ pub struct LocalUsageSnapshotMeta {
/// Usage snapshot produced by a single disk. /// Usage snapshot produced by a single disk.
#[derive(Debug, Clone, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[allow(dead_code, reason = "unwired local usage-snapshot feature; see module docs (backlog#1823)")]
pub struct LocalUsageSnapshot { pub struct LocalUsageSnapshot {
/// Format version recorded in the snapshot. /// Format version recorded in the snapshot.
pub format_version: u32, pub format_version: u32,
@@ -77,7 +64,6 @@ pub struct LocalUsageSnapshot {
pub objects_total_size: u64, pub objects_total_size: u64,
} }
#[allow(dead_code, reason = "unwired local usage-snapshot feature; see module docs (backlog#1823)")]
impl LocalUsageSnapshot { impl LocalUsageSnapshot {
/// Create an empty snapshot with the default format version filled in. /// Create an empty snapshot with the default format version filled in.
pub fn new(meta: LocalUsageSnapshotMeta) -> Self { pub fn new(meta: LocalUsageSnapshotMeta) -> Self {
@@ -113,13 +99,11 @@ impl LocalUsageSnapshot {
} }
/// Build the snapshot file name `<disk-id>.json`. /// Build the snapshot file name `<disk-id>.json`.
#[allow(dead_code, reason = "unwired local usage-snapshot feature; see module docs (backlog#1823)")]
pub fn snapshot_file_name(disk_id: &str) -> String { pub fn snapshot_file_name(disk_id: &str) -> String {
format!("{disk_id}.json") format!("{disk_id}.json")
} }
/// Build the object path relative to `RUSTFS_META_BUCKET`, e.g. `datausage/<disk-id>.json`. /// Build the object path relative to `RUSTFS_META_BUCKET`, e.g. `datausage/<disk-id>.json`.
#[allow(dead_code, reason = "unwired local usage-snapshot feature; see module docs (backlog#1823)")]
pub fn snapshot_object_path(disk_id: &str) -> String { pub fn snapshot_object_path(disk_id: &str) -> String {
format!("{}/{}", DATA_USAGE_DIR, snapshot_file_name(disk_id)) format!("{}/{}", DATA_USAGE_DIR, snapshot_file_name(disk_id))
} }
@@ -135,13 +119,11 @@ pub fn data_usage_state_dir(root: &Path) -> PathBuf {
} }
/// Build the absolute path to the snapshot file for the provided disk ID. /// Build the absolute path to the snapshot file for the provided disk ID.
#[allow(dead_code, reason = "unwired local usage-snapshot feature; see module docs (backlog#1823)")]
pub fn snapshot_path(root: &Path, disk_id: &str) -> PathBuf { pub fn snapshot_path(root: &Path, disk_id: &str) -> PathBuf {
data_usage_dir(root).join(snapshot_file_name(disk_id)) data_usage_dir(root).join(snapshot_file_name(disk_id))
} }
/// Read a snapshot from disk if it exists. /// Read a snapshot from disk if it exists.
#[allow(dead_code, reason = "unwired local usage-snapshot feature; see module docs (backlog#1823)")]
pub async fn read_snapshot(root: &Path, disk_id: &str) -> Result<Option<LocalUsageSnapshot>> { pub async fn read_snapshot(root: &Path, disk_id: &str) -> Result<Option<LocalUsageSnapshot>> {
let path = snapshot_path(root, disk_id); let path = snapshot_path(root, disk_id);
match fs::read(&path).await { match fs::read(&path).await {
@@ -156,7 +138,6 @@ pub async fn read_snapshot(root: &Path, disk_id: &str) -> Result<Option<LocalUsa
} }
/// Persist a snapshot to disk, creating directories as needed and overwriting any existing file. /// Persist a snapshot to disk, creating directories as needed and overwriting any existing file.
#[allow(dead_code, reason = "unwired local usage-snapshot feature; see module docs (backlog#1823)")]
pub async fn write_snapshot(root: &Path, disk_id: &str, snapshot: &LocalUsageSnapshot) -> Result<()> { pub async fn write_snapshot(root: &Path, disk_id: &str, snapshot: &LocalUsageSnapshot) -> Result<()> {
let dir = data_usage_dir(root); let dir = data_usage_dir(root);
fs::create_dir_all(&dir).await.map_err(Error::other)?; fs::create_dir_all(&dir).await.map_err(Error::other)?;
+130 -150
View File
@@ -13,6 +13,7 @@
// limitations under the License. // limitations under the License.
// #730: scanner/data-usage state is partially migrated and still owns staged cache helpers. // #730: scanner/data-usage state is partially migrated and still owns staged cache helpers.
#![allow(dead_code)]
pub mod local_snapshot; pub mod local_snapshot;
@@ -33,8 +34,8 @@ use crate::{
pub use local_snapshot::{LocalUsageSnapshot, read_snapshot as read_local_snapshot, snapshot_path}; pub use local_snapshot::{LocalUsageSnapshot, read_snapshot as read_local_snapshot, snapshot_path};
use rustfs_data_usage::{ use rustfs_data_usage::{
BucketTargetUsageInfo, BucketUsageInfo, CompressionTotalInfo, DATA_USAGE_OBJECT_NAME, DATA_USAGE_OBSERVED_OBJECT_NAME, BucketTargetUsageInfo, BucketUsageInfo, CompressionTotalInfo, DATA_USAGE_OBJECT_NAME, DATA_USAGE_OBSERVED_OBJECT_NAME,
DataUsageCache, DataUsageInfo, DiskUsageStatus, LEGACY_DATA_USAGE_OBJECT_NAME, SizeHistogram, VersionsHistogram, DataUsageCache, DataUsageEntry, DataUsageInfo, DiskUsageStatus, LEGACY_DATA_USAGE_OBJECT_NAME, SizeHistogram, SizeSummary,
observed_data_usage_is_newer, VersionsHistogram, observed_data_usage_is_newer,
}; };
use rustfs_io_metrics::record_system_path_failure; use rustfs_io_metrics::record_system_path_failure;
use rustfs_utils::path::SLASH_SEPARATOR; use rustfs_utils::path::SLASH_SEPARATOR;
@@ -54,6 +55,7 @@ use tracing::{debug, error, info, instrument};
// Data usage storage constants // Data usage storage constants
pub const DATA_USAGE_ROOT: &str = SLASH_SEPARATOR; pub const DATA_USAGE_ROOT: &str = SLASH_SEPARATOR;
const DATA_COMPRESSION_TOTAL_NAME: &str = ".compression.json"; const DATA_COMPRESSION_TOTAL_NAME: &str = ".compression.json";
const DATA_USAGE_BLOOM_NAME: &str = ".bloomcycle.bin";
pub const DATA_USAGE_CACHE_NAME: &str = ".usage-cache.bin"; pub const DATA_USAGE_CACHE_NAME: &str = ".usage-cache.bin";
const DATA_USAGE_CACHE_TTL_SECS: u64 = 30; const DATA_USAGE_CACHE_TTL_SECS: u64 = 30;
const LIVE_BUCKET_USAGE_MAX_ENTRIES: u64 = 1024; const LIVE_BUCKET_USAGE_MAX_ENTRIES: u64 = 1024;
@@ -311,6 +313,11 @@ lazy_static::lazy_static! {
LEGACY_DATA_USAGE_OBJECT_NAME LEGACY_DATA_USAGE_OBJECT_NAME
); );
static ref LEGACY_DATA_USAGE_OBJ_BACKUP_PATH: String = format!("{}.bkp", LEGACY_DATA_USAGE_OBJ_NAME_PATH.as_str()); static ref LEGACY_DATA_USAGE_OBJ_BACKUP_PATH: String = format!("{}.bkp", LEGACY_DATA_USAGE_OBJ_NAME_PATH.as_str());
pub static ref DATA_USAGE_BLOOM_NAME_PATH: String = format!("{}{}{}",
crate::disk::BUCKET_META_PREFIX,
SLASH_SEPARATOR,
DATA_USAGE_BLOOM_NAME
);
pub static ref DATA_COMPRESSION_TOTAL_NAME_PATH: String = format!("{}{}{}", pub static ref DATA_COMPRESSION_TOTAL_NAME_PATH: String = format!("{}{}{}",
crate::disk::BUCKET_META_PREFIX, crate::disk::BUCKET_META_PREFIX,
SLASH_SEPARATOR, SLASH_SEPARATOR,
@@ -851,10 +858,6 @@ async fn resolve_loaded_snapshot_pair_with_source(
} }
} }
#[allow(
dead_code,
reason = "primary/backup snapshot fallback asserted by this file's tests (backlog#1823)"
)]
async fn resolve_loaded_snapshot( async fn resolve_loaded_snapshot(
primary: Result<Vec<u8>, Error>, primary: Result<Vec<u8>, Error>,
backup: impl Future<Output = Result<Vec<u8>, Error>>, backup: impl Future<Output = Result<Vec<u8>, Error>>,
@@ -1184,10 +1187,6 @@ pub async fn invalidate_admin_data_usage_snapshot_cache() {
} }
/// Aggregate usage information from local disk snapshots. /// Aggregate usage information from local disk snapshots.
#[allow(
dead_code,
reason = "reached only through aggregate_local_snapshots, which has no caller (backlog#1823)"
)]
fn merge_snapshot(aggregated: &mut DataUsageInfo, mut snapshot: LocalUsageSnapshot, latest_update: &mut Option<SystemTime>) { fn merge_snapshot(aggregated: &mut DataUsageInfo, mut snapshot: LocalUsageSnapshot, latest_update: &mut Option<SystemTime>) {
if let Some(update) = snapshot.last_update if let Some(update) = snapshot.last_update
&& latest_update.is_none_or(|current| update > current) && latest_update.is_none_or(|current| update > current)
@@ -1221,10 +1220,6 @@ fn merge_snapshot(aggregated: &mut DataUsageInfo, mut snapshot: LocalUsageSnapsh
} }
} }
#[allow(
dead_code,
reason = "entry point of the local usage-snapshot feature, which has had no caller since it landed in #5307 (backlog#1823)"
)]
pub async fn aggregate_local_snapshots(store: Arc<ECStore>) -> Result<(Vec<DiskUsageStatus>, DataUsageInfo), Error> { pub async fn aggregate_local_snapshots(store: Arc<ECStore>) -> Result<(Vec<DiskUsageStatus>, DataUsageInfo), Error> {
let mut aggregated = DataUsageInfo::default(); let mut aggregated = DataUsageInfo::default();
let mut latest_update: Option<SystemTime> = None; let mut latest_update: Option<SystemTime> = None;
@@ -1360,7 +1355,7 @@ impl BucketUsageAccumulator {
return Ok(()); return Ok(());
} }
let object_size = quota_object_size(object)?; let object_size = object.size.max(0) as u64;
self.current_live_versions = self.current_live_versions.saturating_add(1); self.current_live_versions = self.current_live_versions.saturating_add(1);
self.size_histogram.add(object_size); self.size_histogram.add(object_size);
self.total_size = self.total_size.saturating_add(object_size); self.total_size = self.total_size.saturating_add(object_size);
@@ -1390,31 +1385,6 @@ impl BucketUsageAccumulator {
} }
} }
pub fn quota_object_size(object: &ObjectInfo) -> Result<u64, Error> {
let logical_size = u64::try_from(object.get_actual_size().map_err(Error::other)?).map_err(|_| Error::PartMissingOrCorrupt)?;
let persisted_part_size = if object.parts.is_empty() {
u64::try_from(object.size).map_err(|_| Error::PartMissingOrCorrupt)?
} else {
object.parts.iter().try_fold(0_u64, |total, part| {
// Compressed streaming objects persist -1 when the transformed
// part size is unknown. The physical part size remains a valid
// quota floor; reject only non-negative values that overflow.
let actual_size = if part.actual_size < 0 {
if object.is_compressed() {
0
} else {
return Err(Error::PartMissingOrCorrupt);
}
} else {
u64::try_from(part.actual_size).map_err(|_| Error::PartMissingOrCorrupt)?
};
let part_size = actual_size.max(u64::try_from(part.size).map_err(|_| Error::PartMissingOrCorrupt)?);
total.checked_add(part_size).ok_or(Error::PartMissingOrCorrupt)
})?
};
Ok(logical_size.max(persisted_part_size))
}
type UsageVersionPage = StorageListObjectVersionsInfo<ObjectInfo>; type UsageVersionPage = StorageListObjectVersionsInfo<ObjectInfo>;
pub async fn compute_bucket_usage(store: Arc<ECStore>, bucket_name: &str) -> Result<BucketUsageInfo, Error> { pub async fn compute_bucket_usage(store: Arc<ECStore>, bucket_name: &str) -> Result<BucketUsageInfo, Error> {
@@ -1668,7 +1638,7 @@ fn preserve_unknown_dirty_usage(
Some(preserved) Some(preserved)
} }
#[cfg(any(test, feature = "test-util"))] #[cfg(test)]
async fn replace_bucket_usage_memory_from_authoritative(bucket: &str, usage: BucketUsageInfo, refresh_started_at: SystemTime) { async fn replace_bucket_usage_memory_from_authoritative(bucket: &str, usage: BucketUsageInfo, refresh_started_at: SystemTime) {
let mut cache = memory_cache().write().await; let mut cache = memory_cache().write().await;
if let Some(existing) = cache.get(bucket) if let Some(existing) = cache.get(bucket)
@@ -1680,19 +1650,6 @@ async fn replace_bucket_usage_memory_from_authoritative(bucket: &str, usage: Buc
cache.insert(bucket.to_string(), cached_bucket_usage_from_backend(usage, refresh_started_at, true)); cache.insert(bucket.to_string(), cached_bucket_usage_from_backend(usage, refresh_started_at, true));
} }
#[cfg(feature = "test-util")]
pub async fn seed_bucket_usage_memory_for_test(bucket: &str, size: u64) {
replace_bucket_usage_memory_from_authoritative(
bucket,
BucketUsageInfo {
size,
..Default::default()
},
SystemTime::now(),
)
.await;
}
/// Fast in-memory update for immediate quota and admin usage consistency. /// Fast in-memory update for immediate quota and admin usage consistency.
pub async fn record_bucket_object_write_memory(bucket: &str, previous_current_size: Option<u64>, new_size: u64) { pub async fn record_bucket_object_write_memory(bucket: &str, previous_current_size: Option<u64>, new_size: u64) {
record_bucket_object_write_memory_inner(bucket, previous_current_size, new_size, false).await; record_bucket_object_write_memory_inner(bucket, previous_current_size, new_size, false).await;
@@ -1772,6 +1729,11 @@ pub async fn record_bucket_object_write_unknown_previous_memory(bucket: &str, ne
entry.pending_scanner_position = None; entry.pending_scanner_position = None;
} }
/// Fast in-memory increment for immediate quota consistency.
pub async fn increment_bucket_usage_memory(bucket: &str, size_increment: u64) {
record_bucket_object_write_memory(bucket, None, size_increment).await;
}
/// Fast in-memory update for successful object deletes. /// Fast in-memory update for successful object deletes.
pub async fn record_bucket_object_delete_memory(bucket: &str, deleted_size: u64, removed_current_object: bool) { pub async fn record_bucket_object_delete_memory(bucket: &str, deleted_size: u64, removed_current_object: bool) {
ensure_bucket_usage_cached(bucket).await; ensure_bucket_usage_cached(bucket).await;
@@ -1814,6 +1776,11 @@ pub async fn record_bucket_delete_marker_memory(bucket: &str) {
entry.pending_scanner_position = None; entry.pending_scanner_position = None;
} }
/// Fast in-memory decrement for immediate quota consistency
pub async fn decrement_bucket_usage_memory(bucket: &str, size_decrement: u64) {
record_bucket_object_delete_memory(bucket, size_decrement, size_decrement > 0).await;
}
/// Get bucket usage from the authoritative cache for this topology. /// Get bucket usage from the authoritative cache for this topology.
async fn get_persisted_bucket_usage(bucket: &str) -> Option<u64> { async fn get_persisted_bucket_usage(bucket: &str) -> Option<u64> {
let store = runtime_sources::object_store_handle()?; let store = runtime_sources::object_store_handle()?;
@@ -2008,6 +1975,91 @@ pub async fn apply_bucket_usage_memory_overlay(data_usage_info: &mut DataUsageIn
apply_bucket_usage_memory_overlay_if_authoritative(data_usage_info, authoritative).await; apply_bucket_usage_memory_overlay_if_authoritative(data_usage_info, authoritative).await;
} }
/// Sync memory cache with backend data (called by scanner)
pub async fn sync_memory_cache_with_backend() -> Result<(), Error> {
if let Some(store) = runtime_sources::object_store_handle() {
match load_data_usage_from_backend(store.clone()).await {
Ok(data_usage_info) => {
replace_bucket_usage_memory_from_info(&data_usage_info).await;
}
Err(e) => {
debug!("Failed to sync memory cache with backend: {}", e);
}
}
}
Ok(())
}
/// Create a data usage cache entry from size summary
pub fn create_cache_entry_from_summary(summary: &SizeSummary) -> DataUsageEntry {
let mut entry = DataUsageEntry::default();
entry.add_sizes(summary);
entry
}
/// Convert data usage cache to DataUsageInfo
pub fn cache_to_data_usage_info(
cache: &DataUsageCache,
path: &str,
buckets: &[crate::storage_api_contracts::bucket::BucketInfo],
) -> DataUsageInfo {
let e = match cache.find(path) {
Some(e) => e,
None => return DataUsageInfo::default(),
};
let flat = cache.flatten(&e);
let mut buckets_usage = HashMap::new();
for bucket in buckets.iter() {
let e = match cache.find(&bucket.name) {
Some(e) => e,
None => continue,
};
let flat = cache.flatten(&e);
let mut bui = BucketUsageInfo {
size: flat.size as u64,
versions_count: flat.versions as u64,
objects_count: flat.objects as u64,
delete_markers_count: flat.delete_markers as u64,
object_size_histogram: flat.obj_sizes.to_map(),
object_versions_histogram: flat.obj_versions.to_map(),
..Default::default()
};
if let Some(rs) = &flat.replication_stats {
bui.replica_size = rs.replica_size;
bui.replica_count = rs.replica_count;
for (arn, stat) in rs.targets.iter() {
bui.replication_info.insert(
arn.clone(),
BucketTargetUsageInfo {
replication_pending_size: stat.pending_size,
replicated_size: stat.replicated_size,
replication_failed_size: stat.failed_size,
replication_pending_count: stat.pending_count,
replication_failed_count: stat.failed_count,
replicated_count: stat.replicated_count,
..Default::default()
},
);
}
}
buckets_usage.insert(bucket.name.clone(), bui);
}
DataUsageInfo {
last_update: cache.info.last_update,
objects_total_count: flat.objects as u64,
versions_total_count: flat.versions as u64,
delete_markers_total_count: flat.delete_markers as u64,
objects_total_size: flat.size as u64,
buckets_count: e.children.len() as u64,
buckets_usage,
..Default::default()
}
}
// Helper functions for DataUsageCache operations // Helper functions for DataUsageCache operations
pub async fn load_data_usage_cache(store: &crate::set_disk::SetDisks, name: &str) -> crate::error::Result<DataUsageCache> { pub async fn load_data_usage_cache(store: &crate::set_disk::SetDisks, name: &str) -> crate::error::Result<DataUsageCache> {
use crate::disk::{BUCKET_META_PREFIX, RUSTFS_META_BUCKET}; use crate::disk::{BUCKET_META_PREFIX, RUSTFS_META_BUCKET};
@@ -2085,6 +2137,30 @@ pub async fn load_data_usage_cache(store: &crate::set_disk::SetDisks, name: &str
Ok(d) Ok(d)
} }
#[instrument(skip(cache))]
pub async fn save_data_usage_cache(cache: &DataUsageCache, name: &str) -> crate::error::Result<()> {
use crate::config::com::save_config;
use crate::disk::BUCKET_META_PREFIX;
use std::path::Path;
let Some(store) = runtime_sources::object_store_handle() else {
return Err(Error::other("errServerNotInitialized"));
};
let buf = cache.marshal_msg().map_err(Error::other)?;
let buf_clone = buf.clone();
let store_clone = store.clone();
let name = Path::new(BUCKET_META_PREFIX).join(name).to_string_lossy().to_string();
let name_clone = name.clone();
tokio::spawn(async move {
let _ = save_config(store_clone, &format!("{}{}", name_clone, ".bkp"), buf_clone).await;
});
save_config(store, &name, buf).await?;
Ok(())
}
/// Persist the current in-memory compression total to the backend. /// Persist the current in-memory compression total to the backend.
/// Resets the debounce counter so the next auto-persist won't fire /// Resets the debounce counter so the next auto-persist won't fire
/// immediately after this manual flush (intended for shutdown paths). /// immediately after this manual flush (intended for shutdown paths).
@@ -3059,102 +3135,6 @@ mod tests {
assert_eq!(usage.object_versions_histogram.get("BETWEEN_1000_AND_10000"), Some(&1)); assert_eq!(usage.object_versions_histogram.get("BETWEEN_1000_AND_10000"), Some(&1));
} }
#[test]
fn bucket_usage_uses_the_larger_of_logical_and_physical_size() {
let mut metadata = HashMap::new();
rustfs_utils::http::insert_str(
&mut metadata,
rustfs_utils::http::SUFFIX_COMPRESSION,
"klauspost/compress/s2".to_string(),
);
rustfs_utils::http::insert_str(&mut metadata, rustfs_utils::http::SUFFIX_ACTUAL_SIZE, "4096".to_string());
let object = ObjectInfo {
name: "compressed".to_string(),
size: 128,
user_defined: Arc::new(metadata),
..Default::default()
};
let mut usage = BucketUsageAccumulator::default();
usage
.record("bucket", &object)
.expect("valid compressed metadata should be counted");
assert_eq!(usage.finish().size, 4096);
let mut framed_metadata = HashMap::new();
rustfs_utils::http::insert_str(
&mut framed_metadata,
rustfs_utils::http::SUFFIX_COMPRESSION,
"klauspost/compress/s2".to_string(),
);
rustfs_utils::http::insert_str(&mut framed_metadata, rustfs_utils::http::SUFFIX_ACTUAL_SIZE, "1".to_string());
let framed = ObjectInfo {
name: "framed".to_string(),
size: 17,
user_defined: Arc::new(framed_metadata),
..Default::default()
};
assert_eq!(quota_object_size(&framed).expect("physical framing must remain quota-accounted"), 17);
let legacy_compressed_part = ObjectInfo {
name: "legacy-compressed-part".to_string(),
size: 1,
user_defined: Arc::new((*framed.user_defined).clone()),
parts: Arc::new(vec![rustfs_filemeta::ObjectPartInfo {
size: 1,
actual_size: -1,
..Default::default()
}]),
..Default::default()
};
assert_eq!(
quota_object_size(&legacy_compressed_part).expect("unknown compressed part size is a valid sentinel"),
1
);
let uncompressed_negative_part = ObjectInfo {
name: "uncompressed-negative-part".to_string(),
size: 1,
parts: Arc::new(vec![rustfs_filemeta::ObjectPartInfo {
size: 1,
actual_size: -1,
..Default::default()
}]),
..Default::default()
};
assert!(matches!(quota_object_size(&uncompressed_negative_part), Err(Error::PartMissingOrCorrupt)));
let mut corrupt_metadata = (*object.user_defined).clone();
rustfs_utils::http::insert_str(&mut corrupt_metadata, rustfs_utils::http::SUFFIX_ACTUAL_SIZE, "-1".to_string());
let corrupt = ObjectInfo {
user_defined: Arc::new(corrupt_metadata),
..object
};
assert!(matches!(quota_object_size(&corrupt), Err(Error::PartMissingOrCorrupt)));
let mut poisoned_metadata = HashMap::new();
rustfs_utils::http::insert_str(
&mut poisoned_metadata,
rustfs_utils::http::SUFFIX_COMPRESSION,
"klauspost/compress/s2".to_string(),
);
rustfs_utils::http::insert_str(&mut poisoned_metadata, rustfs_utils::http::SUFFIX_ACTUAL_SIZE, "1".to_string());
let poisoned = ObjectInfo {
name: "legacy-swift-metadata".to_string(),
size: 4096,
user_defined: Arc::new(poisoned_metadata),
parts: Arc::new(vec![rustfs_filemeta::ObjectPartInfo {
size: 4096,
actual_size: 4096,
..Default::default()
}]),
..Default::default()
};
assert_eq!(
quota_object_size(&poisoned).expect("persisted part accounting must bound legacy user metadata"),
4096
);
}
#[tokio::test] #[tokio::test]
#[serial] #[serial]
async fn live_bucket_usage_refreshes_are_coalesced_only_while_in_flight() { async fn live_bucket_usage_refreshes_are_coalesced_only_while_in_flight() {
@@ -12,9 +12,7 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
use crate::cluster::rpc::{ use crate::cluster::rpc::{TonicInterceptor, gen_tonic_signature_interceptor, node_service_time_out_client};
ScannerBucketListing, TonicInterceptor, gen_tonic_signature_interceptor, node_service_time_out_client,
};
use crate::data_usage::{DATA_USAGE_CACHE_NAME, DATA_USAGE_ROOT, load_data_usage_from_backend_cached}; use crate::data_usage::{DATA_USAGE_CACHE_NAME, DATA_USAGE_ROOT, load_data_usage_from_backend_cached};
use crate::error::{Error, Result}; use crate::error::{Error, Result};
use crate::{ use crate::{
@@ -25,7 +23,6 @@ use crate::{
use crate::data_usage::load_data_usage_cache; use crate::data_usage::load_data_usage_cache;
use crate::storage_api_contracts::admin::StorageAdminApi; use crate::storage_api_contracts::admin::StorageAdminApi;
use crate::storage_api_contracts::bucket::BucketOptions;
use rustfs_common::heal_channel::DriveState; use rustfs_common::heal_channel::DriveState;
use rustfs_madmin::{ use rustfs_madmin::{
BackendDisks, Disk, ErasureSetInfo, ITEM_INITIALIZING, ITEM_OFFLINE, ITEM_ONLINE, ITEM_UNKNOWN, InfoMessage, MemStats, BackendDisks, Disk, ErasureSetInfo, ITEM_INITIALIZING, ITEM_OFFLINE, ITEM_ONLINE, ITEM_UNKNOWN, InfoMessage, MemStats,
@@ -77,19 +74,6 @@ fn apply_data_usage_result(
} }
} }
fn apply_bucket_namespace_count(result: Result<ScannerBucketListing>, buckets: &mut rustfs_madmin::Buckets) {
if let Ok(listing) = result
&& listing.topology_complete
{
let count = listing.buckets.iter().filter(|bucket| !bucket.name.starts_with('.')).count();
let Ok(count) = u64::try_from(count) else {
return;
};
buckets.count = count;
buckets.error = None;
}
}
// pub const ITEM_OFFLINE: &str = "offline"; // pub const ITEM_OFFLINE: &str = "offline";
// pub const ITEM_INITIALIZING: &str = "initializing"; // pub const ITEM_INITIALIZING: &str = "initializing";
// pub const ITEM_ONLINE: &str = "online"; // pub const ITEM_ONLINE: &str = "online";
@@ -301,18 +285,6 @@ pub async fn get_server_info(get_pools: bool) -> InfoMessage {
&mut delete_markers, &mut delete_markers,
&mut usage, &mut usage,
); );
if buckets.error.is_some() {
apply_bucket_namespace_count(
store
.list_bucket_for_scanner(&BucketOptions {
cached: true,
no_metadata: true,
..Default::default()
})
.await,
&mut buckets,
);
}
let after3 = OffsetDateTime::now_utc(); let after3 = OffsetDateTime::now_utc();
@@ -653,7 +625,6 @@ fn reconcile_servers_with_endpoint_topology(
(added, report) (added, report)
} }
#[allow(dead_code, reason = "exercised by this file's topology tests (backlog#1823)")]
fn server_topology_completeness_report( fn server_topology_completeness_report(
servers: &[ServerProperties], servers: &[ServerProperties],
endpoints: &EndpointServerPools, endpoints: &EndpointServerPools,
@@ -734,13 +705,12 @@ mod tests {
endpoints::{EndpointServerPools, Endpoints, PoolEndpoints}, endpoints::{EndpointServerPools, Endpoints, PoolEndpoints},
}; };
use crate::runtime::sources as runtime_sources; use crate::runtime::sources as runtime_sources;
use crate::storage_api_contracts::bucket::BucketInfo;
use rustfs_madmin::{Disk, ITEM_OFFLINE, ITEM_ONLINE, ITEM_UNKNOWN, ServerProperties}; use rustfs_madmin::{Disk, ITEM_OFFLINE, ITEM_ONLINE, ITEM_UNKNOWN, ServerProperties};
use super::{ use super::{
DATA_USAGE_ROOT, DATA_USAGE_UNAVAILABLE_ERROR, apply_bucket_namespace_count, apply_data_usage_result, DATA_USAGE_ROOT, DATA_USAGE_UNAVAILABLE_ERROR, apply_data_usage_result, apply_erasure_set_usage,
apply_erasure_set_usage, get_local_server_property, get_online_offline_disks_stats, get_server_info, get_local_server_property, get_online_offline_disks_stats, get_server_info, reconcile_servers_with_endpoint_topology,
reconcile_servers_with_endpoint_topology, server_topology_completeness_report, server_topology_completeness_report,
}; };
fn disk_with_state(endpoint: &str, state: &str) -> Disk { fn disk_with_state(endpoint: &str, state: &str) -> Disk {
@@ -990,75 +960,6 @@ mod tests {
assert_eq!(usage.error.as_deref(), Some(DATA_USAGE_UNAVAILABLE_ERROR)); assert_eq!(usage.error.as_deref(), Some(DATA_USAGE_UNAVAILABLE_ERROR));
} }
#[test]
fn live_bucket_namespace_count_survives_unavailable_data_usage() {
let mut buckets = rustfs_madmin::Buckets {
count: 0,
error: Some(DATA_USAGE_UNAVAILABLE_ERROR.to_string()),
};
apply_bucket_namespace_count(
Ok(crate::cluster::rpc::ScannerBucketListing {
buckets: vec![
BucketInfo {
name: "bucket-a".to_string(),
..Default::default()
},
BucketInfo {
name: ".rustfs.sys".to_string(),
..Default::default()
},
BucketInfo {
name: "bucket-b".to_string(),
..Default::default()
},
],
set_buckets: Vec::new(),
topology_complete: true,
}),
&mut buckets,
);
assert_eq!(buckets.count, 2);
assert_eq!(buckets.error, None);
}
#[test]
fn incomplete_bucket_namespace_lookup_preserves_usage_state() {
let mut buckets = rustfs_madmin::Buckets {
count: 7,
error: Some(DATA_USAGE_UNAVAILABLE_ERROR.to_string()),
};
apply_bucket_namespace_count(
Ok(crate::cluster::rpc::ScannerBucketListing {
buckets: vec![BucketInfo {
name: "bucket-a".to_string(),
..Default::default()
}],
set_buckets: Vec::new(),
topology_complete: false,
}),
&mut buckets,
);
assert_eq!(buckets.count, 7);
assert_eq!(buckets.error.as_deref(), Some(DATA_USAGE_UNAVAILABLE_ERROR));
}
#[test]
fn failed_bucket_namespace_lookup_preserves_usage_state() {
let mut buckets = rustfs_madmin::Buckets {
count: 7,
error: Some(DATA_USAGE_UNAVAILABLE_ERROR.to_string()),
};
apply_bucket_namespace_count(Err(crate::error::Error::DiskNotFound), &mut buckets);
assert_eq!(buckets.count, 7);
assert_eq!(buckets.error.as_deref(), Some(DATA_USAGE_UNAVAILABLE_ERROR));
}
#[test] #[test]
fn incomplete_erasure_set_cache_is_not_reported_as_zero() { fn incomplete_erasure_set_cache_is_not_reported_as_zero() {
let mut cache = rustfs_data_usage::DataUsageCache::default(); let mut cache = rustfs_data_usage::DataUsageCache::default();
-62
View File
@@ -46,49 +46,21 @@ pub(crate) const GET_CODEC_STREAMING_OBJECT_CLASS_MULTIPART: &str = "multipart";
pub(crate) const GET_STAGE_DECODE: &str = "decode"; pub(crate) const GET_STAGE_DECODE: &str = "decode";
pub(crate) const GET_STAGE_EMIT: &str = "emit"; pub(crate) const GET_STAGE_EMIT: &str = "emit";
pub(crate) const GET_STAGE_FILL: &str = "fill"; pub(crate) const GET_STAGE_FILL: &str = "fill";
#[allow(
dead_code,
reason = "GET stage vocabulary; value pinned by this file's tests, no writer yet (backlog#1823)"
)]
pub(crate) const GET_STAGE_FIRST_BYTE: &str = "first_byte"; pub(crate) const GET_STAGE_FIRST_BYTE: &str = "first_byte";
#[allow(
dead_code,
reason = "GET stage vocabulary; value pinned by this file's tests, no writer yet (backlog#1823)"
)]
pub(crate) const GET_STAGE_FIRST_METADATA_RESPONSE: &str = "first_metadata_response"; pub(crate) const GET_STAGE_FIRST_METADATA_RESPONSE: &str = "first_metadata_response";
#[allow(
dead_code,
reason = "GET stage vocabulary; value pinned by this file's tests, no writer yet (backlog#1823)"
)]
pub(crate) const GET_STAGE_FIRST_VALID_METADATA_RESPONSE: &str = "first_valid_metadata_response"; pub(crate) const GET_STAGE_FIRST_VALID_METADATA_RESPONSE: &str = "first_valid_metadata_response";
#[allow(
dead_code,
reason = "GET stage vocabulary; value pinned by this file's tests, no writer yet (backlog#1823)"
)]
pub(crate) const GET_STAGE_FIRST_SHARD_READ: &str = "first_shard_read"; pub(crate) const GET_STAGE_FIRST_SHARD_READ: &str = "first_shard_read";
#[allow(
dead_code,
reason = "GET stage vocabulary; value pinned by this file's tests, no writer yet (backlog#1823)"
)]
pub(crate) const GET_STAGE_FULL_BODY: &str = "full_body"; pub(crate) const GET_STAGE_FULL_BODY: &str = "full_body";
pub(crate) const GET_STAGE_INLINE_PREPARE: &str = "inline_prepare"; pub(crate) const GET_STAGE_INLINE_PREPARE: &str = "inline_prepare";
pub(crate) const GET_STAGE_LOCK_ACQUIRE: &str = "lock_acquire"; pub(crate) const GET_STAGE_LOCK_ACQUIRE: &str = "lock_acquire";
pub(crate) const GET_STAGE_METADATA: &str = "metadata"; pub(crate) const GET_STAGE_METADATA: &str = "metadata";
pub(crate) const GET_STAGE_METADATA_CACHE_LOOKUP: &str = "metadata_cache_lookup"; pub(crate) const GET_STAGE_METADATA_CACHE_LOOKUP: &str = "metadata_cache_lookup";
#[allow(
dead_code,
reason = "GET stage vocabulary; value pinned by this file's tests, no writer yet (backlog#1823)"
)]
pub(crate) const GET_STAGE_METADATA_FANOUT: &str = "metadata_fanout"; pub(crate) const GET_STAGE_METADATA_FANOUT: &str = "metadata_fanout";
pub(crate) const GET_STAGE_METADATA_RESOLVE: &str = "metadata_resolve"; pub(crate) const GET_STAGE_METADATA_RESOLVE: &str = "metadata_resolve";
pub(crate) const GET_STAGE_OBJECT_INFO: &str = "object_info"; pub(crate) const GET_STAGE_OBJECT_INFO: &str = "object_info";
pub(crate) const GET_STAGE_OUTPUT_LOCK_WAIT: &str = "output_lock_wait"; pub(crate) const GET_STAGE_OUTPUT_LOCK_WAIT: &str = "output_lock_wait";
pub(crate) const GET_STAGE_OUTPUT_POLL: &str = "output_poll"; pub(crate) const GET_STAGE_OUTPUT_POLL: &str = "output_poll";
pub(crate) const GET_STAGE_PATH_DECISION: &str = "path_decision"; pub(crate) const GET_STAGE_PATH_DECISION: &str = "path_decision";
#[allow(
dead_code,
reason = "GET stage vocabulary; value pinned by this file's tests, no writer yet (backlog#1823)"
)]
pub(crate) const GET_STAGE_QUORUM_REACHED: &str = "quorum_reached"; pub(crate) const GET_STAGE_QUORUM_REACHED: &str = "quorum_reached";
pub(crate) const GET_STAGE_RANGE: &str = "range"; pub(crate) const GET_STAGE_RANGE: &str = "range";
pub(crate) const GET_STAGE_READER_SETUP: &str = "reader_setup"; pub(crate) const GET_STAGE_READER_SETUP: &str = "reader_setup";
@@ -112,28 +84,12 @@ pub(crate) const GET_STAGE_READER_STREAM_FIRST_READ: &str = "reader_stream_first
pub(crate) const GET_STAGE_READER_TASK_BITROT_READER_INIT: &str = "reader_task_bitrot_reader_init"; pub(crate) const GET_STAGE_READER_TASK_BITROT_READER_INIT: &str = "reader_task_bitrot_reader_init";
pub(crate) const GET_STAGE_READER_TASK_FILE_OPEN: &str = "reader_task_file_open"; pub(crate) const GET_STAGE_READER_TASK_FILE_OPEN: &str = "reader_task_file_open";
pub(crate) const GET_STAGE_READER_TASK_READER_CONSTRUCTION: &str = "reader_task_reader_construction"; pub(crate) const GET_STAGE_READER_TASK_READER_CONSTRUCTION: &str = "reader_task_reader_construction";
pub(crate) const GET_STAGE_READ_VERSION_DECODE: &str = "read_version_decode";
pub(crate) const GET_STAGE_READ_VERSION_PATH_CHECK: &str = "read_version_path_check";
pub(crate) const GET_STAGE_READ_VERSION_PATH_RESOLVE: &str = "read_version_path_resolve";
pub(crate) const GET_STAGE_READ_VERSION_XLMETA_READ: &str = "read_version_xlmeta_read";
pub(crate) const GET_STAGE_RECONSTRUCT: &str = "reconstruct"; pub(crate) const GET_STAGE_RECONSTRUCT: &str = "reconstruct";
#[allow(
dead_code,
reason = "GET stage vocabulary; value pinned by this file's tests, no writer yet (backlog#1823)"
)]
pub(crate) const GET_STAGE_RESPONSE_HANDOFF: &str = "response_handoff"; pub(crate) const GET_STAGE_RESPONSE_HANDOFF: &str = "response_handoff";
#[allow(
dead_code,
reason = "GET stage vocabulary; value pinned by this file's tests, no writer yet (backlog#1823)"
)]
pub(crate) const GET_STAGE_SLOWEST_METADATA_RESPONSE: &str = "slowest_metadata_response"; pub(crate) const GET_STAGE_SLOWEST_METADATA_RESPONSE: &str = "slowest_metadata_response";
pub(crate) const GET_STAGE_STRIPE_READ: &str = "stripe_read"; pub(crate) const GET_STAGE_STRIPE_READ: &str = "stripe_read";
pub(crate) const GET_STAGE_STRIPE_READ_FIRST_SHARD: &str = "stripe_read_first_shard"; pub(crate) const GET_STAGE_STRIPE_READ_FIRST_SHARD: &str = "stripe_read_first_shard";
pub(crate) const GET_STAGE_STRIPE_READ_QUORUM: &str = "stripe_read_quorum"; pub(crate) const GET_STAGE_STRIPE_READ_QUORUM: &str = "stripe_read_quorum";
#[allow(
dead_code,
reason = "GET stage vocabulary; value pinned by this file's tests, no writer yet (backlog#1823)"
)]
pub(crate) const GET_STAGE_BITROT_VERIFY: &str = "bitrot_verify"; pub(crate) const GET_STAGE_BITROT_VERIFY: &str = "bitrot_verify";
pub(crate) const GET_READER_BUFFER_OUTPUT: &str = "output"; pub(crate) const GET_READER_BUFFER_OUTPUT: &str = "output";
@@ -181,7 +137,6 @@ pub(crate) const GET_METADATA_CACHE_REASON_NO_LOCK: &str = "no_lock";
pub(crate) const GET_METADATA_CACHE_REASON_NOT_FOUND_OR_EXPIRED: &str = "not_found_or_expired"; pub(crate) const GET_METADATA_CACHE_REASON_NOT_FOUND_OR_EXPIRED: &str = "not_found_or_expired";
pub(crate) const GET_METADATA_CACHE_REASON_NOT_READ_DATA: &str = "not_read_data"; pub(crate) const GET_METADATA_CACHE_REASON_NOT_READ_DATA: &str = "not_read_data";
pub(crate) const GET_METADATA_CACHE_REASON_PART_NUMBER: &str = "part_number"; pub(crate) const GET_METADATA_CACHE_REASON_PART_NUMBER: &str = "part_number";
pub(crate) const GET_METADATA_CACHE_REASON_PART_CHECKSUMS: &str = "part_checksums";
pub(crate) const GET_METADATA_CACHE_REASON_RAW_DATA_MOVEMENT_READ: &str = "raw_data_movement_read"; pub(crate) const GET_METADATA_CACHE_REASON_RAW_DATA_MOVEMENT_READ: &str = "raw_data_movement_read";
pub(crate) const GET_METADATA_CACHE_REASON_STALE_PUBLICATION: &str = "stale_publication"; pub(crate) const GET_METADATA_CACHE_REASON_STALE_PUBLICATION: &str = "stale_publication";
pub(crate) const GET_METADATA_CACHE_REASON_USABLE: &str = "usable"; pub(crate) const GET_METADATA_CACHE_REASON_USABLE: &str = "usable";
@@ -199,20 +154,8 @@ pub(crate) const GET_METADATA_EARLY_STOP_REASON_VERSION_NOT_FOUND: &str = "versi
pub(crate) const GET_METADATA_EARLY_STOP_REASON_VERSION_MATCH_QUORUM: &str = "version_match_quorum"; pub(crate) const GET_METADATA_EARLY_STOP_REASON_VERSION_MATCH_QUORUM: &str = "version_match_quorum";
/// Early-stop active state labels /// Early-stop active state labels
#[allow(
dead_code,
reason = "GET stage vocabulary; value pinned by this file's tests, no writer yet (backlog#1823)"
)]
pub(crate) const EARLY_STOP_ACTIVE_HIT: &str = "hit"; pub(crate) const EARLY_STOP_ACTIVE_HIT: &str = "hit";
#[allow(
dead_code,
reason = "GET stage vocabulary; value pinned by this file's tests, no writer yet (backlog#1823)"
)]
pub(crate) const EARLY_STOP_ACTIVE_MISS: &str = "miss"; pub(crate) const EARLY_STOP_ACTIVE_MISS: &str = "miss";
#[allow(
dead_code,
reason = "GET stage vocabulary; value pinned by this file's tests, no writer yet (backlog#1823)"
)]
pub(crate) const EARLY_STOP_ACTIVE_DISABLED: &str = "disabled"; pub(crate) const EARLY_STOP_ACTIVE_DISABLED: &str = "disabled";
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -498,10 +441,6 @@ mod tests {
assert_eq!(GET_STAGE_QUORUM_REACHED, "quorum_reached"); assert_eq!(GET_STAGE_QUORUM_REACHED, "quorum_reached");
assert_eq!(GET_STAGE_RANGE, "range"); assert_eq!(GET_STAGE_RANGE, "range");
assert_eq!(GET_STAGE_READER_SETUP, "reader_setup"); assert_eq!(GET_STAGE_READER_SETUP, "reader_setup");
assert_eq!(GET_STAGE_READ_VERSION_DECODE, "read_version_decode");
assert_eq!(GET_STAGE_READ_VERSION_PATH_CHECK, "read_version_path_check");
assert_eq!(GET_STAGE_READ_VERSION_PATH_RESOLVE, "read_version_path_resolve");
assert_eq!(GET_STAGE_READ_VERSION_XLMETA_READ, "read_version_xlmeta_read");
assert_eq!(GET_STAGE_RECONSTRUCT, "reconstruct"); assert_eq!(GET_STAGE_RECONSTRUCT, "reconstruct");
assert_eq!(GET_STAGE_RESPONSE_HANDOFF, "response_handoff"); assert_eq!(GET_STAGE_RESPONSE_HANDOFF, "response_handoff");
assert_eq!(GET_STAGE_SLOWEST_METADATA_RESPONSE, "slowest_metadata_response"); assert_eq!(GET_STAGE_SLOWEST_METADATA_RESPONSE, "slowest_metadata_response");
@@ -541,7 +480,6 @@ mod tests {
assert_eq!(GET_METADATA_CACHE_REASON_NO_LOCK, "no_lock"); assert_eq!(GET_METADATA_CACHE_REASON_NO_LOCK, "no_lock");
assert_eq!(GET_METADATA_CACHE_REASON_NOT_FOUND_OR_EXPIRED, "not_found_or_expired"); assert_eq!(GET_METADATA_CACHE_REASON_NOT_FOUND_OR_EXPIRED, "not_found_or_expired");
assert_eq!(GET_METADATA_CACHE_REASON_NOT_READ_DATA, "not_read_data"); assert_eq!(GET_METADATA_CACHE_REASON_NOT_READ_DATA, "not_read_data");
assert_eq!(GET_METADATA_CACHE_REASON_PART_CHECKSUMS, "part_checksums");
assert_eq!(GET_METADATA_CACHE_REASON_PART_NUMBER, "part_number"); assert_eq!(GET_METADATA_CACHE_REASON_PART_NUMBER, "part_number");
assert_eq!(GET_METADATA_CACHE_REASON_RAW_DATA_MOVEMENT_READ, "raw_data_movement_read"); assert_eq!(GET_METADATA_CACHE_REASON_RAW_DATA_MOVEMENT_READ, "raw_data_movement_read");
assert_eq!(GET_METADATA_CACHE_REASON_STALE_PUBLICATION, "stale_publication"); assert_eq!(GET_METADATA_CACHE_REASON_STALE_PUBLICATION, "stale_publication");
+2
View File
@@ -13,6 +13,8 @@
// limitations under the License. // limitations under the License.
// #730: diagnostics constants are staged for request-path telemetry migration. // #730: diagnostics constants are staged for request-path telemetry migration.
#![allow(dead_code)]
pub(crate) mod admin_server_info; pub(crate) mod admin_server_info;
pub(crate) mod get; pub(crate) mod get;
pub(crate) mod pool;
+30
View File
@@ -0,0 +1,30 @@
// 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.
//! BytesPool metric label constants.
//!
//! These constants are used when recording pool acquisition and return
//! metrics to avoid string allocations and ensure label consistency.
/// BytesPool tier labels
pub const POOL_TIER_SMALL: &str = "small";
pub const POOL_TIER_MEDIUM: &str = "medium";
pub const POOL_TIER_LARGE: &str = "large";
pub const POOL_TIER_XLARGE: &str = "xlarge";
/// BytesPool outcome labels
pub const POOL_OUTCOME_HIT: &str = "hit";
pub const POOL_OUTCOME_MISS: &str = "miss";
pub const POOL_OUTCOME_RECYCLED: &str = "recycled";
pub const POOL_OUTCOME_DROPPED: &str = "dropped";
+7 -51
View File
@@ -241,40 +241,6 @@ pub fn get_drive_list_dir_timeout() -> Duration {
) )
} }
pub(crate) trait DiskStoreRenameDataExt {
async fn rename_data_borrowed(
&self,
src_volume: &str,
src_path: &str,
fi: &FileInfo,
dst_volume: &str,
dst_path: &str,
) -> Result<RenameDataResp>;
}
impl DiskStoreRenameDataExt for LocalDiskWrapper {
async fn rename_data_borrowed(
&self,
src_volume: &str,
src_path: &str,
fi: &FileInfo,
dst_volume: &str,
dst_path: &str,
) -> Result<RenameDataResp> {
self.track_disk_health_mutation(
"rename_data",
DiskMetricMutation::Write,
|| async {
self.disk
.rename_data_borrowed(src_volume, src_path, fi, dst_volume, dst_path)
.await
},
get_max_timeout_duration(),
)
.await
}
}
pub fn get_drive_walkdir_timeout() -> Duration { pub fn get_drive_walkdir_timeout() -> Duration {
get_drive_timeout_duration( get_drive_timeout_duration(
rustfs_config::ENV_DRIVE_WALKDIR_TIMEOUT_SECS, rustfs_config::ENV_DRIVE_WALKDIR_TIMEOUT_SECS,
@@ -2019,8 +1985,13 @@ impl DiskAPI for LocalDiskWrapper {
dst_volume: &str, dst_volume: &str,
dst_path: &str, dst_path: &str,
) -> Result<RenameDataResp> { ) -> Result<RenameDataResp> {
self.rename_data_borrowed(src_volume, src_path, &fi, dst_volume, dst_path) self.track_disk_health_mutation(
.await "rename_data",
DiskMetricMutation::Write,
|| async { self.disk.rename_data(src_volume, src_path, fi, dst_volume, dst_path).await },
get_max_timeout_duration(),
)
.await
} }
async fn list_dir(&self, origvolume: &str, volume: &str, dir_path: &str, count: i32) -> Result<Vec<String>> { async fn list_dir(&self, origvolume: &str, volume: &str, dir_path: &str, count: i32) -> Result<Vec<String>> {
@@ -2051,21 +2022,6 @@ impl DiskAPI for LocalDiskWrapper {
.await .await
} }
async fn read_file_stream_chunks(
&self,
volume: &str,
path: &str,
offset: usize,
length: usize,
) -> Result<Option<rustfs_rio::ChunkReaderBox>> {
self.track_disk_health_with_op(
"read_file_stream_chunks",
|| async { self.disk.read_file_stream_chunks(volume, path, offset, length).await },
get_max_timeout_duration(),
)
.await
}
async fn read_file_mmap_copy(&self, volume: &str, path: &str, offset: usize, length: usize) -> Result<bytes::Bytes> { async fn read_file_mmap_copy(&self, volume: &str, path: &str, offset: usize, length: usize) -> Result<bytes::Bytes> {
self.track_disk_health_with_op( self.track_disk_health_with_op(
"read_file_mmap_copy", "read_file_mmap_copy",
+27 -35
View File
@@ -113,9 +113,6 @@ pub enum DiskError {
#[error("bit-rot hash algorithm is invalid")] #[error("bit-rot hash algorithm is invalid")]
BitrotHashAlgoInvalid, BitrotHashAlgoInvalid,
/// Never constructed locally by RustFS (only reachable through wire
/// decoding, and no current node sends it). The wire code is kept for
/// cross-version compatibility — do not renumber or remove (backlog#1831).
#[error("Rename across devices not allowed, please fix your backend configuration")] #[error("Rename across devices not allowed, please fix your backend configuration")]
CrossDeviceLink, CrossDeviceLink,
@@ -146,9 +143,6 @@ pub enum DiskError {
#[error("io error {0}")] #[error("io error {0}")]
Io(#[source] io::Error), Io(#[source] io::Error),
/// Never constructed locally by RustFS (only reachable through wire
/// decoding, and no current node sends it). The wire code is kept for
/// cross-version compatibility — do not renumber or remove (backlog#1831).
#[error("source stalled")] #[error("source stalled")]
SourceStalled, SourceStalled,
@@ -337,14 +331,7 @@ impl From<std::io::Error> for DiskError {
} }
match e.downcast::<DiskError>() { match e.downcast::<DiskError>() {
Ok(disk_error) => disk_error, Ok(disk_error) => disk_error,
// Mirror `From<io::Error> for StorageError`: a StorageError boxed Err(io_error) => DiskError::Io(io_error),
// through `From<StorageError> for io::Error` must recover its typed
// classification instead of degrading to `DiskError::Io`, which
// quorum aggregation (`reduce_errs`) would count as a distinct error.
Err(io_error) => match io_error.downcast::<crate::error::StorageError>() {
Ok(storage_error) => storage_error.into(),
Err(io_error) => DiskError::Io(io_error),
},
} }
} }
} }
@@ -648,6 +635,19 @@ impl Hash for DiskError {
// is currently commented out to avoid complexity. These can be re-enabled // is currently commented out to avoid complexity. These can be re-enabled
// when needed for specific disk quorum checking and error aggregation logic. // when needed for specific disk quorum checking and error aggregation logic.
/// Bitrot errors
#[derive(Debug, thiserror::Error)]
pub enum BitrotErrorType {
#[error("bitrot checksum verification failed")]
BitrotChecksumMismatch { expected: String, got: String },
}
impl From<BitrotErrorType> for DiskError {
fn from(e: BitrotErrorType) -> Self {
DiskError::other(e)
}
}
/// Context wrapper for file access errors /// Context wrapper for file access errors
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub struct FileAccessDeniedWithContext { pub struct FileAccessDeniedWithContext {
@@ -862,6 +862,19 @@ mod tests {
let _disk_error: DiskError = json_error.into(); let _disk_error: DiskError = json_error.into();
} }
#[test]
fn test_bitrot_error_type() {
let bitrot_error = BitrotErrorType::BitrotChecksumMismatch {
expected: "abc123".to_string(),
got: "def456".to_string(),
};
assert!(bitrot_error.to_string().contains("bitrot checksum verification failed"));
let disk_error: DiskError = bitrot_error.into();
assert!(matches!(disk_error, DiskError::Io(_)));
}
#[test] #[test]
fn test_file_access_denied_with_context() { fn test_file_access_denied_with_context() {
let path = PathBuf::from("/test/path"); let path = PathBuf::from("/test/path");
@@ -940,27 +953,6 @@ mod tests {
assert_eq!(original_disk_error, recovered_disk_error); assert_eq!(original_disk_error, recovered_disk_error);
} }
#[test]
fn test_io_error_with_storage_error_inside() {
use crate::error::StorageError;
// An io::Error boxing a disk-representable StorageError (as produced by
// `From<StorageError> for io::Error`) must recover the typed DiskError
// variant instead of degrading to an opaque DiskError::Io.
let io_with_storage_error: std::io::Error = StorageError::FaultyRemoteDisk.into();
let recovered: DiskError = io_with_storage_error.into();
assert_eq!(recovered, DiskError::FaultyRemoteDisk);
let io_with_storage_error: std::io::Error = StorageError::FileAccessDenied.into();
let recovered: DiskError = io_with_storage_error.into();
assert_eq!(recovered, DiskError::FileAccessDenied);
// A StorageError with no DiskError analog stays an opaque Io error.
let io_with_bucket_error: std::io::Error = StorageError::BucketNotFound("bucket".to_string()).into();
let recovered: DiskError = io_with_bucket_error.into();
assert!(matches!(recovered, DiskError::Io(_)));
}
#[test] #[test]
fn test_io_error_different_kinds() { fn test_io_error_different_kinds() {
use std::io::ErrorKind; use std::io::ErrorKind;
File diff suppressed because it is too large Load Diff
+5 -90
View File
@@ -55,7 +55,6 @@ pub fn part_transaction_path(part_path: &str) -> String {
use crate::cluster::rpc::RemoteDisk; use crate::cluster::rpc::RemoteDisk;
use crate::cluster::rpc::build_internode_data_transport_from_env; use crate::cluster::rpc::build_internode_data_transport_from_env;
use crate::disk::disk_store::DiskStoreRenameDataExt;
use crate::disk::disk_store::LocalDiskWrapper; use crate::disk::disk_store::LocalDiskWrapper;
use crate::disk::health_state::RuntimeDriveHealthState; use crate::disk::health_state::RuntimeDriveHealthState;
use crate::disk::local::ScanGuard; use crate::disk::local::ScanGuard;
@@ -66,35 +65,12 @@ use error::{Error, Result};
use local::LocalDisk; use local::LocalDisk;
use rustfs_filemeta::{FileInfo, ObjectPartInfo, RawFileInfo}; use rustfs_filemeta::{FileInfo, ObjectPartInfo, RawFileInfo};
use rustfs_madmin::info_commands::DiskMetrics; use rustfs_madmin::info_commands::DiskMetrics;
use rustfs_rio::ChunkReaderBox;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::{fmt::Debug, path::PathBuf, sync::Arc, time::Duration}; use std::{fmt::Debug, path::PathBuf, sync::Arc, time::Duration};
use time::OffsetDateTime; use time::OffsetDateTime;
use tokio::io::{AsyncRead, AsyncWrite}; use tokio::io::{AsyncRead, AsyncWrite};
use uuid::Uuid; use uuid::Uuid;
const QUOTA_MUTATION_FENCE_PREFIX: &str = "tmp/quota-mutation-fences/";
pub(crate) const QUOTA_MUTATION_FENCE_METADATA_SUFFIX: &str = "quota-mutation-fence-token";
pub(crate) fn quota_mutation_fence_path(bucket: &str, object: &str) -> String {
use sha2::{Digest, Sha256};
let mut input = Vec::with_capacity(bucket.len() + object.len() + 1);
input.extend_from_slice(bucket.as_bytes());
input.push(0);
input.extend_from_slice(object.as_bytes());
let digest = Sha256::digest(input);
format!(
"{QUOTA_MUTATION_FENCE_PREFIX}{}",
hex_simd::encode_to_string(digest, hex_simd::AsciiCase::Lower)
)
}
pub(crate) fn is_quota_mutation_fence_path(path: &str) -> bool {
path.strip_prefix(QUOTA_MUTATION_FENCE_PREFIX)
.is_some_and(|digest| digest.len() == 64 && digest.bytes().all(|byte| byte.is_ascii_hexdigit()))
}
pub type DiskStore = Arc<Disk>; pub type DiskStore = Arc<Disk>;
pub type FileReader = Box<dyn AsyncRead + Send + Sync + Unpin>; pub type FileReader = Box<dyn AsyncRead + Send + Sync + Unpin>;
@@ -119,20 +95,6 @@ impl SnapshotLeaseToken {
pub fn as_bytes(&self) -> &[u8; 16] { pub fn as_bytes(&self) -> &[u8; 16] {
self.0.as_bytes() self.0.as_bytes()
} }
pub(crate) fn as_uuid(self) -> Uuid {
self.0
}
#[doc(hidden)]
pub fn revoke_all() -> Self {
Self(Uuid::nil())
}
#[doc(hidden)]
pub fn is_revoke_all(self) -> bool {
self.0.is_nil()
}
} }
impl Default for SnapshotLeaseToken { impl Default for SnapshotLeaseToken {
@@ -435,8 +397,10 @@ impl DiskAPI for Disk {
dst_volume: &str, dst_volume: &str,
dst_path: &str, dst_path: &str,
) -> Result<RenameDataResp> { ) -> Result<RenameDataResp> {
self.rename_data_borrowed(src_volume, src_path, &fi, dst_volume, dst_path) match self {
.await Disk::Local(local_disk) => local_disk.rename_data(src_volume, src_path, fi, dst_volume, dst_path).await,
Disk::Remote(remote_disk) => remote_disk.rename_data(src_volume, src_path, fi, dst_volume, dst_path).await,
}
} }
#[tracing::instrument(level = "trace", skip_all)] #[tracing::instrument(level = "trace", skip_all)]
@@ -463,19 +427,6 @@ impl DiskAPI for Disk {
} }
} }
async fn read_file_stream_chunks(
&self,
volume: &str,
path: &str,
offset: usize,
length: usize,
) -> Result<Option<ChunkReaderBox>> {
match self {
Disk::Local(_) => Ok(None),
Disk::Remote(remote_disk) => remote_disk.read_file_stream_chunks(volume, path, offset, length).await,
}
}
#[tracing::instrument(level = "trace", skip_all)] #[tracing::instrument(level = "trace", skip_all)]
async fn read_file_mmap_copy(&self, volume: &str, path: &str, offset: usize, length: usize) -> Result<Bytes> { async fn read_file_mmap_copy(&self, volume: &str, path: &str, offset: usize, length: usize) -> Result<Bytes> {
match self { match self {
@@ -666,30 +617,6 @@ impl DiskAPI for Disk {
} }
} }
impl Disk {
pub(crate) async fn rename_data_borrowed(
&self,
src_volume: &str,
src_path: &str,
fi: &FileInfo,
dst_volume: &str,
dst_path: &str,
) -> Result<RenameDataResp> {
match self {
Disk::Local(local_disk) => {
local_disk
.rename_data_borrowed(src_volume, src_path, fi, dst_volume, dst_path)
.await
}
Disk::Remote(remote_disk) => {
remote_disk
.rename_data_borrowed(src_volume, src_path, fi, dst_volume, dst_path)
.await
}
}
}
}
impl Disk { impl Disk {
pub async fn ns_scanner_server_epoch(&self) -> Result<Option<Uuid>> { pub async fn ns_scanner_server_epoch(&self) -> Result<Option<Uuid>> {
match self { match self {
@@ -938,18 +865,6 @@ pub trait DiskAPI: Debug + Send + Sync + 'static {
async fn read_file(&self, volume: &str, path: &str) -> Result<FileReader>; async fn read_file(&self, volume: &str, path: &str) -> Result<FileReader>;
async fn read_file_stream(&self, volume: &str, path: &str, offset: usize, length: usize) -> Result<FileReader>; async fn read_file_stream(&self, volume: &str, path: &str, offset: usize, length: usize) -> Result<FileReader>;
/// Returns an owned-chunk stream when the backing transport can preserve
/// receive-buffer ownership. `None` retains the ordinary reader path.
async fn read_file_stream_chunks(
&self,
_volume: &str,
_path: &str,
_offset: usize,
_length: usize,
) -> Result<Option<ChunkReaderBox>> {
Ok(None)
}
/// File read using mmap-then-copy on Unix or an efficient read on non-Unix. /// File read using mmap-then-copy on Unix or an efficient read on non-Unix.
async fn read_file_mmap_copy(&self, volume: &str, path: &str, offset: usize, length: usize) -> Result<Bytes>; async fn read_file_mmap_copy(&self, volume: &str, path: &str, offset: usize, length: usize) -> Result<Bytes>;
@@ -1336,7 +1251,7 @@ pub struct VolumeInfo {
pub created: Option<OffsetDateTime>, pub created: Option<OffsetDateTime>,
} }
#[derive(Deserialize, Serialize, Debug, Default, Clone, Copy)] #[derive(Deserialize, Serialize, Debug, Default, Clone)]
pub struct ReadOptions { pub struct ReadOptions {
pub incl_free_versions: bool, pub incl_free_versions: bool,
pub read_data: bool, pub read_data: bool,
+12 -199
View File
@@ -78,59 +78,28 @@ pub fn check_path_length(path_name: &str) -> Result<()> {
/// their own unique tempdir to stay robust against parallel test execution. /// their own unique tempdir to stay robust against parallel test execution.
#[cfg(test)] #[cfg(test)]
pub(crate) mod fsync_dir_recorder { pub(crate) mod fsync_dir_recorder {
use std::collections::HashMap;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::Mutex; use std::sync::Mutex;
type Hook = Box<dyn FnOnce() + Send>;
static RECORDED: Mutex<Vec<PathBuf>> = Mutex::new(Vec::new()); static RECORDED: Mutex<Vec<PathBuf>> = Mutex::new(Vec::new());
static LIMITED: Mutex<Vec<PathBuf>> = Mutex::new(Vec::new());
static BEFORE_LIMITED: std::sync::LazyLock<Mutex<HashMap<PathBuf, Hook>>> =
std::sync::LazyLock::new(|| Mutex::new(HashMap::new()));
fn record_path(paths: &Mutex<Vec<PathBuf>>, path: &Path, description: &str) {
let mut paths = paths.lock().expect(description);
paths.push(path.to_path_buf());
if let Ok(canonical) = path.canonicalize()
&& canonical != path
{
paths.push(canonical);
}
}
fn contains_path(paths: &[PathBuf], path: &Path) -> bool {
let canonical = path.canonicalize().ok();
paths
.iter()
.any(|recorded| recorded == path || canonical.as_ref().is_some_and(|canonical| recorded == canonical))
}
pub(crate) fn record(dir: &Path) { pub(crate) fn record(dir: &Path) {
record_path(&RECORDED, dir, "fsync dir recorder"); let mut recorded = RECORDED.lock().expect("fsync dir recorder poisoned");
recorded.push(dir.to_path_buf());
if let Ok(canonical) = dir.canonicalize()
&& canonical != dir
{
recorded.push(canonical);
}
} }
pub(crate) fn was_fsynced(dir: &Path) -> bool { pub(crate) fn was_fsynced(dir: &Path) -> bool {
contains_path(&RECORDED.lock().expect("fsync dir recorder poisoned"), dir) let canonical = dir.canonicalize().ok();
} RECORDED
pub(crate) fn record_limited(dir: &Path) {
record_path(&LIMITED, dir, "limited fsync dir recorder");
let hook = BEFORE_LIMITED.lock().expect("limited fsync hook poisoned").remove(dir);
if let Some(hook) = hook {
hook();
}
}
pub(crate) fn was_limited(dir: &Path) -> bool {
contains_path(&LIMITED.lock().expect("limited fsync dir recorder poisoned"), dir)
}
pub(crate) fn set_before_limited(dir: &Path, hook: impl FnOnce() + Send + 'static) {
BEFORE_LIMITED
.lock() .lock()
.expect("limited fsync hook poisoned") .expect("fsync dir recorder poisoned")
.insert(dir.to_path_buf(), Box::new(hook)); .iter()
.any(|p| p == dir || canonical.as_ref().is_some_and(|canonical| p == canonical))
} }
} }
@@ -306,20 +275,12 @@ fn disk_namespace_mutation_lock(path: &Path) -> Arc<NamespaceMutationLock> {
pub(crate) struct NamespaceMutationLease { pub(crate) struct NamespaceMutationLease {
_namespace_guard: OwnedMutexGuard<()>, _namespace_guard: OwnedMutexGuard<()>,
_volume_guard: Option<OwnedRwLockReadGuard<()>>, _volume_guard: Option<OwnedRwLockReadGuard<()>>,
external_guard: Mutex<Option<Arc<dyn Send + Sync>>>,
}
impl NamespaceMutationLease {
pub(crate) fn attach_external_guard(&self, guard: Arc<dyn Send + Sync>) {
*self.external_guard.lock() = Some(guard);
}
} }
async fn acquire_namespace_mutation_lease(path: &Path) -> Arc<NamespaceMutationLease> { async fn acquire_namespace_mutation_lease(path: &Path) -> Arc<NamespaceMutationLease> {
Arc::new(NamespaceMutationLease { Arc::new(NamespaceMutationLease {
_namespace_guard: disk_namespace_mutation_lock(path).lock_owned().await, _namespace_guard: disk_namespace_mutation_lock(path).lock_owned().await,
_volume_guard: None, _volume_guard: None,
external_guard: Mutex::new(None),
}) })
} }
@@ -335,7 +296,6 @@ pub(crate) async fn acquire_rename_data_mutation_lease(
Arc::new(NamespaceMutationLease { Arc::new(NamespaceMutationLease {
_namespace_guard: namespace_guard, _namespace_guard: namespace_guard,
_volume_guard: Some(volume_guard), _volume_guard: Some(volume_guard),
external_guard: Mutex::new(None),
}) })
} }
@@ -1160,79 +1120,6 @@ pub(crate) async fn run_blocking_namespace_operation<T: Send + 'static>(
.map_err(|err| io::Error::other(format!("blocking namespace operation failed: {err}")))? .map_err(|err| io::Error::other(format!("blocking namespace operation failed: {err}")))?
} }
/// Admit one strict inline commit under the disk sync limit. The caller already
/// owns the namespace lease, establishing namespace -> disk ordering. Holding
/// admission across adjacent durability barriers prevents one transaction from
/// repeatedly joining the disk semaphore tail.
pub(crate) struct FileSyncAdmission {
disk_permit: Arc<OwnedSemaphorePermit>,
}
pub(crate) async fn acquire_file_sync_admission(disk_permits: Arc<Semaphore>) -> io::Result<FileSyncAdmission> {
let disk_permit = disk_permits
.acquire_owned()
.await
.map_err(|_| io::Error::other("disk file sync concurrency limiter closed"))?;
Ok(FileSyncAdmission {
disk_permit: Arc::new(disk_permit),
})
}
/// Keep the disk admission and namespace lease with the blocking syscall if
/// the async waiter is cancelled. The process-wide admission remains with the
/// waiter so cancellation cannot starve healthy disks.
pub(crate) async fn run_blocking_namespace_file_sync_operation<T: Send + 'static>(
lease: Arc<NamespaceMutationLease>,
admission: &FileSyncAdmission,
operation: impl FnOnce() -> io::Result<T> + Send + 'static,
) -> io::Result<T> {
run_blocking_namespace_file_sync_operation_with_global(lease, admission, &FILE_SYNC_PERMITS, operation).await
}
async fn run_blocking_namespace_file_sync_operation_with_global<T: Send + 'static>(
lease: Arc<NamespaceMutationLease>,
admission: &FileSyncAdmission,
global_permits: &Semaphore,
operation: impl FnOnce() -> io::Result<T> + Send + 'static,
) -> io::Result<T> {
let global_permit = global_permits
.acquire()
.await
.map_err(|_| io::Error::other("global file sync concurrency limiter closed"))?;
let disk_permit = admission.disk_permit.clone();
let result = tokio::task::spawn_blocking(move || {
let _lease = lease;
let _disk_permit = disk_permit;
operation()
})
.await;
drop(global_permit);
result.map_err(|err| io::Error::other(format!("blocking namespace file sync operation failed: {err}")))?
}
pub(crate) async fn fsync_dir_with_namespace_file_sync_limit(
dir: impl AsRef<Path>,
lease: Arc<NamespaceMutationLease>,
admission: &FileSyncAdmission,
) -> io::Result<()> {
#[cfg(unix)]
{
let dir = dir.as_ref().to_path_buf();
run_blocking_namespace_file_sync_operation(lease, admission, move || {
#[cfg(test)]
fsync_dir_recorder::record_limited(&dir);
fsync_dir_std(dir)
})
.await
}
#[cfg(not(unix))]
{
let _ = (lease, admission);
fsync_dir_std(dir)
}
}
struct RenamePreparation { struct RenamePreparation {
parent_guard: Option<ExistingBaseDirectoryGuard>, parent_guard: Option<ExistingBaseDirectoryGuard>,
#[cfg(windows)] #[cfg(windows)]
@@ -2897,7 +2784,6 @@ pub fn is_dir_not_empty_error(err: &io::Error) -> bool {
mod tests { mod tests {
use super::*; use super::*;
use std::sync::Mutex; use std::sync::Mutex;
use std::time::Duration;
use tempfile::tempdir; use tempfile::tempdir;
use tracing_subscriber::fmt::MakeWriter; use tracing_subscriber::fmt::MakeWriter;
@@ -4667,79 +4553,6 @@ mod tests {
fsync_dir(temp_dir.path()).await.expect("fsync dir must succeed"); fsync_dir(temp_dir.path()).await.expect("fsync dir must succeed");
} }
#[tokio::test]
async fn file_sync_admission_is_reused_across_commit_barriers() {
let temp_dir = tempdir().expect("create temp dir");
let limiter = Arc::new(Semaphore::new(1));
let lease = acquire_namespace_mutation_lease(temp_dir.path()).await;
let admission = acquire_file_sync_admission(limiter.clone())
.await
.expect("first commit should acquire admission");
run_blocking_namespace_file_sync_operation(lease.clone(), &admission, || Ok(()))
.await
.expect("first barrier should complete under the admission");
let mut waiting = Box::pin(acquire_file_sync_admission(limiter));
assert!(
futures::poll!(&mut waiting).is_pending(),
"another commit must remain queued between durability barriers"
);
run_blocking_namespace_file_sync_operation(lease, &admission, || Ok(()))
.await
.expect("later barrier should reuse admission without requeuing");
drop(admission);
tokio::time::timeout(Duration::from_secs(30), waiting)
.await
.expect("queued commit should acquire admission after release")
.expect("queued commit should acquire admission");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn cancelled_file_sync_waiter_keeps_disk_admission_until_blocking_work_finishes() {
use std::sync::mpsc;
let temp_dir = tempdir().expect("create temp dir");
let limiter = Arc::new(Semaphore::new(1));
let global_permits = Arc::new(Semaphore::new(1));
let lease = acquire_namespace_mutation_lease(temp_dir.path()).await;
let admission = acquire_file_sync_admission(limiter.clone())
.await
.expect("file sync admission should be acquired");
let (entered_tx, entered_rx) = mpsc::channel();
let (release_tx, release_rx) = mpsc::channel();
let waiter_global_permits = global_permits.clone();
let waiter = tokio::spawn(async move {
run_blocking_namespace_file_sync_operation_with_global(lease, &admission, waiter_global_permits.as_ref(), move || {
entered_tx.send(()).expect("signal blocking work");
release_rx.recv().expect("wait for blocking work release");
Ok(())
})
.await
});
tokio::task::spawn_blocking(move || entered_rx.recv_timeout(Duration::from_secs(30)))
.await
.expect("blocking work waiter should run")
.expect("blocking work should start");
waiter.abort();
assert!(waiter.await.expect_err("waiter should be cancelled").is_cancelled());
let returned_global_permit = global_permits
.try_acquire()
.expect("cancelled waiter must return global capacity for healthy disks");
assert!(
limiter.clone().try_acquire_owned().is_err(),
"cancelled waiter must not return disk capacity while blocking work is active"
);
release_tx.send(()).expect("release blocking work");
let _returned_permit = tokio::time::timeout(Duration::from_secs(30), limiter.acquire_owned())
.await
.expect("disk capacity should return after blocking work finishes")
.expect("disk limiter should remain open");
drop(returned_global_permit);
}
#[tokio::test] #[tokio::test]
#[serial_test::serial(file_sync_probe)] #[serial_test::serial(file_sync_probe)]
async fn sync_dir_files_syncs_regular_files_and_dir() { async fn sync_dir_files_syncs_regular_files_and_dir() {
@@ -26,7 +26,6 @@ pub(crate) const GET_RECONSTRUCT_OUTCOME_SKIP_DATA_COMPLETE: &str = "skip_data_c
pub(crate) const GET_RECONSTRUCT_OUTCOME_SKIP_EMPTY_PAYLOAD: &str = "skip_empty_payload"; pub(crate) const GET_RECONSTRUCT_OUTCOME_SKIP_EMPTY_PAYLOAD: &str = "skip_empty_payload";
pub(crate) trait DecodeWorkspace: Send + Sync + 'static { pub(crate) trait DecodeWorkspace: Send + Sync + 'static {
#[allow(dead_code, reason = "workspace width asserted by decode_reader tests (backlog#1823)")]
fn shard_len(&self) -> usize; fn shard_len(&self) -> usize;
} }
@@ -34,14 +33,11 @@ pub(crate) trait ErasureDecodeEngine: Send + Sync + 'static {
type Workspace: DecodeWorkspace; type Workspace: DecodeWorkspace;
fn data_shards(&self) -> usize; fn data_shards(&self) -> usize;
#[allow(dead_code, reason = "engine trait facet asserted by decode_reader tests (backlog#1823)")]
fn parity_shards(&self) -> usize; fn parity_shards(&self) -> usize;
fn block_size(&self) -> usize; fn block_size(&self) -> usize;
fn engine_name(&self) -> &'static str; fn engine_name(&self) -> &'static str;
#[allow(dead_code, reason = "engine trait facet asserted by decode_reader tests (backlog#1823)")]
fn supports_progressive_decode(&self) -> bool; fn supports_progressive_decode(&self) -> bool;
#[allow(dead_code, reason = "engine trait facet asserted by decode_reader tests (backlog#1823)")]
fn supports_aligned_shards(&self) -> bool; fn supports_aligned_shards(&self) -> bool;
fn prepare_workspace(&self, shard_len: usize) -> io::Result<Self::Workspace>; fn prepare_workspace(&self, shard_len: usize) -> io::Result<Self::Workspace>;
@@ -24,7 +24,6 @@ impl RustfsCodecDecodeWorkspace {
} }
#[inline] #[inline]
#[allow(dead_code, reason = "workspace width asserted by decode_reader tests (backlog#1823)")]
pub(crate) fn shard_len(&self) -> usize { pub(crate) fn shard_len(&self) -> usize {
self.shard_len self.shard_len
} }
@@ -77,13 +76,6 @@ impl ShardBufferPool {
self.buffers[index] = Some(buf); self.buffers[index] = Some(buf);
} }
#[cfg(test)]
pub(crate) fn stored_allocation(&self, index: usize) -> Option<(*const u8, usize)> {
self.buffers
.get(index)
.and_then(|buf| buf.as_ref().map(|buf| (buf.as_ptr(), buf.capacity())))
}
#[cfg(test)] #[cfg(test)]
fn stored_capacity(&self, index: usize) -> Option<usize> { fn stored_capacity(&self, index: usize) -> Option<usize> {
self.buffers.get(index).and_then(|buf| buf.as_ref().map(Vec::capacity)) self.buffers.get(index).and_then(|buf| buf.as_ref().map(Vec::capacity))
+26 -555
View File
@@ -14,30 +14,11 @@
use pin_project_lite::pin_project; use pin_project_lite::pin_project;
use rustfs_utils::HashAlgorithm; use rustfs_utils::HashAlgorithm;
use std::future::poll_fn;
use std::io::IoSlice; use std::io::IoSlice;
use std::pin::Pin;
use std::task::{Context, Poll};
use std::time::Duration; use std::time::Duration;
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
use tracing::error; use tracing::error;
use uuid::Uuid;
const LOG_COMPONENT_ECSTORE: &str = "ecstore";
const LOG_SUBSYSTEM_ERASURE: &str = "erasure";
const EVENT_BITROT_SHORT_SHARD_READ: &str = "bitrot_short_shard_read";
const EVENT_BITROT_HASH_MISMATCH: &str = "bitrot_hash_mismatch";
const MAX_RETAINED_CHUNKS_PER_BLOCK: usize = 64;
const MAX_CHUNK_POLLS_PER_YIELD: usize = MAX_RETAINED_CHUNKS_PER_BLOCK + 1;
/// Result of polling an optional owned-chunk handoff.
pub enum ShardChunkRead {
/// The source does not support owned-chunk handoff and remains untouched.
Unsupported,
/// The source reached EOF.
Eof,
/// A non-empty chunk containing at most the requested number of bytes.
Chunk(bytes::Bytes),
}
/// A shard source that may already hold its bytes in memory. /// A shard source that may already hold its bytes in memory.
/// ///
@@ -57,12 +38,6 @@ pub trait ShardSource: AsyncRead + Send + Sync + Unpin {
fn try_take_block(&mut self, _n: usize) -> Option<bytes::Bytes> { fn try_take_block(&mut self, _n: usize) -> Option<bytes::Bytes> {
None None
} }
/// Polls one owned chunk when the source supports chunk handoff.
/// `Unsupported` must leave the source untouched.
fn poll_read_chunk(self: Pin<&mut Self>, _cx: &mut Context<'_>, _max: usize) -> Poll<std::io::Result<ShardChunkRead>> {
Poll::Ready(Ok(ShardChunkRead::Unsupported))
}
} }
/// Borrowed and owned byte slices are ordinary streaming sources: they carry no /// Borrowed and owned byte slices are ordinary streaming sources: they carry no
@@ -96,11 +71,9 @@ pin_project! {
// contiguous on-disk `[hash][data]` block so both are pulled in a single // contiguous on-disk `[hash][data]` block so both are pulled in a single
// pass; grown lazily and never shrunk. // pass; grown lazily and never shrunk.
buf: Vec<u8>, buf: Vec<u8>,
// Reused owned chunk vector for the remote HTTP fast path. Keeping the
// allocation with the reader avoids allocating once per bitrot block.
chunks: Vec<bytes::Bytes>,
skip_verify: bool, skip_verify: bool,
last_verify_duration: Duration, last_verify_duration: Duration,
id: Uuid,
} }
} }
@@ -115,9 +88,9 @@ where
hash_algo: algo, hash_algo: algo,
shard_size, shard_size,
buf: Vec::new(), buf: Vec::new(),
chunks: Vec::new(),
skip_verify, skip_verify,
last_verify_duration: Duration::ZERO, last_verify_duration: Duration::ZERO,
id: Uuid::new_v4(),
} }
} }
@@ -125,11 +98,6 @@ where
self.last_verify_duration self.last_verify_duration
} }
#[cfg(test)]
pub(crate) fn inner_ref(&self) -> &R {
&self.inner
}
/// Read a single (hash+data) block, verify hash, and copy `out.len()` bytes /// Read a single (hash+data) block, verify hash, and copy `out.len()` bytes
/// into `out`. Returns an error if the shard is short, the hash mismatches, /// into `out`. Returns an error if the shard is short, the hash mismatches,
/// or `out` is larger than one shard. On error `out`'s contents are /// or `out` is larger than one shard. On error `out`'s contents are
@@ -150,7 +118,7 @@ where
let need = self.hash_algo.size() + want; let need = self.hash_algo.size() + want;
self.read_scratch_block(need, want).await?; self.read_scratch_block(need, want).await?;
let (data, verify) = split_and_verify(&self.hash_algo, self.skip_verify, &self.buf[..need])?; let (data, verify) = split_and_verify(&self.hash_algo, self.skip_verify, &self.buf[..need], &self.id)?;
out.copy_from_slice(data); out.copy_from_slice(data);
self.last_verify_duration = verify; self.last_verify_duration = verify;
Ok(want) Ok(want)
@@ -189,7 +157,7 @@ where
} }
let filled = fill(&mut self.inner, &mut self.buf[..need]).await?; let filled = fill(&mut self.inner, &mut self.buf[..need]).await?;
if filled < need { if filled < need {
return Err(short_shard_read(filled.saturating_sub(self.hash_algo.size()), want)); return Err(short_shard_read(&self.id, filled.saturating_sub(self.hash_algo.size()), want));
} }
Ok(()) Ok(())
} }
@@ -198,23 +166,15 @@ where
/// buffer returns its length, a short read is UnexpectedEof (backlog#799 B2). /// buffer returns its length, a short read is UnexpectedEof (backlog#799 B2).
fn finish_len(&self, data_len: usize, want: usize) -> std::io::Result<usize> { fn finish_len(&self, data_len: usize, want: usize) -> std::io::Result<usize> {
if data_len < want { if data_len < want {
return Err(short_shard_read(data_len, want)); return Err(short_shard_read(&self.id, data_len, want));
} }
Ok(data_len) Ok(data_len)
} }
} }
/// A truncated shard is `UnexpectedEof`, not a short success (backlog#799 B2). /// A truncated shard is `UnexpectedEof`, not a short success (backlog#799 B2).
fn short_shard_read(got: usize, want: usize) -> std::io::Error { fn short_shard_read(id: &Uuid, got: usize, want: usize) -> std::io::Error {
error!( error!("bitrot reader short shard read: id={id} got {got} of {want} bytes");
event = EVENT_BITROT_SHORT_SHARD_READ,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_ERASURE,
state = "failed",
got,
want,
"short shard read: got {got} of {want} bytes"
);
std::io::Error::new(std::io::ErrorKind::UnexpectedEof, format!("short shard read: got {got} of {want} bytes")) std::io::Error::new(std::io::ErrorKind::UnexpectedEof, format!("short shard read: got {got} of {want} bytes"))
} }
@@ -224,7 +184,12 @@ fn short_shard_read(got: usize, want: usize) -> std::io::Error {
/// hash never reaches the caller's buffer. The verify duration is returned /// hash never reaches the caller's buffer. The verify duration is returned
/// rather than stored so this stays a free function usable while `self` is /// rather than stored so this stays a free function usable while `self` is
/// borrowed for the block. /// borrowed for the block.
fn split_and_verify<'a>(hash_algo: &HashAlgorithm, skip_verify: bool, block: &'a [u8]) -> std::io::Result<(&'a [u8], Duration)> { fn split_and_verify<'a>(
hash_algo: &HashAlgorithm,
skip_verify: bool,
block: &'a [u8],
id: &Uuid,
) -> std::io::Result<(&'a [u8], Duration)> {
let (hash, data) = block.split_at(hash_algo.size()); let (hash, data) = block.split_at(hash_algo.size());
if skip_verify { if skip_verify {
return Ok((data, Duration::ZERO)); return Ok((data, Duration::ZERO));
@@ -233,14 +198,7 @@ fn split_and_verify<'a>(hash_algo: &HashAlgorithm, skip_verify: bool, block: &'a
let actual_hash = hash_algo.hash_encode(data); let actual_hash = hash_algo.hash_encode(data);
let verify = verify_start.elapsed(); let verify = verify_start.elapsed();
if actual_hash.as_ref() != hash { if actual_hash.as_ref() != hash {
error!( error!("bitrot reader hash mismatch, id={id} data_len={}", data.len());
event = EVENT_BITROT_HASH_MISMATCH,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_ERASURE,
state = "failed",
data_len = data.len(),
"bitrot hash mismatch"
);
return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "bitrot hash mismatch")); return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "bitrot hash mismatch"));
} }
Ok((data, verify)) Ok((data, verify))
@@ -290,138 +248,23 @@ where
let need = hash_size + want; let need = hash_size + want;
// In-memory fast path: the block is already resident, so slice it instead
// of copying it into the scratch buffer first (rustfs/backlog#1159). One
// copy (`extend_from_slice`) instead of two. A source that cannot serve
// `need` bytes returns `None` and falls through to the scratch path,
// keeping the short-read contract.
if let Some(block) = self.inner.try_take_block(need) { if let Some(block) = self.inner.try_take_block(need) {
let (data, verify) = split_and_verify(&self.hash_algo, self.skip_verify, &block)?; let (data, verify) = split_and_verify(&self.hash_algo, self.skip_verify, &block, &self.id)?;
out.extend_from_slice(data); out.extend_from_slice(data);
self.last_verify_duration = verify; self.last_verify_duration = verify;
return Ok(want); return Ok(want);
} }
self.chunks.clear();
let handed_off = {
let inner = &mut self.inner;
let chunks = &mut self.chunks;
let tail_buf = &mut self.buf;
let mut received = 0usize;
poll_fn(|cx| {
for _ in 0..MAX_CHUNK_POLLS_PER_YIELD {
let next = match Pin::new(&mut *inner).poll_read_chunk(cx, need - received) {
Poll::Ready(Ok(next)) => next,
Poll::Ready(Err(err)) => return Poll::Ready(Err(err)),
Poll::Pending => return Poll::Pending,
};
let chunk = match next {
ShardChunkRead::Unsupported if received == 0 => return Poll::Ready(Ok(false)),
ShardChunkRead::Unsupported => {
return Poll::Ready(Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"chunk handoff became unavailable after transferring data",
)));
}
ShardChunkRead::Eof => {
return Poll::Ready(Err(short_shard_read(received.saturating_sub(hash_size), want)));
}
ShardChunkRead::Chunk(chunk) => chunk,
};
if received == 0 {
tail_buf.clear();
}
if chunk.is_empty() {
return Poll::Ready(Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"chunk handoff returned an empty chunk",
)));
}
let remaining = need - received;
if chunk.len() > remaining {
return Poll::Ready(Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"chunk handoff exceeded its requested boundary",
)));
}
received += chunk.len();
if chunks.len() == MAX_RETAINED_CHUNKS_PER_BLOCK {
if tail_buf.is_empty() {
tail_buf.reserve_exact(need - (received - chunk.len()));
}
tail_buf.extend_from_slice(&chunk);
} else {
chunks.push(chunk);
}
if received == need {
return Poll::Ready(Ok(true));
}
}
cx.waker().wake_by_ref();
Poll::Pending
})
.await?
};
if handed_off {
if self.chunks.len() == 1 && self.buf.is_empty() {
let block = &self.chunks[0];
let (data, verify) = split_and_verify(&self.hash_algo, self.skip_verify, block)?;
out.extend_from_slice(data);
self.last_verify_duration = verify;
return Ok(want);
}
let block_chunks = || {
self.chunks
.iter()
.map(|chunk| chunk.as_ref())
.chain((!self.buf.is_empty()).then_some(self.buf.as_slice()))
};
if !self.skip_verify {
let verify_start = std::time::Instant::now();
let actual_hash = self
.hash_algo
.hash_encode_slices(block_chunks().scan(hash_size, |skip, chunk| {
let start = (*skip).min(chunk.len());
*skip -= start;
Some(&chunk[start..])
}));
let verify = verify_start.elapsed();
let mut hash_offset = 0;
let mut remaining = hash_size;
for chunk in block_chunks() {
let take = remaining.min(chunk.len());
if actual_hash.as_ref()[hash_offset..hash_offset + take] != chunk[..take] {
error!(
event = EVENT_BITROT_HASH_MISMATCH,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_ERASURE,
state = "failed",
data_len = want,
"bitrot hash mismatch"
);
return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "bitrot hash mismatch"));
}
hash_offset += take;
remaining -= take;
if remaining == 0 {
break;
}
}
self.last_verify_duration = verify;
}
let mut skip = hash_size;
for chunk in block_chunks() {
let start = skip.min(chunk.len());
skip -= start;
out.extend_from_slice(&chunk[start..]);
}
return Ok(want);
}
// Streaming path: same single pass and same verification as `read`; only // Streaming path: same single pass and same verification as `read`; only
// the sink differs (`extend_from_slice` into `out` instead of // the sink differs (`extend_from_slice` into `out` instead of
// `copy_from_slice` into a pre-zeroed buffer). // `copy_from_slice` into a pre-zeroed buffer).
self.read_scratch_block(need, want).await?; self.read_scratch_block(need, want).await?;
let (data, verify) = split_and_verify(&self.hash_algo, self.skip_verify, &self.buf[..need])?; let (data, verify) = split_and_verify(&self.hash_algo, self.skip_verify, &self.buf[..need], &self.id)?;
out.extend_from_slice(data); out.extend_from_slice(data);
self.last_verify_duration = verify; self.last_verify_duration = verify;
Ok(want) Ok(want)
@@ -822,167 +665,18 @@ impl BitrotWriterWrapper {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::ShardSource;
use super::{ use super::{
BitrotReader, BitrotWriter, BitrotWriterWrapper, CustomWriter, bitrot_shard_file_size, bitrot_verify, write_all_vectored, BitrotReader, BitrotWriter, BitrotWriterWrapper, CustomWriter, bitrot_shard_file_size, bitrot_verify, write_all_vectored,
}; };
use super::{MAX_RETAINED_CHUNKS_PER_BLOCK, ShardChunkRead, ShardSource};
use bytes::Bytes;
use rustfs_utils::HashAlgorithm; use rustfs_utils::HashAlgorithm;
use std::collections::VecDeque; use std::io::{Cursor, IoSlice};
use std::io::{self, Cursor, IoSlice};
use std::pin::Pin;
use std::sync::{ use std::sync::{
Arc, Arc,
atomic::{AtomicUsize, Ordering}, atomic::{AtomicUsize, Ordering},
}; };
use std::task::{Context, Poll}; use std::task::{Context, Poll};
use std::time::Duration; use tokio::io::{AsyncWrite, AsyncWriteExt};
use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt, ReadBuf};
struct FragmentedSource {
chunks: VecDeque<Bytes>,
}
impl FragmentedSource {
fn new(bytes: Vec<u8>, fragment_sizes: &[usize]) -> Self {
let mut chunks = VecDeque::new();
let mut offset = 0;
for &size in fragment_sizes {
let end = (offset + size).min(bytes.len());
if offset < end {
chunks.push_back(Bytes::copy_from_slice(&bytes[offset..end]));
}
offset = end;
}
if offset < bytes.len() {
chunks.push_back(Bytes::copy_from_slice(&bytes[offset..]));
}
Self { chunks }
}
}
impl AsyncRead for FragmentedSource {
fn poll_read(self: Pin<&mut Self>, _cx: &mut Context<'_>, _buf: &mut ReadBuf<'_>) -> Poll<io::Result<()>> {
Poll::Ready(Err(io::Error::other("fragmented source must use chunk handoff")))
}
}
impl ShardSource for FragmentedSource {
fn poll_read_chunk(mut self: Pin<&mut Self>, _cx: &mut Context<'_>, max: usize) -> Poll<io::Result<ShardChunkRead>> {
let Some(mut chunk) = self.chunks.pop_front() else {
return Poll::Ready(Ok(ShardChunkRead::Eof));
};
if chunk.len() > max {
self.chunks.push_front(chunk.split_off(max));
chunk.truncate(max);
}
Poll::Ready(Ok(ShardChunkRead::Chunk(chunk)))
}
}
struct GeneratedChunkSource {
bytes: Bytes,
offset: usize,
fragment_size: usize,
fail_at: Option<usize>,
}
impl GeneratedChunkSource {
fn new(bytes: Vec<u8>, fragment_size: usize) -> Self {
assert!(fragment_size > 0);
Self {
bytes: Bytes::from(bytes),
offset: 0,
fragment_size,
fail_at: None,
}
}
fn failing(bytes: Vec<u8>, fragment_size: usize, fail_at: usize) -> Self {
Self {
fail_at: Some(fail_at),
..Self::new(bytes, fragment_size)
}
}
}
impl AsyncRead for GeneratedChunkSource {
fn poll_read(self: Pin<&mut Self>, _cx: &mut Context<'_>, _buf: &mut ReadBuf<'_>) -> Poll<io::Result<()>> {
Poll::Ready(Err(io::Error::other("generated source must use chunk handoff")))
}
}
impl ShardSource for GeneratedChunkSource {
fn poll_read_chunk(mut self: Pin<&mut Self>, _cx: &mut Context<'_>, max: usize) -> Poll<io::Result<ShardChunkRead>> {
if self.fail_at == Some(self.offset) {
return Poll::Ready(Err(rustfs_rio::new_test_internode_http_io_error(
rustfs_rio::InternodeHttpErrorKind::BodyStreamAborted,
)));
}
if self.offset == self.bytes.len() {
return Poll::Ready(Ok(ShardChunkRead::Eof));
}
let error_limit = self.fail_at.unwrap_or(self.bytes.len());
let take = self
.fragment_size
.min(max)
.min(error_limit - self.offset)
.min(self.bytes.len() - self.offset);
let start = self.offset;
self.offset += take;
Poll::Ready(Ok(ShardChunkRead::Chunk(self.bytes.slice(start..start + take))))
}
}
struct InvalidChunkSource {
mode: InvalidChunkMode,
}
#[derive(Clone, Copy)]
enum InvalidChunkMode {
Empty,
Oversized,
UnsupportedAfterChunk,
Unsupported,
}
impl AsyncRead for InvalidChunkSource {
fn poll_read(self: Pin<&mut Self>, _cx: &mut Context<'_>, _buf: &mut ReadBuf<'_>) -> Poll<io::Result<()>> {
Poll::Ready(Err(io::Error::other("invalid source must use chunk handoff")))
}
}
impl ShardSource for InvalidChunkSource {
fn poll_read_chunk(mut self: Pin<&mut Self>, _cx: &mut Context<'_>, max: usize) -> Poll<io::Result<ShardChunkRead>> {
match self.mode {
InvalidChunkMode::Empty => Poll::Ready(Ok(ShardChunkRead::Chunk(Bytes::new()))),
InvalidChunkMode::Oversized => Poll::Ready(Ok(ShardChunkRead::Chunk(Bytes::from(vec![0; max + 1])))),
InvalidChunkMode::UnsupportedAfterChunk => {
self.mode = InvalidChunkMode::Unsupported;
Poll::Ready(Ok(ShardChunkRead::Chunk(Bytes::from_static(b"x"))))
}
InvalidChunkMode::Unsupported => Poll::Ready(Ok(ShardChunkRead::Unsupported)),
}
}
}
struct ScratchReuseSource {
block: Option<Bytes>,
saw_reused_scratch: bool,
}
impl AsyncRead for ScratchReuseSource {
fn poll_read(mut self: Pin<&mut Self>, _cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll<io::Result<()>> {
let Some(block) = self.block.take() else {
return Poll::Ready(Ok(()));
};
self.saw_reused_scratch = buf.initialize_unfilled()[..block.len()].iter().all(|byte| *byte == 0xa5);
buf.put_slice(&block);
Poll::Ready(Ok(()))
}
}
impl ShardSource for ScratchReuseSource {}
#[derive(Default)] #[derive(Default)]
struct VectoredCountingWriter { struct VectoredCountingWriter {
@@ -1740,70 +1434,6 @@ mod tests {
assert!(out.is_empty(), "corrupt bytes must never reach the caller's buffer"); assert!(out.is_empty(), "corrupt bytes must never reach the caller's buffer");
} }
#[tokio::test]
async fn chunked_handoff_verifies_data_split_across_hash_boundaries() {
const SHARD: usize = 4096;
let algo = HashAlgorithm::HighwayHash256S;
let data: Vec<u8> = (0..SHARD).map(|index| (index % 251) as u8).collect();
let mut encoded = Vec::new();
BitrotWriter::new(&mut encoded, SHARD, algo.clone())
.write(&data)
.await
.expect("write shard");
let mut output = Vec::with_capacity(SHARD);
BitrotReader::new(FragmentedSource::new(encoded, &[3, 11, 19, 37, 128]), SHARD, algo, false)
.read_appending(&mut output, SHARD)
.await
.expect("fragmented shard must verify");
assert_eq!(output, data);
}
#[tokio::test]
async fn chunked_handoff_never_appends_a_corrupt_shard() {
const SHARD: usize = 4096;
let algo = HashAlgorithm::HighwayHash256S;
let mut encoded = Vec::new();
BitrotWriter::new(&mut encoded, SHARD, algo.clone())
.write(&vec![9u8; SHARD])
.await
.expect("write shard");
let last = encoded.len() - 1;
encoded[last] ^= 0xff;
let mut output = Vec::with_capacity(SHARD);
let err = BitrotReader::new(FragmentedSource::new(encoded, &[7, 17, 31]), SHARD, algo, false)
.read_appending(&mut output, SHARD)
.await
.expect_err("corrupt fragmented shard must fail");
assert_eq!(err.kind(), io::ErrorKind::InvalidData);
assert!(output.is_empty());
}
#[tokio::test]
async fn chunked_handoff_does_not_hash_when_verification_is_skipped() {
const SHARD: usize = 4096;
let algo = HashAlgorithm::HighwayHash256S;
let mut encoded = Vec::new();
BitrotWriter::new(&mut encoded, SHARD, algo.clone())
.write(&vec![9u8; SHARD])
.await
.expect("write shard");
encoded[0] ^= 0xff;
let mut output = Vec::with_capacity(SHARD);
let mut reader = BitrotReader::new(FragmentedSource::new(encoded, &[7, 17, 31]), SHARD, algo, true);
reader
.read_appending(&mut output, SHARD)
.await
.expect("skipped verification must accept fragmented shard bytes");
assert_eq!(reader.last_verify_duration(), Duration::ZERO);
assert_eq!(output, vec![9u8; SHARD]);
}
#[tokio::test] #[tokio::test]
async fn read_appending_rejects_a_want_larger_than_the_shard() { async fn read_appending_rejects_a_want_larger_than_the_shard() {
let algo = HashAlgorithm::HighwayHash256; let algo = HashAlgorithm::HighwayHash256;
@@ -1855,21 +1485,10 @@ mod tests {
// Equivalence: same bytes out of both paths. // Equivalence: same bytes out of both paths.
let mut via_mem: Vec<u8> = Vec::with_capacity(SHARD); let mut via_mem: Vec<u8> = Vec::with_capacity(SHARD);
let mut memory_reader = BitrotReader::new(Cursor::new(Bytes::from(encoded.clone())), SHARD, algo.clone(), false); BitrotReader::new(Cursor::new(Bytes::from(encoded.clone())), SHARD, algo.clone(), false)
memory_reader
.read_appending(&mut via_mem, SHARD) .read_appending(&mut via_mem, SHARD)
.await .await
.expect("in-memory read"); .expect("in-memory read");
assert_eq!(
memory_reader.chunks.capacity(),
0,
"the synchronous fast path must not allocate chunk storage"
);
assert_eq!(
memory_reader.buf.capacity(),
0,
"the synchronous fast path must not allocate scratch storage"
);
let mut via_stream: Vec<u8> = Vec::with_capacity(SHARD); let mut via_stream: Vec<u8> = Vec::with_capacity(SHARD);
BitrotReader::new(Cursor::new(encoded), SHARD, algo, false) BitrotReader::new(Cursor::new(encoded), SHARD, algo, false)
@@ -1906,152 +1525,4 @@ mod tests {
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData); assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
assert!(out.is_empty(), "corrupt bytes must never reach the caller's buffer"); assert!(out.is_empty(), "corrupt bytes must never reach the caller's buffer");
} }
#[tokio::test]
async fn streaming_fallback_reuses_initialized_scratch() {
const SHARD: usize = 4096;
let algo = HashAlgorithm::HighwayHash256S;
let data = vec![7u8; SHARD];
let encoded = encode_one_block(&data, SHARD, algo.clone()).await;
let source = ScratchReuseSource {
block: Some(Bytes::copy_from_slice(&encoded)),
saw_reused_scratch: false,
};
let mut reader = BitrotReader::new(source, SHARD, algo, false);
reader.buf = vec![0xa5; encoded.len()];
let mut output = Vec::new();
reader
.read_appending(&mut output, SHARD)
.await
.expect("streaming fallback should verify");
assert!(reader.inner.saw_reused_scratch, "capability probing must not clear reusable scratch");
assert_eq!(output, data);
}
#[tokio::test]
async fn chunked_handoff_bounds_production_sized_one_byte_fragments() {
const SHARD: usize = 1024 * 1024 / 4;
let algo = HashAlgorithm::HighwayHash256S;
let data: Vec<u8> = (0..SHARD).map(|index| (index % 251) as u8).collect();
let encoded = encode_one_block(&data, SHARD, algo.clone()).await;
let encoded_len = encoded.len();
let mut reader = BitrotReader::new(GeneratedChunkSource::new(encoded, 1), SHARD, algo, false);
let mut output = Vec::with_capacity(SHARD);
reader
.read_appending(&mut output, SHARD)
.await
.expect("one-byte fragments should verify with bounded retained state");
assert_eq!(output, data);
assert_eq!(reader.chunks.len(), MAX_RETAINED_CHUNKS_PER_BLOCK);
assert!(reader.chunks.capacity() <= MAX_RETAINED_CHUNKS_PER_BLOCK);
assert_eq!(reader.buf.len(), encoded_len - MAX_RETAINED_CHUNKS_PER_BLOCK);
}
#[tokio::test]
async fn chunked_handoff_keeps_sixty_four_frames_zero_copy_and_respects_poll_budget() {
const SHARD: usize = 1024 * 1024;
const FRAME: usize = 16 * 1024;
let algo = HashAlgorithm::HighwayHash256S;
let small_data = vec![3u8; 4096];
let small_encoded = encode_one_block(&small_data, 4096, algo.clone()).await;
let mut exact_reader =
BitrotReader::new(FragmentedSource::new(small_encoded.clone(), &[1; 63]), 4096, algo.clone(), false);
let mut exact_output = Vec::new();
exact_reader
.read_appending(&mut exact_output, 4096)
.await
.expect("exactly sixty-four frames should verify");
assert_eq!(exact_output, small_data);
assert_eq!(exact_reader.chunks.len(), MAX_RETAINED_CHUNKS_PER_BLOCK);
assert!(exact_reader.buf.is_empty(), "the threshold itself must remain zero-copy");
let mut yielded_reader = BitrotReader::new(FragmentedSource::new(small_encoded, &[1; 65]), 4096, algo.clone(), false);
let mut yielded_output = Vec::new();
let mut yielded_read = Box::pin(yielded_reader.read_appending(&mut yielded_output, 4096));
let mut cx = Context::from_waker(std::task::Waker::noop());
assert!(std::future::Future::poll(yielded_read.as_mut(), &mut cx).is_pending());
assert!(matches!(std::future::Future::poll(yielded_read.as_mut(), &mut cx), Poll::Ready(Ok(4096))));
drop(yielded_read);
assert_eq!(yielded_output, small_data);
let data = vec![7u8; SHARD];
let encoded = encode_one_block(&data, SHARD, algo.clone()).await;
let mut reader = BitrotReader::new(FragmentedSource::new(encoded, &[FRAME; 64]), SHARD, algo, false);
let mut output = Vec::with_capacity(SHARD);
let mut read = Box::pin(reader.read_appending(&mut output, SHARD));
assert!(
matches!(std::future::Future::poll(read.as_mut(), &mut cx), Poll::Ready(Ok(SHARD))),
"sixty-five normal HTTP frames should complete without a cooperative yield"
);
drop(read);
assert_eq!(output, data);
assert_eq!(reader.chunks.len(), MAX_RETAINED_CHUNKS_PER_BLOCK);
assert_eq!(reader.buf.len(), HashAlgorithm::HighwayHash256S.size());
}
#[tokio::test]
async fn chunked_tail_failures_preserve_errors_and_output() {
const SHARD: usize = 4096;
let algo = HashAlgorithm::HighwayHash256S;
let data = vec![7u8; SHARD];
let encoded = encode_one_block(&data, SHARD, algo.clone()).await;
let sentinel = vec![1u8, 2, 3];
let mut short_output = sentinel.clone();
let short_err = BitrotReader::new(GeneratedChunkSource::new(encoded[..100].to_vec(), 1), SHARD, algo.clone(), false)
.read_appending(&mut short_output, SHARD)
.await
.expect_err("EOF after the retention threshold must stay a short read");
assert_eq!(short_err.kind(), io::ErrorKind::UnexpectedEof);
assert_eq!(short_output, sentinel);
let mut corrupt = encoded.clone();
let last = corrupt.len() - 1;
corrupt[last] ^= 0xff;
let mut corrupt_output = sentinel.clone();
let corrupt_err = BitrotReader::new(GeneratedChunkSource::new(corrupt, 1), SHARD, algo.clone(), false)
.read_appending(&mut corrupt_output, SHARD)
.await
.expect_err("corrupt coalesced tail must fail verification");
assert_eq!(corrupt_err.kind(), io::ErrorKind::InvalidData);
assert_eq!(corrupt_output, sentinel);
let mut failed_output = sentinel.clone();
let body_err = BitrotReader::new(GeneratedChunkSource::failing(encoded, 1, 65), SHARD, algo, false)
.read_appending(&mut failed_output, SHARD)
.await
.expect_err("a terminal body error must not become EOF");
let source = body_err
.get_ref()
.and_then(|source| source.downcast_ref::<rustfs_rio::InternodeHttpError>())
.expect("body error should retain internode classification");
assert_eq!(source.kind(), rustfs_rio::InternodeHttpErrorKind::BodyStreamAborted);
assert_eq!(failed_output, sentinel);
}
#[tokio::test]
async fn chunked_handoff_rejects_invalid_source_contracts() {
const SHARD: usize = 64;
for mode in [
InvalidChunkMode::Empty,
InvalidChunkMode::Oversized,
InvalidChunkMode::UnsupportedAfterChunk,
] {
let source = InvalidChunkSource { mode };
let mut output = vec![9u8];
let err = BitrotReader::new(source, SHARD, HashAlgorithm::HighwayHash256S, false)
.read_appending(&mut output, SHARD)
.await
.expect_err("invalid chunk contracts must fail closed");
assert_eq!(err.kind(), io::ErrorKind::InvalidData);
assert_eq!(output, vec![9u8]);
}
}
} }
+43 -166
View File
@@ -25,13 +25,10 @@ use crate::disk::error_reduce::reduce_errs;
use crate::erasure::codec::workspace::ShardBufferPool; use crate::erasure::codec::workspace::ShardBufferPool;
use crate::erasure::coding::{BitrotReader, Erasure}; use crate::erasure::coding::{BitrotReader, Erasure};
use crate::io_support::bitrot::DeferredReaderStripeHandle; use crate::io_support::bitrot::DeferredReaderStripeHandle;
use crate::set_disk::shard_source::{ use crate::set_disk::shard_source::{ShardReadCost, ShardStripeSource, StripeReadState};
INLINE_SHARD_SLOTS, ShardBuffers, ShardErrors, ShardReadCost, ShardStripeSource, StripeReadState,
};
use futures::FutureExt; use futures::FutureExt;
use futures::stream::{FuturesUnordered, StreamExt}; use futures::stream::{FuturesUnordered, StreamExt};
use pin_project_lite::pin_project; use pin_project_lite::pin_project;
use smallvec::{SmallVec, smallvec};
use std::future::Future; use std::future::Future;
use std::io; use std::io;
use std::io::ErrorKind; use std::io::ErrorKind;
@@ -43,12 +40,9 @@ use tracing::{debug, error, warn};
type ShardReadFuture<'a> = Pin<Box<dyn Future<Output = (usize, ShardReadCost, Result<Vec<u8>, Error>, bool)> + Send + 'a>>; type ShardReadFuture<'a> = Pin<Box<dyn Future<Output = (usize, ShardReadCost, Result<Vec<u8>, Error>, bool)> + Send + 'a>>;
type ShardIndexes = SmallVec<[usize; INLINE_SHARD_SLOTS]>;
type ActiveReaders = SmallVec<[bool; INLINE_SHARD_SLOTS]>;
/// One stripe's worth of shard buffers plus the per-shard read errors, as /// One stripe's worth of shard buffers plus the per-shard read errors, as
/// returned by `ParallelReader::read` / `read_stripe_timed`. /// returned by `ParallelReader::read` / `read_stripe_timed`.
type StripeReadOutput = (ShardBuffers, ShardErrors); type StripeReadOutput = (Vec<Option<Vec<u8>>>, Vec<Option<Error>>);
const ENV_RUSTFS_SHARD_LOCALITY_SCHEDULING: &str = "RUSTFS_SHARD_LOCALITY_SCHEDULING"; const ENV_RUSTFS_SHARD_LOCALITY_SCHEDULING: &str = "RUSTFS_SHARD_LOCALITY_SCHEDULING";
const ENV_RUSTFS_GET_SHARD_LOCALITY_PREFERENCE_ENABLE: &str = "RUSTFS_GET_SHARD_LOCALITY_PREFERENCE_ENABLE"; const ENV_RUSTFS_GET_SHARD_LOCALITY_PREFERENCE_ENABLE: &str = "RUSTFS_GET_SHARD_LOCALITY_PREFERENCE_ENABLE";
@@ -213,7 +207,6 @@ fn shard_read_launch_rank(cost: ShardReadCost) -> u8 {
} }
} }
#[allow(dead_code, reason = "launch ordering asserted by this file's tests (backlog#1823)")]
fn shard_read_launch_order(read_costs: &[ShardReadCost], num_readers: usize, locality_preference_enabled: bool) -> Vec<usize> { fn shard_read_launch_order(read_costs: &[ShardReadCost], num_readers: usize, locality_preference_enabled: bool) -> Vec<usize> {
let mut order: Vec<usize> = (0..num_readers).collect(); let mut order: Vec<usize> = (0..num_readers).collect();
if locality_preference_enabled { if locality_preference_enabled {
@@ -392,13 +385,12 @@ pub(crate) struct ParallelReader<R> {
// Request-scoped shard buffers keyed by shard index. Keeping ownership in // Request-scoped shard buffers keyed by shard index. Keeping ownership in
// `ParallelReader` avoids dropping unused parity/backup slot buffers between stripes. // `ParallelReader` avoids dropping unused parity/backup slot buffers between stripes.
buffers: ShardBufferPool, buffers: ShardBufferPool,
stripe_state: Option<Box<StripeReadState>>,
// Lockstep-path state (verify_reconstruction == true). `engaged[i]` marks // Lockstep-path state (verify_reconstruction == true). `engaged[i]` marks
// readers that participate in each stripe read: all data slots from the // readers that participate in each stripe read: all data slots from the
// start, parity slots only once a data shard is missing/dead. Unengaged // start, parity slots only once a data shard is missing/dead. Unengaged
// parity stays an unopened deferred reader; `deferred_handles[i]` realigns // parity stays an unopened deferred reader; `deferred_handles[i]` realigns
// it to the current stripe when it is engaged mid-object (backlog#923). // it to the current stripe when it is engaged mid-object (backlog#923).
engaged: SmallVec<[bool; INLINE_SHARD_SLOTS]>, engaged: Vec<bool>,
deferred_handles: Vec<Option<DeferredReaderStripeHandle>>, deferred_handles: Vec<Option<DeferredReaderStripeHandle>>,
stripe_index: usize, stripe_index: usize,
} }
@@ -409,10 +401,6 @@ where
R: crate::erasure::coding::ShardSource, R: crate::erasure::coding::ShardSource,
{ {
// Readers should handle disk errors before being passed in, ensuring each reader reaches the available number of BitrotReaders // Readers should handle disk errors before being passed in, ensuring each reader reaches the available number of BitrotReaders
#[allow(
dead_code,
reason = "ParallelReader constructor used only by this file's tests (backlog#1823)"
)]
pub fn new(readers: Vec<Option<BitrotReader<R>>>, e: Erasure, offset: usize, total_length: usize) -> Self { pub fn new(readers: Vec<Option<BitrotReader<R>>>, e: Erasure, offset: usize, total_length: usize) -> Self {
Self::new_with_metrics_path_read_timeout_and_reconstruction_verification( Self::new_with_metrics_path_read_timeout_and_reconstruction_verification(
readers, readers,
@@ -425,7 +413,6 @@ where
) )
} }
#[allow(dead_code, reason = "constructor used only by this file's tests (backlog#1823)")]
pub fn new_with_metrics_path( pub fn new_with_metrics_path(
readers: Vec<Option<BitrotReader<R>>>, readers: Vec<Option<BitrotReader<R>>>,
e: Erasure, e: Erasure,
@@ -444,7 +431,6 @@ where
) )
} }
#[allow(dead_code, reason = "constructor used only by this file's tests (backlog#1823)")]
pub fn new_with_metrics_path_and_read_costs( pub fn new_with_metrics_path_and_read_costs(
readers: Vec<Option<BitrotReader<R>>>, readers: Vec<Option<BitrotReader<R>>>,
e: Erasure, e: Erasure,
@@ -521,7 +507,6 @@ where
) )
} }
#[allow(dead_code, reason = "constructor used only by this file's tests (backlog#1823)")]
fn new_with_read_timeout( fn new_with_read_timeout(
readers: Vec<Option<BitrotReader<R>>>, readers: Vec<Option<BitrotReader<R>>>,
e: Erasure, e: Erasure,
@@ -588,7 +573,7 @@ where
// behavior. With the gate on, only data slots start engaged; parity is // behavior. With the gate on, only data slots start engaged; parity is
// engaged on demand, stripe-aligned through its deferred handle. // engaged on demand, stripe-aligned through its deferred handle.
let data_shards_only = get_lockstep_data_shards_only_enabled(); let data_shards_only = get_lockstep_data_shards_only_enabled();
let engaged: SmallVec<_> = (0..readers.len()) let engaged = (0..readers.len())
.map(|index| !data_shards_only || index < e.data_shards) .map(|index| !data_shards_only || index < e.data_shards)
.collect(); .collect();
ParallelReader { ParallelReader {
@@ -604,7 +589,6 @@ where
verify_reconstruction, verify_reconstruction,
locality_preference_enabled: get_shard_locality_preference_enabled(), locality_preference_enabled: get_shard_locality_preference_enabled(),
buffers: ShardBufferPool::new(e.data_shards + e.parity_shards), buffers: ShardBufferPool::new(e.data_shards + e.parity_shards),
stripe_state: None,
engaged, engaged,
deferred_handles: Vec::new(), deferred_handles: Vec::new(),
stripe_index: 0, stripe_index: 0,
@@ -628,7 +612,7 @@ where
fn record_shard_read_result( fn record_shard_read_result(
shards: &mut [Option<Vec<u8>>], shards: &mut [Option<Vec<u8>>],
errs: &mut [Option<Error>], errs: &mut [Option<Error>],
retire_readers: &mut ShardIndexes, retire_readers: &mut Vec<usize>,
success: &mut usize, success: &mut usize,
successful_costs: &mut ShardReadCostCounts, successful_costs: &mut ShardReadCostCounts,
i: usize, i: usize,
@@ -653,7 +637,7 @@ fn record_shard_read_result(
} }
} }
fn retire_abandoned_readers(errs: &mut [Option<Error>], retire_readers: &mut ShardIndexes, active_readers: &[bool]) { fn retire_abandoned_readers(errs: &mut [Option<Error>], retire_readers: &mut Vec<usize>, active_readers: &[bool]) {
for (i, active) in active_readers.iter().enumerate() { for (i, active) in active_readers.iter().enumerate() {
if !*active { if !*active {
continue; continue;
@@ -708,13 +692,7 @@ where
R: crate::erasure::coding::ShardSource, R: crate::erasure::coding::ShardSource,
{ {
#[hotpath::measure(impl_type = "ParallelReader")] #[hotpath::measure(impl_type = "ParallelReader")]
pub async fn read(&mut self) -> StripeReadOutput { pub async fn read(&mut self) -> (Vec<Option<Vec<u8>>>, Vec<Option<Error>>) {
let mut state = StripeReadState::with_slot_count(self.readers.len(), self.data_shards);
self.read_into_state(&mut state).await;
state.into_parts()
}
async fn read_into_state(&mut self, state: &mut StripeReadState) {
// On the reconstruction-verifying GET path, read every live shard reader // On the reconstruction-verifying GET path, read every live shard reader
// in lockstep so all readers advance one block per stripe and stay // in lockstep so all readers advance one block per stripe and stay
// mutually aligned. The adaptive data-first path below only reads // mutually aligned. The adaptive data-first path below only reads
@@ -724,14 +702,12 @@ where
// than the data shards, producing "inconsistent read source shards" and // than the data shards, producing "inconsistent read source shards" and
// truncating large-object GETs under concurrency (backlog#832). // truncating large-object GETs under concurrency (backlog#832).
if self.verify_reconstruction { if self.verify_reconstruction {
self.read_lockstep(state).await; return self.read_lockstep().await;
return;
} }
// if self.readers.len() != self.total_shards { // if self.readers.len() != self.total_shards {
// return Err(io::Error::new(ErrorKind::InvalidInput, "Invalid number of readers")); // return Err(io::Error::new(ErrorKind::InvalidInput, "Invalid number of readers"));
// } // }
let num_readers = self.readers.len(); let num_readers = self.readers.len();
state.reset(num_readers, self.data_shards);
let shard_size = if self.offset + self.shard_size > self.shard_file_size { let shard_size = if self.offset + self.shard_size > self.shard_file_size {
self.shard_file_size - self.offset self.shard_file_size - self.offset
@@ -740,7 +716,7 @@ where
}; };
if shard_size == 0 { if shard_size == 0 {
return; return (vec![None; num_readers], vec![None; num_readers]);
} }
// Advance to the next stripe so the following read() computes the correct // Advance to the next stripe so the following read() computes the correct
@@ -751,7 +727,8 @@ where
// is only read above to derive `shard_size`, so advancing here is safe. // is only read above to derive `shard_size`, so advancing here is safe.
self.offset += shard_size; self.offset += shard_size;
let (shards, errs) = state.parts_mut(); let mut shards: Vec<Option<Vec<u8>>> = vec![None; num_readers];
let mut errs = vec![None; num_readers];
let read_costs = self.read_costs.as_slice(); let read_costs = self.read_costs.as_slice();
let locality_preference_enabled = self.locality_preference_enabled; let locality_preference_enabled = self.locality_preference_enabled;
let low_cost_available = self let low_cost_available = self
@@ -782,11 +759,11 @@ where
self.buffers.ensure_slots(num_readers); self.buffers.ensure_slots(num_readers);
let mut retire_readers = ShardIndexes::new(); let mut retire_readers = Vec::new();
if num_readers >= self.data_shards { if num_readers >= self.data_shards {
let mut reader_iter = ReaderLaunchIter::new(&mut self.readers, read_costs, locality_preference_enabled); let mut reader_iter = ReaderLaunchIter::new(&mut self.readers, read_costs, locality_preference_enabled);
let mut sets = FuturesUnordered::new(); let mut sets = FuturesUnordered::new();
let mut active_readers: ActiveReaders = smallvec![false; num_readers]; let mut active_readers = vec![false; num_readers];
let stripe_read_start = self.metrics_path.map(|_| Instant::now()); let stripe_read_start = self.metrics_path.map(|_| Instant::now());
let mut scheduled = 0usize; let mut scheduled = 0usize;
for _ in 0..self.data_shards { for _ in 0..self.data_shards {
@@ -898,8 +875,8 @@ where
} }
let result_is_err = record_shard_read_result( let result_is_err = record_shard_read_result(
shards, &mut shards,
errs, &mut errs,
&mut retire_readers, &mut retire_readers,
&mut success, &mut success,
&mut successful_costs, &mut successful_costs,
@@ -960,8 +937,8 @@ where
active_readers[i] = false; active_readers[i] = false;
completed += 1; completed += 1;
if record_shard_read_result( if record_shard_read_result(
shards, &mut shards,
errs, &mut errs,
&mut retire_readers, &mut retire_readers,
&mut success, &mut success,
&mut successful_costs, &mut successful_costs,
@@ -973,7 +950,7 @@ where
failed += 1; failed += 1;
} }
} }
retire_abandoned_readers(errs, &mut retire_readers, &active_readers); retire_abandoned_readers(&mut errs, &mut retire_readers, &active_readers);
} }
if let Some(path) = self.metrics_path { if let Some(path) = self.metrics_path {
@@ -1017,6 +994,8 @@ where
for i in retire_readers { for i in retire_readers {
self.readers[i] = None; self.readers[i] = None;
} }
(shards, errs)
} }
/// Lockstep stripe read for the reconstruction-verifying GET path. /// Lockstep stripe read for the reconstruction-verifying GET path.
@@ -1044,18 +1023,18 @@ where
/// stripe would reintroduce the desync. A parity reader that cannot be /// stripe would reintroduce the desync. A parity reader that cannot be
/// realigned (no pending deferred handle) is likewise retired instead of /// realigned (no pending deferred handle) is likewise retired instead of
/// being read out of position. /// being read out of position.
async fn read_lockstep(&mut self, state: &mut StripeReadState) { async fn read_lockstep(&mut self) -> (Vec<Option<Vec<u8>>>, Vec<Option<Error>>) {
let num_readers = self.readers.len(); let num_readers = self.readers.len();
state.reset(num_readers, self.data_shards);
let shard_size = if self.offset + self.shard_size > self.shard_file_size { let shard_size = if self.offset + self.shard_size > self.shard_file_size {
self.shard_file_size - self.offset self.shard_file_size - self.offset
} else { } else {
self.shard_size self.shard_size
}; };
let (shards, errs) = state.parts_mut(); let mut shards: Vec<Option<Vec<u8>>> = vec![None; num_readers];
let mut errs: Vec<Option<Error>> = vec![None; num_readers];
if shard_size == 0 { if shard_size == 0 {
return; return (shards, errs);
} }
// Advance to the next stripe (see the matching note in `read`); the // Advance to the next stripe (see the matching note in `read`); the
@@ -1092,7 +1071,7 @@ where
// Pre-claim per-slot buffers so the `self.readers` borrow below stays // Pre-claim per-slot buffers so the `self.readers` borrow below stays
// disjoint from `self.buffers`; `Some(buffer)` also records which slots // disjoint from `self.buffers`; `Some(buffer)` also records which slots
// participate, avoiding a per-stripe sidecar allocation. // participate, avoiding a per-stripe sidecar allocation.
let mut bufs: ShardBuffers = SmallVec::with_capacity(num_readers); let mut bufs: Vec<Option<Vec<u8>>> = Vec::with_capacity(num_readers);
for i in 0..num_readers { for i in 0..num_readers {
bufs.push(if self.engaged[i] && self.readers[i].is_some() { bufs.push(if self.engaged[i] && self.readers[i].is_some() {
Some(self.buffers.take(i, shard_size)) Some(self.buffers.take(i, shard_size))
@@ -1107,7 +1086,7 @@ where
let locality_preference_enabled = self.locality_preference_enabled; let locality_preference_enabled = self.locality_preference_enabled;
let stripe_read_start = metrics_path.map(|_| Instant::now()); let stripe_read_start = metrics_path.map(|_| Instant::now());
let mut retire_readers = ShardIndexes::new(); let mut retire_readers = Vec::new();
let mut scheduled = 0usize; let mut scheduled = 0usize;
let mut success = 0usize; let mut success = 0usize;
let mut completed = 0usize; let mut completed = 0usize;
@@ -1293,6 +1272,8 @@ where
for i in retire_readers { for i in retire_readers {
self.readers[i] = None; self.readers[i] = None;
} }
(shards, errs)
} }
/// Attempt to bring an as-yet-unread parity reader into the lockstep read /// Attempt to bring an as-yet-unread parity reader into the lockstep read
@@ -1338,6 +1319,10 @@ where
} }
} }
} }
pub fn can_decode(&self, shards: &[Option<Vec<u8>>]) -> bool {
shards.iter().filter(|s| s.is_some()).count() >= self.data_shards
}
} }
#[async_trait::async_trait] #[async_trait::async_trait]
@@ -1345,20 +1330,10 @@ impl<R> ShardStripeSource for ParallelReader<R>
where where
R: crate::erasure::coding::ShardSource, R: crate::erasure::coding::ShardSource,
{ {
async fn read_next_stripe(&mut self) -> Box<StripeReadState> { async fn read_next_stripe(&mut self) -> StripeReadState {
let mut state = self let read_quorum = self.data_shards;
.stripe_state let (shards, errors) = ParallelReader::read(self).await;
.take() StripeReadState::from_parts_with_read_costs(shards, errors, &self.read_costs, read_quorum)
.unwrap_or_else(|| Box::new(StripeReadState::with_slot_count(self.readers.len(), self.data_shards)));
self.read_into_state(&mut state).await;
state
}
fn recycle_stripe(&mut self, mut state: Box<StripeReadState>) {
self.recycle_shards(state.shards_mut());
state.reset(0, self.data_shards);
debug_assert!(self.stripe_state.is_none(), "a stripe cannot be recycled twice");
self.stripe_state = Some(state);
} }
} }
@@ -1376,7 +1351,10 @@ fn get_data_block_len(shards: &[Option<Vec<u8>>], data_blocks: usize) -> usize {
/// stripe-read stage timer. Factored out so the depth-1 prefetch loop and the /// stripe-read stage timer. Factored out so the depth-1 prefetch loop and the
/// serial loop time reads identically. A free `async fn` (rather than a closure) /// serial loop time reads identically. A free `async fn` (rather than a closure)
/// so the returned future's borrow of `reader` is correctly tied to the call. /// so the returned future's borrow of `reader` is correctly tied to the call.
async fn read_stripe_timed<R>(reader: &mut ParallelReader<R>, stage_metrics_enabled: bool) -> StripeReadOutput async fn read_stripe_timed<R>(
reader: &mut ParallelReader<R>,
stage_metrics_enabled: bool,
) -> (Vec<Option<Vec<u8>>>, Vec<Option<Error>>)
where where
R: crate::erasure::coding::ShardSource, R: crate::erasure::coding::ShardSource,
{ {
@@ -1543,7 +1521,6 @@ impl Erasure {
.await .await
} }
#[allow(dead_code, reason = "read-cost decode path asserted by this file's tests (backlog#1823)")]
pub(crate) async fn decode_with_read_costs<W, R>( pub(crate) async fn decode_with_read_costs<W, R>(
&self, &self,
writer: &mut W, writer: &mut W,
@@ -1614,9 +1591,9 @@ impl Erasure {
*ret_err = Some(err.into()); *ret_err = Some(err.into());
} }
// Shard-availability check, written out here rather than called on the // Equivalent to `ParallelReader::can_decode`; inlined so this helper does
// reader so this helper does not need to borrow it, leaving the reader // not need to borrow the reader, leaving the reader free for the
// free for the concurrent next-stripe read under prefetch. // concurrent next-stripe read under prefetch.
let available_shards = shards.iter().filter(|shard| shard.is_some()).count(); let available_shards = shards.iter().filter(|shard| shard.is_some()).count();
if available_shards < self.data_shards { if available_shards < self.data_shards {
let reason = GetObjectFailureReason::ReadQuorum; let reason = GetObjectFailureReason::ReadQuorum;
@@ -1990,93 +1967,6 @@ mod tests {
type BoxedShardReader = crate::io_support::bitrot::ShardReader; type BoxedShardReader = crate::io_support::bitrot::ShardReader;
#[test]
fn parallel_reader_keeps_stripe_scratch_out_of_line() {
eprintln!(
"parallel_reader={} stripe_state={} cached_state={}",
std::mem::size_of::<ParallelReader<Cursor<Vec<u8>>>>(),
std::mem::size_of::<StripeReadState>(),
std::mem::size_of::<Option<Box<StripeReadState>>>()
);
assert_eq!(
std::mem::size_of::<Option<Box<StripeReadState>>>(),
std::mem::size_of::<usize>(),
"the request-scoped cache must remain pointer-sized",
);
}
#[tokio::test]
async fn parallel_reader_preserves_slot_count_above_inline_capacity() {
const DATA_SHARDS: usize = INLINE_SHARD_SLOTS;
const TOTAL_SHARDS: usize = INLINE_SHARD_SLOTS + 1;
let readers = std::iter::repeat_with(|| None).take(TOTAL_SHARDS).collect();
let erasure = Erasure::new(DATA_SHARDS, 1, DATA_SHARDS);
let mut reader: ParallelReader<Cursor<Vec<u8>>> = ParallelReader::new(readers, erasure, 0, DATA_SHARDS);
let (shards, errors) = reader.read().await;
assert!(shards.spilled());
assert!(errors.spilled());
assert_eq!(shards.len(), TOTAL_SHARDS);
assert_eq!(errors.len(), TOTAL_SHARDS);
}
#[tokio::test]
async fn codec_reader_reuses_inline_and_spilled_stripe_scratch_between_reads() {
for total_shards in [INLINE_SHARD_SLOTS, INLINE_SHARD_SLOTS + 1] {
let data_shards = total_shards - 1;
let readers = std::iter::repeat_with(|| None).take(total_shards).collect();
let erasure = Erasure::new(data_shards, 1, data_shards * 2);
let mut reader: ParallelReader<Cursor<Vec<u8>>> = ParallelReader::new(readers, erasure, 0, data_shards * 2);
let first = ShardStripeSource::read_next_stripe(&mut reader).await;
let first_state = (&*first) as *const StripeReadState;
let first_storage = first.scratch_storage();
assert_eq!(first_storage.2, total_shards > INLINE_SHARD_SLOTS);
assert_eq!(first_storage.3, total_shards > INLINE_SHARD_SLOTS);
ShardStripeSource::recycle_stripe(&mut reader, first);
let second = ShardStripeSource::read_next_stripe(&mut reader).await;
let second_storage = second.scratch_storage();
assert_eq!(
(&*second) as *const StripeReadState,
first_state,
"the request-scoped state must be reused"
);
assert_eq!(second_storage.0, first_storage.0, "shard slots must reuse their allocation");
assert_eq!(second_storage.1, first_storage.1, "error slots must reuse their allocation");
assert_eq!(second.into_parts().0.len(), total_shards);
}
}
#[tokio::test]
async fn codec_reader_returns_shard_allocations_to_the_request_pool() {
const SHARD_SIZE: usize = 16;
let hash_algo = HashAlgorithm::None;
let readers = vec![Some(create_reader(SHARD_SIZE, 2, 0x5a, &hash_algo, false).await)];
let erasure = Erasure::new(1, 0, SHARD_SIZE);
let mut reader = ParallelReader::new(readers, erasure, 0, SHARD_SIZE * 2);
let first = ShardStripeSource::read_next_stripe(&mut reader).await;
let first_allocation = first
.shard_allocation(0)
.expect("the first stripe should own its shard allocation");
ShardStripeSource::recycle_stripe(&mut reader, first);
assert_eq!(
reader.buffers.stored_allocation(0),
Some(first_allocation),
"recycling a stripe must return its shard allocation to the request pool"
);
let second = ShardStripeSource::read_next_stripe(&mut reader).await;
assert_eq!(
second.shard_allocation(0),
Some(first_allocation),
"the next stripe must reuse the pooled shard allocation"
);
}
/// Counts the raw bytes pulled from a shard stream, to prove which shards /// Counts the raw bytes pulled from a shard stream, to prove which shards
/// a decode path actually touches (backlog#923 call-count evidence). /// a decode path actually touches (backlog#923 call-count evidence).
struct CountingShardReader { struct CountingShardReader {
@@ -2453,19 +2343,6 @@ mod tests {
assert_eq!(err.expect("range beyond total length should fail").kind(), ErrorKind::InvalidInput); assert_eq!(err.expect("range beyond total length should fail").kind(), ErrorKind::InvalidInput);
} }
#[tokio::test]
async fn test_erasure_decode_zero_length_does_not_read_or_emit() {
let erasure = Erasure::new(2, 1, 64);
let readers: Vec<Option<BitrotReader<Cursor<Vec<u8>>>>> = vec![None, None, None];
let mut output = Vec::new();
let (written, err) = erasure.decode(&mut output, readers, 0, 0, 0).await;
assert_eq!(written, 0);
assert!(err.is_none());
assert!(output.is_empty());
}
#[tokio::test] #[tokio::test]
async fn test_erasure_decode_with_read_costs_restores_missing_data_shard_range() { async fn test_erasure_decode_with_read_costs_restores_missing_data_shard_range() {
const DATA_SHARDS: usize = 2; const DATA_SHARDS: usize = 2;
@@ -65,7 +65,7 @@ enum FillPolicy {
} }
impl FillPolicy { impl FillPolicy {
fn load() -> Self { fn from_env() -> Self {
match rustfs_utils::get_env_usize( match rustfs_utils::get_env_usize(
ENV_RUSTFS_GET_CODEC_STREAMING_MAX_INFLIGHT, ENV_RUSTFS_GET_CODEC_STREAMING_MAX_INFLIGHT,
DEFAULT_RUSTFS_GET_CODEC_STREAMING_MAX_INFLIGHT, DEFAULT_RUSTFS_GET_CODEC_STREAMING_MAX_INFLIGHT,
@@ -75,22 +75,6 @@ impl FillPolicy {
} }
} }
fn from_env() -> Self {
#[cfg(test)]
{
Self::load()
}
#[cfg(not(test))]
{
Self::cached_core(Self::load)
}
}
fn cached_core(load: impl FnOnce() -> Self) -> Self {
static CACHED: std::sync::OnceLock<FillPolicy> = std::sync::OnceLock::new();
*CACHED.get_or_init(load)
}
const fn max_inflight(self) -> usize { const fn max_inflight(self) -> usize {
match self { match self {
Self::SingleInFlight => 1, Self::SingleInFlight => 1,
@@ -138,10 +122,6 @@ where
S: ShardStripeSource + Send + 'static, S: ShardStripeSource + Send + 'static,
E: ErasureDecodeEngine + Clone + Send + Sync + 'static, E: ErasureDecodeEngine + Clone + Send + Sync + 'static,
{ {
#[allow(
dead_code,
reason = "default-metrics-path constructor used only by this file's tests (backlog#1823)"
)]
pub(crate) fn new(source: S, engine: E, total_length: usize) -> io::Result<Self> { pub(crate) fn new(source: S, engine: E, total_length: usize) -> io::Result<Self> {
Self::new_with_metrics_path(source, engine, total_length, GET_OBJECT_PATH_CODEC_STREAMING) Self::new_with_metrics_path(source, engine, total_length, GET_OBJECT_PATH_CODEC_STREAMING)
} }
@@ -499,30 +479,22 @@ where
let mut deferred_error = None; let mut deferred_error = None;
let fill_stage_start = get_stage_timer_if_enabled(stage_metrics_enabled); let fill_stage_start = get_stage_timer_if_enabled(stage_metrics_enabled);
let stripe_read_stage_start = get_stage_timer_if_enabled(stage_metrics_enabled); let stripe_read_stage_start = get_stage_timer_if_enabled(stage_metrics_enabled);
let mut state = source.read_next_stripe().await; let state = source.read_next_stripe().await;
record_get_stage_duration_if_enabled(metrics_path, GET_STAGE_STRIPE_READ, stripe_read_stage_start); record_get_stage_duration_if_enabled(metrics_path, GET_STAGE_STRIPE_READ, stripe_read_stage_start);
let decode_stage_start = get_stage_timer_if_enabled(stage_metrics_enabled); let decode_stage_start = get_stage_timer_if_enabled(stage_metrics_enabled);
let mut output_buf = reusable_buffers.pop().unwrap_or_default(); let mut output_buf = reusable_buffers.pop().unwrap_or_default();
let result = match decode_stripe_into( let result =
metrics_path, match decode_stripe_into(metrics_path, stage_metrics_enabled, engine, workspace, state, remaining, &mut output_buf) {
stage_metrics_enabled, Ok(true) => Ok(Some(output_buf)),
engine, Ok(false) => {
workspace, reusable_buffers.push(output_buf);
&mut state, Ok(None)
remaining, }
&mut output_buf, Err(err) => {
) { reusable_buffers.push(output_buf);
Ok(true) => Ok(Some(output_buf)), Err(err)
Ok(false) => { }
reusable_buffers.push(output_buf); };
Ok(None)
}
Err(err) => {
reusable_buffers.push(output_buf);
Err(err)
}
};
source.recycle_stripe(state);
record_get_stage_duration_if_enabled(metrics_path, GET_STAGE_DECODE, decode_stage_start); record_get_stage_duration_if_enabled(metrics_path, GET_STAGE_DECODE, decode_stage_start);
if let Ok(Some(first_buf)) = result.as_ref() { if let Ok(Some(first_buf)) = result.as_ref() {
let mut remaining_after_first = remaining.saturating_sub(first_buf.len()); let mut remaining_after_first = remaining.saturating_sub(first_buf.len());
@@ -531,7 +503,7 @@ where
break; break;
} }
let stripe_read_stage_start = get_stage_timer_if_enabled(stage_metrics_enabled); let stripe_read_stage_start = get_stage_timer_if_enabled(stage_metrics_enabled);
let mut state = source.read_next_stripe().await; let state = source.read_next_stripe().await;
record_get_stage_duration_if_enabled(metrics_path, GET_STAGE_STRIPE_READ, stripe_read_stage_start); record_get_stage_duration_if_enabled(metrics_path, GET_STAGE_STRIPE_READ, stripe_read_stage_start);
let decode_stage_start = get_stage_timer_if_enabled(stage_metrics_enabled); let decode_stage_start = get_stage_timer_if_enabled(stage_metrics_enabled);
let mut queued_buf = reusable_buffers.pop().unwrap_or_default(); let mut queued_buf = reusable_buffers.pop().unwrap_or_default();
@@ -540,11 +512,10 @@ where
stage_metrics_enabled, stage_metrics_enabled,
engine, engine,
workspace, workspace,
&mut state, state,
remaining_after_first, remaining_after_first,
&mut queued_buf, &mut queued_buf,
); );
source.recycle_stripe(state);
record_get_stage_duration_if_enabled(metrics_path, GET_STAGE_DECODE, decode_stage_start); record_get_stage_duration_if_enabled(metrics_path, GET_STAGE_DECODE, decode_stage_start);
match queued_result { match queued_result {
Ok(true) => { Ok(true) => {
@@ -683,10 +654,6 @@ pub(crate) struct SyncErasureDecodeReader<R> {
} }
impl<R> SyncErasureDecodeReader<R> { impl<R> SyncErasureDecodeReader<R> {
#[allow(
dead_code,
reason = "default-metrics-path constructor used only by this file's tests (backlog#1823)"
)]
pub(crate) fn new(inner: R) -> Self { pub(crate) fn new(inner: R) -> Self {
Self::new_with_metrics_path(inner, GET_OBJECT_PATH_CODEC_STREAMING) Self::new_with_metrics_path(inner, GET_OBJECT_PATH_CODEC_STREAMING)
} }
@@ -750,7 +717,7 @@ fn decode_stripe_into<E>(
stage_metrics_enabled: bool, stage_metrics_enabled: bool,
engine: &E, engine: &E,
workspace: &mut E::Workspace, workspace: &mut E::Workspace,
state: &mut StripeReadState, state: StripeReadState,
remaining: usize, remaining: usize,
output: &mut Vec<u8>, output: &mut Vec<u8>,
) -> io::Result<bool> ) -> io::Result<bool>
@@ -758,7 +725,7 @@ where
E: ErasureDecodeEngine, E: ErasureDecodeEngine,
{ {
output.clear(); output.clear();
if state.is_empty() { if state.slots().is_empty() {
return Ok(false); return Ok(false);
} }
if !state.can_decode() { if !state.can_decode() {
@@ -774,12 +741,13 @@ where
); );
record_get_stage_duration_if_enabled(metrics_path, GET_STAGE_RECONSTRUCT, reconstruct_stage_start); record_get_stage_duration_if_enabled(metrics_path, GET_STAGE_RECONSTRUCT, reconstruct_stage_start);
let emit_stage_start = get_stage_timer_if_enabled(stage_metrics_enabled); let emit_stage_start = get_stage_timer_if_enabled(stage_metrics_enabled);
emit_data_shards_into(state, engine.data_shards(), engine.block_size(), remaining, output)?; emit_data_shards_into(&state, engine.data_shards(), engine.block_size(), remaining, output)?;
record_get_stage_duration_if_enabled(metrics_path, GET_STAGE_EMIT, emit_stage_start); record_get_stage_duration_if_enabled(metrics_path, GET_STAGE_EMIT, emit_stage_start);
return Ok(true); return Ok(true);
} }
let reconstruct_outcome = match engine.reconstruct_into(state.shards_mut(), workspace) { let (mut shards, _errs) = state.into_parts();
let reconstruct_outcome = match engine.reconstruct_into(&mut shards, workspace) {
Ok(outcome) => outcome, Ok(outcome) => outcome,
Err(err) => { Err(err) => {
record_get_stage_duration_if_enabled(metrics_path, GET_STAGE_RECONSTRUCT, reconstruct_stage_start); record_get_stage_duration_if_enabled(metrics_path, GET_STAGE_RECONSTRUCT, reconstruct_stage_start);
@@ -789,7 +757,7 @@ where
rustfs_io_metrics::record_get_object_reconstruct_outcome(metrics_path, engine.engine_name(), reconstruct_outcome); rustfs_io_metrics::record_get_object_reconstruct_outcome(metrics_path, engine.engine_name(), reconstruct_outcome);
record_get_stage_duration_if_enabled(metrics_path, GET_STAGE_RECONSTRUCT, reconstruct_stage_start); record_get_stage_duration_if_enabled(metrics_path, GET_STAGE_RECONSTRUCT, reconstruct_stage_start);
if state.shards_mut().len() < engine.data_shards() { if shards.len() < engine.data_shards() {
return Err(io::Error::new( return Err(io::Error::new(
ErrorKind::UnexpectedEof, ErrorKind::UnexpectedEof,
"decoded stripe has fewer shards than data shard count", "decoded stripe has fewer shards than data shard count",
@@ -798,7 +766,7 @@ where
let emit_stage_start = get_stage_timer_if_enabled(stage_metrics_enabled); let emit_stage_start = get_stage_timer_if_enabled(stage_metrics_enabled);
reserve_output_capacity(output, engine.block_size().min(remaining)); reserve_output_capacity(output, engine.block_size().min(remaining));
for shard in state.shards_mut().iter().take(engine.data_shards()) { for shard in shards.iter().take(engine.data_shards()) {
if output.len() >= remaining { if output.len() >= remaining {
break; break;
} }
@@ -813,7 +781,6 @@ where
Ok(true) Ok(true)
} }
#[allow(dead_code, reason = "shard emission asserted by this file's tests (backlog#1823)")]
fn emit_data_shards(state: &StripeReadState, data_shards: usize, block_size: usize, remaining: usize) -> io::Result<Vec<u8>> { fn emit_data_shards(state: &StripeReadState, data_shards: usize, block_size: usize, remaining: usize) -> io::Result<Vec<u8>> {
let mut output = Vec::new(); let mut output = Vec::new();
emit_data_shards_into(state, data_shards, block_size, remaining, &mut output)?; emit_data_shards_into(state, data_shards, block_size, remaining, &mut output)?;
@@ -839,7 +806,10 @@ fn emit_data_shards_into(
if output.len() >= remaining { if output.len() >= remaining {
break; break;
} }
let Some(shard) = state.data_bytes(index) else { let Some(slot) = state.slot_by_index(index) else {
return Err(io::Error::new(ErrorKind::UnexpectedEof, "decoded stripe is missing a data shard"));
};
let Some(shard) = slot.data_bytes() else {
return Err(io::Error::new(ErrorKind::UnexpectedEof, "decoded stripe is missing a data shard")); return Err(io::Error::new(ErrorKind::UnexpectedEof, "decoded stripe is missing a data shard"));
}; };
let copy_len = shard.len().min(remaining - output.len()); let copy_len = shard.len().min(remaining - output.len());
@@ -856,7 +826,7 @@ mod tests {
}; };
use crate::erasure::coding::decode::ParallelReader; use crate::erasure::coding::decode::ParallelReader;
use crate::erasure::coding::{BitrotReader, BitrotWriter, Erasure}; use crate::erasure::coding::{BitrotReader, BitrotWriter, Erasure};
use crate::set_disk::shard_source::StripeReadState; use crate::set_disk::shard_source::{ShardSlot, StripeReadState};
use rustfs_utils::HashAlgorithm; use rustfs_utils::HashAlgorithm;
use std::collections::VecDeque; use std::collections::VecDeque;
use std::future::{pending, poll_fn}; use std::future::{pending, poll_fn};
@@ -875,13 +845,6 @@ mod tests {
read_count: Option<Arc<AtomicUsize>>, read_count: Option<Arc<AtomicUsize>>,
} }
struct RecordingStripeSource {
stripes: VecDeque<StripeReadState>,
read_quorum: usize,
reads: usize,
recycles: usize,
}
struct BlockingSource { struct BlockingSource {
started: Arc<Notify>, started: Arc<Notify>,
dropped: Arc<AtomicUsize>, dropped: Arc<AtomicUsize>,
@@ -936,43 +899,25 @@ mod tests {
#[async_trait::async_trait] #[async_trait::async_trait]
impl ShardStripeSource for VecStripeSource { impl ShardStripeSource for VecStripeSource {
async fn read_next_stripe(&mut self) -> Box<StripeReadState> { async fn read_next_stripe(&mut self) -> StripeReadState {
if let Some(read_count) = &self.read_count { if let Some(read_count) = &self.read_count {
read_count.fetch_add(1, Ordering::SeqCst); read_count.fetch_add(1, Ordering::SeqCst);
} }
Box::new( self.stripes
self.stripes .pop_front()
.pop_front() .unwrap_or_else(|| StripeReadState::new(Vec::new(), self.read_quorum))
.unwrap_or_else(|| StripeReadState::from_parts(Vec::new(), Vec::new(), self.read_quorum)),
)
}
}
#[async_trait::async_trait]
impl ShardStripeSource for RecordingStripeSource {
async fn read_next_stripe(&mut self) -> Box<StripeReadState> {
self.reads += 1;
Box::new(
self.stripes
.pop_front()
.unwrap_or_else(|| StripeReadState::from_parts(Vec::new(), Vec::new(), self.read_quorum)),
)
}
fn recycle_stripe(&mut self, _state: Box<StripeReadState>) {
self.recycles += 1;
} }
} }
#[async_trait::async_trait] #[async_trait::async_trait]
impl ShardStripeSource for BlockingSource { impl ShardStripeSource for BlockingSource {
async fn read_next_stripe(&mut self) -> Box<StripeReadState> { async fn read_next_stripe(&mut self) -> StripeReadState {
let _guard = BlockingSourceDropGuard { let _guard = BlockingSourceDropGuard {
dropped: Arc::clone(&self.dropped), dropped: Arc::clone(&self.dropped),
}; };
self.started.notify_one(); self.started.notify_one();
pending::<()>().await; pending::<()>().await;
Box::new(StripeReadState::from_parts(Vec::new(), Vec::new(), self.read_quorum)) StripeReadState::new(Vec::new(), self.read_quorum)
} }
} }
@@ -1145,23 +1090,6 @@ mod tests {
}); });
} }
#[test]
fn fill_policy_production_cache_loads_once() {
use std::cell::Cell;
let loads = Cell::new(0);
for _ in 0..3 {
assert_eq!(
FillPolicy::cached_core(|| {
loads.set(loads.get() + 1);
FillPolicy::DualInFlight
}),
FillPolicy::DualInFlight
);
}
assert_eq!(loads.get(), 1, "the production fill policy must not re-read the environment per reader");
}
#[test] #[test]
fn erasure_decode_reader_rejects_invalid_engine_shape() { fn erasure_decode_reader_rejects_invalid_engine_shape() {
let source = VecStripeSource { let source = VecStripeSource {
@@ -1761,10 +1689,7 @@ mod tests {
.pop_front() .pop_front()
.expect("first stripe should exist"); .expect("first stripe should exist");
let mut source = VecStripeSource { let mut source = VecStripeSource {
stripes: VecDeque::from([ stripes: VecDeque::from([first_state, StripeReadState::new(Vec::new(), erasure.data_shards)]),
first_state,
StripeReadState::from_parts(Vec::new(), Vec::new(), erasure.data_shards),
]),
read_quorum: erasure.data_shards, read_quorum: erasure.data_shards,
read_count: None, read_count: None,
}; };
@@ -1799,14 +1724,13 @@ mod tests {
.stripes .stripes
.pop_front() .pop_front()
.expect("first stripe should exist"); .expect("first stripe should exist");
let mut source = RecordingStripeSource { let mut source = VecStripeSource {
stripes: VecDeque::from([ stripes: VecDeque::from([
first_state, first_state,
StripeReadState::from_parts(vec![Some(vec![1])], Vec::new(), erasure.data_shards), StripeReadState::new(vec![ShardSlot::data(0, vec![1])], erasure.data_shards),
]), ]),
read_quorum: erasure.data_shards, read_quorum: erasure.data_shards,
reads: 0, read_count: None,
recycles: 0,
}; };
let engine = LegacyEcDecodeEngine::new(erasure); let engine = LegacyEcDecodeEngine::new(erasure);
let mut workspace = engine.prepare_workspace(4).expect("workspace should be prepared"); let mut workspace = engine.prepare_workspace(4).expect("workspace should be prepared");
@@ -1832,8 +1756,6 @@ mod tests {
.kind(), .kind(),
ErrorKind::Other ErrorKind::Other
); );
assert_eq!(source.reads, 2, "the fill must read the primary and queued stripe");
assert_eq!(source.recycles, source.reads, "every completed stripe read must be recycled");
} }
#[tokio::test] #[tokio::test]
@@ -1846,7 +1768,7 @@ mod tests {
.stripes .stripes
.pop_front() .pop_front()
.expect("first stripe should exist"), .expect("first stripe should exist"),
StripeReadState::from_parts(Vec::new(), Vec::new(), erasure.data_shards), StripeReadState::new(Vec::new(), erasure.data_shards),
]), ]),
read_quorum: erasure.data_shards, read_quorum: erasure.data_shards,
read_count: None, read_count: None,
@@ -2106,11 +2028,17 @@ mod tests {
} }
#[test] #[test]
fn emit_data_shards_preserves_output_order() { fn emit_data_shards_preserves_output_order_for_out_of_order_slots() {
let state = let state = StripeReadState::new(
StripeReadState::from_parts(vec![Some(b"ab".to_vec()), Some(b"cd".to_vec()), Some(b"ef".to_vec())], Vec::new(), 2); vec![
ShardSlot::data(1, b"cd".to_vec()),
ShardSlot::data(0, b"ab".to_vec()),
ShardSlot::data(2, b"ef".to_vec()),
],
2,
);
let output = emit_data_shards(&state, 3, 6, 5).expect("data slots should emit by shard index"); let output = emit_data_shards(&state, 3, 6, 5).expect("out-of-order data slots should emit by shard index");
assert_eq!(output, b"abcde"); assert_eq!(output, b"abcde");
} }
@@ -2123,27 +2051,27 @@ mod tests {
}; };
let mut workspace = engine.prepare_workspace(4).expect("workspace should be prepared"); let mut workspace = engine.prepare_workspace(4).expect("workspace should be prepared");
let mut output = Vec::with_capacity(1); let mut output = Vec::with_capacity(1);
let mut short_state = StripeReadState::from_parts(vec![Some(vec![1, 2, 3, 4])], Vec::new(), 1); let short_state = StripeReadState::new(vec![ShardSlot::data(0, vec![1, 2, 3, 4])], 1);
let err = decode_stripe_into( let err = decode_stripe_into(
GET_OBJECT_PATH_CODEC_STREAMING, GET_OBJECT_PATH_CODEC_STREAMING,
false, false,
&engine, &engine,
&mut workspace, &mut workspace,
&mut short_state, short_state,
8, 8,
&mut output, &mut output,
) )
.expect_err("decoded stripe shorter than data shard count must fail"); .expect_err("decoded stripe shorter than data shard count must fail");
assert_eq!(err.kind(), ErrorKind::UnexpectedEof); assert_eq!(err.kind(), ErrorKind::UnexpectedEof);
let mut missing_state = StripeReadState::from_parts(vec![None, Some(vec![5, 6, 7, 8])], Vec::new(), 1); let missing_state = StripeReadState::from_parts(vec![None, Some(vec![5, 6, 7, 8])], Vec::new(), 1);
let err = decode_stripe_into( let err = decode_stripe_into(
GET_OBJECT_PATH_CODEC_STREAMING, GET_OBJECT_PATH_CODEC_STREAMING,
false, false,
&engine, &engine,
&mut workspace, &mut workspace,
&mut missing_state, missing_state,
8, 8,
&mut output, &mut output,
) )
@@ -2154,35 +2082,6 @@ mod tests {
assert!(output.capacity() >= 32); assert!(output.capacity() >= 32);
} }
#[test]
fn decode_stripe_reconstructs_in_place_without_replacing_slot_storage() {
let erasure = Erasure::new(2, 1, 8);
let engine = LegacyEcDecodeEngine::new(erasure.clone());
let mut workspace = engine.prepare_workspace(4).expect("workspace should be prepared");
let encoded = erasure.encode_data(b"abcdefgh").expect("test stripe should encode");
let mut shards = encoded.into_iter().map(|shard| Some(shard.to_vec())).collect::<Vec<_>>();
shards[0] = None;
let mut state = StripeReadState::from_parts(shards, vec![Some(DiskError::FileCorrupt)], 2);
let before = state.scratch_storage();
let mut output = Vec::new();
let decoded = decode_stripe_into(
GET_OBJECT_PATH_CODEC_STREAMING,
false,
&engine,
&mut workspace,
&mut state,
8,
&mut output,
)
.expect("degraded stripe should reconstruct");
assert!(decoded);
assert_eq!(output, b"abcdefgh");
assert_eq!(state.scratch_storage().0, before.0, "reconstruction must retain shard slot storage");
assert_eq!(state.scratch_storage().1, before.1, "unused error storage must not be rebuilt");
}
#[tokio::test] #[tokio::test]
async fn erasure_decode_reader_reports_short_source() { async fn erasure_decode_reader_reports_short_source() {
let erasure = Erasure::new(4, 2, 32); let erasure = Erasure::new(4, 2, 32);
+48 -264
View File
@@ -18,12 +18,10 @@ use crate::disk::error_reduce::{
}; };
use crate::erasure::coding::BitrotWriterWrapper; use crate::erasure::coding::BitrotWriterWrapper;
use crate::erasure::coding::Erasure; use crate::erasure::coding::Erasure;
use crate::erasure::coding::erasure::EncodedBlock;
use crate::runtime::sources as runtime_sources; use crate::runtime::sources as runtime_sources;
use bytes::{Bytes, BytesMut}; use bytes::{Bytes, BytesMut};
use futures::StreamExt; use futures::StreamExt;
use futures::stream::FuturesUnordered; use futures::stream::FuturesUnordered;
use rustfs_utils::HashAlgorithm;
use std::sync::Arc; use std::sync::Arc;
use std::time::Instant; use std::time::Instant;
use std::vec; use std::vec;
@@ -93,11 +91,6 @@ fn use_bytesmut_ingest() -> bool {
}) })
} }
fn small_ingest_capacity(erasure: &Erasure, size_hint: usize) -> usize {
let data_len = size_hint.min(erasure.block_size);
erasure.encoded_capacity_for_data_len(data_len).min(erasure.block_size)
}
/// Keeps the encoder producer scoped to its parent future. Tokio detaches a /// Keeps the encoder producer scoped to its parent future. Tokio detaches a
/// task when its `JoinHandle` is dropped, so the producer must be aborted when /// task when its `JoinHandle` is dropped, so the producer must be aborted when
/// an upload is cancelled before the encode pipeline finishes. /// an upload is cancelled before the encode pipeline finishes.
@@ -166,7 +159,6 @@ where
if total == 0 { Ok(None) } else { Ok(Some(total)) } if total == 0 { Ok(None) } else { Ok(Some(total)) }
} }
#[allow(dead_code, reason = "byte accounting asserted by this file's tests (backlog#1823)")]
fn queued_block_bytes(block: &[Bytes]) -> usize { fn queued_block_bytes(block: &[Bytes]) -> usize {
block.iter().map(Bytes::len).sum() block.iter().map(Bytes::len).sum()
} }
@@ -226,8 +218,8 @@ async fn send_queued<T>(
sender.send(InflightEntry::new(entry, bytes)).await sender.send(InflightEntry::new(entry, bytes)).await
} }
fn queued_batch_bytes(batch: &[EncodedBlock]) -> usize { fn queued_batch_bytes(batch: &[Vec<Bytes>]) -> usize {
batch.iter().map(EncodedBlock::queued_bytes).sum() batch.iter().map(|block| queued_block_bytes(block)).sum()
} }
fn dominant_error_summary_label(summary: &WriteQuorumFailureSummary) -> &'static str { fn dominant_error_summary_label(summary: &WriteQuorumFailureSummary) -> &'static str {
@@ -339,7 +331,7 @@ impl<'a> MultiWriter<'a> {
} }
} }
async fn write_shard(writer_opt: &mut Option<BitrotWriterWrapper>, err: &mut Option<Error>, shard: &[u8]) { async fn write_shard(writer_opt: &mut Option<BitrotWriterWrapper>, err: &mut Option<Error>, shard: &Bytes) {
match writer_opt { match writer_opt {
Some(writer) => { Some(writer) => {
match writer.write(shard).await { match writer.write(shard).await {
@@ -364,20 +356,12 @@ impl<'a> MultiWriter<'a> {
} }
pub async fn write(&mut self, data: Vec<Bytes>) -> std::io::Result<()> { pub async fn write(&mut self, data: Vec<Bytes>) -> std::io::Result<()> {
self.write_shards(data.iter().map(Bytes::as_ref)).await assert_eq!(data.len(), self.writers.len());
}
async fn write_block(&mut self, block: &EncodedBlock) -> std::io::Result<()> {
self.write_shards(block.shards()).await
}
async fn write_shards<'b>(&mut self, shards: impl ExactSizeIterator<Item = &'b [u8]>) -> std::io::Result<()> {
assert_eq!(shards.len(), self.writers.len());
let budget = self.next_progress_budget(); let budget = self.next_progress_budget();
{ {
let mut futures = FuturesUnordered::new(); let mut futures = FuturesUnordered::new();
for ((writer_opt, err), shard) in self.writers.iter_mut().zip(self.errs.iter_mut()).zip(shards) { for ((writer_opt, err), shard) in self.writers.iter_mut().zip(self.errs.iter_mut()).zip(data.iter()) {
if err.is_some() { if err.is_some() {
continue; // Skip if we already have an error for this writer continue; // Skip if we already have an error for this writer
} }
@@ -501,10 +485,10 @@ impl<'a> MultiWriter<'a> {
} }
impl Erasure { impl Erasure {
async fn encode_block(self: Arc<Self>, encode_buf: Vec<u8>, len: usize) -> std::io::Result<(EncodedBlock, Vec<u8>)> { async fn encode_block(self: Arc<Self>, encode_buf: Vec<u8>, len: usize) -> std::io::Result<(Vec<Bytes>, Vec<u8>)> {
let encode_stage_start = stage_timer_if_enabled(); let encode_stage_start = stage_timer_if_enabled();
let encode_once = move || { let encode_once = move || {
let res = self.encode_data_block(&encode_buf[..len]); let res = self.encode_data(&encode_buf[..len]);
(res, encode_buf) (res, encode_buf)
}; };
@@ -529,9 +513,9 @@ impl Erasure {
Ok((res?, returned_buf)) Ok((res?, returned_buf))
} }
async fn encode_block_bytes_mut(self: Arc<Self>, encode_buf: BytesMut, len: usize) -> std::io::Result<EncodedBlock> { async fn encode_block_bytes_mut(self: Arc<Self>, encode_buf: BytesMut, len: usize) -> std::io::Result<Vec<Bytes>> {
let encode_stage_start = stage_timer_if_enabled(); let encode_stage_start = stage_timer_if_enabled();
let encode_once = move || self.encode_data_bytes_mut_block(encode_buf, len); let encode_once = move || self.encode_data_bytes_mut(encode_buf, len);
let res = match tokio::runtime::Handle::current().runtime_flavor() { let res = match tokio::runtime::Handle::current().runtime_flavor() {
// Same rationale as encode_block: inline the short EC burst on the // Same rationale as encode_block: inline the short EC burst on the
@@ -556,14 +540,13 @@ impl Erasure {
writers: &mut [Option<BitrotWriterWrapper>], writers: &mut [Option<BitrotWriterWrapper>],
quorum: usize, quorum: usize,
require_single_block: bool, require_single_block: bool,
size_hint: usize,
) -> std::io::Result<(R, usize)> ) -> std::io::Result<(R, usize)>
where where
R: AsyncRead + Send + Sync + Unpin, R: AsyncRead + Send + Sync + Unpin,
{ {
use tokio::io::AsyncReadExt; use tokio::io::AsyncReadExt;
let mut buf = Vec::with_capacity(small_ingest_capacity(&self, size_hint)); let mut buf = Vec::with_capacity(self.block_size);
let total = if require_single_block { let total = if require_single_block {
let read_limit = self let read_limit = self
.block_size .block_size
@@ -587,46 +570,13 @@ impl Erasure {
)); ));
} }
let block = self.encode_data_owned_block(buf)?; let shards = self.encode_data_owned(buf)?;
let mut mw = MultiWriter::new(writers, quorum); let mut mw = MultiWriter::new(writers, quorum);
mw.write_block(&block).await?; mw.write(shards).await?;
mw.shutdown().await?; mw.shutdown().await?;
Ok((reader, total)) Ok((reader, total))
} }
/// Encode a small inline object directly into its per-disk bitrot payloads.
/// The returned bytes are the same `[hash][shard]` representation produced
/// by `BitrotWriter`, ready to be embedded in each disk's staged `xl.meta`.
#[hotpath::measure(impl_type = "Erasure")]
pub(crate) async fn encode_inline_shards_with_size_hint<R>(
self: Arc<Self>,
mut reader: R,
size_hint: usize,
) -> std::io::Result<(R, usize, Vec<Bytes>)>
where
R: AsyncRead + Send + Sync + Unpin,
{
use tokio::io::AsyncReadExt;
let mut buf = Vec::with_capacity(small_ingest_capacity(&self, size_hint));
let total = reader.read_to_end(&mut buf).await?;
if total == 0 {
return Ok((reader, 0, Vec::new()));
}
let block = self.encode_data_owned_block(buf)?;
let mut inline_shards = Vec::with_capacity(block.shards().len());
for shard in block.shards() {
let hash = HashAlgorithm::HighwayHash256S.hash_encode(shard);
let mut encoded = BytesMut::with_capacity(hash.as_ref().len() + shard.len());
encoded.extend_from_slice(hash.as_ref());
encoded.extend_from_slice(shard);
inline_shards.push(encoded.freeze());
}
Ok((reader, total, inline_shards))
}
#[hotpath::measure(impl_type = "Erasure")] #[hotpath::measure(impl_type = "Erasure")]
pub async fn encode<R>( pub async fn encode<R>(
self: Arc<Self>, self: Arc<Self>,
@@ -668,7 +618,7 @@ impl Erasure {
let expanded_block_bytes = self.shard_size().saturating_mul(self.total_shard_count()); let expanded_block_bytes = self.shard_size().saturating_mul(self.total_shard_count());
let max_inflight_bytes = erasure_encode_max_inflight_bytes(); let max_inflight_bytes = erasure_encode_max_inflight_bytes();
let inflight_blocks = encode_channel_capacity(expanded_block_bytes, max_inflight_bytes); let inflight_blocks = encode_channel_capacity(expanded_block_bytes, max_inflight_bytes);
let (tx, mut rx) = mpsc::channel::<InflightEntry<EncodedBlock>>(inflight_blocks); let (tx, mut rx) = mpsc::channel::<InflightEntry<Vec<Bytes>>>(inflight_blocks);
let mut task = AbortOnDropTask::new(tokio::spawn(async move { let mut task = AbortOnDropTask::new(tokio::spawn(async move {
let block_size = self.block_size; let block_size = self.block_size;
@@ -690,7 +640,7 @@ impl Erasure {
let encode_buf = buf; let encode_buf = buf;
let res = self.clone().encode_block_bytes_mut(encode_buf, n).await?; let res = self.clone().encode_block_bytes_mut(encode_buf, n).await?;
buf = BytesMut::with_capacity(ingest_capacity); buf = BytesMut::with_capacity(ingest_capacity);
let queued_bytes = res.queued_bytes(); let queued_bytes = queued_block_bytes(&res);
let _producer_stage = rustfs_io_metrics::track_ec_encode_producer_bytes(queued_bytes); let _producer_stage = rustfs_io_metrics::track_ec_encode_producer_bytes(queued_bytes);
let send_wait_stage_start = stage_timer_if_enabled(); let send_wait_stage_start = stage_timer_if_enabled();
if let Err(err) = send_queued(&tx, res, queued_bytes).await { if let Err(err) = send_queued(&tx, res, queued_bytes).await {
@@ -720,7 +670,7 @@ impl Erasure {
let encode_buf = std::mem::take(&mut buf); let encode_buf = std::mem::take(&mut buf);
let (res, returned_buf) = self.clone().encode_block(encode_buf, n).await?; let (res, returned_buf) = self.clone().encode_block(encode_buf, n).await?;
buf = returned_buf; buf = returned_buf;
let queued_bytes = res.queued_bytes(); let queued_bytes = queued_block_bytes(&res);
let _producer_stage = rustfs_io_metrics::track_ec_encode_producer_bytes(queued_bytes); let _producer_stage = rustfs_io_metrics::track_ec_encode_producer_bytes(queued_bytes);
let send_wait_stage_start = stage_timer_if_enabled(); let send_wait_stage_start = stage_timer_if_enabled();
if let Err(err) = send_queued(&tx, res, queued_bytes).await { if let Err(err) = send_queued(&tx, res, queued_bytes).await {
@@ -764,9 +714,9 @@ impl Erasure {
if block.is_empty() { if block.is_empty() {
break; break;
} }
let _writer_stage = rustfs_io_metrics::track_ec_encode_writer_bytes(block.queued_bytes()); let _writer_stage = rustfs_io_metrics::track_ec_encode_writer_bytes(queued_block_bytes(&block));
let write_stage_start = stage_timer_if_enabled(); let write_stage_start = stage_timer_if_enabled();
if let Err(err) = writers.write_block(&block).await { if let Err(err) = writers.write(block).await {
write_err = Some(err); write_err = Some(err);
break; break;
} }
@@ -813,7 +763,7 @@ impl Erasure {
let inflight_blocks = encode_channel_capacity(expanded_block_bytes, max_inflight_bytes); let inflight_blocks = encode_channel_capacity(expanded_block_bytes, max_inflight_bytes);
let batch_blocks = encode_batch_block_count().min(inflight_blocks); let batch_blocks = encode_batch_block_count().min(inflight_blocks);
let channel_capacity = inflight_blocks.div_ceil(batch_blocks).max(1); let channel_capacity = inflight_blocks.div_ceil(batch_blocks).max(1);
let (tx, mut rx) = mpsc::channel::<InflightEntry<Vec<EncodedBlock>>>(channel_capacity); let (tx, mut rx) = mpsc::channel::<InflightEntry<Vec<Vec<Bytes>>>>(channel_capacity);
let mut task = AbortOnDropTask::new(tokio::spawn(async move { let mut task = AbortOnDropTask::new(tokio::spawn(async move {
let block_size = self.block_size; let block_size = self.block_size;
@@ -830,7 +780,7 @@ impl Erasure {
let encode_buf = std::mem::take(&mut buf); let encode_buf = std::mem::take(&mut buf);
let (res, returned_buf) = self.clone().encode_block(encode_buf, n).await?; let (res, returned_buf) = self.clone().encode_block(encode_buf, n).await?;
buf = returned_buf; buf = returned_buf;
let queued_bytes = res.queued_bytes(); let queued_bytes = queued_block_bytes(&res);
pending_batch_bytes = pending_batch_bytes.saturating_add(queued_bytes); pending_batch_bytes = pending_batch_bytes.saturating_add(queued_bytes);
pending_batch.push(res); pending_batch.push(res);
drop(pending_batch_stage.take()); drop(pending_batch_stage.take());
@@ -889,7 +839,7 @@ impl Erasure {
let _writer_stage = rustfs_io_metrics::track_ec_encode_writer_bytes(queued_batch_bytes(&batch)); let _writer_stage = rustfs_io_metrics::track_ec_encode_writer_bytes(queued_batch_bytes(&batch));
let write_stage_start = stage_timer_if_enabled(); let write_stage_start = stage_timer_if_enabled();
for block in batch { for block in batch {
if let Err(err) = writers.write_block(&block).await { if let Err(err) = writers.write(block).await {
write_err = Some(err); write_err = Some(err);
break; break;
} }
@@ -930,24 +880,7 @@ impl Erasure {
where where
R: AsyncRead + Send + Sync + Unpin, R: AsyncRead + Send + Sync + Unpin,
{ {
let size_hint = self.block_size; self.encode_small_direct(reader, writers, quorum, false).await
self.encode_small_direct(reader, writers, quorum, false, size_hint).await
}
/// Size-aware inline fast path. `size_hint` only controls the bounded initial
/// allocation; reads remain authoritative.
#[hotpath::measure(impl_type = "Erasure")]
pub async fn encode_inline_small_with_size_hint<R>(
self: Arc<Self>,
reader: R,
writers: &mut [Option<BitrotWriterWrapper>],
quorum: usize,
size_hint: usize,
) -> std::io::Result<(R, usize)>
where
R: AsyncRead + Send + Sync + Unpin,
{
self.encode_small_direct(reader, writers, quorum, false, size_hint).await
} }
/// Fast path for single-block non-inline objects: avoids the producer/consumer /// Fast path for single-block non-inline objects: avoids the producer/consumer
@@ -962,24 +895,7 @@ impl Erasure {
where where
R: AsyncRead + Send + Sync + Unpin, R: AsyncRead + Send + Sync + Unpin,
{ {
let size_hint = self.block_size; self.encode_small_direct(reader, writers, quorum, true).await
self.encode_small_direct(reader, writers, quorum, true, size_hint).await
}
/// Size-aware single-block fast path. `size_hint` only controls the bounded
/// initial allocation; reads remain authoritative.
#[hotpath::measure(impl_type = "Erasure")]
pub async fn encode_single_block_non_inline_with_size_hint<R>(
self: Arc<Self>,
reader: R,
writers: &mut [Option<BitrotWriterWrapper>],
quorum: usize,
size_hint: usize,
) -> std::io::Result<(R, usize)>
where
R: AsyncRead + Send + Sync + Unpin,
{
self.encode_small_direct(reader, writers, quorum, true, size_hint).await
} }
} }
@@ -1939,11 +1855,7 @@ mod tests {
let baseline = rustfs_io_metrics::current_ec_encode_inflight_bytes(); let baseline = rustfs_io_metrics::current_ec_encode_inflight_bytes();
let (tx, rx) = mpsc::channel(2); let (tx, rx) = mpsc::channel(2);
let mut rx = rx; let mut rx = rx;
let erasure = Erasure::new(1, 0, 16); let batch = vec![vec![Bytes::from_static(b"queued")], vec![Bytes::from_static(b"batch")]];
let batch = vec![
erasure.encode_data_block(b"queued").expect("first block should encode"),
erasure.encode_data_block(b"batch").expect("second block should encode"),
];
let batch_bytes = queued_batch_bytes(&batch); let batch_bytes = queued_batch_bytes(&batch);
send_queued(&tx, batch, batch_bytes).await.expect("batch should be queued"); send_queued(&tx, batch, batch_bytes).await.expect("batch should be queued");
@@ -2165,39 +2077,6 @@ mod tests {
); );
} }
#[tokio::test]
async fn cancelling_inline_small_drops_stalled_write() {
const BLOCK_SIZE: usize = 16;
let (writer_entered_tx, writer_entered) = oneshot::channel();
let writes = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let mut writers = vec![Some(bitrot_writer_plain(
StallOnWriteWithSignal {
entered: Some(writer_entered_tx),
writes: writes.clone(),
},
BLOCK_SIZE,
))];
let erasure = Arc::new(Erasure::new(1, 0, BLOCK_SIZE));
let reader = tokio::io::BufReader::new(Cursor::new(vec![0xA5; BLOCK_SIZE - 1]));
let encode = tokio::spawn(async move { erasure.encode_inline_small(reader, &mut writers, 1).await });
tokio::time::timeout(Duration::from_secs(1), writer_entered)
.await
.expect("inline writer should enter before cancellation")
.expect("stalling writer should signal entry");
encode.abort();
assert!(
matches!(encode.await, Err(err) if err.is_cancelled()),
"inline encode task should be cancelled"
);
assert_eq!(
writes.load(std::sync::atomic::Ordering::SeqCst),
1,
"cancellation must drop the stalled write instead of polling it again"
);
}
#[tokio::test] #[tokio::test]
async fn encode_returns_unexpected_eof_for_truncated_limited_reader() { async fn encode_returns_unexpected_eof_for_truncated_limited_reader() {
let committed = Arc::new(Mutex::new(Vec::new())); let committed = Arc::new(Mutex::new(Vec::new()));
@@ -2317,11 +2196,11 @@ mod tests {
.expect("bytesmut encode should succeed on current-thread runtime"); .expect("bytesmut encode should succeed on current-thread runtime");
let expected_shard_size = payload.len().div_ceil(erasure.data_shards); let expected_shard_size = payload.len().div_ceil(erasure.data_shards);
assert_eq!(shards.shards().len(), erasure.total_shard_count()); assert_eq!(shards.len(), erasure.total_shard_count());
assert!(shards.shards().all(|shard| shard.len() == expected_shard_size)); assert!(shards.iter().all(|shard| shard.len() == expected_shard_size));
let mut restored = Vec::new(); let mut restored = Vec::new();
for shard in shards.shards().take(erasure.data_shards) { for shard in shards.iter().take(erasure.data_shards) {
restored.extend_from_slice(shard); restored.extend_from_slice(shard);
} }
restored.truncate(payload.len()); restored.truncate(payload.len());
@@ -2414,51 +2293,13 @@ mod tests {
let erasure = Arc::new(Erasure::new(1, 0, 16)); let erasure = Arc::new(Erasure::new(1, 0, 16));
let reader = tokio::io::BufReader::new(Cursor::new(Vec::<u8>::new())); let reader = tokio::io::BufReader::new(Cursor::new(Vec::<u8>::new()));
let (_reader, total) = erasure let (_reader, total) = erasure.encode_inline_small(reader, &mut writers, 1).await.unwrap();
.encode_inline_small_with_size_hint(reader, &mut writers, 1, 0)
.await
.unwrap();
assert_eq!(total, 0); assert_eq!(total, 0);
// No shutdown was called, so nothing should be committed // No shutdown was called, so nothing should be committed
assert!(committed.lock().unwrap().is_empty()); assert!(committed.lock().unwrap().is_empty());
} }
#[tokio::test]
async fn encode_inline_shards_matches_writer_bitrot_layout() {
const DATA_SHARDS: usize = 2;
const PARITY_SHARDS: usize = 2;
const BLOCK_SIZE: usize = 64;
let checksum_algo = HashAlgorithm::HighwayHash256S;
for uses_legacy in [false, true] {
let erasure = Arc::new(Erasure::new_with_options(DATA_SHARDS, PARITY_SHARDS, BLOCK_SIZE, uses_legacy));
for payload in [Vec::new(), vec![0xA5], vec![0x5A; BLOCK_SIZE - 1], vec![0xC3; BLOCK_SIZE]] {
let reader = tokio::io::BufReader::new(Cursor::new(payload.clone()));
let (_reader, total, inline_shards) = erasure
.clone()
.encode_inline_shards_with_size_hint(reader, payload.len())
.await
.expect("inline shards should encode");
assert_eq!(total, payload.len());
if payload.is_empty() {
assert!(inline_shards.is_empty());
continue;
}
let raw_shards = erasure.encode_data(&payload).expect("reference shards should encode");
assert_eq!(inline_shards.len(), DATA_SHARDS + PARITY_SHARDS);
for (inline, raw) in inline_shards.iter().zip(raw_shards) {
let mut writer =
BitrotWriterWrapper::new(CustomWriter::new_inline_buffer(), raw.len(), checksum_algo.clone());
writer.write(&raw).await.expect("reference writer should accept shard");
writer.shutdown().await.expect("reference writer should shutdown");
assert_eq!(inline.as_ref(), writer.into_inline_data().expect("reference writer should retain bytes"));
}
}
}
}
/// encode_inline_small: small payload is encoded into the correct number of shards /// encode_inline_small: small payload is encoded into the correct number of shards
/// and each writer receives data after shutdown. /// and each writer receives data after shutdown.
#[tokio::test] #[tokio::test]
@@ -2484,10 +2325,7 @@ mod tests {
let payload = b"hello inline small"; let payload = b"hello inline small";
let erasure = Arc::new(Erasure::new(DATA_SHARDS, PARITY_SHARDS, BLOCK_SIZE)); let erasure = Arc::new(Erasure::new(DATA_SHARDS, PARITY_SHARDS, BLOCK_SIZE));
let reader = tokio::io::BufReader::new(Cursor::new(payload.to_vec())); let reader = tokio::io::BufReader::new(Cursor::new(payload.to_vec()));
let (_reader, total) = erasure let (_reader, total) = erasure.encode_inline_small(reader, &mut writers, DATA_SHARDS).await.unwrap();
.encode_inline_small_with_size_hint(reader, &mut writers, DATA_SHARDS, 1)
.await
.unwrap();
assert_eq!(total, payload.len()); assert_eq!(total, payload.len());
// All shards must have received data (shutdown flushed the bitrot header + shard bytes) // All shards must have received data (shutdown flushed the bitrot header + shard bytes)
@@ -2554,7 +2392,7 @@ mod tests {
let erasure = Arc::new(Erasure::new(DATA_SHARDS, PARITY_SHARDS, BLOCK_SIZE)); let erasure = Arc::new(Erasure::new(DATA_SHARDS, PARITY_SHARDS, BLOCK_SIZE));
let reader = tokio::io::BufReader::new(Cursor::new(payload)); let reader = tokio::io::BufReader::new(Cursor::new(payload));
let err = erasure let err = erasure
.encode_single_block_non_inline_with_size_hint(reader, &mut writers, DATA_SHARDS, BLOCK_SIZE) .encode_single_block_non_inline(reader, &mut writers, DATA_SHARDS)
.await .await
.expect_err("single-block fast path must reject oversized readers"); .expect_err("single-block fast path must reject oversized readers");
@@ -2565,21 +2403,6 @@ mod tests {
} }
} }
#[test]
fn small_ingest_capacity_uses_bounded_size_hint() {
let erasure = Erasure::new(4, 2, 1024 * 1024);
assert_eq!(small_ingest_capacity(&erasure, 0), 0);
assert_eq!(small_ingest_capacity(&erasure, 4 * 1024), 6 * 1024);
assert_eq!(small_ingest_capacity(&erasure, 16 * 1024), 24 * 1024);
assert_eq!(small_ingest_capacity(&erasure, usize::MAX), 1024 * 1024);
let legacy = Erasure::new_with_options(4, 2, 1024 * 1024, true);
assert_eq!(small_ingest_capacity(&legacy, 4 * 1024), 6 * 1024);
let high_parity = Erasure::new(4, 12, 1024 * 1024);
assert_eq!(small_ingest_capacity(&high_parity, usize::MAX), 1024 * 1024);
}
#[tokio::test] #[tokio::test]
async fn read_full_buf_or_eof_returns_none_on_empty_reader() { async fn read_full_buf_or_eof_returns_none_on_empty_reader() {
let mut reader = Cursor::new(Vec::<u8>::new()); let mut reader = Cursor::new(Vec::<u8>::new());
@@ -2622,7 +2445,7 @@ mod tests {
assert_eq!(&next[..], &data[16..]); assert_eq!(&next[..], &data[16..]);
} }
async fn committed_shards_for_pipeline(pipeline: EncodePipeline, uses_legacy: bool, payload: &[u8]) -> Vec<Vec<u8>> { async fn committed_shards_for_ingest_mode(use_bytesmut_ingest: bool, uses_legacy: bool, payload: &[u8]) -> Vec<Vec<u8>> {
const DATA_SHARDS: usize = 2; const DATA_SHARDS: usize = 2;
const PARITY_SHARDS: usize = 2; const PARITY_SHARDS: usize = 2;
const TOTAL_SHARDS: usize = DATA_SHARDS + PARITY_SHARDS; const TOTAL_SHARDS: usize = DATA_SHARDS + PARITY_SHARDS;
@@ -2636,16 +2459,10 @@ mod tests {
let erasure = Arc::new(Erasure::new_with_options(DATA_SHARDS, PARITY_SHARDS, BLOCK_SIZE, uses_legacy)); let erasure = Arc::new(Erasure::new_with_options(DATA_SHARDS, PARITY_SHARDS, BLOCK_SIZE, uses_legacy));
let reader = tokio::io::BufReader::new(Cursor::new(payload.to_vec())); let reader = tokio::io::BufReader::new(Cursor::new(payload.to_vec()));
let (_reader, total) = match pipeline { let (_reader, total) = erasure
EncodePipeline::Vec => { .encode_with_ingest_mode(reader, &mut writers, DATA_SHARDS, use_bytesmut_ingest)
erasure .await
.encode_with_ingest_mode(reader, &mut writers, DATA_SHARDS, false) .expect("encode should succeed");
.await
}
EncodePipeline::BytesMut => erasure.encode_with_ingest_mode(reader, &mut writers, DATA_SHARDS, true).await,
EncodePipeline::Batched => erasure.encode_batched(reader, &mut writers, DATA_SHARDS).await,
}
.expect("encode should succeed");
assert_eq!(total, payload.len()); assert_eq!(total, payload.len());
committed committed
@@ -2654,64 +2471,31 @@ mod tests {
.collect() .collect()
} }
async fn expected_committed_shards(uses_legacy: bool, payload: &[u8]) -> Vec<Vec<u8>> { /// HP-10 (rustfs/backlog#931) merge gate: the BytesMut ingest path must produce
const DATA_SHARDS: usize = 2; /// byte-for-byte identical shard streams to the default Vec ingest path, for both
const PARITY_SHARDS: usize = 2; /// legacy-aware shard-size formulas, across empty, sub-block, exactly-full-block,
const TOTAL_SHARDS: usize = DATA_SHARDS + PARITY_SHARDS; /// and multi-block-with-partial-tail payloads.
const BLOCK_SIZE: usize = 64;
let committed: Vec<Arc<Mutex<Vec<u8>>>> = (0..TOTAL_SHARDS).map(|_| Arc::new(Mutex::new(Vec::new()))).collect();
let mut writers: Vec<BitrotWriterWrapper> = committed
.iter()
.map(|c| bitrot_writer(DeferredCommitWriter::new(c.clone()), BLOCK_SIZE / DATA_SHARDS))
.collect();
let erasure = Erasure::new_with_options(DATA_SHARDS, PARITY_SHARDS, BLOCK_SIZE, uses_legacy);
for block in payload.chunks(BLOCK_SIZE) {
let shards = erasure.encode_data(block).expect("reference block should encode");
for (writer, shard) in writers.iter_mut().zip(shards) {
let written = writer.write(&shard).await.expect("reference shard should write");
assert_eq!(written, shard.len());
}
}
for writer in &mut writers {
writer.shutdown().await.expect("reference writer should commit");
}
committed
.iter()
.map(|c| c.lock().expect("committed buffer should be lockable").clone())
.collect()
}
/// The streaming and batched paths must produce the same bitrot-wrapped shard
/// bytes as the public block encoder for both shard-size formulas and all block
/// boundary shapes.
#[tokio::test] #[tokio::test]
async fn bytesmut_ingest_matches_vec_ingest_byte_for_byte() { async fn bytesmut_ingest_matches_vec_ingest_byte_for_byte() {
const BLOCK_SIZE: usize = 64; const BLOCK_SIZE: usize = 64;
let payloads: Vec<Vec<u8>> = vec![ let payloads: Vec<Vec<u8>> = vec![
Vec::new(), Vec::new(),
vec![1], b"tiny".to_vec(),
vec![2; BLOCK_SIZE - 1],
(0..BLOCK_SIZE as u32).map(|i| i as u8).collect(), // exactly one full block (0..BLOCK_SIZE as u32).map(|i| i as u8).collect(), // exactly one full block
vec![4; BLOCK_SIZE + 1], vec![3u8; BLOCK_SIZE * 4], // whole number of blocks
vec![3u8; BLOCK_SIZE * 4], // whole number of blocks
(0..(BLOCK_SIZE * 3 + 7) as u32).map(|i| (i % 251) as u8).collect(), // partial tail (0..(BLOCK_SIZE * 3 + 7) as u32).map(|i| (i % 251) as u8).collect(), // partial tail
]; ];
for uses_legacy in [false, true] { for uses_legacy in [false, true] {
for payload in &payloads { for payload in &payloads {
let expected = expected_committed_shards(uses_legacy, payload).await; let vec_path = committed_shards_for_ingest_mode(false, uses_legacy, payload).await;
for pipeline in [EncodePipeline::Vec, EncodePipeline::BytesMut, EncodePipeline::Batched] { let bytesmut_path = committed_shards_for_ingest_mode(true, uses_legacy, payload).await;
let actual = committed_shards_for_pipeline(pipeline, uses_legacy, payload).await; assert_eq!(
assert_eq!( vec_path,
actual, bytesmut_path,
expected, "ingest paths must be byte-identical (legacy={uses_legacy}, payload_len={})",
"streaming shards must match the public block encoder (legacy={uses_legacy}, payload_len={})", payload.len()
payload.len() );
);
}
} }
} }
} }
+140 -347
View File
@@ -29,58 +29,12 @@ use tokio::io::AsyncRead;
use tracing::warn; use tracing::warn;
use uuid::Uuid; use uuid::Uuid;
pub(crate) struct EncodedBlock {
data: Bytes,
shard_size: usize,
}
impl EncodedBlock {
fn empty() -> Self {
Self {
data: Bytes::new(),
shard_size: 0,
}
}
pub(crate) fn is_empty(&self) -> bool {
self.data.is_empty()
}
pub(crate) fn queued_bytes(&self) -> usize {
self.data.len()
}
pub(crate) fn shards(&self) -> impl ExactSizeIterator<Item = &[u8]> {
debug_assert!(self.shard_size > 0, "only non-empty encoded blocks reach shard writers");
debug_assert_eq!(self.data.len() % self.shard_size, 0);
self.data.chunks_exact(self.shard_size)
}
fn into_shards(mut self, shard_count: usize) -> Vec<Bytes> {
if self.shard_size == 0 {
return vec![Bytes::new(); shard_count];
}
let mut shards = Vec::with_capacity(shard_count);
for _ in 0..shard_count {
shards.push(self.data.split_to(self.shard_size));
}
shards
}
}
const MODERN_MAX_TOTAL_SHARDS: usize = <reed_solomon_erasure::galois_8::Field as reed_solomon_erasure::Field>::ORDER; const MODERN_MAX_TOTAL_SHARDS: usize = <reed_solomon_erasure::galois_8::Field as reed_solomon_erasure::Field>::ORDER;
const MODERN_REED_SOLOMON_CACHE_MAX_ENTRIES: usize = 64; const MODERN_REED_SOLOMON_CACHE_MAX_ENTRIES: usize = 64;
const LEGACY_REED_SOLOMON_CACHE_MAX_ENTRIES: usize = 16;
// Vec growth may retain twice the requested logical length. Keeping the logical
// workspace at half the budget bounds each cached workspace's shard allocation to 1 MiB.
const LEGACY_REED_SOLOMON_CACHE_MAX_LOGICAL_SHARD_BYTES_PER_WORKSPACE: usize = 512 * 1024;
type ModernReedSolomonCache = RwLock<HashMap<(usize, usize), Arc<ReedSolomon>>>; type ModernReedSolomonCache = RwLock<HashMap<(usize, usize), Arc<ReedSolomon>>>;
type LegacyReedSolomonCache = RwLock<HashMap<(usize, usize), Arc<LegacyReedSolomonEncoder>>>;
static MODERN_REED_SOLOMON_CACHE: OnceLock<ModernReedSolomonCache> = OnceLock::new(); static MODERN_REED_SOLOMON_CACHE: OnceLock<ModernReedSolomonCache> = OnceLock::new();
static LEGACY_REED_SOLOMON_CACHE: OnceLock<LegacyReedSolomonCache> = OnceLock::new();
/// Errors returned when constructing an [`Erasure`] codec. /// Errors returned when constructing an [`Erasure`] codec.
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
@@ -147,61 +101,43 @@ pub fn calc_shard_size_legacy(block_size: usize, data_shards: usize) -> usize {
struct LegacyReedSolomonEncoder { struct LegacyReedSolomonEncoder {
data_shards: usize, data_shards: usize,
parity_shards: usize, parity_shards: usize,
cache_workspaces: bool, encoder_cache: std::sync::RwLock<Option<reed_solomon_simd::ReedSolomonEncoder>>,
encoder_cache: RwLock<Option<reed_solomon_simd::ReedSolomonEncoder>>, decoder_cache: std::sync::RwLock<Option<reed_solomon_simd::ReedSolomonDecoder>>,
decoder_cache: RwLock<Option<reed_solomon_simd::ReedSolomonDecoder>>, }
impl Clone for LegacyReedSolomonEncoder {
fn clone(&self) -> Self {
Self {
data_shards: self.data_shards,
parity_shards: self.parity_shards,
encoder_cache: std::sync::RwLock::new(None),
decoder_cache: std::sync::RwLock::new(None),
}
}
} }
impl LegacyReedSolomonEncoder { impl LegacyReedSolomonEncoder {
fn new(data_shards: usize, parity_shards: usize) -> io::Result<Self> { fn new(_data_shards: usize, _parity_shards: usize) -> io::Result<Self> {
Self::with_workspace_cache(data_shards, parity_shards, false)
}
fn with_workspace_cache(data_shards: usize, parity_shards: usize, cache_workspaces: bool) -> io::Result<Self> {
Ok(Self { Ok(Self {
data_shards, data_shards: _data_shards,
parity_shards, parity_shards: _parity_shards,
cache_workspaces, encoder_cache: std::sync::RwLock::new(None),
encoder_cache: RwLock::new(None), decoder_cache: std::sync::RwLock::new(None),
decoder_cache: RwLock::new(None),
}) })
} }
fn logical_shard_bytes_upper_bound(&self, shard_len: usize) -> Option<usize> {
let aligned_shard_len = shard_len.checked_add(63)?.checked_div(64)?.checked_mul(64)?;
let high_rate_decoder_work_count = self
.parity_shards
.checked_next_power_of_two()?
.checked_add(self.data_shards)?
.checked_next_power_of_two()?;
let low_rate_decoder_work_count = self
.data_shards
.checked_next_power_of_two()?
.checked_add(self.parity_shards)?
.checked_next_power_of_two()?;
aligned_shard_len.checked_mul(high_rate_decoder_work_count.max(low_rate_decoder_work_count))
}
fn should_cache_workspace(&self, shard_len: usize) -> bool {
self.cache_workspaces
&& self
.logical_shard_bytes_upper_bound(shard_len)
.is_some_and(|bytes| bytes <= LEGACY_REED_SOLOMON_CACHE_MAX_LOGICAL_SHARD_BYTES_PER_WORKSPACE)
}
fn encode(&self, shards: SmallVec<[&mut [u8]; 16]>) -> io::Result<()> { fn encode(&self, shards: SmallVec<[&mut [u8]; 16]>) -> io::Result<()> {
let mut shards_vec: Vec<&mut [u8]> = shards.into_vec(); let mut shards_vec: Vec<&mut [u8]> = shards.into_vec();
if shards_vec.is_empty() { if shards_vec.is_empty() {
return Ok(()); return Ok(());
} }
let shard_len = shards_vec[0].len(); let shard_len = shards_vec[0].len();
let cached_encoder = self
.encoder_cache
.write()
.map_err(|_| io::Error::other("Failed to acquire encoder cache lock"))?
.take();
let mut encoder = { let mut encoder = {
match cached_encoder { let mut cache_guard = self
.encoder_cache
.write()
.map_err(|_| io::Error::other("Failed to acquire encoder cache lock"))?;
match cache_guard.take() {
Some(mut cached) => { Some(mut cached) => {
if cached.reset(self.data_shards, self.parity_shards, shard_len).is_err() { if cached.reset(self.data_shards, self.parity_shards, shard_len).is_err() {
reed_solomon_simd::ReedSolomonEncoder::new(self.data_shards, self.parity_shards, shard_len) reed_solomon_simd::ReedSolomonEncoder::new(self.data_shards, self.parity_shards, shard_len)
@@ -228,15 +164,10 @@ impl LegacyReedSolomonEncoder {
} }
} }
drop(result); drop(result);
if self.should_cache_workspace(shard_len) { *self
let mut cache = self .encoder_cache
.encoder_cache .write()
.write() .map_err(|_| io::Error::other("Failed to return encoder to cache"))? = Some(encoder);
.map_err(|_| io::Error::other("Failed to return encoder to cache"))?;
if cache.is_none() {
*cache = Some(encoder);
}
}
Ok(()) Ok(())
} }
@@ -250,13 +181,13 @@ impl LegacyReedSolomonEncoder {
.find_map(|s| s.as_ref().map(|v| v.len())) .find_map(|s| s.as_ref().map(|v| v.len()))
.ok_or_else(|| io::Error::other("No valid shards found for reconstruction"))?; .ok_or_else(|| io::Error::other("No valid shards found for reconstruction"))?;
let cached_decoder = self
.decoder_cache
.write()
.map_err(|_| io::Error::other("Failed to acquire decoder cache lock"))?
.take();
let mut decoder = { let mut decoder = {
match cached_decoder { let mut cache_guard = self
.decoder_cache
.write()
.map_err(|_| io::Error::other("Failed to acquire decoder cache lock"))?;
match cache_guard.take() {
Some(mut cached_decoder) => { Some(mut cached_decoder) => {
if let Err(e) = cached_decoder.reset(self.data_shards, self.parity_shards, shard_len) { if let Err(e) = cached_decoder.reset(self.data_shards, self.parity_shards, shard_len) {
warn!("Failed to reset SIMD decoder: {:?}, creating new one", e); warn!("Failed to reset SIMD decoder: {:?}, creating new one", e);
@@ -303,15 +234,10 @@ impl LegacyReedSolomonEncoder {
drop(result); drop(result);
if self.should_cache_workspace(shard_len) { *self
let mut cache = self .decoder_cache
.decoder_cache .write()
.write() .map_err(|_| io::Error::other("Failed to return decoder to cache"))? = Some(decoder);
.map_err(|_| io::Error::other("Failed to return decoder to cache"))?;
if cache.is_none() {
*cache = Some(decoder);
}
}
Ok(()) Ok(())
} }
@@ -469,39 +395,6 @@ fn cached_modern_reed_solomon(data_shards: usize, parity_shards: usize) -> Resul
Ok(encoder) Ok(encoder)
} }
fn cached_legacy_reed_solomon(data_shards: usize, parity_shards: usize) -> io::Result<Arc<LegacyReedSolomonEncoder>> {
let cache = LEGACY_REED_SOLOMON_CACHE.get_or_init(|| RwLock::new(HashMap::new()));
cached_legacy_reed_solomon_in(cache, data_shards, parity_shards)
}
fn cached_legacy_reed_solomon_in(
cache: &LegacyReedSolomonCache,
data_shards: usize,
parity_shards: usize,
) -> io::Result<Arc<LegacyReedSolomonEncoder>> {
let key = (data_shards, parity_shards);
if let Some(encoder) = cache
.read()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.get(&key)
.cloned()
{
return Ok(encoder);
}
let mut cache = cache.write().unwrap_or_else(|poisoned| poisoned.into_inner());
if let Some(existing) = cache.get(&key) {
return Ok(Arc::clone(existing));
}
if cache.len() < LEGACY_REED_SOLOMON_CACHE_MAX_ENTRIES {
let encoder = Arc::new(LegacyReedSolomonEncoder::with_workspace_cache(data_shards, parity_shards, true)?);
cache.insert(key, Arc::clone(&encoder));
return Ok(encoder);
}
drop(cache);
Ok(Arc::new(LegacyReedSolomonEncoder::new(data_shards, parity_shards)?))
}
fn encode_parity_shards<F>(shards: &mut [Option<Vec<u8>>], data_shards: usize, parity_shards: usize, encode: F) -> io::Result<()> fn encode_parity_shards<F>(shards: &mut [Option<Vec<u8>>], data_shards: usize, parity_shards: usize, encode: F) -> io::Result<()>
where where
F: FnOnce(SmallVec<[&mut [u8]; 16]>) -> io::Result<()>, F: FnOnce(SmallVec<[&mut [u8]; 16]>) -> io::Result<()>,
@@ -618,7 +511,7 @@ pub struct Erasure {
pub data_shards: usize, pub data_shards: usize,
pub parity_shards: usize, pub parity_shards: usize,
encoder: Option<ReedSolomonEncoder>, encoder: Option<ReedSolomonEncoder>,
legacy_encoder: Option<Arc<LegacyReedSolomonEncoder>>, legacy_encoder: Option<LegacyReedSolomonEncoder>,
pub block_size: usize, pub block_size: usize,
uses_legacy: bool, uses_legacy: bool,
_id: Uuid, _id: Uuid,
@@ -754,7 +647,7 @@ impl Erasure {
let legacy_encoder = if uses_legacy && parity_shards > 0 { let legacy_encoder = if uses_legacy && parity_shards > 0 {
Some( Some(
cached_legacy_reed_solomon(data_shards, parity_shards) LegacyReedSolomonEncoder::new(data_shards, parity_shards)
.map_err(|source| ErasureConstructionError::LegacyEncoder { source })?, .map_err(|source| ErasureConstructionError::LegacyEncoder { source })?,
) )
} else { } else {
@@ -782,48 +675,106 @@ impl Erasure {
#[tracing::instrument(level = "debug", skip_all, fields(data_len=data.len()))] #[tracing::instrument(level = "debug", skip_all, fields(data_len=data.len()))]
#[hotpath::measure(impl_type = "Erasure")] #[hotpath::measure(impl_type = "Erasure")]
pub fn encode_data(&self, data: &[u8]) -> io::Result<Vec<Bytes>> { pub fn encode_data(&self, data: &[u8]) -> io::Result<Vec<Bytes>> {
self.encode_data_block_inner(data) let shard_size_fn = if self.uses_legacy {
.map(|block| block.into_shards(self.total_shard_count())) calc_shard_size_legacy
} } else {
calc_shard_size
};
let per_shard_size = shard_size_fn(data.len(), self.data_shards);
if per_shard_size == 0 {
return Ok(vec![Bytes::new(); self.total_shard_count()]);
}
let need_total_size = per_shard_size * self.total_shard_count();
#[tracing::instrument(level = "debug", skip_all, fields(data_len=data.len()))] let mut data_buffer = BytesMut::with_capacity(need_total_size);
#[hotpath::measure(label = "Erasure::encode_data", impl_type = "Erasure")]
pub(crate) fn encode_data_block(&self, data: &[u8]) -> io::Result<EncodedBlock> {
self.encode_data_block_inner(data)
}
fn encode_data_block_inner(&self, data: &[u8]) -> io::Result<EncodedBlock> {
let mut data_buffer = BytesMut::with_capacity(self.encoded_capacity_for_data_len(data.len()));
data_buffer.extend_from_slice(data); data_buffer.extend_from_slice(data);
self.encode_buffer(data_buffer, data.len()) data_buffer.resize(need_total_size, 0u8);
{
let data_slices: SmallVec<[&mut [u8]; 16]> = data_buffer.chunks_exact_mut(per_shard_size).collect();
if self.parity_shards > 0 {
if self.uses_legacy {
if let Some(encoder) = self.legacy_encoder.as_ref() {
encoder.encode(data_slices)?;
} else {
warn!("parity_shards > 0, uses_legacy but legacy_encoder is None");
}
} else if let Some(encoder) = self.encoder.as_ref() {
encoder.encode(data_slices)?;
} else {
warn!("parity_shards > 0, but encoder is None");
}
}
}
// Zero-copy split, all shards reference data_buffer
let mut data_buffer = data_buffer.freeze();
let mut shards = Vec::with_capacity(self.total_shard_count());
for _ in 0..self.total_shard_count() {
let shard = data_buffer.split_to(per_shard_size);
shards.push(shard);
}
Ok(shards)
} }
/// Encode owned data, avoiding a copy when the caller already has a heap buffer. /// Encode owned data, avoiding a copy when the caller already has a heap buffer.
/// Falls back to copying into a new buffer if zero-copy conversion fails. /// Falls back to copying into a new buffer if zero-copy conversion fails.
#[hotpath::measure(impl_type = "Erasure")] #[hotpath::measure(impl_type = "Erasure")]
pub fn encode_data_owned(&self, data: Vec<u8>) -> io::Result<Vec<Bytes>> { pub fn encode_data_owned(&self, data: Vec<u8>) -> io::Result<Vec<Bytes>> {
self.encode_data_owned_block_inner(data) let shard_size_fn = if self.uses_legacy {
.map(|block| block.into_shards(self.total_shard_count())) calc_shard_size_legacy
} } else {
calc_shard_size
};
let per_shard_size = shard_size_fn(data.len(), self.data_shards);
if per_shard_size == 0 {
return Ok(vec![Bytes::new(); self.total_shard_count()]);
}
let need_total_size = per_shard_size * self.total_shard_count();
#[hotpath::measure(label = "Erasure::encode_data_owned", impl_type = "Erasure")]
pub(crate) fn encode_data_owned_block(&self, data: Vec<u8>) -> io::Result<EncodedBlock> {
self.encode_data_owned_block_inner(data)
}
fn encode_data_owned_block_inner(&self, data: Vec<u8>) -> io::Result<EncodedBlock> {
let data_len = data.len();
// Try zero-copy: Vec<u8> -> Bytes -> BytesMut (succeeds when refcount == 1) // Try zero-copy: Vec<u8> -> Bytes -> BytesMut (succeeds when refcount == 1)
let data_buffer = match Bytes::from(data).try_into_mut() { let mut data_buffer = match Bytes::from(data).try_into_mut() {
Ok(data_buffer) => data_buffer, Ok(mut bm) => {
bm.resize(need_total_size, 0u8);
bm
}
Err(b) => { Err(b) => {
// Rare path: refcount != 1, fall back to copy // Rare path: refcount != 1, fall back to copy
let mut data_buffer = BytesMut::with_capacity(self.encoded_capacity_for_data_len(data_len)); let mut bm = BytesMut::with_capacity(need_total_size);
data_buffer.extend_from_slice(&b); bm.extend_from_slice(&b);
data_buffer bm.resize(need_total_size, 0u8);
bm
} }
}; };
self.encode_buffer(data_buffer, data_len)
{
let data_slices: SmallVec<[&mut [u8]; 16]> = data_buffer.chunks_exact_mut(per_shard_size).collect();
if self.parity_shards > 0 {
if self.uses_legacy {
if let Some(encoder) = self.legacy_encoder.as_ref() {
encoder.encode(data_slices)?;
} else {
warn!("parity_shards > 0, uses_legacy but legacy_encoder is None");
}
} else if let Some(encoder) = self.encoder.as_ref() {
encoder.encode(data_slices)?;
} else {
warn!("parity_shards > 0, but encoder is None");
}
}
}
let mut data_buffer = data_buffer.freeze();
let mut shards = Vec::with_capacity(self.total_shard_count());
for _ in 0..self.total_shard_count() {
let shard = data_buffer.split_to(per_shard_size);
shards.push(shard);
}
Ok(shards)
} }
/// Encode data from an owned `BytesMut` buffer, avoiding the initial copy /// Encode data from an owned `BytesMut` buffer, avoiding the initial copy
@@ -835,17 +786,7 @@ impl Erasure {
/// `data_len <= block_size` — both shard-size formulas are monotone in /// `data_len <= block_size` — both shard-size formulas are monotone in
/// `data_len` — so this function never reallocates the buffer. /// `data_len` — so this function never reallocates the buffer.
#[hotpath::measure(impl_type = "Erasure")] #[hotpath::measure(impl_type = "Erasure")]
pub fn encode_data_bytes_mut(&self, data_buffer: BytesMut, data_len: usize) -> io::Result<Vec<Bytes>> { pub fn encode_data_bytes_mut(&self, mut data_buffer: BytesMut, data_len: usize) -> io::Result<Vec<Bytes>> {
self.encode_buffer(data_buffer, data_len)
.map(|block| block.into_shards(self.total_shard_count()))
}
#[hotpath::measure(label = "Erasure::encode_data_bytes_mut", impl_type = "Erasure")]
pub(crate) fn encode_data_bytes_mut_block(&self, data_buffer: BytesMut, data_len: usize) -> io::Result<EncodedBlock> {
self.encode_buffer(data_buffer, data_len)
}
fn encode_buffer(&self, mut data_buffer: BytesMut, data_len: usize) -> io::Result<EncodedBlock> {
let shard_size_fn = if self.uses_legacy { let shard_size_fn = if self.uses_legacy {
calc_shard_size_legacy calc_shard_size_legacy
} else { } else {
@@ -853,7 +794,7 @@ impl Erasure {
}; };
let per_shard_size = shard_size_fn(data_len, self.data_shards); let per_shard_size = shard_size_fn(data_len, self.data_shards);
if per_shard_size == 0 { if per_shard_size == 0 {
return Ok(EncodedBlock::empty()); return Ok(vec![Bytes::new(); self.total_shard_count()]);
} }
let need_total_size = per_shard_size * self.total_shard_count(); let need_total_size = per_shard_size * self.total_shard_count();
@@ -880,10 +821,14 @@ impl Erasure {
} }
} }
Ok(EncodedBlock { let mut data_buffer = data_buffer.freeze();
data: data_buffer.freeze(), let mut shards = Vec::with_capacity(self.total_shard_count());
shard_size: per_shard_size, for _ in 0..self.total_shard_count() {
}) let shard = data_buffer.split_to(per_shard_size);
shards.push(shard);
}
Ok(shards)
} }
/// Decode and reconstruct missing data shards in-place. /// Decode and reconstruct missing data shards in-place.
@@ -1023,15 +968,6 @@ impl Erasure {
self.data_shards + self.parity_shards self.data_shards + self.parity_shards
} }
pub(crate) fn encoded_capacity_for_data_len(&self, data_len: usize) -> usize {
let shard_size_fn = if self.uses_legacy {
calc_shard_size_legacy
} else {
calc_shard_size
};
shard_size_fn(data_len, self.data_shards).saturating_mul(self.total_shard_count())
}
/// Whether the erasure dimensions are safe for the shard/offset arithmetic. /// Whether the erasure dimensions are safe for the shard/offset arithmetic.
/// ///
/// `block_size` and `data_shards` come straight from on-disk metadata; a /// `block_size` and `data_shards` come straight from on-disk metadata; a
@@ -1110,10 +1046,6 @@ impl Erasure {
/// ///
/// # Errors /// # Errors
/// Returns error if reading from reader fails or if callback returns error /// Returns error if reading from reader fails or if callback returns error
#[allow(
dead_code,
reason = "callback encode path exercised only by this file's tests (backlog#1823)"
)]
pub(crate) async fn encode_stream_callback_async<F, Fut, E, R>( pub(crate) async fn encode_stream_callback_async<F, Fut, E, R>(
self: std::sync::Arc<Self>, self: std::sync::Arc<Self>,
reader: &mut R, reader: &mut R,
@@ -1476,7 +1408,7 @@ mod tests {
assert_eq!(cloned.block_size, legacy.block_size); assert_eq!(cloned.block_size, legacy.block_size);
assert!(cloned.uses_legacy); assert!(cloned.uses_legacy);
let data = b"legacy clone should preserve SIMD codec behavior"; let data = b"legacy clone should keep independent SIMD caches";
let encoded = cloned.encode_data(data).expect("legacy clone should encode"); let encoded = cloned.encode_data(data).expect("legacy clone should encode");
let mut shards = optional_shards(&encoded); let mut shards = optional_shards(&encoded);
shards[0] = None; shards[0] = None;
@@ -1484,93 +1416,6 @@ mod tests {
assert_eq!(recover_data(&shards, cloned.data_shards, data.len()), data); assert_eq!(recover_data(&shards, cloned.data_shards, data.len()), data);
} }
#[test]
fn legacy_codecs_share_process_cache_across_erasure_instances() {
let first = Erasure::new_with_options(6, 3, 64, true)
.legacy_encoder
.expect("legacy codec should be initialized");
let second = Erasure::new_with_options(6, 3, 128, true)
.legacy_encoder
.expect("same legacy shard layout should be initialized");
assert!(Arc::ptr_eq(&first, &second));
}
#[test]
fn legacy_workspace_cache_rejects_oversize_buffers_and_isolates_layouts() {
let four_plus_two = Erasure::new_with_options(4, 2, 64, true)
.legacy_encoder
.expect("legacy codec should be initialized");
let four_plus_one = Erasure::new_with_options(4, 1, 64, true)
.legacy_encoder
.expect("distinct parity layout should be initialized");
let three_plus_two = Erasure::new_with_options(3, 2, 64, true)
.legacy_encoder
.expect("distinct data layout should be initialized");
assert!(!Arc::ptr_eq(&four_plus_two, &four_plus_one));
assert!(!Arc::ptr_eq(&four_plus_two, &three_plus_two));
assert_eq!(four_plus_two.logical_shard_bytes_upper_bound(64 * 1024), Some(512 * 1024));
assert!(four_plus_two.should_cache_workspace(64 * 1024));
assert!(!four_plus_two.should_cache_workspace(64 * 1024 + 1));
let nine_plus_seven =
LegacyReedSolomonEncoder::with_workspace_cache(9, 7, true).expect("9+7 legacy codec should construct");
assert_eq!(nine_plus_seven.logical_shard_bytes_upper_bound(16 * 1024), Some(512 * 1024));
assert!(nine_plus_seven.should_cache_workspace(16 * 1024));
assert!(!nine_plus_seven.should_cache_workspace(16 * 1024 + 1));
let uncached = LegacyReedSolomonEncoder::new(4, 2).expect("uncached legacy codec should construct");
assert!(!uncached.should_cache_workspace(64));
}
#[test]
fn saturated_legacy_codec_cache_does_not_retain_more_workspaces() {
let cache = RwLock::new(HashMap::new());
for parity_shards in 1..=LEGACY_REED_SOLOMON_CACHE_MAX_ENTRIES {
let cached =
cached_legacy_reed_solomon_in(&cache, 32, parity_shards).expect("cacheable legacy codec should construct");
assert!(cached.cache_workspaces);
}
let uncached =
cached_legacy_reed_solomon_in(&cache, 31, 1).expect("uncached legacy codec should construct after saturation");
assert!(!uncached.cache_workspaces);
assert_eq!(
cache.read().expect("cache lock should remain healthy").len(),
LEGACY_REED_SOLOMON_CACHE_MAX_ENTRIES
);
}
#[test]
fn concurrent_legacy_codecs_preserve_byte_exact_results() {
let barrier = Arc::new(std::sync::Barrier::new(2));
let payloads = [vec![0x35; 257], vec![0xca; 1025]];
std::thread::scope(|scope| {
let handles = payloads.each_ref().map(|payload| {
let barrier = Arc::clone(&barrier);
scope.spawn(move || {
let erasure = Erasure::new_with_options(6, 3, 2048, true);
barrier.wait();
let encoded = erasure.encode_data(payload).expect("concurrent legacy encode should succeed");
barrier.wait();
let mut shards = optional_shards(&encoded);
shards[0] = None;
erasure
.decode_data(&mut shards)
.expect("concurrent legacy decode should reconstruct the missing shard");
recover_data(&shards, erasure.data_shards, payload.len())
})
});
for (handle, payload) in handles.into_iter().zip(payloads.iter()) {
assert_eq!(handle.join().expect("concurrent legacy codec worker should not panic"), *payload);
}
});
}
#[test] #[test]
fn legacy_verify_reports_invalid_empty_valid_and_corrupt_parity_sets() { fn legacy_verify_reports_invalid_empty_valid_and_corrupt_parity_sets() {
let legacy = LegacyReedSolomonEncoder::new(2, 2).expect("legacy encoder should construct"); let legacy = LegacyReedSolomonEncoder::new(2, 2).expect("legacy encoder should construct");
@@ -1644,16 +1489,10 @@ mod tests {
fn encode_data_owned_matches_borrowed_path() { fn encode_data_owned_matches_borrowed_path() {
for uses_legacy in [false, true] { for uses_legacy in [false, true] {
let erasure = Erasure::new_with_options(4, 2, 64, uses_legacy); let erasure = Erasure::new_with_options(4, 2, 64, uses_legacy);
for data in [
Vec::new(), assert_owned_encode_matches_borrowed(&erasure, Vec::new());
vec![0xA5; 1], assert_owned_encode_matches_borrowed(&erasure, b"small payload".to_vec());
b"small payload".to_vec(), assert_owned_encode_matches_borrowed(&erasure, (0_u8..37).collect());
(0_u8..37).collect(),
vec![0xA5; erasure.block_size - 1],
vec![0x5A; erasure.block_size],
] {
assert_owned_encode_matches_borrowed(&erasure, data);
}
} }
} }
@@ -1699,52 +1538,6 @@ mod tests {
} }
} }
#[test]
fn streaming_encoded_block_uses_one_contiguous_backing_buffer() {
for uses_legacy in [false, true] {
let erasure = Erasure::new_with_options(8, 8, 64, uses_legacy);
for data_len in [0, 1, 63, 64] {
let data = (0..data_len).map(|i| i as u8).collect::<Vec<_>>();
let expected = erasure.encode_data(&data).expect("public encode should succeed");
let borrowed = erasure
.encode_data_block(&data)
.expect("borrowed streaming encode should succeed");
let owned = erasure
.encode_data_owned_block(data.clone())
.expect("owned streaming encode should succeed");
let bytes_mut = erasure
.encode_data_bytes_mut_block(BytesMut::from(&data[..]), data.len())
.expect("BytesMut streaming encode should succeed");
assert_eq!(borrowed.queued_bytes(), owned.queued_bytes());
assert_eq!(borrowed.queued_bytes(), bytes_mut.queued_bytes());
if data_len == 0 {
assert!(expected.iter().all(Bytes::is_empty));
assert!(borrowed.is_empty());
assert!(owned.is_empty());
assert!(bytes_mut.is_empty());
continue;
}
assert!(borrowed.shards().eq(expected.iter().map(Bytes::as_ref)));
assert!(owned.shards().eq(expected.iter().map(Bytes::as_ref)));
assert!(bytes_mut.shards().eq(expected.iter().map(Bytes::as_ref)));
assert_eq!(borrowed.shards().len(), 16);
let first = borrowed.shards().next().expect("encoded block should have shards").as_ptr();
for (index, shard) in borrowed.shards().enumerate() {
assert_eq!(shard.as_ptr(), first.wrapping_add(index * shard.len()));
}
}
}
assert_eq!(
std::mem::size_of::<EncodedBlock>(),
std::mem::size_of::<Bytes>() + std::mem::size_of::<usize>(),
"queue entries must contain one backing buffer handle, not per-shard handles"
);
}
/// HP-10 capacity invariant: both shard-size formulas are monotone in `data_len`, /// HP-10 capacity invariant: both shard-size formulas are monotone in `data_len`,
/// so pre-reserving `shard_size(block_size) * total_shard_count` covers the /// so pre-reserving `shard_size(block_size) * total_shard_count` covers the
/// `need_total_size` of every block-or-smaller payload and the ingest buffer /// `need_total_size` of every block-or-smaller payload and the ingest buffer
+1
View File
@@ -13,6 +13,7 @@
// limitations under the License. // limitations under the License.
// #730: erasure codec migration keeps staged streaming decode paths in this module. // #730: erasure codec migration keeps staged streaming decode paths in this module.
#![allow(dead_code)]
pub(crate) mod codec; pub(crate) mod codec;
pub(crate) mod coding; pub(crate) mod coding;
+111 -70
View File
@@ -13,12 +13,13 @@
// limitations under the License. // limitations under the License.
// #730: error taxonomy still exposes compatibility variants while callers move to contracts. // #730: error taxonomy still exposes compatibility variants while callers move to contracts.
#![allow(dead_code)]
use crate::bucket::error::BucketMetadataError; use crate::bucket::error::BucketMetadataError;
use crate::disk::error::DiskError; use crate::disk::error::DiskError;
use crate::storage_api_contracts::{error::StorageErrorCode, range::HTTPRangeError}; use crate::storage_api_contracts::{error::StorageErrorCode, range::HTTPRangeError};
use rustfs_utils::path::decode_dir_object; use rustfs_utils::path::decode_dir_object;
use s3s::S3ErrorCode; use s3s::{S3Error, S3ErrorCode};
pub type Error = StorageError; pub type Error = StorageError;
pub type Result<T> = core::result::Result<T, Error>; pub type Result<T> = core::result::Result<T, Error>;
@@ -203,8 +204,6 @@ pub enum StorageError {
required: usize, required: usize,
achieved: usize, achieved: usize,
}, },
#[error("Bucket quota exceeded. Current usage: {current} bytes, limit: {limit} bytes")]
QuotaExceeded { current: u64, limit: u64 },
// ── Generic ────────────────────────────────────────────────────── // ── Generic ──────────────────────────────────────────────────────
#[error("Unexpected error")] #[error("Unexpected error")]
@@ -357,13 +356,6 @@ impl From<StorageError> for DiskError {
StorageError::VolumeNotFound => DiskError::VolumeNotFound, StorageError::VolumeNotFound => DiskError::VolumeNotFound,
StorageError::VolumeExists => DiskError::VolumeExists, StorageError::VolumeExists => DiskError::VolumeExists,
StorageError::FileNameTooLong => DiskError::FileNameTooLong, StorageError::FileNameTooLong => DiskError::FileNameTooLong,
StorageError::FaultyRemoteDisk => DiskError::FaultyRemoteDisk,
StorageError::DiskAccessDenied => DiskError::DiskAccessDenied,
StorageError::DriveIsRoot => DiskError::DriveIsRoot,
StorageError::IsNotRegular => DiskError::IsNotRegular,
StorageError::VolumeNotEmpty => DiskError::VolumeNotEmpty,
StorageError::VolumeAccessDenied => DiskError::VolumeAccessDenied,
StorageError::FileAccessDenied => DiskError::FileAccessDenied,
_ => DiskError::other(val), _ => DiskError::other(val),
} }
} }
@@ -548,10 +540,6 @@ impl Clone for StorageError {
required: *required, required: *required,
achieved: *achieved, achieved: *achieved,
}, },
StorageError::QuotaExceeded { current, limit } => StorageError::QuotaExceeded {
current: *current,
limit: *limit,
},
} }
} }
} }
@@ -639,7 +627,6 @@ impl StorageError {
StorageError::NotModified => StorageErrorCode::NotModified, StorageError::NotModified => StorageErrorCode::NotModified,
StorageError::InvalidPartNumber(_) => StorageErrorCode::InvalidPartNumber, StorageError::InvalidPartNumber(_) => StorageErrorCode::InvalidPartNumber,
StorageError::NamespaceLockQuorumUnavailable { .. } => StorageErrorCode::NamespaceLockQuorumUnavailable, StorageError::NamespaceLockQuorumUnavailable { .. } => StorageErrorCode::NamespaceLockQuorumUnavailable,
StorageError::QuotaExceeded { .. } => StorageErrorCode::QuotaExceeded,
} }
} }
@@ -765,10 +752,6 @@ impl StorageError {
required: Default::default(), required: Default::default(),
achieved: Default::default(), achieved: Default::default(),
}), }),
StorageErrorCode::QuotaExceeded => Some(StorageError::QuotaExceeded {
current: Default::default(),
limit: Default::default(),
}),
} }
} }
} }
@@ -901,7 +884,6 @@ pub fn is_err_decommission_running(err: &Error) -> bool {
matches!(err, &StorageError::DecommissionAlreadyRunning) matches!(err, &StorageError::DecommissionAlreadyRunning)
} }
#[allow(dead_code, reason = "predicate asserted by this file's tests (backlog#1823)")]
pub fn is_err_rebalance_running(err: &Error) -> bool { pub fn is_err_rebalance_running(err: &Error) -> bool {
matches!(err, &StorageError::RebalanceAlreadyRunning) matches!(err, &StorageError::RebalanceAlreadyRunning)
} }
@@ -910,11 +892,14 @@ pub fn is_err_operation_canceled(err: &Error) -> bool {
matches!(err, &StorageError::OperationCanceled) matches!(err, &StorageError::OperationCanceled)
} }
#[allow(dead_code, reason = "predicate asserted by this file's tests (backlog#1823)")]
pub fn is_err_not_initialized(err: &Error) -> bool { pub fn is_err_not_initialized(err: &Error) -> bool {
err.to_string().contains("errServerNotInitialized") || err.to_string().contains("ServerNotInitialized") err.to_string().contains("errServerNotInitialized") || err.to_string().contains("ServerNotInitialized")
} }
pub fn is_err_io(err: &Error) -> bool {
matches!(err, &StorageError::Io(_))
}
/// Strict "not found" predicate that only matches genuine object/version/volume /// Strict "not found" predicate that only matches genuine object/version/volume
/// absence errors: `FileNotFound`/`VolumeNotFound`/`FileVersionNotFound`/ /// absence errors: `FileNotFound`/`VolumeNotFound`/`FileVersionNotFound`/
/// `ObjectNotFound`/`VersionNotFound`. /// `ObjectNotFound`/`VersionNotFound`.
@@ -1075,6 +1060,21 @@ pub struct GenericError {
#[derive(Debug, thiserror::Error, PartialEq, Eq)] #[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum ObjectApiError { pub enum ObjectApiError {
#[error("Operation timed out")]
OperationTimedOut,
#[error("etag of the object has changed")]
InvalidETag,
#[error("BackendDown")]
BackendDown(String),
#[error("Unsupported headers in Metadata")]
UnsupportedMetadata,
#[error("Method not allowed: {}/{}", .0.bucket, .0.object)]
MethodNotAllowed(GenericError),
#[error("The operation is not valid for the current state of the object {}/{}({})", .0.bucket, .0.object, .0.version_id)] #[error("The operation is not valid for the current state of the object {}/{}({})", .0.bucket, .0.object, .0.version_id)]
InvalidObjectState(GenericError), InvalidObjectState(GenericError),
} }
@@ -1091,6 +1091,96 @@ pub struct ErrorResponse {
pub host_id: String, pub host_id: String,
} }
pub fn error_resp_to_object_err(err: ErrorResponse, params: Vec<&str>) -> std::io::Error {
let mut bucket = "";
let mut object = "";
let mut version_id = "";
if !params.is_empty() {
bucket = params[0];
}
if params.len() >= 2 {
object = params[1];
}
if params.len() >= 3 {
version_id = params[2];
}
if is_network_or_host_down(&err.to_string(), false) {
return std::io::Error::other(ObjectApiError::BackendDown(format!("{err}")));
}
let err_ = std::io::Error::other(err.to_string());
let r_err = err;
let err;
let bucket = bucket.to_string();
let object = object.to_string();
let version_id = version_id.to_string();
match r_err.code {
S3ErrorCode::BucketNotEmpty => {
err = std::io::Error::other(StorageError::BucketNotEmpty("".to_string()).to_string());
}
S3ErrorCode::InvalidBucketName => {
err = std::io::Error::other(StorageError::BucketNameInvalid(bucket));
}
S3ErrorCode::InvalidPart => {
err = std::io::Error::other(StorageError::InvalidPart(0, bucket, object /* , version_id */));
}
S3ErrorCode::NoSuchBucket => {
err = std::io::Error::other(StorageError::BucketNotFound(bucket));
}
S3ErrorCode::NoSuchKey => {
if !object.is_empty() {
err = std::io::Error::other(StorageError::ObjectNotFound(bucket, object));
} else {
err = std::io::Error::other(StorageError::BucketNotFound(bucket));
}
}
S3ErrorCode::NoSuchVersion => {
if !object.is_empty() {
err = std::io::Error::other(StorageError::ObjectNotFound(bucket, object)); //, version_id);
} else {
err = std::io::Error::other(StorageError::BucketNotFound(bucket));
}
}
S3ErrorCode::AccessDenied => {
err = std::io::Error::other(StorageError::PrefixAccessDenied(bucket, object));
}
S3ErrorCode::NoSuchUpload => {
err = std::io::Error::other(StorageError::InvalidUploadID(bucket, object, version_id));
}
_ => {
err = err_;
}
}
err
}
pub fn storage_to_object_err(err: Error, params: Vec<&str>) -> S3Error {
let storage_err = &err;
let mut bucket: String = "".to_string();
let mut object: String = "".to_string();
if !params.is_empty() {
bucket = params[0].to_string();
}
if params.len() >= 2 {
object = decode_dir_object(params[1]);
}
match storage_err {
StorageError::MethodNotAllowed => S3Error::with_message(
S3ErrorCode::MethodNotAllowed,
ObjectApiError::MethodNotAllowed(GenericError {
bucket,
object,
..Default::default()
})
.to_string(),
),
_ => s3s::S3Error::with_message(S3ErrorCode::Custom("err".into()), err.to_string()),
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -1211,7 +1301,6 @@ mod tests {
.to_u32(), .to_u32(),
0x42 0x42
); );
assert_eq!(StorageError::QuotaExceeded { current: 1, limit: 2 }.to_u32(), 0x53);
} }
#[test] #[test]
@@ -1230,10 +1319,6 @@ mod tests {
StorageError::from_u32(0x42), StorageError::from_u32(0x42),
Some(StorageError::NamespaceLockQuorumUnavailable { .. }) Some(StorageError::NamespaceLockQuorumUnavailable { .. })
)); ));
assert!(matches!(
StorageError::from_u32(0x53),
Some(StorageError::QuotaExceeded { current: 0, limit: 0 })
));
// Test invalid code returns None // Test invalid code returns None
assert!(StorageError::from_u32(0xFF).is_none()); assert!(StorageError::from_u32(0xFF).is_none());
@@ -1391,49 +1476,6 @@ mod tests {
} }
} }
// Every DiskError variant must survive DiskError -> StorageError -> DiskError
// unchanged. A variant that degrades to `DiskError::Io` on the way back loses
// its identity for quorum aggregation (`reduce_errs` classifies by variant
// equality), so ignore-list entries such as FaultyRemoteDisk and
// DiskAccessDenied would silently stop matching.
#[test]
fn test_disk_error_storage_error_round_trip_identity_all_variants() {
// DiskError codes are contiguous from 0x01, so enumerating via from_u32
// covers every variant and picks up newly appended ones automatically.
let all_variants: Vec<DiskError> = (1u32..).map_while(DiskError::from_u32).collect();
assert!(
all_variants.len() >= 42,
"DiskError variant enumeration shrank: got {}, expected at least 42",
all_variants.len()
);
for original in all_variants {
let storage_error: StorageError = original.clone().into();
let round_tripped: DiskError = storage_error.into();
assert_eq!(
std::mem::discriminant(&original),
std::mem::discriminant(&round_tripped),
"round trip changed variant: {original:?} -> {round_tripped:?}"
);
assert_eq!(original, round_tripped, "round trip not identical for {original:?}");
}
// Io is the only payload-carrying variant: a representative kind and
// message must both survive the round trip.
let io_original = DiskError::Io(IoError::new(ErrorKind::PermissionDenied, "denied"));
let storage_error: StorageError = io_original.clone().into();
let io_round_tripped: DiskError = storage_error.into();
assert_eq!(io_original, io_round_tripped);
match io_round_tripped {
DiskError::Io(inner) => {
assert_eq!(inner.kind(), ErrorKind::PermissionDenied);
assert_eq!(inner.to_string(), "denied");
}
other => panic!("expected DiskError::Io, got {other:?}"),
}
}
#[test] #[test]
fn test_storage_error_from_io_error() { fn test_storage_error_from_io_error() {
// Test direct IO error conversion // Test direct IO error conversion
@@ -1507,7 +1549,6 @@ mod tests {
StorageError::DecommissionAlreadyRunning, StorageError::DecommissionAlreadyRunning,
StorageError::RebalanceAlreadyRunning, StorageError::RebalanceAlreadyRunning,
StorageError::OperationCanceled, StorageError::OperationCanceled,
StorageError::QuotaExceeded { current: 1, limit: 2 },
]; ];
for original_error in test_errors { for original_error in test_errors {
+2
View File
@@ -13,6 +13,8 @@
// limitations under the License. // limitations under the License.
// #730: event target types are retained for notification owner migration. // #730: event target types are retained for notification owner migration.
#![allow(dead_code)]
pub mod name; pub mod name;
pub mod targetid;
pub mod targetlist; pub mod targetlist;
@@ -1,3 +1,4 @@
#![allow(clippy::all)]
// Copyright 2024 RustFS Team // Copyright 2024 RustFS Team
// //
// Licensed under the Apache License, Version 2.0 (the "License"); // Licensed under the Apache License, Version 2.0 (the "License");
@@ -12,6 +13,13 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
/// Cross-crate lock identity used to fence table-bucket publication against pub struct TargetID {
/// object mutations that bypass the S3 request authorization layer. id: String,
pub const TABLE_BUCKET_PUBLICATION_LOCK_PATH: &str = ".rustfs-table/warehouses/default/publication.lock"; name: String,
}
impl TargetID {
fn to_string(&self) -> String {
format!("{}:{}", self.id, self.name)
}
}
+18 -9
View File
@@ -12,20 +12,18 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
use crate::event::targetid::TargetID;
use std::sync::atomic::AtomicI64; use std::sync::atomic::AtomicI64;
/// Placeholder notification target list held by `EventNotifier`.
///
/// The working notification stack lives in `rustfs-notify` / `rustfs-targets`;
/// this type never grew past its counter. `total_events` is read by the
/// notifier's log line but nothing increments it, so that field reports zero.
#[derive(Default)] #[derive(Default)]
#[allow(
dead_code,
reason = "held only by the dead ecstore EventNotifier; see services/event_notification.rs (backlog#1823)"
)]
pub struct TargetList { pub struct TargetList {
pub current_send_calls: AtomicI64,
pub total_events: AtomicI64, pub total_events: AtomicI64,
pub events_skipped: AtomicI64,
pub events_errors_total: AtomicI64,
//pub targets: HashMap<TargetID, Target>,
//pub queue: AsyncEvent,
//pub targetStats: HashMap<TargetID, TargetStat>,
} }
impl TargetList { impl TargetList {
@@ -33,3 +31,14 @@ impl TargetList {
TargetList::default() TargetList::default()
} }
} }
struct TargetStat {
current_send_calls: i64,
total_events: i64,
failed_events: i64,
}
struct TargetIDResult {
id: TargetID,
err: std::io::Error,
}
+34 -175
View File
@@ -22,13 +22,12 @@ use crate::diagnostics::get::{
#[cfg(feature = "hotpath")] #[cfg(feature = "hotpath")]
use crate::disk::FileWriter; use crate::disk::FileWriter;
use crate::disk::{self, DiskAPI as _, DiskStore, FileReader, MmapCopyStageMetrics, error::DiskError}; use crate::disk::{self, DiskAPI as _, DiskStore, FileReader, MmapCopyStageMetrics, error::DiskError};
use crate::erasure::coding::{BitrotReader, BitrotWriterWrapper, CustomWriter, ShardChunkRead}; use crate::erasure::coding::{BitrotReader, BitrotWriterWrapper, CustomWriter};
use bytes::Bytes; use bytes::Bytes;
use rustfs_config::{ use rustfs_config::{
DEFAULT_OBJECT_MMAP_READ_ENABLE, DEFAULT_OBJECT_MMAP_READ_MAX_LENGTH, ENV_OBJECT_MMAP_READ_ENABLE, DEFAULT_OBJECT_MMAP_READ_ENABLE, DEFAULT_OBJECT_MMAP_READ_MAX_LENGTH, ENV_OBJECT_MMAP_READ_ENABLE,
ENV_OBJECT_MMAP_READ_MAX_LENGTH, ENV_OBJECT_ZERO_COPY_ENABLE, ENV_OBJECT_MMAP_READ_MAX_LENGTH, ENV_OBJECT_ZERO_COPY_ENABLE,
}; };
use rustfs_rio::ChunkReaderBox;
use rustfs_utils::HashAlgorithm; use rustfs_utils::HashAlgorithm;
use std::future::Future; use std::future::Future;
use std::io::{self, Cursor}; use std::io::{self, Cursor};
@@ -52,25 +51,13 @@ tokio::task_local! {
/// (rustfs/backlog#1159). Everything else is a stream and keeps the old path. /// (rustfs/backlog#1159). Everything else is a stream and keeps the old path.
pub enum ShardReader { pub enum ShardReader {
InMemory(Cursor<Bytes>), InMemory(Cursor<Bytes>),
Chunked(ChunkReaderBox),
Stream(Box<dyn AsyncRead + Send + Sync + Unpin>), Stream(Box<dyn AsyncRead + Send + Sync + Unpin>),
} }
#[cfg(test)]
impl ShardReader {
pub(crate) fn inline_bytes(&self) -> Option<&Bytes> {
match self {
Self::InMemory(cursor) => Some(cursor.get_ref()),
Self::Chunked(_) | Self::Stream(_) => None,
}
}
}
impl AsyncRead for ShardReader { impl AsyncRead for ShardReader {
fn poll_read(self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut tokio::io::ReadBuf<'_>) -> Poll<std::io::Result<()>> { fn poll_read(self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut tokio::io::ReadBuf<'_>) -> Poll<std::io::Result<()>> {
match self.get_mut() { match self.get_mut() {
Self::InMemory(cursor) => Pin::new(cursor).poll_read(cx, buf), Self::InMemory(cursor) => Pin::new(cursor).poll_read(cx, buf),
Self::Chunked(reader) => Pin::new(&mut **reader).poll_read(cx, buf),
Self::Stream(reader) => Pin::new(reader).poll_read(cx, buf), Self::Stream(reader) => Pin::new(reader).poll_read(cx, buf),
} }
} }
@@ -80,19 +67,7 @@ impl crate::erasure::coding::ShardSource for ShardReader {
fn try_take_block(&mut self, n: usize) -> Option<Bytes> { fn try_take_block(&mut self, n: usize) -> Option<Bytes> {
match self { match self {
Self::InMemory(cursor) => cursor.try_take_block(n), Self::InMemory(cursor) => cursor.try_take_block(n),
Self::Chunked(_) | Self::Stream(_) => None, Self::Stream(_) => None,
}
}
fn poll_read_chunk(self: Pin<&mut Self>, cx: &mut Context<'_>, max: usize) -> Poll<io::Result<ShardChunkRead>> {
let Self::Chunked(reader) = self.get_mut() else {
return Poll::Ready(Ok(ShardChunkRead::Unsupported));
};
match Pin::new(&mut **reader).poll_read_chunk(cx, max) {
Poll::Ready(Ok(Some(chunk))) => Poll::Ready(Ok(ShardChunkRead::Chunk(chunk))),
Poll::Ready(Ok(None)) => Poll::Ready(Ok(ShardChunkRead::Eof)),
Poll::Ready(Err(err)) => Poll::Ready(Err(err)),
Poll::Pending => Poll::Pending,
} }
} }
} }
@@ -145,41 +120,26 @@ struct BitrotReaderSource {
impl BitrotReaderSource { impl BitrotReaderSource {
async fn open(self) -> disk::error::Result<Option<BoxedObjectReader>> { async fn open(self) -> disk::error::Result<Option<BoxedObjectReader>> {
open_reader_source( if let Some(data) = self.inline_data {
self.inline_data, let mut rd = Cursor::new(data);
self.disk.as_ref(), let offset = u64::try_from(self.offset).map_err(|_| DiskError::FileCorrupt)?;
&self.bucket, rd.set_position(offset);
&self.path, Ok(Some(ShardReader::InMemory(rd)))
self.offset, } else if let Some(disk) = self.disk {
self.length, open_disk_reader(
self.use_mmap_read, &disk,
self.stage_metrics.map(|metrics| metrics.path), &self.bucket,
) &self.path,
.await self.offset,
} self.length,
} self.use_mmap_read,
self.stage_metrics.map(|metrics| metrics.path),
#[allow(clippy::too_many_arguments)] )
async fn open_reader_source(
inline_data: Option<Bytes>,
disk: Option<&DiskStore>,
bucket: &str,
path: &str,
offset: usize,
length: usize,
use_mmap_read: bool,
metrics_path: Option<&'static str>,
) -> disk::error::Result<Option<BoxedObjectReader>> {
if let Some(data) = inline_data {
let mut reader = Cursor::new(data);
reader.set_position(u64::try_from(offset).map_err(|_| DiskError::FileCorrupt)?);
Ok(Some(ShardReader::InMemory(reader)))
} else if let Some(disk) = disk {
open_disk_reader(disk, bucket, path, offset, length, use_mmap_read, metrics_path)
.await .await
.map(Some) .map(Some)
} else { } else {
Ok(None) Ok(None)
}
} }
} }
@@ -370,17 +330,6 @@ async fn open_disk_reader(
let metrics_path = metrics_path.filter(|_| rustfs_io_metrics::get_stage_metrics_enabled()); let metrics_path = metrics_path.filter(|_| rustfs_io_metrics::get_stage_metrics_enabled());
let stage_metrics_enabled = metrics_path.is_some(); let stage_metrics_enabled = metrics_path.is_some();
// Preserve HTTP body ownership only on healthy remote reads. Instrumented
// and local paths retain their existing AsyncRead wrappers.
if use_mmap_read
&& !disk.is_local()
&& !stage_metrics_enabled
&& !cfg!(feature = "hotpath")
&& let Some(reader) = disk.read_file_stream_chunks(bucket, path, offset, length).await?
{
return Ok(ShardReader::Chunked(reader));
}
// Mmap-copy materializes the whole `offset..offset+length` range as one // Mmap-copy materializes the whole `offset..offset+length` range as one
// owned allocation before any byte is served, and GET/heal shard reads // owned allocation before any byte is served, and GET/heal shard reads
// request the entire part span in one call. Over-cap reads (e.g. a huge // request the entire part span in one call. Over-cap reads (e.g. a huge
@@ -656,7 +605,7 @@ pub async fn create_bitrot_reader_from_bytes(
} }
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
pub(crate) async fn create_bitrot_reader_from_bytes_with_stage_metrics( async fn create_bitrot_reader_from_bytes_with_stage_metrics(
inline_data: Option<Bytes>, inline_data: Option<Bytes>,
disk: Option<&DiskStore>, disk: Option<&DiskStore>,
bucket: &str, bucket: &str,
@@ -674,22 +623,22 @@ pub(crate) async fn create_bitrot_reader_from_bytes_with_stage_metrics(
let reader_construction_start = stage_metrics_enabled.then(Instant::now); let reader_construction_start = stage_metrics_enabled.then(Instant::now);
let (offset, length) = bitrot_encoded_range(offset, length, shard_size, checksum_algo.clone()); let (offset, length) = bitrot_encoded_range(offset, length, shard_size, checksum_algo.clone());
let source = BitrotReaderSource {
inline_data,
disk: disk.cloned(),
bucket: bucket.to_string(),
path: path.to_string(),
offset,
length,
use_mmap_read,
stage_metrics,
};
if let Some(metrics) = stage_metrics { if let Some(metrics) = stage_metrics {
record_get_stage_duration_if_enabled(metrics.path, metrics.reader_construction_stage, reader_construction_start); record_get_stage_duration_if_enabled(metrics.path, metrics.reader_construction_stage, reader_construction_start);
} }
let file_open_start = stage_metrics_enabled.then(Instant::now); let file_open_start = stage_metrics_enabled.then(Instant::now);
let reader = open_reader_source( let reader = source.open().await?;
inline_data,
disk,
bucket,
path,
offset,
length,
use_mmap_read,
stage_metrics.map(|metrics| metrics.path),
)
.await?;
if let Some(metrics) = stage_metrics { if let Some(metrics) = stage_metrics {
record_get_stage_duration_if_enabled(metrics.path, metrics.file_open_stage, file_open_start); record_get_stage_duration_if_enabled(metrics.path, metrics.file_open_stage, file_open_start);
} }
@@ -749,12 +698,11 @@ pub(crate) fn create_deferred_bitrot_reader_with_stripe_handle(
) -> (BitrotReader<ShardReader>, DeferredReaderStripeHandle) { ) -> (BitrotReader<ShardReader>, DeferredReaderStripeHandle) {
let stripe_stride = shard_size + checksum_algo.size(); let stripe_stride = shard_size + checksum_algo.size();
let (offset, length) = bitrot_encoded_range(offset, length, shard_size, checksum_algo.clone()); let (offset, length) = bitrot_encoded_range(offset, length, shard_size, checksum_algo.clone());
let inline_source = inline_data.is_some();
let source = BitrotReaderSource { let source = BitrotReaderSource {
inline_data, inline_data,
disk, disk,
bucket: if inline_source { String::new() } else { bucket.to_string() }, bucket: bucket.to_string(),
path: if inline_source { String::new() } else { path.to_string() }, path: path.to_string(),
offset, offset,
length, length,
use_mmap_read, use_mmap_read,
@@ -816,50 +764,6 @@ pub async fn create_bitrot_writer(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use rustfs_rio::ChunkReader;
use std::collections::VecDeque;
struct TestChunkReader {
chunks: VecDeque<Bytes>,
}
impl TestChunkReader {
fn new(bytes: Bytes, fragment_sizes: &[usize]) -> Self {
let mut chunks = VecDeque::new();
let mut offset = 0;
for &size in fragment_sizes {
let end = (offset + size).min(bytes.len());
if offset < end {
chunks.push_back(bytes.slice(offset..end));
}
offset = end;
}
if offset < bytes.len() {
chunks.push_back(bytes.slice(offset..));
}
Self { chunks }
}
}
impl AsyncRead for TestChunkReader {
fn poll_read(self: Pin<&mut Self>, _cx: &mut Context<'_>, _buf: &mut ReadBuf<'_>) -> Poll<io::Result<()>> {
Poll::Ready(Err(io::Error::other("test chunk reader must use chunk handoff")))
}
}
impl ChunkReader for TestChunkReader {
fn poll_read_chunk(mut self: Pin<&mut Self>, _cx: &mut Context<'_>, max: usize) -> Poll<io::Result<Option<Bytes>>> {
let Some(mut chunk) = self.chunks.pop_front() else {
return Poll::Ready(Ok(None));
};
let take = chunk.len().min(max);
if take < chunk.len() {
self.chunks.push_front(chunk.split_off(take));
}
chunk.truncate(take);
Poll::Ready(Ok(Some(chunk)))
}
}
#[cfg(feature = "hotpath")] #[cfg(feature = "hotpath")]
use crate::cluster::rpc::RemoteDisk; use crate::cluster::rpc::RemoteDisk;
@@ -1749,49 +1653,4 @@ mod tests {
println!("error: {error:?}"); println!("error: {error:?}");
assert_eq!(error, DiskError::DiskNotFound); assert_eq!(error, DiskError::DiskNotFound);
} }
#[tokio::test]
async fn shard_reader_chunked_path_verifies_fragmented_remote_block() {
const SHARD_SIZE: usize = 1024;
let algo = HashAlgorithm::HighwayHash256S;
let data = vec![42u8; SHARD_SIZE];
let mut encoded = Vec::new();
crate::erasure::coding::BitrotWriter::new(&mut encoded, SHARD_SIZE, algo.clone())
.write(&data)
.await
.expect("test shard should encode");
let source = TestChunkReader::new(Bytes::from(encoded), &[3, 7, 17, 31]);
let mut reader = BitrotReader::new(ShardReader::Chunked(Box::new(source)), SHARD_SIZE, algo, false);
let mut output = Vec::with_capacity(SHARD_SIZE);
reader
.read_appending(&mut output, SHARD_SIZE)
.await
.expect("fragmented remote shard should verify");
assert_eq!(output, data);
}
#[tokio::test]
async fn shard_reader_chunked_path_handles_more_than_one_poll_budget() {
const SHARD_SIZE: usize = 1024;
let algo = HashAlgorithm::HighwayHash256S;
let data = vec![42u8; SHARD_SIZE];
let mut encoded = Vec::new();
crate::erasure::coding::BitrotWriter::new(&mut encoded, SHARD_SIZE, algo.clone())
.write(&data)
.await
.expect("test shard should encode");
let fragment_sizes = vec![1; encoded.len()];
let source = TestChunkReader::new(Bytes::from(encoded), &fragment_sizes);
let mut reader = BitrotReader::new(ShardReader::Chunked(Box::new(source)), SHARD_SIZE, algo, false);
let mut output = Vec::with_capacity(SHARD_SIZE);
reader
.read_appending(&mut output, SHARD_SIZE)
.await
.expect("fragmented remote shard should verify after multiple polls");
assert_eq!(output, data);
}
} }
-22
View File
@@ -31,13 +31,6 @@ pub const ENV_DISK_COMPRESSION_MIME_TYPES: &str = "RUSTFS_COMPRESSION_MIME_TYPES
// Environment variable for additional extensions to exclude from compression (comma-separated, e.g. ".foo,.bar") // Environment variable for additional extensions to exclude from compression (comma-separated, e.g. ".foo,.bar")
pub const ENV_ADDED_EXCLUDE_COMPRESS_EXTENSIONS: &str = "RUSTFS_ADDED_EXCLUDE_COMPRESS_EXTENSIONS"; pub const ENV_ADDED_EXCLUDE_COMPRESS_EXTENSIONS: &str = "RUSTFS_ADDED_EXCLUDE_COMPRESS_EXTENSIONS";
// Environment variable to additionally enable disk compression for multipart uploads.
// Default off: nodes from before the resumable decompressor fix fail transient reads of
// compressed objects, so multipart compression stays dark until the operator confirms the
// fleet has converged on a fixed build.
// RUSTFS_COMPAT_TODO(multipart-compression-default-off-window): staged rollout switch for restored multipart compression, flipping the default to enabled on retirement. Remove after the minimum supported direct-upgrade release ships the resumable DecompressReader.
pub const ENV_DISK_COMPRESSION_MULTIPART_ENABLED: &str = "RUSTFS_COMPRESSION_MULTIPART_ENABLED";
pub const DEFAULT_DISK_COMPRESS_EXTENSIONS: &str = ".txt,.log,.csv,.json,.tar,.xml,.bin"; pub const DEFAULT_DISK_COMPRESS_EXTENSIONS: &str = ".txt,.log,.csv,.json,.tar,.xml,.bin";
pub const DEFAULT_DISK_COMPRESS_MIME_TYPES: &str = "text/*,application/json,application/xml,binary/octet-stream"; pub const DEFAULT_DISK_COMPRESS_MIME_TYPES: &str = "text/*,application/json,application/xml,binary/octet-stream";
@@ -178,21 +171,6 @@ pub fn is_disk_compression_enabled() -> bool {
DISK_COMPRESSION_CONFIG.get_or_init(parse_disk_compression_config).enabled DISK_COMPRESSION_CONFIG.get_or_init(parse_disk_compression_config).enabled
} }
// Parsed once at first use, mirroring DISK_COMPRESSION_CONFIG.
static MULTIPART_DISK_COMPRESSION_ENABLED: OnceLock<bool> = OnceLock::new();
/// Whether multipart uploads may advertise disk compression. Requires the
/// regular disk-compression gates to pass as well; this is the staged-rollout
/// switch that keeps multipart compression dark during rolling upgrades from
/// builds whose decompressor was not yet resumable.
pub fn is_multipart_disk_compression_enabled() -> bool {
*MULTIPART_DISK_COMPRESSION_ENABLED.get_or_init(|| {
env::var(ENV_DISK_COMPRESSION_MULTIPART_ENABLED)
.map(|s| matches!(s.to_ascii_lowercase().as_str(), "true" | "on" | "1"))
.unwrap_or(false)
})
}
fn is_disk_compressible_with_config(headers: &http::HeaderMap, object_name: &str, config: &DiskCompressionConfig) -> bool { fn is_disk_compressible_with_config(headers: &http::HeaderMap, object_name: &str, config: &DiskCompressionConfig) -> bool {
// Check if disk compression is enabled (read once at first use, then fixed for process lifetime) // Check if disk compression is enabled (read once at first use, then fixed for process lifetime)
if !config.enabled { if !config.enabled {
+1
View File
@@ -13,6 +13,7 @@
// limitations under the License. // limitations under the License.
// #730: I/O backend selection keeps test-only and staged rio helpers scoped here. // #730: I/O backend selection keeps test-only and staged rio helpers scoped here.
#![allow(dead_code)]
pub(crate) mod bitrot; pub(crate) mod bitrot;
pub(crate) mod compress; pub(crate) mod compress;
+11 -16
View File
@@ -25,20 +25,9 @@ use tokio::io::AsyncRead;
#[cfg(feature = "rio-v2")] #[cfg(feature = "rio-v2")]
const MINIO_S2_COMPRESSION_SCHEME: &str = "klauspost/compress/s2"; const MINIO_S2_COMPRESSION_SCHEME: &str = "klauspost/compress/s2";
// The S2 padding multiple rio-v2 pads compressed streams to before
// encryption. Only the padding test asserts it today, so the lib target sees
// it as unused (backlog#1823).
#[cfg(feature = "rio-v2")] #[cfg(feature = "rio-v2")]
#[allow(dead_code, reason = "on-disk contract asserted by the rio-v2 padding test (backlog#1823)")]
const ENCRYPTED_S2_PADDING_MULTIPLE: usize = 256; const ENCRYPTED_S2_PADDING_MULTIPLE: usize = 256;
/// Which rio implementation this build compiled in. Only the feature-seam
/// guard test in lib.rs reads it, so the lib target sees it as unused
/// (backlog#1823).
#[allow(
dead_code,
reason = "asserted by the rio backend feature-seam test in lib.rs (backlog#1823)"
)]
pub const fn backend_name() -> &'static str { pub const fn backend_name() -> &'static str {
#[cfg(feature = "rio-v2")] #[cfg(feature = "rio-v2")]
{ {
@@ -64,6 +53,17 @@ pub fn compression_metadata_value(algorithm: CompressionAlgorithm) -> String {
} }
} }
pub fn compression_scheme_to_algorithm(scheme: &str) -> std::io::Result<CompressionAlgorithm> {
#[cfg(feature = "rio-v2")]
if scheme.eq_ignore_ascii_case(MINIO_S2_COMPRESSION_SCHEME) {
// rio_v2 currently routes all compressed-object handling through the S2
// reader implementation, so the enum is only a placeholder token here.
return Ok(CompressionAlgorithm::default());
}
CompressionAlgorithm::from_str(scheme)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReadCompressionBackend { pub enum ReadCompressionBackend {
Legacy, Legacy,
@@ -82,11 +82,6 @@ pub fn compression_scheme_to_read_plan(scheme: &str) -> std::io::Result<(Compres
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReadEncryptionBackend { pub enum ReadEncryptionBackend {
Legacy, Legacy,
// Never constructed today — every read still selects Legacy — but the
// decrypt paths below carry live match arms for it. This is the rio-v2
// read seam (backlog#1638 / #1835), not dead code: deleting the variant
// would delete those arms with it.
#[allow(dead_code, reason = "rio-v2 read seam; match arms below are live (backlog#1823)")]
V2, V2,
} }
+2 -3
View File
@@ -21,8 +21,7 @@ use tracing::debug;
/// Supported set sizes this is used to find the optimal /// Supported set sizes this is used to find the optimal
/// single set size. /// single set size.
pub(crate) const MAX_ERASURE_SET_DRIVE_COUNT: usize = 16; const SET_SIZES: [usize; 15] = [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16];
const SET_SIZES: [usize; 15] = [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, MAX_ERASURE_SET_DRIVE_COUNT];
const ENV_RUSTFS_ERASURE_SET_DRIVE_COUNT: &str = "RUSTFS_ERASURE_SET_DRIVE_COUNT"; const ENV_RUSTFS_ERASURE_SET_DRIVE_COUNT: &str = "RUSTFS_ERASURE_SET_DRIVE_COUNT";
#[derive(Deserialize, Debug, Default)] #[derive(Deserialize, Debug, Default)]
@@ -328,7 +327,7 @@ fn possible_set_counts(set_size: usize) -> Vec<usize> {
/// checks whether given count is a valid set size for erasure coding. /// checks whether given count is a valid set size for erasure coding.
fn is_valid_set_size(count: usize) -> bool { fn is_valid_set_size(count: usize) -> bool {
count >= SET_SIZES[0] && count <= MAX_ERASURE_SET_DRIVE_COUNT count >= SET_SIZES[0] && count <= SET_SIZES[SET_SIZES.len() - 1]
} }
/// Final set size with all the symmetry accounted for. /// Final set size with all the symmetry accounted for.

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