mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-15 09:33:13 +00:00
Compare commits
93 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2c7d1f1f9f | |||
| 5328e8b958 | |||
| 1e16e06f8a | |||
| 72fd7339c9 | |||
| 71e83aeec4 | |||
| 9138c24571 | |||
| e9f5318027 | |||
| 69e8ef9af5 | |||
| 0b2a46b36f | |||
| 56509ead1f | |||
| ffe889ad59 | |||
| e11ce2f132 | |||
| eca6bc1600 | |||
| 85be26b3c1 | |||
| ebbcfa3ac2 | |||
| ebd0531124 | |||
| 4421d4829f | |||
| d6c62b9601 | |||
| d91086d094 | |||
| 69719c257e | |||
| 67a19021b5 | |||
| 0ff3d4cbf4 | |||
| 6f29431a65 | |||
| 5a4c063d16 | |||
| e2be34cade | |||
| d60a77b750 | |||
| 307f50ee1b | |||
| c48a6330d0 | |||
| 8ac2ff5c61 | |||
| eb41f45175 | |||
| 122d200675 | |||
| 161e515c72 | |||
| 83cf063b45 | |||
| f8bbfcbeb1 | |||
| 710dcb4865 | |||
| f5cced910a | |||
| 8c9249054f | |||
| 7c2b513613 | |||
| 00844721ff | |||
| 068a0c2b8c | |||
| 5b54c4303d | |||
| 6178083985 | |||
| e16c07b9cd | |||
| 1ac28d6459 | |||
| 9b66040a02 | |||
| 7710f70fda | |||
| aa4d3317ed | |||
| f704d015d6 | |||
| 6b86d44cac | |||
| e3c15f012c | |||
| d2b1003612 | |||
| 36deab8670 | |||
| e11fcfbd08 | |||
| 11eecdc888 | |||
| 80eb4244a3 | |||
| e4da9bd718 | |||
| e28430ab3d | |||
| db4707f187 | |||
| 3a0dbccc2e | |||
| 846517625b | |||
| f21e88b112 | |||
| a5594c3d89 | |||
| fc927caadd | |||
| b7e6334c13 | |||
| 65091aa6a8 | |||
| 3c78a56ab0 | |||
| bdd7ecd205 | |||
| a70a3787d8 | |||
| b2ae430805 | |||
| 299eb0d965 | |||
| f5a780099b | |||
| 5cfafcf39b | |||
| a49243c671 | |||
| c2a15f5214 | |||
| ee54f1e618 | |||
| e6b85b60a8 | |||
| 8c1e3c09ff | |||
| ca06c7ec2c | |||
| 4a41325d1a | |||
| 45e2bd0c28 | |||
| e2fb0427f9 | |||
| a825326ede | |||
| e313276e49 | |||
| 66af487978 | |||
| d668a9293f | |||
| ace28c1f85 | |||
| 2ad8ab534e | |||
| f7df4fa62a | |||
| ca4e66daab | |||
| 5b9c5289c2 | |||
| 3f9b84ec70 | |||
| 73bd5d9d95 | |||
| 398d2d87c8 |
@@ -252,10 +252,16 @@ test-group = 'ecstore-serial-flaky'
|
||||
# cluster, so it keeps the lane's parallel-safe / no-external-dependency
|
||||
# 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.
|
||||
#
|
||||
# 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]
|
||||
default-filter = """
|
||||
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|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|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(/^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::tiering::/)
|
||||
|
||||
@@ -85,7 +85,7 @@ runs:
|
||||
repo-token: ${{ github.token }}
|
||||
|
||||
- name: Install flatc
|
||||
uses: Nugine/setup-flatc@e7855e994773ce90094a3f1626d4afc9080c23ae # v1
|
||||
uses: Nugine/setup-flatc@698800de72a96bfb22cf60431dc21a2ff9a7e07b # v1
|
||||
with:
|
||||
version: "25.12.19"
|
||||
|
||||
|
||||
@@ -182,7 +182,12 @@ jobs:
|
||||
echo '```'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# Readers: test-and-lint-rio-v2, build-rustfs-debug-binary-rio-v2.
|
||||
# Readers: test-and-lint-rio-v2 (per-PR), 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:
|
||||
name: Warm ci-feat-rio
|
||||
runs-on: sm-standard-4
|
||||
|
||||
@@ -533,7 +533,12 @@ jobs:
|
||||
|
||||
build-rustfs-debug-binary-rio-v2:
|
||||
name: Build RustFS Debug Binary (rio-v2)
|
||||
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
||||
# Dormant rio-v2 variant (rustfs/backlog#1835): the feature ships in no
|
||||
# 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 ]
|
||||
runs-on: sm-standard-4
|
||||
timeout-minutes: 30
|
||||
@@ -824,6 +829,9 @@ jobs:
|
||||
|
||||
e2e-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 ]
|
||||
runs-on: sm-standard-2
|
||||
timeout-minutes: 30
|
||||
|
||||
@@ -94,6 +94,7 @@ jobs:
|
||||
short_sha: ${{ steps.check.outputs.short_sha }}
|
||||
is_prerelease: ${{ steps.check.outputs.is_prerelease }}
|
||||
create_latest: ${{ steps.check.outputs.create_latest }}
|
||||
source_ref: ${{ steps.check.outputs.source_ref }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
@@ -118,6 +119,7 @@ jobs:
|
||||
short_sha=""
|
||||
is_prerelease=false
|
||||
create_latest=false
|
||||
source_ref="$GITHUB_SHA"
|
||||
|
||||
if [[ "${{ github.event_name }}" == "workflow_run" ]]; then
|
||||
# Triggered by build workflow completion
|
||||
@@ -137,6 +139,7 @@ jobs:
|
||||
# Extract version info from commit message or use commit SHA
|
||||
# Use Git to generate consistent short SHA (ensures uniqueness like build.yml)
|
||||
short_sha=$(git rev-parse --short "$HEAD_SHA")
|
||||
source_ref="$HEAD_SHA"
|
||||
|
||||
# Determine build type based on triggering workflow event and ref
|
||||
triggering_event="$TRIGGERING_EVENT"
|
||||
@@ -261,6 +264,23 @@ jobs:
|
||||
echo "⚠️ Only release versions (latest, v1.0.0, 1.0.0) and prereleases (v1.0.0-alpha1, 1.0.0-beta2) are supported"
|
||||
;;
|
||||
esac
|
||||
|
||||
if [[ "$should_build" == true && "$input_version" != "latest" ]]; then
|
||||
tag_ref="refs/tags/$input_version"
|
||||
if ! git ls-remote --exit-code origin "$tag_ref" >/dev/null 2>&1; then
|
||||
if [[ "$input_version" == v* ]]; then
|
||||
tag_ref="refs/tags/${input_version#v}"
|
||||
else
|
||||
tag_ref="refs/tags/v$input_version"
|
||||
fi
|
||||
fi
|
||||
|
||||
if ! git ls-remote --exit-code origin "$tag_ref" >/dev/null 2>&1; then
|
||||
echo "❌ Release tag not found for Docker build: $input_version"
|
||||
exit 1
|
||||
fi
|
||||
source_ref="$tag_ref"
|
||||
fi
|
||||
fi
|
||||
|
||||
{
|
||||
@@ -271,6 +291,7 @@ jobs:
|
||||
echo "short_sha=$short_sha"
|
||||
echo "is_prerelease=$is_prerelease"
|
||||
echo "create_latest=$create_latest"
|
||||
echo "source_ref=$source_ref"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
echo "🐳 Docker Build Summary:"
|
||||
@@ -281,6 +302,7 @@ jobs:
|
||||
echo " - Short SHA: $short_sha"
|
||||
echo " - Is prerelease: $is_prerelease"
|
||||
echo " - Create latest: $create_latest"
|
||||
echo " - Source ref: $source_ref"
|
||||
|
||||
# Build multi-arch Docker images
|
||||
# Strategy: Build images using pre-built binaries from dl.rustfs.com
|
||||
@@ -308,6 +330,7 @@ jobs:
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ needs.build-check.outputs.source_ref }}
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
|
||||
@@ -397,7 +420,8 @@ jobs:
|
||||
LABELS="org.opencontainers.image.title=RustFS"
|
||||
LABELS="$LABELS,org.opencontainers.image.description=RustFS distributed object storage system"
|
||||
LABELS="$LABELS,org.opencontainers.image.version=$VERSION"
|
||||
LABELS="$LABELS,org.opencontainers.image.revision=${{ github.sha }}"
|
||||
SOURCE_REVISION="$(git rev-parse HEAD)"
|
||||
LABELS="$LABELS,org.opencontainers.image.revision=$SOURCE_REVISION"
|
||||
LABELS="$LABELS,org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}"
|
||||
LABELS="$LABELS,org.opencontainers.image.created=$(date -u +'%Y-%m-%dT%H:%M:%SZ')"
|
||||
LABELS="$LABELS,org.opencontainers.image.build-type=$BUILD_TYPE"
|
||||
|
||||
+7
-4
@@ -101,7 +101,10 @@ refactors.
|
||||
|
||||
The `rustfs` binary crate composes these libraries into the running server.
|
||||
`ecstore` remains the storage engine at the architectural center; its internal
|
||||
module split is tracked under `docs/architecture/`.
|
||||
module split is tracked under `docs/architecture/`. `rio-v2` is the
|
||||
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
|
||||
|
||||
@@ -131,9 +134,9 @@ module split is tracked under `docs/architecture/`.
|
||||
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`). A zero-consumer
|
||||
`BackpressureSettings` copy lingers in `crates/io-metrics/src/config.rs`;
|
||||
its removal is tracked in rustfs/backlog#1833.
|
||||
`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
|
||||
storage-level abstractions (objects, buckets, disks, pools).
|
||||
|
||||
Generated
+86
-78
@@ -1162,9 +1162,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-smithy-eventstream"
|
||||
version = "0.61.1"
|
||||
version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a9381123ab62d20c13082b151f30f962a3b112b727345394536dfa39a482944"
|
||||
checksum = "6de526c7b567420a31bc283657a7921b45c4cafe0827fdf2490713dcc770c28f"
|
||||
dependencies = [
|
||||
"aws-smithy-types",
|
||||
"bytes",
|
||||
@@ -1195,9 +1195,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-smithy-http-client"
|
||||
version = "1.2.0"
|
||||
version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "635d23afda0a6ab48d666c4d447c4873e8d1e83518a2be2093122397e50b838e"
|
||||
checksum = "3c1c8a04cb31ba74d0115af5a890bb8c0d48fba64b52812fa13929a6ef0cc83c"
|
||||
dependencies = [
|
||||
"aws-smithy-async",
|
||||
"aws-smithy-protocol-test",
|
||||
@@ -1277,9 +1277,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-smithy-runtime"
|
||||
version = "1.12.1"
|
||||
version = "1.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "07505b34e8f4b3591a4fa69e9792b52289b95488dbbc68c3c0075b7bedb245e1"
|
||||
checksum = "483b858ff67522011c4786310c5cd8fd88d0be7ea3d5f1a48328446300c4269e"
|
||||
dependencies = [
|
||||
"aws-smithy-async",
|
||||
"aws-smithy-http",
|
||||
@@ -1343,9 +1343,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-smithy-types"
|
||||
version = "1.6.1"
|
||||
version = "1.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6dc683efb34b9e755675b37fedbe0103141e5b6df7bdc9eb6967756a8c167d8"
|
||||
checksum = "fce83ce9abbb198d25bc7131e468d0f9fe1257125e58c39f3f9fc9f5098c9647"
|
||||
dependencies = [
|
||||
"base64-simd",
|
||||
"bytes",
|
||||
@@ -3761,7 +3761,7 @@ checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
||||
|
||||
[[package]]
|
||||
name = "e2e_test"
|
||||
version = "1.0.0-rc.1"
|
||||
version = "1.0.0-rc.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"astral-tokio-tar",
|
||||
@@ -5133,9 +5133,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "http-body-util"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2"
|
||||
checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
@@ -5957,7 +5957,7 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
|
||||
[[package]]
|
||||
name = "libmimalloc-sys"
|
||||
version = "0.1.49"
|
||||
source = "git+https://github.com/xonatius/mimalloc_rust.git?rev=ce6338661179c8be22e516b00af7483f151485a7#ce6338661179c8be22e516b00af7483f151485a7"
|
||||
source = "git+https://github.com/xonatius/mimalloc_rust.git?rev=6d4c41bb10c6d9da1d1b6f07b38c4cc051667f11#6d4c41bb10c6d9da1d1b6f07b38c4cc051667f11"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cty",
|
||||
@@ -6259,9 +6259,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "metrique"
|
||||
version = "0.1.29"
|
||||
version = "0.1.30"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d2e394c63e2d1a30aeb3b9392ecf3439d8475d2df810a8f4f6e66d6866754017"
|
||||
checksum = "dedbf06ffeef4c37990c73636fbd993aa34fb1948afd736e6114f239220993db"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"jiff",
|
||||
@@ -6289,9 +6289,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "metrique-macro"
|
||||
version = "0.1.20"
|
||||
version = "0.1.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "786df1fd0abebd0db685f7e9a353c78756d4b370fb98a52376c2015fa55f141f"
|
||||
checksum = "f4fb1f30185f53f7f6e4c9e46745c1a1350af8e77fda5a88aded44b0637a82e0"
|
||||
dependencies = [
|
||||
"Inflector",
|
||||
"darling 0.23.0",
|
||||
@@ -6318,9 +6318,9 @@ checksum = "2faca4e4480069ff02b1763b3b79f5cec7e8628e24d9dc5b6073f53d2577a4d9"
|
||||
|
||||
[[package]]
|
||||
name = "metrique-writer"
|
||||
version = "0.1.25"
|
||||
version = "0.1.26"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "82cdde44d241dab7fc8b7a32e0eb5dae6cd28f8de80b59f9a1e9f2f0b05e485e"
|
||||
checksum = "20bd17c1a3ca2719e31f19ce77a853948dc2102f35976b92276c42a64fdc5f3f"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"crossbeam-queue",
|
||||
@@ -6339,9 +6339,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "metrique-writer-core"
|
||||
version = "0.1.19"
|
||||
version = "0.1.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e57379b7ee2272efaeaaa6de062503563e57333b24aadc7f2255b3d602899e8b"
|
||||
checksum = "f1a55b6aae1d85c557c729564c4e2b32a26dc65ba2d90d9647ca01f2bd4854c4"
|
||||
dependencies = [
|
||||
"derive-where",
|
||||
"itertools 0.14.0",
|
||||
@@ -6366,7 +6366,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "mimalloc"
|
||||
version = "0.1.52"
|
||||
source = "git+https://github.com/xonatius/mimalloc_rust.git?rev=ce6338661179c8be22e516b00af7483f151485a7#ce6338661179c8be22e516b00af7483f151485a7"
|
||||
source = "git+https://github.com/xonatius/mimalloc_rust.git?rev=6d4c41bb10c6d9da1d1b6f07b38c4cc051667f11#6d4c41bb10c6d9da1d1b6f07b38c4cc051667f11"
|
||||
dependencies = [
|
||||
"libmimalloc-sys",
|
||||
]
|
||||
@@ -6882,7 +6882,7 @@ version = "5.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"base64 0.21.7",
|
||||
"chrono",
|
||||
"getrandom 0.2.17",
|
||||
"http 1.5.0",
|
||||
@@ -8043,7 +8043,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf"
|
||||
dependencies = [
|
||||
"heck 0.5.0",
|
||||
"itertools 0.14.0",
|
||||
"itertools 0.10.5",
|
||||
"log",
|
||||
"multimap",
|
||||
"once_cell",
|
||||
@@ -8063,7 +8063,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "03da047801ff44bb6a4d407d4860c05fd70bb81714e6b2f3812603d5b145b042"
|
||||
dependencies = [
|
||||
"heck 0.5.0",
|
||||
"itertools 0.14.0",
|
||||
"itertools 0.10.5",
|
||||
"log",
|
||||
"multimap",
|
||||
"petgraph 0.8.3",
|
||||
@@ -8084,7 +8084,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
"itertools 0.10.5",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
@@ -8097,7 +8097,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
"itertools 0.10.5",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
@@ -9090,7 +9090,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs"
|
||||
version = "1.0.0-rc.1"
|
||||
version = "1.0.0-rc.2"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"anyhow",
|
||||
@@ -9201,7 +9201,6 @@ dependencies = [
|
||||
"sha2 0.11.0",
|
||||
"shadow-rs",
|
||||
"socket2",
|
||||
"starshard",
|
||||
"subtle",
|
||||
"sysinfo",
|
||||
"temp-env",
|
||||
@@ -9228,7 +9227,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-audit"
|
||||
version = "1.0.0-rc.1"
|
||||
version = "1.0.0-rc.2"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"const-str",
|
||||
@@ -9251,7 +9250,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-checksums"
|
||||
version = "1.0.0-rc.1"
|
||||
version = "1.0.0-rc.2"
|
||||
dependencies = [
|
||||
"base64-simd",
|
||||
"bytes",
|
||||
@@ -9267,7 +9266,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-common"
|
||||
version = "1.0.0-rc.1"
|
||||
version = "1.0.0-rc.2"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"hotpath",
|
||||
@@ -9285,7 +9284,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-concurrency"
|
||||
version = "1.0.0-rc.1"
|
||||
version = "1.0.0-rc.2"
|
||||
dependencies = [
|
||||
"hotpath",
|
||||
"insta",
|
||||
@@ -9298,7 +9297,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-config"
|
||||
version = "1.0.0-rc.1"
|
||||
version = "1.0.0-rc.2"
|
||||
dependencies = [
|
||||
"const-str",
|
||||
"hotpath",
|
||||
@@ -9308,7 +9307,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-credentials"
|
||||
version = "1.0.0-rc.1"
|
||||
version = "1.0.0-rc.2"
|
||||
dependencies = [
|
||||
"base64-simd",
|
||||
"hmac 0.13.0",
|
||||
@@ -9322,7 +9321,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-crypto"
|
||||
version = "1.0.0-rc.1"
|
||||
version = "1.0.0-rc.2"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"argon2",
|
||||
@@ -9343,7 +9342,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-data-usage"
|
||||
version = "1.0.0-rc.1"
|
||||
version = "1.0.0-rc.2"
|
||||
dependencies = [
|
||||
"hotpath",
|
||||
"rmp-serde",
|
||||
@@ -9353,7 +9352,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-ecstore"
|
||||
version = "1.0.0-rc.1"
|
||||
version = "1.0.0-rc.2"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"async-channel",
|
||||
@@ -9492,7 +9491,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-extension-schema"
|
||||
version = "1.0.0-rc.1"
|
||||
version = "1.0.0-rc.2"
|
||||
dependencies = [
|
||||
"hotpath",
|
||||
"serde",
|
||||
@@ -9502,7 +9501,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-filemeta"
|
||||
version = "1.0.0-rc.1"
|
||||
version = "1.0.0-rc.2"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"byteorder",
|
||||
@@ -9529,7 +9528,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-heal"
|
||||
version = "1.0.0-rc.1"
|
||||
version = "1.0.0-rc.2"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.23.1",
|
||||
@@ -9560,7 +9559,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-iam"
|
||||
version = "1.0.0-rc.1"
|
||||
version = "1.0.0-rc.2"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"async-trait",
|
||||
@@ -9601,7 +9600,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-io-core"
|
||||
version = "1.0.0-rc.1"
|
||||
version = "1.0.0-rc.2"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"hotpath",
|
||||
@@ -9614,16 +9613,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-io-metrics"
|
||||
version = "1.0.0-rc.1"
|
||||
version = "1.0.0-rc.2"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"hotpath",
|
||||
"metrics",
|
||||
"metrics-util",
|
||||
"num_cpus",
|
||||
"rustfs-common",
|
||||
"rustfs-s3-ops",
|
||||
"rustfs-utils",
|
||||
"sysinfo",
|
||||
"thiserror 2.0.20",
|
||||
"tokio",
|
||||
@@ -9680,7 +9677,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-keystone"
|
||||
version = "1.0.0-rc.1"
|
||||
version = "1.0.0-rc.2"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures",
|
||||
@@ -9707,7 +9704,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-kms"
|
||||
version = "1.0.0-rc.1"
|
||||
version = "1.0.0-rc.2"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"anyhow",
|
||||
@@ -9737,6 +9734,7 @@ dependencies = [
|
||||
"rustfs-utils",
|
||||
"rustify",
|
||||
"serde",
|
||||
"serde_ignored",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"subtle",
|
||||
@@ -9755,7 +9753,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-lifecycle"
|
||||
version = "1.0.0-rc.1"
|
||||
version = "1.0.0-rc.2"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"hotpath",
|
||||
@@ -9778,7 +9776,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-lock"
|
||||
version = "1.0.0-rc.1"
|
||||
version = "1.0.0-rc.2"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"compact_str",
|
||||
@@ -9801,7 +9799,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-log-analyzer"
|
||||
version = "1.0.0-rc.1"
|
||||
version = "1.0.0-rc.2"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"flate2",
|
||||
@@ -9820,7 +9818,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-madmin"
|
||||
version = "1.0.0-rc.1"
|
||||
version = "1.0.0-rc.2"
|
||||
dependencies = [
|
||||
"hotpath",
|
||||
"humantime",
|
||||
@@ -9835,7 +9833,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-notify"
|
||||
version = "1.0.0-rc.1"
|
||||
version = "1.0.0-rc.2"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"async-trait",
|
||||
@@ -9870,7 +9868,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-object-capacity"
|
||||
version = "1.0.0-rc.1"
|
||||
version = "1.0.0-rc.2"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"futures",
|
||||
@@ -9890,7 +9888,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-object-data-cache"
|
||||
version = "1.0.0-rc.1"
|
||||
version = "1.0.0-rc.2"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"criterion",
|
||||
@@ -9907,7 +9905,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-obs"
|
||||
version = "1.0.0-rc.1"
|
||||
version = "1.0.0-rc.2"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"crossbeam-channel",
|
||||
@@ -9962,7 +9960,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-policy"
|
||||
version = "1.0.0-rc.1"
|
||||
version = "1.0.0-rc.2"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64-simd",
|
||||
@@ -9993,7 +9991,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-protocols"
|
||||
version = "1.0.0-rc.1"
|
||||
version = "1.0.0-rc.2"
|
||||
dependencies = [
|
||||
"astral-tokio-tar",
|
||||
"async-compression",
|
||||
@@ -10055,7 +10053,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-protos"
|
||||
version = "1.0.0-rc.1"
|
||||
version = "1.0.0-rc.2"
|
||||
dependencies = [
|
||||
"flatbuffers",
|
||||
"hotpath",
|
||||
@@ -10079,7 +10077,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-replication"
|
||||
version = "1.0.0-rc.1"
|
||||
version = "1.0.0-rc.2"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"bytes",
|
||||
@@ -10097,7 +10095,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-rio"
|
||||
version = "1.0.0-rc.1"
|
||||
version = "1.0.0-rc.2"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"arc-swap",
|
||||
@@ -10135,7 +10133,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-rio-v2"
|
||||
version = "1.0.0-rc.1"
|
||||
version = "1.0.0-rc.2"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"bytes",
|
||||
@@ -10158,7 +10156,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-s3-ops"
|
||||
version = "1.0.0-rc.1"
|
||||
version = "1.0.0-rc.2"
|
||||
dependencies = [
|
||||
"hotpath",
|
||||
"rustfs-s3-types",
|
||||
@@ -10166,7 +10164,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-s3-types"
|
||||
version = "1.0.0-rc.1"
|
||||
version = "1.0.0-rc.2"
|
||||
dependencies = [
|
||||
"hotpath",
|
||||
"serde",
|
||||
@@ -10175,7 +10173,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-s3select-api"
|
||||
version = "1.0.0-rc.1"
|
||||
version = "1.0.0-rc.2"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
@@ -10205,7 +10203,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-s3select-query"
|
||||
version = "1.0.0-rc.1"
|
||||
version = "1.0.0-rc.2"
|
||||
dependencies = [
|
||||
"async-recursion",
|
||||
"async-trait",
|
||||
@@ -10224,7 +10222,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-scanner"
|
||||
version = "1.0.0-rc.1"
|
||||
version = "1.0.0-rc.2"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
@@ -10264,7 +10262,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-security-governance"
|
||||
version = "1.0.0-rc.1"
|
||||
version = "1.0.0-rc.2"
|
||||
dependencies = [
|
||||
"hotpath",
|
||||
"thiserror 2.0.20",
|
||||
@@ -10272,7 +10270,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-signer"
|
||||
version = "1.0.0-rc.1"
|
||||
version = "1.0.0-rc.2"
|
||||
dependencies = [
|
||||
"base64-simd",
|
||||
"bytes",
|
||||
@@ -10290,7 +10288,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-storage-api"
|
||||
version = "1.0.0-rc.1"
|
||||
version = "1.0.0-rc.2"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"hotpath",
|
||||
@@ -10305,7 +10303,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-targets"
|
||||
version = "1.0.0-rc.1"
|
||||
version = "1.0.0-rc.2"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"async-nats",
|
||||
@@ -10359,7 +10357,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-test-utils"
|
||||
version = "1.0.0-rc.1"
|
||||
version = "1.0.0-rc.2"
|
||||
dependencies = [
|
||||
"hotpath",
|
||||
"rustfs-data-usage",
|
||||
@@ -10375,7 +10373,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-tls-runtime"
|
||||
version = "1.0.0-rc.1"
|
||||
version = "1.0.0-rc.2"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"hotpath",
|
||||
@@ -10396,7 +10394,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-trusted-proxies"
|
||||
version = "1.0.0-rc.1"
|
||||
version = "1.0.0-rc.2"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
@@ -10433,7 +10431,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-utils"
|
||||
version = "1.0.0-rc.1"
|
||||
version = "1.0.0-rc.2"
|
||||
dependencies = [
|
||||
"base64-simd",
|
||||
"blake2",
|
||||
@@ -10475,7 +10473,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-zip"
|
||||
version = "1.0.0-rc.1"
|
||||
version = "1.0.0-rc.2"
|
||||
dependencies = [
|
||||
"astral-tokio-tar",
|
||||
"async-compression",
|
||||
@@ -10561,9 +10559,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls-connector"
|
||||
version = "0.23.7"
|
||||
version = "0.23.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09a5abe04eec18f8b9fbe87885bcaee6426de80bbc579958c0bc064b728ee617"
|
||||
checksum = "1babecfcc65b139b812e74bcc7f9ec7b4e00db659fd42d99567b7e77f0c714c6"
|
||||
dependencies = [
|
||||
"futures-io",
|
||||
"futures-rustls",
|
||||
@@ -10939,6 +10937,16 @@ dependencies = [
|
||||
"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]]
|
||||
name = "serde_json"
|
||||
version = "1.0.151"
|
||||
@@ -11792,7 +11800,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.4.3",
|
||||
"getrandom 0.3.4",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
|
||||
+54
-53
@@ -41,7 +41,7 @@ members = [
|
||||
"crates/protocols", # Protocol implementations (FTPS, SFTP, etc.)
|
||||
"crates/protos", # Protocol buffer definitions
|
||||
"crates/rio", # Rust I/O utilities and abstractions
|
||||
"crates/rio-v2", # Next-generation Rust I/O compatibility layer
|
||||
"crates/rio-v2", # MinIO on-disk format compatibility I/O layer (feature-gated, ships in no default build)
|
||||
"crates/replication", # Replication contracts and wire formats
|
||||
"crates/concurrency", # Concurrency management for RustFS - timeout, locking, backpressure, and I/O scheduling
|
||||
"crates/s3-types", # S3 event type definitions
|
||||
@@ -69,7 +69,7 @@ edition = "2024"
|
||||
license = "Apache-2.0"
|
||||
repository = "https://github.com/rustfs/rustfs"
|
||||
rust-version = "1.97.1"
|
||||
version = "1.0.0-rc.1"
|
||||
version = "1.0.0-rc.2"
|
||||
homepage = "https://rustfs.com"
|
||||
description = "RustFS is a high-performance distributed object storage software built using Rust, one of the most popular languages worldwide. "
|
||||
keywords = ["RustFS", "Minio", "object-storage", "filesystem", "s3"]
|
||||
@@ -86,52 +86,52 @@ redundant_clone = "warn"
|
||||
|
||||
[workspace.dependencies]
|
||||
# RustFS Internal Crates
|
||||
rustfs = { path = "./rustfs", version = "1.0.0-rc.1" }
|
||||
rustfs-heal = { path = "crates/heal", version = "1.0.0-rc.1" }
|
||||
rustfs-audit = { path = "crates/audit", version = "1.0.0-rc.1" }
|
||||
rustfs-checksums = { path = "crates/checksums", version = "1.0.0-rc.1" }
|
||||
rustfs-common = { path = "crates/common", version = "1.0.0-rc.1" }
|
||||
rustfs-data-usage = { path = "crates/data-usage", version = "1.0.0-rc.1" }
|
||||
rustfs-config = { path = "./crates/config", version = "1.0.0-rc.1" }
|
||||
rustfs-concurrency = { path = "./crates/concurrency", version = "1.0.0-rc.1" }
|
||||
rustfs-credentials = { path = "crates/credentials", version = "1.0.0-rc.1" }
|
||||
rustfs-crypto = { path = "crates/crypto", version = "1.0.0-rc.1" }
|
||||
rustfs-ecstore = { path = "crates/ecstore", version = "1.0.0-rc.1" }
|
||||
rustfs-filemeta = { path = "crates/filemeta", version = "1.0.0-rc.1" }
|
||||
rustfs-iam = { path = "crates/iam", version = "1.0.0-rc.1" }
|
||||
rustfs-keystone = { path = "crates/keystone", version = "1.0.0-rc.1" }
|
||||
rustfs-lifecycle = { path = "crates/lifecycle", version = "1.0.0-rc.1" }
|
||||
rustfs-kms = { path = "crates/kms", version = "1.0.0-rc.1" }
|
||||
rustfs-lock = { path = "crates/lock", version = "1.0.0-rc.1" }
|
||||
rustfs-madmin = { path = "crates/madmin", version = "1.0.0-rc.1" }
|
||||
rustfs-notify = { path = "crates/notify", version = "1.0.0-rc.1" }
|
||||
rustfs-io-metrics = { path = "crates/io-metrics", version = "1.0.0-rc.1" }
|
||||
rustfs-io-core = { path = "crates/io-core", version = "1.0.0-rc.1" }
|
||||
rustfs-object-capacity = { path = "crates/object-capacity", version = "1.0.0-rc.1" }
|
||||
rustfs-object-data-cache = { path = "crates/object-data-cache", version = "1.0.0-rc.1", default-features = false }
|
||||
rustfs-log-analyzer = { path = "crates/log-analyzer", version = "1.0.0-rc.1" }
|
||||
rustfs-obs = { path = "crates/obs", version = "1.0.0-rc.1" }
|
||||
rustfs-policy = { path = "crates/policy", version = "1.0.0-rc.1" }
|
||||
rustfs-protos = { path = "crates/protos", version = "1.0.0-rc.1" }
|
||||
rustfs-protocols = { path = "crates/protocols", version = "1.0.0-rc.1" }
|
||||
rustfs-replication = { path = "crates/replication", version = "1.0.0-rc.1" }
|
||||
rustfs-rio = { path = "crates/rio", version = "1.0.0-rc.1" }
|
||||
rustfs-rio-v2 = { path = "crates/rio-v2", version = "1.0.0-rc.1" }
|
||||
rustfs-s3-types = { path = "crates/s3-types", version = "1.0.0-rc.1" }
|
||||
rustfs-s3-ops = { path = "crates/s3-ops", version = "1.0.0-rc.1" }
|
||||
rustfs-s3select-api = { path = "crates/s3select-api", version = "1.0.0-rc.1" }
|
||||
rustfs-s3select-query = { path = "crates/s3select-query", version = "1.0.0-rc.1" }
|
||||
rustfs-scanner = { path = "crates/scanner", version = "1.0.0-rc.1" }
|
||||
rustfs-security-governance = { path = "crates/security-governance", version = "1.0.0-rc.1" }
|
||||
rustfs-extension-schema = { path = "crates/extension-schema", version = "1.0.0-rc.1" }
|
||||
rustfs-signer = { path = "crates/signer", version = "1.0.0-rc.1" }
|
||||
rustfs-storage-api = { path = "crates/storage-api", version = "1.0.0-rc.1" }
|
||||
rustfs-trusted-proxies = { path = "crates/trusted-proxies", version = "1.0.0-rc.1" }
|
||||
rustfs-targets = { path = "crates/targets", version = "1.0.0-rc.1" }
|
||||
rustfs-test-utils = { path = "crates/test-utils", version = "1.0.0-rc.1" }
|
||||
rustfs-tls-runtime = { path = "crates/tls-runtime", version = "1.0.0-rc.1" }
|
||||
rustfs-utils = { path = "crates/utils", version = "1.0.0-rc.1" }
|
||||
rustfs-zip = { path = "./crates/zip", version = "1.0.0-rc.1" }
|
||||
rustfs = { path = "./rustfs", version = "1.0.0-rc.2" }
|
||||
rustfs-heal = { path = "crates/heal", version = "1.0.0-rc.2" }
|
||||
rustfs-audit = { path = "crates/audit", version = "1.0.0-rc.2" }
|
||||
rustfs-checksums = { path = "crates/checksums", version = "1.0.0-rc.2" }
|
||||
rustfs-common = { path = "crates/common", version = "1.0.0-rc.2" }
|
||||
rustfs-data-usage = { path = "crates/data-usage", version = "1.0.0-rc.2" }
|
||||
rustfs-config = { path = "./crates/config", version = "1.0.0-rc.2" }
|
||||
rustfs-concurrency = { path = "./crates/concurrency", version = "1.0.0-rc.2" }
|
||||
rustfs-credentials = { path = "crates/credentials", version = "1.0.0-rc.2" }
|
||||
rustfs-crypto = { path = "crates/crypto", version = "1.0.0-rc.2" }
|
||||
rustfs-ecstore = { path = "crates/ecstore", version = "1.0.0-rc.2" }
|
||||
rustfs-filemeta = { path = "crates/filemeta", version = "1.0.0-rc.2" }
|
||||
rustfs-iam = { path = "crates/iam", version = "1.0.0-rc.2" }
|
||||
rustfs-keystone = { path = "crates/keystone", version = "1.0.0-rc.2" }
|
||||
rustfs-lifecycle = { path = "crates/lifecycle", version = "1.0.0-rc.2" }
|
||||
rustfs-kms = { path = "crates/kms", version = "1.0.0-rc.2" }
|
||||
rustfs-lock = { path = "crates/lock", version = "1.0.0-rc.2" }
|
||||
rustfs-madmin = { path = "crates/madmin", version = "1.0.0-rc.2" }
|
||||
rustfs-notify = { path = "crates/notify", version = "1.0.0-rc.2" }
|
||||
rustfs-io-metrics = { path = "crates/io-metrics", version = "1.0.0-rc.2" }
|
||||
rustfs-io-core = { path = "crates/io-core", version = "1.0.0-rc.2" }
|
||||
rustfs-object-capacity = { path = "crates/object-capacity", version = "1.0.0-rc.2" }
|
||||
rustfs-object-data-cache = { path = "crates/object-data-cache", version = "1.0.0-rc.2", default-features = false }
|
||||
rustfs-log-analyzer = { path = "crates/log-analyzer", version = "1.0.0-rc.2" }
|
||||
rustfs-obs = { path = "crates/obs", version = "1.0.0-rc.2" }
|
||||
rustfs-policy = { path = "crates/policy", version = "1.0.0-rc.2" }
|
||||
rustfs-protos = { path = "crates/protos", version = "1.0.0-rc.2" }
|
||||
rustfs-protocols = { path = "crates/protocols", version = "1.0.0-rc.2" }
|
||||
rustfs-replication = { path = "crates/replication", version = "1.0.0-rc.2" }
|
||||
rustfs-rio = { path = "crates/rio", version = "1.0.0-rc.2" }
|
||||
rustfs-rio-v2 = { path = "crates/rio-v2", version = "1.0.0-rc.2" }
|
||||
rustfs-s3-types = { path = "crates/s3-types", version = "1.0.0-rc.2" }
|
||||
rustfs-s3-ops = { path = "crates/s3-ops", version = "1.0.0-rc.2" }
|
||||
rustfs-s3select-api = { path = "crates/s3select-api", version = "1.0.0-rc.2" }
|
||||
rustfs-s3select-query = { path = "crates/s3select-query", version = "1.0.0-rc.2" }
|
||||
rustfs-scanner = { path = "crates/scanner", version = "1.0.0-rc.2" }
|
||||
rustfs-security-governance = { path = "crates/security-governance", version = "1.0.0-rc.2" }
|
||||
rustfs-extension-schema = { path = "crates/extension-schema", version = "1.0.0-rc.2" }
|
||||
rustfs-signer = { path = "crates/signer", version = "1.0.0-rc.2" }
|
||||
rustfs-storage-api = { path = "crates/storage-api", version = "1.0.0-rc.2" }
|
||||
rustfs-trusted-proxies = { path = "crates/trusted-proxies", version = "1.0.0-rc.2" }
|
||||
rustfs-targets = { path = "crates/targets", version = "1.0.0-rc.2" }
|
||||
rustfs-test-utils = { path = "crates/test-utils", version = "1.0.0-rc.2" }
|
||||
rustfs-tls-runtime = { path = "crates/tls-runtime", version = "1.0.0-rc.2" }
|
||||
rustfs-utils = { path = "crates/utils", version = "1.0.0-rc.2" }
|
||||
rustfs-zip = { path = "./crates/zip", version = "1.0.0-rc.2" }
|
||||
|
||||
# Async Runtime and Networking
|
||||
async-channel = "2.5.0"
|
||||
@@ -154,7 +154,7 @@ hyper-rustls = { default-features = false, version = "0.27.9" }
|
||||
hyper-util = { version = "0.1.20" }
|
||||
http = "1.5.0"
|
||||
http-body = "1.1.0"
|
||||
http-body-util = "0.1.4"
|
||||
http-body-util = "0.1.5"
|
||||
minlz = "1.2.3"
|
||||
reqwest = "0.13.4"
|
||||
rustfs-kafka-async = { version = "1.2.0" }
|
||||
@@ -182,6 +182,7 @@ quick-xml = "0.41.0"
|
||||
rmp = { version = "0.8.15" }
|
||||
rmp-serde = { version = "1.3.1" }
|
||||
serde = { version = "1.0.229" }
|
||||
serde_ignored = { version = "0.1" }
|
||||
serde_json = { version = "1.0.151" }
|
||||
serde_urlencoded = "0.7.1"
|
||||
|
||||
@@ -230,9 +231,9 @@ aws-credential-types = { version = "1.3.0" }
|
||||
aws-sdk-kms = { default-features = false, version = "1.114.0" }
|
||||
aws-sdk-s3 = { default-features = false, version = "1.141.0" }
|
||||
aws-sdk-sts = { default-features = false, version = "1.110.0" }
|
||||
aws-smithy-http-client = { default-features = false, version = "1.2.0" }
|
||||
aws-smithy-http-client = { default-features = false, version = "1.3.0" }
|
||||
aws-smithy-runtime-api = { version = "1.14.0" }
|
||||
aws-smithy-types = { version = "1.6.1" }
|
||||
aws-smithy-types = { version = "1.6.2" }
|
||||
base64 = "0.23.1"
|
||||
base64-simd = "0.8.0"
|
||||
brotli = "8.0.4"
|
||||
@@ -347,8 +348,8 @@ russh-sftp = "2.4.0"
|
||||
dav-server = "0.11.0"
|
||||
|
||||
# Performance Analysis and Memory Profiling
|
||||
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 = "ce6338661179c8be22e516b00af7483f151485a7", features = ["extended"] }
|
||||
mimalloc = { version = "0.1.52", git = "https://github.com/xonatius/mimalloc_rust.git", rev = "6d4c41bb10c6d9da1d1b6f07b38c4cc051667f11" }
|
||||
libmimalloc-sys = { version = "0.1.49", git = "https://github.com/xonatius/mimalloc_rust.git", rev = "6d4c41bb10c6d9da1d1b6f07b38c4cc051667f11", features = ["extended"] }
|
||||
hotpath = { version = "0.23.2", default-features = false }
|
||||
# Snapshot testing for output format regression detection
|
||||
insta = { version = "1.48" }
|
||||
|
||||
@@ -116,7 +116,7 @@ chown -R 10001:10001 data logs
|
||||
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:latest
|
||||
|
||||
# Using specific version
|
||||
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.0-rc.1
|
||||
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.0-rc.2
|
||||
```
|
||||
|
||||
If you use [podman](https://github.com/containers/podman) instead of docker, you can install the RustFS with the below command
|
||||
|
||||
+1
-1
@@ -113,7 +113,7 @@ chown -R 10001:10001 data logs
|
||||
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:latest
|
||||
|
||||
# 使用指定版本运行
|
||||
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.0-rc.1
|
||||
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.0-rc.2
|
||||
```
|
||||
|
||||
如果您通过绑定挂载启用 TLS 证书目录,也请用同样方式准备该目录:
|
||||
|
||||
@@ -21,6 +21,13 @@ use crate::{
|
||||
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_C_HEADER_NAME: &str = "x-amz-checksum-crc32c";
|
||||
pub const SHA_1_HEADER_NAME: &str = "x-amz-checksum-sha1";
|
||||
|
||||
@@ -41,6 +41,14 @@ pub const XXHASH_64_NAME: &str = "xxhash64";
|
||||
pub const XXHASH_128_NAME: &str = "xxhash128";
|
||||
pub const MD5_NAME: &str = "md5";
|
||||
|
||||
/// One of three deliberately separate checksum registries (backlog#1833):
|
||||
/// this enum owns the **streaming-hash algorithm registry**, including the
|
||||
/// RustFS extensions (sha512, xxhash3/64/128). The on-disk xl.meta bitset
|
||||
/// lives in `rustfs_rio::ChecksumType` (crates/rio/src/checksum.rs, varint
|
||||
/// bits are append-only), and the MinIO-port client keeps its own
|
||||
/// `ChecksumMode` (crates/ecstore/src/client/checksum.rs). When adding an
|
||||
/// algorithm, extend all three (or record why not) — they do not derive from
|
||||
/// each other.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
#[non_exhaustive]
|
||||
pub enum ChecksumAlgorithm {
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use 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)
|
||||
// }
|
||||
// }
|
||||
@@ -572,44 +572,3 @@ mod tests {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
pub mod bucket_stats;
|
||||
// pub mod error;
|
||||
pub mod globals;
|
||||
pub mod heal_channel;
|
||||
|
||||
@@ -353,6 +353,11 @@ pub const DEFAULT_OBS_TRACES_EXPORT_ENABLED: bool = true;
|
||||
/// Environment variable: RUSTFS_OBS_METRICS_EXPORT_ENABLED
|
||||
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
|
||||
/// It is used to enable or disable exporting logs
|
||||
/// Default value: true
|
||||
|
||||
@@ -137,6 +137,37 @@ 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_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
|
||||
// =============================================================================
|
||||
@@ -649,4 +680,22 @@ mod remote_version_state_tests {
|
||||
"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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,6 +81,9 @@ 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";
|
||||
/// Runtime env var controlling the transition worker count.
|
||||
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.
|
||||
pub const ENV_TRANSITION_WORKERS_ABSOLUTE_MAX: &str = "RUSTFS_ABSOLUTE_MAX_WORKERS";
|
||||
/// Runtime env var controlling the transition queue capacity.
|
||||
|
||||
@@ -44,6 +44,10 @@ 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_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_LOG_STDOUT_ENABLED: &str = "RUSTFS_OBS_LOG_STDOUT_ENABLED";
|
||||
pub const ENV_OBS_LOG_DIRECTORY: &str = "RUSTFS_OBS_LOG_DIRECTORY";
|
||||
@@ -141,6 +145,7 @@ mod tests {
|
||||
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_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
|
||||
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");
|
||||
|
||||
@@ -67,7 +67,10 @@ 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())
|
||||
}
|
||||
|
||||
fn capture_command_logs(command: &mut Command, log_path: Option<&str>) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
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(());
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
use crate::common::{RustFSTestEnvironment, init_logging, rustfs_binary_path};
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use aws_sdk_s3::types::{CompletedMultipartUpload, CompletedPart};
|
||||
use serial_test::serial;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
@@ -25,6 +26,15 @@ fn generate_compressible_data(size: usize) -> Vec<u8> {
|
||||
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> {
|
||||
let bucket_path = PathBuf::from(temp_dir).join(bucket);
|
||||
let mut part_files = Vec::new();
|
||||
@@ -55,9 +65,14 @@ async fn start_rustfs_with_compression(env: &mut RustFSTestEnvironment) -> Resul
|
||||
env.cleanup_existing_processes().await?;
|
||||
|
||||
let binary_path = rustfs_binary_path();
|
||||
let process = Command::new(&binary_path)
|
||||
// Route the child's stdout/stderr through the shared RUSTFS_E2E_LOG_DIR
|
||||
// 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_COMPRESSION_ENABLED", "true")
|
||||
.env("RUSTFS_COMPRESSION_MULTIPART_ENABLED", "true")
|
||||
.args([
|
||||
"--address",
|
||||
&env.address,
|
||||
@@ -66,8 +81,9 @@ async fn start_rustfs_with_compression(env: &mut RustFSTestEnvironment) -> Resul
|
||||
"--secret-key",
|
||||
&env.secret_key,
|
||||
&env.temp_dir,
|
||||
])
|
||||
.spawn()?;
|
||||
]);
|
||||
crate::common::capture_command_logs(&mut command, env.capture_log_path.as_deref())?;
|
||||
let process = command.spawn()?;
|
||||
|
||||
env.process = Some(process);
|
||||
|
||||
@@ -154,3 +170,647 @@ async fn test_compression_roundtrip() -> Result<(), Box<dyn std::error::Error +
|
||||
env.stop_server();
|
||||
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(())
|
||||
}
|
||||
|
||||
@@ -1828,33 +1828,36 @@ async fn four_node_compressed_inline_fallback() -> TestResult {
|
||||
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]
|
||||
#[serial]
|
||||
async fn four_node_multipart_ignores_disk_compression_fallback() -> TestResult {
|
||||
async fn four_node_multipart_disk_compression_roundtrip() -> TestResult {
|
||||
init_logging();
|
||||
|
||||
let collector = OtlpMetricCollector::start().await?;
|
||||
let mut cluster = RustFSTestClusterEnvironment::new(4).await?;
|
||||
configure_reader_metric_cluster(&mut cluster, &collector);
|
||||
cluster.set_env("RUSTFS_COMPRESSION_ENABLED", "true");
|
||||
cluster.set_env("RUSTFS_COMPRESSION_MULTIPART_ENABLED", "true");
|
||||
cluster.start().await?;
|
||||
|
||||
let bucket = "inline-multipart-compression-fallback";
|
||||
let bucket = "inline-multipart-compression-roundtrip";
|
||||
cluster.create_test_bucket(bucket).await?;
|
||||
let client = cluster.create_s3_client(0)?;
|
||||
let key = "multipart/compression-disabled.txt";
|
||||
let key = "multipart/compressed.txt";
|
||||
let (body, second_part, etag) = put_two_part_multipart(&client, bucket, key).await?;
|
||||
|
||||
assert_reader_path(
|
||||
&collector,
|
||||
&client,
|
||||
ReaderPathExpectation::for_class(ReaderObject::new(bucket, key, &body, etag.as_deref(), None), LEGACY_DUPLEX, MULTIPART),
|
||||
ReaderPathExpectation::for_class(ReaderObject::new(bucket, key, &body, etag.as_deref(), None), LEGACY_DUPLEX, COMPRESSED),
|
||||
)
|
||||
.await?;
|
||||
assert_part_number_reader_path(
|
||||
&collector,
|
||||
&client,
|
||||
PartNumberReaderPathExpectation::new(bucket, key, &second_part, body.len(), MULTIPART, LEGACY_DUPLEX),
|
||||
PartNumberReaderPathExpectation::new(bucket, key, &second_part, body.len(), COMPRESSED, LEGACY_DUPLEX),
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -1871,6 +1874,7 @@ async fn four_node_mixed_msgpack_compat_mode_preserves_fallback_controls() -> Te
|
||||
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_COMPRESSION_ENABLED", "true");
|
||||
cluster.set_env("RUSTFS_COMPRESSION_MULTIPART_ENABLED", "true");
|
||||
configure_mixed_msgpack_cluster(&mut cluster, &collector)?;
|
||||
cluster.start().await?;
|
||||
|
||||
@@ -1890,14 +1894,21 @@ async fn four_node_mixed_msgpack_compat_mode_preserves_fallback_controls() -> Te
|
||||
ReaderPathExpectation::for_class(
|
||||
ReaderObject::new(bucket, multipart_key, &multipart_body, multipart_etag.as_deref(), None),
|
||||
LEGACY_DUPLEX,
|
||||
MULTIPART,
|
||||
COMPRESSED,
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
assert_part_number_reader_path(
|
||||
&collector,
|
||||
&client,
|
||||
PartNumberReaderPathExpectation::new(bucket, multipart_key, &second_part, multipart_body.len(), MULTIPART, LEGACY_DUPLEX),
|
||||
PartNumberReaderPathExpectation::new(
|
||||
bucket,
|
||||
multipart_key,
|
||||
&second_part,
|
||||
multipart_body.len(),
|
||||
COMPRESSED,
|
||||
LEGACY_DUPLEX,
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
assert_msgpack_decode_observed(&collector, &decode_before).await?;
|
||||
@@ -2353,7 +2364,11 @@ async fn four_node_mixed_msgpack_compat_mode_preserves_fallback_controls_during_
|
||||
hot_client.create_bucket().bucket(bucket).send().await?;
|
||||
put_lifecycle_with_transition_retry(&hot_client, bucket, &tier_name).await?;
|
||||
|
||||
let key = "transition/mixed-multipart.bin";
|
||||
// `.zip` sits on the disk-compression exclusion list: this test pins
|
||||
// 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?;
|
||||
wait_for_transition(&hot_client, bucket, key, &tier_name).await?;
|
||||
assert!(
|
||||
|
||||
@@ -0,0 +1,611 @@
|
||||
// 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(())
|
||||
}
|
||||
@@ -59,3 +59,6 @@ mod configured_roundtrip_test;
|
||||
|
||||
#[cfg(test)]
|
||||
mod kms_authorization_negative_matrix_test;
|
||||
|
||||
#[cfg(test)]
|
||||
mod kms_ilm_sse_kms_test;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2854,7 +2854,7 @@ pub(crate) mod cmptst_30 {
|
||||
result
|
||||
}
|
||||
|
||||
#[ignore]
|
||||
#[ignore = "timing-sensitive backend-pressure latency probe; run explicitly with --ignored"]
|
||||
#[tokio::test]
|
||||
async fn regression() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
crate::common::init_logging();
|
||||
|
||||
@@ -2401,15 +2401,20 @@ async fn wait_for_site_replication_info<F>(
|
||||
where
|
||||
F: Fn(&SiteReplicationInfo) -> bool,
|
||||
{
|
||||
for _ in 0..40 {
|
||||
// 30s to match wait_for_replication_state: the three-node site tests run
|
||||
// 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?;
|
||||
if predicate(&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;
|
||||
}
|
||||
|
||||
Err(format!("site replication info did not reach expected state on {}", env.address).into())
|
||||
}
|
||||
|
||||
async fn wait_for_site_replication_status<F>(
|
||||
@@ -2420,15 +2425,19 @@ async fn wait_for_site_replication_status<F>(
|
||||
where
|
||||
F: Fn(&SRStatusInfo) -> bool,
|
||||
{
|
||||
for _ in 0..40 {
|
||||
// Same 30s ceiling as wait_for_site_replication_info: the status probes
|
||||
// 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?;
|
||||
if predicate(&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;
|
||||
}
|
||||
|
||||
Err(format!("site replication status did not reach expected state on {}", env.address).into())
|
||||
}
|
||||
|
||||
async fn wait_for_replication_reset_target<F>(
|
||||
@@ -4235,37 +4244,49 @@ async fn test_bucket_replication_acceptance_matrix_local_dual_targets() -> TestR
|
||||
"tag rule with disabled delete-marker replication created a marker: {tagged_state:?}"
|
||||
);
|
||||
|
||||
set_bucket_versioning(&source_env, source_bucket, BucketVersioningStatus::Suspended).await?;
|
||||
set_bucket_versioning(&target_env_a, target_bucket_a, BucketVersioningStatus::Suspended).await?;
|
||||
let null_put = source_client
|
||||
// AWS S3 and MinIO both reject suspending versioning on a bucket that
|
||||
// carries a replication configuration (InvalidBucketState): suspension
|
||||
// would mint null versions that versioned replication can never converge.
|
||||
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()
|
||||
.bucket(source_bucket)
|
||||
.key("prefix/null.txt")
|
||||
.body(ByteStream::from_static(b"null version"))
|
||||
.key("prefix/after-rejected-suspend.txt")
|
||||
.body(ByteStream::from_static(b"still replicating"))
|
||||
.send()
|
||||
.await?;
|
||||
assert!(null_put.version_id().is_none(), "suspended source PUT must create a null version");
|
||||
wait_for_replication_state(&target_client_a, target_bucket_a, "null version did not replicate", |state| {
|
||||
state
|
||||
.iter()
|
||||
.any(|entry| entry.key == "prefix/null.txt" && entry.version_id == "null" && !entry.delete_marker)
|
||||
})
|
||||
.await?;
|
||||
let null_delete = source_client
|
||||
.delete_object()
|
||||
.bucket(source_bucket)
|
||||
.key("prefix/null.txt")
|
||||
.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)
|
||||
})
|
||||
let post_reject_version_id = post_reject_put
|
||||
.version_id()
|
||||
.ok_or("PUT after rejected suspension omitted version ID")?
|
||||
.to_string();
|
||||
wait_for_replication_state(
|
||||
&target_client_a,
|
||||
target_bucket_a,
|
||||
"replication stopped after rejected versioning suspension",
|
||||
|state| {
|
||||
state
|
||||
.iter()
|
||||
.any(|entry| entry.key == "prefix/after-rejected-suspend.txt" && entry.version_id == post_reject_version_id)
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -32,6 +32,11 @@ workspace = true
|
||||
|
||||
[features]
|
||||
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"]
|
||||
hotpath = [
|
||||
"hotpath/hotpath",
|
||||
|
||||
@@ -61,9 +61,11 @@ pub mod bucket {
|
||||
delete_manual_transition_scope_admission_if_current, load_manual_transition_job_record,
|
||||
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_scope_key, persist_manual_transition_job_progress, renew_manual_transition_job_lease,
|
||||
request_manual_transition_job_cancel, save_manual_transition_job_record,
|
||||
save_manual_transition_job_record_if_current, save_manual_transition_scope_admission_if_absent,
|
||||
manual_transition_scope_key, persist_manual_transition_job_progress,
|
||||
persist_manual_transition_job_progress_if_owned, renew_manual_transition_job_lease,
|
||||
renew_manual_transition_job_lease_if_owned, request_manual_transition_job_cancel,
|
||||
save_manual_transition_job_record, save_manual_transition_job_record_if_current,
|
||||
save_manual_transition_scope_admission_if_absent, update_manual_transition_job_record,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -129,6 +131,8 @@ pub mod bucket {
|
||||
}
|
||||
|
||||
pub mod metadata_sys {
|
||||
#[cfg(feature = "test-util")]
|
||||
pub use crate::bucket::metadata_sys::ConfigWriteLockProbe;
|
||||
pub use crate::bucket::metadata_sys::{
|
||||
BucketMetadataMutationGuard, BucketMetadataSys, ObjectLockConfigState, acquire_bucket_metadata_transaction_lock,
|
||||
capture_bucket_metadata_incarnation, delete, delete_if_incarnation, get, get_accelerate_config, get_bucket_policy,
|
||||
@@ -138,7 +142,7 @@ pub mod bucket {
|
||||
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,
|
||||
set_bucket_metadata, update, update_bucket_targets_under_transaction_lock, update_config_with, update_if_incarnation,
|
||||
update_under_transaction_lock,
|
||||
update_quota_if_incarnation, update_under_transaction_lock,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -274,7 +278,9 @@ pub mod cluster {
|
||||
}
|
||||
|
||||
pub mod compression {
|
||||
pub use crate::io_support::compress::{MIN_DISK_COMPRESSIBLE_SIZE, is_disk_compressible, is_disk_compression_enabled};
|
||||
pub use crate::io_support::compress::{
|
||||
MIN_DISK_COMPRESSIBLE_SIZE, is_disk_compressible, is_disk_compression_enabled, is_multipart_disk_compression_enabled,
|
||||
};
|
||||
}
|
||||
|
||||
pub mod config {
|
||||
@@ -314,7 +320,7 @@ pub mod data_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,
|
||||
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,
|
||||
load_compression_total_from_memory, load_data_usage_from_backend, load_data_usage_from_backend_cached, quota_object_size,
|
||||
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,
|
||||
refresh_bucket_usage_from_object_layer, refresh_versioned_bucket_usage_from_object_layer,
|
||||
@@ -344,7 +350,7 @@ pub mod disk {
|
||||
}
|
||||
|
||||
pub mod error {
|
||||
pub use crate::disk::error::{BitrotErrorType, DiskError, Error, FileAccessDeniedWithContext, Result};
|
||||
pub use crate::disk::error::{DiskError, Error, FileAccessDeniedWithContext, Result};
|
||||
}
|
||||
|
||||
pub mod error_reduce {
|
||||
@@ -401,8 +407,11 @@ pub mod metrics {
|
||||
}
|
||||
|
||||
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::{
|
||||
NotificationPeerErr, NotificationSys, get_global_notification_sys, new_global_notification_sys,
|
||||
CrossPoolFenceFleetProofToken, NotificationPeerErr, NotificationSys, acquire_cross_pool_fence_fleet_proof,
|
||||
cross_pool_fence_fleet_proof_matches, get_global_notification_sys, new_global_notification_sys,
|
||||
start_remote_version_state_fleet_probe,
|
||||
};
|
||||
}
|
||||
@@ -462,7 +471,8 @@ pub mod set_disk {
|
||||
|
||||
#[cfg(feature = "test-util")]
|
||||
pub mod test_util {
|
||||
pub use crate::set_disk::{PutObjectCommitBarrier, PutObjectCommitPause};
|
||||
pub use crate::bucket::quota::reservation::fail_next_quota_ledger_save_for_test;
|
||||
pub use crate::set_disk::{MultipartCommitBarrier, MultipartCommitPause, PutObjectCommitBarrier, PutObjectCommitPause};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -86,6 +86,21 @@ where
|
||||
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<()>
|
||||
where
|
||||
S: ObjectOperations<
|
||||
|
||||
@@ -45,6 +45,104 @@ const MANUAL_TRANSITION_JOB_LEASE_SECONDS: i128 = 60;
|
||||
const MANUAL_TRANSITION_LEGACY_SCOPE_SCAN_LIMIT: i32 = 1000;
|
||||
const MANUAL_TRANSITION_TASK_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 {
|
||||
!*value
|
||||
@@ -148,7 +246,6 @@ impl ManualTransitionJobRecord {
|
||||
|
||||
pub fn fail(&mut self, error: impl Into<String>) {
|
||||
self.state = ManualTransitionJobState::Failed;
|
||||
self.report.tier_failure = self.report.tier_failure.saturating_add(1);
|
||||
self.error = Some(error.into());
|
||||
self.mark_updated_terminal();
|
||||
}
|
||||
@@ -1040,7 +1137,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 data = job.encode().map_err(manual_transition_job_store_error)?;
|
||||
config_boundary::save_config_with_opts(
|
||||
config_boundary::save_config_with_opts_quiet(
|
||||
api,
|
||||
&object,
|
||||
data,
|
||||
@@ -1056,6 +1153,54 @@ pub async fn save_manual_transition_job_record_if_current(
|
||||
.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(
|
||||
api: Arc<ECStore>,
|
||||
record: &ManualTransitionWorkerResultRecord,
|
||||
@@ -1314,99 +1459,113 @@ pub async fn reconcile_manual_transition_worker_results(
|
||||
api: Arc<ECStore>,
|
||||
job_id: Uuid,
|
||||
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> {
|
||||
let task_stats = match scan_manual_transition_task_journal(api.clone(), job_id).await? {
|
||||
ManualTransitionTaskJournal::Stats(stats) => stats,
|
||||
ManualTransitionTaskJournal::Corrupt(error) => {
|
||||
return mark_manual_transition_job_unknown_for_task_journal_error(api, job_id, error, queue_snapshot).await;
|
||||
return mark_manual_transition_job_unknown_for_task_journal_error(
|
||||
api,
|
||||
job_id,
|
||||
expected_lease_id,
|
||||
error,
|
||||
queue_snapshot,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
};
|
||||
let stats = match scan_manual_transition_worker_result_journal(api.clone(), job_id).await? {
|
||||
ManualTransitionWorkerResultJournal::Stats(stats) => stats,
|
||||
ManualTransitionWorkerResultJournal::Corrupt(error) => {
|
||||
return mark_manual_transition_job_unknown_for_worker_result_journal_error(api, job_id, error, queue_snapshot).await;
|
||||
return mark_manual_transition_job_unknown_for_worker_result_journal_error(
|
||||
api,
|
||||
job_id,
|
||||
expected_lease_id,
|
||||
error,
|
||||
queue_snapshot,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
};
|
||||
for _ in 0..4 {
|
||||
let (mut record, etag) = load_manual_transition_job_record_with_etag(api.clone(), job_id).await?;
|
||||
let changed = record.apply_worker_result_counts(
|
||||
let mut changed = false;
|
||||
let record = update_manual_transition_job_record(api.clone(), job_id, expected_lease_id, |record| {
|
||||
let counts_changed = record.apply_worker_result_counts(
|
||||
stats.stats.completed,
|
||||
stats.stats.failed,
|
||||
&stats.stats.tier_failure_by_reason,
|
||||
task_stats.queued,
|
||||
queue_snapshot,
|
||||
);
|
||||
if !changed {
|
||||
return Ok(record);
|
||||
}
|
||||
match save_manual_transition_job_record_if_current(api.clone(), &record, &etag).await {
|
||||
Ok(()) => {
|
||||
if record.is_terminal() {
|
||||
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),
|
||||
}
|
||||
let became_unknown = mark_missing_results_unknown && record.mark_unknown_if_worker_results_lost(queue_snapshot);
|
||||
changed = counts_changed || became_unknown;
|
||||
changed
|
||||
})
|
||||
.await?;
|
||||
if !changed {
|
||||
return Ok(record);
|
||||
}
|
||||
Err(Error::PreconditionFailed)
|
||||
if record.is_terminal() {
|
||||
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(
|
||||
api: Arc<ECStore>,
|
||||
job_id: Uuid,
|
||||
expected_lease_id: Option<Uuid>,
|
||||
error: String,
|
||||
queue_snapshot: ManualTransitionQueueSnapshot,
|
||||
) -> EcstoreResult<ManualTransitionJobRecord> {
|
||||
for _ in 0..4 {
|
||||
let (mut record, etag) = load_manual_transition_job_record_with_etag(api.clone(), job_id).await?;
|
||||
if !record.mark_unknown_for_task_journal_error(error.clone(), queue_snapshot) {
|
||||
return Ok(record);
|
||||
}
|
||||
match save_manual_transition_job_record_if_current(api.clone(), &record, &etag).await {
|
||||
Ok(()) => {
|
||||
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),
|
||||
}
|
||||
let mut changed = false;
|
||||
let record = update_manual_transition_job_record(api.clone(), job_id, expected_lease_id, |record| {
|
||||
changed = record.mark_unknown_for_task_journal_error(error.clone(), queue_snapshot);
|
||||
changed
|
||||
})
|
||||
.await?;
|
||||
if changed && record.is_terminal() {
|
||||
delete_manual_transition_scope_admission_if_current(api, &record.scope_key, record.job_id, record.lease_id).await?;
|
||||
}
|
||||
Err(Error::PreconditionFailed)
|
||||
Ok(record)
|
||||
}
|
||||
|
||||
async fn mark_manual_transition_job_unknown_for_worker_result_journal_error(
|
||||
api: Arc<ECStore>,
|
||||
job_id: Uuid,
|
||||
expected_lease_id: Option<Uuid>,
|
||||
error: String,
|
||||
queue_snapshot: ManualTransitionQueueSnapshot,
|
||||
) -> EcstoreResult<ManualTransitionJobRecord> {
|
||||
for _ in 0..4 {
|
||||
let (mut record, etag) = load_manual_transition_job_record_with_etag(api.clone(), job_id).await?;
|
||||
if !record.mark_unknown_for_worker_result_journal_error(error.clone(), queue_snapshot) {
|
||||
return Ok(record);
|
||||
}
|
||||
match save_manual_transition_job_record_if_current(api.clone(), &record, &etag).await {
|
||||
Ok(()) => {
|
||||
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),
|
||||
}
|
||||
let mut changed = false;
|
||||
let record = update_manual_transition_job_record(api.clone(), job_id, expected_lease_id, |record| {
|
||||
changed = record.mark_unknown_for_worker_result_journal_error(error.clone(), queue_snapshot);
|
||||
changed
|
||||
})
|
||||
.await?;
|
||||
if changed && record.is_terminal() {
|
||||
delete_manual_transition_scope_admission_if_current(api, &record.scope_key, record.job_id, record.lease_id).await?;
|
||||
}
|
||||
Err(Error::PreconditionFailed)
|
||||
Ok(record)
|
||||
}
|
||||
|
||||
pub async fn save_manual_transition_scope_admission_if_absent(
|
||||
@@ -1603,19 +1762,14 @@ 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> {
|
||||
for _ in 0..4 {
|
||||
let (mut record, etag) = load_manual_transition_job_record_with_etag(api.clone(), job_id).await?;
|
||||
update_manual_transition_job_record(api, job_id, None, |record| {
|
||||
if record.is_terminal() || record.cancel_requested {
|
||||
return Ok(record);
|
||||
return false;
|
||||
}
|
||||
record.mark_cancel_requested();
|
||||
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)
|
||||
true
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn persist_manual_transition_job_progress(
|
||||
@@ -1624,10 +1778,39 @@ pub async fn persist_manual_transition_job_progress(
|
||||
report: &ManualTransitionRunReport,
|
||||
queue_snapshot: ManualTransitionQueueSnapshot,
|
||||
) -> EcstoreResult<ManualTransitionJobRecord> {
|
||||
let (mut record, etag) = load_manual_transition_job_record_with_etag(api.clone(), job_id).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?;
|
||||
let current = 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
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -1661,25 +1844,58 @@ pub async fn renew_manual_transition_job_lease(
|
||||
job_id: Uuid,
|
||||
queue_snapshot: ManualTransitionQueueSnapshot,
|
||||
) -> EcstoreResult<ManualTransitionJobRecord> {
|
||||
let (mut record, mut etag) = load_manual_transition_job_record_with_etag(api.clone(), job_id).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?;
|
||||
if record.is_terminal() || !record.report.worker_transition_pending() {
|
||||
return Ok(record);
|
||||
let current = 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
|
||||
}
|
||||
|
||||
pub async fn renew_manual_transition_job_lease_if_owned(
|
||||
api: Arc<ECStore>,
|
||||
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);
|
||||
}
|
||||
save_manual_transition_job_record_if_current(api.clone(), &record, &etag).await?;
|
||||
if became_terminal {
|
||||
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?;
|
||||
}
|
||||
true
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
if record.is_terminal() {
|
||||
delete_manual_transition_scope_admission_if_current(api, &record.scope_key, record.job_id, record.lease_id).await?;
|
||||
} else if record.state == ManualTransitionJobState::Running {
|
||||
renew_manual_transition_scope_admission_from_job(api, &record).await?;
|
||||
}
|
||||
Ok(record)
|
||||
}
|
||||
@@ -1688,15 +1904,31 @@ async fn renew_manual_transition_scope_admission_from_job(
|
||||
api: Arc<ECStore>,
|
||||
record: &ManualTransitionJobRecord,
|
||||
) -> EcstoreResult<()> {
|
||||
if let Ok((admission, admission_etag)) =
|
||||
load_manual_transition_scope_admission_with_etag(api.clone(), &record.scope_key).await
|
||||
&& admission.job_id == record.job_id
|
||||
&& admission.lease_id == record.lease_id
|
||||
{
|
||||
let renewed_admission = ManualTransitionScopeAdmission::from_job(record);
|
||||
save_manual_transition_scope_admission_if_current(api, &renewed_admission, &admission_etag).await?;
|
||||
for _ in 0..MANUAL_TRANSITION_JOB_CAS_RETRIES {
|
||||
let (admission, admission_etag) =
|
||||
match load_manual_transition_scope_admission_with_etag(api.clone(), &record.scope_key).await {
|
||||
Ok(admission) => admission,
|
||||
Err(Error::ConfigNotFound) => return Ok(()),
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
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),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
Err(Error::PreconditionFailed)
|
||||
}
|
||||
|
||||
pub async fn delete_manual_transition_scope_admission_if_current(
|
||||
@@ -2386,14 +2618,14 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_transition_job_record_failure_counts_tier_failure() {
|
||||
fn manual_transition_job_record_control_plane_failure_does_not_count_tier_failure() {
|
||||
let options = ManualTransitionRunOptions::default();
|
||||
let mut record = ManualTransitionJobRecord::new(Uuid::new_v4(), "bucket", &options, TEST_OWNER);
|
||||
|
||||
record.fail("missing tier");
|
||||
|
||||
assert_eq!(record.state, ManualTransitionJobState::Failed);
|
||||
assert_eq!(record.report.tier_failure, 1);
|
||||
assert_eq!(record.report.tier_failure, 0);
|
||||
assert_eq!(record.error.as_deref(), Some("missing tier"));
|
||||
}
|
||||
|
||||
|
||||
@@ -50,6 +50,72 @@ use uuid::Uuid;
|
||||
|
||||
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)]
|
||||
enum MetadataLoadMode {
|
||||
Initial,
|
||||
@@ -590,6 +656,31 @@ pub async fn update_under_transaction_lock(
|
||||
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(
|
||||
guard: &BucketMetadataMutationGuard,
|
||||
bucket: &str,
|
||||
@@ -734,7 +825,26 @@ async fn acquire_transaction_lock_with_sys(
|
||||
let lock = api
|
||||
.new_ns_lock(RUSTFS_META_BUCKET, &bucket_metadata_transaction_lock_key(bucket))
|
||||
.await?;
|
||||
Ok(lock.get_write_lock(crate::set_disk::get_lock_acquire_timeout()).await?)
|
||||
let acquire = lock.get_write_lock(crate::set_disk::get_lock_acquire_timeout());
|
||||
#[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
|
||||
@@ -889,6 +999,37 @@ 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)> {
|
||||
let bucket_meta_sys_lock = get_bucket_metadata_sys()?;
|
||||
let bucket_meta_sys = bucket_meta_sys_lock.read().await;
|
||||
|
||||
@@ -52,6 +52,7 @@ impl QuotaChecker {
|
||||
) -> Result<QuotaCheckResult, QuotaError> {
|
||||
let start_time = Instant::now();
|
||||
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
|
||||
let quota_limit = match quota_config.quota {
|
||||
@@ -67,6 +68,7 @@ impl QuotaChecker {
|
||||
quota_limit: None,
|
||||
operation_size,
|
||||
remaining: None,
|
||||
uses_durable_reservations,
|
||||
});
|
||||
}
|
||||
Some(q) => q,
|
||||
@@ -74,14 +76,17 @@ impl QuotaChecker {
|
||||
|
||||
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 {
|
||||
QuotaOperation::PutObject | QuotaOperation::PostObject | QuotaOperation::CopyObject => current_usage + operation_size,
|
||||
QuotaOperation::PutObject | QuotaOperation::PostObject | QuotaOperation::CopyObject => {
|
||||
current_usage.saturating_add(admission_size)
|
||||
}
|
||||
QuotaOperation::DeleteObject => current_usage.saturating_sub(operation_size),
|
||||
};
|
||||
|
||||
let allowed = match operation {
|
||||
QuotaOperation::PutObject | QuotaOperation::PostObject | QuotaOperation::CopyObject => {
|
||||
quota_config.check_operation_allowed(current_usage, operation_size)
|
||||
quota_config.check_operation_allowed(current_usage, admission_size)
|
||||
}
|
||||
QuotaOperation::DeleteObject => true,
|
||||
};
|
||||
@@ -105,6 +110,7 @@ impl QuotaChecker {
|
||||
quota_limit: Some(quota_limit),
|
||||
operation_size,
|
||||
remaining,
|
||||
uses_durable_reservations,
|
||||
};
|
||||
|
||||
let duration = start_time.elapsed();
|
||||
@@ -158,6 +164,26 @@ impl QuotaChecker {
|
||||
.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("a).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(
|
||||
&mut self,
|
||||
bucket: &str,
|
||||
@@ -355,6 +381,7 @@ mod tests {
|
||||
quota_limit: None,
|
||||
operation_size: 1024,
|
||||
remaining: None,
|
||||
uses_durable_reservations: false,
|
||||
};
|
||||
|
||||
assert!(result.allowed);
|
||||
@@ -378,4 +405,13 @@ mod tests {
|
||||
let allowed = quota.check_operation_allowed(512, 1024);
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,40 +13,100 @@
|
||||
// limitations under the License.
|
||||
|
||||
pub mod checker;
|
||||
pub(crate) mod reservation;
|
||||
|
||||
use crate::error::Result;
|
||||
use rustfs_config::{
|
||||
QUOTA_API_PATH, QUOTA_EXCEEDED_ERROR_CODE, QUOTA_INTERNAL_ERROR_CODE, QUOTA_INVALID_CONFIG_ERROR_CODE,
|
||||
QUOTA_NOT_FOUND_ERROR_CODE,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as _};
|
||||
use thiserror::Error;
|
||||
use time::OffsetDateTime;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
pub enum QuotaType {
|
||||
/// Hard quota: reject immediately when exceeded
|
||||
/// Hard quota accounting.
|
||||
#[default]
|
||||
#[serde(alias = "HARD", alias = "hard")]
|
||||
Hard,
|
||||
}
|
||||
|
||||
pub(crate) const QUOTA_RESERVATION_PROTOCOL_V1: u32 = 1;
|
||||
|
||||
/// Bucket quota configuration. quota_type defaults to Hard when omitted.
|
||||
#[derive(Debug, Deserialize, Serialize, Default, Clone, PartialEq)]
|
||||
#[derive(Debug, Default, Clone, PartialEq)]
|
||||
pub struct BucketQuota {
|
||||
#[serde(default)]
|
||||
pub quota: Option<u64>,
|
||||
/// Defaults to Hard when missing.
|
||||
#[serde(default)]
|
||||
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)
|
||||
#[serde(default, with = "time::serde::rfc3339::option")]
|
||||
pub created_at: Option<OffsetDateTime>,
|
||||
/// Accept updated_at for compatibility; not used.
|
||||
#[serde(default, with = "time::serde::rfc3339::option", skip_serializing_if = "Option::is_none")]
|
||||
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")]
|
||||
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 {
|
||||
/// Serialize to JSON bytes. Same format as parse_all_configs.
|
||||
pub fn marshal_msg(&self) -> Result<Vec<u8>> {
|
||||
@@ -63,6 +123,7 @@ impl BucketQuota {
|
||||
Self {
|
||||
quota,
|
||||
quota_type: QuotaType::Hard,
|
||||
reservation_protocol: quota.map(|_| QUOTA_RESERVATION_PROTOCOL_V1),
|
||||
created_at: Some(now),
|
||||
updated_at: None,
|
||||
}
|
||||
@@ -72,7 +133,19 @@ impl BucketQuota {
|
||||
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 {
|
||||
if operation_size == 0 {
|
||||
return true;
|
||||
}
|
||||
if let Some(quota_limit) = self.quota {
|
||||
current_usage.saturating_add(operation_size) <= quota_limit
|
||||
} else {
|
||||
@@ -94,6 +167,7 @@ pub struct QuotaCheckResult {
|
||||
pub quota_limit: Option<u64>,
|
||||
pub operation_size: u64,
|
||||
pub remaining: Option<u64>,
|
||||
pub uses_durable_reservations: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -210,7 +284,59 @@ mod tests {
|
||||
let buf = q.marshal_msg().expect("marshal");
|
||||
let restored = BucketQuota::unmarshal(&buf).expect("unmarshal");
|
||||
assert_eq!(q.quota, restored.quota);
|
||||
assert_eq!(q.quota_type, restored.quota_type);
|
||||
assert_eq!(restored.quota_type, QuotaType::Hard);
|
||||
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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -27,12 +27,24 @@ use crate::client::utils::base64_decode;
|
||||
use crate::client::utils::base64_encode;
|
||||
use crate::client::{api_put_object::PutObjectOptions, api_s3_datatypes::ObjectPart};
|
||||
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::{
|
||||
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};
|
||||
|
||||
/// 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)]
|
||||
#[enumset(repr = "u8")]
|
||||
pub enum ChecksumMode {
|
||||
@@ -57,8 +69,6 @@ lazy_static! {
|
||||
static ref C_ChecksumFullObjectCRC32C: EnumSet<ChecksumMode> =
|
||||
enum_set!(ChecksumMode::ChecksumCRC32C | ChecksumMode::ChecksumFullObject);
|
||||
}
|
||||
const AMZ_CHECKSUM_CRC64NVME: &str = "x-amz-checksum-crc64nvme";
|
||||
|
||||
impl ChecksumMode {
|
||||
//pub const CRC64_NVME_POLYNOMIAL: i64 = 0xad93d23594c93659;
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
// limitations under the License.
|
||||
|
||||
// #730: cluster/RPC migration leaves transport capabilities staged for upcoming owners.
|
||||
#![allow(dead_code)]
|
||||
|
||||
mod control_plane;
|
||||
pub(crate) mod rpc;
|
||||
|
||||
@@ -256,6 +256,7 @@ 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 {
|
||||
PEER_REPLAY_STATES
|
||||
.lock()
|
||||
|
||||
@@ -31,7 +31,7 @@ use rustfs_config::{
|
||||
DEFAULT_INTERNODE_DATA_TRANSPORT, ENV_RUSTFS_INTERNODE_DATA_TRANSPORT, INTERNODE_DATA_TRANSPORT_TCP,
|
||||
KNOWN_INTERNODE_DATA_TRANSPORT_BACKENDS,
|
||||
};
|
||||
use rustfs_rio::{HttpReader, HttpWriter};
|
||||
use rustfs_rio::{ChunkReaderBox, HttpChunkReader, HttpReader, HttpWriter};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::HashMap;
|
||||
use std::future::Future;
|
||||
@@ -43,6 +43,10 @@ use tokio::io::{AsyncReadExt, AsyncWrite};
|
||||
use tokio::sync::OnceCell;
|
||||
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();
|
||||
|
||||
const READ_FILE_STREAM_PATH: &str = "/rustfs/rpc/read_file_stream";
|
||||
@@ -134,6 +138,10 @@ fn put_file_capability_status_is_legacy(status: u16) -> bool {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
|
||||
#[allow(
|
||||
dead_code,
|
||||
reason = "capability-negotiation seam; constructed only by transport test doubles (backlog#1823)"
|
||||
)]
|
||||
pub struct InternodeDataTransportCapabilities {
|
||||
/// Backend can open a streaming remote disk reader.
|
||||
pub streaming_read: bool,
|
||||
@@ -150,6 +158,10 @@ pub struct InternodeDataTransportCapabilities {
|
||||
}
|
||||
|
||||
impl InternodeDataTransportCapabilities {
|
||||
#[allow(
|
||||
dead_code,
|
||||
reason = "capability-negotiation seam; used by transport test doubles (backlog#1823)"
|
||||
)]
|
||||
pub const fn tcp_http() -> Self {
|
||||
Self {
|
||||
streaming_read: true,
|
||||
@@ -221,6 +233,11 @@ pub struct NsScannerCapabilityRequest {
|
||||
#[async_trait]
|
||||
pub trait InternodeDataTransport: Send + Sync + std::fmt::Debug {
|
||||
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_walk_dir(&self, request: WalkDirStreamRequest) -> Result<FileReader>;
|
||||
async fn open_ns_scanner(&self, _request: NsScannerStreamRequest) -> Result<FileReader> {
|
||||
@@ -229,7 +246,12 @@ pub trait InternodeDataTransport: Send + Sync + std::fmt::Debug {
|
||||
async fn probe_ns_scanner(&self, _request: NsScannerCapabilityRequest) -> Result<Uuid> {
|
||||
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;
|
||||
#[allow(dead_code, reason = "unused capability-negotiation facet (backlog#1823)")]
|
||||
fn capabilities(&self) -> InternodeDataTransportCapabilities;
|
||||
}
|
||||
|
||||
@@ -247,6 +269,15 @@ 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> {
|
||||
let server_epoch = self.put_file_auth_capability(&request.endpoint).await?;
|
||||
let nonce = server_epoch.map(|_| Uuid::new_v4());
|
||||
@@ -656,6 +687,10 @@ 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>> {
|
||||
build_internode_data_transport_result(configured_transport).map_err(Error::other)
|
||||
}
|
||||
|
||||
@@ -248,6 +248,16 @@ fn decode_remote_version_state_capability(expected_member: &str, result: &[u8])
|
||||
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)]
|
||||
pub struct PeerLiveEventsBatch {
|
||||
pub events: Vec<u8>,
|
||||
@@ -1288,6 +1298,16 @@ impl PeerRestClient {
|
||||
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<()> {
|
||||
self.finalize_result(
|
||||
async {
|
||||
@@ -2738,6 +2758,24 @@ mod tests {
|
||||
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> {
|
||||
version: u32,
|
||||
phase: TierMutationRpcPhase,
|
||||
|
||||
@@ -854,7 +854,6 @@ impl PeerS3Client for LocalPeerS3Client {
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct RemotePeerS3Client {
|
||||
pub node: Option<Node>,
|
||||
pub pools: Option<Vec<usize>>,
|
||||
addr: String,
|
||||
/// Health tracker for connection monitoring
|
||||
@@ -886,7 +885,6 @@ impl RemotePeerS3Client {
|
||||
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 client = Self {
|
||||
node,
|
||||
pools,
|
||||
addr,
|
||||
health: Arc::new(DiskHealthTracker::new()),
|
||||
@@ -905,10 +903,6 @@ impl RemotePeerS3Client {
|
||||
.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
|
||||
fn start_health_monitoring(&self) {
|
||||
let health = Arc::clone(&self.health);
|
||||
@@ -1208,6 +1202,10 @@ 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> {
|
||||
let disks = clone_drives().await;
|
||||
heal_bucket_local_on_disks(bucket, opts, disks).await
|
||||
@@ -1404,6 +1402,10 @@ 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>> {
|
||||
runtime_sources::local_disk_entries().await
|
||||
}
|
||||
@@ -1585,15 +1587,7 @@ mod tests {
|
||||
}
|
||||
|
||||
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 {
|
||||
node: Some(node),
|
||||
pools: Some(vec![0]),
|
||||
addr: addr.to_string(),
|
||||
health: Arc::new(DiskHealthTracker::new()),
|
||||
|
||||
@@ -522,6 +522,33 @@ 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) {
|
||||
self.health.record_capacity_probe(total, used, free);
|
||||
}
|
||||
@@ -846,31 +873,49 @@ impl RemoteDisk {
|
||||
/// 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
|
||||
/// re-invoked per attempt, so it must be `Fn` (rebuild the request from borrowed inputs, do not
|
||||
/// move captured state out).
|
||||
/// move captured state out). Attempts and backoff share one total timeout budget.
|
||||
async fn execute_read_with_retry<T, F, Fut>(&self, op: &'static str, operation: F, timeout_duration: Duration) -> Result<T>
|
||||
where
|
||||
F: Fn() -> Fut,
|
||||
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 mut attempt = 0usize;
|
||||
loop {
|
||||
// Only the final attempt marks the disk faulty / evicts the channel. Earlier retries
|
||||
// ignore the failure, so a transient error cannot flip the disk into a faulty
|
||||
// short-circuit (which would defeat the retry) or over-count failures.
|
||||
let attempt_timeout = deadline
|
||||
.map(|deadline| deadline.saturating_duration_since(time::Instant::now()))
|
||||
.unwrap_or(Duration::ZERO);
|
||||
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 {
|
||||
FailureHealthAction::MarkFailure
|
||||
} else {
|
||||
FailureHealthAction::IgnoreFailure
|
||||
};
|
||||
match self
|
||||
.execute_with_timeout_for_op_and_health_action(op, &operation, timeout_duration, health_action)
|
||||
.execute_with_timeout_for_op_and_health_action(op, &operation, attempt_timeout, health_action)
|
||||
.await
|
||||
{
|
||||
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;
|
||||
let backoff = REMOTE_DISK_READ_RETRY_BASE_BACKOFF
|
||||
.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!(
|
||||
endpoint = %self.endpoint,
|
||||
addr = %self.addr,
|
||||
@@ -878,7 +923,17 @@ impl RemoteDisk {
|
||||
attempt,
|
||||
"retrying idempotent read-only RPC after transient network error"
|
||||
);
|
||||
tokio::time::sleep(backoff).await;
|
||||
if let Some(deadline) = deadline {
|
||||
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,
|
||||
}
|
||||
@@ -957,32 +1012,35 @@ impl RemoteDisk {
|
||||
operation_result
|
||||
}
|
||||
Err(_) => {
|
||||
// Timeout occurred, mark disk as potentially faulty
|
||||
counter!(
|
||||
"rustfs_drive_op_timeout_total",
|
||||
"endpoint" => self.endpoint.to_string(),
|
||||
"op" => op.to_string()
|
||||
)
|
||||
.increment(1);
|
||||
self.record_timeout(op, timeout_duration);
|
||||
if failure_health_action == FailureHealthAction::MarkFailure {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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>(
|
||||
&self,
|
||||
op: &'static str,
|
||||
@@ -1016,7 +1074,7 @@ impl RemoteDisk {
|
||||
}
|
||||
}
|
||||
|
||||
async fn mark_faulty_and_evict(&self, reason: &'static str) {
|
||||
fn mark_faulty(&self, reason: &'static str) -> bool {
|
||||
let previous_state = self.runtime_state();
|
||||
let transitioned_to_offline = self.mark_suspect_or_offline(reason);
|
||||
let state = self.runtime_state();
|
||||
@@ -1053,6 +1111,12 @@ impl RemoteDisk {
|
||||
"Remote disk marked suspect"
|
||||
);
|
||||
}
|
||||
}
|
||||
state != previous_state
|
||||
}
|
||||
|
||||
async fn mark_faulty_and_evict(&self, reason: &'static str) {
|
||||
if self.mark_faulty(reason) {
|
||||
counter!(
|
||||
"rustfs_drive_connection_evict_total",
|
||||
"endpoint" => self.endpoint.to_string(),
|
||||
@@ -1295,6 +1359,71 @@ fn validate_decoded_file_info(file_info: &FileInfo) -> Result<()> {
|
||||
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]
|
||||
impl DiskAPI for RemoteDisk {
|
||||
#[tracing::instrument(level = "trace", skip_all)]
|
||||
@@ -2068,7 +2197,7 @@ impl DiskAPI for RemoteDisk {
|
||||
|
||||
Ok(file_info)
|
||||
},
|
||||
get_max_timeout_duration(),
|
||||
get_drive_metadata_timeout(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -2220,58 +2349,8 @@ impl DiskAPI for RemoteDisk {
|
||||
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
|
||||
self.rename_data_borrowed(src_volume, src_path, &fi, dst_volume, dst_path)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tracing::instrument(level = "trace", skip_all)]
|
||||
@@ -2417,6 +2496,30 @@ impl DiskAPI for RemoteDisk {
|
||||
.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.
|
||||
/// The transport stream is collected into owned Bytes for caller sharing.
|
||||
#[tracing::instrument(level = "trace", skip_all)]
|
||||
@@ -5094,6 +5197,452 @@ 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]
|
||||
async fn test_execute_with_timeout_evicts_cached_connection() {
|
||||
let addr = "http://127.0.0.1:59991".to_string();
|
||||
@@ -5603,6 +6152,40 @@ mod tests {
|
||||
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]
|
||||
async fn delete_volume_bounds_the_wait_on_a_stalled_peer() {
|
||||
runtime_sources::ensure_test_rpc_secret();
|
||||
|
||||
@@ -48,10 +48,6 @@ impl RemoteClient {
|
||||
Self { addr: endpoint }
|
||||
}
|
||||
|
||||
pub fn from_url(url: url::Url) -> Self {
|
||||
Self { addr: url.to_string() }
|
||||
}
|
||||
|
||||
fn build_ping_request() -> PingRequest {
|
||||
let mut fbb = flatbuffers::FlatBufferBuilder::new();
|
||||
let payload = fbb.create_vector(b"health-check");
|
||||
|
||||
@@ -46,7 +46,6 @@ use rustfs_config::{
|
||||
SCANNER_SUB_SYS,
|
||||
};
|
||||
use rustfs_filemeta::FileInfo;
|
||||
use rustfs_utils::path::SLASH_SEPARATOR;
|
||||
use serde_json::{Map, Value};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::LazyLock;
|
||||
@@ -200,8 +199,6 @@ 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];
|
||||
|
||||
static CONFIG_BUCKET: LazyLock<String> = LazyLock::new(|| format!("{RUSTFS_META_BUCKET}{SLASH_SEPARATOR}{CONFIG_PREFIX}"));
|
||||
|
||||
type ServerConfigDecryptFn = crate::bucket::migration::LegacyBlobDecryptFn;
|
||||
|
||||
static SERVER_CONFIG_DECRYPT_FN: LazyLock<RwLock<Option<ServerConfigDecryptFn>>> = LazyLock::new(|| RwLock::new(None));
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
// limitations under the License.
|
||||
|
||||
// #730: configuration migration keeps legacy subsystem definitions available behind this module.
|
||||
#![allow(dead_code)]
|
||||
|
||||
mod audit;
|
||||
pub mod com;
|
||||
|
||||
@@ -101,6 +101,7 @@ const DEFAULT_RRS_STORAGE_CLASS: &str = "EC:1";
|
||||
const ZERO_SET_DRIVE_COUNT_ERROR: &str = "set drive count must be greater than zero";
|
||||
|
||||
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(|| {
|
||||
let kvs = vec![
|
||||
@@ -150,6 +151,8 @@ pub struct Config {
|
||||
optimize: Option<String>,
|
||||
inline_block: usize,
|
||||
initialized: bool,
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
inline_block_explicit: bool,
|
||||
#[serde(skip)]
|
||||
standard_parities: Vec<PoolParity>,
|
||||
#[serde(skip)]
|
||||
@@ -186,6 +189,10 @@ impl Config {
|
||||
/// A topology-bound lookup fails closed for unknown drive counts and for
|
||||
/// deserialized legacy configurations that have no pool topology. Legacy
|
||||
/// 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> {
|
||||
if !self.initialized {
|
||||
return None;
|
||||
@@ -233,17 +240,19 @@ impl Config {
|
||||
.map(|(pool_index, pool)| (pool_index, pool.drives_per_set))
|
||||
}
|
||||
|
||||
pub fn should_inline(&self, shard_size: i64, versioned: bool) -> bool {
|
||||
if shard_size < 0 {
|
||||
pub fn should_inline(&self, shard_size: i64, data_shards: usize, versioned: bool) -> bool {
|
||||
if shard_size < 0 || data_shards == 0 {
|
||||
return false;
|
||||
}
|
||||
|
||||
let shard_size = shard_size as usize;
|
||||
|
||||
let mut inline_block = DEFAULT_INLINE_BLOCK;
|
||||
if self.initialized {
|
||||
inline_block = self.inline_block;
|
||||
}
|
||||
// Keep the historical two-data-shard object budget while preventing
|
||||
// wider EC layouts from multiplying the maximum inline object size.
|
||||
let inline_block = if self.initialized && self.inline_block_explicit {
|
||||
self.inline_block
|
||||
} else {
|
||||
(DEFAULT_INLINE_OBJECT_BUDGET / data_shards).min(DEFAULT_INLINE_BLOCK)
|
||||
};
|
||||
|
||||
if versioned {
|
||||
shard_size <= inline_block / 8
|
||||
@@ -392,6 +401,7 @@ fn lookup_config_for_pools_with_env(
|
||||
}
|
||||
|
||||
let optimize = overrides.optimize;
|
||||
let inline_block_explicit = overrides.inline_block.is_some();
|
||||
let inline_block = if let Some(value) = overrides.inline_block {
|
||||
let block = value
|
||||
.parse::<bytesize::ByteSize>()
|
||||
@@ -424,6 +434,7 @@ fn lookup_config_for_pools_with_env(
|
||||
optimize,
|
||||
inline_block,
|
||||
initialized: true,
|
||||
inline_block_explicit,
|
||||
standard_parities,
|
||||
rrs_parities,
|
||||
})
|
||||
@@ -541,22 +552,26 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_inline_preserves_exact_default_shard_boundaries() {
|
||||
let config = Config::default();
|
||||
fn should_inline_scales_default_threshold_by_data_shards() {
|
||||
let config = lookup_config_for_pools_with_env(&KVS::new(), &[3, 12], no_env_overrides())
|
||||
.expect("default inline policy should resolve for EC2+1 and EC8+4");
|
||||
|
||||
for (case, shard_size, versioned, expected) in [
|
||||
("unversioned below", 128 * 1024 - 1, false, true),
|
||||
("unversioned exact", 128 * 1024, false, true),
|
||||
("unversioned above", 128 * 1024 + 1, false, false),
|
||||
("versioned below", 16 * 1024 - 1, true, true),
|
||||
("versioned exact", 16 * 1024, true, true),
|
||||
("versioned above", 16 * 1024 + 1, true, false),
|
||||
("negative", -1, false, false),
|
||||
for (case, shard_size, data_shards, versioned, expected) in [
|
||||
("EC2+1 unversioned exact", 128 * 1024, 2, false, true),
|
||||
("EC2+1 unversioned above", 128 * 1024 + 1, 2, false, false),
|
||||
("EC2+1 versioned exact", 16 * 1024, 2, true, true),
|
||||
("EC2+1 versioned above", 16 * 1024 + 1, 2, true, false),
|
||||
("EC8+4 unversioned exact", 32 * 1024, 8, false, true),
|
||||
("EC8+4 unversioned above", 32 * 1024 + 1, 8, false, false),
|
||||
("EC8+4 versioned exact", 4 * 1024, 8, true, true),
|
||||
("EC8+4 versioned above", 4 * 1024 + 1, 8, true, false),
|
||||
("negative", -1, 2, false, false),
|
||||
("zero data shards", 0, 0, false, false),
|
||||
] {
|
||||
assert_eq!(
|
||||
config.should_inline(shard_size, versioned),
|
||||
config.should_inline(shard_size, data_shards, versioned),
|
||||
expected,
|
||||
"{case}: shard_size={shard_size}, versioned={versioned}"
|
||||
"{case}: shard_size={shard_size}, data_shards={data_shards}, versioned={versioned}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -577,13 +592,28 @@ mod tests {
|
||||
let shard_size = erasure.shard_file_size(object_size);
|
||||
assert_eq!(shard_size, expected_shard_size, "{case}: object_size={object_size}");
|
||||
assert_eq!(
|
||||
config.should_inline(shard_size, versioned),
|
||||
config.should_inline(shard_size, erasure.data_shards, versioned),
|
||||
expected,
|
||||
"{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]
|
||||
fn write_capability_contract_only_accepts_implemented_layouts() {
|
||||
assert_eq!(SUPPORTED_WRITE_CLASSES, [STANDARD, RRS]);
|
||||
@@ -777,6 +807,7 @@ mod tests {
|
||||
let encoded = serde_json::to_string(&cfg).expect("config should serialize");
|
||||
assert!(!encoded.contains("standard_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");
|
||||
assert_eq!(decoded.get_parity_for_sc(STANDARD), Some(2));
|
||||
@@ -786,6 +817,25 @@ mod tests {
|
||||
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]
|
||||
fn lookup_config_reads_rrs_from_class_rrs_key() {
|
||||
// Regression: kvs.get(RRS) used RRS="REDUCED_REDUNDANCY" instead of
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
// limitations under the License.
|
||||
|
||||
// #730: pool coordination helpers are being migrated behind runtime owners.
|
||||
#![allow(dead_code)]
|
||||
|
||||
pub(crate) mod pools;
|
||||
pub(crate) mod sets;
|
||||
|
||||
@@ -226,6 +226,7 @@ fn ensure_decommission_start_rebalance_meta_allowed(meta: Option<&RebalanceMeta>
|
||||
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<()> {
|
||||
for idx in indices {
|
||||
ensure_local_decommission_pool_leader(endpoints, *idx)?;
|
||||
@@ -1058,11 +1059,19 @@ fn should_cleanup_decommission_source_entry(decommissioned: usize, total_version
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[allow(
|
||||
dead_code,
|
||||
reason = "terminal-state classification asserted by this file's tests (backlog#1823)"
|
||||
)]
|
||||
enum DecommissionTerminalState {
|
||||
Completed,
|
||||
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 {
|
||||
if failed_items_present {
|
||||
DecommissionTerminalState::Failed
|
||||
@@ -2266,15 +2275,19 @@ fn decommission_delete_marker_opts(
|
||||
version: &rustfs_filemeta::FileInfo,
|
||||
version_id: Option<String>,
|
||||
src_pool_idx: usize,
|
||||
expected_bucket_incarnation_id: Option<uuid::Uuid>,
|
||||
) -> ObjectOptions {
|
||||
let version_suspended = version.version_id.is_none() && version_id.is_none();
|
||||
ObjectOptions {
|
||||
versioned: true,
|
||||
version_id,
|
||||
versioned: !version_suspended,
|
||||
version_suspended,
|
||||
version_id: version_id.or_else(|| version_suspended.then(|| uuid::Uuid::nil().to_string())),
|
||||
mod_time: version.mod_time,
|
||||
src_pool_idx,
|
||||
data_movement: true,
|
||||
delete_marker: true,
|
||||
skip_decommissioned: true,
|
||||
expected_bucket_incarnation_id,
|
||||
delete_replication: version
|
||||
.replication_state_internal
|
||||
.as_ref()
|
||||
@@ -2299,6 +2312,7 @@ fn decommission_remote_tiered_opts(
|
||||
version: &rustfs_filemeta::FileInfo,
|
||||
version_id: Option<String>,
|
||||
src_pool_idx: usize,
|
||||
expected_bucket_incarnation_id: Option<uuid::Uuid>,
|
||||
) -> ObjectOptions {
|
||||
ObjectOptions {
|
||||
versioned: version_id.is_some(),
|
||||
@@ -2307,6 +2321,9 @@ fn decommission_remote_tiered_opts(
|
||||
user_defined: version.metadata.clone(),
|
||||
src_pool_idx,
|
||||
data_movement: true,
|
||||
include_part_checksums: true,
|
||||
http_preconditions: Some(crate::data_movement::data_movement_target_precondition()),
|
||||
expected_bucket_incarnation_id,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
@@ -2805,6 +2822,7 @@ impl ECStore {
|
||||
lifecycle_config: Option<BucketLifecycleConfiguration>,
|
||||
object_lock_config: Option<ObjectLockConfiguration>,
|
||||
replication_config: Option<(ReplicationConfiguration, OffsetDateTime)>,
|
||||
expected_bucket_incarnation_id: Option<uuid::Uuid>,
|
||||
) -> Result<()> {
|
||||
debug!(
|
||||
event = EVENT_DECOMMISSION_ENTRY,
|
||||
@@ -2834,6 +2852,11 @@ impl ECStore {
|
||||
}
|
||||
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?;
|
||||
|
||||
fivs.versions
|
||||
@@ -2894,7 +2917,7 @@ impl ECStore {
|
||||
.delete_object(
|
||||
bucket.as_str(),
|
||||
&version.name,
|
||||
decommission_delete_marker_opts(version, version_id.clone(), idx),
|
||||
decommission_delete_marker_opts(version, version_id.clone(), idx, expected_bucket_incarnation_id),
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -2984,7 +3007,7 @@ impl ECStore {
|
||||
bucket.as_str(),
|
||||
&version.name,
|
||||
version,
|
||||
&decommission_remote_tiered_opts(version, version_id.clone(), idx),
|
||||
&decommission_remote_tiered_opts(version, version_id.clone(), idx, expected_bucket_incarnation_id),
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -3056,7 +3079,11 @@ impl ECStore {
|
||||
)
|
||||
.await?;
|
||||
|
||||
if let Err(err) = self.clone().decommission_object(idx, bucket, rd).await {
|
||||
if let Err(err) = self
|
||||
.clone()
|
||||
.decommission_object(idx, bucket, rd, expected_bucket_incarnation_id)
|
||||
.await
|
||||
{
|
||||
if is_decommission_copy_cleanup_safe_error(&err) {
|
||||
ignore = true;
|
||||
cleanup_ignored = true;
|
||||
@@ -3133,6 +3160,9 @@ impl ECStore {
|
||||
}
|
||||
|
||||
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())?;
|
||||
|
||||
self.save_decommission_entry_progress_stage(
|
||||
@@ -3157,6 +3187,12 @@ impl ECStore {
|
||||
entry.name.as_str(),
|
||||
&fivs,
|
||||
&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",
|
||||
)
|
||||
.await
|
||||
@@ -3268,6 +3304,11 @@ impl ECStore {
|
||||
let mut lifecycle_config = None;
|
||||
let mut object_lock_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 {
|
||||
let _ = resolve_decommission_optional_bucket_config_result(
|
||||
@@ -3321,6 +3362,7 @@ impl ECStore {
|
||||
let lifecycle_config = lifecycle_config.clone();
|
||||
let object_lock_config = object_lock_config.clone();
|
||||
let replication_config = replication_config.clone();
|
||||
let expected_bucket_incarnation_id = expected_bucket_incarnation_id;
|
||||
let entry_error = entry_error.clone();
|
||||
let callback_rx = callback_rx.clone();
|
||||
|
||||
@@ -3383,6 +3425,7 @@ impl ECStore {
|
||||
lifecycle_config,
|
||||
object_lock_config,
|
||||
replication_config,
|
||||
expected_bucket_incarnation_id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -4168,10 +4211,24 @@ impl ECStore {
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(self, rd))]
|
||||
async fn decommission_object(self: Arc<Self>, pool_idx: usize, bucket: String, rd: GetObjectReader) -> Result<()> {
|
||||
async fn decommission_object(
|
||||
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);
|
||||
let object_name = rd.object_info.name.clone();
|
||||
let result = data_movement::migrate_object(self, pool_idx, bucket.clone(), rd, "decommission_object").await;
|
||||
let result = data_movement::migrate_object(
|
||||
self,
|
||||
pool_idx,
|
||||
bucket.clone(),
|
||||
rd,
|
||||
expected_bucket_incarnation_id,
|
||||
"decommission_object",
|
||||
)
|
||||
.await;
|
||||
if result.is_ok() {
|
||||
warn!("decommission_object: migrated {} {}", &bucket, &object_name);
|
||||
}
|
||||
@@ -4347,7 +4404,8 @@ mod tests {
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let opts = decommission_delete_marker_opts(&version, Some("version-id".to_string()), 7);
|
||||
let incarnation = uuid::Uuid::new_v4();
|
||||
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");
|
||||
|
||||
assert!(opts.versioned);
|
||||
@@ -4357,11 +4415,25 @@ mod tests {
|
||||
assert_eq!(opts.src_pool_idx, 7);
|
||||
assert_eq!(opts.version_id.as_deref(), Some("version-id"));
|
||||
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!(replication.delete_marker);
|
||||
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]
|
||||
fn test_decommission_object_migration_read_opts_are_raw_data_movement() {
|
||||
let opts = decommission_object_migration_read_opts(Some("vid-1".to_string()));
|
||||
@@ -4383,7 +4455,8 @@ mod tests {
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let opts = decommission_remote_tiered_opts(&version, Some("version-id".to_string()), 9);
|
||||
let incarnation = uuid::Uuid::new_v4();
|
||||
let opts = decommission_remote_tiered_opts(&version, Some("version-id".to_string()), 9, Some(incarnation));
|
||||
|
||||
assert!(opts.versioned);
|
||||
assert!(opts.data_movement);
|
||||
@@ -4391,6 +4464,9 @@ mod tests {
|
||||
assert_eq!(opts.version_id.as_deref(), Some("version-id"));
|
||||
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!(opts.include_part_checksums);
|
||||
assert!(opts.http_preconditions.is_some());
|
||||
assert_eq!(opts.expected_bucket_incarnation_id, Some(incarnation));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+1564
-253
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,16 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// 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::disk::RUSTFS_META_BUCKET;
|
||||
use crate::error::{Error, Result};
|
||||
@@ -26,10 +36,12 @@ pub const DATA_USAGE_DIR: &str = "datausage";
|
||||
/// Directory used to store incremental scan state files under the metadata bucket.
|
||||
pub const DATA_USAGE_STATE_DIR: &str = "datausage/state";
|
||||
/// 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;
|
||||
|
||||
/// Additional metadata describing which disk produced the snapshot.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[allow(dead_code, reason = "unwired local usage-snapshot feature; see module docs (backlog#1823)")]
|
||||
pub struct LocalUsageSnapshotMeta {
|
||||
/// Disk UUID stored as a string for simpler serialization.
|
||||
pub disk_id: String,
|
||||
@@ -43,6 +55,7 @@ pub struct LocalUsageSnapshotMeta {
|
||||
|
||||
/// Usage snapshot produced by a single disk.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[allow(dead_code, reason = "unwired local usage-snapshot feature; see module docs (backlog#1823)")]
|
||||
pub struct LocalUsageSnapshot {
|
||||
/// Format version recorded in the snapshot.
|
||||
pub format_version: u32,
|
||||
@@ -64,6 +77,7 @@ pub struct LocalUsageSnapshot {
|
||||
pub objects_total_size: u64,
|
||||
}
|
||||
|
||||
#[allow(dead_code, reason = "unwired local usage-snapshot feature; see module docs (backlog#1823)")]
|
||||
impl LocalUsageSnapshot {
|
||||
/// Create an empty snapshot with the default format version filled in.
|
||||
pub fn new(meta: LocalUsageSnapshotMeta) -> Self {
|
||||
@@ -99,11 +113,13 @@ impl LocalUsageSnapshot {
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
format!("{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 {
|
||||
format!("{}/{}", DATA_USAGE_DIR, snapshot_file_name(disk_id))
|
||||
}
|
||||
@@ -119,11 +135,13 @@ pub fn data_usage_state_dir(root: &Path) -> PathBuf {
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
data_usage_dir(root).join(snapshot_file_name(disk_id))
|
||||
}
|
||||
|
||||
/// 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>> {
|
||||
let path = snapshot_path(root, disk_id);
|
||||
match fs::read(&path).await {
|
||||
@@ -138,6 +156,7 @@ 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.
|
||||
#[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<()> {
|
||||
let dir = data_usage_dir(root);
|
||||
fs::create_dir_all(&dir).await.map_err(Error::other)?;
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
// limitations under the License.
|
||||
|
||||
// #730: scanner/data-usage state is partially migrated and still owns staged cache helpers.
|
||||
#![allow(dead_code)]
|
||||
|
||||
pub mod local_snapshot;
|
||||
|
||||
@@ -34,8 +33,8 @@ use crate::{
|
||||
pub use local_snapshot::{LocalUsageSnapshot, read_snapshot as read_local_snapshot, snapshot_path};
|
||||
use rustfs_data_usage::{
|
||||
BucketTargetUsageInfo, BucketUsageInfo, CompressionTotalInfo, DATA_USAGE_OBJECT_NAME, DATA_USAGE_OBSERVED_OBJECT_NAME,
|
||||
DataUsageCache, DataUsageEntry, DataUsageInfo, DiskUsageStatus, LEGACY_DATA_USAGE_OBJECT_NAME, SizeHistogram, SizeSummary,
|
||||
VersionsHistogram, observed_data_usage_is_newer,
|
||||
DataUsageCache, DataUsageInfo, DiskUsageStatus, LEGACY_DATA_USAGE_OBJECT_NAME, SizeHistogram, VersionsHistogram,
|
||||
observed_data_usage_is_newer,
|
||||
};
|
||||
use rustfs_io_metrics::record_system_path_failure;
|
||||
use rustfs_utils::path::SLASH_SEPARATOR;
|
||||
@@ -55,7 +54,6 @@ use tracing::{debug, error, info, instrument};
|
||||
// Data usage storage constants
|
||||
pub const DATA_USAGE_ROOT: &str = SLASH_SEPARATOR;
|
||||
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";
|
||||
const DATA_USAGE_CACHE_TTL_SECS: u64 = 30;
|
||||
const LIVE_BUCKET_USAGE_MAX_ENTRIES: u64 = 1024;
|
||||
@@ -313,11 +311,6 @@ lazy_static::lazy_static! {
|
||||
LEGACY_DATA_USAGE_OBJECT_NAME
|
||||
);
|
||||
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!("{}{}{}",
|
||||
crate::disk::BUCKET_META_PREFIX,
|
||||
SLASH_SEPARATOR,
|
||||
@@ -858,6 +851,10 @@ 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(
|
||||
primary: Result<Vec<u8>, Error>,
|
||||
backup: impl Future<Output = Result<Vec<u8>, Error>>,
|
||||
@@ -1187,6 +1184,10 @@ pub async fn invalidate_admin_data_usage_snapshot_cache() {
|
||||
}
|
||||
|
||||
/// 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>) {
|
||||
if let Some(update) = snapshot.last_update
|
||||
&& latest_update.is_none_or(|current| update > current)
|
||||
@@ -1220,6 +1221,10 @@ 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> {
|
||||
let mut aggregated = DataUsageInfo::default();
|
||||
let mut latest_update: Option<SystemTime> = None;
|
||||
@@ -1355,7 +1360,7 @@ impl BucketUsageAccumulator {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let object_size = object.size.max(0) as u64;
|
||||
let object_size = quota_object_size(object)?;
|
||||
self.current_live_versions = self.current_live_versions.saturating_add(1);
|
||||
self.size_histogram.add(object_size);
|
||||
self.total_size = self.total_size.saturating_add(object_size);
|
||||
@@ -1385,6 +1390,31 @@ 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>;
|
||||
|
||||
pub async fn compute_bucket_usage(store: Arc<ECStore>, bucket_name: &str) -> Result<BucketUsageInfo, Error> {
|
||||
@@ -1742,11 +1772,6 @@ pub async fn record_bucket_object_write_unknown_previous_memory(bucket: &str, ne
|
||||
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.
|
||||
pub async fn record_bucket_object_delete_memory(bucket: &str, deleted_size: u64, removed_current_object: bool) {
|
||||
ensure_bucket_usage_cached(bucket).await;
|
||||
@@ -1789,11 +1814,6 @@ pub async fn record_bucket_delete_marker_memory(bucket: &str) {
|
||||
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.
|
||||
async fn get_persisted_bucket_usage(bucket: &str) -> Option<u64> {
|
||||
let store = runtime_sources::object_store_handle()?;
|
||||
@@ -1988,91 +2008,6 @@ 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;
|
||||
}
|
||||
|
||||
/// 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
|
||||
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};
|
||||
@@ -3124,6 +3059,102 @@ mod tests {
|
||||
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]
|
||||
#[serial]
|
||||
async fn live_bucket_usage_refreshes_are_coalesced_only_while_in_flight() {
|
||||
|
||||
@@ -653,6 +653,7 @@ fn reconcile_servers_with_endpoint_topology(
|
||||
(added, report)
|
||||
}
|
||||
|
||||
#[allow(dead_code, reason = "exercised by this file's topology tests (backlog#1823)")]
|
||||
fn server_topology_completeness_report(
|
||||
servers: &[ServerProperties],
|
||||
endpoints: &EndpointServerPools,
|
||||
|
||||
@@ -46,21 +46,49 @@ pub(crate) const GET_CODEC_STREAMING_OBJECT_CLASS_MULTIPART: &str = "multipart";
|
||||
pub(crate) const GET_STAGE_DECODE: &str = "decode";
|
||||
pub(crate) const GET_STAGE_EMIT: &str = "emit";
|
||||
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";
|
||||
#[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";
|
||||
#[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";
|
||||
#[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";
|
||||
#[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_INLINE_PREPARE: &str = "inline_prepare";
|
||||
pub(crate) const GET_STAGE_LOCK_ACQUIRE: &str = "lock_acquire";
|
||||
pub(crate) const GET_STAGE_METADATA: &str = "metadata";
|
||||
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_RESOLVE: &str = "metadata_resolve";
|
||||
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_POLL: &str = "output_poll";
|
||||
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_RANGE: &str = "range";
|
||||
pub(crate) const GET_STAGE_READER_SETUP: &str = "reader_setup";
|
||||
@@ -84,12 +112,28 @@ 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_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_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";
|
||||
#[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";
|
||||
#[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_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_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_READER_BUFFER_OUTPUT: &str = "output";
|
||||
@@ -137,6 +181,7 @@ 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_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_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_STALE_PUBLICATION: &str = "stale_publication";
|
||||
pub(crate) const GET_METADATA_CACHE_REASON_USABLE: &str = "usable";
|
||||
@@ -154,8 +199,20 @@ 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";
|
||||
|
||||
/// 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";
|
||||
#[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";
|
||||
#[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";
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
@@ -441,6 +498,10 @@ mod tests {
|
||||
assert_eq!(GET_STAGE_QUORUM_REACHED, "quorum_reached");
|
||||
assert_eq!(GET_STAGE_RANGE, "range");
|
||||
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_RESPONSE_HANDOFF, "response_handoff");
|
||||
assert_eq!(GET_STAGE_SLOWEST_METADATA_RESPONSE, "slowest_metadata_response");
|
||||
@@ -480,6 +541,7 @@ mod tests {
|
||||
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_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_RAW_DATA_MOVEMENT_READ, "raw_data_movement_read");
|
||||
assert_eq!(GET_METADATA_CACHE_REASON_STALE_PUBLICATION, "stale_publication");
|
||||
|
||||
@@ -13,8 +13,6 @@
|
||||
// limitations under the License.
|
||||
|
||||
// #730: diagnostics constants are staged for request-path telemetry migration.
|
||||
#![allow(dead_code)]
|
||||
|
||||
pub(crate) mod admin_server_info;
|
||||
pub(crate) mod get;
|
||||
pub(crate) mod pool;
|
||||
|
||||
@@ -1,30 +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.
|
||||
|
||||
//! 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";
|
||||
@@ -241,6 +241,40 @@ 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 {
|
||||
get_drive_timeout_duration(
|
||||
rustfs_config::ENV_DRIVE_WALKDIR_TIMEOUT_SECS,
|
||||
@@ -1985,13 +2019,8 @@ impl DiskAPI for LocalDiskWrapper {
|
||||
dst_volume: &str,
|
||||
dst_path: &str,
|
||||
) -> Result<RenameDataResp> {
|
||||
self.track_disk_health_mutation(
|
||||
"rename_data",
|
||||
DiskMetricMutation::Write,
|
||||
|| async { self.disk.rename_data(src_volume, src_path, fi, dst_volume, dst_path).await },
|
||||
get_max_timeout_duration(),
|
||||
)
|
||||
.await
|
||||
self.rename_data_borrowed(src_volume, src_path, &fi, dst_volume, dst_path)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn list_dir(&self, origvolume: &str, volume: &str, dir_path: &str, count: i32) -> Result<Vec<String>> {
|
||||
@@ -2022,6 +2051,21 @@ impl DiskAPI for LocalDiskWrapper {
|
||||
.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> {
|
||||
self.track_disk_health_with_op(
|
||||
"read_file_mmap_copy",
|
||||
|
||||
@@ -113,6 +113,9 @@ pub enum DiskError {
|
||||
#[error("bit-rot hash algorithm is invalid")]
|
||||
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")]
|
||||
CrossDeviceLink,
|
||||
|
||||
@@ -143,6 +146,9 @@ pub enum DiskError {
|
||||
#[error("io error {0}")]
|
||||
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")]
|
||||
SourceStalled,
|
||||
|
||||
@@ -642,19 +648,6 @@ impl Hash for DiskError {
|
||||
// is currently commented out to avoid complexity. These can be re-enabled
|
||||
// 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
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub struct FileAccessDeniedWithContext {
|
||||
@@ -869,19 +862,6 @@ mod tests {
|
||||
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]
|
||||
fn test_file_access_denied_with_context() {
|
||||
let path = PathBuf::from("/test/path");
|
||||
|
||||
@@ -15,6 +15,11 @@
|
||||
use crate::config::storageclass::DEFAULT_INLINE_BLOCK;
|
||||
use crate::crash_inject::{self, CrashPoint};
|
||||
use crate::data_usage::local_snapshot::ensure_data_usage_layout;
|
||||
use crate::diagnostics::get::{
|
||||
GET_OBJECT_PATH_INTERNAL_META, GET_OBJECT_PATH_LEGACY_DUPLEX, GET_STAGE_READ_VERSION_DECODE,
|
||||
GET_STAGE_READ_VERSION_PATH_CHECK, GET_STAGE_READ_VERSION_PATH_RESOLVE, GET_STAGE_READ_VERSION_XLMETA_READ,
|
||||
get_stage_timer_if_enabled, record_get_stage_duration_if_enabled,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use crate::disk::HEALING_MARKER_PATH;
|
||||
use crate::disk::disk_store::{get_drive_walkdir_stall_timeout, get_object_disk_read_timeout};
|
||||
@@ -22,17 +27,18 @@ use crate::disk::{
|
||||
BUCKET_META_PREFIX, CHECK_PART_FILE_CORRUPT, CHECK_PART_FILE_NOT_FOUND, CHECK_PART_SUCCESS, CHECK_PART_UNKNOWN,
|
||||
CHECK_PART_VOLUME_NOT_FOUND, CheckPartsResp, ConditionalFileUpdate, DataDirDeleteStatus, DeleteOptions, DiskAPI, DiskInfo,
|
||||
DiskInfoOptions, DiskLocation, DiskMetrics, FileInfoVersions, FileReader, FileWriter, MmapCopyStageMetrics, OldCurrentSize,
|
||||
PART_TRANSACTION_NEW_META, PART_TRANSACTION_OLD_META, PART_TRANSACTION_ROLLBACK, PartTransactionAction, RUSTFS_META_BUCKET,
|
||||
RUSTFS_META_TMP_BUCKET, RUSTFS_META_TMP_DELETED_BUCKET, ReadMultipleReq, ReadMultipleResp, ReadOptions, RenameDataResp,
|
||||
STORAGE_FORMAT_FILE, STORAGE_FORMAT_FILE_BACKUP, SnapshotLeaseToken, UpdateMetadataOpts, VolumeInfo, WalkDirOptions,
|
||||
conv_part_err_to_int,
|
||||
PART_TRANSACTION_NEW_META, PART_TRANSACTION_OLD_META, PART_TRANSACTION_ROLLBACK, PartTransactionAction,
|
||||
QUOTA_MUTATION_FENCE_METADATA_SUFFIX, RUSTFS_META_BUCKET, RUSTFS_META_TMP_BUCKET, RUSTFS_META_TMP_DELETED_BUCKET,
|
||||
ReadMultipleReq, ReadMultipleResp, ReadOptions, RenameDataResp, STORAGE_FORMAT_FILE, STORAGE_FORMAT_FILE_BACKUP,
|
||||
SnapshotLeaseToken, UpdateMetadataOpts, VolumeInfo, WalkDirOptions, conv_part_err_to_int,
|
||||
endpoint::Endpoint,
|
||||
error::{DiskError, Error, FileAccessDeniedWithContext, Result},
|
||||
error_conv::{to_access_error, to_file_error, to_unformatted_disk_error, to_volume_error},
|
||||
format::FormatV3,
|
||||
fs::{O_APPEND, O_CREATE, O_RDONLY, O_TRUNC, O_WRONLY, access, lstat, lstat_std, remove, remove_all_std, remove_std, rename},
|
||||
os,
|
||||
is_quota_mutation_fence_path, os,
|
||||
os::{check_path_length, is_dir_not_empty_error, is_empty_dir, is_root_disk, rename_all, rename_all_ignore_missing_source},
|
||||
quota_mutation_fence_path,
|
||||
};
|
||||
use crate::erasure::coding::{self, bitrot_verify};
|
||||
use crate::runtime::sources as runtime_sources;
|
||||
@@ -55,9 +61,7 @@ use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::fmt::Debug;
|
||||
use std::io::{Error as IoError, SeekFrom};
|
||||
#[cfg(target_os = "linux")]
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, OnceLock};
|
||||
use std::time::Duration;
|
||||
use std::{
|
||||
@@ -2024,14 +2028,17 @@ static RENAME_DATA_REMOVE_DST_BASE_BEFORE_COMMIT: std::sync::Mutex<Option<(Strin
|
||||
#[cfg(test)]
|
||||
type InlinePreparationHook = Box<dyn FnOnce() + Send>;
|
||||
#[cfg(test)]
|
||||
type RenameDataPublicationHookKey = (PathBuf, String, String);
|
||||
#[cfg(test)]
|
||||
static INLINE_PREPARATION_BEFORE_BACKUP: std::sync::LazyLock<std::sync::Mutex<HashMap<String, InlinePreparationHook>>> =
|
||||
std::sync::LazyLock::new(|| std::sync::Mutex::new(HashMap::new()));
|
||||
#[cfg(test)]
|
||||
static INLINE_BEFORE_FILE_SYNC_ADMISSION: std::sync::LazyLock<std::sync::Mutex<HashMap<String, InlinePreparationHook>>> =
|
||||
std::sync::LazyLock::new(|| std::sync::Mutex::new(HashMap::new()));
|
||||
#[cfg(test)]
|
||||
static RENAME_DATA_AFTER_FIRST_PUBLICATION: std::sync::LazyLock<std::sync::Mutex<HashMap<String, InlinePreparationHook>>> =
|
||||
std::sync::LazyLock::new(|| std::sync::Mutex::new(HashMap::new()));
|
||||
static RENAME_DATA_AFTER_FIRST_PUBLICATION: std::sync::LazyLock<
|
||||
std::sync::Mutex<HashMap<RenameDataPublicationHookKey, InlinePreparationHook>>,
|
||||
> = std::sync::LazyLock::new(|| std::sync::Mutex::new(HashMap::new()));
|
||||
#[cfg(test)]
|
||||
static OWNED_FILE_WRITE_BEFORE_OPEN: std::sync::LazyLock<std::sync::Mutex<HashMap<PathBuf, InlinePreparationHook>>> =
|
||||
std::sync::LazyLock::new(|| std::sync::Mutex::new(HashMap::new()));
|
||||
@@ -2103,11 +2110,11 @@ fn set_inline_before_file_sync_admission(dst_path: &str, hook: impl FnOnce() + S
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn set_rename_data_after_first_publication(dst_path: &str, hook: impl FnOnce() + Send + 'static) {
|
||||
fn set_rename_data_after_first_publication(root: &Path, dst_volume: &str, dst_path: &str, hook: impl FnOnce() + Send + 'static) {
|
||||
RENAME_DATA_AFTER_FIRST_PUBLICATION
|
||||
.lock()
|
||||
.expect("test publication hook lock should not be poisoned")
|
||||
.insert(dst_path.to_string(), Box::new(hook));
|
||||
.insert((root.to_path_buf(), dst_volume.to_string(), dst_path.to_string()), Box::new(hook));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -2259,11 +2266,11 @@ fn run_inline_before_file_sync_admission(dst_path: &str) {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn run_rename_data_after_first_publication(dst_path: &str) {
|
||||
fn run_rename_data_after_first_publication(root: &Path, dst_volume: &str, dst_path: &str) {
|
||||
let hook = RENAME_DATA_AFTER_FIRST_PUBLICATION
|
||||
.lock()
|
||||
.expect("test publication hook lock should not be poisoned")
|
||||
.remove(dst_path);
|
||||
.remove(&(root.to_path_buf(), dst_volume.to_string(), dst_path.to_string()));
|
||||
if let Some(hook) = hook {
|
||||
hook();
|
||||
}
|
||||
@@ -2361,9 +2368,6 @@ async fn remove_dst_base_before_commit(
|
||||
#[cfg(not(test))]
|
||||
fn run_inline_preparation_before_backup(_dst_path: &str) {}
|
||||
|
||||
#[cfg(not(test))]
|
||||
fn run_rename_data_after_first_publication(_dst_path: &str) {}
|
||||
|
||||
#[cfg(not(test))]
|
||||
fn should_fail_after_delete_data_staged(_path: &str) -> bool {
|
||||
false
|
||||
@@ -4751,6 +4755,25 @@ struct SnapshotLeaseEntry {
|
||||
tokens: HashSet<SnapshotLeaseToken>,
|
||||
pending_delete: Option<DeleteOptions>,
|
||||
deleting: bool,
|
||||
mutation_fence: Option<Arc<QuotaMutationFenceState>>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct QuotaMutationFenceState {
|
||||
revoked: AtomicBool,
|
||||
running: AtomicUsize,
|
||||
notify: Notify,
|
||||
}
|
||||
|
||||
struct QuotaMutationFenceClaim {
|
||||
state: Arc<QuotaMutationFenceState>,
|
||||
}
|
||||
|
||||
impl Drop for QuotaMutationFenceClaim {
|
||||
fn drop(&mut self) {
|
||||
self.state.running.fetch_sub(1, Ordering::AcqRel);
|
||||
self.state.notify.notify_waiters();
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -7315,6 +7338,34 @@ fn normalize_path_components(path: impl AsRef<Path>) -> PathBuf {
|
||||
}
|
||||
|
||||
impl LocalDisk {
|
||||
async fn claim_quota_mutation_fence(
|
||||
&self,
|
||||
volume: &str,
|
||||
path: &str,
|
||||
token: SnapshotLeaseToken,
|
||||
) -> Result<Arc<QuotaMutationFenceClaim>> {
|
||||
let key = SnapshotLeaseKey {
|
||||
volume: RUSTFS_META_BUCKET.to_string(),
|
||||
path: quota_mutation_fence_path(volume, path),
|
||||
};
|
||||
let state = {
|
||||
let registry = self.snapshot_leases.lock().await;
|
||||
let entry = registry.entries.get(&key).ok_or(DiskError::FileNotFound)?;
|
||||
let state = entry.mutation_fence.as_ref().ok_or(DiskError::FileNotFound)?;
|
||||
if !entry.tokens.contains(&token) || state.revoked.load(Ordering::Acquire) {
|
||||
return Err(DiskError::FileNotFound);
|
||||
}
|
||||
state.running.fetch_add(1, Ordering::AcqRel);
|
||||
Arc::clone(state)
|
||||
};
|
||||
if state.revoked.load(Ordering::Acquire) {
|
||||
state.running.fetch_sub(1, Ordering::AcqRel);
|
||||
state.notify.notify_waiters();
|
||||
return Err(DiskError::FileNotFound);
|
||||
}
|
||||
Ok(Arc::new(QuotaMutationFenceClaim { state }))
|
||||
}
|
||||
|
||||
async fn reserve_version_delete(&self, volume: &str, object: &str, data_dir: Uuid, rollback_dir: Uuid) -> Result<bool> {
|
||||
let path = format!("{object}/{data_dir}");
|
||||
let data_path = self.io_get_object_path(volume, &path)?;
|
||||
@@ -8638,17 +8689,41 @@ impl DiskAPI for LocalDisk {
|
||||
&self,
|
||||
src_volume: &str,
|
||||
src_path: &str,
|
||||
mut fi: FileInfo,
|
||||
fi: FileInfo,
|
||||
dst_volume: &str,
|
||||
dst_path: &str,
|
||||
) -> Result<RenameDataResp> {
|
||||
crate::hp_guard!("LocalDisk::rename_data");
|
||||
let mut fi = fi;
|
||||
// A non-force DeleteBucket must not remove a directory while a local
|
||||
// object commit is publishing into it. The peer's empty scan remains
|
||||
// optimistic; this lease establishes the local commit/delete order and
|
||||
// remains owned by any blocking syscall that outlives async cancellation.
|
||||
let destination_object_path = self.io_get_object_path(dst_volume, dst_path)?;
|
||||
let quota_fence_token =
|
||||
match rustfs_utils::http::metadata_compat::get_consistent_str(&fi.metadata, QUOTA_MUTATION_FENCE_METADATA_SUFFIX) {
|
||||
Some(value) => {
|
||||
let token = Uuid::parse_str(value).map_err(|_| DiskError::FileCorrupt)?;
|
||||
Some(SnapshotLeaseToken::from_slice(token.as_bytes())?)
|
||||
}
|
||||
None if rustfs_utils::http::metadata_compat::contains_key_str(
|
||||
&fi.metadata,
|
||||
QUOTA_MUTATION_FENCE_METADATA_SUFFIX,
|
||||
) =>
|
||||
{
|
||||
return Err(DiskError::FileCorrupt);
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
rustfs_utils::http::metadata_compat::remove_str(&mut fi.metadata, QUOTA_MUTATION_FENCE_METADATA_SUFFIX);
|
||||
let quota_fence_claim = match quota_fence_token {
|
||||
Some(token) => Some(self.claim_quota_mutation_fence(dst_volume, dst_path, token).await?),
|
||||
None => None,
|
||||
};
|
||||
let mutation_lease = os::acquire_rename_data_mutation_lease(&self.root, dst_volume, &destination_object_path).await;
|
||||
if let Some(claim) = quota_fence_claim {
|
||||
mutation_lease.attach_external_guard(claim);
|
||||
}
|
||||
if fi.is_legacy_indexed_delete_marker() {
|
||||
fi.erasure.index = 0;
|
||||
}
|
||||
@@ -8941,8 +9016,9 @@ impl DiskAPI for LocalDisk {
|
||||
.await?;
|
||||
return Err(err);
|
||||
}
|
||||
#[cfg(test)]
|
||||
if has_data_dir_path.is_some() {
|
||||
run_rename_data_after_first_publication(dst_path);
|
||||
run_rename_data_after_first_publication(&self.root, dst_volume, dst_path);
|
||||
}
|
||||
|
||||
// Crash-consistency injection: hard power loss after the data dir
|
||||
@@ -9174,7 +9250,7 @@ impl DiskAPI for LocalDisk {
|
||||
|
||||
if let Some(src_file_path_parent) = src_file_path.parent() {
|
||||
if src_volume != super::RUSTFS_META_MULTIPART_BUCKET {
|
||||
let _ = remove_std(src_file_path_parent);
|
||||
let _ = std::fs::remove_dir(src_file_path_parent);
|
||||
} else {
|
||||
let _ = self
|
||||
.delete_file(&dst_volume_dir, &src_file_path_parent.to_path_buf(), true, false)
|
||||
@@ -9375,7 +9451,8 @@ impl DiskAPI for LocalDisk {
|
||||
let _ = remove_file_if_exists(staged_backup);
|
||||
return Err(err);
|
||||
}
|
||||
run_rename_data_after_first_publication(dst_path);
|
||||
#[cfg(test)]
|
||||
run_rename_data_after_first_publication(&self.root, dst_volume, dst_path);
|
||||
if sync {
|
||||
file_sync_admission = Some(
|
||||
os::acquire_file_sync_admission(self.file_sync_permits.clone())
|
||||
@@ -9499,7 +9576,7 @@ impl DiskAPI for LocalDisk {
|
||||
if let Some(ref cleanup) = cleanup_path {
|
||||
let _ = self.delete_file(&dst_volume_dir, cleanup, true, false).await;
|
||||
} else if let Some(parent) = src_file_path.parent() {
|
||||
let _ = remove_std(parent);
|
||||
let _ = std::fs::remove_dir(parent);
|
||||
}
|
||||
|
||||
// Heal reuses a version's `data_dir` and lands the rebuilt shard on
|
||||
@@ -9638,11 +9715,26 @@ impl DiskAPI for LocalDisk {
|
||||
}
|
||||
|
||||
async fn acquire_snapshot_lease(&self, volume: &str, path: &str) -> Result<SnapshotLeaseToken> {
|
||||
let file_path = self.io_get_object_path(volume, path)?;
|
||||
let key = SnapshotLeaseKey {
|
||||
volume: volume.to_string(),
|
||||
path: path.to_string(),
|
||||
};
|
||||
if volume == RUSTFS_META_BUCKET && is_quota_mutation_fence_path(path) {
|
||||
let mut registry = self.snapshot_leases.lock().await;
|
||||
let entry = registry.entries.entry(key).or_default();
|
||||
let state = entry
|
||||
.mutation_fence
|
||||
.get_or_insert_with(|| Arc::new(QuotaMutationFenceState::default()));
|
||||
if state.revoked.load(Ordering::Acquire) {
|
||||
return Err(DiskError::FileNotFound);
|
||||
}
|
||||
let token = SnapshotLeaseToken::new();
|
||||
entry.tokens.insert(token);
|
||||
return Ok(token);
|
||||
}
|
||||
|
||||
let file_path = self.io_get_object_path(volume, path)?;
|
||||
let _mutation_lease = os::acquire_rename_data_mutation_lease(&self.root, volume, &file_path).await;
|
||||
let token = {
|
||||
let mut registry = self.snapshot_leases.lock().await;
|
||||
if registry.entries.get(&key).is_some_and(|entry| entry.deleting) {
|
||||
@@ -9670,6 +9762,48 @@ impl DiskAPI for LocalDisk {
|
||||
volume: volume.to_string(),
|
||||
path: path.to_string(),
|
||||
};
|
||||
if volume == RUSTFS_META_BUCKET && is_quota_mutation_fence_path(path) {
|
||||
if !token.is_revoke_all() {
|
||||
let mut registry = self.snapshot_leases.lock().await;
|
||||
let Some(entry) = registry.entries.get_mut(&key) else {
|
||||
return Ok(());
|
||||
};
|
||||
entry.tokens.remove(&token);
|
||||
let removable = entry.tokens.is_empty()
|
||||
&& entry
|
||||
.mutation_fence
|
||||
.as_ref()
|
||||
.is_none_or(|state| state.running.load(Ordering::Acquire) == 0);
|
||||
if removable {
|
||||
registry.entries.remove(&key);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let state = {
|
||||
let mut registry = self.snapshot_leases.lock().await;
|
||||
let Some(entry) = registry.entries.get_mut(&key) else {
|
||||
return Ok(());
|
||||
};
|
||||
let Some(state) = entry.mutation_fence.as_ref().cloned() else {
|
||||
registry.entries.remove(&key);
|
||||
return Ok(());
|
||||
};
|
||||
state.revoked.store(true, Ordering::Release);
|
||||
entry.tokens.clear();
|
||||
state
|
||||
};
|
||||
loop {
|
||||
let notified = state.notify.notified();
|
||||
tokio::pin!(notified);
|
||||
notified.as_mut().enable();
|
||||
if state.running.load(Ordering::Acquire) == 0 {
|
||||
break;
|
||||
}
|
||||
notified.await;
|
||||
}
|
||||
self.snapshot_leases.lock().await.entries.remove(&key);
|
||||
return Ok(());
|
||||
}
|
||||
let opts = {
|
||||
let mut registry = self.snapshot_leases.lock().await;
|
||||
let Some(entry) = registry.entries.get_mut(&key) else {
|
||||
@@ -9840,6 +9974,12 @@ impl DiskAPI for LocalDisk {
|
||||
opts: &ReadOptions,
|
||||
) -> Result<FileInfo> {
|
||||
crate::hp_guard!("LocalDisk::read_version");
|
||||
let stage_metrics_enabled = rustfs_io_metrics::get_stage_metrics_enabled();
|
||||
let metrics_path = if stage_metrics_enabled && crate::bucket::utils::is_meta_bucketname(volume) {
|
||||
GET_OBJECT_PATH_INTERNAL_META
|
||||
} else {
|
||||
GET_OBJECT_PATH_LEGACY_DUPLEX
|
||||
};
|
||||
if !org_volume.is_empty() {
|
||||
let org_volume_path = self.io_get_bucket_path(org_volume)?;
|
||||
if !skip_access_checks(org_volume) {
|
||||
@@ -9849,36 +9989,46 @@ impl DiskAPI for LocalDisk {
|
||||
}
|
||||
}
|
||||
|
||||
let path_resolve_start = get_stage_timer_if_enabled(stage_metrics_enabled);
|
||||
let file_path = self.io_get_object_path(volume, path)?;
|
||||
let volume_dir = self.io_get_bucket_path(volume)?;
|
||||
record_get_stage_duration_if_enabled(metrics_path, GET_STAGE_READ_VERSION_PATH_RESOLVE, path_resolve_start);
|
||||
|
||||
let path_check_start = get_stage_timer_if_enabled(stage_metrics_enabled);
|
||||
check_path_length(file_path.to_string_lossy().as_ref())?;
|
||||
record_get_stage_duration_if_enabled(metrics_path, GET_STAGE_READ_VERSION_PATH_CHECK, path_check_start);
|
||||
|
||||
let read_data = opts.read_data;
|
||||
|
||||
let (data, _) = self
|
||||
.read_raw(volume, volume_dir.clone(), file_path, read_data)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
if e == DiskError::FileNotFound && !version_id.is_empty() {
|
||||
DiskError::FileVersionNotFound
|
||||
} else {
|
||||
e
|
||||
}
|
||||
})?;
|
||||
let xlmeta_read_start = get_stage_timer_if_enabled(stage_metrics_enabled);
|
||||
let raw_read_result = self.read_raw(volume, volume_dir.clone(), file_path, read_data).await;
|
||||
record_get_stage_duration_if_enabled(metrics_path, GET_STAGE_READ_VERSION_XLMETA_READ, xlmeta_read_start);
|
||||
let (data, _) = raw_read_result.map_err(|e| {
|
||||
if e == DiskError::FileNotFound && !version_id.is_empty() {
|
||||
DiskError::FileVersionNotFound
|
||||
} else {
|
||||
e
|
||||
}
|
||||
})?;
|
||||
|
||||
let mut fi = get_file_info(
|
||||
&data,
|
||||
volume,
|
||||
path,
|
||||
version_id,
|
||||
FileInfoOpts {
|
||||
data: read_data,
|
||||
include_free_versions: opts.incl_free_versions,
|
||||
},
|
||||
)?;
|
||||
|
||||
fi.validate_for_metadata_read()?;
|
||||
let decode_start = get_stage_timer_if_enabled(stage_metrics_enabled);
|
||||
let file_info_result: Result<FileInfo> = (|| {
|
||||
let fi = get_file_info(
|
||||
&data,
|
||||
volume,
|
||||
path,
|
||||
version_id,
|
||||
FileInfoOpts {
|
||||
data: read_data,
|
||||
include_free_versions: opts.incl_free_versions,
|
||||
include_part_checksums: false,
|
||||
},
|
||||
)?;
|
||||
fi.validate_for_metadata_read()?;
|
||||
Ok(fi)
|
||||
})();
|
||||
record_get_stage_duration_if_enabled(metrics_path, GET_STAGE_READ_VERSION_DECODE, decode_start);
|
||||
let mut fi = file_info_result?;
|
||||
if fi.is_canonical_delete_marker() {
|
||||
return Ok(fi);
|
||||
}
|
||||
@@ -10432,6 +10582,19 @@ impl DiskAPI for LocalDisk {
|
||||
}
|
||||
}
|
||||
|
||||
impl LocalDisk {
|
||||
pub(crate) async fn rename_data_borrowed(
|
||||
&self,
|
||||
src_volume: &str,
|
||||
src_path: &str,
|
||||
fi: &FileInfo,
|
||||
dst_volume: &str,
|
||||
dst_path: &str,
|
||||
) -> Result<RenameDataResp> {
|
||||
<Self as DiskAPI>::rename_data(self, src_volume, src_path, fi.clone(), dst_volume, dst_path).await
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for_startup_cleanup_signal(
|
||||
startup_cleanup_ready: &AtomicU32,
|
||||
startup_cleanup_notify: &Notify,
|
||||
@@ -10561,6 +10724,108 @@ mod test {
|
||||
meta.marshal_msg().expect("test metadata should encode")
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn read_version_records_local_metadata_stage_breakdown() {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("test runtime should be created");
|
||||
let recorder = crate::test_metrics::CapturingRecorder::default();
|
||||
let previous_gate = rustfs_io_metrics::get_stage_metrics_enabled();
|
||||
rustfs_io_metrics::set_get_stage_metrics_enabled(true);
|
||||
|
||||
metrics::with_local_recorder(&recorder, || {
|
||||
runtime.block_on(async {
|
||||
let dir = tempfile::tempdir().expect("temp dir should be created");
|
||||
let endpoint =
|
||||
Endpoint::try_from(dir.path().to_str().expect("temp dir should be utf8")).expect("endpoint should parse");
|
||||
let disk = LocalDisk::new(&endpoint, false).await.expect("local disk should be created");
|
||||
let bucket = "bucket";
|
||||
let object = "stage-breakdown";
|
||||
ensure_test_volume(&disk, bucket).await;
|
||||
|
||||
let object_dir = dir.path().join(bucket).join(object);
|
||||
fs::create_dir_all(&object_dir)
|
||||
.await
|
||||
.expect("object directory should be created");
|
||||
fs::write(
|
||||
object_dir.join(STORAGE_FORMAT_FILE),
|
||||
test_meta(test_file_info(object, Uuid::new_v4(), None, Some(Bytes::from_static(b"inline")))),
|
||||
)
|
||||
.await
|
||||
.expect("object metadata should be written");
|
||||
|
||||
disk.read_version(
|
||||
"",
|
||||
bucket,
|
||||
object,
|
||||
"",
|
||||
&ReadOptions {
|
||||
read_data: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("read_version should succeed");
|
||||
|
||||
let meta_object = "stage-breakdown-meta";
|
||||
let meta_object_dir = dir.path().join(RUSTFS_META_BUCKET).join(meta_object);
|
||||
fs::create_dir_all(&meta_object_dir)
|
||||
.await
|
||||
.expect("internal metadata object directory should be created");
|
||||
fs::write(
|
||||
meta_object_dir.join(STORAGE_FORMAT_FILE),
|
||||
test_meta(test_file_info(meta_object, Uuid::new_v4(), None, Some(Bytes::from_static(b"meta")))),
|
||||
)
|
||||
.await
|
||||
.expect("internal metadata should be written");
|
||||
|
||||
disk.read_version(
|
||||
"",
|
||||
RUSTFS_META_BUCKET,
|
||||
meta_object,
|
||||
"",
|
||||
&ReadOptions {
|
||||
read_data: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("internal metadata read_version should succeed");
|
||||
});
|
||||
});
|
||||
rustfs_io_metrics::set_get_stage_metrics_enabled(previous_gate);
|
||||
|
||||
for stage in [
|
||||
GET_STAGE_READ_VERSION_PATH_RESOLVE,
|
||||
GET_STAGE_READ_VERSION_PATH_CHECK,
|
||||
GET_STAGE_READ_VERSION_XLMETA_READ,
|
||||
GET_STAGE_READ_VERSION_DECODE,
|
||||
] {
|
||||
assert_eq!(
|
||||
recorder
|
||||
.histogram_values(
|
||||
"rustfs_io_get_object_stage_duration_seconds",
|
||||
&[("path", GET_OBJECT_PATH_LEGACY_DUPLEX), ("stage", stage)]
|
||||
)
|
||||
.len(),
|
||||
1,
|
||||
"{stage} should be recorded once for user-bucket LocalDisk::read_version"
|
||||
);
|
||||
assert_eq!(
|
||||
recorder
|
||||
.histogram_values(
|
||||
"rustfs_io_get_object_stage_duration_seconds",
|
||||
&[("path", GET_OBJECT_PATH_INTERNAL_META), ("stage", stage)]
|
||||
)
|
||||
.len(),
|
||||
1,
|
||||
"{stage} should be recorded once for internal-meta LocalDisk::read_version"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_metadata_rollback_dir_avoids_real_data_dir_collision() {
|
||||
let target_version = Uuid::parse_str("11111111-2222-3333-4444-555555555555").expect("version id should parse");
|
||||
@@ -12439,6 +12704,10 @@ mod test {
|
||||
.join(RUSTFS_META_TMP_BUCKET)
|
||||
.join(tmp_object)
|
||||
.join(new_data_dir.to_string());
|
||||
let tmp_parent = tmp_data_dir
|
||||
.parent()
|
||||
.expect("tmp data dir should have a parent")
|
||||
.to_path_buf();
|
||||
fs::create_dir_all(&tmp_data_dir)
|
||||
.await
|
||||
.expect("new tmp data dir should be created");
|
||||
@@ -12450,6 +12719,10 @@ mod test {
|
||||
disk.rename_data(RUSTFS_META_TMP_BUCKET, tmp_object, new_fi, bucket, object)
|
||||
.await
|
||||
.expect("rename_data should commit");
|
||||
assert!(
|
||||
!tmp_parent.exists(),
|
||||
"successful non-inline commit should remove the empty staging parent"
|
||||
);
|
||||
|
||||
// The tmp xl.meta write point uses SyncMode::FileOnly: its parent dir
|
||||
// ({tmp}/{tmp_object}) must not be fsynced.
|
||||
@@ -12654,6 +12927,9 @@ mod test {
|
||||
let tmp_object = "tmp-new-inline";
|
||||
ensure_test_volume(&disk, bucket).await;
|
||||
ensure_test_volume(&disk, RUSTFS_META_TMP_BUCKET).await;
|
||||
let tmp_parent = disk
|
||||
.get_object_path(RUSTFS_META_TMP_BUCKET, tmp_object)
|
||||
.expect("tmp parent should resolve");
|
||||
|
||||
let _mode = durability_mode_override::set(DurabilityMode::Strict);
|
||||
let version_id = Uuid::parse_str("99999999-9999-9999-9999-999999999999").expect("version id should parse");
|
||||
@@ -12662,6 +12938,7 @@ mod test {
|
||||
disk.rename_data(RUSTFS_META_TMP_BUCKET, tmp_object, new_fi, bucket, object)
|
||||
.await
|
||||
.expect("inline rename_data should commit the new object");
|
||||
assert!(!tmp_parent.exists(), "successful inline commit should remove the empty staging parent");
|
||||
|
||||
let bucket_dir = disk.get_bucket_path(bucket).expect("bucket path should resolve");
|
||||
let prefix_dir = disk.get_object_path(bucket, "prefix").expect("prefix path should resolve");
|
||||
@@ -12685,6 +12962,34 @@ mod test {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rename_data_inline_preserves_non_empty_staging_parent() {
|
||||
use tempfile::tempdir;
|
||||
|
||||
let dir = tempdir().expect("temp dir should be created");
|
||||
let endpoint = Endpoint::try_from(dir.path().to_str().expect("temp dir should be utf8")).expect("endpoint should parse");
|
||||
let disk = LocalDisk::new(&endpoint, false).await.expect("local disk should be created");
|
||||
let bucket = "inline-staging-sentinel-bucket";
|
||||
let object = "inline-object";
|
||||
let tmp_object = "inline-stage-with-sentinel";
|
||||
ensure_test_volume(&disk, bucket).await;
|
||||
ensure_test_volume(&disk, RUSTFS_META_TMP_BUCKET).await;
|
||||
|
||||
let tmp_parent = disk
|
||||
.get_object_path(RUSTFS_META_TMP_BUCKET, tmp_object)
|
||||
.expect("tmp parent should resolve");
|
||||
fs::create_dir_all(&tmp_parent).await.expect("tmp parent should be created");
|
||||
let sentinel = tmp_parent.join("sentinel");
|
||||
fs::write(&sentinel, b"keep").await.expect("sentinel should be written");
|
||||
|
||||
let fi = test_file_info(object, Uuid::new_v4(), None, Some(Bytes::from_static(b"inline-payload")));
|
||||
disk.rename_data(RUSTFS_META_TMP_BUCKET, tmp_object, fi, bucket, object)
|
||||
.await
|
||||
.expect("non-empty staging cleanup must not negate the committed object");
|
||||
|
||||
assert_eq!(fs::read(&sentinel).await.expect("sentinel should remain"), b"keep");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[allow(clippy::await_holding_lock)]
|
||||
@@ -12844,7 +13149,7 @@ mod test {
|
||||
let replacement_staging_parent_for_hook = replacement_staging_parent.clone();
|
||||
let staged_metadata_for_hook = staged_metadata.clone();
|
||||
let replacement_staged_metadata_for_hook = replacement_staged_metadata.clone();
|
||||
set_rename_data_after_first_publication(object, move || {
|
||||
set_rename_data_after_first_publication(&disk.root, bucket, object, move || {
|
||||
std::fs::rename(&object_dir_for_hook, &replacement_dir_for_hook)
|
||||
.expect_err("the destination object identity must remain pinned until xl.meta commits");
|
||||
std::fs::rename(&staging_parent_for_hook, &replacement_staging_parent_for_hook)
|
||||
@@ -12859,7 +13164,10 @@ mod test {
|
||||
.expect("non-inline rename_data should commit");
|
||||
|
||||
assert!(!replacement_dir.exists(), "the destination object directory must not be replaced");
|
||||
assert!(staging_parent.exists(), "the guarded staging parent must retain its identity");
|
||||
assert!(
|
||||
!staging_parent.exists(),
|
||||
"successful commit should remove the empty staging parent after releasing its guard"
|
||||
);
|
||||
assert!(
|
||||
!replacement_staging_parent.exists(),
|
||||
"the staging parent must not be replaced between data and metadata publication"
|
||||
@@ -13108,7 +13416,7 @@ mod test {
|
||||
let replacement_dir_for_hook = replacement_dir.clone();
|
||||
let staged_metadata_for_hook = staged_metadata.clone();
|
||||
let replacement_staged_metadata_for_hook = replacement_staged_metadata.clone();
|
||||
set_rename_data_after_first_publication(object, move || {
|
||||
set_rename_data_after_first_publication(&disk.root, bucket, object, move || {
|
||||
std::fs::rename(&object_dir_for_hook, &replacement_dir_for_hook)
|
||||
.expect_err("the destination object identity must remain pinned after publishing its rollback backup");
|
||||
std::fs::rename(&staged_metadata_for_hook, &replacement_staged_metadata_for_hook)
|
||||
@@ -13474,7 +13782,7 @@ mod test {
|
||||
|
||||
let (entered_tx, entered_rx) = mpsc::channel();
|
||||
let (release_tx, release_rx) = mpsc::channel();
|
||||
set_rename_data_after_first_publication(object, move || {
|
||||
set_rename_data_after_first_publication(&disk.root, bucket, object, move || {
|
||||
entered_tx.send(()).expect("signal first publication");
|
||||
release_rx.recv().expect("wait while delete_volume is blocked");
|
||||
});
|
||||
@@ -14068,7 +14376,7 @@ mod test {
|
||||
|
||||
let (published_tx, published_rx) = mpsc::channel();
|
||||
let (release_tx, release_rx) = mpsc::channel();
|
||||
set_rename_data_after_first_publication(object, move || {
|
||||
set_rename_data_after_first_publication(&disk.root, bucket, object, move || {
|
||||
published_tx.send(()).expect("signal backup publication");
|
||||
release_rx.recv().expect("wait for lock-order assertion");
|
||||
});
|
||||
@@ -18723,6 +19031,48 @@ mod test {
|
||||
assert!(matches!(disk.read_all(volume, &first_part).await, Err(DiskError::FileNotFound)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn quota_mutation_fence_revoke_waits_for_active_claim_and_rejects_late_claims() {
|
||||
use tempfile::tempdir;
|
||||
|
||||
let root_dir = tempdir().expect("temp dir should be created");
|
||||
let endpoint = Endpoint::try_from(root_dir.path().to_string_lossy().as_ref()).expect("endpoint should parse");
|
||||
let disk = Arc::new(LocalDisk::new(&endpoint, false).await.expect("local disk should be created"));
|
||||
let bucket = "quota-fence-volume";
|
||||
let object = "object";
|
||||
let fence_path = quota_mutation_fence_path(bucket, object);
|
||||
let token = disk
|
||||
.acquire_snapshot_lease(RUSTFS_META_BUCKET, &fence_path)
|
||||
.await
|
||||
.expect("quota mutation token should be prepared");
|
||||
let claim = disk
|
||||
.claim_quota_mutation_fence(bucket, object, token)
|
||||
.await
|
||||
.expect("prepared token should be claimable");
|
||||
|
||||
let release_disk = Arc::clone(&disk);
|
||||
let mut release = tokio::spawn(async move {
|
||||
release_disk
|
||||
.release_snapshot_lease(RUSTFS_META_BUCKET, &fence_path, SnapshotLeaseToken::revoke_all())
|
||||
.await
|
||||
});
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_millis(50), &mut release).await.is_err(),
|
||||
"revoke must wait until an already claimed mutation has finished"
|
||||
);
|
||||
|
||||
drop(claim);
|
||||
tokio::time::timeout(Duration::from_secs(1), release)
|
||||
.await
|
||||
.expect("revoke should wake after the final claim drops")
|
||||
.expect("revoke task should not panic")
|
||||
.expect("revoke should succeed");
|
||||
assert!(matches!(
|
||||
disk.claim_quota_mutation_fence(bucket, object, token).await,
|
||||
Err(DiskError::FileNotFound)
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_version_keeps_later_part_until_snapshot_release() {
|
||||
use tempfile::tempdir;
|
||||
|
||||
@@ -55,6 +55,7 @@ pub fn part_transaction_path(part_path: &str) -> String {
|
||||
|
||||
use crate::cluster::rpc::RemoteDisk;
|
||||
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::health_state::RuntimeDriveHealthState;
|
||||
use crate::disk::local::ScanGuard;
|
||||
@@ -65,12 +66,35 @@ use error::{Error, Result};
|
||||
use local::LocalDisk;
|
||||
use rustfs_filemeta::{FileInfo, ObjectPartInfo, RawFileInfo};
|
||||
use rustfs_madmin::info_commands::DiskMetrics;
|
||||
use rustfs_rio::ChunkReaderBox;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{fmt::Debug, path::PathBuf, sync::Arc, time::Duration};
|
||||
use time::OffsetDateTime;
|
||||
use tokio::io::{AsyncRead, AsyncWrite};
|
||||
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 FileReader = Box<dyn AsyncRead + Send + Sync + Unpin>;
|
||||
@@ -95,6 +119,20 @@ impl SnapshotLeaseToken {
|
||||
pub fn as_bytes(&self) -> &[u8; 16] {
|
||||
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 {
|
||||
@@ -397,10 +435,8 @@ impl DiskAPI for Disk {
|
||||
dst_volume: &str,
|
||||
dst_path: &str,
|
||||
) -> Result<RenameDataResp> {
|
||||
match self {
|
||||
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,
|
||||
}
|
||||
self.rename_data_borrowed(src_volume, src_path, &fi, dst_volume, dst_path)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tracing::instrument(level = "trace", skip_all)]
|
||||
@@ -427,6 +463,19 @@ 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)]
|
||||
async fn read_file_mmap_copy(&self, volume: &str, path: &str, offset: usize, length: usize) -> Result<Bytes> {
|
||||
match self {
|
||||
@@ -617,6 +666,30 @@ 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 {
|
||||
pub async fn ns_scanner_server_epoch(&self) -> Result<Option<Uuid>> {
|
||||
match self {
|
||||
@@ -865,6 +938,18 @@ pub trait DiskAPI: Debug + Send + Sync + 'static {
|
||||
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>;
|
||||
|
||||
/// 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.
|
||||
async fn read_file_mmap_copy(&self, volume: &str, path: &str, offset: usize, length: usize) -> Result<Bytes>;
|
||||
|
||||
|
||||
@@ -306,12 +306,20 @@ fn disk_namespace_mutation_lock(path: &Path) -> Arc<NamespaceMutationLock> {
|
||||
pub(crate) struct NamespaceMutationLease {
|
||||
_namespace_guard: OwnedMutexGuard<()>,
|
||||
_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> {
|
||||
Arc::new(NamespaceMutationLease {
|
||||
_namespace_guard: disk_namespace_mutation_lock(path).lock_owned().await,
|
||||
_volume_guard: None,
|
||||
external_guard: Mutex::new(None),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -327,6 +335,7 @@ pub(crate) async fn acquire_rename_data_mutation_lease(
|
||||
Arc::new(NamespaceMutationLease {
|
||||
_namespace_guard: namespace_guard,
|
||||
_volume_guard: Some(volume_guard),
|
||||
external_guard: Mutex::new(None),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ 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) trait DecodeWorkspace: Send + Sync + 'static {
|
||||
#[allow(dead_code, reason = "workspace width asserted by decode_reader tests (backlog#1823)")]
|
||||
fn shard_len(&self) -> usize;
|
||||
}
|
||||
|
||||
@@ -33,11 +34,14 @@ pub(crate) trait ErasureDecodeEngine: Send + Sync + 'static {
|
||||
type Workspace: DecodeWorkspace;
|
||||
|
||||
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 block_size(&self) -> usize;
|
||||
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;
|
||||
#[allow(dead_code, reason = "engine trait facet asserted by decode_reader tests (backlog#1823)")]
|
||||
fn supports_aligned_shards(&self) -> bool;
|
||||
|
||||
fn prepare_workspace(&self, shard_len: usize) -> io::Result<Self::Workspace>;
|
||||
|
||||
@@ -24,6 +24,7 @@ impl RustfsCodecDecodeWorkspace {
|
||||
}
|
||||
|
||||
#[inline]
|
||||
#[allow(dead_code, reason = "workspace width asserted by decode_reader tests (backlog#1823)")]
|
||||
pub(crate) fn shard_len(&self) -> usize {
|
||||
self.shard_len
|
||||
}
|
||||
@@ -76,6 +77,13 @@ impl ShardBufferPool {
|
||||
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)]
|
||||
fn stored_capacity(&self, index: usize) -> Option<usize> {
|
||||
self.buffers.get(index).and_then(|buf| buf.as_ref().map(Vec::capacity))
|
||||
|
||||
@@ -14,7 +14,10 @@
|
||||
|
||||
use pin_project_lite::pin_project;
|
||||
use rustfs_utils::HashAlgorithm;
|
||||
use std::future::poll_fn;
|
||||
use std::io::IoSlice;
|
||||
use std::pin::Pin;
|
||||
use std::task::{Context, Poll};
|
||||
use std::time::Duration;
|
||||
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
|
||||
use tracing::error;
|
||||
@@ -23,6 +26,18 @@ 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.
|
||||
///
|
||||
@@ -42,6 +57,12 @@ pub trait ShardSource: AsyncRead + Send + Sync + Unpin {
|
||||
fn try_take_block(&mut self, _n: usize) -> Option<bytes::Bytes> {
|
||||
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
|
||||
@@ -75,6 +96,9 @@ pin_project! {
|
||||
// contiguous on-disk `[hash][data]` block so both are pulled in a single
|
||||
// pass; grown lazily and never shrunk.
|
||||
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,
|
||||
last_verify_duration: Duration,
|
||||
}
|
||||
@@ -91,6 +115,7 @@ where
|
||||
hash_algo: algo,
|
||||
shard_size,
|
||||
buf: Vec::new(),
|
||||
chunks: Vec::new(),
|
||||
skip_verify,
|
||||
last_verify_duration: Duration::ZERO,
|
||||
}
|
||||
@@ -100,6 +125,11 @@ where
|
||||
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
|
||||
/// 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
|
||||
@@ -260,11 +290,6 @@ where
|
||||
|
||||
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) {
|
||||
let (data, verify) = split_and_verify(&self.hash_algo, self.skip_verify, &block)?;
|
||||
out.extend_from_slice(data);
|
||||
@@ -272,6 +297,126 @@ where
|
||||
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
|
||||
// the sink differs (`extend_from_slice` into `out` instead of
|
||||
// `copy_from_slice` into a pre-zeroed buffer).
|
||||
@@ -677,18 +822,167 @@ impl BitrotWriterWrapper {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::ShardSource;
|
||||
use super::{
|
||||
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 std::io::{Cursor, IoSlice};
|
||||
use std::collections::VecDeque;
|
||||
use std::io::{self, Cursor, IoSlice};
|
||||
use std::pin::Pin;
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
};
|
||||
use std::task::{Context, Poll};
|
||||
use tokio::io::{AsyncWrite, AsyncWriteExt};
|
||||
use std::time::Duration;
|
||||
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)]
|
||||
struct VectoredCountingWriter {
|
||||
@@ -1446,6 +1740,70 @@ mod tests {
|
||||
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]
|
||||
async fn read_appending_rejects_a_want_larger_than_the_shard() {
|
||||
let algo = HashAlgorithm::HighwayHash256;
|
||||
@@ -1497,10 +1855,21 @@ mod tests {
|
||||
|
||||
// Equivalence: same bytes out of both paths.
|
||||
let mut via_mem: Vec<u8> = Vec::with_capacity(SHARD);
|
||||
BitrotReader::new(Cursor::new(Bytes::from(encoded.clone())), SHARD, algo.clone(), false)
|
||||
let mut memory_reader = BitrotReader::new(Cursor::new(Bytes::from(encoded.clone())), SHARD, algo.clone(), false);
|
||||
memory_reader
|
||||
.read_appending(&mut via_mem, SHARD)
|
||||
.await
|
||||
.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);
|
||||
BitrotReader::new(Cursor::new(encoded), SHARD, algo, false)
|
||||
@@ -1537,4 +1906,152 @@ mod tests {
|
||||
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
|
||||
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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,9 @@ use crate::disk::error_reduce::reduce_errs;
|
||||
use crate::erasure::codec::workspace::ShardBufferPool;
|
||||
use crate::erasure::coding::{BitrotReader, Erasure};
|
||||
use crate::io_support::bitrot::DeferredReaderStripeHandle;
|
||||
use crate::set_disk::shard_source::{ShardReadCost, ShardStripeSource, StripeReadState};
|
||||
use crate::set_disk::shard_source::{
|
||||
INLINE_SHARD_SLOTS, ShardBuffers, ShardErrors, ShardReadCost, ShardStripeSource, StripeReadState,
|
||||
};
|
||||
use futures::FutureExt;
|
||||
use futures::stream::{FuturesUnordered, StreamExt};
|
||||
use pin_project_lite::pin_project;
|
||||
@@ -41,9 +43,6 @@ use tracing::{debug, error, warn};
|
||||
|
||||
type ShardReadFuture<'a> = Pin<Box<dyn Future<Output = (usize, ShardReadCost, Result<Vec<u8>, Error>, bool)> + Send + 'a>>;
|
||||
|
||||
const INLINE_SHARD_SLOTS: usize = 32;
|
||||
type ShardBuffers = SmallVec<[Option<Vec<u8>>; INLINE_SHARD_SLOTS]>;
|
||||
type ShardErrors = SmallVec<[Option<Error>; INLINE_SHARD_SLOTS]>;
|
||||
type ShardIndexes = SmallVec<[usize; INLINE_SHARD_SLOTS]>;
|
||||
type ActiveReaders = SmallVec<[bool; INLINE_SHARD_SLOTS]>;
|
||||
|
||||
@@ -214,6 +213,7 @@ 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> {
|
||||
let mut order: Vec<usize> = (0..num_readers).collect();
|
||||
if locality_preference_enabled {
|
||||
@@ -392,6 +392,7 @@ pub(crate) struct ParallelReader<R> {
|
||||
// Request-scoped shard buffers keyed by shard index. Keeping ownership in
|
||||
// `ParallelReader` avoids dropping unused parity/backup slot buffers between stripes.
|
||||
buffers: ShardBufferPool,
|
||||
stripe_state: Option<Box<StripeReadState>>,
|
||||
// Lockstep-path state (verify_reconstruction == true). `engaged[i]` marks
|
||||
// readers that participate in each stripe read: all data slots from the
|
||||
// start, parity slots only once a data shard is missing/dead. Unengaged
|
||||
@@ -408,6 +409,10 @@ where
|
||||
R: crate::erasure::coding::ShardSource,
|
||||
{
|
||||
// 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 {
|
||||
Self::new_with_metrics_path_read_timeout_and_reconstruction_verification(
|
||||
readers,
|
||||
@@ -420,6 +425,7 @@ where
|
||||
)
|
||||
}
|
||||
|
||||
#[allow(dead_code, reason = "constructor used only by this file's tests (backlog#1823)")]
|
||||
pub fn new_with_metrics_path(
|
||||
readers: Vec<Option<BitrotReader<R>>>,
|
||||
e: Erasure,
|
||||
@@ -438,6 +444,7 @@ where
|
||||
)
|
||||
}
|
||||
|
||||
#[allow(dead_code, reason = "constructor used only by this file's tests (backlog#1823)")]
|
||||
pub fn new_with_metrics_path_and_read_costs(
|
||||
readers: Vec<Option<BitrotReader<R>>>,
|
||||
e: Erasure,
|
||||
@@ -514,6 +521,7 @@ where
|
||||
)
|
||||
}
|
||||
|
||||
#[allow(dead_code, reason = "constructor used only by this file's tests (backlog#1823)")]
|
||||
fn new_with_read_timeout(
|
||||
readers: Vec<Option<BitrotReader<R>>>,
|
||||
e: Erasure,
|
||||
@@ -596,6 +604,7 @@ where
|
||||
verify_reconstruction,
|
||||
locality_preference_enabled: get_shard_locality_preference_enabled(),
|
||||
buffers: ShardBufferPool::new(e.data_shards + e.parity_shards),
|
||||
stripe_state: None,
|
||||
engaged,
|
||||
deferred_handles: Vec::new(),
|
||||
stripe_index: 0,
|
||||
@@ -700,6 +709,12 @@ where
|
||||
{
|
||||
#[hotpath::measure(impl_type = "ParallelReader")]
|
||||
pub async fn read(&mut self) -> StripeReadOutput {
|
||||
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
|
||||
// in lockstep so all readers advance one block per stripe and stay
|
||||
// mutually aligned. The adaptive data-first path below only reads
|
||||
@@ -709,12 +724,14 @@ where
|
||||
// than the data shards, producing "inconsistent read source shards" and
|
||||
// truncating large-object GETs under concurrency (backlog#832).
|
||||
if self.verify_reconstruction {
|
||||
return self.read_lockstep().await;
|
||||
self.read_lockstep(state).await;
|
||||
return;
|
||||
}
|
||||
// if self.readers.len() != self.total_shards {
|
||||
// return Err(io::Error::new(ErrorKind::InvalidInput, "Invalid number of readers"));
|
||||
// }
|
||||
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 {
|
||||
self.shard_file_size - self.offset
|
||||
@@ -723,7 +740,7 @@ where
|
||||
};
|
||||
|
||||
if shard_size == 0 {
|
||||
return (smallvec![None; num_readers], smallvec![None; num_readers]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Advance to the next stripe so the following read() computes the correct
|
||||
@@ -734,8 +751,7 @@ where
|
||||
// is only read above to derive `shard_size`, so advancing here is safe.
|
||||
self.offset += shard_size;
|
||||
|
||||
let mut shards: ShardBuffers = smallvec![None; num_readers];
|
||||
let mut errs: ShardErrors = smallvec![None; num_readers];
|
||||
let (shards, errs) = state.parts_mut();
|
||||
let read_costs = self.read_costs.as_slice();
|
||||
let locality_preference_enabled = self.locality_preference_enabled;
|
||||
let low_cost_available = self
|
||||
@@ -882,8 +898,8 @@ where
|
||||
}
|
||||
|
||||
let result_is_err = record_shard_read_result(
|
||||
&mut shards,
|
||||
&mut errs,
|
||||
shards,
|
||||
errs,
|
||||
&mut retire_readers,
|
||||
&mut success,
|
||||
&mut successful_costs,
|
||||
@@ -944,8 +960,8 @@ where
|
||||
active_readers[i] = false;
|
||||
completed += 1;
|
||||
if record_shard_read_result(
|
||||
&mut shards,
|
||||
&mut errs,
|
||||
shards,
|
||||
errs,
|
||||
&mut retire_readers,
|
||||
&mut success,
|
||||
&mut successful_costs,
|
||||
@@ -957,7 +973,7 @@ where
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
retire_abandoned_readers(&mut errs, &mut retire_readers, &active_readers);
|
||||
retire_abandoned_readers(errs, &mut retire_readers, &active_readers);
|
||||
}
|
||||
|
||||
if let Some(path) = self.metrics_path {
|
||||
@@ -1001,8 +1017,6 @@ where
|
||||
for i in retire_readers {
|
||||
self.readers[i] = None;
|
||||
}
|
||||
|
||||
(shards, errs)
|
||||
}
|
||||
|
||||
/// Lockstep stripe read for the reconstruction-verifying GET path.
|
||||
@@ -1030,18 +1044,18 @@ where
|
||||
/// stripe would reintroduce the desync. A parity reader that cannot be
|
||||
/// realigned (no pending deferred handle) is likewise retired instead of
|
||||
/// being read out of position.
|
||||
async fn read_lockstep(&mut self) -> StripeReadOutput {
|
||||
async fn read_lockstep(&mut self, state: &mut StripeReadState) {
|
||||
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 {
|
||||
self.shard_file_size - self.offset
|
||||
} else {
|
||||
self.shard_size
|
||||
};
|
||||
|
||||
let mut shards: ShardBuffers = smallvec![None; num_readers];
|
||||
let mut errs: ShardErrors = smallvec![None; num_readers];
|
||||
let (shards, errs) = state.parts_mut();
|
||||
if shard_size == 0 {
|
||||
return (shards, errs);
|
||||
return;
|
||||
}
|
||||
|
||||
// Advance to the next stripe (see the matching note in `read`); the
|
||||
@@ -1279,8 +1293,6 @@ where
|
||||
for i in retire_readers {
|
||||
self.readers[i] = None;
|
||||
}
|
||||
|
||||
(shards, errs)
|
||||
}
|
||||
|
||||
/// Attempt to bring an as-yet-unread parity reader into the lockstep read
|
||||
@@ -1326,10 +1338,6 @@ 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]
|
||||
@@ -1337,10 +1345,20 @@ impl<R> ShardStripeSource for ParallelReader<R>
|
||||
where
|
||||
R: crate::erasure::coding::ShardSource,
|
||||
{
|
||||
async fn read_next_stripe(&mut self) -> StripeReadState {
|
||||
let read_quorum = self.data_shards;
|
||||
let (shards, errors) = ParallelReader::read(self).await;
|
||||
StripeReadState::from_parts_with_read_costs(shards, errors, &self.read_costs, read_quorum)
|
||||
async fn read_next_stripe(&mut self) -> Box<StripeReadState> {
|
||||
let mut state = self
|
||||
.stripe_state
|
||||
.take()
|
||||
.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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1525,6 +1543,7 @@ impl Erasure {
|
||||
.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>(
|
||||
&self,
|
||||
writer: &mut W,
|
||||
@@ -1595,9 +1614,9 @@ impl Erasure {
|
||||
*ret_err = Some(err.into());
|
||||
}
|
||||
|
||||
// Equivalent to `ParallelReader::can_decode`; inlined so this helper does
|
||||
// not need to borrow the reader, leaving the reader free for the
|
||||
// concurrent next-stripe read under prefetch.
|
||||
// Shard-availability check, written out here rather than called on the
|
||||
// reader so this helper does not need to borrow it, leaving the reader
|
||||
// free for the concurrent next-stripe read under prefetch.
|
||||
let available_shards = shards.iter().filter(|shard| shard.is_some()).count();
|
||||
if available_shards < self.data_shards {
|
||||
let reason = GetObjectFailureReason::ReadQuorum;
|
||||
@@ -1972,13 +1991,18 @@ mod tests {
|
||||
type BoxedShardReader = crate::io_support::bitrot::ShardReader;
|
||||
|
||||
#[test]
|
||||
fn shard_scratch_stays_inline_through_the_common_limit_and_spills_safely() {
|
||||
let inline: ShardBuffers = smallvec![None; INLINE_SHARD_SLOTS];
|
||||
assert!(!inline.spilled(), "the common shard-count boundary must not allocate");
|
||||
|
||||
let spilled: ShardBuffers = smallvec![None; INLINE_SHARD_SLOTS + 1];
|
||||
assert!(spilled.spilled(), "larger supported shard counts must fall back to the heap");
|
||||
assert_eq!(spilled.len(), INLINE_SHARD_SLOTS + 1);
|
||||
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]
|
||||
@@ -1997,6 +2021,62 @@ mod tests {
|
||||
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
|
||||
/// a decode path actually touches (backlog#923 call-count evidence).
|
||||
struct CountingShardReader {
|
||||
|
||||
@@ -65,7 +65,7 @@ enum FillPolicy {
|
||||
}
|
||||
|
||||
impl FillPolicy {
|
||||
fn from_env() -> Self {
|
||||
fn load() -> Self {
|
||||
match rustfs_utils::get_env_usize(
|
||||
ENV_RUSTFS_GET_CODEC_STREAMING_MAX_INFLIGHT,
|
||||
DEFAULT_RUSTFS_GET_CODEC_STREAMING_MAX_INFLIGHT,
|
||||
@@ -75,6 +75,22 @@ 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 {
|
||||
match self {
|
||||
Self::SingleInFlight => 1,
|
||||
@@ -122,6 +138,10 @@ where
|
||||
S: ShardStripeSource + Send + '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> {
|
||||
Self::new_with_metrics_path(source, engine, total_length, GET_OBJECT_PATH_CODEC_STREAMING)
|
||||
}
|
||||
@@ -479,22 +499,30 @@ where
|
||||
let mut deferred_error = None;
|
||||
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 state = source.read_next_stripe().await;
|
||||
let mut state = source.read_next_stripe().await;
|
||||
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 mut output_buf = reusable_buffers.pop().unwrap_or_default();
|
||||
let result =
|
||||
match decode_stripe_into(metrics_path, stage_metrics_enabled, engine, workspace, state, remaining, &mut output_buf) {
|
||||
Ok(true) => Ok(Some(output_buf)),
|
||||
Ok(false) => {
|
||||
reusable_buffers.push(output_buf);
|
||||
Ok(None)
|
||||
}
|
||||
Err(err) => {
|
||||
reusable_buffers.push(output_buf);
|
||||
Err(err)
|
||||
}
|
||||
};
|
||||
let result = match decode_stripe_into(
|
||||
metrics_path,
|
||||
stage_metrics_enabled,
|
||||
engine,
|
||||
workspace,
|
||||
&mut state,
|
||||
remaining,
|
||||
&mut output_buf,
|
||||
) {
|
||||
Ok(true) => Ok(Some(output_buf)),
|
||||
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);
|
||||
if let Ok(Some(first_buf)) = result.as_ref() {
|
||||
let mut remaining_after_first = remaining.saturating_sub(first_buf.len());
|
||||
@@ -503,7 +531,7 @@ where
|
||||
break;
|
||||
}
|
||||
let stripe_read_stage_start = get_stage_timer_if_enabled(stage_metrics_enabled);
|
||||
let state = source.read_next_stripe().await;
|
||||
let mut state = source.read_next_stripe().await;
|
||||
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 mut queued_buf = reusable_buffers.pop().unwrap_or_default();
|
||||
@@ -512,10 +540,11 @@ where
|
||||
stage_metrics_enabled,
|
||||
engine,
|
||||
workspace,
|
||||
state,
|
||||
&mut state,
|
||||
remaining_after_first,
|
||||
&mut queued_buf,
|
||||
);
|
||||
source.recycle_stripe(state);
|
||||
record_get_stage_duration_if_enabled(metrics_path, GET_STAGE_DECODE, decode_stage_start);
|
||||
match queued_result {
|
||||
Ok(true) => {
|
||||
@@ -654,6 +683,10 @@ pub(crate) struct 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 {
|
||||
Self::new_with_metrics_path(inner, GET_OBJECT_PATH_CODEC_STREAMING)
|
||||
}
|
||||
@@ -717,7 +750,7 @@ fn decode_stripe_into<E>(
|
||||
stage_metrics_enabled: bool,
|
||||
engine: &E,
|
||||
workspace: &mut E::Workspace,
|
||||
state: StripeReadState,
|
||||
state: &mut StripeReadState,
|
||||
remaining: usize,
|
||||
output: &mut Vec<u8>,
|
||||
) -> io::Result<bool>
|
||||
@@ -725,7 +758,7 @@ where
|
||||
E: ErasureDecodeEngine,
|
||||
{
|
||||
output.clear();
|
||||
if state.slots().is_empty() {
|
||||
if state.is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
if !state.can_decode() {
|
||||
@@ -741,13 +774,12 @@ where
|
||||
);
|
||||
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);
|
||||
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);
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
let (mut shards, _errs) = state.into_parts();
|
||||
let reconstruct_outcome = match engine.reconstruct_into(&mut shards, workspace) {
|
||||
let reconstruct_outcome = match engine.reconstruct_into(state.shards_mut(), workspace) {
|
||||
Ok(outcome) => outcome,
|
||||
Err(err) => {
|
||||
record_get_stage_duration_if_enabled(metrics_path, GET_STAGE_RECONSTRUCT, reconstruct_stage_start);
|
||||
@@ -757,7 +789,7 @@ where
|
||||
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);
|
||||
|
||||
if shards.len() < engine.data_shards() {
|
||||
if state.shards_mut().len() < engine.data_shards() {
|
||||
return Err(io::Error::new(
|
||||
ErrorKind::UnexpectedEof,
|
||||
"decoded stripe has fewer shards than data shard count",
|
||||
@@ -766,7 +798,7 @@ where
|
||||
|
||||
let emit_stage_start = get_stage_timer_if_enabled(stage_metrics_enabled);
|
||||
reserve_output_capacity(output, engine.block_size().min(remaining));
|
||||
for shard in shards.iter().take(engine.data_shards()) {
|
||||
for shard in state.shards_mut().iter().take(engine.data_shards()) {
|
||||
if output.len() >= remaining {
|
||||
break;
|
||||
}
|
||||
@@ -781,6 +813,7 @@ where
|
||||
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>> {
|
||||
let mut output = Vec::new();
|
||||
emit_data_shards_into(state, data_shards, block_size, remaining, &mut output)?;
|
||||
@@ -806,10 +839,7 @@ fn emit_data_shards_into(
|
||||
if output.len() >= remaining {
|
||||
break;
|
||||
}
|
||||
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 {
|
||||
let Some(shard) = state.data_bytes(index) else {
|
||||
return Err(io::Error::new(ErrorKind::UnexpectedEof, "decoded stripe is missing a data shard"));
|
||||
};
|
||||
let copy_len = shard.len().min(remaining - output.len());
|
||||
@@ -826,7 +856,7 @@ mod tests {
|
||||
};
|
||||
use crate::erasure::coding::decode::ParallelReader;
|
||||
use crate::erasure::coding::{BitrotReader, BitrotWriter, Erasure};
|
||||
use crate::set_disk::shard_source::{ShardSlot, StripeReadState};
|
||||
use crate::set_disk::shard_source::StripeReadState;
|
||||
use rustfs_utils::HashAlgorithm;
|
||||
use std::collections::VecDeque;
|
||||
use std::future::{pending, poll_fn};
|
||||
@@ -845,6 +875,13 @@ mod tests {
|
||||
read_count: Option<Arc<AtomicUsize>>,
|
||||
}
|
||||
|
||||
struct RecordingStripeSource {
|
||||
stripes: VecDeque<StripeReadState>,
|
||||
read_quorum: usize,
|
||||
reads: usize,
|
||||
recycles: usize,
|
||||
}
|
||||
|
||||
struct BlockingSource {
|
||||
started: Arc<Notify>,
|
||||
dropped: Arc<AtomicUsize>,
|
||||
@@ -899,25 +936,43 @@ mod tests {
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ShardStripeSource for VecStripeSource {
|
||||
async fn read_next_stripe(&mut self) -> StripeReadState {
|
||||
async fn read_next_stripe(&mut self) -> Box<StripeReadState> {
|
||||
if let Some(read_count) = &self.read_count {
|
||||
read_count.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
self.stripes
|
||||
.pop_front()
|
||||
.unwrap_or_else(|| StripeReadState::new(Vec::new(), self.read_quorum))
|
||||
Box::new(
|
||||
self.stripes
|
||||
.pop_front()
|
||||
.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]
|
||||
impl ShardStripeSource for BlockingSource {
|
||||
async fn read_next_stripe(&mut self) -> StripeReadState {
|
||||
async fn read_next_stripe(&mut self) -> Box<StripeReadState> {
|
||||
let _guard = BlockingSourceDropGuard {
|
||||
dropped: Arc::clone(&self.dropped),
|
||||
};
|
||||
self.started.notify_one();
|
||||
pending::<()>().await;
|
||||
StripeReadState::new(Vec::new(), self.read_quorum)
|
||||
Box::new(StripeReadState::from_parts(Vec::new(), Vec::new(), self.read_quorum))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1090,6 +1145,23 @@ 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]
|
||||
fn erasure_decode_reader_rejects_invalid_engine_shape() {
|
||||
let source = VecStripeSource {
|
||||
@@ -1689,7 +1761,10 @@ mod tests {
|
||||
.pop_front()
|
||||
.expect("first stripe should exist");
|
||||
let mut source = VecStripeSource {
|
||||
stripes: VecDeque::from([first_state, StripeReadState::new(Vec::new(), erasure.data_shards)]),
|
||||
stripes: VecDeque::from([
|
||||
first_state,
|
||||
StripeReadState::from_parts(Vec::new(), Vec::new(), erasure.data_shards),
|
||||
]),
|
||||
read_quorum: erasure.data_shards,
|
||||
read_count: None,
|
||||
};
|
||||
@@ -1724,13 +1799,14 @@ mod tests {
|
||||
.stripes
|
||||
.pop_front()
|
||||
.expect("first stripe should exist");
|
||||
let mut source = VecStripeSource {
|
||||
let mut source = RecordingStripeSource {
|
||||
stripes: VecDeque::from([
|
||||
first_state,
|
||||
StripeReadState::new(vec![ShardSlot::data(0, vec![1])], erasure.data_shards),
|
||||
StripeReadState::from_parts(vec![Some(vec![1])], Vec::new(), erasure.data_shards),
|
||||
]),
|
||||
read_quorum: erasure.data_shards,
|
||||
read_count: None,
|
||||
reads: 0,
|
||||
recycles: 0,
|
||||
};
|
||||
let engine = LegacyEcDecodeEngine::new(erasure);
|
||||
let mut workspace = engine.prepare_workspace(4).expect("workspace should be prepared");
|
||||
@@ -1756,6 +1832,8 @@ mod tests {
|
||||
.kind(),
|
||||
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]
|
||||
@@ -1768,7 +1846,7 @@ mod tests {
|
||||
.stripes
|
||||
.pop_front()
|
||||
.expect("first stripe should exist"),
|
||||
StripeReadState::new(Vec::new(), erasure.data_shards),
|
||||
StripeReadState::from_parts(Vec::new(), Vec::new(), erasure.data_shards),
|
||||
]),
|
||||
read_quorum: erasure.data_shards,
|
||||
read_count: None,
|
||||
@@ -2028,17 +2106,11 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emit_data_shards_preserves_output_order_for_out_of_order_slots() {
|
||||
let state = StripeReadState::new(
|
||||
vec![
|
||||
ShardSlot::data(1, b"cd".to_vec()),
|
||||
ShardSlot::data(0, b"ab".to_vec()),
|
||||
ShardSlot::data(2, b"ef".to_vec()),
|
||||
],
|
||||
2,
|
||||
);
|
||||
fn emit_data_shards_preserves_output_order() {
|
||||
let state =
|
||||
StripeReadState::from_parts(vec![Some(b"ab".to_vec()), Some(b"cd".to_vec()), Some(b"ef".to_vec())], Vec::new(), 2);
|
||||
|
||||
let output = emit_data_shards(&state, 3, 6, 5).expect("out-of-order data slots should emit by shard index");
|
||||
let output = emit_data_shards(&state, 3, 6, 5).expect("data slots should emit by shard index");
|
||||
|
||||
assert_eq!(output, b"abcde");
|
||||
}
|
||||
@@ -2051,27 +2123,27 @@ mod tests {
|
||||
};
|
||||
let mut workspace = engine.prepare_workspace(4).expect("workspace should be prepared");
|
||||
let mut output = Vec::with_capacity(1);
|
||||
let short_state = StripeReadState::new(vec![ShardSlot::data(0, vec![1, 2, 3, 4])], 1);
|
||||
let mut short_state = StripeReadState::from_parts(vec![Some(vec![1, 2, 3, 4])], Vec::new(), 1);
|
||||
|
||||
let err = decode_stripe_into(
|
||||
GET_OBJECT_PATH_CODEC_STREAMING,
|
||||
false,
|
||||
&engine,
|
||||
&mut workspace,
|
||||
short_state,
|
||||
&mut short_state,
|
||||
8,
|
||||
&mut output,
|
||||
)
|
||||
.expect_err("decoded stripe shorter than data shard count must fail");
|
||||
assert_eq!(err.kind(), ErrorKind::UnexpectedEof);
|
||||
|
||||
let missing_state = StripeReadState::from_parts(vec![None, Some(vec![5, 6, 7, 8])], Vec::new(), 1);
|
||||
let mut missing_state = StripeReadState::from_parts(vec![None, Some(vec![5, 6, 7, 8])], Vec::new(), 1);
|
||||
let err = decode_stripe_into(
|
||||
GET_OBJECT_PATH_CODEC_STREAMING,
|
||||
false,
|
||||
&engine,
|
||||
&mut workspace,
|
||||
missing_state,
|
||||
&mut missing_state,
|
||||
8,
|
||||
&mut output,
|
||||
)
|
||||
@@ -2082,6 +2154,35 @@ mod tests {
|
||||
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]
|
||||
async fn erasure_decode_reader_reports_short_source() {
|
||||
let erasure = Erasure::new(4, 2, 32);
|
||||
|
||||
@@ -18,10 +18,12 @@ use crate::disk::error_reduce::{
|
||||
};
|
||||
use crate::erasure::coding::BitrotWriterWrapper;
|
||||
use crate::erasure::coding::Erasure;
|
||||
use crate::erasure::coding::erasure::EncodedBlock;
|
||||
use crate::runtime::sources as runtime_sources;
|
||||
use bytes::{Bytes, BytesMut};
|
||||
use futures::StreamExt;
|
||||
use futures::stream::FuturesUnordered;
|
||||
use rustfs_utils::HashAlgorithm;
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
use std::vec;
|
||||
@@ -164,6 +166,7 @@ where
|
||||
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 {
|
||||
block.iter().map(Bytes::len).sum()
|
||||
}
|
||||
@@ -223,8 +226,8 @@ async fn send_queued<T>(
|
||||
sender.send(InflightEntry::new(entry, bytes)).await
|
||||
}
|
||||
|
||||
fn queued_batch_bytes(batch: &[Vec<Bytes>]) -> usize {
|
||||
batch.iter().map(|block| queued_block_bytes(block)).sum()
|
||||
fn queued_batch_bytes(batch: &[EncodedBlock]) -> usize {
|
||||
batch.iter().map(EncodedBlock::queued_bytes).sum()
|
||||
}
|
||||
|
||||
fn dominant_error_summary_label(summary: &WriteQuorumFailureSummary) -> &'static str {
|
||||
@@ -336,7 +339,7 @@ impl<'a> MultiWriter<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
async fn write_shard(writer_opt: &mut Option<BitrotWriterWrapper>, err: &mut Option<Error>, shard: &Bytes) {
|
||||
async fn write_shard(writer_opt: &mut Option<BitrotWriterWrapper>, err: &mut Option<Error>, shard: &[u8]) {
|
||||
match writer_opt {
|
||||
Some(writer) => {
|
||||
match writer.write(shard).await {
|
||||
@@ -361,12 +364,20 @@ impl<'a> MultiWriter<'a> {
|
||||
}
|
||||
|
||||
pub async fn write(&mut self, data: Vec<Bytes>) -> std::io::Result<()> {
|
||||
assert_eq!(data.len(), self.writers.len());
|
||||
self.write_shards(data.iter().map(Bytes::as_ref)).await
|
||||
}
|
||||
|
||||
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 mut futures = FuturesUnordered::new();
|
||||
for ((writer_opt, err), shard) in self.writers.iter_mut().zip(self.errs.iter_mut()).zip(data.iter()) {
|
||||
for ((writer_opt, err), shard) in self.writers.iter_mut().zip(self.errs.iter_mut()).zip(shards) {
|
||||
if err.is_some() {
|
||||
continue; // Skip if we already have an error for this writer
|
||||
}
|
||||
@@ -490,10 +501,10 @@ impl<'a> MultiWriter<'a> {
|
||||
}
|
||||
|
||||
impl Erasure {
|
||||
async fn encode_block(self: Arc<Self>, encode_buf: Vec<u8>, len: usize) -> std::io::Result<(Vec<Bytes>, Vec<u8>)> {
|
||||
async fn encode_block(self: Arc<Self>, encode_buf: Vec<u8>, len: usize) -> std::io::Result<(EncodedBlock, Vec<u8>)> {
|
||||
let encode_stage_start = stage_timer_if_enabled();
|
||||
let encode_once = move || {
|
||||
let res = self.encode_data(&encode_buf[..len]);
|
||||
let res = self.encode_data_block(&encode_buf[..len]);
|
||||
(res, encode_buf)
|
||||
};
|
||||
|
||||
@@ -518,9 +529,9 @@ impl Erasure {
|
||||
Ok((res?, returned_buf))
|
||||
}
|
||||
|
||||
async fn encode_block_bytes_mut(self: Arc<Self>, encode_buf: BytesMut, len: usize) -> std::io::Result<Vec<Bytes>> {
|
||||
async fn encode_block_bytes_mut(self: Arc<Self>, encode_buf: BytesMut, len: usize) -> std::io::Result<EncodedBlock> {
|
||||
let encode_stage_start = stage_timer_if_enabled();
|
||||
let encode_once = move || self.encode_data_bytes_mut(encode_buf, len);
|
||||
let encode_once = move || self.encode_data_bytes_mut_block(encode_buf, len);
|
||||
|
||||
let res = match tokio::runtime::Handle::current().runtime_flavor() {
|
||||
// Same rationale as encode_block: inline the short EC burst on the
|
||||
@@ -576,13 +587,46 @@ impl Erasure {
|
||||
));
|
||||
}
|
||||
|
||||
let shards = self.encode_data_owned(buf)?;
|
||||
let block = self.encode_data_owned_block(buf)?;
|
||||
let mut mw = MultiWriter::new(writers, quorum);
|
||||
mw.write(shards).await?;
|
||||
mw.write_block(&block).await?;
|
||||
mw.shutdown().await?;
|
||||
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")]
|
||||
pub async fn encode<R>(
|
||||
self: Arc<Self>,
|
||||
@@ -624,7 +668,7 @@ impl Erasure {
|
||||
let expanded_block_bytes = self.shard_size().saturating_mul(self.total_shard_count());
|
||||
let max_inflight_bytes = erasure_encode_max_inflight_bytes();
|
||||
let inflight_blocks = encode_channel_capacity(expanded_block_bytes, max_inflight_bytes);
|
||||
let (tx, mut rx) = mpsc::channel::<InflightEntry<Vec<Bytes>>>(inflight_blocks);
|
||||
let (tx, mut rx) = mpsc::channel::<InflightEntry<EncodedBlock>>(inflight_blocks);
|
||||
|
||||
let mut task = AbortOnDropTask::new(tokio::spawn(async move {
|
||||
let block_size = self.block_size;
|
||||
@@ -646,7 +690,7 @@ impl Erasure {
|
||||
let encode_buf = buf;
|
||||
let res = self.clone().encode_block_bytes_mut(encode_buf, n).await?;
|
||||
buf = BytesMut::with_capacity(ingest_capacity);
|
||||
let queued_bytes = queued_block_bytes(&res);
|
||||
let queued_bytes = res.queued_bytes();
|
||||
let _producer_stage = rustfs_io_metrics::track_ec_encode_producer_bytes(queued_bytes);
|
||||
let send_wait_stage_start = stage_timer_if_enabled();
|
||||
if let Err(err) = send_queued(&tx, res, queued_bytes).await {
|
||||
@@ -676,7 +720,7 @@ impl Erasure {
|
||||
let encode_buf = std::mem::take(&mut buf);
|
||||
let (res, returned_buf) = self.clone().encode_block(encode_buf, n).await?;
|
||||
buf = returned_buf;
|
||||
let queued_bytes = queued_block_bytes(&res);
|
||||
let queued_bytes = res.queued_bytes();
|
||||
let _producer_stage = rustfs_io_metrics::track_ec_encode_producer_bytes(queued_bytes);
|
||||
let send_wait_stage_start = stage_timer_if_enabled();
|
||||
if let Err(err) = send_queued(&tx, res, queued_bytes).await {
|
||||
@@ -720,9 +764,9 @@ impl Erasure {
|
||||
if block.is_empty() {
|
||||
break;
|
||||
}
|
||||
let _writer_stage = rustfs_io_metrics::track_ec_encode_writer_bytes(queued_block_bytes(&block));
|
||||
let _writer_stage = rustfs_io_metrics::track_ec_encode_writer_bytes(block.queued_bytes());
|
||||
let write_stage_start = stage_timer_if_enabled();
|
||||
if let Err(err) = writers.write(block).await {
|
||||
if let Err(err) = writers.write_block(&block).await {
|
||||
write_err = Some(err);
|
||||
break;
|
||||
}
|
||||
@@ -769,7 +813,7 @@ impl Erasure {
|
||||
let inflight_blocks = encode_channel_capacity(expanded_block_bytes, max_inflight_bytes);
|
||||
let batch_blocks = encode_batch_block_count().min(inflight_blocks);
|
||||
let channel_capacity = inflight_blocks.div_ceil(batch_blocks).max(1);
|
||||
let (tx, mut rx) = mpsc::channel::<InflightEntry<Vec<Vec<Bytes>>>>(channel_capacity);
|
||||
let (tx, mut rx) = mpsc::channel::<InflightEntry<Vec<EncodedBlock>>>(channel_capacity);
|
||||
|
||||
let mut task = AbortOnDropTask::new(tokio::spawn(async move {
|
||||
let block_size = self.block_size;
|
||||
@@ -786,7 +830,7 @@ impl Erasure {
|
||||
let encode_buf = std::mem::take(&mut buf);
|
||||
let (res, returned_buf) = self.clone().encode_block(encode_buf, n).await?;
|
||||
buf = returned_buf;
|
||||
let queued_bytes = queued_block_bytes(&res);
|
||||
let queued_bytes = res.queued_bytes();
|
||||
pending_batch_bytes = pending_batch_bytes.saturating_add(queued_bytes);
|
||||
pending_batch.push(res);
|
||||
drop(pending_batch_stage.take());
|
||||
@@ -845,7 +889,7 @@ impl Erasure {
|
||||
let _writer_stage = rustfs_io_metrics::track_ec_encode_writer_bytes(queued_batch_bytes(&batch));
|
||||
let write_stage_start = stage_timer_if_enabled();
|
||||
for block in batch {
|
||||
if let Err(err) = writers.write(block).await {
|
||||
if let Err(err) = writers.write_block(&block).await {
|
||||
write_err = Some(err);
|
||||
break;
|
||||
}
|
||||
@@ -1895,7 +1939,11 @@ mod tests {
|
||||
let baseline = rustfs_io_metrics::current_ec_encode_inflight_bytes();
|
||||
let (tx, rx) = mpsc::channel(2);
|
||||
let mut rx = rx;
|
||||
let batch = vec![vec![Bytes::from_static(b"queued")], vec![Bytes::from_static(b"batch")]];
|
||||
let erasure = Erasure::new(1, 0, 16);
|
||||
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);
|
||||
|
||||
send_queued(&tx, batch, batch_bytes).await.expect("batch should be queued");
|
||||
@@ -2117,6 +2165,39 @@ 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]
|
||||
async fn encode_returns_unexpected_eof_for_truncated_limited_reader() {
|
||||
let committed = Arc::new(Mutex::new(Vec::new()));
|
||||
@@ -2236,11 +2317,11 @@ mod tests {
|
||||
.expect("bytesmut encode should succeed on current-thread runtime");
|
||||
|
||||
let expected_shard_size = payload.len().div_ceil(erasure.data_shards);
|
||||
assert_eq!(shards.len(), erasure.total_shard_count());
|
||||
assert!(shards.iter().all(|shard| shard.len() == expected_shard_size));
|
||||
assert_eq!(shards.shards().len(), erasure.total_shard_count());
|
||||
assert!(shards.shards().all(|shard| shard.len() == expected_shard_size));
|
||||
|
||||
let mut restored = Vec::new();
|
||||
for shard in shards.iter().take(erasure.data_shards) {
|
||||
for shard in shards.shards().take(erasure.data_shards) {
|
||||
restored.extend_from_slice(shard);
|
||||
}
|
||||
restored.truncate(payload.len());
|
||||
@@ -2343,6 +2424,41 @@ mod tests {
|
||||
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
|
||||
/// and each writer receives data after shutdown.
|
||||
#[tokio::test]
|
||||
@@ -2506,7 +2622,7 @@ mod tests {
|
||||
assert_eq!(&next[..], &data[16..]);
|
||||
}
|
||||
|
||||
async fn committed_shards_for_ingest_mode(use_bytesmut_ingest: bool, uses_legacy: bool, payload: &[u8]) -> Vec<Vec<u8>> {
|
||||
async fn committed_shards_for_pipeline(pipeline: EncodePipeline, uses_legacy: bool, payload: &[u8]) -> Vec<Vec<u8>> {
|
||||
const DATA_SHARDS: usize = 2;
|
||||
const PARITY_SHARDS: usize = 2;
|
||||
const TOTAL_SHARDS: usize = DATA_SHARDS + PARITY_SHARDS;
|
||||
@@ -2520,10 +2636,16 @@ mod tests {
|
||||
|
||||
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, total) = erasure
|
||||
.encode_with_ingest_mode(reader, &mut writers, DATA_SHARDS, use_bytesmut_ingest)
|
||||
.await
|
||||
.expect("encode should succeed");
|
||||
let (_reader, total) = match pipeline {
|
||||
EncodePipeline::Vec => {
|
||||
erasure
|
||||
.encode_with_ingest_mode(reader, &mut writers, DATA_SHARDS, false)
|
||||
.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());
|
||||
|
||||
committed
|
||||
@@ -2532,31 +2654,64 @@ mod tests {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// HP-10 (rustfs/backlog#931) merge gate: the BytesMut ingest path must produce
|
||||
/// byte-for-byte identical shard streams to the default Vec ingest path, for both
|
||||
/// legacy-aware shard-size formulas, across empty, sub-block, exactly-full-block,
|
||||
/// and multi-block-with-partial-tail payloads.
|
||||
async fn expected_committed_shards(uses_legacy: bool, payload: &[u8]) -> Vec<Vec<u8>> {
|
||||
const DATA_SHARDS: usize = 2;
|
||||
const PARITY_SHARDS: usize = 2;
|
||||
const TOTAL_SHARDS: usize = DATA_SHARDS + PARITY_SHARDS;
|
||||
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]
|
||||
async fn bytesmut_ingest_matches_vec_ingest_byte_for_byte() {
|
||||
const BLOCK_SIZE: usize = 64;
|
||||
let payloads: Vec<Vec<u8>> = vec![
|
||||
Vec::new(),
|
||||
b"tiny".to_vec(),
|
||||
vec![1],
|
||||
vec![2; BLOCK_SIZE - 1],
|
||||
(0..BLOCK_SIZE as u32).map(|i| i as u8).collect(), // exactly one full block
|
||||
vec![3u8; BLOCK_SIZE * 4], // whole number of blocks
|
||||
vec![4; BLOCK_SIZE + 1],
|
||||
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
|
||||
];
|
||||
|
||||
for uses_legacy in [false, true] {
|
||||
for payload in &payloads {
|
||||
let vec_path = committed_shards_for_ingest_mode(false, uses_legacy, payload).await;
|
||||
let bytesmut_path = committed_shards_for_ingest_mode(true, uses_legacy, payload).await;
|
||||
assert_eq!(
|
||||
vec_path,
|
||||
bytesmut_path,
|
||||
"ingest paths must be byte-identical (legacy={uses_legacy}, payload_len={})",
|
||||
payload.len()
|
||||
);
|
||||
let expected = expected_committed_shards(uses_legacy, payload).await;
|
||||
for pipeline in [EncodePipeline::Vec, EncodePipeline::BytesMut, EncodePipeline::Batched] {
|
||||
let actual = committed_shards_for_pipeline(pipeline, uses_legacy, payload).await;
|
||||
assert_eq!(
|
||||
actual,
|
||||
expected,
|
||||
"streaming shards must match the public block encoder (legacy={uses_legacy}, payload_len={})",
|
||||
payload.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,12 +29,58 @@ use tokio::io::AsyncRead;
|
||||
use tracing::warn;
|
||||
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_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 LegacyReedSolomonCache = RwLock<HashMap<(usize, usize), Arc<LegacyReedSolomonEncoder>>>;
|
||||
|
||||
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.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
@@ -101,43 +147,61 @@ pub fn calc_shard_size_legacy(block_size: usize, data_shards: usize) -> usize {
|
||||
struct LegacyReedSolomonEncoder {
|
||||
data_shards: usize,
|
||||
parity_shards: usize,
|
||||
encoder_cache: std::sync::RwLock<Option<reed_solomon_simd::ReedSolomonEncoder>>,
|
||||
decoder_cache: std::sync::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),
|
||||
}
|
||||
}
|
||||
cache_workspaces: bool,
|
||||
encoder_cache: RwLock<Option<reed_solomon_simd::ReedSolomonEncoder>>,
|
||||
decoder_cache: RwLock<Option<reed_solomon_simd::ReedSolomonDecoder>>,
|
||||
}
|
||||
|
||||
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 {
|
||||
data_shards: _data_shards,
|
||||
parity_shards: _parity_shards,
|
||||
encoder_cache: std::sync::RwLock::new(None),
|
||||
decoder_cache: std::sync::RwLock::new(None),
|
||||
data_shards,
|
||||
parity_shards,
|
||||
cache_workspaces,
|
||||
encoder_cache: 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<()> {
|
||||
let mut shards_vec: Vec<&mut [u8]> = shards.into_vec();
|
||||
if shards_vec.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
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 cache_guard = self
|
||||
.encoder_cache
|
||||
.write()
|
||||
.map_err(|_| io::Error::other("Failed to acquire encoder cache lock"))?;
|
||||
match cache_guard.take() {
|
||||
match cached_encoder {
|
||||
Some(mut cached) => {
|
||||
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)
|
||||
@@ -164,10 +228,15 @@ impl LegacyReedSolomonEncoder {
|
||||
}
|
||||
}
|
||||
drop(result);
|
||||
*self
|
||||
.encoder_cache
|
||||
.write()
|
||||
.map_err(|_| io::Error::other("Failed to return encoder to cache"))? = Some(encoder);
|
||||
if self.should_cache_workspace(shard_len) {
|
||||
let mut cache = self
|
||||
.encoder_cache
|
||||
.write()
|
||||
.map_err(|_| io::Error::other("Failed to return encoder to cache"))?;
|
||||
if cache.is_none() {
|
||||
*cache = Some(encoder);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -181,13 +250,13 @@ impl LegacyReedSolomonEncoder {
|
||||
.find_map(|s| s.as_ref().map(|v| v.len()))
|
||||
.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 cache_guard = self
|
||||
.decoder_cache
|
||||
.write()
|
||||
.map_err(|_| io::Error::other("Failed to acquire decoder cache lock"))?;
|
||||
|
||||
match cache_guard.take() {
|
||||
match cached_decoder {
|
||||
Some(mut cached_decoder) => {
|
||||
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);
|
||||
@@ -234,10 +303,15 @@ impl LegacyReedSolomonEncoder {
|
||||
|
||||
drop(result);
|
||||
|
||||
*self
|
||||
.decoder_cache
|
||||
.write()
|
||||
.map_err(|_| io::Error::other("Failed to return decoder to cache"))? = Some(decoder);
|
||||
if self.should_cache_workspace(shard_len) {
|
||||
let mut cache = self
|
||||
.decoder_cache
|
||||
.write()
|
||||
.map_err(|_| io::Error::other("Failed to return decoder to cache"))?;
|
||||
if cache.is_none() {
|
||||
*cache = Some(decoder);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -395,6 +469,39 @@ fn cached_modern_reed_solomon(data_shards: usize, parity_shards: usize) -> Resul
|
||||
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<()>
|
||||
where
|
||||
F: FnOnce(SmallVec<[&mut [u8]; 16]>) -> io::Result<()>,
|
||||
@@ -511,7 +618,7 @@ pub struct Erasure {
|
||||
pub data_shards: usize,
|
||||
pub parity_shards: usize,
|
||||
encoder: Option<ReedSolomonEncoder>,
|
||||
legacy_encoder: Option<LegacyReedSolomonEncoder>,
|
||||
legacy_encoder: Option<Arc<LegacyReedSolomonEncoder>>,
|
||||
pub block_size: usize,
|
||||
uses_legacy: bool,
|
||||
_id: Uuid,
|
||||
@@ -647,7 +754,7 @@ impl Erasure {
|
||||
|
||||
let legacy_encoder = if uses_legacy && parity_shards > 0 {
|
||||
Some(
|
||||
LegacyReedSolomonEncoder::new(data_shards, parity_shards)
|
||||
cached_legacy_reed_solomon(data_shards, parity_shards)
|
||||
.map_err(|source| ErasureConstructionError::LegacyEncoder { source })?,
|
||||
)
|
||||
} else {
|
||||
@@ -675,106 +782,48 @@ impl Erasure {
|
||||
#[tracing::instrument(level = "debug", skip_all, fields(data_len=data.len()))]
|
||||
#[hotpath::measure(impl_type = "Erasure")]
|
||||
pub fn encode_data(&self, data: &[u8]) -> io::Result<Vec<Bytes>> {
|
||||
let shard_size_fn = if self.uses_legacy {
|
||||
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();
|
||||
self.encode_data_block_inner(data)
|
||||
.map(|block| block.into_shards(self.total_shard_count()))
|
||||
}
|
||||
|
||||
let mut data_buffer = BytesMut::with_capacity(need_total_size);
|
||||
#[tracing::instrument(level = "debug", skip_all, fields(data_len=data.len()))]
|
||||
#[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.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)
|
||||
self.encode_buffer(data_buffer, data.len())
|
||||
}
|
||||
|
||||
/// 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.
|
||||
#[hotpath::measure(impl_type = "Erasure")]
|
||||
pub fn encode_data_owned(&self, data: Vec<u8>) -> io::Result<Vec<Bytes>> {
|
||||
let shard_size_fn = if self.uses_legacy {
|
||||
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();
|
||||
self.encode_data_owned_block_inner(data)
|
||||
.map(|block| block.into_shards(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)
|
||||
let mut data_buffer = match Bytes::from(data).try_into_mut() {
|
||||
Ok(mut bm) => {
|
||||
bm.resize(need_total_size, 0u8);
|
||||
bm
|
||||
}
|
||||
let data_buffer = match Bytes::from(data).try_into_mut() {
|
||||
Ok(data_buffer) => data_buffer,
|
||||
Err(b) => {
|
||||
// Rare path: refcount != 1, fall back to copy
|
||||
let mut bm = BytesMut::with_capacity(need_total_size);
|
||||
bm.extend_from_slice(&b);
|
||||
bm.resize(need_total_size, 0u8);
|
||||
bm
|
||||
let mut data_buffer = BytesMut::with_capacity(self.encoded_capacity_for_data_len(data_len));
|
||||
data_buffer.extend_from_slice(&b);
|
||||
data_buffer
|
||||
}
|
||||
};
|
||||
|
||||
{
|
||||
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)
|
||||
self.encode_buffer(data_buffer, data_len)
|
||||
}
|
||||
|
||||
/// Encode data from an owned `BytesMut` buffer, avoiding the initial copy
|
||||
@@ -786,7 +835,17 @@ impl Erasure {
|
||||
/// `data_len <= block_size` — both shard-size formulas are monotone in
|
||||
/// `data_len` — so this function never reallocates the buffer.
|
||||
#[hotpath::measure(impl_type = "Erasure")]
|
||||
pub fn encode_data_bytes_mut(&self, mut data_buffer: BytesMut, data_len: usize) -> io::Result<Vec<Bytes>> {
|
||||
pub fn encode_data_bytes_mut(&self, 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 {
|
||||
calc_shard_size_legacy
|
||||
} else {
|
||||
@@ -794,7 +853,7 @@ impl Erasure {
|
||||
};
|
||||
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()]);
|
||||
return Ok(EncodedBlock::empty());
|
||||
}
|
||||
let need_total_size = per_shard_size * self.total_shard_count();
|
||||
|
||||
@@ -821,14 +880,10 @@ impl Erasure {
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
Ok(EncodedBlock {
|
||||
data: data_buffer.freeze(),
|
||||
shard_size: per_shard_size,
|
||||
})
|
||||
}
|
||||
|
||||
/// Decode and reconstruct missing data shards in-place.
|
||||
@@ -1055,6 +1110,10 @@ impl Erasure {
|
||||
///
|
||||
/// # Errors
|
||||
/// 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>(
|
||||
self: std::sync::Arc<Self>,
|
||||
reader: &mut R,
|
||||
@@ -1417,7 +1476,7 @@ mod tests {
|
||||
assert_eq!(cloned.block_size, legacy.block_size);
|
||||
assert!(cloned.uses_legacy);
|
||||
|
||||
let data = b"legacy clone should keep independent SIMD caches";
|
||||
let data = b"legacy clone should preserve SIMD codec behavior";
|
||||
let encoded = cloned.encode_data(data).expect("legacy clone should encode");
|
||||
let mut shards = optional_shards(&encoded);
|
||||
shards[0] = None;
|
||||
@@ -1425,6 +1484,93 @@ mod tests {
|
||||
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]
|
||||
fn legacy_verify_reports_invalid_empty_valid_and_corrupt_parity_sets() {
|
||||
let legacy = LegacyReedSolomonEncoder::new(2, 2).expect("legacy encoder should construct");
|
||||
@@ -1498,10 +1644,16 @@ mod tests {
|
||||
fn encode_data_owned_matches_borrowed_path() {
|
||||
for uses_legacy in [false, true] {
|
||||
let erasure = Erasure::new_with_options(4, 2, 64, uses_legacy);
|
||||
|
||||
assert_owned_encode_matches_borrowed(&erasure, Vec::new());
|
||||
assert_owned_encode_matches_borrowed(&erasure, b"small payload".to_vec());
|
||||
assert_owned_encode_matches_borrowed(&erasure, (0_u8..37).collect());
|
||||
for data in [
|
||||
Vec::new(),
|
||||
vec![0xA5; 1],
|
||||
b"small payload".to_vec(),
|
||||
(0_u8..37).collect(),
|
||||
vec![0xA5; erasure.block_size - 1],
|
||||
vec![0x5A; erasure.block_size],
|
||||
] {
|
||||
assert_owned_encode_matches_borrowed(&erasure, data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1547,6 +1699,52 @@ 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`,
|
||||
/// 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
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
// limitations under the License.
|
||||
|
||||
// #730: erasure codec migration keeps staged streaming decode paths in this module.
|
||||
#![allow(dead_code)]
|
||||
|
||||
pub(crate) mod codec;
|
||||
pub(crate) mod coding;
|
||||
|
||||
@@ -13,13 +13,12 @@
|
||||
// limitations under the License.
|
||||
|
||||
// #730: error taxonomy still exposes compatibility variants while callers move to contracts.
|
||||
#![allow(dead_code)]
|
||||
|
||||
use crate::bucket::error::BucketMetadataError;
|
||||
use crate::disk::error::DiskError;
|
||||
use crate::storage_api_contracts::{error::StorageErrorCode, range::HTTPRangeError};
|
||||
use rustfs_utils::path::decode_dir_object;
|
||||
use s3s::{S3Error, S3ErrorCode};
|
||||
use s3s::S3ErrorCode;
|
||||
|
||||
pub type Error = StorageError;
|
||||
pub type Result<T> = core::result::Result<T, Error>;
|
||||
@@ -902,6 +901,7 @@ pub fn is_err_decommission_running(err: &Error) -> bool {
|
||||
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 {
|
||||
matches!(err, &StorageError::RebalanceAlreadyRunning)
|
||||
}
|
||||
@@ -910,14 +910,11 @@ pub fn is_err_operation_canceled(err: &Error) -> bool {
|
||||
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 {
|
||||
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
|
||||
/// absence errors: `FileNotFound`/`VolumeNotFound`/`FileVersionNotFound`/
|
||||
/// `ObjectNotFound`/`VersionNotFound`.
|
||||
@@ -1078,21 +1075,6 @@ pub struct GenericError {
|
||||
|
||||
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
|
||||
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)]
|
||||
InvalidObjectState(GenericError),
|
||||
}
|
||||
@@ -1109,96 +1091,6 @@ pub struct ErrorResponse {
|
||||
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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -13,8 +13,6 @@
|
||||
// limitations under the License.
|
||||
|
||||
// #730: event target types are retained for notification owner migration.
|
||||
#![allow(dead_code)]
|
||||
|
||||
pub mod name;
|
||||
pub mod targetid;
|
||||
pub mod targetlist;
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
#![allow(clippy::all)]
|
||||
// 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.
|
||||
|
||||
pub struct TargetID {
|
||||
id: String,
|
||||
name: String,
|
||||
}
|
||||
|
||||
impl TargetID {
|
||||
fn to_string(&self) -> String {
|
||||
format!("{}:{}", self.id, self.name)
|
||||
}
|
||||
}
|
||||
@@ -12,18 +12,20 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use crate::event::targetid::TargetID;
|
||||
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)]
|
||||
#[allow(
|
||||
dead_code,
|
||||
reason = "held only by the dead ecstore EventNotifier; see services/event_notification.rs (backlog#1823)"
|
||||
)]
|
||||
pub struct TargetList {
|
||||
pub current_send_calls: 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 {
|
||||
@@ -31,14 +33,3 @@ impl TargetList {
|
||||
TargetList::default()
|
||||
}
|
||||
}
|
||||
|
||||
struct TargetStat {
|
||||
current_send_calls: i64,
|
||||
total_events: i64,
|
||||
failed_events: i64,
|
||||
}
|
||||
|
||||
struct TargetIDResult {
|
||||
id: TargetID,
|
||||
err: std::io::Error,
|
||||
}
|
||||
|
||||
@@ -22,12 +22,13 @@ use crate::diagnostics::get::{
|
||||
#[cfg(feature = "hotpath")]
|
||||
use crate::disk::FileWriter;
|
||||
use crate::disk::{self, DiskAPI as _, DiskStore, FileReader, MmapCopyStageMetrics, error::DiskError};
|
||||
use crate::erasure::coding::{BitrotReader, BitrotWriterWrapper, CustomWriter};
|
||||
use crate::erasure::coding::{BitrotReader, BitrotWriterWrapper, CustomWriter, ShardChunkRead};
|
||||
use bytes::Bytes;
|
||||
use rustfs_config::{
|
||||
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,
|
||||
};
|
||||
use rustfs_rio::ChunkReaderBox;
|
||||
use rustfs_utils::HashAlgorithm;
|
||||
use std::future::Future;
|
||||
use std::io::{self, Cursor};
|
||||
@@ -51,13 +52,25 @@ tokio::task_local! {
|
||||
/// (rustfs/backlog#1159). Everything else is a stream and keeps the old path.
|
||||
pub enum ShardReader {
|
||||
InMemory(Cursor<Bytes>),
|
||||
Chunked(ChunkReaderBox),
|
||||
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 {
|
||||
fn poll_read(self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut tokio::io::ReadBuf<'_>) -> Poll<std::io::Result<()>> {
|
||||
match self.get_mut() {
|
||||
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),
|
||||
}
|
||||
}
|
||||
@@ -67,7 +80,19 @@ impl crate::erasure::coding::ShardSource for ShardReader {
|
||||
fn try_take_block(&mut self, n: usize) -> Option<Bytes> {
|
||||
match self {
|
||||
Self::InMemory(cursor) => cursor.try_take_block(n),
|
||||
Self::Stream(_) => None,
|
||||
Self::Chunked(_) | 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -345,6 +370,17 @@ async fn open_disk_reader(
|
||||
let metrics_path = metrics_path.filter(|_| rustfs_io_metrics::get_stage_metrics_enabled());
|
||||
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
|
||||
// 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
|
||||
@@ -620,7 +656,7 @@ pub async fn create_bitrot_reader_from_bytes(
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn create_bitrot_reader_from_bytes_with_stage_metrics(
|
||||
pub(crate) async fn create_bitrot_reader_from_bytes_with_stage_metrics(
|
||||
inline_data: Option<Bytes>,
|
||||
disk: Option<&DiskStore>,
|
||||
bucket: &str,
|
||||
@@ -780,6 +816,50 @@ pub async fn create_bitrot_writer(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
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")]
|
||||
use crate::cluster::rpc::RemoteDisk;
|
||||
@@ -1669,4 +1749,49 @@ mod tests {
|
||||
println!("error: {error:?}");
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,13 @@ 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")
|
||||
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_MIME_TYPES: &str = "text/*,application/json,application/xml,binary/octet-stream";
|
||||
|
||||
@@ -171,6 +178,21 @@ pub fn is_disk_compression_enabled() -> bool {
|
||||
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 {
|
||||
// Check if disk compression is enabled (read once at first use, then fixed for process lifetime)
|
||||
if !config.enabled {
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
// limitations under the License.
|
||||
|
||||
// #730: I/O backend selection keeps test-only and staged rio helpers scoped here.
|
||||
#![allow(dead_code)]
|
||||
|
||||
pub(crate) mod bitrot;
|
||||
pub(crate) mod compress;
|
||||
|
||||
@@ -25,9 +25,20 @@ use tokio::io::AsyncRead;
|
||||
|
||||
#[cfg(feature = "rio-v2")]
|
||||
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")]
|
||||
#[allow(dead_code, reason = "on-disk contract asserted by the rio-v2 padding test (backlog#1823)")]
|
||||
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 {
|
||||
#[cfg(feature = "rio-v2")]
|
||||
{
|
||||
@@ -53,17 +64,6 @@ 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)]
|
||||
pub enum ReadCompressionBackend {
|
||||
Legacy,
|
||||
@@ -82,6 +82,11 @@ pub fn compression_scheme_to_read_plan(scheme: &str) -> std::io::Result<(Compres
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ReadEncryptionBackend {
|
||||
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,
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,8 @@ use tracing::debug;
|
||||
|
||||
/// Supported set sizes this is used to find the optimal
|
||||
/// single set size.
|
||||
const SET_SIZES: [usize; 15] = [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16];
|
||||
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, MAX_ERASURE_SET_DRIVE_COUNT];
|
||||
const ENV_RUSTFS_ERASURE_SET_DRIVE_COUNT: &str = "RUSTFS_ERASURE_SET_DRIVE_COUNT";
|
||||
|
||||
#[derive(Deserialize, Debug, Default)]
|
||||
@@ -327,7 +328,7 @@ fn possible_set_counts(set_size: usize) -> Vec<usize> {
|
||||
|
||||
/// checks whether given count is a valid set size for erasure coding.
|
||||
fn is_valid_set_size(count: usize) -> bool {
|
||||
count >= SET_SIZES[0] && count <= SET_SIZES[SET_SIZES.len() - 1]
|
||||
count >= SET_SIZES[0] && count <= MAX_ERASURE_SET_DRIVE_COUNT
|
||||
}
|
||||
|
||||
/// Final set size with all the symmetry accounted for.
|
||||
|
||||
@@ -209,15 +209,12 @@ impl AsMut<Vec<Endpoints>> for PoolEndpointList {
|
||||
}
|
||||
|
||||
impl PoolEndpointList {
|
||||
/// creates a list of endpoints per pool, resolves their relevant
|
||||
/// hostnames and discovers those are local or remote.
|
||||
async fn create_pool_endpoints(server_addr: &str, disks_layout: &DisksLayout) -> Result<Self> {
|
||||
Self::create_pool_endpoints_with(server_addr, disks_layout, None, None).await
|
||||
}
|
||||
|
||||
/// Same as [`create_pool_endpoints`] but lets tests inject an explicit
|
||||
/// startup topology convergence policy and local endpoint host instead of
|
||||
/// resolving them from the environment.
|
||||
/// Creates a list of endpoints per pool, resolves their relevant hostnames
|
||||
/// and discovers whether those are local or remote.
|
||||
///
|
||||
/// The policy and host overrides let tests inject an explicit startup
|
||||
/// topology convergence policy and local endpoint host instead of
|
||||
/// resolving them from the environment; production passes `None` for both.
|
||||
async fn create_pool_endpoints_with(
|
||||
server_addr: &str,
|
||||
disks_layout: &DisksLayout,
|
||||
@@ -594,6 +591,10 @@ impl PoolEndpointList {
|
||||
}
|
||||
|
||||
const DNS_RETRY_BASE_DELAY: Duration = Duration::from_millis(500);
|
||||
#[allow(
|
||||
dead_code,
|
||||
reason = "retry-cap bound asserted by this file's dns_retry_delay tests (backlog#1823)"
|
||||
)]
|
||||
const DNS_RETRY_MAX_DELAY: Duration = Duration::from_secs(8);
|
||||
const DNS_RETRY_JITTER_PERCENT: u64 = 20;
|
||||
/// Minimum spacing between "still retrying" warnings so a long orchestrated
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
// limitations under the License.
|
||||
|
||||
// #730: set-layout contracts are staged while ECStore ownership boundaries shrink.
|
||||
#![allow(dead_code)]
|
||||
|
||||
//! Static ECStore layout boundaries.
|
||||
//!
|
||||
|
||||
@@ -4,6 +4,7 @@ use std::io::{Error, Result};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[allow(dead_code, reason = "ESET-001 layout model; exercised by this file's tests (backlog#1823)")]
|
||||
pub(crate) struct StaticSetLayoutSnapshot {
|
||||
pub(crate) deployment_id: Uuid,
|
||||
pub(crate) set_count: usize,
|
||||
@@ -12,6 +13,7 @@ pub(crate) struct StaticSetLayoutSnapshot {
|
||||
pub(crate) distribution_algo: DistributionAlgoVersion,
|
||||
}
|
||||
|
||||
#[allow(dead_code, reason = "ESET-001 layout model; exercised by this file's tests (backlog#1823)")]
|
||||
impl StaticSetLayoutSnapshot {
|
||||
pub(crate) fn from_format(format: &FormatV3) -> Self {
|
||||
let disk_ids = format.erasure.sets.clone();
|
||||
@@ -39,17 +41,20 @@ impl StaticSetLayoutSnapshot {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[allow(dead_code, reason = "ESET-001 layout model; exercised by this file's tests (backlog#1823)")]
|
||||
pub(crate) struct SetDiskPosition {
|
||||
pub(crate) set_index: usize,
|
||||
pub(crate) disk_index: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[allow(dead_code, reason = "ESET-001 layout model; exercised by this file's tests (backlog#1823)")]
|
||||
pub(crate) struct RuntimeSetLayoutPlan {
|
||||
pub(crate) sets: Vec<Vec<RuntimeSetDrivePlan>>,
|
||||
lock_hosts_by_set: Vec<Vec<String>>,
|
||||
}
|
||||
|
||||
#[allow(dead_code, reason = "ESET-001 layout model; exercised by this file's tests (backlog#1823)")]
|
||||
impl RuntimeSetLayoutPlan {
|
||||
pub(crate) fn from_endpoint_hosts<S>(set_count: usize, drives_per_set: usize, endpoint_hosts: &[S]) -> Result<Self>
|
||||
where
|
||||
@@ -108,6 +113,7 @@ impl RuntimeSetLayoutPlan {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[allow(dead_code, reason = "ESET-001 layout model; exercised by this file's tests (backlog#1823)")]
|
||||
pub(crate) struct RuntimeSetDrivePlan {
|
||||
pub(crate) set_index: usize,
|
||||
pub(crate) disk_index: usize,
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
// limitations under the License.
|
||||
|
||||
// #730: object API readers keep staged compatibility paths during facade migration.
|
||||
#![allow(dead_code)]
|
||||
|
||||
use crate::bucket::metadata_sys::get_versioning_config;
|
||||
use crate::bucket::replication::{
|
||||
@@ -23,7 +22,7 @@ use crate::bucket::replication::{
|
||||
use crate::bucket::versioning::VersioningApi as _;
|
||||
use crate::config::storageclass;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::io_support::rio::{HashReader, LimitReader};
|
||||
use crate::io_support::rio::{HardLimitReader, HashReader};
|
||||
use crate::storage_api_contracts::{
|
||||
lifecycle::{ExpirationOptions, TransitionedObject},
|
||||
range::HTTPRangeSpec,
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
use super::*;
|
||||
|
||||
use crate::io_support::rio::Index;
|
||||
use std::mem::MaybeUninit;
|
||||
|
||||
#[cfg(feature = "rio-v2")]
|
||||
const DARE_PAYLOAD_SIZE: i64 = 64 * 1024;
|
||||
@@ -448,10 +449,16 @@ impl GetObjectReader {
|
||||
}
|
||||
|
||||
enum ReadTransform {
|
||||
Plain {
|
||||
visible_offset: usize,
|
||||
visible_length: i64,
|
||||
},
|
||||
// Written but never read by production code: the enclosing struct already
|
||||
// carries the same pair as `storage_offset`/`storage_length`. They survive
|
||||
// as the read plan's test-visible record — four tests assert them by
|
||||
// literal pattern (`Plain { visible_offset: 6, visible_length: 4 }`), which
|
||||
// rustc does not count as a read.
|
||||
#[allow(
|
||||
dead_code,
|
||||
reason = "asserted by literal pattern in this file's read-plan tests (backlog#1823)"
|
||||
)]
|
||||
Plain { visible_offset: usize, visible_length: i64 },
|
||||
Compressed {
|
||||
algorithm: CompressionAlgorithm,
|
||||
backend: crate::io_support::rio::ReadCompressionBackend,
|
||||
@@ -472,7 +479,15 @@ enum ReadTransform {
|
||||
},
|
||||
}
|
||||
|
||||
struct ReadPlan {
|
||||
/// How an object's stored bytes must be fetched and transformed to serve a
|
||||
/// request.
|
||||
///
|
||||
/// Public so callers that fetch the stored bytes from somewhere other than the
|
||||
/// local erasure set — the remote-tier read path — can position their own fetch
|
||||
/// with [`ReadPlan::storage_offset`] / [`ReadPlan::storage_length`] and then
|
||||
/// hand the resulting stream to [`ReadPlan::into_object_reader`], instead of
|
||||
/// reimplementing the transform decisions (rustfs/rustfs#6025).
|
||||
pub struct ReadPlan {
|
||||
storage_offset: usize,
|
||||
storage_length: i64,
|
||||
object_size: i64,
|
||||
@@ -480,6 +495,43 @@ struct ReadPlan {
|
||||
}
|
||||
|
||||
impl ReadPlan {
|
||||
/// Byte offset into the object's **stored** bytes where the fetch must
|
||||
/// start. Encrypted and compressed objects address their storage in a
|
||||
/// different coordinate system than the plaintext range the caller asked
|
||||
/// for, which is exactly the distinction this plan resolves.
|
||||
pub fn storage_offset(&self) -> usize {
|
||||
self.storage_offset
|
||||
}
|
||||
|
||||
/// Number of **stored** bytes the fetch must deliver, in the same
|
||||
/// coordinate system as [`Self::storage_offset`].
|
||||
pub fn storage_length(&self) -> i64 {
|
||||
self.storage_length
|
||||
}
|
||||
|
||||
/// Build the plan for a request without consuming a stream, so a caller
|
||||
/// that has to issue its own positioned fetch can read the offsets first.
|
||||
pub async fn build_for_request(
|
||||
rs: Option<HTTPRangeSpec>,
|
||||
oi: &ObjectInfo,
|
||||
opts: &ObjectOptions,
|
||||
h: &HeaderMap<HeaderValue>,
|
||||
resolver: Option<&dyn ObjectEncryptionResolver>,
|
||||
) -> Result<Self> {
|
||||
Self::build_with_resolver(rs, oi, opts, h, resolver).await
|
||||
}
|
||||
|
||||
/// Wrap `reader` — the stored bytes this plan asked for, already positioned
|
||||
/// at [`Self::storage_offset`] — in the transforms that turn them into the
|
||||
/// bytes the caller requested.
|
||||
pub fn into_object_reader(
|
||||
self,
|
||||
reader: Box<dyn AsyncRead + Unpin + Send + Sync>,
|
||||
oi: &ObjectInfo,
|
||||
) -> Result<GetObjectReader> {
|
||||
self.into_reader(reader, oi).map(|(reader, _, _)| reader)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
async fn build(rs: Option<HTTPRangeSpec>, oi: &ObjectInfo, opts: &ObjectOptions, h: &HeaderMap<HeaderValue>) -> Result<Self> {
|
||||
Self::build_with_resolver(rs, oi, opts, h, Some(&tests::TEST_RESOLVER)).await
|
||||
@@ -493,8 +545,17 @@ impl ReadPlan {
|
||||
resolver: Option<&dyn ObjectEncryptionResolver>,
|
||||
) -> Result<Self> {
|
||||
let mut rs = rs;
|
||||
// A part number addresses the object's PLAINTEXT bytes. A restore read
|
||||
// serves the stored representation instead (see
|
||||
// [`restore_request_active`]), where that synthesized range would be
|
||||
// reinterpreted as a storage range and truncate an encrypted or
|
||||
// compressed payload by exactly its encoding overhead — the copy-back
|
||||
// then fails its length check partway through
|
||||
// (rustfs/rustfs#6025). An explicit caller range is already in storage
|
||||
// coordinates on that path and is still honored.
|
||||
if let Some(part_number) = opts.part_number
|
||||
&& rs.is_none()
|
||||
&& !restore_request_active(opts)
|
||||
{
|
||||
rs = http_range_spec_from_object_info(oi, part_number);
|
||||
}
|
||||
@@ -747,7 +808,7 @@ impl ReadPlan {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Box::new(LimitReader::new(dec_reader, total_plaintext_size))
|
||||
Box::new(HardLimitReader::new(dec_reader, decompressed_length))
|
||||
};
|
||||
|
||||
let mut object_info = oi.clone();
|
||||
@@ -839,7 +900,7 @@ impl ReadPlan {
|
||||
)?;
|
||||
Box::new(ranged_reader)
|
||||
} else {
|
||||
Box::new(LimitReader::new(decompressed_reader, total_plaintext_size))
|
||||
Box::new(HardLimitReader::new(decompressed_reader, total_plaintext_size_i64))
|
||||
}
|
||||
} else if plaintext_offset > 0 || plaintext_length != total_plaintext_size_i64 {
|
||||
Box::new(RangedDecompressReader::new(
|
||||
@@ -849,7 +910,7 @@ impl ReadPlan {
|
||||
total_plaintext_size,
|
||||
)?)
|
||||
} else {
|
||||
Box::new(LimitReader::new(decrypted_reader, total_plaintext_size))
|
||||
Box::new(HardLimitReader::new(decrypted_reader, total_plaintext_size_i64))
|
||||
};
|
||||
|
||||
let mut object_info = oi.clone();
|
||||
@@ -922,7 +983,7 @@ struct SkipReader<R> {
|
||||
inner: R,
|
||||
bytes_to_skip: usize,
|
||||
bytes_skipped: usize,
|
||||
scratch: Vec<u8>,
|
||||
scratch: Box<[MaybeUninit<u8>]>,
|
||||
}
|
||||
|
||||
impl<R: AsyncRead + Unpin + Send + Sync> SkipReader<R> {
|
||||
@@ -931,7 +992,7 @@ impl<R: AsyncRead + Unpin + Send + Sync> SkipReader<R> {
|
||||
inner,
|
||||
bytes_to_skip,
|
||||
bytes_skipped: 0,
|
||||
scratch: vec![0u8; 8192],
|
||||
scratch: Box::<[u8]>::new_uninit_slice(8192),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -943,7 +1004,7 @@ impl<R: AsyncRead + Unpin + Send + Sync> AsyncRead for SkipReader<R> {
|
||||
while this.bytes_skipped < this.bytes_to_skip {
|
||||
let remaining = this.bytes_to_skip - this.bytes_skipped;
|
||||
let scratch_len = remaining.min(this.scratch.len());
|
||||
let mut scratch_buf = ReadBuf::new(&mut this.scratch[..scratch_len]);
|
||||
let mut scratch_buf = ReadBuf::uninit(&mut this.scratch[..scratch_len]);
|
||||
match Pin::new(&mut this.inner).poll_read(cx, &mut scratch_buf) {
|
||||
Poll::Pending => return Poll::Pending,
|
||||
Poll::Ready(Err(err)) => return Poll::Ready(Err(err)),
|
||||
@@ -974,7 +1035,7 @@ pub struct RangedDecompressReader<R: AsyncRead + Unpin + Send + Sync + 'static>
|
||||
target_length: usize,
|
||||
current_offset: usize,
|
||||
bytes_returned: usize,
|
||||
scratch: Vec<u8>,
|
||||
scratch: Box<[MaybeUninit<u8>]>,
|
||||
drain_on_done: bool,
|
||||
drain_task: Option<tokio::task::JoinHandle<()>>,
|
||||
}
|
||||
@@ -1012,7 +1073,7 @@ impl<R: AsyncRead + Unpin + Send + Sync + 'static> RangedDecompressReader<R> {
|
||||
target_length: actual_length,
|
||||
current_offset: 0,
|
||||
bytes_returned: 0,
|
||||
scratch: vec![0u8; 8192],
|
||||
scratch: Box::<[u8]>::new_uninit_slice(8192),
|
||||
drain_on_done,
|
||||
drain_task: None,
|
||||
})
|
||||
@@ -1062,7 +1123,7 @@ impl<R: AsyncRead + Unpin + Send + Sync + 'static> AsyncRead for RangedDecompres
|
||||
}
|
||||
|
||||
let scratch_len = std::cmp::min(this.scratch.len(), std::cmp::max(buf_capacity, 1));
|
||||
let mut temp_read_buf = ReadBuf::new(&mut this.scratch[..scratch_len]);
|
||||
let mut temp_read_buf = ReadBuf::uninit(&mut this.scratch[..scratch_len]);
|
||||
|
||||
let Some(inner) = this.inner.as_mut() else {
|
||||
return Poll::Ready(Ok(()));
|
||||
@@ -1114,7 +1175,8 @@ impl<R: AsyncRead + Unpin + Send + Sync + 'static> AsyncRead for RangedDecompres
|
||||
);
|
||||
|
||||
if bytes_to_return > 0 {
|
||||
let data_slice = &this.scratch[data_start_in_buffer..data_start_in_buffer + bytes_to_return];
|
||||
let data_slice =
|
||||
&temp_read_buf.filled()[data_start_in_buffer..data_start_in_buffer + bytes_to_return];
|
||||
buf.put_slice(data_slice);
|
||||
this.bytes_returned += bytes_to_return;
|
||||
|
||||
@@ -1133,7 +1195,7 @@ impl<R: AsyncRead + Unpin + Send + Sync + 'static> AsyncRead for RangedDecompres
|
||||
std::cmp::min(n, std::cmp::min(buf.remaining(), this.target_length - this.bytes_returned));
|
||||
|
||||
if bytes_to_return > 0 {
|
||||
buf.put_slice(&this.scratch[..bytes_to_return]);
|
||||
buf.put_slice(&temp_read_buf.filled()[..bytes_to_return]);
|
||||
this.bytes_returned += bytes_to_return;
|
||||
|
||||
tracing::trace!("Returned {} bytes at offset {}", bytes_to_return, old_offset);
|
||||
@@ -1203,20 +1265,7 @@ impl<R: AsyncRead + Unpin + Send + 'static> AsyncRead for StreamConsumer<R> {
|
||||
|
||||
impl<R: AsyncRead + Unpin + Send + 'static> Drop for StreamConsumer<R> {
|
||||
fn drop(&mut self) {
|
||||
if self.consumer_task.is_none() && self.inner.is_some() {
|
||||
let mut inner = self.inner.take().unwrap();
|
||||
let task = tokio::spawn(async move {
|
||||
let mut buf = [0u8; 8192];
|
||||
loop {
|
||||
match inner.read(&mut buf).await {
|
||||
Ok(0) => break, // EOF
|
||||
Ok(_) => continue, // Keep consuming
|
||||
Err(_) => break, // Error, stop consuming
|
||||
}
|
||||
}
|
||||
});
|
||||
self.consumer_task = Some(task);
|
||||
}
|
||||
self.ensure_consumer_started();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1263,6 +1312,43 @@ mod tests {
|
||||
use temp_env::async_with_vars;
|
||||
use tokio::io::AsyncReadExt;
|
||||
|
||||
#[derive(Debug)]
|
||||
struct PendingPartialReader {
|
||||
data: &'static [u8],
|
||||
position: usize,
|
||||
pending: bool,
|
||||
}
|
||||
|
||||
impl PendingPartialReader {
|
||||
fn new(data: &'static [u8]) -> Self {
|
||||
Self {
|
||||
data,
|
||||
position: 0,
|
||||
pending: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AsyncRead for PendingPartialReader {
|
||||
fn poll_read(mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll<std::io::Result<()>> {
|
||||
if self.pending {
|
||||
self.pending = false;
|
||||
cx.waker().wake_by_ref();
|
||||
return Poll::Pending;
|
||||
}
|
||||
if self.position == self.data.len() {
|
||||
return Poll::Ready(Ok(()));
|
||||
}
|
||||
|
||||
let length = buf.remaining().min(3).min(self.data.len() - self.position);
|
||||
let end = self.position + length;
|
||||
buf.put_slice(&self.data[self.position..end]);
|
||||
self.position = end;
|
||||
self.pending = true;
|
||||
Poll::Ready(Ok(()))
|
||||
}
|
||||
}
|
||||
|
||||
const TEST_DIRECT_KEY_HEADER: &str = "x-rustfs-test-direct-key";
|
||||
const TEST_OBJECT_KEY_HEADER: &str = "x-rustfs-test-object-key";
|
||||
const TEST_NONCE_HEADER: &str = "x-rustfs-test-nonce";
|
||||
@@ -1400,6 +1486,36 @@ mod tests {
|
||||
assert_eq!(result, b"World");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn uninitialized_scratch_preserves_partial_pending_and_eof_reads() {
|
||||
let mut skipped = SkipReader::new(PendingPartialReader::new(b"0123456789abcdef"), 5);
|
||||
let mut skipped_output = Vec::new();
|
||||
skipped
|
||||
.read_to_end(&mut skipped_output)
|
||||
.await
|
||||
.expect("skip reader should survive partial pending reads through EOF");
|
||||
assert_eq!(skipped_output, b"56789abcdef");
|
||||
|
||||
let mut ranged = RangedDecompressReader::new(PendingPartialReader::new(b"0123456789abcdef"), 5, 7, 16)
|
||||
.expect("valid range should construct");
|
||||
let mut ranged_output = Vec::new();
|
||||
ranged
|
||||
.read_to_end(&mut ranged_output)
|
||||
.await
|
||||
.expect("range reader should survive partial pending reads through EOF");
|
||||
assert_eq!(ranged_output, b"56789ab");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn uninitialized_skip_scratch_reports_early_eof() {
|
||||
let mut reader = SkipReader::new(PendingPartialReader::new(b"short"), 6);
|
||||
let error = reader
|
||||
.read_to_end(&mut Vec::new())
|
||||
.await
|
||||
.expect_err("EOF before the skip boundary must remain visible");
|
||||
assert_eq!(error.kind(), std::io::ErrorKind::UnexpectedEof);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_ranged_decompress_reader_from_start() {
|
||||
let original_data = b"Hello, World! This is a test.";
|
||||
@@ -1665,6 +1781,423 @@ mod tests {
|
||||
assert_eq!(actual, b"fghijkl");
|
||||
}
|
||||
|
||||
/// Compresses one multipart part exactly like the write path does
|
||||
/// (`WritePlan::with_compression` wraps each part in its own
|
||||
/// `compression_reader`), returning the on-disk bytes and the storage-format
|
||||
/// compression index.
|
||||
async fn compressed_part_fixture(data: &[u8]) -> (Vec<u8>, Option<Bytes>) {
|
||||
use crate::io_support::rio::TryGetIndex as _;
|
||||
let mut compressor =
|
||||
crate::io_support::rio::compression_reader(Cursor::new(data.to_vec()), CompressionAlgorithm::default(), false);
|
||||
let mut compressed = Vec::new();
|
||||
compressor.read_to_end(&mut compressed).await.expect("compress part stream");
|
||||
let index = compressor
|
||||
.try_get_index()
|
||||
.map(crate::io_support::rio::compression_index_storage_bytes);
|
||||
(compressed, index)
|
||||
}
|
||||
|
||||
struct CompressedMultipartFixture {
|
||||
object_info: ObjectInfo,
|
||||
stored: Vec<u8>,
|
||||
plaintext: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Builds the on-disk representation of a compressed multipart object: each
|
||||
/// part is an independent compressed stream and the storage layer serves
|
||||
/// their concatenation.
|
||||
async fn compressed_multipart_fixture(part_sizes: &[usize]) -> CompressedMultipartFixture {
|
||||
let pattern = b"compressed multipart read path fixture data ";
|
||||
let mut plaintext = Vec::new();
|
||||
let mut stored = Vec::new();
|
||||
let mut parts = Vec::with_capacity(part_sizes.len());
|
||||
|
||||
for (i, part_size) in part_sizes.iter().enumerate() {
|
||||
let mut part_plaintext = Vec::with_capacity(*part_size);
|
||||
while part_plaintext.len() < *part_size {
|
||||
part_plaintext.extend_from_slice(pattern);
|
||||
part_plaintext.push(i as u8);
|
||||
}
|
||||
part_plaintext.truncate(*part_size);
|
||||
|
||||
let (compressed, index) = compressed_part_fixture(&part_plaintext).await;
|
||||
parts.push(ObjectPartInfo {
|
||||
number: i + 1,
|
||||
size: compressed.len(),
|
||||
actual_size: *part_size as i64,
|
||||
index,
|
||||
..Default::default()
|
||||
});
|
||||
stored.extend_from_slice(&compressed);
|
||||
plaintext.extend_from_slice(&part_plaintext);
|
||||
}
|
||||
|
||||
let mut user_defined = HashMap::new();
|
||||
rustfs_utils::http::insert_str(
|
||||
&mut user_defined,
|
||||
rustfs_utils::http::SUFFIX_COMPRESSION,
|
||||
crate::io_support::rio::compression_metadata_value(CompressionAlgorithm::default()),
|
||||
);
|
||||
rustfs_utils::http::insert_str(&mut user_defined, rustfs_utils::http::SUFFIX_ACTUAL_SIZE, plaintext.len().to_string());
|
||||
|
||||
let object_info = ObjectInfo {
|
||||
bucket: "test-bucket".to_string(),
|
||||
name: "compressed-multipart".to_string(),
|
||||
size: stored.len() as i64,
|
||||
etag: Some(format!("6bcf86bed8807b8e78f0fc6e0a53079d-{}", part_sizes.len())),
|
||||
parts: Arc::new(parts),
|
||||
user_defined: Arc::new(user_defined),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
CompressedMultipartFixture {
|
||||
object_info,
|
||||
stored,
|
||||
plaintext,
|
||||
}
|
||||
}
|
||||
|
||||
/// Plans the read once to learn the storage window, then serves exactly that
|
||||
/// window — mirroring how `set_disk` feeds the erasure read into the
|
||||
/// returned reader.
|
||||
async fn read_compressed_multipart(
|
||||
fixture: &CompressedMultipartFixture,
|
||||
rs: Option<HTTPRangeSpec>,
|
||||
opts: &ObjectOptions,
|
||||
) -> Vec<u8> {
|
||||
let headers = HeaderMap::new();
|
||||
let (_, offset, length) =
|
||||
GetObjectReader::new(Box::new(Cursor::new(Vec::new())), rs.clone(), &fixture.object_info, opts, &headers)
|
||||
.await
|
||||
.expect("plan compressed multipart read");
|
||||
|
||||
let end = offset + usize::try_from(length).expect("storage window length must be non-negative");
|
||||
assert!(
|
||||
end <= fixture.stored.len(),
|
||||
"planned storage window {offset}..{end} exceeds stored stream of {} bytes",
|
||||
fixture.stored.len()
|
||||
);
|
||||
let window = fixture.stored[offset..end].to_vec();
|
||||
|
||||
let (mut reader, replay_offset, replay_length) =
|
||||
GetObjectReader::new(Box::new(Cursor::new(window)), rs, &fixture.object_info, opts, &headers)
|
||||
.await
|
||||
.expect("build compressed multipart reader");
|
||||
assert_eq!((replay_offset, replay_length), (offset, length), "read plan must be deterministic");
|
||||
|
||||
reader.read_all().await.expect("read compressed multipart stream")
|
||||
}
|
||||
|
||||
/// Byte pattern with a 2 KiB period: it compresses extremely well while
|
||||
/// looking nothing like ASCII fixtures. Mirrors the e2e generator that
|
||||
/// exposed a truncated full GET on high-ratio multipart payloads.
|
||||
fn high_ratio_binary_payload(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()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn compressed_multipart_full_get_handles_high_ratio_binary_payload() {
|
||||
let part_sizes = [5 * 1024 * 1024_usize, 1024 * 1024];
|
||||
let mut plaintext = Vec::new();
|
||||
let mut stored = Vec::new();
|
||||
let mut parts = Vec::with_capacity(part_sizes.len());
|
||||
|
||||
for (i, part_size) in part_sizes.iter().enumerate() {
|
||||
let part_plaintext = high_ratio_binary_payload(*part_size, if i == 0 { 7 } else { 61 });
|
||||
let (compressed, index) = compressed_part_fixture(&part_plaintext).await;
|
||||
parts.push(ObjectPartInfo {
|
||||
number: i + 1,
|
||||
size: compressed.len(),
|
||||
actual_size: *part_size as i64,
|
||||
index,
|
||||
..Default::default()
|
||||
});
|
||||
stored.extend_from_slice(&compressed);
|
||||
plaintext.extend_from_slice(&part_plaintext);
|
||||
}
|
||||
|
||||
let mut user_defined = HashMap::new();
|
||||
rustfs_utils::http::insert_str(
|
||||
&mut user_defined,
|
||||
rustfs_utils::http::SUFFIX_COMPRESSION,
|
||||
crate::io_support::rio::compression_metadata_value(CompressionAlgorithm::default()),
|
||||
);
|
||||
rustfs_utils::http::insert_str(&mut user_defined, rustfs_utils::http::SUFFIX_ACTUAL_SIZE, plaintext.len().to_string());
|
||||
let fixture = CompressedMultipartFixture {
|
||||
object_info: ObjectInfo {
|
||||
bucket: "test-bucket".to_string(),
|
||||
name: "high-ratio-multipart".to_string(),
|
||||
size: stored.len() as i64,
|
||||
etag: Some("6bcf86bed8807b8e78f0fc6e0a53079d-2".to_string()),
|
||||
parts: Arc::new(parts),
|
||||
user_defined: Arc::new(user_defined),
|
||||
..Default::default()
|
||||
},
|
||||
stored,
|
||||
plaintext,
|
||||
};
|
||||
|
||||
let read = read_compressed_multipart(&fixture, None, &ObjectOptions::default()).await;
|
||||
|
||||
assert_eq!(read.len(), fixture.plaintext.len(), "full GET must return the logical size");
|
||||
assert_eq!(read, fixture.plaintext, "high-ratio multipart payload must survive the roundtrip");
|
||||
}
|
||||
|
||||
/// Full GET over a compressed multipart object must decode across part
|
||||
/// boundaries: every part is an independent compressed stream (this is also
|
||||
/// the on-disk shape written by builds before rustfs/rustfs#5169 disabled
|
||||
/// multipart compression, so this pins legacy-object readability).
|
||||
#[tokio::test]
|
||||
async fn compressed_multipart_full_get_decodes_across_part_boundaries() {
|
||||
let fixture = compressed_multipart_fixture(&[3 * 1024 * 1024, 2 * 1024 * 1024, 512 * 1024]).await;
|
||||
|
||||
let read = read_compressed_multipart(&fixture, None, &ObjectOptions::default()).await;
|
||||
|
||||
assert_eq!(read.len(), fixture.plaintext.len(), "full GET must return the logical size");
|
||||
assert_eq!(read, fixture.plaintext, "full GET must reassemble all parts");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn compressed_multipart_range_get_crosses_part_boundary() {
|
||||
let fixture = compressed_multipart_fixture(&[3 * 1024 * 1024, 2 * 1024 * 1024]).await;
|
||||
let boundary = 3 * 1024 * 1024_i64;
|
||||
let rs = HTTPRangeSpec {
|
||||
is_suffix_length: false,
|
||||
start: boundary - 100_000,
|
||||
end: boundary + 100_000 - 1,
|
||||
};
|
||||
|
||||
let read = read_compressed_multipart(&fixture, Some(rs), &ObjectOptions::default()).await;
|
||||
|
||||
let expected = &fixture.plaintext[(boundary - 100_000) as usize..(boundary + 100_000) as usize];
|
||||
assert_eq!(read, expected, "boundary-crossing range must splice both parts");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn compressed_multipart_range_get_seeks_into_later_part() {
|
||||
let fixture = compressed_multipart_fixture(&[3 * 1024 * 1024, 4 * 1024 * 1024]).await;
|
||||
// Deep inside part 2 so the plan skips part 1 entirely and (when the
|
||||
// part carries an index) seeks within part 2.
|
||||
let start = 3 * 1024 * 1024_i64 + 2 * 1024 * 1024_i64 + 137;
|
||||
let rs = HTTPRangeSpec {
|
||||
is_suffix_length: false,
|
||||
start,
|
||||
end: start + 64 * 1024 - 1,
|
||||
};
|
||||
|
||||
let read = read_compressed_multipart(&fixture, Some(rs), &ObjectOptions::default()).await;
|
||||
|
||||
let expected = &fixture.plaintext[start as usize..(start + 64 * 1024) as usize];
|
||||
assert_eq!(read, expected, "range inside a later part must decode from that part");
|
||||
}
|
||||
|
||||
/// Parts written without a compression index (small parts skip the index in
|
||||
/// the rio-v2 backend) must still be rangeable: the plan starts at the part
|
||||
/// boundary and skips decompressed bytes.
|
||||
#[tokio::test]
|
||||
async fn compressed_multipart_range_get_works_without_part_indexes() {
|
||||
let mut fixture = compressed_multipart_fixture(&[1024 * 1024, 1024 * 1024]).await;
|
||||
let parts = fixture
|
||||
.object_info
|
||||
.parts
|
||||
.iter()
|
||||
.map(|part| ObjectPartInfo {
|
||||
index: None,
|
||||
..part.clone()
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
fixture.object_info.parts = Arc::new(parts);
|
||||
|
||||
let start = 1024 * 1024_i64 + 4096;
|
||||
let rs = HTTPRangeSpec {
|
||||
is_suffix_length: false,
|
||||
start,
|
||||
end: start + 32 * 1024 - 1,
|
||||
};
|
||||
|
||||
let read = read_compressed_multipart(&fixture, Some(rs), &ObjectOptions::default()).await;
|
||||
|
||||
let expected = &fixture.plaintext[start as usize..(start + 32 * 1024) as usize];
|
||||
assert_eq!(read, expected, "index-less parts must fall back to part-boundary skip");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn compressed_multipart_part_number_get_returns_single_part() {
|
||||
let part_sizes = [3 * 1024 * 1024, 2 * 1024 * 1024, 512 * 1024];
|
||||
let fixture = compressed_multipart_fixture(&part_sizes).await;
|
||||
|
||||
let mut logical_offset = 0_usize;
|
||||
for (i, part_size) in part_sizes.iter().enumerate() {
|
||||
let opts = ObjectOptions {
|
||||
part_number: Some(i + 1),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let read = read_compressed_multipart(&fixture, None, &opts).await;
|
||||
|
||||
let expected = &fixture.plaintext[logical_offset..logical_offset + part_size];
|
||||
assert_eq!(read.len(), *part_size, "partNumber={} GET must return the part's logical size", i + 1);
|
||||
assert_eq!(read, expected, "partNumber={} GET must return the original part bytes", i + 1);
|
||||
logical_offset += part_size;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn compressed_multipart_suffix_range_reads_tail() {
|
||||
let fixture = compressed_multipart_fixture(&[3 * 1024 * 1024, 1024 * 1024]).await;
|
||||
let suffix_len = 128 * 1024_i64;
|
||||
let rs = HTTPRangeSpec {
|
||||
is_suffix_length: true,
|
||||
start: suffix_len,
|
||||
end: -1,
|
||||
};
|
||||
|
||||
let read = read_compressed_multipart(&fixture, Some(rs), &ObjectOptions::default()).await;
|
||||
|
||||
let expected = &fixture.plaintext[fixture.plaintext.len() - suffix_len as usize..];
|
||||
assert_eq!(read, expected, "suffix range must return the tail of the last part");
|
||||
}
|
||||
|
||||
/// Builds an SSE-C + disk-compression multipart object exactly like the
|
||||
/// write path: each part is compressed into its own stream and then
|
||||
/// encrypted with the per-part key schedule. The fixture is
|
||||
/// legacy-encryption-specific (`rustfs_rio::EncryptReader`), matching the
|
||||
/// pre-existing `build_legacy_ssec_multipart_fixture` shape, while the
|
||||
/// compression layer follows the active backend feature.
|
||||
async fn compressed_encrypted_multipart_fixture(key_bytes: [u8; 32], part_sizes: &[usize]) -> CompressedMultipartFixture {
|
||||
let pattern = b"compressed encrypted multipart fixture data ";
|
||||
let mut plaintext = Vec::new();
|
||||
let mut stored = Vec::new();
|
||||
let mut parts = Vec::with_capacity(part_sizes.len());
|
||||
|
||||
for (i, part_size) in part_sizes.iter().enumerate() {
|
||||
let part_number = i + 1;
|
||||
let mut part_plaintext = Vec::with_capacity(*part_size);
|
||||
while part_plaintext.len() < *part_size {
|
||||
part_plaintext.extend_from_slice(pattern);
|
||||
part_plaintext.push(part_number as u8);
|
||||
}
|
||||
part_plaintext.truncate(*part_size);
|
||||
|
||||
let (compressed, index) = compressed_part_fixture(&part_plaintext).await;
|
||||
let mut part_cipher = Vec::new();
|
||||
rustfs_rio::EncryptReader::new_multipart(Cursor::new(compressed), key_bytes, LEGACY_FIXTURE_BASE_NONCE, part_number)
|
||||
.read_to_end(&mut part_cipher)
|
||||
.await
|
||||
.expect("encrypt compressed fixture part");
|
||||
|
||||
parts.push(ObjectPartInfo {
|
||||
number: part_number,
|
||||
size: part_cipher.len(),
|
||||
actual_size: *part_size as i64,
|
||||
index,
|
||||
..Default::default()
|
||||
});
|
||||
stored.extend_from_slice(&part_cipher);
|
||||
plaintext.extend_from_slice(&part_plaintext);
|
||||
}
|
||||
|
||||
let mut user_defined = legacy_ssec_multipart_metadata(key_bytes, plaintext.len());
|
||||
rustfs_utils::http::insert_str(
|
||||
&mut user_defined,
|
||||
rustfs_utils::http::SUFFIX_COMPRESSION,
|
||||
crate::io_support::rio::compression_metadata_value(CompressionAlgorithm::default()),
|
||||
);
|
||||
rustfs_utils::http::insert_str(&mut user_defined, rustfs_utils::http::SUFFIX_ACTUAL_SIZE, plaintext.len().to_string());
|
||||
|
||||
let object_info = ObjectInfo {
|
||||
bucket: "test-bucket".to_string(),
|
||||
name: "compressed-encrypted-multipart".to_string(),
|
||||
size: stored.len() as i64,
|
||||
etag: Some(format!("6bcf86bed8807b8e78f0fc6e0a53079d-{}", part_sizes.len())),
|
||||
parts: Arc::new(parts),
|
||||
user_defined: Arc::new(user_defined),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
CompressedMultipartFixture {
|
||||
object_info,
|
||||
stored,
|
||||
plaintext,
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_compressed_encrypted_multipart(
|
||||
fixture: &CompressedMultipartFixture,
|
||||
key_bytes: [u8; 32],
|
||||
rs: Option<HTTPRangeSpec>,
|
||||
opts: &ObjectOptions,
|
||||
) -> Vec<u8> {
|
||||
let headers = ssec_headers_from_key(key_bytes);
|
||||
let (_, offset, length) =
|
||||
GetObjectReader::new(Box::new(Cursor::new(Vec::new())), rs.clone(), &fixture.object_info, opts, &headers)
|
||||
.await
|
||||
.expect("plan compressed encrypted multipart read");
|
||||
|
||||
let end = offset + usize::try_from(length).expect("storage window length must be non-negative");
|
||||
assert!(
|
||||
end <= fixture.stored.len(),
|
||||
"planned storage window {offset}..{end} exceeds stored stream of {} bytes",
|
||||
fixture.stored.len()
|
||||
);
|
||||
let window = fixture.stored[offset..end].to_vec();
|
||||
|
||||
let (mut reader, replay_offset, replay_length) =
|
||||
GetObjectReader::new(Box::new(Cursor::new(window)), rs, &fixture.object_info, opts, &headers)
|
||||
.await
|
||||
.expect("build compressed encrypted multipart reader");
|
||||
assert_eq!((replay_offset, replay_length), (offset, length), "read plan must be deterministic");
|
||||
|
||||
reader.read_all().await.expect("read compressed encrypted multipart stream")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn compressed_encrypted_multipart_full_get_roundtrip() {
|
||||
let key_bytes = [0x6Eu8; 32];
|
||||
let fixture = compressed_encrypted_multipart_fixture(key_bytes, &[3 * 1024 * 1024, 1024 * 1024]).await;
|
||||
|
||||
let read = read_compressed_encrypted_multipart(&fixture, key_bytes, None, &ObjectOptions::default()).await;
|
||||
|
||||
assert_eq!(read.len(), fixture.plaintext.len(), "full GET must return the logical size");
|
||||
assert_eq!(read, fixture.plaintext, "SSE-C + compression full GET must reassemble all parts");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn compressed_encrypted_multipart_range_crosses_part_boundary() {
|
||||
let key_bytes = [0x6Eu8; 32];
|
||||
let fixture = compressed_encrypted_multipart_fixture(key_bytes, &[3 * 1024 * 1024, 1024 * 1024]).await;
|
||||
let boundary = 3 * 1024 * 1024_i64;
|
||||
let rs = HTTPRangeSpec {
|
||||
is_suffix_length: false,
|
||||
start: boundary - 65_536,
|
||||
end: boundary + 65_536 - 1,
|
||||
};
|
||||
|
||||
let read = read_compressed_encrypted_multipart(&fixture, key_bytes, Some(rs), &ObjectOptions::default()).await;
|
||||
|
||||
let expected = &fixture.plaintext[(boundary - 65_536) as usize..(boundary + 65_536) as usize];
|
||||
assert_eq!(read, expected, "SSE-C + compression boundary-crossing range must splice both parts");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn compressed_encrypted_multipart_part_number_get_returns_single_part() {
|
||||
let key_bytes = [0x6Eu8; 32];
|
||||
let part_sizes = [3 * 1024 * 1024, 1024 * 1024];
|
||||
let fixture = compressed_encrypted_multipart_fixture(key_bytes, &part_sizes).await;
|
||||
|
||||
let opts = ObjectOptions {
|
||||
part_number: Some(2),
|
||||
..Default::default()
|
||||
};
|
||||
let read = read_compressed_encrypted_multipart(&fixture, key_bytes, None, &opts).await;
|
||||
|
||||
let expected = &fixture.plaintext[part_sizes[0]..];
|
||||
assert_eq!(read.len(), part_sizes[1], "partNumber=2 GET must return the part's logical size");
|
||||
assert_eq!(read, expected, "partNumber=2 GET must return the original part bytes");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_object_reader_rejects_ssec_read_without_headers() {
|
||||
let object_info = ObjectInfo {
|
||||
|
||||
@@ -172,6 +172,7 @@ impl ObjectLockConfigSnapshot {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code, reason = "snapshot-scope predicate asserted by this file's tests (backlog#1823)")]
|
||||
pub(crate) fn is_for_store_bucket(
|
||||
&self,
|
||||
store_id: Uuid,
|
||||
@@ -260,6 +261,9 @@ pub struct ObjectOptions {
|
||||
|
||||
pub data_movement: bool,
|
||||
pub raw_data_movement_read: bool,
|
||||
/// Materialize the data-movement per-part checksum sidecar for APIs that
|
||||
/// return part checksums. Ordinary object reads leave it encoded.
|
||||
pub include_part_checksums: bool,
|
||||
pub src_pool_idx: usize,
|
||||
pub user_defined: HashMap<String, String>,
|
||||
pub preserve_etag: Option<String>,
|
||||
|
||||
@@ -31,7 +31,6 @@ use std::{
|
||||
use tokio::sync::{OnceCell, RwLock};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::warn;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub const DISK_ASSUME_UNKNOWN_SIZE: u64 = 1 << 30;
|
||||
pub const DISK_MIN_INODES: u64 = 1000;
|
||||
@@ -109,18 +108,6 @@ pub fn set_global_rustfs_port(value: u16) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the global deployment id
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `id` - The Uuid to set as the global deployment id
|
||||
///
|
||||
/// # Returns
|
||||
/// * None
|
||||
///
|
||||
pub fn set_global_deployment_id(id: Uuid) {
|
||||
current_ctx().set_deployment_id(id);
|
||||
}
|
||||
|
||||
/// Get the global deployment id
|
||||
///
|
||||
/// # Returns
|
||||
@@ -288,19 +275,6 @@ pub fn get_global_region() -> Option<s3s::region::Region> {
|
||||
current_ctx().region()
|
||||
}
|
||||
|
||||
/// Initialize the global background services cancellation token
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `cancel_token` - The CancellationToken instance to set globally
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Ok(())` if successful
|
||||
/// * `Err(CancellationToken)` if setting fails
|
||||
///
|
||||
pub fn init_background_services_cancel_token(cancel_token: CancellationToken) -> Result<(), CancellationToken> {
|
||||
current_ctx().init_background_cancel_token(cancel_token)
|
||||
}
|
||||
|
||||
/// Get the global background services cancellation token
|
||||
///
|
||||
/// # Returns
|
||||
@@ -310,18 +284,6 @@ pub fn get_background_services_cancel_token() -> Option<CancellationToken> {
|
||||
current_ctx().background_cancel_token()
|
||||
}
|
||||
|
||||
/// Create and initialize the global background services cancellation token
|
||||
///
|
||||
/// # Returns
|
||||
/// * `CancellationToken` - The newly created global cancellation token
|
||||
///
|
||||
pub fn create_background_services_cancel_token() -> CancellationToken {
|
||||
let cancel_token = CancellationToken::new();
|
||||
init_background_services_cancel_token(cancel_token.clone())
|
||||
.expect("background services cancel token should be initialized once during startup");
|
||||
cancel_token
|
||||
}
|
||||
|
||||
/// Shutdown all background services gracefully
|
||||
///
|
||||
/// # Returns
|
||||
|
||||
@@ -402,6 +402,10 @@ impl InstanceContext {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(
|
||||
dead_code,
|
||||
reason = "driven by the tier-delete-journal recovery test behind `--features test-util` (backlog#1823)"
|
||||
)]
|
||||
pub(crate) fn wake_tier_delete_journal_recovery(&self) {
|
||||
self.tier_delete_journal_recovery_wakeup.notify_one();
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
// limitations under the License.
|
||||
|
||||
// #730: runtime source migration keeps fallback handles until all owners inject state.
|
||||
#![allow(dead_code)]
|
||||
|
||||
pub(crate) mod global;
|
||||
pub(crate) mod instance;
|
||||
|
||||
@@ -38,7 +38,6 @@ use crate::{
|
||||
set_object_layer, update_erasure_type,
|
||||
},
|
||||
services::batch_processor::{GlobalBatchProcessors, get_global_processors},
|
||||
services::event_notification::EventNotifier,
|
||||
services::notification_sys::{NotificationSys, get_global_notification_sys},
|
||||
services::tier::tier::TierConfigMgr,
|
||||
store::ECStore,
|
||||
@@ -143,6 +142,10 @@ pub async fn setup_is_erasure_sd() -> bool {
|
||||
is_erasure_sd().await
|
||||
}
|
||||
|
||||
#[allow(
|
||||
dead_code,
|
||||
reason = "setup-type override used only by tests across this crate (backlog#1823)"
|
||||
)]
|
||||
pub(crate) async fn current_setup_type() -> SetupType {
|
||||
if setup_is_dist_erasure().await {
|
||||
SetupType::DistErasure
|
||||
@@ -155,6 +158,10 @@ pub(crate) async fn current_setup_type() -> SetupType {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(
|
||||
dead_code,
|
||||
reason = "setup-type override used only by tests across this crate (backlog#1823)"
|
||||
)]
|
||||
pub(crate) async fn set_setup_type(setup_type: SetupType) {
|
||||
update_erasure_type(setup_type).await;
|
||||
}
|
||||
@@ -164,6 +171,9 @@ pub(crate) async fn local_node_name() -> String {
|
||||
}
|
||||
|
||||
pub(crate) async fn set_local_node_name(node_name: String) {
|
||||
// Also stamp the internode-metrics server label: io-metrics is a leaf
|
||||
// crate and no longer resolves node identity itself (backlog#1834).
|
||||
rustfs_io_metrics::internode_metrics::set_internode_server_label(node_name.as_str());
|
||||
rustfs_common::set_global_local_node_name(&node_name).await;
|
||||
}
|
||||
|
||||
@@ -229,14 +239,6 @@ pub(crate) fn ensure_test_rpc_secret() {
|
||||
let _ = rustfs_credentials::set_global_rpc_secret(TEST_RPC_SECRET.to_owned());
|
||||
}
|
||||
|
||||
pub(crate) fn storage_class_parity(storage_class: Option<&str>) -> Option<usize> {
|
||||
get_global_storage_class_snapshot().get_parity_for_sc(storage_class.unwrap_or_default())
|
||||
}
|
||||
|
||||
pub(crate) fn storage_class_should_inline(shard_size: i64, versioned: bool) -> bool {
|
||||
get_global_storage_class_snapshot().should_inline(shard_size, versioned)
|
||||
}
|
||||
|
||||
pub(crate) fn deployment_upload_id(upload_id: &str) -> String {
|
||||
base64_simd::URL_SAFE_NO_PAD
|
||||
.encode_to_string(format!("{}.{}", get_global_deployment_id().unwrap_or_default(), upload_id).as_bytes())
|
||||
@@ -329,21 +331,6 @@ pub(crate) fn storage_class_config_snapshot() -> Arc<storageclass::Config> {
|
||||
get_global_storage_class_snapshot()
|
||||
}
|
||||
|
||||
/// Scalar STANDARD / RRS parity for backend-info reporting.
|
||||
///
|
||||
/// Retained for the rebalance/backend-info path. `get_parity_for_sc` returns
|
||||
/// `None` when the runtime config is uninitialized or (post per-pool support)
|
||||
/// when pools disagree, so STANDARD falls back to the caller's default and RRS
|
||||
/// stays `None` — matching the pre-per-pool scalar reporting.
|
||||
pub(crate) fn backend_storage_class_parities(default_standard_parity: usize) -> (Option<usize>, Option<usize>) {
|
||||
let sc = get_global_storage_class_snapshot();
|
||||
let standard = sc
|
||||
.get_parity_for_sc(storageclass::CLASS_STANDARD)
|
||||
.or(Some(default_standard_parity));
|
||||
let reduced_redundancy = sc.get_parity_for_sc(storageclass::RRS);
|
||||
(standard, reduced_redundancy)
|
||||
}
|
||||
|
||||
pub(crate) fn set_storage_class_config(config: storageclass::Config) {
|
||||
set_global_storage_class(config);
|
||||
}
|
||||
@@ -411,10 +398,6 @@ pub fn transition_state_handle() -> Arc<TransitionState> {
|
||||
crate::runtime::global::current_ctx().transition_state()
|
||||
}
|
||||
|
||||
pub(crate) fn event_notifier_handle() -> Arc<RwLock<EventNotifier>> {
|
||||
crate::runtime::global::current_ctx().event_notifier()
|
||||
}
|
||||
|
||||
pub(crate) async fn local_disk_by_path(path: &str) -> Option<DiskStore> {
|
||||
local_disk_map_handle().read().await.get(path).cloned().flatten()
|
||||
}
|
||||
@@ -508,30 +491,6 @@ pub(crate) async fn local_disk_set_drive(
|
||||
instance_ctx.local_disk_set_drives().read().await[pool_idx][set_idx][disk_idx].clone()
|
||||
}
|
||||
|
||||
pub(crate) async fn local_disk_for_endpoint(endpoint: &Endpoint) -> Option<DiskStore> {
|
||||
let set_drives = local_disk_set_drives_handle();
|
||||
let global_set_drives = set_drives.read().await;
|
||||
if global_set_drives.is_empty() {
|
||||
return local_disk_map_handle()
|
||||
.read()
|
||||
.await
|
||||
.get(&endpoint.to_string())
|
||||
.cloned()
|
||||
.unwrap_or(None);
|
||||
}
|
||||
|
||||
let pool_idx = usize::try_from(endpoint.pool_idx).ok()?;
|
||||
let set_idx = usize::try_from(endpoint.set_idx).ok()?;
|
||||
let disk_idx = usize::try_from(endpoint.disk_idx).ok()?;
|
||||
|
||||
global_set_drives
|
||||
.get(pool_idx)
|
||||
.and_then(|sets| sets.get(set_idx))
|
||||
.and_then(|disks| disks.get(disk_idx))
|
||||
.cloned()
|
||||
.unwrap_or(None)
|
||||
}
|
||||
|
||||
pub(crate) async fn local_disk_paths() -> Vec<String> {
|
||||
local_disk_map_handle().read().await.keys().cloned().collect()
|
||||
}
|
||||
|
||||
@@ -23,6 +23,10 @@ use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::task::JoinSet;
|
||||
|
||||
#[allow(
|
||||
dead_code,
|
||||
reason = "default operation label for the test-only AsyncBatchProcessor::new (backlog#1823)"
|
||||
)]
|
||||
const BATCH_PROCESSOR_OPERATION_CUSTOM: &str = "custom";
|
||||
const BATCH_PROCESSOR_OPERATION_READ: &str = "read";
|
||||
const BATCH_PROCESSOR_OPERATION_WRITE: &str = "write";
|
||||
@@ -211,6 +215,7 @@ pub struct AsyncBatchProcessor {
|
||||
}
|
||||
|
||||
impl AsyncBatchProcessor {
|
||||
#[allow(dead_code, reason = "constructor used only by this file's tests (backlog#1823)")]
|
||||
pub fn new(max_concurrent: usize) -> Self {
|
||||
Self::new_with_operation(max_concurrent, BATCH_PROCESSOR_OPERATION_CUSTOM)
|
||||
}
|
||||
|
||||
@@ -26,11 +26,26 @@ use std::sync::atomic::Ordering;
|
||||
use tokio::sync::RwLock;
|
||||
use tracing::warn;
|
||||
|
||||
/// Dead ecstore-side notification skeleton.
|
||||
///
|
||||
/// The working notification stack is `rustfs-notify`, whose own `EventNotifier`
|
||||
/// is the one bucket configuration actually drives. Nothing calls the methods
|
||||
/// below; `init_bucket_targets` even logs that it is a no-op in this build.
|
||||
/// Removing it means also retiring the `InstanceContext` slot that holds it
|
||||
/// (backlog#939 Phase 5), so it is left explicit here rather than half-removed.
|
||||
#[allow(
|
||||
dead_code,
|
||||
reason = "ecstore-side notification skeleton superseded by rustfs-notify; see module note (backlog#1823)"
|
||||
)]
|
||||
pub struct EventNotifier {
|
||||
target_list: TargetList,
|
||||
//bucket_rules_map: HashMap<String , HashMap<EventName, Rules>>,
|
||||
}
|
||||
|
||||
#[allow(
|
||||
dead_code,
|
||||
reason = "ecstore-side notification skeleton superseded by rustfs-notify; see module note (backlog#1823)"
|
||||
)]
|
||||
impl EventNotifier {
|
||||
pub fn new() -> Arc<RwLock<Self>> {
|
||||
Arc::new(RwLock::new(Self {
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
// limitations under the License.
|
||||
|
||||
// #730: background service owners still contain staged notification/rebalance/tier paths.
|
||||
#![allow(dead_code)]
|
||||
|
||||
pub(crate) mod batch_processor;
|
||||
pub(crate) mod event_notification;
|
||||
|
||||
@@ -44,12 +44,14 @@ const CONSECUTIVE_FAILURE_THRESHOLD: u32 = 3;
|
||||
const LOG_COMPONENT_ECSTORE: &str = "ecstore";
|
||||
const LOG_SUBSYSTEM_NOTIFICATION: &str = "notification";
|
||||
const EVENT_NOTIFICATION_PEER_PROPAGATION: &str = "notification_peer_propagation";
|
||||
const EVENT_NOTIFICATION_CAPABILITY_PROBE: &str = "notification_capability_probe";
|
||||
const SCANNER_ACTIVITY_PROBE_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const TIER_CONFIG_RELOAD_RETRY_BASE: Duration = Duration::from_millis(100);
|
||||
const TIER_CONFIG_RELOAD_RETRY_CAP: Duration = Duration::from_secs(5);
|
||||
const REMOTE_VERSION_STATE_PROBE_INTERVAL: Duration = Duration::from_secs(10);
|
||||
const REMOTE_VERSION_STATE_PROBE_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const REMOTE_VERSION_STATE_PROOF_TTL: Duration = Duration::from_secs(30);
|
||||
const CROSS_POOL_FENCE_SUPPORTED_VERSION: u32 = 1;
|
||||
|
||||
/// Cached result from the last successful admin call to a peer.
|
||||
struct PeerAdminCache {
|
||||
@@ -95,15 +97,15 @@ lazy_static! {
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct RemoteVersionStateFleetProof {
|
||||
struct FleetCapabilityProof {
|
||||
topology_fingerprint: String,
|
||||
peer_epochs: Arc<BTreeMap<String, Uuid>>,
|
||||
expires_at: Instant,
|
||||
}
|
||||
|
||||
impl RemoteVersionStateFleetProof {
|
||||
fn token(&self) -> RemoteVersionStateFleetProofToken {
|
||||
RemoteVersionStateFleetProofToken {
|
||||
impl FleetCapabilityProof {
|
||||
fn token(&self) -> FleetCapabilityProofToken {
|
||||
FleetCapabilityProofToken {
|
||||
topology_fingerprint: self.topology_fingerprint.clone(),
|
||||
peer_epochs: self.peer_epochs.clone(),
|
||||
}
|
||||
@@ -111,37 +113,41 @@ impl RemoteVersionStateFleetProof {
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub(crate) struct RemoteVersionStateFleetProofToken {
|
||||
struct FleetCapabilityProofToken {
|
||||
topology_fingerprint: String,
|
||||
peer_epochs: Arc<BTreeMap<String, Uuid>>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct RemoteVersionStateFleetProofState {
|
||||
proof: Option<RemoteVersionStateFleetProof>,
|
||||
struct FleetCapabilityProofState {
|
||||
proof: Option<FleetCapabilityProof>,
|
||||
topology_conflict: bool,
|
||||
}
|
||||
|
||||
static REMOTE_VERSION_STATE_FLEET_PROOF: OnceLock<std::sync::RwLock<RemoteVersionStateFleetProofState>> = OnceLock::new();
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub(crate) struct RemoteVersionStateFleetProofToken(FleetCapabilityProofToken);
|
||||
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct CrossPoolFenceFleetProofToken(FleetCapabilityProofToken);
|
||||
|
||||
static REMOTE_VERSION_STATE_FLEET_PROOF: OnceLock<std::sync::RwLock<FleetCapabilityProofState>> = OnceLock::new();
|
||||
static CROSS_POOL_FENCE_FLEET_PROOF: OnceLock<std::sync::RwLock<FleetCapabilityProofState>> = OnceLock::new();
|
||||
static REMOTE_VERSION_STATE_PROBE_TOPOLOGY: OnceLock<String> = OnceLock::new();
|
||||
|
||||
fn remote_version_state_fleet_proof_slot() -> &'static std::sync::RwLock<RemoteVersionStateFleetProofState> {
|
||||
REMOTE_VERSION_STATE_FLEET_PROOF.get_or_init(|| std::sync::RwLock::new(RemoteVersionStateFleetProofState::default()))
|
||||
fn cross_pool_fence_fleet_proof_slot() -> &'static std::sync::RwLock<FleetCapabilityProofState> {
|
||||
CROSS_POOL_FENCE_FLEET_PROOF.get_or_init(|| std::sync::RwLock::new(FleetCapabilityProofState::default()))
|
||||
}
|
||||
|
||||
fn replace_remote_version_state_fleet_proof(proof: Option<RemoteVersionStateFleetProof>) {
|
||||
replace_remote_version_state_fleet_proof_in(remote_version_state_fleet_proof_slot(), proof);
|
||||
fn remote_version_state_fleet_proof_slot() -> &'static std::sync::RwLock<FleetCapabilityProofState> {
|
||||
REMOTE_VERSION_STATE_FLEET_PROOF.get_or_init(|| std::sync::RwLock::new(FleetCapabilityProofState::default()))
|
||||
}
|
||||
|
||||
fn replace_remote_version_state_fleet_proof_in(
|
||||
slot: &std::sync::RwLock<RemoteVersionStateFleetProofState>,
|
||||
proof: Option<RemoteVersionStateFleetProof>,
|
||||
) {
|
||||
fn replace_fleet_capability_proof(slot: &std::sync::RwLock<FleetCapabilityProofState>, proof: Option<FleetCapabilityProof>) {
|
||||
slot.write().unwrap_or_else(std::sync::PoisonError::into_inner).proof = proof;
|
||||
}
|
||||
|
||||
fn publish_remote_version_state_probe_result(
|
||||
slot: &std::sync::RwLock<RemoteVersionStateFleetProofState>,
|
||||
fn publish_fleet_capability_probe_result(
|
||||
slot: &std::sync::RwLock<FleetCapabilityProofState>,
|
||||
topology_fingerprint: &str,
|
||||
result: Result<BTreeMap<String, Uuid>>,
|
||||
observed_at: Instant,
|
||||
@@ -155,7 +161,7 @@ fn publish_remote_version_state_probe_result(
|
||||
.filter(|proof| proof.topology_fingerprint == topology_fingerprint && proof.peer_epochs.as_ref() == &peer_epochs)
|
||||
.map(|proof| Arc::clone(&proof.peer_epochs))
|
||||
.unwrap_or_else(|| Arc::new(peer_epochs));
|
||||
state.proof = Some(RemoteVersionStateFleetProof {
|
||||
state.proof = Some(FleetCapabilityProof {
|
||||
topology_fingerprint: topology_fingerprint.to_string(),
|
||||
peer_epochs,
|
||||
expires_at: observed_at + REMOTE_VERSION_STATE_PROOF_TTL,
|
||||
@@ -163,7 +169,7 @@ fn publish_remote_version_state_probe_result(
|
||||
None
|
||||
}
|
||||
Err(err) => {
|
||||
replace_remote_version_state_fleet_proof_in(slot, None);
|
||||
replace_fleet_capability_proof(slot, None);
|
||||
Some(err)
|
||||
}
|
||||
}
|
||||
@@ -174,27 +180,60 @@ pub(crate) fn acquire_remote_version_state_fleet_proof() -> Option<RemoteVersion
|
||||
let state = remote_version_state_fleet_proof_slot()
|
||||
.read()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
acquire_remote_version_state_fleet_proof_from(&state, expected_topology, Instant::now())
|
||||
acquire_fleet_capability_proof_from(&state, expected_topology, Instant::now()).map(RemoteVersionStateFleetProofToken)
|
||||
}
|
||||
|
||||
fn acquire_remote_version_state_fleet_proof_from(
|
||||
state: &RemoteVersionStateFleetProofState,
|
||||
fn acquire_fleet_capability_proof_from(
|
||||
state: &FleetCapabilityProofState,
|
||||
expected_topology: &str,
|
||||
now: Instant,
|
||||
) -> Option<RemoteVersionStateFleetProofToken> {
|
||||
if state.topology_conflict || !remote_version_state_fleet_proof_valid_at(state.proof.as_ref(), expected_topology, now) {
|
||||
) -> Option<FleetCapabilityProofToken> {
|
||||
if state.topology_conflict || !fleet_capability_proof_valid_at(state.proof.as_ref(), expected_topology, now) {
|
||||
return None;
|
||||
}
|
||||
state.proof.as_ref().map(RemoteVersionStateFleetProof::token)
|
||||
state.proof.as_ref().map(FleetCapabilityProof::token)
|
||||
}
|
||||
|
||||
pub(crate) fn remote_version_state_fleet_proof_matches(proof: &RemoteVersionStateFleetProofToken) -> bool {
|
||||
fleet_capability_proof_matches(remote_version_state_fleet_proof_slot(), &proof.0)
|
||||
}
|
||||
|
||||
pub fn acquire_cross_pool_fence_fleet_proof() -> Option<CrossPoolFenceFleetProofToken> {
|
||||
let expected_topology = REMOTE_VERSION_STATE_PROBE_TOPOLOGY.get()?;
|
||||
let state = cross_pool_fence_fleet_proof_slot()
|
||||
.read()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
acquire_fleet_capability_proof_from(&state, expected_topology, Instant::now()).map(CrossPoolFenceFleetProofToken)
|
||||
}
|
||||
|
||||
pub fn cross_pool_fence_fleet_proof_matches(proof: &CrossPoolFenceFleetProofToken) -> bool {
|
||||
fleet_capability_proof_matches(cross_pool_fence_fleet_proof_slot(), &proof.0)
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
pub fn rotate_cross_pool_fence_fleet_proof_for_test() -> bool {
|
||||
let mut state = cross_pool_fence_fleet_proof_slot()
|
||||
.write()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let Some(current) = state.proof.as_ref() else {
|
||||
return false;
|
||||
};
|
||||
state.proof = Some(FleetCapabilityProof {
|
||||
topology_fingerprint: current.topology_fingerprint.clone(),
|
||||
peer_epochs: Arc::new(current.peer_epochs.as_ref().clone()),
|
||||
expires_at: current.expires_at,
|
||||
});
|
||||
true
|
||||
}
|
||||
|
||||
fn fleet_capability_proof_matches(
|
||||
slot: &std::sync::RwLock<FleetCapabilityProofState>,
|
||||
proof: &FleetCapabilityProofToken,
|
||||
) -> bool {
|
||||
let Some(expected_topology) = REMOTE_VERSION_STATE_PROBE_TOPOLOGY.get() else {
|
||||
return false;
|
||||
};
|
||||
let state = remote_version_state_fleet_proof_slot()
|
||||
.read()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let state = slot.read().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if state.topology_conflict {
|
||||
return false;
|
||||
}
|
||||
@@ -206,14 +245,42 @@ pub(crate) fn remote_version_state_fleet_proof_matches(proof: &RemoteVersionStat
|
||||
})
|
||||
}
|
||||
|
||||
fn remote_version_state_fleet_proof_valid_at(
|
||||
proof: Option<&RemoteVersionStateFleetProof>,
|
||||
expected_topology: &str,
|
||||
now: Instant,
|
||||
) -> bool {
|
||||
fn fleet_capability_proof_valid_at(proof: Option<&FleetCapabilityProof>, expected_topology: &str, now: Instant) -> bool {
|
||||
proof.is_some_and(|proof| proof.topology_fingerprint == expected_topology && now < proof.expires_at)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) struct RemoteVersionStateFleetProofGuard;
|
||||
|
||||
#[cfg(test)]
|
||||
impl Drop for RemoteVersionStateFleetProofGuard {
|
||||
fn drop(&mut self) {
|
||||
replace_fleet_capability_proof(remote_version_state_fleet_proof_slot(), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn install_remote_version_state_fleet_proof_for_test(topology_fingerprint: &str) -> RemoteVersionStateFleetProofGuard {
|
||||
match REMOTE_VERSION_STATE_PROBE_TOPOLOGY.set(topology_fingerprint.to_string()) {
|
||||
Ok(()) => {}
|
||||
Err(_)
|
||||
if REMOTE_VERSION_STATE_PROBE_TOPOLOGY
|
||||
.get()
|
||||
.is_some_and(|current| current == topology_fingerprint) => {}
|
||||
Err(_) => panic!("remote version state test topology is already bound to another fingerprint"),
|
||||
}
|
||||
let peer_epochs = BTreeMap::new();
|
||||
if let Some(err) = publish_fleet_capability_probe_result(
|
||||
remote_version_state_fleet_proof_slot(),
|
||||
topology_fingerprint,
|
||||
Ok(peer_epochs),
|
||||
Instant::now(),
|
||||
) {
|
||||
panic!("test proof installation must not fail: {err}");
|
||||
}
|
||||
RemoteVersionStateFleetProofGuard
|
||||
}
|
||||
|
||||
fn insert_remote_version_state_peer(peer_epochs: &mut BTreeMap<String, Uuid>, peer: String, epoch: Uuid) -> Result<()> {
|
||||
if epoch.is_nil() || peer_epochs.values().any(|existing| *existing == epoch) || peer_epochs.insert(peer, epoch).is_some() {
|
||||
return Err(Error::other("remote version state capability peer identity is invalid"));
|
||||
@@ -224,11 +291,11 @@ fn insert_remote_version_state_peer(peer_epochs: &mut BTreeMap<String, Uuid>, pe
|
||||
pub fn start_remote_version_state_fleet_probe(topology_fingerprint: String) {
|
||||
if REMOTE_VERSION_STATE_PROBE_TOPOLOGY.set(topology_fingerprint.clone()).is_err() {
|
||||
if REMOTE_VERSION_STATE_PROBE_TOPOLOGY.get() != Some(&topology_fingerprint) {
|
||||
let mut state = remote_version_state_fleet_proof_slot()
|
||||
.write()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
state.topology_conflict = true;
|
||||
state.proof = None;
|
||||
for slot in [remote_version_state_fleet_proof_slot(), cross_pool_fence_fleet_proof_slot()] {
|
||||
let mut state = slot.write().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
state.topology_conflict = true;
|
||||
state.proof = None;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -249,13 +316,23 @@ pub fn start_remote_version_state_fleet_probe(topology_fingerprint: String) {
|
||||
}
|
||||
None => Err(Error::other("remote version state fleet capability notification system is unavailable")),
|
||||
};
|
||||
let fence_result = match get_global_notification_sys() {
|
||||
Some(notification_sys) => timeout(
|
||||
REMOTE_VERSION_STATE_PROBE_TIMEOUT,
|
||||
notification_sys.probe_cross_pool_fence_fleet(&topology_fingerprint),
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|_| Err(Error::other("cross-pool fence fleet capability probe timed out"))),
|
||||
None => Err(Error::other("cross-pool fence fleet capability notification system is unavailable")),
|
||||
};
|
||||
let topology_conflict = remote_version_state_fleet_proof_slot()
|
||||
.read()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.topology_conflict;
|
||||
if topology_conflict {
|
||||
replace_remote_version_state_fleet_proof(None);
|
||||
} else if let Some(err) = publish_remote_version_state_probe_result(
|
||||
replace_fleet_capability_proof(remote_version_state_fleet_proof_slot(), None);
|
||||
replace_fleet_capability_proof(cross_pool_fence_fleet_proof_slot(), None);
|
||||
} else if let Some(err) = publish_fleet_capability_probe_result(
|
||||
remote_version_state_fleet_proof_slot(),
|
||||
&topology_fingerprint,
|
||||
result,
|
||||
@@ -263,6 +340,24 @@ pub fn start_remote_version_state_fleet_probe(topology_fingerprint: String) {
|
||||
) {
|
||||
debug!(error = %err, "remote version state fleet capability probe failed closed");
|
||||
}
|
||||
if !topology_conflict
|
||||
&& let Some(err) = publish_fleet_capability_probe_result(
|
||||
cross_pool_fence_fleet_proof_slot(),
|
||||
&topology_fingerprint,
|
||||
fence_result,
|
||||
Instant::now(),
|
||||
)
|
||||
{
|
||||
debug!(
|
||||
event = EVENT_NOTIFICATION_CAPABILITY_PROBE,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_NOTIFICATION,
|
||||
capability = "cross_pool_fence_v1",
|
||||
state = "failed_closed",
|
||||
error = %err,
|
||||
"notification capability probe"
|
||||
);
|
||||
}
|
||||
sleep(REMOTE_VERSION_STATE_PROBE_INTERVAL).await;
|
||||
}
|
||||
});
|
||||
@@ -330,6 +425,27 @@ impl NotificationSys {
|
||||
}
|
||||
Ok(peer_epochs)
|
||||
}
|
||||
|
||||
async fn probe_cross_pool_fence_fleet(&self, topology_fingerprint: &str) -> Result<BTreeMap<String, Uuid>> {
|
||||
if self.peer_clients.len() != self.peer_topology_hosts.len() {
|
||||
return Err(Error::other("cross-pool fence capability fleet membership is incomplete"));
|
||||
}
|
||||
let probes = self.peer_clients.iter().map(|client| async {
|
||||
let client = client
|
||||
.as_ref()
|
||||
.ok_or_else(|| Error::other("cross-pool fence capability peer is unreachable"))?;
|
||||
client.probe_cross_pool_fence(topology_fingerprint.to_string()).await
|
||||
});
|
||||
let mut peer_epochs = BTreeMap::new();
|
||||
for result in join_all(probes).await {
|
||||
let (peer, version, epoch) = result?;
|
||||
if version < CROSS_POOL_FENCE_SUPPORTED_VERSION {
|
||||
return Err(Error::other("cross-pool fence capability version is unsupported"));
|
||||
}
|
||||
insert_remote_version_state_peer(&mut peer_epochs, peer, epoch)?;
|
||||
}
|
||||
Ok(peer_epochs)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct NotificationPeerErr {
|
||||
@@ -1507,6 +1623,7 @@ impl NotificationSys {
|
||||
workers.peers.remove(host);
|
||||
}
|
||||
|
||||
#[allow(dead_code, reason = "asserted by this file's tests (backlog#1823)")]
|
||||
fn tier_config_reload_worker_active(&self, host: &str) -> bool {
|
||||
self.tier_config_reload_workers
|
||||
.lock()
|
||||
@@ -1680,6 +1797,7 @@ where
|
||||
.map_err(|_| Error::other(format!("scanner activity peer {host} timed out after {timeout_duration:?}")))?
|
||||
}
|
||||
|
||||
#[allow(dead_code, reason = "asserted by this file's tests (backlog#1823)")]
|
||||
async fn call_peer_with_timeout<F, Fut>(
|
||||
timeout_dur: Duration,
|
||||
host_label: &str,
|
||||
@@ -2145,16 +2263,16 @@ mod tests {
|
||||
let now = Instant::now();
|
||||
let mut peer_epochs = BTreeMap::new();
|
||||
peer_epochs.insert("peer-a".to_string(), Uuid::new_v4());
|
||||
let proof = RemoteVersionStateFleetProof {
|
||||
let proof = FleetCapabilityProof {
|
||||
topology_fingerprint: "topology-a".to_string(),
|
||||
peer_epochs: Arc::new(peer_epochs),
|
||||
expires_at: now + Duration::from_secs(1),
|
||||
};
|
||||
|
||||
assert!(remote_version_state_fleet_proof_valid_at(Some(&proof), "topology-a", now));
|
||||
assert!(!remote_version_state_fleet_proof_valid_at(Some(&proof), "topology-b", now));
|
||||
assert!(!remote_version_state_fleet_proof_valid_at(Some(&proof), "topology-a", proof.expires_at));
|
||||
assert!(!remote_version_state_fleet_proof_valid_at(None, "topology-a", now));
|
||||
assert!(fleet_capability_proof_valid_at(Some(&proof), "topology-a", now));
|
||||
assert!(!fleet_capability_proof_valid_at(Some(&proof), "topology-b", now));
|
||||
assert!(!fleet_capability_proof_valid_at(Some(&proof), "topology-a", proof.expires_at));
|
||||
assert!(!fleet_capability_proof_valid_at(None, "topology-a", now));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2168,25 +2286,25 @@ mod tests {
|
||||
#[test]
|
||||
fn remote_version_state_fleet_proof_accepts_single_node_membership() {
|
||||
let now = Instant::now();
|
||||
let proof = RemoteVersionStateFleetProof {
|
||||
let proof = FleetCapabilityProof {
|
||||
topology_fingerprint: "topology-a".to_string(),
|
||||
peer_epochs: Arc::new(BTreeMap::new()),
|
||||
expires_at: now + Duration::from_secs(1),
|
||||
};
|
||||
|
||||
assert!(remote_version_state_fleet_proof_valid_at(Some(&proof), "topology-a", now));
|
||||
assert!(fleet_capability_proof_valid_at(Some(&proof), "topology-a", now));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_version_state_fleet_proof_token_changes_with_process_epoch() {
|
||||
let now = Instant::now();
|
||||
let proof = RemoteVersionStateFleetProof {
|
||||
let proof = FleetCapabilityProof {
|
||||
topology_fingerprint: "topology-a".to_string(),
|
||||
peer_epochs: Arc::new(BTreeMap::from([("peer-a".to_string(), Uuid::new_v4())])),
|
||||
expires_at: now + Duration::from_secs(1),
|
||||
};
|
||||
let captured = proof.token();
|
||||
let restarted = RemoteVersionStateFleetProof {
|
||||
let restarted = FleetCapabilityProof {
|
||||
topology_fingerprint: proof.topology_fingerprint.clone(),
|
||||
peer_epochs: Arc::new(BTreeMap::from([("peer-a".to_string(), Uuid::new_v4())])),
|
||||
expires_at: proof.expires_at,
|
||||
@@ -2197,11 +2315,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn remote_version_state_fleet_proof_renewal_preserves_only_same_epoch_token() {
|
||||
let slot = std::sync::RwLock::new(RemoteVersionStateFleetProofState::default());
|
||||
let slot = std::sync::RwLock::new(FleetCapabilityProofState::default());
|
||||
let now = Instant::now();
|
||||
let epoch = Uuid::new_v4();
|
||||
let peers = BTreeMap::from([("peer-a".to_string(), epoch)]);
|
||||
assert!(publish_remote_version_state_probe_result(&slot, "topology-a", Ok(peers.clone()), now).is_none());
|
||||
assert!(publish_fleet_capability_probe_result(&slot, "topology-a", Ok(peers.clone()), now).is_none());
|
||||
let original = slot
|
||||
.read()
|
||||
.expect("proof slot should not poison")
|
||||
@@ -2210,9 +2328,7 @@ mod tests {
|
||||
.expect("successful probe should publish proof")
|
||||
.token();
|
||||
|
||||
assert!(
|
||||
publish_remote_version_state_probe_result(&slot, "topology-a", Ok(peers), now + Duration::from_millis(1)).is_none()
|
||||
);
|
||||
assert!(publish_fleet_capability_probe_result(&slot, "topology-a", Ok(peers), now + Duration::from_millis(1)).is_none());
|
||||
let renewed = slot
|
||||
.read()
|
||||
.expect("proof slot should not poison")
|
||||
@@ -2224,8 +2340,7 @@ mod tests {
|
||||
|
||||
let restarted = BTreeMap::from([("peer-a".to_string(), Uuid::new_v4())]);
|
||||
assert!(
|
||||
publish_remote_version_state_probe_result(&slot, "topology-a", Ok(restarted), now + Duration::from_millis(2))
|
||||
.is_none()
|
||||
publish_fleet_capability_probe_result(&slot, "topology-a", Ok(restarted), now + Duration::from_millis(2)).is_none()
|
||||
);
|
||||
let replaced = slot
|
||||
.read()
|
||||
@@ -2240,18 +2355,18 @@ mod tests {
|
||||
#[test]
|
||||
fn remote_version_state_fleet_proof_conflict_revokes_atomic_snapshot() {
|
||||
let now = Instant::now();
|
||||
let mut state = RemoteVersionStateFleetProofState {
|
||||
proof: Some(RemoteVersionStateFleetProof {
|
||||
let mut state = FleetCapabilityProofState {
|
||||
proof: Some(FleetCapabilityProof {
|
||||
topology_fingerprint: "topology-a".to_string(),
|
||||
peer_epochs: Arc::new(BTreeMap::new()),
|
||||
expires_at: now + Duration::from_secs(1),
|
||||
}),
|
||||
topology_conflict: false,
|
||||
};
|
||||
assert!(acquire_remote_version_state_fleet_proof_from(&state, "topology-a", now).is_some());
|
||||
assert!(acquire_fleet_capability_proof_from(&state, "topology-a", now).is_some());
|
||||
|
||||
state.topology_conflict = true;
|
||||
assert!(acquire_remote_version_state_fleet_proof_from(&state, "topology-a", now).is_none());
|
||||
assert!(acquire_fleet_capability_proof_from(&state, "topology-a", now).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2267,19 +2382,19 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn remote_version_state_fleet_probe_failure_revokes_previous_proof() {
|
||||
let slot = std::sync::RwLock::new(RemoteVersionStateFleetProofState::default());
|
||||
let slot = std::sync::RwLock::new(FleetCapabilityProofState::default());
|
||||
let now = Instant::now();
|
||||
let peer_epochs = BTreeMap::from([("node-a:9000".to_string(), Uuid::new_v4())]);
|
||||
assert!(publish_remote_version_state_probe_result(&slot, "topology-a", Ok(peer_epochs), now).is_none());
|
||||
assert!(publish_fleet_capability_probe_result(&slot, "topology-a", Ok(peer_epochs), now).is_none());
|
||||
assert!(slot.read().expect("proof slot should not poison").proof.is_some());
|
||||
|
||||
assert!(
|
||||
publish_remote_version_state_probe_result(&slot, "topology-a", Err(Error::other("peer unavailable")), now,).is_some()
|
||||
publish_fleet_capability_probe_result(&slot, "topology-a", Err(Error::other("peer unavailable")), now,).is_some()
|
||||
);
|
||||
assert!(slot.read().expect("proof slot should not poison").proof.is_none());
|
||||
|
||||
let peer_epochs = BTreeMap::from([("node-a:9000".to_string(), Uuid::new_v4())]);
|
||||
assert!(publish_remote_version_state_probe_result(&slot, "topology-a", Ok(peer_epochs), now).is_none());
|
||||
assert!(publish_fleet_capability_probe_result(&slot, "topology-a", Ok(peer_epochs), now).is_none());
|
||||
assert!(slot.read().expect("proof slot should not poison").proof.is_some());
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ use super::meta::{
|
||||
clone_arc_by_index, ensure_valid_rebalance_pool_index, invalid_rebalance_pool_index_error,
|
||||
rebalance_metadata_not_initialized_error, should_ignore_rebalance_data_usage_cache,
|
||||
};
|
||||
use super::migration::migrate_entry_version;
|
||||
use super::migration::{RebalanceMigrationBackend, migrate_entry_version};
|
||||
use super::worker::{
|
||||
RebalanceEntryCleanupResult, RebalanceEntryTask, load_rebalance_bucket_configs, rebalance_max_attempts,
|
||||
resolve_rebalance_bucket_error, resolve_rebalance_entry_cleanup_delete_result, resolve_rebalance_file_info_versions_result,
|
||||
@@ -144,6 +144,11 @@ impl ECStore {
|
||||
return Ok(RebalanceEntryOutcome::Completed);
|
||||
}
|
||||
|
||||
let bucket_incarnation_fence = match bucket_configs.bucket_incarnation_id {
|
||||
Some(expected) => Some(self.acquire_bucket_incarnation_fence(&bucket, expected).await?),
|
||||
None => None,
|
||||
};
|
||||
|
||||
let mut fivs =
|
||||
resolve_rebalance_file_info_versions_result(entry.file_info_versions(&bucket), bucket.as_str(), entry.name.as_str())?;
|
||||
|
||||
@@ -203,9 +208,14 @@ impl ECStore {
|
||||
}
|
||||
|
||||
let version_id = version.version_id.map(|v| v.to_string());
|
||||
let expected_bucket_incarnation_id = bucket_configs.bucket_incarnation_id;
|
||||
let mut transfer = |src_pool_idx: usize, bucket: String, rd: GetObjectReader| {
|
||||
let store = self.clone();
|
||||
async move { store.rebalance_object(src_pool_idx, bucket, rd).await }
|
||||
async move {
|
||||
store
|
||||
.rebalance_object(src_pool_idx, bucket, rd, expected_bucket_incarnation_id)
|
||||
.await
|
||||
}
|
||||
};
|
||||
// Route delete-marker migration through the store layer so it lands on the
|
||||
// cross-pool target (excluding the source pool), not back onto the source set.
|
||||
@@ -214,11 +224,12 @@ impl ECStore {
|
||||
async move { store.delete_object(&bucket, &object, opts).await }
|
||||
};
|
||||
let result = migrate_entry_version(
|
||||
set.as_ref(),
|
||||
&RebalanceMigrationBackend::new(set.as_ref(), self.as_ref()),
|
||||
bucket.clone(),
|
||||
pool_index,
|
||||
version,
|
||||
version_id.clone(),
|
||||
expected_bucket_incarnation_id,
|
||||
rebalance_max_attempts(),
|
||||
should_ignore_rebalance_data_usage_cache(bucket.as_str()),
|
||||
&mut transfer,
|
||||
@@ -303,6 +314,9 @@ impl ECStore {
|
||||
}
|
||||
|
||||
if should_cleanup_rebalance_source_entry(rebalanced, fivs.versions.len(), expired) {
|
||||
if bucket_incarnation_fence.as_ref().is_some_and(|guard| guard.is_lock_lost()) {
|
||||
return Err(Error::other("rebalance bucket incarnation fence was lost before source cleanup"));
|
||||
}
|
||||
let cleanup_result = self
|
||||
.finish_rebalance_entry_after_cleanup(
|
||||
pool_index,
|
||||
@@ -315,6 +329,12 @@ impl ECStore {
|
||||
entry.name.as_str(),
|
||||
&fivs,
|
||||
&cleanup_preflight_allowed_missing,
|
||||
data_movement::SourceCleanupBucketFence {
|
||||
expected_incarnation_id: bucket_configs.bucket_incarnation_id,
|
||||
lifecycle_guard: bucket_incarnation_fence
|
||||
.as_ref()
|
||||
.and_then(|guard| guard.namespace_lock_guard()),
|
||||
},
|
||||
"rebalance",
|
||||
),
|
||||
)
|
||||
@@ -389,8 +409,14 @@ impl ECStore {
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(self, rd))]
|
||||
async fn rebalance_object(self: Arc<Self>, pool_idx: usize, bucket: String, rd: GetObjectReader) -> Result<()> {
|
||||
data_movement::migrate_object(self, pool_idx, bucket, rd, "rebalance_object").await
|
||||
async fn rebalance_object(
|
||||
self: Arc<Self>,
|
||||
pool_idx: usize,
|
||||
bucket: String,
|
||||
rd: GetObjectReader,
|
||||
expected_bucket_incarnation_id: Option<uuid::Uuid>,
|
||||
) -> Result<()> {
|
||||
data_movement::migrate_object(self, pool_idx, bucket, rd, expected_bucket_incarnation_id, "rebalance_object").await
|
||||
}
|
||||
|
||||
async fn update_rebalance_last_error(&self, pool_idx: usize, message: String) -> Result<()> {
|
||||
|
||||
@@ -864,6 +864,10 @@ pub(super) fn merge_rebalance_meta(remote: &mut RebalanceMeta, local: &Rebalance
|
||||
RebalanceMetaMergeOutcome::Merged
|
||||
}
|
||||
|
||||
#[allow(
|
||||
dead_code,
|
||||
reason = "stop-transition helper retained beside stop_rebalance_meta_snapshot; no caller yet (backlog#1823)"
|
||||
)]
|
||||
pub(super) fn mark_started_rebalance_pools_stopped(meta: &mut RebalanceMeta, stop_time: OffsetDateTime) {
|
||||
for pool_stat in meta.pool_stats.iter_mut() {
|
||||
if pool_stat.info.status == RebalStatus::Started {
|
||||
@@ -964,6 +968,7 @@ pub(super) fn rollback_rebalance_start_meta_snapshot_for_id(
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(dead_code, reason = "asserted by this file's tests (backlog#1823)")]
|
||||
pub(super) fn stop_rebalance_meta_snapshot(meta: Option<&mut RebalanceMeta>, now: OffsetDateTime) -> Option<RebalanceMeta> {
|
||||
let meta = meta?;
|
||||
stop_rebalance_state(meta, now);
|
||||
|
||||
@@ -5,6 +5,7 @@ use crate::error::{Error, Result, is_err_object_not_found, is_err_version_not_fo
|
||||
use crate::object_api::{GetObjectReader, ObjectInfo, ObjectOptions};
|
||||
use crate::set_disk::SetDisks;
|
||||
use crate::storage_api_contracts::{object::ObjectIO, range::HTTPRangeSpec};
|
||||
use crate::store::ECStore;
|
||||
use http::HeaderMap;
|
||||
use rustfs_filemeta::FileInfo;
|
||||
use rustfs_utils::path::encode_dir_object;
|
||||
@@ -21,15 +22,23 @@ pub(crate) struct MigrationVersionResult {
|
||||
pub error: Option<Error>,
|
||||
}
|
||||
|
||||
pub(super) fn rebalance_delete_marker_opts(version: &FileInfo, version_id: Option<String>, src_pool_idx: usize) -> ObjectOptions {
|
||||
pub(super) fn rebalance_delete_marker_opts(
|
||||
version: &FileInfo,
|
||||
version_id: Option<String>,
|
||||
src_pool_idx: usize,
|
||||
expected_bucket_incarnation_id: Option<uuid::Uuid>,
|
||||
) -> ObjectOptions {
|
||||
let version_suspended = version.version_id.is_none() && version_id.is_none();
|
||||
ObjectOptions {
|
||||
versioned: true,
|
||||
version_id,
|
||||
versioned: !version_suspended,
|
||||
version_suspended,
|
||||
version_id: version_id.or_else(|| version_suspended.then(|| uuid::Uuid::nil().to_string())),
|
||||
mod_time: version.mod_time,
|
||||
src_pool_idx,
|
||||
data_movement: true,
|
||||
delete_marker: true,
|
||||
skip_decommissioned: true,
|
||||
expected_bucket_incarnation_id,
|
||||
delete_replication: version
|
||||
.replication_state_internal
|
||||
.as_ref()
|
||||
@@ -38,7 +47,12 @@ pub(super) fn rebalance_delete_marker_opts(version: &FileInfo, version_id: Optio
|
||||
}
|
||||
}
|
||||
|
||||
fn rebalance_remote_tiered_opts(version: &FileInfo, version_id: Option<String>, src_pool_idx: usize) -> ObjectOptions {
|
||||
fn rebalance_remote_tiered_opts(
|
||||
version: &FileInfo,
|
||||
version_id: Option<String>,
|
||||
src_pool_idx: usize,
|
||||
expected_bucket_incarnation_id: Option<uuid::Uuid>,
|
||||
) -> ObjectOptions {
|
||||
ObjectOptions {
|
||||
versioned: version_id.is_some(),
|
||||
version_id,
|
||||
@@ -46,6 +60,21 @@ fn rebalance_remote_tiered_opts(version: &FileInfo, version_id: Option<String>,
|
||||
user_defined: version.metadata.clone(),
|
||||
src_pool_idx,
|
||||
data_movement: true,
|
||||
include_part_checksums: true,
|
||||
http_preconditions: Some(crate::data_movement::data_movement_target_precondition()),
|
||||
expected_bucket_incarnation_id,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn rebalance_object_migration_read_opts(version_id: Option<String>) -> ObjectOptions {
|
||||
ObjectOptions {
|
||||
version_id,
|
||||
no_lock: true,
|
||||
data_movement: true,
|
||||
raw_data_movement_read: true,
|
||||
skip_decommissioned: true,
|
||||
skip_rebalancing: true,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
@@ -70,8 +99,19 @@ pub(crate) trait MigrationBackend: Send + Sync {
|
||||
) -> Result<()>;
|
||||
}
|
||||
|
||||
pub(crate) struct RebalanceMigrationBackend<'a> {
|
||||
source: &'a SetDisks,
|
||||
store: &'a ECStore,
|
||||
}
|
||||
|
||||
impl<'a> RebalanceMigrationBackend<'a> {
|
||||
pub(crate) fn new(source: &'a SetDisks, store: &'a ECStore) -> Self {
|
||||
Self { source, store }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationBackend for SetDisks {
|
||||
impl MigrationBackend for RebalanceMigrationBackend<'_> {
|
||||
async fn get_object_reader_for_migration(
|
||||
&self,
|
||||
bucket: &str,
|
||||
@@ -80,7 +120,7 @@ impl MigrationBackend for SetDisks {
|
||||
h: HeaderMap,
|
||||
opts: &ObjectOptions,
|
||||
) -> Result<GetObjectReader> {
|
||||
self.get_object_reader(bucket, object, range, h, opts).await
|
||||
self.source.get_object_reader(bucket, object, range, h, opts).await
|
||||
}
|
||||
|
||||
async fn move_remote_version_for_migration(
|
||||
@@ -90,7 +130,7 @@ impl MigrationBackend for SetDisks {
|
||||
fi: &FileInfo,
|
||||
opts: &ObjectOptions,
|
||||
) -> Result<()> {
|
||||
self.decommission_tiered_object(bucket, object, fi, opts).await
|
||||
self.store.decommission_tiered_object(bucket, object, fi, opts).await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,6 +141,7 @@ pub(crate) async fn migrate_entry_version<Backend, F, Fut, D, DFut>(
|
||||
pool_index: usize,
|
||||
version: &FileInfo,
|
||||
version_id: Option<String>,
|
||||
expected_bucket_incarnation_id: Option<uuid::Uuid>,
|
||||
max_attempts: usize,
|
||||
ignore_data_usage_cache: bool,
|
||||
transfer: F,
|
||||
@@ -113,12 +154,13 @@ where
|
||||
D: FnMut(String, String, ObjectOptions) -> DFut + Send,
|
||||
DFut: Future<Output = Result<ObjectInfo>> + Send,
|
||||
{
|
||||
migrate_entry_version_with_retry_wait(
|
||||
migrate_entry_version_with_retry_wait_and_incarnation(
|
||||
set,
|
||||
bucket,
|
||||
pool_index,
|
||||
version,
|
||||
version_id,
|
||||
expected_bucket_incarnation_id,
|
||||
max_attempts,
|
||||
ignore_data_usage_cache,
|
||||
transfer,
|
||||
@@ -129,6 +171,7 @@ where
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
#[allow(dead_code, reason = "asserted by this file's tests (backlog#1823)")]
|
||||
pub(super) async fn migrate_entry_version_with_retry_wait<Backend, F, Fut, D, DFut, W, WFut>(
|
||||
set: &Backend,
|
||||
bucket: String,
|
||||
@@ -137,6 +180,45 @@ pub(super) async fn migrate_entry_version_with_retry_wait<Backend, F, Fut, D, DF
|
||||
version_id: Option<String>,
|
||||
max_attempts: usize,
|
||||
ignore_data_usage_cache: bool,
|
||||
transfer: F,
|
||||
delete_marker: D,
|
||||
wait_retry: W,
|
||||
) -> MigrationVersionResult
|
||||
where
|
||||
Backend: MigrationBackend + ?Sized,
|
||||
F: FnMut(usize, String, GetObjectReader) -> Fut + Send,
|
||||
Fut: Future<Output = Result<()>> + Send,
|
||||
D: FnMut(String, String, ObjectOptions) -> DFut + Send,
|
||||
DFut: Future<Output = Result<ObjectInfo>> + Send,
|
||||
W: FnMut(Duration) -> WFut + Send,
|
||||
WFut: Future<Output = ()> + Send,
|
||||
{
|
||||
migrate_entry_version_with_retry_wait_and_incarnation(
|
||||
set,
|
||||
bucket,
|
||||
pool_index,
|
||||
version,
|
||||
version_id,
|
||||
None,
|
||||
max_attempts,
|
||||
ignore_data_usage_cache,
|
||||
transfer,
|
||||
delete_marker,
|
||||
wait_retry,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn migrate_entry_version_with_retry_wait_and_incarnation<Backend, F, Fut, D, DFut, W, WFut>(
|
||||
set: &Backend,
|
||||
bucket: String,
|
||||
pool_index: usize,
|
||||
version: &FileInfo,
|
||||
version_id: Option<String>,
|
||||
expected_bucket_incarnation_id: Option<uuid::Uuid>,
|
||||
max_attempts: usize,
|
||||
ignore_data_usage_cache: bool,
|
||||
mut transfer: F,
|
||||
mut delete_marker: D,
|
||||
mut wait_retry: W,
|
||||
@@ -169,7 +251,7 @@ where
|
||||
&bucket,
|
||||
&version.name,
|
||||
version,
|
||||
&rebalance_remote_tiered_opts(version, version_id, pool_index),
|
||||
&rebalance_remote_tiered_opts(version, version_id, pool_index, expected_bucket_incarnation_id),
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -212,7 +294,7 @@ where
|
||||
if let Err(err) = delete_marker(
|
||||
bucket.clone(),
|
||||
version.name.clone(),
|
||||
rebalance_delete_marker_opts(version, version_id, pool_index),
|
||||
rebalance_delete_marker_opts(version, version_id, pool_index, expected_bucket_incarnation_id),
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -255,11 +337,7 @@ where
|
||||
&encode_dir_object(&version.name),
|
||||
None,
|
||||
HeaderMap::new(),
|
||||
&ObjectOptions {
|
||||
version_id: version_id.clone(),
|
||||
no_lock: true,
|
||||
..Default::default()
|
||||
},
|
||||
&rebalance_object_migration_read_opts(version_id.clone()),
|
||||
)
|
||||
.await
|
||||
{
|
||||
|
||||
@@ -113,6 +113,8 @@ struct LegacyRebalanceMeta {
|
||||
struct MigrationBackendSpy {
|
||||
get_object_reader: Mutex<Option<core::result::Result<GetObjectReader, Error>>>,
|
||||
move_remote: Mutex<Option<core::result::Result<(), Error>>>,
|
||||
get_opts: Mutex<Vec<ObjectOptions>>,
|
||||
move_remote_opts: Mutex<Vec<ObjectOptions>>,
|
||||
get_calls: AtomicUsize,
|
||||
move_remote_calls: AtomicUsize,
|
||||
}
|
||||
@@ -125,6 +127,8 @@ impl MigrationBackendSpy {
|
||||
Self {
|
||||
get_object_reader: Mutex::new(get_object_reader),
|
||||
move_remote: Mutex::new(move_remote),
|
||||
get_opts: Mutex::new(Vec::new()),
|
||||
move_remote_opts: Mutex::new(Vec::new()),
|
||||
get_calls: AtomicUsize::new(0),
|
||||
move_remote_calls: AtomicUsize::new(0),
|
||||
}
|
||||
@@ -138,6 +142,24 @@ impl MigrationBackendSpy {
|
||||
self.move_remote_calls.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
fn last_get_opts(&self) -> ObjectOptions {
|
||||
self.get_opts
|
||||
.lock()
|
||||
.unwrap()
|
||||
.last()
|
||||
.cloned()
|
||||
.expect("reader opts should be captured")
|
||||
}
|
||||
|
||||
fn last_move_remote_opts(&self) -> ObjectOptions {
|
||||
self.move_remote_opts
|
||||
.lock()
|
||||
.unwrap()
|
||||
.last()
|
||||
.cloned()
|
||||
.expect("remote opts should be captured")
|
||||
}
|
||||
|
||||
fn make_reader() -> GetObjectReader {
|
||||
GetObjectReader {
|
||||
stream: Box::new(Cursor::new(vec![0_u8; 3])),
|
||||
@@ -156,9 +178,10 @@ impl MigrationBackend for MigrationBackendSpy {
|
||||
_object: &str,
|
||||
_range: Option<HTTPRangeSpec>,
|
||||
_h: http::HeaderMap,
|
||||
_opts: &ObjectOptions,
|
||||
opts: &ObjectOptions,
|
||||
) -> Result<GetObjectReader> {
|
||||
self.get_calls.fetch_add(1, Ordering::SeqCst);
|
||||
self.get_opts.lock().unwrap().push(opts.clone());
|
||||
if let Some(result) = self.get_object_reader.lock().unwrap().take() {
|
||||
return result;
|
||||
}
|
||||
@@ -171,9 +194,10 @@ impl MigrationBackend for MigrationBackendSpy {
|
||||
_bucket: &str,
|
||||
_object: &str,
|
||||
_fi: &FileInfo,
|
||||
_opts: &ObjectOptions,
|
||||
opts: &ObjectOptions,
|
||||
) -> Result<()> {
|
||||
self.move_remote_calls.fetch_add(1, Ordering::SeqCst);
|
||||
self.move_remote_opts.lock().unwrap().push(opts.clone());
|
||||
if let Some(result) = self.move_remote.lock().unwrap().take() {
|
||||
return result;
|
||||
}
|
||||
@@ -217,7 +241,8 @@ fn test_rebalance_delete_marker_opts_preserves_replication_state() {
|
||||
..version_deleted()
|
||||
};
|
||||
|
||||
let opts = rebalance_delete_marker_opts(&version, Some("version-id".to_string()), 7);
|
||||
let incarnation = uuid::Uuid::new_v4();
|
||||
let opts = rebalance_delete_marker_opts(&version, Some("version-id".to_string()), 7, Some(incarnation));
|
||||
let replication = opts.delete_replication.expect("replication state should be preserved");
|
||||
|
||||
assert!(opts.versioned);
|
||||
@@ -227,11 +252,22 @@ fn test_rebalance_delete_marker_opts_preserves_replication_state() {
|
||||
assert_eq!(opts.src_pool_idx, 7);
|
||||
assert_eq!(opts.version_id.as_deref(), Some("version-id"));
|
||||
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!(replication.delete_marker);
|
||||
assert_eq!(replication.replicate_decision_str, "existing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rebalance_delete_marker_opts_preserves_suspended_null_version() {
|
||||
let version = version_deleted();
|
||||
let opts = rebalance_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()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_migrate_entry_version_remote_version_is_moved_without_transfer() {
|
||||
let backend = MigrationBackendSpy::new(None, Some(Ok(())));
|
||||
@@ -248,12 +284,14 @@ async fn test_migrate_entry_version_remote_version_is_moved_without_transfer() {
|
||||
}
|
||||
};
|
||||
|
||||
let incarnation = uuid::Uuid::new_v4();
|
||||
let result = migrate_entry_version(
|
||||
&backend,
|
||||
"bucket".to_string(),
|
||||
0,
|
||||
&version,
|
||||
version.version_id.map(|v| v.to_string()),
|
||||
Some(incarnation),
|
||||
3,
|
||||
false,
|
||||
&mut transfer,
|
||||
@@ -269,6 +307,10 @@ async fn test_migrate_entry_version_remote_version_is_moved_without_transfer() {
|
||||
assert_eq!(transfer_count.load(Ordering::SeqCst), 0);
|
||||
assert_eq!(backend.move_remote_calls(), 1);
|
||||
assert_eq!(backend.get_calls(), 0);
|
||||
let remote_opts = backend.last_move_remote_opts();
|
||||
assert!(remote_opts.include_part_checksums);
|
||||
assert!(remote_opts.http_preconditions.is_some());
|
||||
assert_eq!(remote_opts.expected_bucket_incarnation_id, Some(incarnation));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -294,6 +336,7 @@ async fn test_migrate_entry_version_remote_not_found_is_cleanup_ignored() {
|
||||
0,
|
||||
&version,
|
||||
version.version_id.map(|v| v.to_string()),
|
||||
None,
|
||||
3,
|
||||
false,
|
||||
&mut transfer,
|
||||
@@ -330,6 +373,7 @@ async fn test_migrate_entry_version_remote_overwrite_is_not_ignored() {
|
||||
0,
|
||||
&version,
|
||||
Some("vid-1".to_string()),
|
||||
None,
|
||||
3,
|
||||
false,
|
||||
&mut transfer,
|
||||
@@ -368,6 +412,7 @@ async fn test_migrate_entry_version_remote_failure_is_reported() {
|
||||
0,
|
||||
&version,
|
||||
version.version_id.map(|v| v.to_string()),
|
||||
None,
|
||||
3,
|
||||
false,
|
||||
&mut transfer,
|
||||
@@ -410,6 +455,7 @@ async fn test_migrate_entry_version_deleted_version_routes_delete_through_store_
|
||||
1,
|
||||
&version,
|
||||
version.version_id.map(|v| v.to_string()),
|
||||
None,
|
||||
3,
|
||||
false,
|
||||
&mut transfer,
|
||||
@@ -449,6 +495,7 @@ async fn test_migrate_entry_version_deleted_version_not_found_is_ignored() {
|
||||
1,
|
||||
&version,
|
||||
version.version_id.map(|v| v.to_string()),
|
||||
None,
|
||||
3,
|
||||
false,
|
||||
&mut transfer,
|
||||
@@ -491,6 +538,7 @@ async fn test_migrate_entry_version_deleted_version_overwrite_is_not_ignored() {
|
||||
1,
|
||||
&version,
|
||||
Some("vid-1".to_string()),
|
||||
None,
|
||||
3,
|
||||
false,
|
||||
&mut transfer,
|
||||
@@ -520,6 +568,7 @@ async fn test_migrate_entry_version_reader_not_found_is_ignored() {
|
||||
1,
|
||||
&version,
|
||||
version.version_id.map(|v| v.to_string()),
|
||||
None,
|
||||
3,
|
||||
false,
|
||||
&mut transfer,
|
||||
@@ -647,6 +696,7 @@ async fn test_migrate_entry_version_reader_fails_after_retries() {
|
||||
1,
|
||||
&version,
|
||||
version.version_id.map(|v| v.to_string()),
|
||||
None,
|
||||
3,
|
||||
false,
|
||||
&mut transfer,
|
||||
@@ -685,6 +735,7 @@ async fn test_migrate_entry_version_zero_max_attempts_still_attempts_once() {
|
||||
1,
|
||||
&version,
|
||||
version.version_id.map(|v| v.to_string()),
|
||||
None,
|
||||
0,
|
||||
false,
|
||||
&mut transfer,
|
||||
@@ -750,6 +801,13 @@ async fn test_migrate_entry_version_transfer_retries_before_success() {
|
||||
assert_eq!(backend.get_calls(), 2);
|
||||
assert_eq!(transfer_count.load(Ordering::SeqCst), 2);
|
||||
assert_eq!(wait_count.load(Ordering::SeqCst), 1);
|
||||
let read_opts = backend.last_get_opts();
|
||||
assert_eq!(read_opts.version_id.as_deref(), version.version_id.map(|id| id.to_string()).as_deref());
|
||||
assert!(read_opts.no_lock);
|
||||
assert!(read_opts.data_movement);
|
||||
assert!(read_opts.raw_data_movement_read);
|
||||
assert!(read_opts.skip_decommissioned);
|
||||
assert!(read_opts.skip_rebalancing);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -822,6 +880,7 @@ async fn test_migrate_entry_version_transfer_fails_after_retries() {
|
||||
1,
|
||||
&version,
|
||||
version.version_id.map(|v| v.to_string()),
|
||||
None,
|
||||
2,
|
||||
false,
|
||||
&mut transfer,
|
||||
@@ -860,6 +919,7 @@ async fn test_migrate_entry_version_transfer_not_found_is_ignored() {
|
||||
1,
|
||||
&version,
|
||||
version.version_id.map(|v| v.to_string()),
|
||||
None,
|
||||
3,
|
||||
false,
|
||||
&mut transfer,
|
||||
@@ -901,6 +961,7 @@ async fn test_migrate_entry_version_transfer_overwrite_is_not_ignored() {
|
||||
1,
|
||||
&version,
|
||||
Some("vid-1".to_string()),
|
||||
None,
|
||||
3,
|
||||
false,
|
||||
&mut transfer,
|
||||
@@ -943,6 +1004,7 @@ async fn test_migrate_entry_version_ignores_data_usage_cache_when_enabled() {
|
||||
1,
|
||||
&version,
|
||||
version.version_id.map(|v| v.to_string()),
|
||||
None,
|
||||
2,
|
||||
true,
|
||||
&mut transfer,
|
||||
@@ -985,6 +1047,7 @@ async fn test_migrate_entry_version_data_usage_cache_moves_when_ignore_disabled(
|
||||
1,
|
||||
&version,
|
||||
version.version_id.map(|v| v.to_string()),
|
||||
None,
|
||||
2,
|
||||
false,
|
||||
&mut transfer,
|
||||
@@ -2026,6 +2089,7 @@ async fn test_migrate_entry_version_transfer_failure_reports_write_target_stage(
|
||||
1,
|
||||
&version,
|
||||
version.version_id.map(|v| v.to_string()),
|
||||
None,
|
||||
1,
|
||||
false,
|
||||
&mut transfer,
|
||||
@@ -2050,6 +2114,7 @@ async fn test_migrate_entry_version_reader_failure_reports_read_source_stage() {
|
||||
1,
|
||||
&version,
|
||||
version.version_id.map(|v| v.to_string()),
|
||||
None,
|
||||
1,
|
||||
false,
|
||||
&mut transfer,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use time::OffsetDateTime;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
@@ -32,10 +31,9 @@ pub struct RebalanceStats {
|
||||
pub cleanup_warnings: RebalanceCleanupWarnings,
|
||||
}
|
||||
|
||||
pub type RStats = Vec<Arc<RebalanceStats>>;
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub(super) struct RebalanceBucketConfigs {
|
||||
pub(super) bucket_incarnation_id: Option<uuid::Uuid>,
|
||||
pub(super) lifecycle_config: Option<s3s::dto::BucketLifecycleConfiguration>,
|
||||
pub(super) object_lock_config: Option<s3s::dto::ObjectLockConfiguration>,
|
||||
pub(super) replication_config: Option<(s3s::dto::ReplicationConfiguration, OffsetDateTime)>,
|
||||
|
||||
@@ -406,6 +406,7 @@ pub(super) async fn load_rebalance_bucket_configs(api: &ECStore, bucket: &str) -
|
||||
|
||||
let expiry_configs = crate::bucket::lifecycle::get_expiry_configs(api, bucket).await?;
|
||||
Ok(RebalanceBucketConfigs {
|
||||
bucket_incarnation_id: Some(api.bucket_incarnation_id_from_disk(bucket).await?),
|
||||
lifecycle_config: expiry_configs.lifecycle.map(|config| (*config).clone()),
|
||||
object_lock_config: expiry_configs.object_lock.map(|config| (*config).clone()),
|
||||
replication_config: resolve_rebalance_optional_bucket_config_result(
|
||||
|
||||
@@ -30,6 +30,5 @@ pub mod warm_backend_minio;
|
||||
pub mod warm_backend_r2;
|
||||
pub mod warm_backend_rustfs;
|
||||
pub mod warm_backend_s3;
|
||||
pub mod warm_backend_s3sdk;
|
||||
pub mod warm_backend_tencent;
|
||||
pub mod warm_backend_wasabi;
|
||||
|
||||
@@ -488,6 +488,7 @@ impl TierCandidateMutation {
|
||||
targets
|
||||
}
|
||||
|
||||
#[allow(dead_code, reason = "asserted by this file's tests (backlog#1823)")]
|
||||
fn affected_targets(
|
||||
&self,
|
||||
manager: &TierConfigMgr,
|
||||
@@ -802,6 +803,7 @@ fn tier_persisted_reference_blocks_any_target(
|
||||
.any(|target| tier_persisted_reference_blocks_target(tier_name, backend_identity, target))
|
||||
}
|
||||
|
||||
#[allow(dead_code, reason = "asserted by this file's tests (backlog#1823)")]
|
||||
fn tier_object_blocks_target_rebind(object: &ObjectInfo, target: &TierMutationIntentTarget) -> io::Result<bool> {
|
||||
tier_object_blocks_any_target_rebind(object, std::slice::from_ref(target))
|
||||
}
|
||||
@@ -2726,14 +2728,6 @@ impl TierConfigMgr {
|
||||
Self::publish_candidate_owned(handle, candidate, driver_tier.map(str::to_string), update).await
|
||||
}
|
||||
|
||||
fn begin_publish_transition(
|
||||
handle: &Arc<RwLock<Self>>,
|
||||
manager: &mut Self,
|
||||
candidate: &Self,
|
||||
) -> std::result::Result<TierPublishTransition, AdminError> {
|
||||
Self::begin_publish_transition_with_allowed_mutation_blocks(handle, manager, candidate, None)
|
||||
}
|
||||
|
||||
fn begin_publish_transition_with_allowed_mutation_blocks(
|
||||
handle: &Arc<RwLock<Self>>,
|
||||
manager: &mut Self,
|
||||
@@ -2819,14 +2813,6 @@ impl TierConfigMgr {
|
||||
})
|
||||
}
|
||||
|
||||
async fn publish_candidate_inner(
|
||||
handle: &Arc<RwLock<Self>>,
|
||||
candidate: Self,
|
||||
driver_tier: Option<&str>,
|
||||
) -> std::result::Result<(), AdminError> {
|
||||
Self::publish_candidate_inner_with_allowed_mutation_blocks(handle, candidate, driver_tier, None).await
|
||||
}
|
||||
|
||||
async fn publish_candidate_inner_with_allowed_mutation_blocks(
|
||||
handle: &Arc<RwLock<Self>>,
|
||||
candidate: Self,
|
||||
@@ -2939,6 +2925,7 @@ impl TierConfigMgr {
|
||||
admin_err
|
||||
}
|
||||
|
||||
#[allow(dead_code, reason = "reached only through #[cfg(test)] helpers in this file (backlog#1823)")]
|
||||
async fn publish_candidate_owned(
|
||||
handle: &Arc<RwLock<Self>>,
|
||||
candidate: Self,
|
||||
@@ -3541,6 +3528,7 @@ impl TierConfigMgr {
|
||||
Self::update_candidate_with_config_lock(handle, api, TierCandidateMutation::Remove(tier_name.to_string(), force)).await
|
||||
}
|
||||
|
||||
#[allow(dead_code, reason = "reached only through #[cfg(test)] helpers in this file (backlog#1823)")]
|
||||
async fn remove_and_save_with<S>(
|
||||
handle: &Arc<RwLock<Self>>,
|
||||
api: Arc<S>,
|
||||
@@ -3574,6 +3562,7 @@ impl TierConfigMgr {
|
||||
Self::update_candidate_with_config_lock(handle, api, TierCandidateMutation::Clear(force)).await
|
||||
}
|
||||
|
||||
#[allow(dead_code, reason = "reached only through #[cfg(test)] helpers in this file (backlog#1823)")]
|
||||
async fn clear_and_save_with<S>(
|
||||
handle: &Arc<RwLock<Self>>,
|
||||
api: Arc<S>,
|
||||
@@ -3612,6 +3601,10 @@ impl TierConfigMgr {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(
|
||||
dead_code,
|
||||
reason = "lease accounting asserted by a bucket_lifecycle_ops test behind `--features test-util` (backlog#1823)"
|
||||
)]
|
||||
pub(crate) async fn active_operation_lease_count(handle: &Arc<RwLock<Self>>, tier_name: &str) -> usize {
|
||||
let manager = handle.read().await;
|
||||
let Some(runtime) = registered_tier_driver_runtime(&manager) else {
|
||||
@@ -3717,10 +3710,6 @@ impl TierConfigMgr {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn retire_driver(&mut self, tier_name: &str) {
|
||||
self.revoke_driver(tier_name);
|
||||
}
|
||||
|
||||
fn revoke_all_drivers(&mut self) {
|
||||
if let Some(runtime) = registered_tier_driver_runtime(self) {
|
||||
let mut runtime = lock_unpoisoned(&runtime);
|
||||
@@ -3884,6 +3873,7 @@ impl TierConfigMgr {
|
||||
self.save_config(api, &config_file, data).await
|
||||
}
|
||||
|
||||
#[allow(dead_code, reason = "reached only through #[cfg(test)] helpers in this file (backlog#1823)")]
|
||||
async fn save_tiering_config_if_current<S>(
|
||||
&self,
|
||||
api: Arc<S>,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user