mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-16 01:48:21 +00:00
Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9d4896e2cf | |||
| c1f66969d7 | |||
| cfa9276fad | |||
| db8f55cb97 | |||
| 7f23a1ba91 | |||
| 1619c4be60 | |||
| 72fd7339c9 | |||
| 71e83aeec4 | |||
| 9138c24571 | |||
| e9f5318027 | |||
| 69e8ef9af5 | |||
| 0b2a46b36f | |||
| 56509ead1f | |||
| ffe889ad59 | |||
| e11ce2f132 | |||
| eca6bc1600 | |||
| 85be26b3c1 | |||
| ebbcfa3ac2 | |||
| ebd0531124 | |||
| 4421d4829f | |||
| d6c62b9601 | |||
| d91086d094 | |||
| 69719c257e |
@@ -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::/)
|
||||
|
||||
@@ -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"
|
||||
|
||||
Generated
+51
-47
@@ -278,6 +278,7 @@ checksum = "312c1ea69e5fe9966e0029fb95aca8790100b85aff4f0d3b00a9337c74069a9c"
|
||||
dependencies = [
|
||||
"bigdecimal",
|
||||
"bon",
|
||||
"crc32fast",
|
||||
"digest 0.11.3",
|
||||
"log",
|
||||
"miniz_oxide 0.9.1",
|
||||
@@ -289,9 +290,11 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_bytes",
|
||||
"serde_json",
|
||||
"snap",
|
||||
"strum",
|
||||
"thiserror 2.0.20",
|
||||
"uuid",
|
||||
"zstd",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3761,7 +3764,7 @@ checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
||||
|
||||
[[package]]
|
||||
name = "e2e_test"
|
||||
version = "1.0.0-rc.1"
|
||||
version = "1.0.0-rc.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"astral-tokio-tar",
|
||||
@@ -9090,7 +9093,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs"
|
||||
version = "1.0.0-rc.1"
|
||||
version = "1.0.0-rc.2"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"anyhow",
|
||||
@@ -9200,6 +9203,7 @@ dependencies = [
|
||||
"serial_test",
|
||||
"sha2 0.11.0",
|
||||
"shadow-rs",
|
||||
"snap",
|
||||
"socket2",
|
||||
"subtle",
|
||||
"sysinfo",
|
||||
@@ -9227,7 +9231,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-audit"
|
||||
version = "1.0.0-rc.1"
|
||||
version = "1.0.0-rc.2"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"const-str",
|
||||
@@ -9250,7 +9254,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-checksums"
|
||||
version = "1.0.0-rc.1"
|
||||
version = "1.0.0-rc.2"
|
||||
dependencies = [
|
||||
"base64-simd",
|
||||
"bytes",
|
||||
@@ -9266,7 +9270,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-common"
|
||||
version = "1.0.0-rc.1"
|
||||
version = "1.0.0-rc.2"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"hotpath",
|
||||
@@ -9284,7 +9288,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-concurrency"
|
||||
version = "1.0.0-rc.1"
|
||||
version = "1.0.0-rc.2"
|
||||
dependencies = [
|
||||
"hotpath",
|
||||
"insta",
|
||||
@@ -9297,7 +9301,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-config"
|
||||
version = "1.0.0-rc.1"
|
||||
version = "1.0.0-rc.2"
|
||||
dependencies = [
|
||||
"const-str",
|
||||
"hotpath",
|
||||
@@ -9307,7 +9311,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",
|
||||
@@ -9321,7 +9325,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-crypto"
|
||||
version = "1.0.0-rc.1"
|
||||
version = "1.0.0-rc.2"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"argon2",
|
||||
@@ -9342,7 +9346,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-data-usage"
|
||||
version = "1.0.0-rc.1"
|
||||
version = "1.0.0-rc.2"
|
||||
dependencies = [
|
||||
"hotpath",
|
||||
"rmp-serde",
|
||||
@@ -9352,7 +9356,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-ecstore"
|
||||
version = "1.0.0-rc.1"
|
||||
version = "1.0.0-rc.2"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"async-channel",
|
||||
@@ -9491,7 +9495,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-extension-schema"
|
||||
version = "1.0.0-rc.1"
|
||||
version = "1.0.0-rc.2"
|
||||
dependencies = [
|
||||
"hotpath",
|
||||
"serde",
|
||||
@@ -9501,7 +9505,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-filemeta"
|
||||
version = "1.0.0-rc.1"
|
||||
version = "1.0.0-rc.2"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"byteorder",
|
||||
@@ -9528,7 +9532,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",
|
||||
@@ -9559,7 +9563,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-iam"
|
||||
version = "1.0.0-rc.1"
|
||||
version = "1.0.0-rc.2"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"async-trait",
|
||||
@@ -9600,7 +9604,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-io-core"
|
||||
version = "1.0.0-rc.1"
|
||||
version = "1.0.0-rc.2"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"hotpath",
|
||||
@@ -9613,7 +9617,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-io-metrics"
|
||||
version = "1.0.0-rc.1"
|
||||
version = "1.0.0-rc.2"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"hotpath",
|
||||
@@ -9677,7 +9681,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-keystone"
|
||||
version = "1.0.0-rc.1"
|
||||
version = "1.0.0-rc.2"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures",
|
||||
@@ -9704,7 +9708,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-kms"
|
||||
version = "1.0.0-rc.1"
|
||||
version = "1.0.0-rc.2"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"anyhow",
|
||||
@@ -9753,7 +9757,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-lifecycle"
|
||||
version = "1.0.0-rc.1"
|
||||
version = "1.0.0-rc.2"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"hotpath",
|
||||
@@ -9776,7 +9780,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-lock"
|
||||
version = "1.0.0-rc.1"
|
||||
version = "1.0.0-rc.2"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"compact_str",
|
||||
@@ -9799,7 +9803,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-log-analyzer"
|
||||
version = "1.0.0-rc.1"
|
||||
version = "1.0.0-rc.2"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"flate2",
|
||||
@@ -9818,7 +9822,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-madmin"
|
||||
version = "1.0.0-rc.1"
|
||||
version = "1.0.0-rc.2"
|
||||
dependencies = [
|
||||
"hotpath",
|
||||
"humantime",
|
||||
@@ -9833,7 +9837,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-notify"
|
||||
version = "1.0.0-rc.1"
|
||||
version = "1.0.0-rc.2"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"async-trait",
|
||||
@@ -9868,7 +9872,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-object-capacity"
|
||||
version = "1.0.0-rc.1"
|
||||
version = "1.0.0-rc.2"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"futures",
|
||||
@@ -9888,7 +9892,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-object-data-cache"
|
||||
version = "1.0.0-rc.1"
|
||||
version = "1.0.0-rc.2"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"criterion",
|
||||
@@ -9905,7 +9909,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-obs"
|
||||
version = "1.0.0-rc.1"
|
||||
version = "1.0.0-rc.2"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"crossbeam-channel",
|
||||
@@ -9960,7 +9964,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-policy"
|
||||
version = "1.0.0-rc.1"
|
||||
version = "1.0.0-rc.2"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64-simd",
|
||||
@@ -9991,7 +9995,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-protocols"
|
||||
version = "1.0.0-rc.1"
|
||||
version = "1.0.0-rc.2"
|
||||
dependencies = [
|
||||
"astral-tokio-tar",
|
||||
"async-compression",
|
||||
@@ -10053,7 +10057,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-protos"
|
||||
version = "1.0.0-rc.1"
|
||||
version = "1.0.0-rc.2"
|
||||
dependencies = [
|
||||
"flatbuffers",
|
||||
"hotpath",
|
||||
@@ -10077,7 +10081,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-replication"
|
||||
version = "1.0.0-rc.1"
|
||||
version = "1.0.0-rc.2"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"bytes",
|
||||
@@ -10095,7 +10099,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-rio"
|
||||
version = "1.0.0-rc.1"
|
||||
version = "1.0.0-rc.2"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"arc-swap",
|
||||
@@ -10133,7 +10137,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-rio-v2"
|
||||
version = "1.0.0-rc.1"
|
||||
version = "1.0.0-rc.2"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"bytes",
|
||||
@@ -10156,7 +10160,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-s3-ops"
|
||||
version = "1.0.0-rc.1"
|
||||
version = "1.0.0-rc.2"
|
||||
dependencies = [
|
||||
"hotpath",
|
||||
"rustfs-s3-types",
|
||||
@@ -10164,7 +10168,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-s3-types"
|
||||
version = "1.0.0-rc.1"
|
||||
version = "1.0.0-rc.2"
|
||||
dependencies = [
|
||||
"hotpath",
|
||||
"serde",
|
||||
@@ -10173,7 +10177,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-s3select-api"
|
||||
version = "1.0.0-rc.1"
|
||||
version = "1.0.0-rc.2"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
@@ -10203,7 +10207,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-s3select-query"
|
||||
version = "1.0.0-rc.1"
|
||||
version = "1.0.0-rc.2"
|
||||
dependencies = [
|
||||
"async-recursion",
|
||||
"async-trait",
|
||||
@@ -10222,7 +10226,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-scanner"
|
||||
version = "1.0.0-rc.1"
|
||||
version = "1.0.0-rc.2"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
@@ -10262,7 +10266,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",
|
||||
@@ -10270,7 +10274,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-signer"
|
||||
version = "1.0.0-rc.1"
|
||||
version = "1.0.0-rc.2"
|
||||
dependencies = [
|
||||
"base64-simd",
|
||||
"bytes",
|
||||
@@ -10288,7 +10292,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-storage-api"
|
||||
version = "1.0.0-rc.1"
|
||||
version = "1.0.0-rc.2"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"hotpath",
|
||||
@@ -10303,7 +10307,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-targets"
|
||||
version = "1.0.0-rc.1"
|
||||
version = "1.0.0-rc.2"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"async-nats",
|
||||
@@ -10357,7 +10361,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-test-utils"
|
||||
version = "1.0.0-rc.1"
|
||||
version = "1.0.0-rc.2"
|
||||
dependencies = [
|
||||
"hotpath",
|
||||
"rustfs-data-usage",
|
||||
@@ -10373,7 +10377,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-tls-runtime"
|
||||
version = "1.0.0-rc.1"
|
||||
version = "1.0.0-rc.2"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"hotpath",
|
||||
@@ -10394,7 +10398,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-trusted-proxies"
|
||||
version = "1.0.0-rc.1"
|
||||
version = "1.0.0-rc.2"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
@@ -10431,7 +10435,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-utils"
|
||||
version = "1.0.0-rc.1"
|
||||
version = "1.0.0-rc.2"
|
||||
dependencies = [
|
||||
"base64-simd",
|
||||
"blake2",
|
||||
@@ -10473,7 +10477,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-zip"
|
||||
version = "1.0.0-rc.1"
|
||||
version = "1.0.0-rc.2"
|
||||
dependencies = [
|
||||
"astral-tokio-tar",
|
||||
"async-compression",
|
||||
|
||||
+48
-48
@@ -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"
|
||||
@@ -171,7 +171,7 @@ tower = { version = "0.5.3" }
|
||||
tower-http = { version = "0.7.0" }
|
||||
|
||||
# Serialization and Data Formats
|
||||
apache-avro = "0.22.0"
|
||||
apache-avro = { version = "0.22.0", features = ["snappy", "zstandard"] }
|
||||
bytes = { version = "1.12.1" }
|
||||
bytesize = "2.7.0"
|
||||
byteorder = "1.5.0"
|
||||
|
||||
@@ -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 证书目录,也请用同样方式准备该目录:
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -97,7 +97,7 @@ async fn start_enforcing_ilm_server(env: &mut LocalKMSTestEnvironment) -> TestRe
|
||||
|
||||
let envs = [
|
||||
("RUSTFS_KMS_ALLOW_INSECURE_DEV_DEFAULTS", "true"),
|
||||
("RUSTFS_KMS_ENFORCE_SSE_KEY_POLICY", "false"),
|
||||
("RUSTFS_KMS_ENFORCE_SSE_KEY_POLICY", "true"),
|
||||
("RUSTFS_SCANNER_CYCLE", "1"),
|
||||
("RUSTFS_ILM_PROCESS_TIME", "1"),
|
||||
("RUSTFS_ILM_DEBUG_DAY_SECS", "2"),
|
||||
@@ -486,7 +486,6 @@ async fn ilm_expiration_on_sse_kms_bucket_under_enforcement() -> TestResult {
|
||||
/// depend on scanner scheduling; the 1s scanner cycle stays on as a backstop.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
#[ignore = "pins rustfs/rustfs#6025: GET on a transitioned managed-SSE object silently returns corrupt bytes (fails with enforcement on AND off, so it is not an authorization regression); un-ignore with the fix"]
|
||||
async fn ilm_transition_on_sse_kms_bucket_under_enforcement_reads_back() -> TestResult {
|
||||
init_logging();
|
||||
|
||||
|
||||
@@ -135,7 +135,8 @@ pub mod bucket {
|
||||
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,
|
||||
acquire_bucket_metadata_transaction_lock_for_incarnation, capture_bucket_metadata_incarnation, delete,
|
||||
delete_if_incarnation, delete_under_transaction_lock, get, get_accelerate_config, get_bucket_policy,
|
||||
get_bucket_policy_raw, get_bucket_targets_config, get_config_from_disk, get_cors_config, get_durability_config,
|
||||
get_global_bucket_metadata_sys, get_lifecycle_config, get_logging_config, get_notification_config,
|
||||
get_object_lock_config, get_object_lock_config_state, get_public_access_block_config, get_quota_config,
|
||||
@@ -184,17 +185,18 @@ pub mod bucket {
|
||||
mrf_backlog_observability_snapshot,
|
||||
};
|
||||
pub use crate::bucket::replication::{
|
||||
BucketReplicationResyncStatus, BucketReplicationStats, BucketStats, DeleteReplicationConfigSnapshot,
|
||||
DeletedObjectReplicationInfo, DurableMrfBacklog, DynReplicationPool, MrfOpKind, MrfReplicateEntry,
|
||||
MustReplicateOptions, ObjectOpts, REMOTE_TARGET_CAPABILITY_CONTRACT_VERSION, REMOTE_TARGET_UNSUPPORTED_FIELDS,
|
||||
REMOTE_TARGET_WRITABLE_FIELDS, REPLICATE_INCOMING_DELETE, REPLICATION_CAPABILITY_CONTRACT_VERSION,
|
||||
REPLICATION_READ_ONLY_HISTORICAL_FIELDS, REPLICATION_WRITABLE_FIELDS, ReplicateDecision, ReplicateObjectInfo,
|
||||
ReplicationBatchAdmission, ReplicationConfig, ReplicationConfigStructureError, ReplicationConfigurationExt,
|
||||
ReplicationDeleteScheduleInput, ReplicationDeleteStateSource, ReplicationHealQueueResult, ReplicationObjectBridge,
|
||||
ReplicationObjectIO, ReplicationOperation, ReplicationPoolTrait, ReplicationPriority, ReplicationQueueAdmission,
|
||||
ReplicationScannerBridge, ReplicationState, ReplicationStats, ReplicationStatusType, ReplicationStorage,
|
||||
ReplicationTargetValidationError, ReplicationType, ResyncOpts, ResyncStatusType, RuntimeReplicationTargetBacklog,
|
||||
TargetReplicationResyncStatus, VersionPurgeStatusType, commit_force_delete_intent, complete_force_delete_intent,
|
||||
BucketReplicationResyncStatus, BucketReplicationStat, BucketReplicationStats, BucketStats,
|
||||
DeleteReplicationConfigSnapshot, DeletedObjectReplicationInfo, DurableMrfBacklog, DynReplicationPool, InQueueMetric,
|
||||
MrfOpKind, MrfReplicateEntry, MustReplicateOptions, ObjectOpts, REMOTE_TARGET_CAPABILITY_CONTRACT_VERSION,
|
||||
REMOTE_TARGET_UNSUPPORTED_FIELDS, REMOTE_TARGET_WRITABLE_FIELDS, REPLICATE_INCOMING_DELETE,
|
||||
REPLICATION_CAPABILITY_CONTRACT_VERSION, REPLICATION_READ_ONLY_HISTORICAL_FIELDS, REPLICATION_WRITABLE_FIELDS,
|
||||
ReplicateDecision, ReplicateObjectInfo, ReplicationBatchAdmission, ReplicationConfig,
|
||||
ReplicationConfigStructureError, ReplicationConfigurationExt, ReplicationDeleteScheduleInput,
|
||||
ReplicationDeleteStateSource, ReplicationHealQueueResult, ReplicationObjectBridge, ReplicationObjectIO,
|
||||
ReplicationOperation, ReplicationPoolTrait, ReplicationPriority, ReplicationQueueAdmission, ReplicationScannerBridge,
|
||||
ReplicationState, ReplicationStats, ReplicationStatusType, ReplicationStorage, ReplicationTargetValidationError,
|
||||
ReplicationType, ResyncOpts, ResyncStatusType, RuntimeReplicationTargetBacklog, TargetReplicationResyncStatus,
|
||||
VersionPurgeStatusType, XferStats, commit_force_delete_intent, complete_force_delete_intent,
|
||||
delete_replication_state_from_config, delete_replication_version_id, get_global_replication_pool,
|
||||
get_global_replication_stats, init_background_replication, invalid_replication_config_status_field,
|
||||
persist_force_delete_intent, read_durable_mrf_backlog, replication_state_to_filemeta, replication_status_to_filemeta,
|
||||
@@ -278,7 +280,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 {
|
||||
|
||||
@@ -46,15 +46,13 @@ use crate::bucket::lifecycle::transition_transaction::run_transition_transaction
|
||||
use crate::bucket::object_lock::ObjectLockApi;
|
||||
use crate::bucket::versioning::VersioningApi as _;
|
||||
use crate::bucket::versioning_sys::BucketVersioningSys;
|
||||
use crate::client::object_api_utils::new_getobjectreader;
|
||||
use crate::disk::error::DiskError;
|
||||
use crate::disk::{DeleteOptions, Disk, DiskAPI, RUSTFS_META_BUCKET, RUSTFS_META_MULTIPART_BUCKET, STORAGE_FORMAT_FILE};
|
||||
use crate::error::Error;
|
||||
use crate::error::StorageError;
|
||||
use crate::error::{
|
||||
error_resp_to_object_err, is_err_object_not_found, is_err_read_quorum, is_err_version_not_found, is_network_or_host_down,
|
||||
};
|
||||
use crate::error::{is_err_object_not_found, is_err_read_quorum, is_err_version_not_found, is_network_or_host_down};
|
||||
use crate::object_api::{GetObjectReader, ObjectInfo, ObjectOptions};
|
||||
use crate::object_api::{ObjectEncryptionResolver, ReadPlan};
|
||||
use crate::services::tier::{
|
||||
tier::{TierConfigMgr, TierOperationLease, tier_destination_id_from_metadata},
|
||||
warm_backend::WarmBackendGetOpts,
|
||||
@@ -4400,9 +4398,10 @@ pub async fn get_transitioned_object_reader(
|
||||
h: &HeaderMap,
|
||||
oi: &ObjectInfo,
|
||||
opts: &ObjectOptions,
|
||||
resolver: Option<&dyn ObjectEncryptionResolver>,
|
||||
) -> Result<GetObjectReader, std::io::Error> {
|
||||
let tier_config_mgr = runtime_sources::tier_config_mgr_handle();
|
||||
get_transitioned_object_reader_with_tier_manager(bucket, object, rs, h, oi, opts, &tier_config_mgr).await
|
||||
get_transitioned_object_reader_with_tier_manager(bucket, object, rs, h, oi, opts, &tier_config_mgr, resolver).await
|
||||
}
|
||||
|
||||
fn validate_transition_remote_version(oi: &ObjectInfo) -> Result<bool, std::io::Error> {
|
||||
@@ -4422,6 +4421,10 @@ fn validate_transition_remote_version(oi: &ObjectInfo) -> Result<bool, std::io::
|
||||
}
|
||||
}
|
||||
|
||||
// The resolver joins the tier manager as the second injected port this read
|
||||
// needs; grouping the request half into a struct would churn every call site of
|
||||
// a bug fix.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) async fn get_transitioned_object_reader_with_tier_manager(
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
@@ -4430,6 +4433,7 @@ pub(crate) async fn get_transitioned_object_reader_with_tier_manager(
|
||||
oi: &ObjectInfo,
|
||||
opts: &ObjectOptions,
|
||||
tier_config_mgr: &Arc<RwLock<TierConfigMgr>>,
|
||||
resolver: Option<&dyn ObjectEncryptionResolver>,
|
||||
) -> Result<GetObjectReader, std::io::Error> {
|
||||
validate_transition_remote_version(oi)?;
|
||||
let expected_identity = tier_destination_id_from_metadata(&oi.user_defined)?;
|
||||
@@ -4447,11 +4451,16 @@ pub(crate) async fn get_transitioned_object_reader_with_tier_manager(
|
||||
|
||||
tgt_client.validate_remote_version_id(&oi.transitioned_object.version_id)?;
|
||||
|
||||
let ret = new_getobjectreader(rs, oi, opts, h);
|
||||
if let Err(err) = ret {
|
||||
return Err(error_resp_to_object_err(err, vec![bucket, object]));
|
||||
}
|
||||
let (get_fn, off, length) = ret.expect("get_transitioned_object_reader should succeed after error check");
|
||||
// The same read plan the local path uses, so the tier fetch is positioned in
|
||||
// the object's *stored* coordinate system and the stream is handed the same
|
||||
// decrypt/decompress transforms. Reading an encrypted object's ciphertext
|
||||
// through a plaintext-coordinate range and skipping the transform is how a
|
||||
// transitioned SSE object used to come back as silently corrupt bytes of the
|
||||
// right length (rustfs/rustfs#6025).
|
||||
let plan = ReadPlan::build_for_request(rs.clone(), oi, opts, h, resolver)
|
||||
.await
|
||||
.map_err(|err| std::io::Error::other(format!("building the read plan for {bucket}/{object} failed: {err}")))?;
|
||||
let (off, length) = (plan.storage_offset() as i64, plan.storage_length());
|
||||
let mut gopts = WarmBackendGetOpts::default();
|
||||
|
||||
if off >= 0 && length >= 0 {
|
||||
@@ -4488,7 +4497,10 @@ pub(crate) async fn get_transitioned_object_reader_with_tier_manager(
|
||||
);
|
||||
e
|
||||
})?;
|
||||
Ok(attach_tier_operation_lease(get_fn(reader, h.clone()), tgt_client))
|
||||
let object_reader = plan
|
||||
.into_object_reader(Box::new(reader), oi)
|
||||
.map_err(|err| std::io::Error::other(format!("wrapping the tier stream for {bucket}/{object} failed: {err}")))?;
|
||||
Ok(attach_tier_operation_lease(object_reader, tgt_client))
|
||||
}
|
||||
|
||||
struct TierOperationLeaseReader {
|
||||
@@ -5776,6 +5788,7 @@ mod tests {
|
||||
&object_info,
|
||||
&ObjectOptions::default(),
|
||||
&manager,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("transitioned reader should open");
|
||||
@@ -5840,6 +5853,7 @@ mod tests {
|
||||
&object_info,
|
||||
&ObjectOptions::default(),
|
||||
&manager,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -5880,6 +5894,7 @@ mod tests {
|
||||
&object_info,
|
||||
&ObjectOptions::default(),
|
||||
&manager,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -6117,6 +6132,7 @@ mod tests {
|
||||
&oi,
|
||||
&ObjectOptions::default(),
|
||||
&manager,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -6140,6 +6156,7 @@ mod tests {
|
||||
&oi,
|
||||
&ObjectOptions::default(),
|
||||
&manager,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
|
||||
@@ -656,6 +656,16 @@ pub async fn update_under_transaction_lock(
|
||||
update_under_config_write_guard(get_bucket_metadata_sys()?, guard, config_file, data).await
|
||||
}
|
||||
|
||||
/// Clear one config file while the caller holds this bucket's transaction lock.
|
||||
pub async fn delete_under_transaction_lock(
|
||||
guard: &BucketMetadataMutationGuard,
|
||||
bucket: &str,
|
||||
config_file: &str,
|
||||
) -> Result<OffsetDateTime> {
|
||||
guard.ensure_valid(bucket)?;
|
||||
delete_under_config_write_guard(get_bucket_metadata_sys()?, guard, config_file).await
|
||||
}
|
||||
|
||||
pub async fn update_quota_if_incarnation(
|
||||
bucket: &str,
|
||||
data: Vec<u8>,
|
||||
@@ -795,6 +805,14 @@ pub async fn acquire_bucket_metadata_transaction_lock(bucket: &str) -> Result<Bu
|
||||
acquire_config_write_guard(get_bucket_metadata_sys()?, bucket).await
|
||||
}
|
||||
|
||||
/// Acquire the bucket transaction lock only if its incarnation still matches.
|
||||
pub async fn acquire_bucket_metadata_transaction_lock_for_incarnation(
|
||||
bucket: &str,
|
||||
expected_incarnation_id: Uuid,
|
||||
) -> Result<BucketMetadataMutationGuard> {
|
||||
acquire_config_write_guard_for_incarnation(get_bucket_metadata_sys()?, bucket, Some(expected_incarnation_id)).await
|
||||
}
|
||||
|
||||
pub(crate) async fn acquire_bucket_metadata_transaction_lock_in(
|
||||
ctx: &crate::runtime::instance::InstanceContext,
|
||||
bucket: &str,
|
||||
|
||||
@@ -81,6 +81,6 @@ pub use replication_queue_boundary::{
|
||||
pub use replication_resync_boundary::{BucketReplicationResyncStatus, ResyncOpts, TargetReplicationResyncStatus};
|
||||
pub use replication_scanner_bridge::ReplicationScannerBridge;
|
||||
pub use replication_state::{ReplicationStats, RuntimeReplicationTargetBacklog};
|
||||
pub use replication_stats_boundary::{BucketReplicationStats, BucketStats};
|
||||
pub use replication_stats_boundary::{BucketReplicationStat, BucketReplicationStats, BucketStats, InQueueMetric, XferStats};
|
||||
pub use replication_storage_boundary::{ReplicationObjectIO, ReplicationStorage};
|
||||
pub(crate) use replication_target_config_bridge::ReplicationTargetConfigBridge;
|
||||
|
||||
@@ -704,6 +704,12 @@ impl ReplicationStats {
|
||||
} else {
|
||||
BucketReplicationStats::new()
|
||||
};
|
||||
// Stamp the serializable failure windows from the live samples: the
|
||||
// samples themselves do not cross the peer-RPC wire, so this snapshot
|
||||
// is what cluster aggregation and the metrics endpoints see.
|
||||
for stat in replication_stats.stats.values_mut() {
|
||||
stat.fail_stats.refresh_windows();
|
||||
}
|
||||
let uptime = if cache.contains_key(bucket) {
|
||||
SystemTime::now()
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
|
||||
@@ -15,7 +15,9 @@
|
||||
#[cfg(test)]
|
||||
pub(crate) use rustfs_replication::FailStats;
|
||||
pub(crate) use rustfs_replication::{
|
||||
ActiveWorkerStat, BucketReplicationStat, InQueueMetric, ProxyMetric, ProxyStatsCache, QueueCache, ReplicationMetricScope,
|
||||
SRMetricsSummary, XferStats,
|
||||
ActiveWorkerStat, ProxyMetric, ProxyStatsCache, QueueCache, ReplicationMetricScope, SRMetricsSummary,
|
||||
};
|
||||
pub use rustfs_replication::{BucketReplicationStats, BucketStats};
|
||||
// Public so the admin wire DTOs (rustfs/src/admin/replication_metrics_wire.rs)
|
||||
// can project the internal stats onto the minio-go response shapes through
|
||||
// the storage_api facade chain.
|
||||
pub use rustfs_replication::{BucketReplicationStat, BucketReplicationStats, BucketStats, InQueueMetric, XferStats};
|
||||
|
||||
@@ -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;
|
||||
@@ -1767,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;
|
||||
@@ -1814,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()?;
|
||||
@@ -2013,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};
|
||||
|
||||
@@ -190,6 +190,17 @@ pub(crate) const GET_METADATA_CACHE_REASON_VERSION_SUSPENDED: &str = "version_su
|
||||
pub(crate) const GET_METADATA_CACHE_REASON_VERSIONED: &str = "versioned";
|
||||
pub(crate) const GET_METADATA_EARLY_STOP_REASON_CONFLICTING_METADATA: &str = "conflicting_metadata";
|
||||
pub(crate) const GET_METADATA_EARLY_STOP_REASON_DELETE_MARKER: &str = "delete_marker";
|
||||
pub(crate) const GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_BODY_VERIFY: &str = "data_read_inline_body_verify";
|
||||
pub(crate) const GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_DELETED: &str = "data_read_inline_deleted";
|
||||
pub(crate) const GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_GEOMETRY: &str = "data_read_inline_geometry";
|
||||
pub(crate) const GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_IDENTITY_MISMATCH: &str = "data_read_inline_identity_mismatch";
|
||||
pub(crate) const GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_MISSING_PAYLOAD: &str = "data_read_inline_missing_payload";
|
||||
pub(crate) const GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_MISSING_SHARD: &str = "data_read_inline_missing_shard";
|
||||
pub(crate) const GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_NOT_INLINE: &str = "data_read_inline_not_inline";
|
||||
pub(crate) const GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_PART_SHAPE: &str = "data_read_inline_part_shape";
|
||||
pub(crate) const GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_REMOTE: &str = "data_read_inline_remote";
|
||||
pub(crate) const GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_SIZE: &str = "data_read_inline_size";
|
||||
pub(crate) const GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_TRANSFORMED: &str = "data_read_inline_transformed";
|
||||
pub(crate) const GET_METADATA_EARLY_STOP_REASON_ERROR: &str = "error";
|
||||
pub(crate) const GET_METADATA_EARLY_STOP_REASON_INSUFFICIENT_QUORUM: &str = "insufficient_quorum";
|
||||
pub(crate) const GET_METADATA_EARLY_STOP_REASON_NOT_FOUND: &str = "not_found";
|
||||
@@ -551,6 +562,32 @@ mod tests {
|
||||
assert_eq!(GET_METADATA_CACHE_REASON_VERSIONED, "versioned");
|
||||
assert_eq!(GET_METADATA_EARLY_STOP_REASON_CONFLICTING_METADATA, "conflicting_metadata");
|
||||
assert_eq!(GET_METADATA_EARLY_STOP_REASON_DELETE_MARKER, "delete_marker");
|
||||
assert_eq!(
|
||||
GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_BODY_VERIFY,
|
||||
"data_read_inline_body_verify"
|
||||
);
|
||||
assert_eq!(GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_DELETED, "data_read_inline_deleted");
|
||||
assert_eq!(GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_GEOMETRY, "data_read_inline_geometry");
|
||||
assert_eq!(
|
||||
GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_IDENTITY_MISMATCH,
|
||||
"data_read_inline_identity_mismatch"
|
||||
);
|
||||
assert_eq!(
|
||||
GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_MISSING_PAYLOAD,
|
||||
"data_read_inline_missing_payload"
|
||||
);
|
||||
assert_eq!(
|
||||
GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_MISSING_SHARD,
|
||||
"data_read_inline_missing_shard"
|
||||
);
|
||||
assert_eq!(GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_NOT_INLINE, "data_read_inline_not_inline");
|
||||
assert_eq!(GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_PART_SHAPE, "data_read_inline_part_shape");
|
||||
assert_eq!(GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_REMOTE, "data_read_inline_remote");
|
||||
assert_eq!(GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_SIZE, "data_read_inline_size");
|
||||
assert_eq!(
|
||||
GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_TRANSFORMED,
|
||||
"data_read_inline_transformed"
|
||||
);
|
||||
assert_eq!(GET_METADATA_EARLY_STOP_REASON_ERROR, "error");
|
||||
assert_eq!(GET_METADATA_EARLY_STOP_REASON_INSUFFICIENT_QUORUM, "insufficient_quorum");
|
||||
assert_eq!(GET_METADATA_EARLY_STOP_REASON_NOT_FOUND, "not_found");
|
||||
|
||||
@@ -1075,9 +1075,6 @@ pub struct GenericError {
|
||||
|
||||
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
|
||||
pub enum ObjectApiError {
|
||||
#[error("BackendDown")]
|
||||
BackendDown(String),
|
||||
|
||||
#[error("The operation is not valid for the current state of the object {}/{}({})", .0.bucket, .0.object, .0.version_id)]
|
||||
InvalidObjectState(GenericError),
|
||||
}
|
||||
@@ -1094,72 +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
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -20,6 +20,10 @@ use std::sync::atomic::AtomicI64;
|
||||
/// 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 total_events: AtomicI64,
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -22,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,
|
||||
|
||||
@@ -479,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,
|
||||
@@ -487,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
|
||||
@@ -500,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);
|
||||
}
|
||||
@@ -754,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();
|
||||
@@ -846,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(
|
||||
@@ -856,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();
|
||||
@@ -1727,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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1623,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()
|
||||
@@ -1796,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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -171,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,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use time::OffsetDateTime;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
@@ -32,8 +31,6 @@ 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>,
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -305,6 +305,10 @@ impl TierMutationIntent {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(
|
||||
dead_code,
|
||||
reason = "intent-record persistence asserted by store::init tests (backlog#1823)"
|
||||
)]
|
||||
pub(crate) fn tier_mutation_intent_record_object_name(mutation_id: Uuid) -> Result<String> {
|
||||
tier_mutation_intent_record_object_name_with_prefix(TIER_MUTATION_INTENT_RECORD_PREFIX, mutation_id)
|
||||
}
|
||||
@@ -317,6 +321,10 @@ fn tier_mutation_intent_record_object_name_with_prefix(prefix: &str, mutation_id
|
||||
Ok(format!("{}/{}/{}/{}.json", prefix, &mutation_key[..2], &mutation_key[2..4], mutation_key))
|
||||
}
|
||||
|
||||
#[allow(
|
||||
dead_code,
|
||||
reason = "intent-record persistence asserted by store::init tests (backlog#1823)"
|
||||
)]
|
||||
pub(crate) fn tier_mutation_intent_id_from_record_object_name(object: &str) -> Result<Uuid> {
|
||||
tier_mutation_intent_id_from_record_object_name_with_prefix(TIER_MUTATION_INTENT_RECORD_PREFIX, object)
|
||||
}
|
||||
@@ -355,6 +363,10 @@ fn tier_mutation_intent_id_from_record_object_name_with_prefix(prefix: &str, obj
|
||||
Uuid::parse_str(mutation_key).map_err(|_| TierMutationIntentError::Corrupt("intent record path has invalid uuid"))
|
||||
}
|
||||
|
||||
#[allow(
|
||||
dead_code,
|
||||
reason = "intent-record persistence asserted by store::init tests (backlog#1823)"
|
||||
)]
|
||||
pub(crate) async fn save_tier_mutation_intent_record<S>(api: Arc<S>, intent: &TierMutationIntent) -> EcstoreResult<()>
|
||||
where
|
||||
S: EcstoreObjectIO,
|
||||
@@ -446,6 +458,10 @@ where
|
||||
Ok((intent, etag))
|
||||
}
|
||||
|
||||
#[allow(
|
||||
dead_code,
|
||||
reason = "intent-record persistence asserted by store::init tests (backlog#1823)"
|
||||
)]
|
||||
pub(crate) async fn save_tier_mutation_intent_record_if_current<S>(
|
||||
api: Arc<S>,
|
||||
intent: &TierMutationIntent,
|
||||
|
||||
@@ -41,10 +41,7 @@ use crate::services::tier::{
|
||||
};
|
||||
use tracing::warn;
|
||||
|
||||
const MAX_MULTIPART_PUT_OBJECT_SIZE: i64 = 1024 * 1024 * 1024 * 1024 * 5;
|
||||
const MAX_PARTS_COUNT: i64 = 10000;
|
||||
const _MAX_PART_SIZE: i64 = 1024 * 1024 * 1024 * 5;
|
||||
const MIN_PART_SIZE: i64 = 1024 * 1024 * 128;
|
||||
|
||||
fn parse_generation(remote_version: &str) -> Result<Option<i64>, Error> {
|
||||
if remote_version.is_empty() {
|
||||
@@ -64,7 +61,6 @@ pub struct WarmBackendGCS {
|
||||
pub control: Arc<StorageControl>,
|
||||
pub bucket: String,
|
||||
pub prefix: String,
|
||||
pub storage_class: String,
|
||||
}
|
||||
|
||||
impl WarmBackendGCS {
|
||||
@@ -104,7 +100,6 @@ impl WarmBackendGCS {
|
||||
control,
|
||||
bucket: conf.bucket.clone(),
|
||||
prefix: conf.prefix.strip_suffix("/").unwrap_or(&conf.prefix).to_owned(),
|
||||
storage_class: "".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -33,8 +33,6 @@ use crate::client::{
|
||||
transition_api::{BucketLookupType, Options, TransitionClient, TransitionCore},
|
||||
transition_api::{ReadCloser, ReaderImpl},
|
||||
};
|
||||
use crate::error::ErrorResponse;
|
||||
use crate::error::error_resp_to_object_err;
|
||||
use crate::services::tier::{
|
||||
tier_config::TierS3,
|
||||
warm_backend::{
|
||||
|
||||
@@ -1,200 +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.
|
||||
#![allow(unused_imports)]
|
||||
#![allow(unused_variables)]
|
||||
#![allow(unused_mut)]
|
||||
#![allow(unused_assignments)]
|
||||
#![allow(unused_must_use)]
|
||||
#![allow(clippy::all)]
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use url::Url;
|
||||
|
||||
use aws_config::meta::region::RegionProviderChain;
|
||||
use aws_sdk_s3::Client;
|
||||
use aws_sdk_s3::config::{Credentials, Region};
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
|
||||
use crate::client::{
|
||||
api_get_options::GetObjectOptions,
|
||||
api_put_object::PutObjectOptions,
|
||||
api_remove::RemoveObjectOptions,
|
||||
transition_api::{ReadCloser, ReaderImpl},
|
||||
};
|
||||
use crate::error::ErrorResponse;
|
||||
use crate::error::error_resp_to_object_err;
|
||||
use crate::services::tier::{
|
||||
tier_config::TierS3,
|
||||
warm_backend::{WarmBackend, WarmBackendGetOpts},
|
||||
};
|
||||
|
||||
pub struct WarmBackendS3 {
|
||||
pub client: Arc<Client>,
|
||||
pub bucket: String,
|
||||
pub prefix: String,
|
||||
pub storage_class: String,
|
||||
}
|
||||
|
||||
impl WarmBackendS3 {
|
||||
pub async fn new(conf: &TierS3, tier: &str) -> Result<Self, std::io::Error> {
|
||||
let u = match Url::parse(&conf.endpoint) {
|
||||
Ok(u) => u,
|
||||
Err(err) => {
|
||||
return Err(std::io::Error::other(err.to_string()));
|
||||
}
|
||||
};
|
||||
|
||||
if conf.aws_role_web_identity_token_file == "" && conf.aws_role_arn != ""
|
||||
|| conf.aws_role_web_identity_token_file != "" && conf.aws_role_arn == ""
|
||||
{
|
||||
return Err(std::io::Error::other("both the token file and the role ARN are required"));
|
||||
} else if conf.access_key == "" && conf.secret_key != "" || conf.access_key != "" && conf.secret_key == "" {
|
||||
return Err(std::io::Error::other("both the access and secret keys are required"));
|
||||
} else if conf.aws_role
|
||||
&& (conf.aws_role_web_identity_token_file != ""
|
||||
|| conf.aws_role_arn != ""
|
||||
|| conf.access_key != ""
|
||||
|| conf.secret_key != "")
|
||||
{
|
||||
return Err(std::io::Error::other(
|
||||
"AWS Role cannot be activated with static credentials or the web identity token file",
|
||||
));
|
||||
} else if conf.bucket == "" {
|
||||
return Err(std::io::Error::other("no bucket name was provided"));
|
||||
}
|
||||
|
||||
let creds;
|
||||
if conf.access_key != "" && conf.secret_key != "" {
|
||||
creds = Credentials::new(
|
||||
conf.access_key.clone(), // access_key_id
|
||||
conf.secret_key.clone(), // secret_access_key
|
||||
None, // session_token (optional)
|
||||
None,
|
||||
"Static",
|
||||
);
|
||||
} else {
|
||||
return Err(std::io::Error::other("insufficient parameters for S3 backend authentication"));
|
||||
}
|
||||
let region_provider = RegionProviderChain::default_provider().or_else(Region::new(conf.region.clone()));
|
||||
#[allow(deprecated)]
|
||||
let config = aws_config::from_env()
|
||||
.endpoint_url(conf.endpoint.clone())
|
||||
.region(region_provider)
|
||||
.credentials_provider(creds)
|
||||
.load()
|
||||
.await;
|
||||
let client = Client::new(&config);
|
||||
let client = Arc::new(client);
|
||||
Ok(Self {
|
||||
client,
|
||||
bucket: conf.bucket.clone(),
|
||||
prefix: conf.prefix.clone().trim_matches('/').to_string(),
|
||||
storage_class: conf.storage_class.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_dest(&self, object: &str) -> String {
|
||||
let mut dest_obj = object.to_string();
|
||||
if self.prefix != "" {
|
||||
dest_obj = format!("{}/{}", &self.prefix, object);
|
||||
}
|
||||
return dest_obj;
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl WarmBackend for WarmBackendS3 {
|
||||
async fn put_with_meta(
|
||||
&self,
|
||||
object: &str,
|
||||
r: ReaderImpl,
|
||||
length: i64,
|
||||
meta: HashMap<String, String>,
|
||||
) -> Result<String, std::io::Error> {
|
||||
let client = self.client.clone();
|
||||
let Ok(res) = client
|
||||
.put_object()
|
||||
.bucket(&self.bucket)
|
||||
.key(&self.get_dest(object))
|
||||
.body(match r {
|
||||
ReaderImpl::Body(content_body) => ByteStream::from(content_body.to_vec()),
|
||||
ReaderImpl::ObjectBody(mut content_body) => ByteStream::from(content_body.read_all().await?),
|
||||
})
|
||||
.send()
|
||||
.await
|
||||
else {
|
||||
return Err(std::io::Error::other("put_object error"));
|
||||
};
|
||||
|
||||
Ok(res.version_id().unwrap_or("").to_string())
|
||||
}
|
||||
|
||||
async fn put(&self, object: &str, r: ReaderImpl, length: i64) -> Result<String, std::io::Error> {
|
||||
self.put_with_meta(object, r, length, HashMap::new()).await
|
||||
}
|
||||
|
||||
async fn get(&self, object: &str, rv: &str, opts: WarmBackendGetOpts) -> Result<ReadCloser, std::io::Error> {
|
||||
let client = self.client.clone();
|
||||
let mut req = client.get_object().bucket(&self.bucket).key(&self.get_dest(object));
|
||||
|
||||
if !rv.is_empty() {
|
||||
req = req.version_id(rv);
|
||||
}
|
||||
|
||||
if opts.start_offset >= 0 && opts.length > 0 {
|
||||
let end = opts
|
||||
.start_offset
|
||||
.checked_add(opts.length)
|
||||
.and_then(|v| v.checked_sub(1))
|
||||
.ok_or_else(|| std::io::Error::other("invalid range: overflow"))?;
|
||||
req = req.range(format!("bytes={}-{}", opts.start_offset, end));
|
||||
}
|
||||
|
||||
let res = req.send().await.map_err(|e| std::io::Error::other(e.to_string()))?;
|
||||
|
||||
Ok(ReadCloser::new(std::io::Cursor::new(
|
||||
res.body.collect().await.map(|data| data.into_bytes().to_vec())?,
|
||||
)))
|
||||
}
|
||||
|
||||
async fn remove(&self, object: &str, rv: &str) -> Result<(), std::io::Error> {
|
||||
let client = self.client.clone();
|
||||
let mut req = client.delete_object().bucket(&self.bucket).key(&self.get_dest(object));
|
||||
|
||||
if !rv.is_empty() {
|
||||
req = req.version_id(rv);
|
||||
}
|
||||
|
||||
req.send().await.map_err(|e| std::io::Error::other(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn in_use(&self) -> Result<bool, std::io::Error> {
|
||||
let client = self.client.clone();
|
||||
let Ok(res) = client
|
||||
.list_objects_v2()
|
||||
.bucket(&self.bucket)
|
||||
//.max_keys(10)
|
||||
//.into_paginator()
|
||||
.send()
|
||||
.await
|
||||
else {
|
||||
return Err(std::io::Error::other("list_objects_v2 error"));
|
||||
};
|
||||
|
||||
Ok(res.common_prefixes.unwrap_or_default().len() > 0 || res.contents.unwrap_or_default().len() > 0)
|
||||
}
|
||||
}
|
||||
@@ -32,15 +32,22 @@ use crate::diagnostics::get::{
|
||||
GET_METADATA_CACHE_REASON_NOT_READ_DATA, GET_METADATA_CACHE_REASON_PART_NUMBER,
|
||||
GET_METADATA_CACHE_REASON_RAW_DATA_MOVEMENT_READ, GET_METADATA_CACHE_REASON_USABLE, GET_METADATA_CACHE_REASON_VERSION_ID,
|
||||
GET_METADATA_CACHE_REASON_VERSION_SUSPENDED, GET_METADATA_CACHE_REASON_VERSIONED,
|
||||
GET_METADATA_EARLY_STOP_REASON_CONFLICTING_METADATA, GET_METADATA_EARLY_STOP_REASON_DELETE_MARKER,
|
||||
GET_METADATA_EARLY_STOP_REASON_ERROR, GET_METADATA_EARLY_STOP_REASON_INSUFFICIENT_QUORUM,
|
||||
GET_METADATA_EARLY_STOP_REASON_NOT_FOUND, GET_METADATA_EARLY_STOP_REASON_UNSAFE_REQUEST,
|
||||
GET_METADATA_EARLY_STOP_REASON_VALID_QUORUM, GET_METADATA_EARLY_STOP_REASON_VERSION_MATCH_QUORUM,
|
||||
GET_METADATA_EARLY_STOP_REASON_VERSION_NOT_FOUND, GET_METADATA_RESPONSE_CORRUPT, GET_METADATA_RESPONSE_DISK_NOT_FOUND,
|
||||
GET_METADATA_RESPONSE_ERROR, GET_METADATA_RESPONSE_IGNORED, GET_METADATA_RESPONSE_NOT_FOUND, GET_METADATA_RESPONSE_TIMEOUT,
|
||||
GET_METADATA_RESPONSE_VALID, GET_METADATA_RESPONSE_VERSION_NOT_FOUND, GET_OBJECT_PATH_CODEC_STREAMING,
|
||||
GET_OBJECT_PATH_DIRECT_MEMORY, GET_OBJECT_PATH_INTERNAL_META, GET_OBJECT_PATH_LEGACY_DUPLEX, GET_OBJECT_PATH_SET_DISK,
|
||||
GET_STAGE_DECODE, GET_STAGE_METADATA_CACHE_LOOKUP, GET_STAGE_METADATA_RESOLVE, GET_STAGE_RANGE, GET_STAGE_READER_SETUP,
|
||||
GET_METADATA_EARLY_STOP_REASON_CONFLICTING_METADATA, GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_BODY_VERIFY,
|
||||
GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_DELETED, GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_GEOMETRY,
|
||||
GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_IDENTITY_MISMATCH,
|
||||
GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_MISSING_PAYLOAD,
|
||||
GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_MISSING_SHARD, GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_NOT_INLINE,
|
||||
GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_PART_SHAPE, GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_REMOTE,
|
||||
GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_SIZE, GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_TRANSFORMED,
|
||||
GET_METADATA_EARLY_STOP_REASON_DELETE_MARKER, GET_METADATA_EARLY_STOP_REASON_ERROR,
|
||||
GET_METADATA_EARLY_STOP_REASON_INSUFFICIENT_QUORUM, GET_METADATA_EARLY_STOP_REASON_NOT_FOUND,
|
||||
GET_METADATA_EARLY_STOP_REASON_UNSAFE_REQUEST, GET_METADATA_EARLY_STOP_REASON_VALID_QUORUM,
|
||||
GET_METADATA_EARLY_STOP_REASON_VERSION_MATCH_QUORUM, GET_METADATA_EARLY_STOP_REASON_VERSION_NOT_FOUND,
|
||||
GET_METADATA_RESPONSE_CORRUPT, GET_METADATA_RESPONSE_DISK_NOT_FOUND, GET_METADATA_RESPONSE_ERROR,
|
||||
GET_METADATA_RESPONSE_IGNORED, GET_METADATA_RESPONSE_NOT_FOUND, GET_METADATA_RESPONSE_TIMEOUT, GET_METADATA_RESPONSE_VALID,
|
||||
GET_METADATA_RESPONSE_VERSION_NOT_FOUND, GET_OBJECT_PATH_CODEC_STREAMING, GET_OBJECT_PATH_DIRECT_MEMORY,
|
||||
GET_OBJECT_PATH_INTERNAL_META, GET_OBJECT_PATH_LEGACY_DUPLEX, GET_OBJECT_PATH_SET_DISK, GET_STAGE_DECODE,
|
||||
GET_STAGE_METADATA_CACHE_LOOKUP, GET_STAGE_METADATA_RESOLVE, GET_STAGE_RANGE, GET_STAGE_READER_SETUP,
|
||||
GET_STAGE_READER_SETUP_DROP_PENDING, GET_STAGE_READER_SETUP_SCHEDULE, GET_STAGE_READER_SETUP_WAIT_QUORUM,
|
||||
GET_STAGE_READER_TASK_BITROT_READER_INIT, GET_STAGE_READER_TASK_FILE_OPEN, GET_STAGE_READER_TASK_READER_CONSTRUCTION,
|
||||
GetObjectFailureReason, classify_disk_error, get_stage_timer_if_enabled, record_get_object_pipeline_failure,
|
||||
@@ -652,36 +659,49 @@ pub(in crate::set_disk) fn metadata_early_stop_candidate_matches(left: &FileInfo
|
||||
&& left.erasure.distribution == right.erasure.distribution
|
||||
}
|
||||
|
||||
pub(in crate::set_disk) async fn data_read_early_stop_inline_body_verified(
|
||||
pub(in crate::set_disk) async fn data_read_early_stop_inline_body_miss_reason(
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
candidate: &FileInfo,
|
||||
parts_metadata: &[FileInfo],
|
||||
disks: &[Option<DiskStore>],
|
||||
) -> bool {
|
||||
if !candidate.inline_data()
|
||||
|| candidate.is_compressed()
|
||||
) -> Option<&'static str> {
|
||||
// `inline_data` excludes remote objects; this diagnostic reports them separately.
|
||||
if !rustfs_utils::http::contains_key_str(&candidate.metadata, rustfs_utils::http::SUFFIX_INLINE_DATA) {
|
||||
return Some(GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_NOT_INLINE);
|
||||
}
|
||||
if candidate.is_compressed()
|
||||
|| candidate
|
||||
.metadata
|
||||
.keys()
|
||||
.any(|key| rustfs_utils::http::is_object_encryption_marker(key))
|
||||
|| candidate.is_remote()
|
||||
|| candidate.deleted
|
||||
|| candidate.size <= 0
|
||||
|| candidate.parts.len() != 1
|
||||
|| !candidate.has_valid_erasure_geometry()
|
||||
{
|
||||
return false;
|
||||
return Some(GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_TRANSFORMED);
|
||||
}
|
||||
if candidate.is_remote() {
|
||||
return Some(GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_REMOTE);
|
||||
}
|
||||
if candidate.deleted {
|
||||
return Some(GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_DELETED);
|
||||
}
|
||||
if candidate.size <= 0 {
|
||||
return Some(GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_SIZE);
|
||||
}
|
||||
if candidate.parts.len() != 1 {
|
||||
return Some(GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_PART_SHAPE);
|
||||
}
|
||||
if !candidate.has_valid_erasure_geometry() {
|
||||
return Some(GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_GEOMETRY);
|
||||
}
|
||||
|
||||
let Ok(object_size) = usize::try_from(candidate.size) else {
|
||||
return false;
|
||||
return Some(GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_SIZE);
|
||||
};
|
||||
if candidate.parts.first().is_none_or(|part| part.size != object_size) {
|
||||
return false;
|
||||
return Some(GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_PART_SHAPE);
|
||||
}
|
||||
if !can_try_inline_data_shards_direct(object_size, candidate.erasure.block_size) {
|
||||
return false;
|
||||
return Some(GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_SIZE);
|
||||
}
|
||||
|
||||
let Ok(erasure) = coding::Erasure::try_new_with_options(
|
||||
@@ -690,18 +710,18 @@ pub(in crate::set_disk) async fn data_read_early_stop_inline_body_verified(
|
||||
candidate.erasure.block_size,
|
||||
candidate.uses_legacy_checksum,
|
||||
) else {
|
||||
return false;
|
||||
return Some(GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_GEOMETRY);
|
||||
};
|
||||
let Some(data_files) =
|
||||
collect_inline_data_shard_fileinfos_by_index(parts_metadata, candidate, erasure.data_shards, |index| {
|
||||
let data_files =
|
||||
match collect_inline_data_shard_fileinfos_by_index_or_reason(parts_metadata, candidate, erasure.data_shards, |index| {
|
||||
disks.get(index).is_some_and(Option::is_some)
|
||||
})
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
}) {
|
||||
Ok(data_files) => data_files,
|
||||
Err(reason) => return Some(reason),
|
||||
};
|
||||
|
||||
let Some(part) = candidate.parts.first() else {
|
||||
return false;
|
||||
return Some(GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_PART_SHAPE);
|
||||
};
|
||||
let checksum_info = candidate.erasure.get_checksum_info(part.number);
|
||||
let checksum_algo = if candidate.uses_legacy_checksum && checksum_info.algorithm == HashAlgorithm::HighwayHash256S {
|
||||
@@ -721,12 +741,13 @@ pub(in crate::set_disk) async fn data_read_early_stop_inline_body_verified(
|
||||
let Ok(mut readers) =
|
||||
build_inline_bitrot_readers_from_refs(&data_files, bucket, object, read_length, shard_size, &checksum_algo, false).await
|
||||
else {
|
||||
return false;
|
||||
return Some(GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_BODY_VERIFY);
|
||||
};
|
||||
|
||||
try_read_inline_data_shards_direct(&mut readers, erasure.data_shards, read_length, object_size)
|
||||
.await
|
||||
.is_some_and(|body| body.len() == object_size)
|
||||
match try_read_inline_data_shards_direct(&mut readers, erasure.data_shards, read_length, object_size).await {
|
||||
Some(body) if body.len() == object_size => None,
|
||||
_ => Some(GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_BODY_VERIFY),
|
||||
}
|
||||
}
|
||||
|
||||
pub(in crate::set_disk) fn classify_metadata_response_error(err: &DiskError) -> &'static str {
|
||||
@@ -2469,6 +2490,7 @@ impl SetDisks {
|
||||
let mut next_fanout_index = 0usize;
|
||||
let mut scheduled_count = 0usize;
|
||||
let mut force_full_wait = false;
|
||||
let mut final_miss_reason_override = None;
|
||||
let spawn_read_version =
|
||||
|join_set: &mut JoinSet<(usize, disk::error::Result<FileInfo>, Duration)>, index: usize, disk: Option<DiskStore>| {
|
||||
let task_opts = opts;
|
||||
@@ -2541,17 +2563,29 @@ impl SetDisks {
|
||||
.or_else(|| accumulator.version_early_stop_decision())
|
||||
{
|
||||
let should_return_early = if read_data {
|
||||
let allow_data_read_early_stop = match accumulator.candidate.as_ref() {
|
||||
Some(candidate) => {
|
||||
data_read_early_stop_inline_body_verified(bucket.as_ref(), object.as_ref(), candidate, &ress, disks)
|
||||
.await
|
||||
match accumulator.candidate.as_ref() {
|
||||
Some(candidate) => match data_read_early_stop_inline_body_miss_reason(
|
||||
bucket.as_ref(),
|
||||
object.as_ref(),
|
||||
candidate,
|
||||
&ress,
|
||||
disks,
|
||||
)
|
||||
.await
|
||||
{
|
||||
None => true,
|
||||
Some(reason) => {
|
||||
force_full_wait = true;
|
||||
final_miss_reason_override = Some(reason);
|
||||
false
|
||||
}
|
||||
},
|
||||
None => {
|
||||
force_full_wait = true;
|
||||
final_miss_reason_override = Some(GET_METADATA_EARLY_STOP_REASON_INSUFFICIENT_QUORUM);
|
||||
false
|
||||
}
|
||||
None => false,
|
||||
};
|
||||
if !allow_data_read_early_stop {
|
||||
force_full_wait = true;
|
||||
}
|
||||
allow_data_read_early_stop
|
||||
} else {
|
||||
true
|
||||
};
|
||||
@@ -2613,7 +2647,12 @@ impl SetDisks {
|
||||
}
|
||||
}
|
||||
|
||||
rustfs_io_metrics::record_get_object_metadata_early_stop_miss(metrics_path, accumulator.final_miss_reason());
|
||||
let accumulator_miss_reason = accumulator.final_miss_reason();
|
||||
let final_miss_reason = match (final_miss_reason_override, accumulator_miss_reason) {
|
||||
(Some(reason), GET_METADATA_EARLY_STOP_REASON_INSUFFICIENT_QUORUM) => reason,
|
||||
_ => accumulator_miss_reason,
|
||||
};
|
||||
rustfs_io_metrics::record_get_object_metadata_early_stop_miss(metrics_path, final_miss_reason);
|
||||
rustfs_io_metrics::record_get_object_metadata_early_stop_saved_responses(metrics_path, 0);
|
||||
rustfs_io_metrics::record_get_object_metadata_fanout_lifecycle(metrics_path, scheduled_count, scheduled_count, 0);
|
||||
let diagnostics = MetadataFanoutDiagnostics::new(fanout_start.elapsed(), observations);
|
||||
@@ -6067,11 +6106,133 @@ mod tests {
|
||||
.clone();
|
||||
|
||||
assert!(
|
||||
data_read_early_stop_inline_body_verified(bucket, object, &candidate, &parts_metadata, &disks).await,
|
||||
data_read_early_stop_inline_body_miss_reason(bucket, object, &candidate, &parts_metadata, &disks)
|
||||
.await
|
||||
.is_none(),
|
||||
"legacy inline metadata must use the legacy bitrot shard sizing and checksum algorithm"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn data_read_early_stop_reports_inline_miss_reasons() {
|
||||
let bucket = "inline-data-get-miss-reason-bucket";
|
||||
let object = "inline-data-get-miss-reason-object";
|
||||
let payload = b"verified inline payload";
|
||||
let (_dirs, disks) = call_counter_local_disks(bucket, 4).await;
|
||||
let files = inline_metadata_fanout_fileinfos_with_mode(bucket, object, payload, false).await;
|
||||
let distribution = files
|
||||
.first()
|
||||
.map(|file| file.erasure.distribution.clone())
|
||||
.expect("fixture should include metadata");
|
||||
let order = bounded_metadata_fanout_order(bucket, object, 4, 2);
|
||||
let mut parts_metadata = vec![FileInfo::default(); 4];
|
||||
for disk_index in order.into_iter().take(3) {
|
||||
let block_index = distribution
|
||||
.get(disk_index)
|
||||
.copied()
|
||||
.expect("fixture distribution should cover every disk");
|
||||
parts_metadata[disk_index] = files
|
||||
.get(block_index.checked_sub(1).expect("erasure block indexes are one-based"))
|
||||
.expect("fixture should include every distributed shard")
|
||||
.clone();
|
||||
}
|
||||
let candidate = parts_metadata
|
||||
.iter()
|
||||
.find(|file| file.name == object)
|
||||
.expect("fixture should include observed metadata")
|
||||
.clone();
|
||||
let data_disk = distribution
|
||||
.iter()
|
||||
.position(|block_index| *block_index == 1)
|
||||
.expect("fixture distribution should include first data shard");
|
||||
|
||||
assert_eq!(
|
||||
data_read_early_stop_inline_body_miss_reason(bucket, object, &candidate, &parts_metadata, &disks).await,
|
||||
None
|
||||
);
|
||||
|
||||
let mut not_inline = candidate.clone();
|
||||
rustfs_utils::http::remove_str(&mut not_inline.metadata, rustfs_utils::http::SUFFIX_INLINE_DATA);
|
||||
assert_eq!(
|
||||
data_read_early_stop_inline_body_miss_reason(bucket, object, ¬_inline, &parts_metadata, &disks).await,
|
||||
Some(GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_NOT_INLINE)
|
||||
);
|
||||
|
||||
let mut remote = candidate.clone();
|
||||
remote.transition_status = TRANSITION_COMPLETE.to_string();
|
||||
assert_eq!(
|
||||
data_read_early_stop_inline_body_miss_reason(bucket, object, &remote, &parts_metadata, &disks).await,
|
||||
Some(GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_REMOTE)
|
||||
);
|
||||
|
||||
let mut transformed = candidate.clone();
|
||||
rustfs_utils::http::insert_str(&mut transformed.metadata, rustfs_utils::http::SUFFIX_COMPRESSION, "zstd".to_string());
|
||||
assert_eq!(
|
||||
data_read_early_stop_inline_body_miss_reason(bucket, object, &transformed, &parts_metadata, &disks).await,
|
||||
Some(GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_TRANSFORMED)
|
||||
);
|
||||
|
||||
let mut deleted = candidate.clone();
|
||||
deleted.deleted = true;
|
||||
assert_eq!(
|
||||
data_read_early_stop_inline_body_miss_reason(bucket, object, &deleted, &parts_metadata, &disks).await,
|
||||
Some(GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_DELETED)
|
||||
);
|
||||
|
||||
let mut zero_size = candidate.clone();
|
||||
zero_size.size = 0;
|
||||
assert_eq!(
|
||||
data_read_early_stop_inline_body_miss_reason(bucket, object, &zero_size, &parts_metadata, &disks).await,
|
||||
Some(GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_SIZE)
|
||||
);
|
||||
|
||||
let mut multipart = candidate.clone();
|
||||
multipart.parts.push(multipart.parts[0].clone());
|
||||
assert_eq!(
|
||||
data_read_early_stop_inline_body_miss_reason(bucket, object, &multipart, &parts_metadata, &disks).await,
|
||||
Some(GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_PART_SHAPE)
|
||||
);
|
||||
|
||||
let mut invalid_geometry = candidate.clone();
|
||||
invalid_geometry.erasure.data_blocks = 0;
|
||||
assert_eq!(
|
||||
data_read_early_stop_inline_body_miss_reason(bucket, object, &invalid_geometry, &parts_metadata, &disks).await,
|
||||
Some(GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_GEOMETRY)
|
||||
);
|
||||
|
||||
let mut missing_shard = parts_metadata.clone();
|
||||
missing_shard[data_disk] = FileInfo::default();
|
||||
assert_eq!(
|
||||
data_read_early_stop_inline_body_miss_reason(bucket, object, &candidate, &missing_shard, &disks).await,
|
||||
Some(GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_MISSING_SHARD)
|
||||
);
|
||||
|
||||
let mut missing_payload = parts_metadata.clone();
|
||||
missing_payload[data_disk].data = None;
|
||||
assert_eq!(
|
||||
data_read_early_stop_inline_body_miss_reason(bucket, object, &candidate, &missing_payload, &disks).await,
|
||||
Some(GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_MISSING_PAYLOAD)
|
||||
);
|
||||
|
||||
let mut identity_mismatch = parts_metadata.clone();
|
||||
identity_mismatch[data_disk].version_id = Some(Uuid::new_v4());
|
||||
assert_eq!(
|
||||
data_read_early_stop_inline_body_miss_reason(bucket, object, &candidate, &identity_mismatch, &disks).await,
|
||||
Some(GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_IDENTITY_MISMATCH)
|
||||
);
|
||||
|
||||
let mut corrupt = parts_metadata.clone();
|
||||
if let Some(data) = corrupt[data_disk].data.as_mut() {
|
||||
let mut corrupt_data = data.to_vec();
|
||||
corrupt_data[0] ^= 0x01;
|
||||
*data = Bytes::from(corrupt_data);
|
||||
}
|
||||
assert_eq!(
|
||||
data_read_early_stop_inline_body_miss_reason(bucket, object, &candidate, &corrupt, &disks).await,
|
||||
Some(GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_BODY_VERIFY)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn metadata_fanout_lifecycle_records_real_early_stop_abort() {
|
||||
@@ -6161,7 +6322,7 @@ mod tests {
|
||||
&[
|
||||
("path", GET_OBJECT_PATH_INTERNAL_META),
|
||||
("decision", "miss"),
|
||||
("reason", GET_METADATA_EARLY_STOP_REASON_INSUFFICIENT_QUORUM),
|
||||
("reason", GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_BODY_VERIFY),
|
||||
],
|
||||
),
|
||||
1,
|
||||
@@ -6173,7 +6334,7 @@ mod tests {
|
||||
&[
|
||||
("path", GET_OBJECT_PATH_LEGACY_DUPLEX),
|
||||
("decision", "miss"),
|
||||
("reason", GET_METADATA_EARLY_STOP_REASON_INSUFFICIENT_QUORUM),
|
||||
("reason", GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_BODY_VERIFY),
|
||||
],
|
||||
),
|
||||
0,
|
||||
|
||||
@@ -59,7 +59,10 @@ use crate::client::{object_api_utils::get_raw_etag, transition_api::ReaderImpl};
|
||||
use crate::cluster::rpc::heal_bucket_local_on_disks;
|
||||
use crate::data_usage::record_compression_total_memory;
|
||||
use crate::diagnostics::get::{
|
||||
GET_CODEC_STREAMING_OBJECT_CLASS_PLAIN_SINGLE_PART, GET_OBJECT_PATH_BODY_CACHE, GET_OBJECT_PATH_CODEC_STREAMING,
|
||||
GET_CODEC_STREAMING_OBJECT_CLASS_PLAIN_SINGLE_PART, GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_GEOMETRY,
|
||||
GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_IDENTITY_MISMATCH,
|
||||
GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_MISSING_PAYLOAD,
|
||||
GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_MISSING_SHARD, GET_OBJECT_PATH_BODY_CACHE, GET_OBJECT_PATH_CODEC_STREAMING,
|
||||
GET_OBJECT_PATH_CODEC_STREAMING_LEGACY_ENGINE, GET_OBJECT_PATH_CODEC_STREAMING_RUSTFS_ENGINE, GET_OBJECT_PATH_DIRECT_MEMORY,
|
||||
GET_OBJECT_PATH_EMPTY, GET_OBJECT_PATH_INLINE_DIRECT, GET_OBJECT_PATH_INTERNAL_META, GET_OBJECT_PATH_LEGACY_DUPLEX,
|
||||
GET_OBJECT_PATH_REMOTE_TRANSITION, GET_OBJECT_PATH_SET_DISK, GET_STAGE_DECODE, GET_STAGE_EMIT, GET_STAGE_INLINE_PREPARE,
|
||||
@@ -3866,8 +3869,17 @@ fn collect_inline_data_shard_fileinfos_by_index<'a>(
|
||||
parts_metadata: &'a [FileInfo],
|
||||
fi: &FileInfo,
|
||||
data_shards: usize,
|
||||
mut disk_is_online: impl FnMut(usize) -> bool,
|
||||
disk_is_online: impl FnMut(usize) -> bool,
|
||||
) -> Option<Vec<&'a FileInfo>> {
|
||||
collect_inline_data_shard_fileinfos_by_index_or_reason(parts_metadata, fi, data_shards, disk_is_online).ok()
|
||||
}
|
||||
|
||||
fn collect_inline_data_shard_fileinfos_by_index_or_reason<'a>(
|
||||
parts_metadata: &'a [FileInfo],
|
||||
fi: &FileInfo,
|
||||
data_shards: usize,
|
||||
mut disk_is_online: impl FnMut(usize) -> bool,
|
||||
) -> std::result::Result<Vec<&'a FileInfo>, &'static str> {
|
||||
let distribution = &fi.erasure.distribution;
|
||||
let mut data_files = vec![None; data_shards];
|
||||
|
||||
@@ -3875,27 +3887,35 @@ fn collect_inline_data_shard_fileinfos_by_index<'a>(
|
||||
if !disk_is_online(disk_index) {
|
||||
continue;
|
||||
}
|
||||
let block_index = *distribution.get(disk_index)?;
|
||||
let Some(&block_index) = distribution.get(disk_index) else {
|
||||
return Err(GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_GEOMETRY);
|
||||
};
|
||||
if block_index == 0 || block_index > data_shards {
|
||||
continue;
|
||||
}
|
||||
if file_info.name.is_empty() {
|
||||
return Err(GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_MISSING_SHARD);
|
||||
}
|
||||
if file_info.erasure.index != block_index {
|
||||
continue;
|
||||
return Err(GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_IDENTITY_MISMATCH);
|
||||
}
|
||||
if !file_info.has_valid_erasure_geometry() {
|
||||
continue;
|
||||
return Err(GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_GEOMETRY);
|
||||
}
|
||||
if !core::io_primitives::metadata_early_stop_candidate_matches(file_info, fi) {
|
||||
continue;
|
||||
return Err(GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_IDENTITY_MISMATCH);
|
||||
}
|
||||
if file_info.data.as_ref().is_none_or(|data| data.is_empty()) {
|
||||
continue;
|
||||
return Err(GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_MISSING_PAYLOAD);
|
||||
}
|
||||
|
||||
data_files[block_index - 1] = Some(file_info);
|
||||
}
|
||||
|
||||
data_files.into_iter().collect()
|
||||
data_files
|
||||
.into_iter()
|
||||
.collect::<Option<Vec<_>>>()
|
||||
.ok_or(GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_MISSING_SHARD)
|
||||
}
|
||||
|
||||
impl SetDisks {
|
||||
|
||||
@@ -315,6 +315,41 @@ async fn get_object_reader_with_context(
|
||||
GetObjectReader::new_with_resolver(reader, range, object_info, opts, headers, ctx.object_encryption_resolver()).await
|
||||
}
|
||||
|
||||
async fn get_legacy_object_reader_with_context<R>(
|
||||
ctx: &InstanceContext,
|
||||
reader: R,
|
||||
terminal: tokio::sync::oneshot::Receiver<Result<()>>,
|
||||
range: Option<HTTPRangeSpec>,
|
||||
object_info: &ObjectInfo,
|
||||
opts: &ObjectOptions,
|
||||
headers: &HeaderMap<HeaderValue>,
|
||||
) -> Result<(GetObjectReader, usize, i64)>
|
||||
where
|
||||
R: AsyncRead + Unpin + Send + Sync + 'static,
|
||||
{
|
||||
// ReadPlan validates this size below; failure here only keeps the terminal
|
||||
// guard inside the transform until that validation returns its typed error.
|
||||
let full_plaintext_size = object_info.get_actual_size().ok();
|
||||
let whole_object = opts.part_number.is_none()
|
||||
&& match (&range, full_plaintext_size) {
|
||||
(None, _) => true,
|
||||
(Some(range), Some(size)) => range
|
||||
.get_offset_length(size)
|
||||
.is_ok_and(|(offset, length)| offset == 0 && length == size),
|
||||
(Some(_), None) => false,
|
||||
};
|
||||
let (source, terminal): (Box<dyn AsyncRead + Unpin + Send + Sync>, _) = if whole_object {
|
||||
(Box::new(reader), Some(terminal))
|
||||
} else {
|
||||
(Box::new(LegacyDuplexProducerReader::new(reader, terminal)), None)
|
||||
};
|
||||
let (mut reader, offset, length) = get_object_reader_with_context(ctx, source, range, object_info, opts, headers).await?;
|
||||
if let Some(terminal) = terminal {
|
||||
reader.stream = Box::new(LegacyDuplexProducerReader::new(reader.stream, terminal));
|
||||
}
|
||||
Ok((reader, offset, length))
|
||||
}
|
||||
|
||||
fn data_read_metadata_early_stop_request_shape_allowed(range: &Option<HTTPRangeSpec>, opts: &ObjectOptions) -> bool {
|
||||
range.is_none()
|
||||
&& opts.part_number.is_none()
|
||||
@@ -899,6 +934,7 @@ impl crate::storage_api_contracts::object::ObjectIO for SetDisks {
|
||||
&object_info,
|
||||
&opts,
|
||||
&self.ctx.tier_config_mgr(),
|
||||
self.ctx.object_encryption_resolver(),
|
||||
)
|
||||
.await?;
|
||||
return Ok(finish_set_disk_read_lock(gr, read_lock_guard.take(), bucket, object));
|
||||
@@ -1089,8 +1125,9 @@ impl crate::storage_api_contracts::object::ObjectIO for SetDisks {
|
||||
let (rd, wd) = tokio::io::duplex(duplex_buffer_size);
|
||||
debug!(bucket, object, duplex_buffer_size, "Created duplex pipe for object data transfer");
|
||||
|
||||
let (producer_terminal_tx, producer_terminal_rx) = tokio::sync::oneshot::channel();
|
||||
let (mut reader, offset, length) =
|
||||
get_object_reader_with_context(&self.ctx, Box::new(rd), range, &object_info, opts, &h).await?;
|
||||
get_legacy_object_reader_with_context(&self.ctx, rd, producer_terminal_rx, range, &object_info, opts, &h).await?;
|
||||
// Carry the hook probe result so the app layer skips its now-redundant
|
||||
// lookup on the streaming miss path (ODC-16).
|
||||
reader.body_source = body_source;
|
||||
@@ -1110,7 +1147,7 @@ impl crate::storage_api_contracts::object::ObjectIO for SetDisks {
|
||||
// `get_object_with_fileinfo` also waits on `writer`, so an outer timeout
|
||||
// would incorrectly treat downstream backpressure as disk-read latency.
|
||||
// Disk read timeouts must be enforced at the actual disk I/O operations.
|
||||
if let Err(e) = Self::get_object_with_fileinfo(
|
||||
let producer_result = Self::get_object_with_fileinfo(
|
||||
&bucket,
|
||||
&object,
|
||||
erasure_cache,
|
||||
@@ -1128,9 +1165,9 @@ impl crate::storage_api_contracts::object::ObjectIO for SetDisks {
|
||||
object_class.as_str(),
|
||||
size_bucket,
|
||||
)
|
||||
.await
|
||||
{
|
||||
let reason = classify_storage_error(&e);
|
||||
.await;
|
||||
if let Err(e) = &producer_result {
|
||||
let reason = classify_storage_error(e);
|
||||
if reason == GetObjectFailureReason::DownstreamClosed {
|
||||
debug!(
|
||||
event = EVENT_SET_DISK_WRITE,
|
||||
@@ -1169,6 +1206,7 @@ impl crate::storage_api_contracts::object::ObjectIO for SetDisks {
|
||||
);
|
||||
}
|
||||
};
|
||||
let _ = producer_terminal_tx.send(producer_result.map(|_| ()));
|
||||
});
|
||||
|
||||
Ok(reader)
|
||||
@@ -2557,6 +2595,420 @@ impl<R: AsyncRead + Unpin> AsyncRead for TransitionUploadReader<R> {
|
||||
}
|
||||
}
|
||||
|
||||
struct LegacyDuplexProducerReader<R> {
|
||||
inner: Option<R>,
|
||||
terminal: Option<tokio::sync::oneshot::Receiver<Result<()>>>,
|
||||
inner_eof: bool,
|
||||
}
|
||||
|
||||
impl<R> LegacyDuplexProducerReader<R> {
|
||||
fn new(inner: R, terminal: tokio::sync::oneshot::Receiver<Result<()>>) -> Self {
|
||||
Self {
|
||||
inner: Some(inner),
|
||||
terminal: Some(terminal),
|
||||
inner_eof: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: AsyncRead + Unpin> AsyncRead for LegacyDuplexProducerReader<R> {
|
||||
fn poll_read(mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll<std::io::Result<()>> {
|
||||
if buf.remaining() == 0 {
|
||||
return Poll::Ready(Ok(()));
|
||||
}
|
||||
if !self.inner_eof {
|
||||
let before = buf.filled().len();
|
||||
if let Some(inner) = self.inner.as_mut() {
|
||||
match Pin::new(inner).poll_read(cx, buf) {
|
||||
Poll::Pending => return Poll::Pending,
|
||||
Poll::Ready(Err(err)) => return Poll::Ready(Err(err)),
|
||||
Poll::Ready(Ok(())) if buf.filled().len() > before => return Poll::Ready(Ok(())),
|
||||
Poll::Ready(Ok(())) => {
|
||||
self.inner_eof = true;
|
||||
self.inner = None;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.inner_eof = true;
|
||||
}
|
||||
}
|
||||
|
||||
let Some(terminal) = self.terminal.as_mut() else {
|
||||
return Poll::Ready(Ok(()));
|
||||
};
|
||||
match Pin::new(terminal).poll(cx) {
|
||||
Poll::Pending => Poll::Pending,
|
||||
Poll::Ready(Ok(Ok(()))) => {
|
||||
self.terminal = None;
|
||||
Poll::Ready(Ok(()))
|
||||
}
|
||||
Poll::Ready(Ok(Err(err))) => {
|
||||
self.terminal = None;
|
||||
Poll::Ready(Err(std::io::Error::other(err)))
|
||||
}
|
||||
Poll::Ready(Err(_)) => {
|
||||
self.terminal = None;
|
||||
Poll::Ready(Err(std::io::Error::other(StorageError::Unexpected)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod legacy_duplex_producer_reader_tests {
|
||||
use super::*;
|
||||
use crate::object_api::{EncryptionResolutionError, ObjectEncryptionResolver, ReadEncryptionMaterial, ReadEncryptionMode};
|
||||
use rustfs_utils::CompressionAlgorithm;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
const TEST_DUPLEX_CAPACITY: usize = 64 * 1024;
|
||||
|
||||
fn storage_error_source(error: &std::io::Error) -> &StorageError {
|
||||
error
|
||||
.get_ref()
|
||||
.and_then(|source| source.downcast_ref::<StorageError>())
|
||||
.expect("legacy duplex terminal error should retain StorageError source")
|
||||
}
|
||||
|
||||
async fn compressed_fixture(plaintext: Vec<u8>, recorded_size: usize) -> (Vec<u8>, ObjectInfo) {
|
||||
let mut compressor = rustfs_rio::CompressReader::new(std::io::Cursor::new(plaintext), CompressionAlgorithm::default());
|
||||
let mut compressed = Vec::new();
|
||||
compressor
|
||||
.read_to_end(&mut compressed)
|
||||
.await
|
||||
.expect("compress test plaintext");
|
||||
|
||||
let mut metadata = HashMap::new();
|
||||
rustfs_utils::http::insert_str(
|
||||
&mut metadata,
|
||||
rustfs_utils::http::SUFFIX_COMPRESSION,
|
||||
CompressionAlgorithm::default().to_string(),
|
||||
);
|
||||
rustfs_utils::http::insert_str(&mut metadata, rustfs_utils::http::SUFFIX_ACTUAL_SIZE, recorded_size.to_string());
|
||||
let object_info = ObjectInfo {
|
||||
size: i64::try_from(compressed.len()).expect("compressed fixture length should fit in i64"),
|
||||
user_defined: Arc::new(metadata),
|
||||
..Default::default()
|
||||
};
|
||||
(compressed, object_info)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn legacy_duplex_reader_allows_clean_completion() {
|
||||
let (mut writer, reader) = tokio::io::duplex(64);
|
||||
let (terminal_tx, terminal_rx) = tokio::sync::oneshot::channel();
|
||||
writer
|
||||
.write_all(b"complete")
|
||||
.await
|
||||
.expect("duplex write should fit in buffer");
|
||||
drop(writer);
|
||||
terminal_tx.send(Ok(())).expect("terminal receiver should remain installed");
|
||||
|
||||
let mut reader = LegacyDuplexProducerReader::new(reader, terminal_rx);
|
||||
let mut out = Vec::new();
|
||||
reader
|
||||
.read_to_end(&mut out)
|
||||
.await
|
||||
.expect("clean producer completion should surface clean EOF");
|
||||
|
||||
assert_eq!(out, b"complete");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn legacy_duplex_reader_ignores_zero_capacity_read_buf() {
|
||||
let (mut writer, reader) = tokio::io::duplex(64);
|
||||
let (terminal_tx, terminal_rx) = tokio::sync::oneshot::channel();
|
||||
writer.write_all(b"body").await.expect("duplex write should fit in buffer");
|
||||
drop(writer);
|
||||
terminal_tx
|
||||
.send(Err(StorageError::FileCorrupt))
|
||||
.expect("terminal receiver should remain installed");
|
||||
|
||||
let mut reader = LegacyDuplexProducerReader::new(reader, terminal_rx);
|
||||
let mut empty = [];
|
||||
std::future::poll_fn(|cx| {
|
||||
let mut read_buf = ReadBuf::new(&mut empty);
|
||||
Pin::new(&mut reader).poll_read(cx, &mut read_buf)
|
||||
})
|
||||
.await
|
||||
.expect("zero-capacity reads should complete without observing EOF or terminal state");
|
||||
assert!(!reader.inner_eof);
|
||||
assert!(reader.terminal.is_some());
|
||||
|
||||
let mut out = Vec::new();
|
||||
let err = reader
|
||||
.read_to_end(&mut out)
|
||||
.await
|
||||
.expect_err("subsequent reads must still receive data and the terminal error");
|
||||
assert_eq!(out, b"body");
|
||||
assert!(matches!(storage_error_source(&err), StorageError::FileCorrupt));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn legacy_duplex_reader_surfaces_terminal_error_after_partial_data() {
|
||||
let (mut writer, reader) = tokio::io::duplex(64);
|
||||
let (terminal_tx, terminal_rx) = tokio::sync::oneshot::channel();
|
||||
writer.write_all(b"partial").await.expect("duplex write should fit in buffer");
|
||||
drop(writer);
|
||||
terminal_tx
|
||||
.send(Err(StorageError::FileCorrupt))
|
||||
.expect("terminal receiver should remain installed");
|
||||
|
||||
let mut reader = LegacyDuplexProducerReader::new(reader, terminal_rx);
|
||||
let mut out = Vec::new();
|
||||
let err = reader
|
||||
.read_to_end(&mut out)
|
||||
.await
|
||||
.expect_err("terminal producer error must not become clean EOF");
|
||||
|
||||
assert_eq!(out, b"partial");
|
||||
assert!(matches!(storage_error_source(&err), StorageError::FileCorrupt));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn legacy_duplex_reader_surfaces_terminal_error_after_declared_length() {
|
||||
let (mut writer, reader) = tokio::io::duplex(64);
|
||||
let (terminal_tx, terminal_rx) = tokio::sync::oneshot::channel();
|
||||
writer.write_all(b"exact").await.expect("duplex write should fit in buffer");
|
||||
drop(writer);
|
||||
terminal_tx
|
||||
.send(Err(StorageError::Io(std::io::Error::new(
|
||||
std::io::ErrorKind::ConnectionReset,
|
||||
"remote body reset after final byte",
|
||||
))))
|
||||
.expect("terminal receiver should remain installed");
|
||||
|
||||
let reader = LegacyDuplexProducerReader::new(reader, terminal_rx);
|
||||
let mut reader =
|
||||
HashReader::from_stream(reader, 5, 5, None, None, false).expect("hash reader should accept exact declared length");
|
||||
let mut out = Vec::new();
|
||||
let err = reader
|
||||
.read_to_end(&mut out)
|
||||
.await
|
||||
.expect_err("producer terminal error after the declared length must still fail");
|
||||
|
||||
assert_eq!(out, b"exact");
|
||||
assert!(
|
||||
matches!(storage_error_source(&err), StorageError::Io(io_error) if io_error.kind() == std::io::ErrorKind::ConnectionReset)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn legacy_compressed_reader_surfaces_terminal_error_after_complete_plaintext() {
|
||||
let plaintext = b"compressed terminal result must survive the plaintext limit".repeat(16);
|
||||
let (compressed, object_info) = compressed_fixture(plaintext.clone(), plaintext.len()).await;
|
||||
let full_range = HTTPRangeSpec {
|
||||
is_suffix_length: false,
|
||||
start: 0,
|
||||
end: i64::try_from(plaintext.len()).expect("plaintext fixture length should fit in i64") - 1,
|
||||
};
|
||||
for range in [None, Some(full_range)] {
|
||||
let (mut writer, reader) = tokio::io::duplex(compressed.len().max(1));
|
||||
writer
|
||||
.write_all(&compressed)
|
||||
.await
|
||||
.expect("compressed body should fit in duplex buffer");
|
||||
drop(writer);
|
||||
let (terminal_tx, terminal_rx) = tokio::sync::oneshot::channel();
|
||||
terminal_tx
|
||||
.send(Err(StorageError::FileCorrupt))
|
||||
.expect("terminal receiver should remain installed");
|
||||
|
||||
let (mut reader, _, _) = get_legacy_object_reader_with_context(
|
||||
&InstanceContext::new(),
|
||||
reader,
|
||||
terminal_rx,
|
||||
range,
|
||||
&object_info,
|
||||
&ObjectOptions::default(),
|
||||
&HeaderMap::new(),
|
||||
)
|
||||
.await
|
||||
.expect("compressed read plan should build");
|
||||
let mut out = Vec::new();
|
||||
let err = reader
|
||||
.read_to_end(&mut out)
|
||||
.await
|
||||
.expect_err("terminal error after complete decompression must not become clean EOF");
|
||||
|
||||
assert_eq!(out, plaintext);
|
||||
assert!(matches!(storage_error_source(&err), StorageError::FileCorrupt));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn legacy_exact_reader_rejects_extra_data_without_backpressure_deadlock() {
|
||||
let payload = vec![0x5a; TEST_DUPLEX_CAPACITY * 2];
|
||||
let (mut writer, reader) = tokio::io::duplex(TEST_DUPLEX_CAPACITY);
|
||||
let (terminal_tx, terminal_rx) = tokio::sync::oneshot::channel();
|
||||
let producer = tokio::spawn(async move {
|
||||
let result = writer.write_all(&payload).await;
|
||||
drop(writer);
|
||||
let terminal_result = result
|
||||
.as_ref()
|
||||
.map(|_| ())
|
||||
.map_err(|err| StorageError::Io(std::io::Error::new(err.kind(), err.to_string())));
|
||||
let _ = terminal_tx.send(terminal_result);
|
||||
result
|
||||
});
|
||||
let reader = crate::io_support::rio::HardLimitReader::new(reader, 1);
|
||||
let mut reader = LegacyDuplexProducerReader::new(reader, terminal_rx);
|
||||
|
||||
let mut out = Vec::new();
|
||||
tokio::time::timeout(std::time::Duration::from_secs(1), reader.read_to_end(&mut out))
|
||||
.await
|
||||
.expect("extra data beyond the declared size must not deadlock")
|
||||
.expect_err("extra data beyond the declared size must fail closed");
|
||||
assert_eq!(out, [0x5a]);
|
||||
drop(reader);
|
||||
let _ = tokio::time::timeout(std::time::Duration::from_secs(1), producer)
|
||||
.await
|
||||
.expect("producer must unblock after the read fails")
|
||||
.expect("producer task should not panic");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn legacy_terminal_reader_releases_unconsumed_source_before_waiting() {
|
||||
let payload = vec![0x5a; TEST_DUPLEX_CAPACITY * 2];
|
||||
let (mut writer, reader) = tokio::io::duplex(TEST_DUPLEX_CAPACITY);
|
||||
let (terminal_tx, terminal_rx) = tokio::sync::oneshot::channel();
|
||||
let producer = tokio::spawn(async move {
|
||||
let result = writer.write_all(&payload).await;
|
||||
drop(writer);
|
||||
let terminal_result = result
|
||||
.as_ref()
|
||||
.map(|_| ())
|
||||
.map_err(|err| StorageError::Io(std::io::Error::new(err.kind(), err.to_string())));
|
||||
let _ = terminal_tx.send(terminal_result);
|
||||
result
|
||||
});
|
||||
let reader = rustfs_rio::LimitReader::new(reader, 1);
|
||||
let mut reader = LegacyDuplexProducerReader::new(reader, terminal_rx);
|
||||
|
||||
let mut out = Vec::new();
|
||||
let err = tokio::time::timeout(std::time::Duration::from_secs(1), reader.read_to_end(&mut out))
|
||||
.await
|
||||
.expect("terminal wait must not deadlock behind unconsumed source data")
|
||||
.expect_err("unconsumed source data must fail the producer terminal result");
|
||||
assert_eq!(out, [0x5a]);
|
||||
assert!(
|
||||
matches!(storage_error_source(&err), StorageError::Io(io_error) if io_error.kind() == std::io::ErrorKind::BrokenPipe)
|
||||
);
|
||||
producer
|
||||
.await
|
||||
.expect("producer task should not panic")
|
||||
.expect_err("source should close early");
|
||||
}
|
||||
|
||||
struct FixedEncryptionResolver {
|
||||
key_bytes: [u8; 32],
|
||||
base_nonce: [u8; 12],
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ObjectEncryptionResolver for FixedEncryptionResolver {
|
||||
async fn resolve_read_material(
|
||||
&self,
|
||||
_request: crate::object_api::ReadEncryptionRequest<'_>,
|
||||
) -> std::result::Result<Option<ReadEncryptionMaterial>, EncryptionResolutionError> {
|
||||
Ok(Some(ReadEncryptionMaterial {
|
||||
key_bytes: self.key_bytes,
|
||||
mode: ReadEncryptionMode::Direct {
|
||||
base_nonce: self.base_nonce,
|
||||
},
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn legacy_encrypted_reader_surfaces_terminal_error_after_complete_plaintext() {
|
||||
let plaintext = b"encrypted terminal result must survive the plaintext limit".repeat(16);
|
||||
let key_bytes = [0x31; 32];
|
||||
let base_nonce = [0x42; 12];
|
||||
let mut encryptor = rustfs_rio::EncryptReader::new(std::io::Cursor::new(plaintext.clone()), key_bytes, base_nonce);
|
||||
let mut encrypted = Vec::new();
|
||||
encryptor.read_to_end(&mut encrypted).await.expect("encrypt test plaintext");
|
||||
|
||||
let object_info = ObjectInfo {
|
||||
bucket: "bucket".to_string(),
|
||||
name: "encrypted-object".to_string(),
|
||||
size: i64::try_from(encrypted.len()).expect("encrypted fixture length should fit in i64"),
|
||||
user_defined: Arc::new(HashMap::from([
|
||||
("x-amz-server-side-encryption-customer-algorithm".to_string(), "AES256".to_string()),
|
||||
(
|
||||
"x-amz-server-side-encryption-customer-original-size".to_string(),
|
||||
plaintext.len().to_string(),
|
||||
),
|
||||
])),
|
||||
..Default::default()
|
||||
};
|
||||
let ctx = InstanceContext::new();
|
||||
assert!(
|
||||
ctx.set_object_encryption_resolver(Arc::new(FixedEncryptionResolver { key_bytes, base_nonce }))
|
||||
.is_ok(),
|
||||
"fresh context should accept resolver"
|
||||
);
|
||||
let full_range = HTTPRangeSpec {
|
||||
is_suffix_length: false,
|
||||
start: 0,
|
||||
end: i64::try_from(plaintext.len()).expect("plaintext fixture length should fit in i64") - 1,
|
||||
};
|
||||
for range in [None, Some(full_range)] {
|
||||
let (mut writer, reader) = tokio::io::duplex(encrypted.len().max(1));
|
||||
writer
|
||||
.write_all(&encrypted)
|
||||
.await
|
||||
.expect("encrypted body should fit in duplex buffer");
|
||||
drop(writer);
|
||||
let (terminal_tx, terminal_rx) = tokio::sync::oneshot::channel();
|
||||
terminal_tx
|
||||
.send(Err(StorageError::FileCorrupt))
|
||||
.expect("terminal receiver should remain installed");
|
||||
|
||||
let (mut reader, _, _) = get_legacy_object_reader_with_context(
|
||||
&ctx,
|
||||
reader,
|
||||
terminal_rx,
|
||||
range,
|
||||
&object_info,
|
||||
&ObjectOptions::default(),
|
||||
&HeaderMap::new(),
|
||||
)
|
||||
.await
|
||||
.expect("encrypted read plan should build");
|
||||
let mut out = Vec::new();
|
||||
let err = reader
|
||||
.read_to_end(&mut out)
|
||||
.await
|
||||
.expect_err("terminal error after complete decryption must not become clean EOF");
|
||||
|
||||
assert_eq!(out, plaintext);
|
||||
assert!(matches!(storage_error_source(&err), StorageError::FileCorrupt));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn legacy_duplex_reader_fails_closed_when_terminal_channel_closes() {
|
||||
let (mut writer, reader) = tokio::io::duplex(64);
|
||||
let (terminal_tx, terminal_rx) = tokio::sync::oneshot::channel::<Result<()>>();
|
||||
writer.write_all(b"body").await.expect("duplex write should fit in buffer");
|
||||
drop(writer);
|
||||
drop(terminal_tx);
|
||||
|
||||
let mut reader = LegacyDuplexProducerReader::new(reader, terminal_rx);
|
||||
let mut out = Vec::new();
|
||||
let err = reader
|
||||
.read_to_end(&mut out)
|
||||
.await
|
||||
.expect_err("producer disappearance must fail closed");
|
||||
|
||||
assert_eq!(out, b"body");
|
||||
assert!(matches!(storage_error_source(&err), StorageError::Unexpected));
|
||||
}
|
||||
}
|
||||
|
||||
struct TransitionUploadWriter<W> {
|
||||
inner: W,
|
||||
produced: u64,
|
||||
@@ -6065,6 +6517,7 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
|
||||
&oi,
|
||||
&opts,
|
||||
&self_.ctx.tier_config_mgr(),
|
||||
self_.ctx.object_encryption_resolver(),
|
||||
)
|
||||
.await;
|
||||
if let Err(err) = gr {
|
||||
@@ -6134,6 +6587,7 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
|
||||
&oi,
|
||||
&part_opts,
|
||||
&self_.ctx.tier_config_mgr(),
|
||||
self_.ctx.object_encryption_resolver(),
|
||||
)
|
||||
.await
|
||||
.map_err(StorageError::Io)?;
|
||||
@@ -11180,7 +11634,7 @@ mod put_object_tmp_cleanup_tests {
|
||||
use tokio::io::AsyncReadExt;
|
||||
|
||||
/// Large enough that the erasure shards are written as real tmp files
|
||||
/// (never inlined into xl.meta), so both tests exercise actual cleanup.
|
||||
/// (never inlined into xl.meta), so the cleanup tests exercise actual cleanup.
|
||||
const TEST_OBJECT_SIZE: usize = 1 << 20;
|
||||
|
||||
/// Entries under `.rustfs.sys/tmp` on every disk, excluding the `.trash`
|
||||
@@ -11204,6 +11658,18 @@ mod put_object_tmp_cleanup_tests {
|
||||
leftovers
|
||||
}
|
||||
|
||||
async fn wait_for_tmp_workspace_to_drain(temp_dirs: &[TempDir], failure_context: &str) {
|
||||
let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
|
||||
loop {
|
||||
let leftovers = non_trash_tmp_entries(temp_dirs).await;
|
||||
if leftovers.is_empty() {
|
||||
break;
|
||||
}
|
||||
assert!(tokio::time::Instant::now() < deadline, "{failure_context}, leftovers: {leftovers:?}");
|
||||
tokio::time::sleep(Duration::from_millis(25)).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn put_object_success_eventually_cleans_tmp_workspace() {
|
||||
let (temp_dirs, disk_stores, set_disks) = hermetic_set_disks(4).await;
|
||||
@@ -11219,22 +11685,39 @@ mod put_object_tmp_cleanup_tests {
|
||||
.await
|
||||
.expect("put_object should succeed");
|
||||
|
||||
// The speculative cleanup runs on a spawned task off the PUT response
|
||||
// path, so poll for the tmp workspace to drain instead of asserting
|
||||
// immediately.
|
||||
let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
|
||||
loop {
|
||||
let leftovers = non_trash_tmp_entries(&temp_dirs).await;
|
||||
if leftovers.is_empty() {
|
||||
break;
|
||||
}
|
||||
assert!(
|
||||
tokio::time::Instant::now() < deadline,
|
||||
"tmp workspace should drain after a successful PUT, leftovers: {leftovers:?}"
|
||||
);
|
||||
tokio::time::sleep(Duration::from_millis(25)).await;
|
||||
wait_for_tmp_workspace_to_drain(&temp_dirs, "tmp workspace should drain after a successful PUT").await;
|
||||
|
||||
drop(temp_dirs);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancelled_put_before_rename_cleans_tmp_workspace() {
|
||||
let (temp_dirs, disk_stores, set_disks) = hermetic_set_disks(4).await;
|
||||
|
||||
let bucket = "tmp-clean-cancelled-bucket";
|
||||
let object = "cancelled-object";
|
||||
for disk in &disk_stores {
|
||||
disk.make_volume(bucket).await.expect("bucket volume should be created");
|
||||
}
|
||||
|
||||
let barrier = PutObjectCommitBarrier::install(bucket, object, PutObjectCommitPause::AfterQuotaReservation);
|
||||
let cancelled_set = set_disks.clone();
|
||||
let put = tokio::spawn(async move {
|
||||
let mut reader = PutObjReader::from_vec(vec![8u8; TEST_OBJECT_SIZE]);
|
||||
cancelled_set
|
||||
.put_object(bucket, object, &mut reader, &ObjectOptions::default())
|
||||
.await
|
||||
});
|
||||
barrier.wait_until_paused().await;
|
||||
put.abort();
|
||||
let join_error = put.await.expect_err("the paused PUT task must be cancelled");
|
||||
assert!(join_error.is_cancelled(), "the paused PUT task must not panic");
|
||||
|
||||
// Keep the barrier armed so a detached child cannot proceed and hide
|
||||
// missing cancellation cleanup.
|
||||
wait_for_tmp_workspace_to_drain(&temp_dirs, "cancelling before rename should drain the tmp workspace").await;
|
||||
|
||||
drop(barrier);
|
||||
drop(temp_dirs);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,81 +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 super::*;
|
||||
|
||||
impl ECStore {
|
||||
#[instrument(level = "trace", skip(self))]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) async fn handle_list_objects_v2(
|
||||
self: Arc<Self>,
|
||||
bucket: &str,
|
||||
prefix: &str,
|
||||
continuation_token: Option<String>,
|
||||
delimiter: Option<String>,
|
||||
max_keys: i32,
|
||||
fetch_owner: bool,
|
||||
start_after: Option<String>,
|
||||
incl_deleted: bool,
|
||||
) -> Result<ListObjectsV2Info> {
|
||||
self.inner_list_objects_v2(
|
||||
bucket,
|
||||
prefix,
|
||||
continuation_token,
|
||||
delimiter,
|
||||
max_keys,
|
||||
fetch_owner,
|
||||
start_after,
|
||||
incl_deleted,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[instrument(skip(self))]
|
||||
pub(super) async fn handle_list_object_versions(
|
||||
self: Arc<Self>,
|
||||
bucket: &str,
|
||||
prefix: &str,
|
||||
marker: Option<String>,
|
||||
version_marker: Option<String>,
|
||||
delimiter: Option<String>,
|
||||
max_keys: i32,
|
||||
) -> Result<ListObjectVersionsInfo> {
|
||||
self.inner_list_object_versions(bucket, prefix, marker, version_marker, delimiter, max_keys)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn list_object_versions_for_lifecycle(
|
||||
self: Arc<Self>,
|
||||
bucket: &str,
|
||||
prefix: &str,
|
||||
marker: Option<String>,
|
||||
version_marker: Option<String>,
|
||||
delimiter: Option<String>,
|
||||
max_keys: i32,
|
||||
) -> Result<ListObjectVersionsInfo> {
|
||||
self.inner_list_object_versions_for_lifecycle(bucket, prefix, marker, version_marker, delimiter, max_keys)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(super) async fn handle_walk(
|
||||
self: Arc<Self>,
|
||||
rx: CancellationToken,
|
||||
bucket: &str,
|
||||
prefix: &str,
|
||||
result: tokio::sync::mpsc::Sender<ObjectInfoOrErr>,
|
||||
opts: WalkOptions,
|
||||
) -> Result<()> {
|
||||
self.walk_internal(rx, bucket, prefix, result, opts).await
|
||||
}
|
||||
}
|
||||
@@ -3845,7 +3845,7 @@ impl ECStore {
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn inner_list_object_versions_for_lifecycle(
|
||||
pub(crate) async fn list_object_versions_for_lifecycle(
|
||||
self: Arc<Self>,
|
||||
bucket: &str,
|
||||
prefix: &str,
|
||||
|
||||
@@ -148,7 +148,6 @@ mod heal_walk;
|
||||
pub use heal_walk::HealWalkVersion;
|
||||
mod init;
|
||||
pub(crate) mod init_format;
|
||||
mod list;
|
||||
pub(crate) mod list_objects;
|
||||
mod multipart;
|
||||
mod object;
|
||||
@@ -601,7 +600,7 @@ impl crate::storage_api_contracts::list::ListOperations for ECStore {
|
||||
start_after: Option<String>,
|
||||
incl_deleted: bool,
|
||||
) -> Result<ListObjectsV2Info> {
|
||||
self.handle_list_objects_v2(
|
||||
self.inner_list_objects_v2(
|
||||
bucket,
|
||||
prefix,
|
||||
continuation_token,
|
||||
@@ -624,7 +623,7 @@ impl crate::storage_api_contracts::list::ListOperations for ECStore {
|
||||
delimiter: Option<String>,
|
||||
max_keys: i32,
|
||||
) -> Result<ListObjectVersionsInfo> {
|
||||
self.handle_list_object_versions(bucket, prefix, marker, version_marker, delimiter, max_keys)
|
||||
self.inner_list_object_versions(bucket, prefix, marker, version_marker, delimiter, max_keys)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -636,7 +635,7 @@ impl crate::storage_api_contracts::list::ListOperations for ECStore {
|
||||
result: tokio::sync::mpsc::Sender<ObjectInfoOrErr>,
|
||||
opts: WalkOptions,
|
||||
) -> Result<()> {
|
||||
self.handle_walk(rx, bucket, prefix, result, opts).await
|
||||
self.walk_internal(rx, bucket, prefix, result, opts).await
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -157,6 +157,18 @@ fn ecstore_implements_storage_list_operations_contract() {
|
||||
assert!(storage_list_operations_type_name::<ECStore>().ends_with("::ECStore"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ecstore_pools_expose_storage_list_operations_contract() {
|
||||
fn assert_contract(store: &ECStore) {
|
||||
let future = store.pools[0]
|
||||
.clone()
|
||||
.list_objects_v2("bucket", "", None, None, 1, false, None, false);
|
||||
drop(future);
|
||||
}
|
||||
|
||||
let _ = assert_contract;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ecstore_implements_storage_multipart_operations_contract() {
|
||||
assert!(storage_multipart_operations_type_name::<ECStore>().ends_with("::ECStore"));
|
||||
|
||||
@@ -135,10 +135,7 @@ impl FileMeta {
|
||||
let i = buf.len() as u64;
|
||||
|
||||
// check version, buf = buf[8..]
|
||||
let (buf, _, _) = Self::check_xl2_v1(buf).map_err(|e| {
|
||||
error!("failed to check XL2 v1 format: {}", e);
|
||||
e
|
||||
})?;
|
||||
let (buf, _, _) = Self::check_xl2_v1(buf)?;
|
||||
|
||||
if buf.len() < 5 {
|
||||
error!(
|
||||
|
||||
@@ -225,8 +225,6 @@ async fn nothing_readable_leaves_the_bundle_unwrapped() {
|
||||
"artifact {} carries the raw on-disk record",
|
||||
artifact.path
|
||||
);
|
||||
// A cheap structural check too: an encrypted payload is not JSON.
|
||||
assert_ne!(payload.first(), Some(&b'{'), "artifact {} looks like plaintext JSON", artifact.path);
|
||||
}
|
||||
|
||||
// The manifest itself is not encrypted, so assert directly that it carries
|
||||
|
||||
@@ -103,11 +103,7 @@ pub(super) fn rules() -> Vec<Rule> {
|
||||
P2Degraded,
|
||||
"heal",
|
||||
"heal 任务调度/执行失败",
|
||||
any([
|
||||
prefix("Heal task timeout"),
|
||||
prefix("Heal task execution failed"),
|
||||
contains("Heal manager is not running"),
|
||||
]),
|
||||
any([prefix("Heal task timeout"), prefix("Heal task execution failed")]),
|
||||
"heal 任务调度/执行层故障。",
|
||||
"检查 heal 后台服务状态与资源压力。",
|
||||
)
|
||||
|
||||
@@ -520,6 +520,14 @@ struct FailureSample {
|
||||
pub struct FailStats {
|
||||
pub count: i64,
|
||||
pub size: i64,
|
||||
/// Rolling-window snapshots refreshed at collection time
|
||||
/// ([`Self::refresh_windows`]). The raw samples (`recent`) are process
|
||||
/// local (serde-skipped), so these fields are what survives the peer-RPC
|
||||
/// wire and [`Self::merge`]-based cluster aggregation.
|
||||
#[serde(default)]
|
||||
pub last_minute: FailedMetric,
|
||||
#[serde(default)]
|
||||
pub last_hour: FailedMetric,
|
||||
#[serde(skip)]
|
||||
recent: VecDeque<FailureSample>,
|
||||
}
|
||||
@@ -537,6 +545,17 @@ impl FailStats {
|
||||
self.prune(observed_at);
|
||||
}
|
||||
|
||||
/// Recompute the serializable rolling-window snapshots from the local
|
||||
/// samples. Called at the collection point (per-node stats snapshot),
|
||||
/// never on the failure hot path — the two deque scans are O(window) and
|
||||
/// `add_size` runs under the bucket-stats write lock. Only meaningful on
|
||||
/// the live per-node struct: a deserialized or merged struct has no
|
||||
/// samples, and refreshing it would wipe the aggregated windows.
|
||||
pub fn refresh_windows(&mut self) {
|
||||
self.last_minute = self.recent_since(Duration::from_secs(60));
|
||||
self.last_hour = self.recent_since(Duration::from_secs(3600));
|
||||
}
|
||||
|
||||
fn prune(&mut self, observed_at: Instant) {
|
||||
while self
|
||||
.recent
|
||||
@@ -565,6 +584,16 @@ impl FailStats {
|
||||
Self {
|
||||
count: self.count.saturating_add(other.count),
|
||||
size: self.size.saturating_add(other.size),
|
||||
// The window snapshots sum across nodes; the raw samples do not
|
||||
// travel and stay empty on aggregated structs.
|
||||
last_minute: FailedMetric {
|
||||
count: self.last_minute.count.saturating_add(other.last_minute.count),
|
||||
size: self.last_minute.size.saturating_add(other.last_minute.size),
|
||||
},
|
||||
last_hour: FailedMetric {
|
||||
count: self.last_hour.count.saturating_add(other.last_hour.count),
|
||||
size: self.last_hour.size.saturating_add(other.last_hour.size),
|
||||
},
|
||||
recent: VecDeque::new(),
|
||||
}
|
||||
}
|
||||
@@ -636,7 +665,9 @@ impl BucketReplicationStat {
|
||||
}
|
||||
|
||||
pub fn update_xfer_rate(&mut self, size: i64, duration: Duration) {
|
||||
if size > 1024 * 1024 {
|
||||
// Same boundary as the worker-pool split and minio-go's
|
||||
// Large/Small transfer-summary labels: >= 128 MiB is "large".
|
||||
if size >= crate::runtime::MIN_LARGE_OBJ_SIZE {
|
||||
self.xfer_rate_lrg.add_size(size, duration);
|
||||
} else {
|
||||
self.xfer_rate_sml.add_size(size, duration);
|
||||
|
||||
@@ -71,6 +71,7 @@ where
|
||||
|
||||
/// Optional: allow users to customize block_size
|
||||
pub fn with_block_size(inner: R, block_size: usize, compression_algorithm: CompressionAlgorithm) -> Self {
|
||||
debug_assert!(block_size > 0, "CompressReader block_size must be non-zero");
|
||||
Self {
|
||||
inner,
|
||||
buffer: Vec::new(),
|
||||
@@ -183,11 +184,21 @@ pin_project! {
|
||||
buffer: Vec<u8>,
|
||||
buffer_pos: usize,
|
||||
finished: bool,
|
||||
// A previously surfaced stream error is sticky: without this, a caller
|
||||
// that polls again after an error would restart at the header phase and
|
||||
// read a truncated tail as a clean EOF, converting the error into a
|
||||
// silently short body.
|
||||
poisoned: bool,
|
||||
// Fields for saving header read progress across polls
|
||||
header_buf: [u8; 8],
|
||||
header_read: usize,
|
||||
header_done: bool,
|
||||
// Fields for saving compressed block read progress across polls
|
||||
// Fields for saving compressed block read progress across polls.
|
||||
// `compressed_len > 0` means a block payload is in flight: the header has
|
||||
// been fully parsed and `compressed_read` bytes of the payload are already
|
||||
// consumed from the inner stream. The header phase must not run again (and
|
||||
// must not reset `compressed_read`) until this block completes, or a
|
||||
// `Poll::Pending` in the middle of a payload would silently drop the bytes
|
||||
// read so far and desynchronize the block framing.
|
||||
compressed_buf: Vec<u8>,
|
||||
compressed_read: usize,
|
||||
compressed_len: usize,
|
||||
@@ -205,9 +216,9 @@ where
|
||||
buffer: Vec::new(),
|
||||
buffer_pos: 0,
|
||||
finished: false,
|
||||
poisoned: false,
|
||||
header_buf: [0u8; 8],
|
||||
header_read: 0,
|
||||
header_done: false,
|
||||
compressed_buf: Vec::new(),
|
||||
compressed_read: 0,
|
||||
compressed_len: 0,
|
||||
@@ -236,54 +247,74 @@ where
|
||||
if *this.finished {
|
||||
return Poll::Ready(Ok(()));
|
||||
}
|
||||
// Read header
|
||||
while !*this.header_done && *this.header_read < HEADER_LEN {
|
||||
let mut temp = [0u8; HEADER_LEN];
|
||||
let mut temp_buf = ReadBuf::new(&mut temp[0..HEADER_LEN - *this.header_read]);
|
||||
match this.inner.as_mut().poll_read(cx, &mut temp_buf) {
|
||||
Poll::Pending => return Poll::Pending,
|
||||
Poll::Ready(Ok(())) => {
|
||||
let n = temp_buf.filled().len();
|
||||
if n == 0 {
|
||||
break;
|
||||
if *this.poisoned {
|
||||
return Poll::Ready(Err(io::Error::new(io::ErrorKind::InvalidData, "decompress reader previously failed")));
|
||||
}
|
||||
|
||||
if *this.compressed_len == 0 {
|
||||
// Read the 8-byte block header, resuming across polls via `header_read`.
|
||||
while *this.header_read < HEADER_LEN {
|
||||
let mut temp = [0u8; HEADER_LEN];
|
||||
let mut temp_buf = ReadBuf::new(&mut temp[0..HEADER_LEN - *this.header_read]);
|
||||
match this.inner.as_mut().poll_read(cx, &mut temp_buf) {
|
||||
Poll::Pending => return Poll::Pending,
|
||||
Poll::Ready(Ok(())) => {
|
||||
let n = temp_buf.filled().len();
|
||||
if n == 0 {
|
||||
if *this.header_read == 0 {
|
||||
// Clean EOF on a block boundary.
|
||||
*this.finished = true;
|
||||
return Poll::Ready(Ok(()));
|
||||
}
|
||||
*this.poisoned = true;
|
||||
return Poll::Ready(Err(io::Error::new(
|
||||
io::ErrorKind::UnexpectedEof,
|
||||
"unexpected EOF while reading compressed block header",
|
||||
)));
|
||||
}
|
||||
this.header_buf[*this.header_read..*this.header_read + n].copy_from_slice(&temp_buf.filled()[..n]);
|
||||
*this.header_read += n;
|
||||
}
|
||||
Poll::Ready(Err(e)) => {
|
||||
// error!("DecompressReader poll_read: read header error: {e}");
|
||||
*this.poisoned = true;
|
||||
return Poll::Ready(Err(e));
|
||||
}
|
||||
this.header_buf[*this.header_read..*this.header_read + n].copy_from_slice(&temp_buf.filled()[..n]);
|
||||
*this.header_read += n;
|
||||
}
|
||||
Poll::Ready(Err(e)) => {
|
||||
// error!("DecompressReader poll_read: read header error: {e}");
|
||||
return Poll::Ready(Err(e));
|
||||
}
|
||||
}
|
||||
if *this.header_read < HEADER_LEN {
|
||||
return Poll::Pending;
|
||||
}
|
||||
}
|
||||
if !*this.header_done && *this.header_read == 0 {
|
||||
return Poll::Ready(Ok(()));
|
||||
}
|
||||
let typ = this.header_buf[0];
|
||||
let len = (this.header_buf[1] as usize) | ((this.header_buf[2] as usize) << 8) | ((this.header_buf[3] as usize) << 16);
|
||||
let crc = (this.header_buf[4] as u32)
|
||||
| ((this.header_buf[5] as u32) << 8)
|
||||
| ((this.header_buf[6] as u32) << 16)
|
||||
| ((this.header_buf[7] as u32) << 24);
|
||||
*this.header_read = 0;
|
||||
*this.header_done = true;
|
||||
|
||||
if typ == COMPRESS_TYPE_END {
|
||||
let typ = this.header_buf[0];
|
||||
let len =
|
||||
(this.header_buf[1] as usize) | ((this.header_buf[2] as usize) << 8) | ((this.header_buf[3] as usize) << 16);
|
||||
*this.header_read = 0;
|
||||
|
||||
// `CompressReader` never emits an end block — a stream terminates on
|
||||
// inner EOF, which is what lets concatenated per-part streams decode as
|
||||
// one. This branch is kept for streams that do carry the marker.
|
||||
if typ == COMPRESS_TYPE_END {
|
||||
*this.compressed_read = 0;
|
||||
*this.compressed_len = 0;
|
||||
*this.finished = true;
|
||||
return Poll::Ready(Ok(()));
|
||||
}
|
||||
if typ != COMPRESS_TYPE_COMPRESSED && typ != COMPRESS_TYPE_UNCOMPRESSED {
|
||||
// error!("DecompressReader unknown compression type: {typ}");
|
||||
*this.poisoned = true;
|
||||
return Poll::Ready(Err(io::Error::new(io::ErrorKind::InvalidData, "Unknown compression type")));
|
||||
}
|
||||
if len == 0 {
|
||||
*this.poisoned = true;
|
||||
return Poll::Ready(Err(io::Error::new(io::ErrorKind::InvalidData, "Invalid compressed block length")));
|
||||
}
|
||||
|
||||
if this.compressed_buf.len() < len {
|
||||
this.compressed_buf.resize(len, 0);
|
||||
}
|
||||
*this.compressed_len = len;
|
||||
*this.compressed_read = 0;
|
||||
*this.compressed_len = 0;
|
||||
*this.finished = true;
|
||||
return Poll::Ready(Ok(()));
|
||||
}
|
||||
|
||||
if this.compressed_buf.len() < len {
|
||||
this.compressed_buf.resize(len, 0);
|
||||
}
|
||||
*this.compressed_len = len;
|
||||
*this.compressed_read = 0;
|
||||
|
||||
// Fill the in-flight block payload, resuming across polls via `compressed_read`.
|
||||
while *this.compressed_read < *this.compressed_len {
|
||||
let mut temp_buf = ReadBuf::new(&mut this.compressed_buf[*this.compressed_read..*this.compressed_len]);
|
||||
match this.inner.as_mut().poll_read(cx, &mut temp_buf) {
|
||||
@@ -291,7 +322,13 @@ where
|
||||
Poll::Ready(Ok(())) => {
|
||||
let n = temp_buf.filled().len();
|
||||
if n == 0 {
|
||||
break;
|
||||
*this.compressed_read = 0;
|
||||
*this.compressed_len = 0;
|
||||
*this.poisoned = true;
|
||||
return Poll::Ready(Err(io::Error::new(
|
||||
io::ErrorKind::UnexpectedEof,
|
||||
"unexpected EOF while reading compressed block payload",
|
||||
)));
|
||||
}
|
||||
*this.compressed_read += n;
|
||||
}
|
||||
@@ -299,10 +336,17 @@ where
|
||||
// error!("DecompressReader poll_read: read compressed block error: {e}");
|
||||
*this.compressed_read = 0;
|
||||
*this.compressed_len = 0;
|
||||
*this.poisoned = true;
|
||||
return Poll::Ready(Err(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let typ = this.header_buf[0];
|
||||
let crc = (this.header_buf[4] as u32)
|
||||
| ((this.header_buf[5] as u32) << 8)
|
||||
| ((this.header_buf[6] as u32) << 16)
|
||||
| ((this.header_buf[7] as u32) << 24);
|
||||
let compressed_buf = &this.compressed_buf[..*this.compressed_len];
|
||||
// `compressed_buf`'s length comes from the untrusted 24-bit header length field, so it
|
||||
// can be shorter than 16 bytes. `uvarint` is safe on any slice length (reads at most 10
|
||||
@@ -316,6 +360,7 @@ where
|
||||
if uvarint <= 0 || uvarint as usize > compressed_buf.len() {
|
||||
*this.compressed_read = 0;
|
||||
*this.compressed_len = 0;
|
||||
*this.poisoned = true;
|
||||
return Poll::Ready(Err(io::Error::new(io::ErrorKind::InvalidData, "Invalid compressed block length prefix")));
|
||||
}
|
||||
let compressed_data = &compressed_buf[uvarint as usize..];
|
||||
@@ -326,21 +371,29 @@ where
|
||||
// error!("DecompressReader decompress_block error: {e}");
|
||||
*this.compressed_read = 0;
|
||||
*this.compressed_len = 0;
|
||||
*this.poisoned = true;
|
||||
return Poll::Ready(Err(e));
|
||||
}
|
||||
}
|
||||
} else if typ == COMPRESS_TYPE_UNCOMPRESSED {
|
||||
compressed_data.to_vec()
|
||||
} else {
|
||||
// error!("DecompressReader unknown compression type: {typ}");
|
||||
// The header phase already rejected every type other than
|
||||
// COMPRESS_TYPE_COMPRESSED / COMPRESS_TYPE_UNCOMPRESSED.
|
||||
compressed_data.to_vec()
|
||||
};
|
||||
if decompressed.is_empty() {
|
||||
// The writer never emits zero-length plaintext blocks; an empty
|
||||
// decode surfacing as Ready(Ok) with no bytes would read as EOF and
|
||||
// silently truncate the stream.
|
||||
*this.poisoned = true;
|
||||
*this.compressed_read = 0;
|
||||
*this.compressed_len = 0;
|
||||
return Poll::Ready(Err(io::Error::new(io::ErrorKind::InvalidData, "Unknown compression type")));
|
||||
};
|
||||
return Poll::Ready(Err(io::Error::new(io::ErrorKind::InvalidData, "Empty compressed block")));
|
||||
}
|
||||
if decompressed.len() != uncompress_len as usize {
|
||||
// error!("DecompressReader decompressed length mismatch: {} != {}", decompressed.len(), uncompress_len);
|
||||
*this.compressed_read = 0;
|
||||
*this.compressed_len = 0;
|
||||
*this.poisoned = true;
|
||||
return Poll::Ready(Err(io::Error::new(io::ErrorKind::InvalidData, "Decompressed length mismatch")));
|
||||
}
|
||||
let actual_crc = {
|
||||
@@ -352,13 +405,13 @@ where
|
||||
// error!("DecompressReader CRC32 mismatch: actual {actual_crc} != expected {crc}");
|
||||
*this.compressed_read = 0;
|
||||
*this.compressed_len = 0;
|
||||
*this.poisoned = true;
|
||||
return Poll::Ready(Err(io::Error::new(io::ErrorKind::InvalidData, "CRC32 mismatch")));
|
||||
}
|
||||
*this.buffer = decompressed;
|
||||
*this.buffer_pos = 0;
|
||||
*this.compressed_read = 0;
|
||||
*this.compressed_len = 0;
|
||||
*this.header_done = false;
|
||||
let to_copy = min(buf.remaining(), this.buffer.len());
|
||||
buf.put_slice(&this.buffer[..to_copy]);
|
||||
*this.buffer_pos += to_copy;
|
||||
@@ -493,6 +546,184 @@ mod tests {
|
||||
assert_eq!(&decompressed, &data);
|
||||
}
|
||||
|
||||
/// Wraps a reader so every other poll returns `Poll::Pending` and every
|
||||
/// `Ready` poll serves at most `chunk` bytes. This is the shape a duplex
|
||||
/// pipe produces when the erasure writer is slower than the decoder, which
|
||||
/// is exactly what desynchronized the block framing before the resumable
|
||||
/// payload state was added (rustfs/rustfs#5957 multipart GET truncation).
|
||||
struct PendingChunkReader<R> {
|
||||
inner: R,
|
||||
chunk: usize,
|
||||
pending_next: bool,
|
||||
}
|
||||
|
||||
impl<R> PendingChunkReader<R> {
|
||||
fn new(inner: R, chunk: usize) -> Self {
|
||||
Self {
|
||||
inner,
|
||||
chunk,
|
||||
pending_next: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: AsyncRead + Unpin> AsyncRead for PendingChunkReader<R> {
|
||||
fn poll_read(
|
||||
mut self: std::pin::Pin<&mut Self>,
|
||||
cx: &mut std::task::Context<'_>,
|
||||
buf: &mut tokio::io::ReadBuf<'_>,
|
||||
) -> std::task::Poll<std::io::Result<()>> {
|
||||
if self.pending_next {
|
||||
self.pending_next = false;
|
||||
cx.waker().wake_by_ref();
|
||||
return std::task::Poll::Pending;
|
||||
}
|
||||
self.pending_next = true;
|
||||
let cap = self.chunk.min(buf.remaining());
|
||||
let mut scratch = vec![0u8; cap];
|
||||
let mut inner_buf = tokio::io::ReadBuf::new(&mut scratch);
|
||||
match std::pin::Pin::new(&mut self.inner).poll_read(cx, &mut inner_buf) {
|
||||
std::task::Poll::Ready(Ok(())) => {
|
||||
buf.put_slice(inner_buf.filled());
|
||||
std::task::Poll::Ready(Ok(()))
|
||||
}
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn patterned_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()
|
||||
}
|
||||
|
||||
/// Root-cause regression for the multipart compressed GET truncation: a
|
||||
/// `Poll::Pending` in the middle of a block payload must not drop the bytes
|
||||
/// already consumed. Before the resumable payload state, the decoder reset
|
||||
/// `compressed_read` on every re-poll and surfaced
|
||||
/// `LZ4 error: ERROR_frameType_unknown` mid-stream.
|
||||
#[tokio::test]
|
||||
async fn test_decompress_reader_survives_pending_mid_payload() {
|
||||
let data = patterned_payload(100 * 1024, 7);
|
||||
let mut compress_reader =
|
||||
CompressReader::with_block_size(Cursor::new(data.clone()), 8192, CompressionAlgorithm::default());
|
||||
let mut compressed = Vec::new();
|
||||
compress_reader.read_to_end(&mut compressed).await.unwrap();
|
||||
|
||||
for chunk in [1usize, 3, 7, 8, 17, 1000, 8192] {
|
||||
let inner = PendingChunkReader::new(Cursor::new(compressed.clone()), chunk);
|
||||
let mut decompress_reader = DecompressReader::new(inner, CompressionAlgorithm::default());
|
||||
let mut decompressed = Vec::new();
|
||||
decompress_reader.read_to_end(&mut decompressed).await.unwrap();
|
||||
assert_eq!(decompressed, data, "pending-chunked decode must be byte-exact for chunk={chunk}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Two independently compressed streams concatenated back to back — the
|
||||
/// on-disk shape of a compressed multipart object — must decode across the
|
||||
/// stream boundary even when every poll can suspend mid-block.
|
||||
#[tokio::test]
|
||||
async fn test_decompress_reader_survives_pending_across_concatenated_streams() {
|
||||
let part1 = patterned_payload(64 * 1024, 7);
|
||||
let part2 = patterned_payload(24 * 1024, 61);
|
||||
|
||||
let mut stored = Vec::new();
|
||||
for part in [&part1, &part2] {
|
||||
let mut compress_reader =
|
||||
CompressReader::with_block_size(Cursor::new(part.clone()), 8192, CompressionAlgorithm::default());
|
||||
let mut compressed = Vec::new();
|
||||
compress_reader.read_to_end(&mut compressed).await.unwrap();
|
||||
stored.extend_from_slice(&compressed);
|
||||
}
|
||||
|
||||
let mut expected = part1;
|
||||
expected.extend_from_slice(&part2);
|
||||
|
||||
for chunk in [1usize, 5, 8, 13, 4096] {
|
||||
let inner = PendingChunkReader::new(Cursor::new(stored.clone()), chunk);
|
||||
let mut decompress_reader = DecompressReader::new(inner, CompressionAlgorithm::default());
|
||||
let mut decompressed = Vec::new();
|
||||
decompress_reader.read_to_end(&mut decompressed).await.unwrap();
|
||||
assert_eq!(
|
||||
decompressed, expected,
|
||||
"concatenated part streams must decode byte-exact for chunk={chunk}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// After the first stream error, every further poll must keep failing.
|
||||
/// Without the sticky poison a retrying caller would restart at the header
|
||||
/// phase and read the truncated tail as a clean EOF — converting a hard
|
||||
/// error into a silently short body.
|
||||
#[tokio::test]
|
||||
async fn test_decompress_reader_error_is_sticky() {
|
||||
let data = patterned_payload(32 * 1024, 7);
|
||||
let mut compress_reader = CompressReader::with_block_size(Cursor::new(data), 8192, CompressionAlgorithm::default());
|
||||
let mut compressed = Vec::new();
|
||||
compress_reader.read_to_end(&mut compressed).await.unwrap();
|
||||
compressed.truncate(compressed.len() - 3);
|
||||
|
||||
let mut decompress_reader = DecompressReader::new(Cursor::new(compressed), CompressionAlgorithm::default());
|
||||
let mut out = Vec::new();
|
||||
let first = decompress_reader
|
||||
.read_to_end(&mut out)
|
||||
.await
|
||||
.expect_err("truncated payload must error");
|
||||
assert_eq!(first.kind(), std::io::ErrorKind::UnexpectedEof);
|
||||
|
||||
let mut retry = Vec::new();
|
||||
let second = decompress_reader
|
||||
.read_to_end(&mut retry)
|
||||
.await
|
||||
.expect_err("a poll after the first error must not turn into a clean EOF");
|
||||
assert_eq!(second.kind(), std::io::ErrorKind::InvalidData);
|
||||
assert!(retry.is_empty(), "no bytes may be produced after the stream failed");
|
||||
}
|
||||
|
||||
/// A stream cut off in the middle of a block payload must fail with a clean
|
||||
/// UnexpectedEof instead of decoding a short buffer.
|
||||
#[tokio::test]
|
||||
async fn test_decompress_reader_truncated_payload_is_unexpected_eof() {
|
||||
let data = patterned_payload(32 * 1024, 7);
|
||||
let mut compress_reader = CompressReader::with_block_size(Cursor::new(data), 8192, CompressionAlgorithm::default());
|
||||
let mut compressed = Vec::new();
|
||||
compress_reader.read_to_end(&mut compressed).await.unwrap();
|
||||
|
||||
compressed.truncate(compressed.len() - 3);
|
||||
let mut decompress_reader = DecompressReader::new(Cursor::new(compressed), CompressionAlgorithm::default());
|
||||
let mut out = Vec::new();
|
||||
let err = decompress_reader
|
||||
.read_to_end(&mut out)
|
||||
.await
|
||||
.expect_err("truncated payload must error");
|
||||
assert_eq!(err.kind(), std::io::ErrorKind::UnexpectedEof);
|
||||
}
|
||||
|
||||
/// A stream cut off in the middle of a block header must fail with a clean
|
||||
/// UnexpectedEof instead of parsing a garbage header.
|
||||
#[tokio::test]
|
||||
async fn test_decompress_reader_truncated_header_is_unexpected_eof() {
|
||||
let data = patterned_payload(12 * 1024, 7);
|
||||
let mut compress_reader = CompressReader::with_block_size(Cursor::new(data), 8192, CompressionAlgorithm::default());
|
||||
let mut compressed = Vec::new();
|
||||
compress_reader.read_to_end(&mut compressed).await.unwrap();
|
||||
|
||||
// Keep the first full block plus 3 bytes of the next header.
|
||||
let ln = (compressed[1] as usize) | ((compressed[2] as usize) << 8) | ((compressed[3] as usize) << 16);
|
||||
let first_block_end = 8 + ln;
|
||||
assert!(compressed.len() > first_block_end, "fixture must contain more than one block");
|
||||
compressed.truncate(first_block_end + 3);
|
||||
|
||||
let mut decompress_reader = DecompressReader::new(Cursor::new(compressed), CompressionAlgorithm::default());
|
||||
let mut out = Vec::new();
|
||||
let err = decompress_reader
|
||||
.read_to_end(&mut out)
|
||||
.await
|
||||
.expect_err("truncated header must error");
|
||||
assert_eq!(err.kind(), std::io::ErrorKind::UnexpectedEof);
|
||||
}
|
||||
|
||||
// Regression: a corrupted block whose 24-bit length field is < 16 must not panic.
|
||||
// Header layout (HEADER_LEN = 8): [type, len_lo, len_mid, len_hi, crc0..crc3], then `len`
|
||||
// bytes of block body. Pre-fix, poll_read sliced `compressed_buf[0..16]` unconditionally,
|
||||
@@ -518,6 +749,85 @@ mod tests {
|
||||
assert_eq!(res.unwrap_err().kind(), std::io::ErrorKind::InvalidData);
|
||||
}
|
||||
|
||||
// Header-level fail-closed matrix, built by hand so the decoder is exercised against bytes no
|
||||
// encoder in this crate can produce. Header layout (HEADER_LEN = 8):
|
||||
// [type, len_lo, len_mid, len_hi, crc0..crc3], then `len` body bytes = uvarint(plain_len) + data.
|
||||
#[tokio::test]
|
||||
async fn test_decompress_reader_header_validation_matrix() {
|
||||
// Build a block whose body is `uvarint(plain.len()) + plain` (i.e. the
|
||||
// COMPRESS_TYPE_UNCOMPRESSED shape), with the header CRC taken over the plaintext exactly
|
||||
// like the production writer does.
|
||||
fn build_raw_block(typ: u8, plain: &[u8], len_override: Option<usize>) -> Vec<u8> {
|
||||
let crc = {
|
||||
let mut hasher = crc_fast::Digest::new(crc_fast::CrcAlgorithm::Crc32IsoHdlc);
|
||||
hasher.update(plain);
|
||||
hasher.finalize() as u32
|
||||
};
|
||||
let mut uvarint_buf = [0u8; 10];
|
||||
let int_len = put_uvarint(&mut uvarint_buf[..], plain.len() as u64);
|
||||
let body_len = int_len + plain.len();
|
||||
let len = len_override.unwrap_or(body_len);
|
||||
|
||||
let mut out = Vec::with_capacity(HEADER_LEN + body_len);
|
||||
out.push(typ);
|
||||
out.push((len & 0xFF) as u8);
|
||||
out.push(((len >> 8) & 0xFF) as u8);
|
||||
out.push(((len >> 16) & 0xFF) as u8);
|
||||
out.extend_from_slice(&crc.to_le_bytes());
|
||||
out.extend_from_slice(&uvarint_buf[..int_len]);
|
||||
out.extend_from_slice(plain);
|
||||
out
|
||||
}
|
||||
|
||||
let plain = b"uncompressed passthrough payload";
|
||||
|
||||
// (a) A well-formed uncompressed block decodes to the plaintext verbatim.
|
||||
let mut out = Vec::new();
|
||||
DecompressReader::new(
|
||||
Cursor::new(build_raw_block(COMPRESS_TYPE_UNCOMPRESSED, plain, None)),
|
||||
CompressionAlgorithm::default(),
|
||||
)
|
||||
.read_to_end(&mut out)
|
||||
.await
|
||||
.expect("a well-formed uncompressed block must decode");
|
||||
assert_eq!(out.as_slice(), plain.as_slice());
|
||||
|
||||
// (b) An unknown block type must be rejected instead of being treated as passthrough.
|
||||
let mut out = Vec::new();
|
||||
let err = DecompressReader::new(Cursor::new(build_raw_block(0x7E, plain, None)), CompressionAlgorithm::default())
|
||||
.read_to_end(&mut out)
|
||||
.await
|
||||
.expect_err("unknown compression type must error");
|
||||
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
|
||||
assert!(err.to_string().contains("Unknown compression type"), "got: {err}");
|
||||
|
||||
// (c) A zero-length block would stall the decoder, so it must be rejected up front.
|
||||
let mut out = Vec::new();
|
||||
let err = DecompressReader::new(
|
||||
Cursor::new(build_raw_block(COMPRESS_TYPE_UNCOMPRESSED, plain, Some(0))),
|
||||
CompressionAlgorithm::default(),
|
||||
)
|
||||
.read_to_end(&mut out)
|
||||
.await
|
||||
.expect_err("zero-length block must error");
|
||||
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
|
||||
assert!(err.to_string().contains("Invalid compressed block length"), "got: {err}");
|
||||
|
||||
// (d) A block that decodes to zero plaintext bytes must be rejected: the
|
||||
// writer never emits empty blocks, and an empty decode surfacing as
|
||||
// Ready(Ok) with no bytes would read as EOF and silently truncate.
|
||||
let mut out = Vec::new();
|
||||
let err = DecompressReader::new(
|
||||
Cursor::new(build_raw_block(COMPRESS_TYPE_UNCOMPRESSED, b"", None)),
|
||||
CompressionAlgorithm::default(),
|
||||
)
|
||||
.read_to_end(&mut out)
|
||||
.await
|
||||
.expect_err("empty block must error");
|
||||
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
|
||||
assert!(err.to_string().contains("Empty compressed block"), "got: {err}");
|
||||
}
|
||||
|
||||
// Directly exercises the length-prefix guard: an unterminated varint (all continuation bytes)
|
||||
// makes `uvarint` return 0, which must be rejected as an invalid length prefix.
|
||||
#[tokio::test]
|
||||
|
||||
@@ -24,12 +24,17 @@ pin_project! {
|
||||
#[pin]
|
||||
pub inner: R,
|
||||
remaining: i64,
|
||||
scratch: Vec<u8>,
|
||||
}
|
||||
}
|
||||
|
||||
impl<R> HardLimitReader<R> {
|
||||
pub fn new(inner: R, limit: i64) -> Self {
|
||||
HardLimitReader { inner, remaining: limit }
|
||||
HardLimitReader {
|
||||
inner,
|
||||
remaining: limit,
|
||||
scratch: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,19 +42,21 @@ impl<R> AsyncRead for HardLimitReader<R>
|
||||
where
|
||||
R: AsyncRead,
|
||||
{
|
||||
fn poll_read(mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll<Result<()>> {
|
||||
if self.remaining < 0 {
|
||||
fn poll_read(self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll<Result<()>> {
|
||||
let mut this = self.project();
|
||||
if *this.remaining < 0 {
|
||||
return Poll::Ready(Err(Error::other("input provided more bytes than specified")));
|
||||
}
|
||||
let original_filled = buf.filled().len();
|
||||
if self.remaining == 0 {
|
||||
if buf.remaining() == 0 {
|
||||
return Poll::Ready(Ok(()));
|
||||
}
|
||||
if *this.remaining == 0 {
|
||||
let mut discard = [0u8; 8192];
|
||||
let mut discard_buf = ReadBuf::new(&mut discard);
|
||||
return match self.as_mut().project().inner.poll_read(cx, &mut discard_buf) {
|
||||
return match this.inner.as_mut().poll_read(cx, &mut discard_buf) {
|
||||
Poll::Pending => Poll::Pending,
|
||||
Poll::Ready(Ok(())) => {
|
||||
if discard_buf.filled().is_empty() {
|
||||
debug_assert_eq!(buf.filled().len(), original_filled);
|
||||
Poll::Ready(Ok(()))
|
||||
} else {
|
||||
Poll::Ready(Err(Error::other("input provided more bytes than specified")))
|
||||
@@ -58,30 +65,46 @@ where
|
||||
Poll::Ready(Err(err)) => Poll::Ready(Err(err)),
|
||||
};
|
||||
}
|
||||
// Save the initial length
|
||||
let before = original_filled;
|
||||
|
||||
// Poll the inner reader
|
||||
let this = self.as_mut().project();
|
||||
let poll = this.inner.poll_read(cx, buf);
|
||||
|
||||
if let Poll::Ready(Ok(())) = &poll {
|
||||
let after = buf.filled().len();
|
||||
let read = (after - before) as i64;
|
||||
if read == 0 && *this.remaining > 0 {
|
||||
return Poll::Ready(Err(Error::new(
|
||||
std::io::ErrorKind::UnexpectedEof,
|
||||
IncompleteBody {
|
||||
remaining: *this.remaining,
|
||||
},
|
||||
)));
|
||||
let remaining = match usize::try_from(*this.remaining) {
|
||||
Ok(remaining) => remaining,
|
||||
Err(_) => usize::MAX,
|
||||
};
|
||||
let allowed = remaining.min(buf.remaining());
|
||||
let read = if allowed == buf.remaining() {
|
||||
let before = buf.filled().len();
|
||||
match this.inner.as_mut().poll_read(cx, buf) {
|
||||
Poll::Pending => return Poll::Pending,
|
||||
Poll::Ready(Err(err)) => return Poll::Ready(Err(err)),
|
||||
Poll::Ready(Ok(())) => buf.filled().len() - before,
|
||||
}
|
||||
*this.remaining -= read;
|
||||
if *this.remaining < 0 {
|
||||
return Poll::Ready(Err(Error::other("input provided more bytes than specified")));
|
||||
} else {
|
||||
this.scratch.resize(allowed, 0);
|
||||
let mut scratch_buf = ReadBuf::new(&mut this.scratch[..allowed]);
|
||||
match this.inner.as_mut().poll_read(cx, &mut scratch_buf) {
|
||||
Poll::Pending => return Poll::Pending,
|
||||
Poll::Ready(Err(err)) => return Poll::Ready(Err(err)),
|
||||
Poll::Ready(Ok(())) => {
|
||||
let read = scratch_buf.filled().len();
|
||||
buf.put_slice(scratch_buf.filled());
|
||||
read
|
||||
}
|
||||
}
|
||||
};
|
||||
if read == 0 {
|
||||
return Poll::Ready(Err(Error::new(
|
||||
std::io::ErrorKind::UnexpectedEof,
|
||||
IncompleteBody {
|
||||
remaining: *this.remaining,
|
||||
},
|
||||
)));
|
||||
}
|
||||
poll
|
||||
let read = match i64::try_from(read) {
|
||||
Ok(read) => read,
|
||||
Err(_) => return Poll::Ready(Err(Error::other("read count exceeds i64::MAX"))),
|
||||
};
|
||||
*this.remaining -= read;
|
||||
Poll::Ready(Ok(()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,7 +163,12 @@ mod tests {
|
||||
assert!(err.is_some());
|
||||
|
||||
let err = err.unwrap();
|
||||
assert_eq!(err.kind(), std::io::ErrorKind::Other);
|
||||
assert_eq!(err.kind(), std::io::ErrorKind::UnexpectedEof);
|
||||
assert!(
|
||||
err.get_ref()
|
||||
.and_then(|source| source.downcast_ref::<std::io::Error>())
|
||||
.is_some_and(|source| source.to_string().contains("more bytes than specified"))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -155,6 +183,17 @@ mod tests {
|
||||
assert_eq!(&buf, data);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_hardlimit_reader_zero_capacity_read_does_not_consume_input() {
|
||||
let mut reader = HardLimitReader::new(BufReader::new(&b"abc"[..]), 3);
|
||||
let mut empty = [];
|
||||
|
||||
assert_eq!(reader.read(&mut empty).await.expect("zero-capacity read should succeed"), 0);
|
||||
let mut out = Vec::new();
|
||||
reader.read_to_end(&mut out).await.expect("input should remain readable");
|
||||
assert_eq!(out, b"abc");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_hardlimit_reader_short_input_returns_unexpected_eof() {
|
||||
let data = b"abc";
|
||||
@@ -195,4 +234,18 @@ mod tests {
|
||||
assert_eq!(err.kind(), std::io::ErrorKind::Other);
|
||||
assert!(err.to_string().contains("more bytes than specified"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_hardlimit_reader_caps_each_read_before_reporting_extra_bytes() {
|
||||
let mut reader = HardLimitReader::new(BufReader::new(&b"abcdef"[..]), 3);
|
||||
let mut out = Vec::new();
|
||||
|
||||
let err = reader
|
||||
.read_to_end(&mut out)
|
||||
.await
|
||||
.expect_err("bytes beyond the declared limit must be rejected");
|
||||
|
||||
assert_eq!(out, b"abc");
|
||||
assert!(err.to_string().contains("more bytes than specified"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,6 +138,12 @@ impl std::fmt::Display for InternodeHttpErrorKind {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(thiserror::Error, Debug, Clone, Copy, Eq, PartialEq)]
|
||||
#[error("internode body stalled for {timeout:?}")]
|
||||
pub struct BodyStalled {
|
||||
pub timeout: Duration,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Eq, PartialEq)]
|
||||
pub struct InternodeHttpRequestContext {
|
||||
method: String,
|
||||
@@ -271,6 +277,10 @@ pub fn internode_http_timeout_error(method: &Method, url: &str) -> io::Error {
|
||||
internode_kind_error(method, url, internode_rpc_operation(url), InternodeHttpErrorKind::ConnectTimeout)
|
||||
}
|
||||
|
||||
fn body_stalled_error(stall_timeout: Duration) -> io::Error {
|
||||
Error::new(io::ErrorKind::TimedOut, BodyStalled { timeout: stall_timeout })
|
||||
}
|
||||
|
||||
/// Clone an internode HTTP I/O error while retaining its structured classification.
|
||||
///
|
||||
/// The underlying transport source is intentionally omitted because it is not
|
||||
@@ -1085,10 +1095,7 @@ impl AsyncRead for HttpReader {
|
||||
);
|
||||
record_internode_stall_timeout(*this.track_internode_metrics, *this.internode_operation);
|
||||
record_internode_error(*this.track_internode_metrics, *this.internode_operation);
|
||||
Poll::Ready(Err(Error::new(
|
||||
io::ErrorKind::TimedOut,
|
||||
"HttpReader stall timeout: no data received before deadline",
|
||||
)))
|
||||
Poll::Ready(Err(body_stalled_error(stall_timeout)))
|
||||
} else {
|
||||
Poll::Pending
|
||||
}
|
||||
@@ -1217,10 +1224,7 @@ impl ChunkReader for HttpChunkReader {
|
||||
);
|
||||
record_internode_stall_timeout(*this.track_internode_metrics, *this.internode_operation);
|
||||
record_internode_error(*this.track_internode_metrics, *this.internode_operation);
|
||||
return Poll::Ready(Err(Error::new(
|
||||
io::ErrorKind::TimedOut,
|
||||
"HttpReader stall timeout: no data received before deadline",
|
||||
)));
|
||||
return Poll::Ready(Err(body_stalled_error(stall_timeout)));
|
||||
}
|
||||
return Poll::Pending;
|
||||
}
|
||||
@@ -2379,6 +2383,46 @@ mod tests {
|
||||
Err(err) => err,
|
||||
};
|
||||
assert_eq!(err.kind(), io::ErrorKind::TimedOut);
|
||||
let stalled = err
|
||||
.get_ref()
|
||||
.and_then(|source| source.downcast_ref::<BodyStalled>())
|
||||
.expect("stall timeout should retain typed body-stalled source");
|
||||
assert_eq!(stalled.timeout, Duration::from_millis(20));
|
||||
|
||||
handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn http_chunk_reader_stall_timeout_retains_typed_source() {
|
||||
let state = TestState::default();
|
||||
let Some((base_url, handle)) = start_test_server(state).await else {
|
||||
return;
|
||||
};
|
||||
let url = base_url.replace("/stream", "/stall");
|
||||
let mut reader =
|
||||
HttpChunkReader::new_with_stall_timeout(url, Method::GET, HeaderMap::new(), None, Some(Duration::from_millis(20)))
|
||||
.await
|
||||
.expect("chunk reader should open");
|
||||
|
||||
let first = std::future::poll_fn(|cx| Pin::new(&mut reader).poll_read_chunk(cx, 64))
|
||||
.await
|
||||
.expect("initial body chunk should arrive")
|
||||
.expect("initial body chunk should not be EOF");
|
||||
assert_eq!(first, b"hello"[..]);
|
||||
|
||||
let err = tokio::time::timeout(
|
||||
Duration::from_secs(1),
|
||||
std::future::poll_fn(|cx| Pin::new(&mut reader).poll_read_chunk(cx, 64)),
|
||||
)
|
||||
.await
|
||||
.expect("stall timeout should wake chunk reader")
|
||||
.expect_err("chunk reader should return a timeout error");
|
||||
assert_eq!(err.kind(), io::ErrorKind::TimedOut);
|
||||
let stalled = err
|
||||
.get_ref()
|
||||
.and_then(|source| source.downcast_ref::<BodyStalled>())
|
||||
.expect("chunk stall timeout should retain typed body-stalled source");
|
||||
assert_eq!(stalled.timeout, Duration::from_millis(20));
|
||||
|
||||
handle.abort();
|
||||
}
|
||||
|
||||
@@ -102,7 +102,7 @@ bytes.workspace = true
|
||||
hex-simd.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tracing-subscriber = { workspace = true, features = ["env-filter", "time"] }
|
||||
tracing-subscriber = { workspace = true, features = ["json", "env-filter", "time"] }
|
||||
serial_test = { workspace = true }
|
||||
temp-env = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
|
||||
@@ -65,6 +65,7 @@ const LOG_SUBSYSTEM_FOLDER: &str = "folder";
|
||||
const LOG_SUBSYSTEM_LIFECYCLE: &str = "lifecycle";
|
||||
const LOG_SUBSYSTEM_HEAL: &str = "heal";
|
||||
const EVENT_SCANNER_FOLDER_STATE: &str = "scanner_folder_state";
|
||||
const EVENT_SCANNER_METADATA_CORRUPT: &str = "scanner_metadata_corrupt";
|
||||
const EVENT_SCANNER_LIFECYCLE_ACTION: &str = "scanner_lifecycle_action";
|
||||
const EVENT_SCANNER_HEAL_ADMISSION: &str = "scanner_heal_admission";
|
||||
const EVENT_SCANNER_ALERT_STATE: &str = "scanner_alert_state";
|
||||
@@ -2154,17 +2155,34 @@ impl FolderScanner {
|
||||
self.record_failed(&item.path);
|
||||
|
||||
if should_log_failed_object(into.failed_objects) {
|
||||
warn!(
|
||||
target: "rustfs::scanner::folder",
|
||||
event = EVENT_SCANNER_FOLDER_STATE,
|
||||
component = LOG_COMPONENT_SCANNER,
|
||||
subsystem = LOG_SUBSYSTEM_FOLDER,
|
||||
path = %item.path,
|
||||
failed_objects = into.failed_objects,
|
||||
state = "get_size_failed",
|
||||
error = %e,
|
||||
"Scanner folder failed to get object size"
|
||||
);
|
||||
if let GetSizeFailureAction::HealMetadata { object } = &failure_action {
|
||||
error!(
|
||||
target: "rustfs::scanner::folder",
|
||||
event = EVENT_SCANNER_METADATA_CORRUPT,
|
||||
component = LOG_COMPONENT_SCANNER,
|
||||
subsystem = LOG_SUBSYSTEM_FOLDER,
|
||||
drive = %self.local_disk.path().display(),
|
||||
bucket = %item.bucket,
|
||||
object = %object,
|
||||
metadata_path = %item.path,
|
||||
failed_objects = into.failed_objects,
|
||||
state = "metadata_corrupt",
|
||||
error = %e,
|
||||
"Scanner detected corrupt object metadata"
|
||||
);
|
||||
} else {
|
||||
warn!(
|
||||
target: "rustfs::scanner::folder",
|
||||
event = EVENT_SCANNER_FOLDER_STATE,
|
||||
component = LOG_COMPONENT_SCANNER,
|
||||
subsystem = LOG_SUBSYSTEM_FOLDER,
|
||||
path = %item.path,
|
||||
failed_objects = into.failed_objects,
|
||||
state = "get_size_failed",
|
||||
error = %e,
|
||||
"Scanner folder failed to get object size"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3054,12 +3072,59 @@ mod tests {
|
||||
use crate::{DiskOption, Endpoint, STORAGE_FORMAT_FILE, TierStats, new_disk, storageclass};
|
||||
use rustfs_filemeta::{FileInfo, FileMeta};
|
||||
use serial_test::serial;
|
||||
use std::io::Write;
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::{PermissionsExt, symlink};
|
||||
use std::sync::Mutex;
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
use temp_env::{with_var, with_var_unset};
|
||||
use tracing_subscriber::fmt::MakeWriter;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct CapturedLogs {
|
||||
buffer: Arc<Mutex<Vec<u8>>>,
|
||||
}
|
||||
|
||||
struct CapturedLogWriter {
|
||||
buffer: Arc<Mutex<Vec<u8>>>,
|
||||
}
|
||||
|
||||
impl CapturedLogs {
|
||||
fn contents(&self) -> String {
|
||||
let buffer = self
|
||||
.buffer
|
||||
.lock()
|
||||
.expect("captured logs mutex should not be poisoned")
|
||||
.clone();
|
||||
String::from_utf8(buffer).expect("captured logs should be valid UTF-8")
|
||||
}
|
||||
}
|
||||
|
||||
impl Write for CapturedLogWriter {
|
||||
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
||||
self.buffer
|
||||
.lock()
|
||||
.expect("captured logs mutex should not be poisoned")
|
||||
.extend_from_slice(buf);
|
||||
Ok(buf.len())
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> MakeWriter<'a> for CapturedLogs {
|
||||
type Writer = CapturedLogWriter;
|
||||
|
||||
fn make_writer(&'a self) -> Self::Writer {
|
||||
CapturedLogWriter {
|
||||
buffer: Arc::clone(&self.buffer),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scanner_size_summary_application_saturates_usage_counters() {
|
||||
let target = "arn:minio:replication::target".to_string();
|
||||
@@ -4542,9 +4607,19 @@ mod tests {
|
||||
assert!(budget.entries_visited() >= 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
#[serial]
|
||||
async fn test_scan_folder_corrupt_xl_meta_stops_erasure_data_dir_descent() {
|
||||
let logs = CapturedLogs::default();
|
||||
let subscriber = tracing_subscriber::fmt()
|
||||
.json()
|
||||
.with_max_level(tracing::Level::ERROR)
|
||||
.with_writer(logs.clone())
|
||||
.with_ansi(false)
|
||||
.without_time()
|
||||
.finish();
|
||||
let _subscriber_guard = tracing::subscriber::set_default(subscriber);
|
||||
|
||||
let (mut scanner, temp_dir) = build_test_scanner().await;
|
||||
let _guard = TestGuard::new(60, 100, &mut scanner, temp_dir.clone());
|
||||
|
||||
@@ -4596,6 +4671,30 @@ mod tests {
|
||||
assert!(!budget.budget_elapsed());
|
||||
assert_eq!(budget.reason(), None);
|
||||
|
||||
let captured = logs.contents();
|
||||
assert!(
|
||||
!captured.contains("failed to check XL2 v1 format"),
|
||||
"the context-free filemeta parser error must not be emitted"
|
||||
);
|
||||
let events = captured
|
||||
.lines()
|
||||
.map(|line| serde_json::from_str::<serde_json::Value>(line).expect("captured scanner log should be valid JSON"))
|
||||
.filter(|line| line["fields"]["event"] == EVENT_SCANNER_METADATA_CORRUPT)
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
events.len(),
|
||||
1,
|
||||
"one corrupt metadata observation must emit one scanner-owned diagnostic event"
|
||||
);
|
||||
let fields = &events[0]["fields"];
|
||||
assert_eq!(fields["component"], LOG_COMPONENT_SCANNER);
|
||||
assert_eq!(fields["subsystem"], LOG_SUBSYSTEM_FOLDER);
|
||||
assert_eq!(fields["drive"], temp_dir.to_string_lossy().as_ref());
|
||||
assert_eq!(fields["bucket"], "bucket");
|
||||
assert_eq!(fields["object"], "object");
|
||||
assert_eq!(fields["metadata_path"], metadata_path.to_string_lossy().as_ref());
|
||||
assert_eq!(fields["state"], "metadata_corrupt");
|
||||
|
||||
let retry_budget = ScannerCycleBudget::new_with_progress_tracking(
|
||||
&parent,
|
||||
crate::scanner_budget::ScannerCycleBudgetConfig {
|
||||
|
||||
@@ -3849,17 +3849,6 @@ impl ScannerIODisk for Disk {
|
||||
let fivs = match meta.get_file_info_versions(item.bucket.as_str(), item.object_path().as_str(), false) {
|
||||
Ok(versions) => versions,
|
||||
Err(e) => {
|
||||
error!(
|
||||
target: "rustfs::scanner::io",
|
||||
event = EVENT_SCANNER_DISK_BUCKET_STATE,
|
||||
component = LOG_COMPONENT_SCANNER,
|
||||
subsystem = LOG_SUBSYSTEM_IO,
|
||||
bucket = %item.bucket,
|
||||
object = %item.object_path(),
|
||||
state = "file_info_versions_failed",
|
||||
error = %e,
|
||||
"Scanner disk bucket failed to resolve file info versions"
|
||||
);
|
||||
return Err(scanner_metadata_corrupt_error(
|
||||
format!("failed to resolve file info versions: {e}"),
|
||||
&item.bucket,
|
||||
@@ -4262,6 +4251,7 @@ mod tests {
|
||||
.delete_bucket(&bucket, &DeleteBucketOptions::default())
|
||||
.await
|
||||
.expect("bucket should be removed from the first pool only");
|
||||
init_bucket_metadata_sys_for_scanner_tests(store.clone()).await;
|
||||
|
||||
let ctx = CancellationToken::new();
|
||||
let budget = ScannerCycleBudget::new(&ctx, ScannerCycleBudgetConfig::default());
|
||||
|
||||
@@ -659,9 +659,6 @@ mod test {
|
||||
// Port should be in valid range (u16 max is always <= 65535)
|
||||
assert!(port1 > 0);
|
||||
assert!(port2 > 0);
|
||||
|
||||
// Different calls should typically return different ports
|
||||
assert_ne!(port1, port2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -33,6 +33,7 @@ for later deletion.
|
||||
- `tonic-013-status-render` peer RPC failure classification: internode failures that reach a node only as text (a peer's error_info payload, a status flattened through format!) are classified by matching the rendering of an Unavailable gRPC status. Releases up to 1.0.0-alpha.38 shipped tonic 0.13, which rendered that status as "status: Unavailable, message: ..."; tonic 0.14 renders it as "code: 'The service is currently unavailable', message: ...". Both forms are matched so an older peer's relayed text still marks an unreachable peer offline. Remove the tonic 0.13 form after the minimum supported RustFS peer version ships tonic 0.14 or later.
|
||||
- `rustfs-5063` pre-beta.9 Local KMS recovery: persisted Local KMS configs from beta.8 and earlier predate the explicit insecure-development flag, and encrypted key files use the legacy SHA-256 KDF. Remove the config fallback after supported upgrades have rewritten or explicitly resaved all pre-beta.9 configs with the development-default field, and remove the legacy KDF after supported upgrades have rewritten all pre-beta.9 Local KMS key files with explicit at-rest protection.
|
||||
- `sse-local-dek-json-v1` legacy local SSE DEK decoding: releases before the JSON envelope wrote wrapped DEKs as `base64(nonce):base64(ciphertext)`, so readers retain that decoder while all new writes use the versioned JSON envelope. Remove the colon decoder after the minimum supported direct-upgrade release writes JSON envelopes and migration tooling has rewritten every retained legacy object.
|
||||
- `multipart-compression-default-off-window` staged multipart disk-compression rollout: releases before the resumable legacy decompressor fail transient reads of compressed objects under mid-payload suspension, so multipart uploads advertise the compression marker only when RUSTFS_COMPRESSION_MULTIPART_ENABLED is set in addition to RUSTFS_COMPRESSION_ENABLED, keeping rolling upgrades from creating new compressed multipart objects while pre-fix nodes may still serve reads. Flip the default to enabled (and retire the extra switch) after the minimum supported direct-upgrade release ships the resumable decompressor.
|
||||
|
||||
## Review Checklist
|
||||
|
||||
|
||||
@@ -111,7 +111,7 @@ inventory. Generic function-local names such as `CACHE`, `LOCK`, `INIT`, and
|
||||
| `GET_OBJECT_BUFFER_THRESHOLD_WARNED`, `GET_READER_STREAM_BUFFER_SIZE_OVERRIDE`, function-local `ENABLED`, `OBJECT_SEEK_SUPPORT_THRESHOLD`, `OBJECT_SEEK_SUPPORT_CONCURRENCY_THRESHOLDS` | `rustfs/src/app/object_usecase.rs` | Cache or constant / owner-local cache | Object GET/seek tuning caches and warning guards stay private to object usecase helpers. |
|
||||
| `SUPPORTED_HEADERS` | `rustfs/src/storage/options.rs` | Cache or constant / owner-local constant | Supported-header lookup state stays private to storage option parsing. |
|
||||
| `AUDIT_TARGET_SPECS`, `NOTIFICATION_TARGET_SPECS` | `rustfs/src/admin/handlers/audit.rs`, `rustfs/src/admin/handlers/event.rs`, `rustfs/src/admin/handlers/plugins_instances.rs` | Cache or constant / owner-local constant | Admin target descriptor tables stay private to their handler owners. |
|
||||
| `SITE_REPLICATION_PEER_CLIENT`, `SITE_REPLICATION_STATE_LOCK` | `rustfs/src/admin/handlers/site_replication.rs` | Process-global owner-local cache / guard | Site-replication peer client cache and state lock stay private to site-replication handlers. |
|
||||
| `SITE_REPLICATION_PEER_CLIENT` | `rustfs/src/admin/handlers/site_replication.rs` | Process-global owner-local cache | Site-replication peer client cache stays private to site-replication handlers. The state RMW transaction holds no process-local mutex — see `rustfs/src/admin/site_replication_state.rs`. |
|
||||
| `AUDIT_MODULE_ENABLED`, `NOTIFY_MODULE_ENABLED`, `PERSISTED_NOTIFY_MODULE_ENABLED`, `PERSISTED_AUDIT_MODULE_ENABLED`, `PERSISTED_MODULE_SWITCH_CONFIGURED` | `rustfs/src/server/audit.rs`, `rustfs/src/server/event.rs`, `rustfs/src/server/module_switch.rs` | Process-global owner-local toggles | Audit/notify module snapshots stay private to the server module switch owners. |
|
||||
| `DELETE_TAIL_TOTAL`, `DELETE_CLEANUP_TOTAL`, `DELETE_REPLICATION_TOTAL`, `DELETE_NOTIFY_TOTAL` | `rustfs/src/delete_tail_activity.rs` | Process-global owner-local counters | Delete-tail activity counters stay private behind delete-tail activity helpers. |
|
||||
| `EMBEDDED_SERVER_STARTED` | `rustfs/src/startup_lifecycle.rs` | Process-global owner-local guard | Embedded startup single-start protection stays private to startup lifecycle. |
|
||||
|
||||
@@ -61,17 +61,17 @@ catalog extension.
|
||||
|
||||
| Area | Status | Covered behavior |
|
||||
|---|---|---|
|
||||
| Catalog config | Supported | `GET /v1/config` advertises RustFS catalog defaults and route capabilities. |
|
||||
| Catalog config | Supported | `GET /v1/config` advertises RustFS catalog defaults and only the supported OpenAPI REST paths in `endpoints`. RustFS administration, maintenance, migration, diagnostics, refs, and metadata-location extensions remain available but are not presented as standard Iceberg REST endpoints. |
|
||||
| Table bucket discovery | Supported | `PUT` and `GET /v1/buckets/{warehouse}` enable and inspect table bucket state. |
|
||||
| Namespaces | Supported | Create, list, load, existence check, and drop namespace routes are registered on both catalog prefixes. List responses support Iceberg REST `pageSize`/`pageToken` pagination with context-bound tokens and bounded catalog-store reads. Namespace identifiers are limited to 512 ASCII characters so persisted paths and stateless continuation tokens remain bounded. |
|
||||
| Tables | Supported | Create, register, list, load, existence check, commit, metadata-location get/update, and drop table routes are registered on both catalog prefixes. Table and view listings support Iceberg REST `pageSize`/`pageToken` pagination with context-bound tokens and bounded catalog-store reads. |
|
||||
| Commit CAS | Supported | Single-table commits validate base metadata, expected version token, referenced object existence, warehouse scope, and Iceberg commit requirements before advancing the current metadata pointer. Standard commits preserve the normal commit-token file name and use an immutable-table-scoped fallback when rename followed by source-name reuse would otherwise collide at the same generation and commit ID. |
|
||||
| Tables | Supported | Create, register, list, load, existence check, commit, metadata-location get/update, and drop table routes are registered on both catalog prefixes. Table and view listings support Iceberg REST `pageSize`/`pageToken` pagination with context-bound tokens and bounded catalog-store reads. Commit identifiers must match the URL resource; unknown requirements, updates, and snapshot operations fail as bad requests; staged create, register overwrite, purge-on-drop, and v3-only encryption-key updates return an explicit unsupported-operation response. Standard statistics, partition statistics, and schema/spec cleanup updates are accepted. |
|
||||
| Commit CAS | Supported | Single-table commits validate base metadata, expected version token, referenced object existence, warehouse scope, and Iceberg commit requirements before advancing the current metadata pointer. Externally supplied metadata transitions preserve monotonic column, partition, and sequence assignment watermarks and immutable definitions for retained schemas, partition specs, sort orders, and snapshots. Standard commits preserve the normal commit-token file name and use an immutable-table-scoped fallback when rename followed by source-name reuse would otherwise collide at the same generation and commit ID. The catalog does not advertise `idempotency-key-lifetime`; clients must treat standard mutation-wide `Idempotency-Key` semantics as unsupported. |
|
||||
| Commit recovery | Supported | Commit log, idempotency lookup, diagnostics, and recovery routes expose staged/finalization gaps and repair safe idempotency gaps without moving the table pointer. |
|
||||
| Snapshot refs | Supported | Refs can be listed, created or replaced, and deleted through catalog commits. `main` is protected and refs with explicit retention require forced delete. |
|
||||
| Iceberg views | Supported | Basic create, list, load, replace, existence check, and drop routes persist view metadata with view-scoped authorization. |
|
||||
| Table credentials endpoint | Supported | Returns an empty `storage-credentials` list by default. Returns table-scoped temporary credentials only when credential vending is enabled. |
|
||||
| Iceberg views | Supported | Basic create, list, load, replace, existence check, and drop routes persist view metadata with view-scoped authorization. Replace identifiers must match the URL resource, `schema-id: -1` resolves to the last added schema, one commit timestamp is used consistently, and only Iceberg view format version 1 is accepted. |
|
||||
| Table credentials endpoint | Supported | Returns an empty `storage-credentials` list by default. Returns table-scoped temporary credentials only when credential vending is enabled. Credential responses set `Cache-Control: no-store, private`, `Pragma: no-cache`, and `Expires: 0`. |
|
||||
| Catalog diagnostics and export | Supported | Exposes recovery state, consistency state, backing manifest, recoverable commit-log WAL state, strong backing migration target, single-active-writer policy, and scale validation matrix. |
|
||||
| Catalog import and rollback | Supported | Import/register and rollback use catalog validation and commit paths rather than direct pointer mutation. |
|
||||
| Catalog import and rollback | Supported | Import/register and online rollback use catalog validation and commit paths rather than direct pointer mutation. Online rollback accepts only a forward-safe metadata target that preserves assignment watermarks and retained definitions. Restoring an older target that lowers those watermarks is an offline disaster-recovery operation and requires every writer to be stopped. |
|
||||
| External catalog bridge | Supported operator path | Operator-supplied metadata pointer sync/import is supported for external catalog identity boundaries. Online vendor SDK polling and policy mirroring are not claimed. |
|
||||
| Multi-table transactions | Not claimed | RustFS currently claims single-table commit atomicity only. |
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
{
|
||||
default = rustPlatform.buildRustPackage {
|
||||
pname = "rustfs";
|
||||
version = "1.0.0-rc.1";
|
||||
version = "1.0.0-rc.2";
|
||||
|
||||
src = ./.;
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ apiVersion: v2
|
||||
name: rustfs
|
||||
description: RustFS helm chart to deploy RustFS on kubernetes cluster.
|
||||
type: application
|
||||
version: "1.0.0-rc.1"
|
||||
appVersion: "1.0.0-rc.1"
|
||||
version: "1.0.0-rc.2"
|
||||
appVersion: "1.0.0-rc.2"
|
||||
home: https://rustfs.com
|
||||
icon: https://media.sys.truenas.net/apps/rustfs/icons/icon.svg
|
||||
maintainers:
|
||||
|
||||
+5
-2
@@ -1,9 +1,9 @@
|
||||
%global _enable_debug_packages 0
|
||||
%global _empty_manifest_terminate_build 0
|
||||
%global prerelease rc.1
|
||||
%global prerelease rc.2
|
||||
Name: rustfs
|
||||
Version: 1.0.0
|
||||
Release: rc.1
|
||||
Release: rc.2
|
||||
Summary: High-performance distributed object storage for MinIO alternative
|
||||
|
||||
License: Apache-2.0
|
||||
@@ -58,6 +58,9 @@ install %_builddir/%{name}-%{version}-%{prerelease}/target/%_arch/%_arch-unknown
|
||||
%_bindir/rustfs
|
||||
|
||||
%changelog
|
||||
* Fri Aug 14 2026 overtrue <anzhengchao@gmail.com>
|
||||
- Update RPM package to RustFS 1.0.0-rc.2
|
||||
|
||||
* Sat Aug 08 2026 overtrue <anzhengchao@gmail.com>
|
||||
- Update RPM package to RustFS 1.0.0-rc.1
|
||||
|
||||
|
||||
+2
-1
@@ -278,6 +278,8 @@ rustfs-signer.workspace = true
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true, features = ["raw_value"] }
|
||||
serde_urlencoded = { workspace = true }
|
||||
snap.workspace = true
|
||||
zstd.workspace = true
|
||||
|
||||
# Cryptography and Security
|
||||
rustls = { workspace = true, default-features = false, features = ["aws-lc-rs", "logging", "tls12", "prefer-post-quantum", "std"] }
|
||||
@@ -355,7 +357,6 @@ rcgen = { workspace = true }
|
||||
rustfs-test-utils.workspace = true
|
||||
# diagnose_e2e fixtures (archives are generated in-test, never checked in)
|
||||
zip = { workspace = true }
|
||||
zstd = { workspace = true }
|
||||
# Enables the shared MockWarmBackend / xl.meta assertion helpers exposed via
|
||||
# the ecstore `api::tier::test_util` facade module (rustfs/backlog#1148 ilm-6).
|
||||
rustfs-ecstore = { workspace = true, features = ["test-util"] }
|
||||
|
||||
@@ -535,8 +535,13 @@ impl Operation for GetReplicationMetricsHandler {
|
||||
|
||||
let bucket_stats = cluster_replication_stats(bucket, app_context_from_req(&req)).await;
|
||||
|
||||
let data = serde_json::to_vec(&bucket_stats.replication_stats)
|
||||
.map_err(|_| S3Error::with_message(S3ErrorCode::InternalError, "serialize failed"))?;
|
||||
// Same minio-go `replication.Metrics` wire shape as
|
||||
// `?replication-metrics` — the internal snake_case stats are the peer
|
||||
// RPC wire format and must not leak here.
|
||||
let data = serde_json::to_vec(&crate::admin::replication_metrics_wire::MetricsWire::from(
|
||||
&bucket_stats.replication_stats,
|
||||
))
|
||||
.map_err(|_| S3Error::with_message(S3ErrorCode::InternalError, "serialize failed"))?;
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
|
||||
Ok(S3Response::with_headers((StatusCode::OK, Body::from(data)), headers))
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -29,6 +29,6 @@ impl Operation for RestLoadCredentialsHandler {
|
||||
let issuer = IamTableCredentialIssuer::from_request(&req)?;
|
||||
let response =
|
||||
load_credentials_response(&store, &warehouse, &namespace, &table, &issuer, Some(&principal.credentials)).await?;
|
||||
build_json_response(StatusCode::OK, &response)
|
||||
build_sensitive_json_response(StatusCode::OK, &response)
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -125,7 +125,9 @@ impl Operation for RestLoadTableHandler {
|
||||
ensure_table_bucket_enabled_from_extensions(&req.extensions, &warehouse).await?;
|
||||
let metadata_backend = table_catalog_backend_from_extensions(&req.extensions)?;
|
||||
let store = table_catalog_store_from_backend(metadata_backend.clone())?;
|
||||
let response = load_table_response(&store, &metadata_backend, &warehouse, &namespace, &table).await?;
|
||||
let snapshot_selection = rest_table_snapshot_selection_from_query(&req.uri)?;
|
||||
let mut response = load_table_response(&store, &metadata_backend, &warehouse, &namespace, &table).await?;
|
||||
apply_rest_table_snapshot_selection(&mut response.metadata, snapshot_selection);
|
||||
build_json_response(StatusCode::OK, &response)
|
||||
}
|
||||
}
|
||||
@@ -158,7 +160,7 @@ impl Operation for RestCommitTableHandler {
|
||||
let principal = authorize_table_catalog_resource_request(&req, &resource, AdminAction::CommitTableAction).await?;
|
||||
install_table_catalog_s3_request_info(&mut req, &principal)?;
|
||||
ensure_table_bucket_enabled_from_extensions(&req.extensions, &warehouse).await?;
|
||||
let request = read_json_body::<RestCommitTableRequest>(std::mem::take(&mut req.input)).await?;
|
||||
let request = read_rest_commit_table_request(std::mem::take(&mut req.input)).await?;
|
||||
let metadata_backend = table_catalog_backend_from_extensions(&req.extensions)?;
|
||||
let store = table_catalog_store_from_backend(metadata_backend.clone())?;
|
||||
let commit_backend = TableCommitObjectBackend::for_request(metadata_backend, req);
|
||||
@@ -178,6 +180,14 @@ impl Operation for RestDropTableHandler {
|
||||
let table = table_name_from_params(¶ms)?;
|
||||
let resource = TableCatalogResource::table(&warehouse, &namespace, &table);
|
||||
authorize_table_catalog_resource_request(&req, &resource, AdminAction::DeleteTableAction).await?;
|
||||
let purge_requested = rest_purge_requested_from_query(&req.uri)?;
|
||||
if purge_requested {
|
||||
return Err(iceberg_rest_error(
|
||||
ICEBERG_ERROR_UNSUPPORTED_OPERATION,
|
||||
StatusCode::NOT_ACCEPTABLE,
|
||||
"purgeRequested=true is not supported",
|
||||
));
|
||||
}
|
||||
ensure_table_bucket_enabled_from_extensions(&req.extensions, &warehouse).await?;
|
||||
let store = table_catalog_store_from_extensions(&req.extensions)?;
|
||||
drop_table_in_store(&store, &warehouse, &namespace, &table).await?;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -43,8 +43,9 @@ impl Operation for RestCreateViewHandler {
|
||||
let metadata_backend = table_catalog_backend_from_extensions(&req.extensions)?;
|
||||
let store = table_catalog_store_from_backend(metadata_backend.clone())?;
|
||||
let table_bucket_enabled = table_bucket_enabled_from_extensions(&req.extensions, &warehouse).await?;
|
||||
let publication_backend = TableCommitObjectBackend::preauthorized(metadata_backend);
|
||||
let response =
|
||||
create_view_response(&store, &metadata_backend, &warehouse, &namespace, request, table_bucket_enabled).await?;
|
||||
create_view_response(&store, &publication_backend, &warehouse, &namespace, request, table_bucket_enabled).await?;
|
||||
build_json_response(StatusCode::OK, &response)
|
||||
}
|
||||
}
|
||||
@@ -87,17 +88,20 @@ pub struct RestReplaceViewHandler {}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Operation for RestReplaceViewHandler {
|
||||
async fn call(&self, req: S3Request<Body>, params: Params<'_, '_>) -> S3Result<S3Response<(StatusCode, Body)>> {
|
||||
async fn call(&self, mut req: S3Request<Body>, params: Params<'_, '_>) -> S3Result<S3Response<(StatusCode, Body)>> {
|
||||
let warehouse = warehouse_from_params(¶ms)?;
|
||||
let namespace = namespace_from_params(¶ms)?;
|
||||
let view = view_name_from_params(¶ms)?;
|
||||
let resource = TableCatalogResource::view(&warehouse, &namespace, &view);
|
||||
authorize_table_catalog_resource_request(&req, &resource, AdminAction::CommitTableAction).await?;
|
||||
let principal = authorize_table_catalog_resource_request(&req, &resource, AdminAction::CommitTableAction).await?;
|
||||
install_table_catalog_s3_request_info(&mut req, &principal)?;
|
||||
ensure_table_bucket_enabled_from_extensions(&req.extensions, &warehouse).await?;
|
||||
let request = read_json_body::<RestCommitViewRequest>(req.input).await?;
|
||||
let request = read_rest_commit_view_request(std::mem::take(&mut req.input)).await?;
|
||||
let metadata_backend = table_catalog_backend_from_extensions(&req.extensions)?;
|
||||
let store = table_catalog_store_from_backend(metadata_backend.clone())?;
|
||||
let response = replace_view_response(&store, &metadata_backend, &warehouse, &namespace, &view, request).await?;
|
||||
let commit_backend = TableCommitObjectBackend::for_request(metadata_backend, req);
|
||||
let result = replace_view_response(&store, &commit_backend, &warehouse, &namespace, &view, request).await;
|
||||
let response = commit_backend.finish(result).await?;
|
||||
build_json_response(StatusCode::OK, &response)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ mod auth;
|
||||
pub mod console;
|
||||
pub mod handlers;
|
||||
mod plugin_contract;
|
||||
pub(crate) mod replication_metrics_wire;
|
||||
// Contract inventory is validated by tests before later runtime integration.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) mod route_policy;
|
||||
|
||||
@@ -0,0 +1,673 @@
|
||||
// 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.
|
||||
|
||||
//! Serialize-only wire projections of the internal replication statistics
|
||||
//! onto the minio-go `replication.Metrics` / `replication.MetricsV2` json
|
||||
//! shapes consumed by `mc replicate status` (`?replication-metrics[=2]` and
|
||||
//! the admin `replicationmetrics` endpoint).
|
||||
//!
|
||||
//! Red line: the internal `BucketStats` family in
|
||||
//! `crates/replication/src/stats.rs` is ALSO the intra-cluster peer-RPC wire
|
||||
//! format — `node_service.rs` encodes it with `rmp_serde::to_vec_named`, so
|
||||
//! its Rust field names travel between nodes as msgpack map keys. Renaming
|
||||
//! those serde names would break mixed-version clusters mid rolling upgrade.
|
||||
//! All madmin/minio-go interop therefore happens in these DTOs; never add
|
||||
//! `#[serde(rename)]` to the internal structs instead.
|
||||
//!
|
||||
//! Field names below are the exact json tags of minio-go
|
||||
//! `pkg/replication/replication.go` (v7.0.91). Keys minio-go does not know
|
||||
//! are RustFS extensions; Go decoders ignore unknown keys. `max`/`peak` are
|
||||
//! both emitted for the queue peak because the MinIO server writes `max`
|
||||
//! while minio-go reads `peak` (an upstream drift); emitting both keeps every
|
||||
//! decoder working.
|
||||
|
||||
use serde::Serialize;
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::admin::storage_api::replication::{
|
||||
BucketReplicationStat as InternalReplicationStat, BucketReplicationStats as InternalReplicationStats, BucketStats,
|
||||
InQueueMetric as InternalInQueueMetric, XferStats as InternalXferStats,
|
||||
};
|
||||
|
||||
/// minio-go `replication.RStat`.
|
||||
#[derive(Debug, Default, Clone, Copy, Serialize)]
|
||||
pub(crate) struct RStatWire {
|
||||
#[serde(rename = "count")]
|
||||
pub count: f64,
|
||||
#[serde(rename = "bytes")]
|
||||
pub bytes: i64,
|
||||
}
|
||||
|
||||
/// minio-go `replication.TimedErrStats`.
|
||||
#[derive(Debug, Default, Clone, Copy, Serialize)]
|
||||
pub(crate) struct TimedErrStatsWire {
|
||||
#[serde(rename = "lastMinute")]
|
||||
pub last_minute: RStatWire,
|
||||
#[serde(rename = "lastHour")]
|
||||
pub last_hour: RStatWire,
|
||||
#[serde(rename = "totals")]
|
||||
pub totals: RStatWire,
|
||||
}
|
||||
|
||||
impl TimedErrStatsWire {
|
||||
fn add(self, other: TimedErrStatsWire) -> TimedErrStatsWire {
|
||||
fn add(a: RStatWire, b: RStatWire) -> RStatWire {
|
||||
RStatWire {
|
||||
count: a.count + b.count,
|
||||
bytes: a.bytes.saturating_add(b.bytes),
|
||||
}
|
||||
}
|
||||
TimedErrStatsWire {
|
||||
last_minute: add(self.last_minute, other.last_minute),
|
||||
last_hour: add(self.last_hour, other.last_hour),
|
||||
totals: add(self.totals, other.totals),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// minio-go `replication.QStat`.
|
||||
#[derive(Debug, Default, Clone, Copy, Serialize)]
|
||||
pub(crate) struct QStatWire {
|
||||
#[serde(rename = "count")]
|
||||
pub count: f64,
|
||||
#[serde(rename = "bytes")]
|
||||
pub bytes: f64,
|
||||
}
|
||||
|
||||
/// minio-go `replication.InQueueMetric`, with the queue peak emitted under
|
||||
/// both `peak` (minio-go tag) and `max` (MinIO server tag).
|
||||
#[derive(Debug, Default, Clone, Copy, Serialize)]
|
||||
pub(crate) struct InQueueMetricWire {
|
||||
#[serde(rename = "curr")]
|
||||
pub curr: QStatWire,
|
||||
#[serde(rename = "avg")]
|
||||
pub avg: QStatWire,
|
||||
#[serde(rename = "max")]
|
||||
pub max: QStatWire,
|
||||
#[serde(rename = "peak")]
|
||||
pub peak: QStatWire,
|
||||
}
|
||||
|
||||
impl From<&InternalInQueueMetric> for InQueueMetricWire {
|
||||
fn from(metric: &InternalInQueueMetric) -> Self {
|
||||
fn qstat(bytes: i64, count: i64) -> QStatWire {
|
||||
QStatWire {
|
||||
count: count as f64,
|
||||
bytes: bytes as f64,
|
||||
}
|
||||
}
|
||||
let peak = qstat(metric.max.bytes, metric.max.count);
|
||||
InQueueMetricWire {
|
||||
curr: qstat(metric.curr.bytes, metric.curr.count),
|
||||
avg: qstat(metric.avg.bytes, metric.avg.count),
|
||||
max: peak,
|
||||
peak,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// minio-go `replication.XferStats`.
|
||||
#[derive(Debug, Default, Clone, Copy, Serialize)]
|
||||
pub(crate) struct XferStatsWire {
|
||||
#[serde(rename = "avgRate")]
|
||||
pub avg_rate: f64,
|
||||
#[serde(rename = "peakRate")]
|
||||
pub peak_rate: f64,
|
||||
#[serde(rename = "currRate")]
|
||||
pub curr_rate: f64,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct XferStatsAverage {
|
||||
sum: XferStatsWire,
|
||||
active: u32,
|
||||
}
|
||||
|
||||
impl XferStatsAverage {
|
||||
fn add_active(&mut self, stats: XferStatsWire) {
|
||||
if stats.peak_rate <= 0.0 {
|
||||
return;
|
||||
}
|
||||
self.add_raw(stats);
|
||||
self.active += 1;
|
||||
}
|
||||
|
||||
fn add_raw(&mut self, stats: XferStatsWire) {
|
||||
self.sum.avg_rate += stats.avg_rate;
|
||||
self.sum.curr_rate += stats.curr_rate;
|
||||
self.sum.peak_rate = self.sum.peak_rate.max(stats.peak_rate);
|
||||
}
|
||||
|
||||
fn finish(self) -> XferStatsWire {
|
||||
let active = self.active;
|
||||
self.finish_with_divisor(active)
|
||||
}
|
||||
|
||||
fn finish_with_divisor(self, divisor: u32) -> XferStatsWire {
|
||||
if divisor == 0 {
|
||||
return self.sum;
|
||||
}
|
||||
XferStatsWire {
|
||||
avg_rate: self.sum.avg_rate / f64::from(divisor),
|
||||
peak_rate: self.sum.peak_rate,
|
||||
curr_rate: self.sum.curr_rate / f64::from(divisor),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&InternalXferStats> for XferStatsWire {
|
||||
fn from(stats: &InternalXferStats) -> Self {
|
||||
XferStatsWire {
|
||||
avg_rate: stats.avg,
|
||||
peak_rate: stats.peak,
|
||||
curr_rate: stats.curr,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// minio-go `replication.WorkerStat`. RustFS does not track per-bucket worker
|
||||
/// occupancy yet, so this always reports zeros.
|
||||
#[derive(Debug, Default, Clone, Copy, Serialize)]
|
||||
pub(crate) struct WorkerStatWire {
|
||||
#[serde(rename = "curr")]
|
||||
pub curr: i32,
|
||||
#[serde(rename = "avg")]
|
||||
pub avg: f32,
|
||||
#[serde(rename = "max")]
|
||||
pub max: i32,
|
||||
}
|
||||
|
||||
/// minio-go `replication.ReplMRFStats`. RustFS does not track the 5-minute /
|
||||
/// dropped MRF windows, so this always reports zeros; the durable backlog is
|
||||
/// enumerable via `/v3/replication/mrf` instead.
|
||||
#[derive(Debug, Default, Clone, Copy, Serialize)]
|
||||
pub(crate) struct ReplMrfStatsWire {
|
||||
#[serde(rename = "failedCount_last5min")]
|
||||
pub last_failed_count: u64,
|
||||
#[serde(rename = "droppedCount_since_uptime")]
|
||||
pub total_dropped_count: u64,
|
||||
#[serde(rename = "droppedBytes_since_uptime")]
|
||||
pub total_dropped_bytes: u64,
|
||||
}
|
||||
|
||||
/// minio-go `replication.CounterSummary`.
|
||||
#[derive(Debug, Default, Clone, Copy, Serialize)]
|
||||
pub(crate) struct CounterSummaryWire {
|
||||
#[serde(rename = "last1hr")]
|
||||
pub last1hr: u64,
|
||||
#[serde(rename = "last1m")]
|
||||
pub last1m: u64,
|
||||
#[serde(rename = "total")]
|
||||
pub total: u64,
|
||||
}
|
||||
|
||||
/// minio-go `replication.TargetMetrics` (one remote target / ARN).
|
||||
#[derive(Debug, Default, Serialize)]
|
||||
pub(crate) struct TargetMetricsWire {
|
||||
#[serde(rename = "replicationCount")]
|
||||
pub replicated_count: i64,
|
||||
#[serde(rename = "completedReplicationSize")]
|
||||
pub replicated_size: i64,
|
||||
/// Bandwidth limit for this target. The tag says "bits" but both MinIO
|
||||
/// and minio-go treat the value as bytes/sec; keep bytes/sec.
|
||||
#[serde(rename = "limitInBits")]
|
||||
pub bandwidth_limit_bytes_per_sec: i64,
|
||||
#[serde(rename = "currentBandwidth")]
|
||||
pub current_bandwidth_bytes_per_sec: f64,
|
||||
#[serde(rename = "failed")]
|
||||
pub failed: TimedErrStatsWire,
|
||||
#[serde(rename = "failedReplicationSize")]
|
||||
pub failed_size: i64,
|
||||
#[serde(rename = "failedReplicationCount")]
|
||||
pub failed_count: i64,
|
||||
}
|
||||
|
||||
fn target_timed_err_stats(stat: &InternalReplicationStat) -> TimedErrStatsWire {
|
||||
// Cluster aggregation merges FailStats without the process-local samples,
|
||||
// so the serializable window snapshots (refreshed at each node's
|
||||
// collection point, summed by merge) are authoritative here; the live
|
||||
// samples only ever agree with or lag them, so take the larger.
|
||||
let sampled_minute = stat.fail_stats.recent_since(Duration::from_secs(60));
|
||||
let sampled_hour = stat.fail_stats.recent_since(Duration::from_secs(3600));
|
||||
let window = |sampled_count: i64, sampled_size: i64, snapshot_count: i64, snapshot_size: i64| RStatWire {
|
||||
count: sampled_count.max(snapshot_count) as f64,
|
||||
bytes: sampled_size.max(snapshot_size),
|
||||
};
|
||||
TimedErrStatsWire {
|
||||
last_minute: window(
|
||||
sampled_minute.count,
|
||||
sampled_minute.size,
|
||||
stat.fail_stats.last_minute.count,
|
||||
stat.fail_stats.last_minute.size,
|
||||
),
|
||||
last_hour: window(
|
||||
sampled_hour.count,
|
||||
sampled_hour.size,
|
||||
stat.fail_stats.last_hour.count,
|
||||
stat.fail_stats.last_hour.size,
|
||||
),
|
||||
totals: RStatWire {
|
||||
count: stat.failed.count as f64,
|
||||
bytes: stat.failed.size,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&InternalReplicationStat> for TargetMetricsWire {
|
||||
fn from(stat: &InternalReplicationStat) -> Self {
|
||||
TargetMetricsWire {
|
||||
replicated_count: stat.replicated_count,
|
||||
replicated_size: stat.replicated_size,
|
||||
bandwidth_limit_bytes_per_sec: stat.bandwidth_limit_bytes_per_sec,
|
||||
current_bandwidth_bytes_per_sec: stat.current_bandwidth_bytes_per_sec,
|
||||
failed: target_timed_err_stats(stat),
|
||||
failed_size: stat.failed.size,
|
||||
failed_count: stat.failed.count,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// minio-go `replication.Metrics` — the `currStats` member of `MetricsV2` and
|
||||
/// the whole v1 response body. The trailing snake_case fields are RustFS
|
||||
/// source-health extension keys (ignored by Go decoders) carried over from
|
||||
/// the previous response shape.
|
||||
#[derive(Debug, Default, Serialize)]
|
||||
pub(crate) struct MetricsWire {
|
||||
#[serde(rename = "Stats")]
|
||||
pub stats: HashMap<String, TargetMetricsWire>,
|
||||
#[serde(rename = "completedReplicationSize")]
|
||||
pub replicated_size: i64,
|
||||
#[serde(rename = "replicaSize")]
|
||||
pub replica_size: i64,
|
||||
#[serde(rename = "replicaCount")]
|
||||
pub replica_count: i64,
|
||||
#[serde(rename = "replicationCount")]
|
||||
pub replicated_count: i64,
|
||||
#[serde(rename = "failed")]
|
||||
pub failed: TimedErrStatsWire,
|
||||
#[serde(rename = "queued")]
|
||||
pub queued: InQueueMetricWire,
|
||||
// RustFS extension keys (source health of the aggregation).
|
||||
pub provider_available: bool,
|
||||
pub cluster_complete: bool,
|
||||
pub observed_node_count: u32,
|
||||
pub expected_node_count: u32,
|
||||
}
|
||||
|
||||
impl From<&InternalReplicationStats> for MetricsWire {
|
||||
fn from(stats: &InternalReplicationStats) -> Self {
|
||||
let mut failed = TimedErrStatsWire::default();
|
||||
let mut targets = HashMap::with_capacity(stats.stats.len());
|
||||
for (arn, stat) in &stats.stats {
|
||||
let target = TargetMetricsWire::from(stat);
|
||||
failed = failed.add(target.failed);
|
||||
targets.insert(arn.clone(), target);
|
||||
}
|
||||
MetricsWire {
|
||||
stats: targets,
|
||||
replicated_size: stats.replicated_size,
|
||||
replica_size: stats.replica_size,
|
||||
replica_count: stats.replica_count,
|
||||
replicated_count: stats.replicated_count,
|
||||
failed,
|
||||
queued: InQueueMetricWire::from(&stats.q_stat),
|
||||
provider_available: stats.provider_available,
|
||||
cluster_complete: stats.cluster_complete,
|
||||
observed_node_count: stats.observed_node_count,
|
||||
expected_node_count: stats.expected_node_count,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// minio-go `replication.ReplQNodeStats`.
|
||||
#[derive(Debug, Default, Serialize)]
|
||||
pub(crate) struct ReplQNodeStatsWire {
|
||||
#[serde(rename = "nodeName")]
|
||||
pub node_name: String,
|
||||
#[serde(rename = "uptime")]
|
||||
pub uptime: i64,
|
||||
#[serde(rename = "activeWorkers")]
|
||||
pub workers: WorkerStatWire,
|
||||
#[serde(rename = "transferSummary")]
|
||||
pub xfer_stats: XferSummaryWire,
|
||||
#[serde(rename = "tgtTransferStats")]
|
||||
pub tgt_xfer_stats: TargetXferSummaryWire,
|
||||
#[serde(rename = "queueStats")]
|
||||
pub q_stats: InQueueMetricWire,
|
||||
#[serde(rename = "mrfStats")]
|
||||
pub mrf_stats: ReplMrfStatsWire,
|
||||
#[serde(rename = "retries")]
|
||||
pub retries: CounterSummaryWire,
|
||||
#[serde(rename = "errors")]
|
||||
pub errors: CounterSummaryWire,
|
||||
}
|
||||
|
||||
/// minio-go `replication.ReplQueueStats`.
|
||||
#[derive(Debug, Default, Serialize)]
|
||||
pub(crate) struct ReplQueueStatsWire {
|
||||
#[serde(rename = "nodes")]
|
||||
pub nodes: Vec<ReplQNodeStatsWire>,
|
||||
}
|
||||
|
||||
/// minio-go `replication.MetricsV2` — the `?replication-metrics=2` body.
|
||||
#[derive(Debug, Default, Serialize)]
|
||||
pub(crate) struct MetricsV2Wire {
|
||||
#[serde(rename = "uptime")]
|
||||
pub uptime: i64,
|
||||
#[serde(rename = "currStats")]
|
||||
pub current_stats: MetricsWire,
|
||||
#[serde(rename = "queueStats")]
|
||||
pub queue_stats: ReplQueueStatsWire,
|
||||
#[serde(rename = "downtimeInfo")]
|
||||
pub downtime_info: HashMap<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
/// `transferSummary` map keyed by minio-go `MetricName` (Large/Small/Total).
|
||||
type XferSummaryWire = HashMap<&'static str, XferStatsWire>;
|
||||
/// `tgtTransferStats` map keyed by target ARN.
|
||||
type TargetXferSummaryWire = HashMap<String, XferSummaryWire>;
|
||||
|
||||
fn transfer_summaries(stats: &InternalReplicationStats) -> (XferSummaryWire, TargetXferSummaryWire) {
|
||||
let mut per_target: TargetXferSummaryWire = HashMap::new();
|
||||
let mut large_summary = XferStatsAverage::default();
|
||||
let mut small_summary = XferStatsAverage::default();
|
||||
let mut total_summary = XferStatsAverage::default();
|
||||
let mut active_targets = 0;
|
||||
for (arn, stat) in &stats.stats {
|
||||
let large = XferStatsWire::from(&stat.xfer_rate_lrg);
|
||||
let small = XferStatsWire::from(&stat.xfer_rate_sml);
|
||||
let mut target_total = XferStatsAverage::default();
|
||||
target_total.add_active(large);
|
||||
target_total.add_active(small);
|
||||
let total = target_total.finish();
|
||||
per_target.insert(arn.clone(), HashMap::from([("Large", large), ("Small", small), ("Total", total)]));
|
||||
if large.peak_rate > 0.0 || small.peak_rate > 0.0 {
|
||||
active_targets += 1;
|
||||
large_summary.add_raw(large);
|
||||
small_summary.add_raw(small);
|
||||
total_summary.add_raw(large);
|
||||
total_summary.add_raw(small);
|
||||
}
|
||||
}
|
||||
let summary = HashMap::from([
|
||||
("Large", large_summary.finish_with_divisor(active_targets)),
|
||||
("Small", small_summary.finish_with_divisor(active_targets)),
|
||||
("Total", total_summary.finish_with_divisor(active_targets)),
|
||||
]);
|
||||
(summary, per_target)
|
||||
}
|
||||
|
||||
impl MetricsV2Wire {
|
||||
/// Project the aggregated internal stats onto the `MetricsV2` shape.
|
||||
///
|
||||
/// The aggregation path leaves `queue_stats.nodes` empty today, so a
|
||||
/// single node entry is synthesized from the bucket queue snapshot —
|
||||
/// `mc replicate status` derives its queue/worker panels from
|
||||
/// `queueStats.nodes` and treats an empty list as "no data".
|
||||
pub(crate) fn from_stats(bucket_stats: &BucketStats, node_name: &str) -> Self {
|
||||
let (xfer_stats, tgt_xfer_stats) = transfer_summaries(&bucket_stats.replication_stats);
|
||||
let mut nodes: Vec<ReplQNodeStatsWire> = bucket_stats
|
||||
.queue_stats
|
||||
.nodes
|
||||
.iter()
|
||||
.map(|node| ReplQNodeStatsWire {
|
||||
node_name: node_name.to_string(),
|
||||
uptime: bucket_stats.uptime,
|
||||
q_stats: InQueueMetricWire::from(&node.q_stats),
|
||||
..Default::default()
|
||||
})
|
||||
.collect();
|
||||
if nodes.is_empty() {
|
||||
nodes.push(ReplQNodeStatsWire {
|
||||
node_name: node_name.to_string(),
|
||||
uptime: bucket_stats.uptime,
|
||||
q_stats: InQueueMetricWire::from(&bucket_stats.replication_stats.q_stat),
|
||||
xfer_stats: xfer_stats.clone(),
|
||||
tgt_xfer_stats: tgt_xfer_stats.clone(),
|
||||
..Default::default()
|
||||
});
|
||||
} else {
|
||||
// Attach the transfer summaries to the first node; the internal
|
||||
// snapshot does not attribute transfer rates per node.
|
||||
if let Some(first) = nodes.first_mut() {
|
||||
first.xfer_stats = xfer_stats.clone();
|
||||
first.tgt_xfer_stats = tgt_xfer_stats.clone();
|
||||
}
|
||||
}
|
||||
|
||||
MetricsV2Wire {
|
||||
uptime: bucket_stats.uptime,
|
||||
current_stats: MetricsWire::from(&bucket_stats.replication_stats),
|
||||
queue_stats: ReplQueueStatsWire { nodes },
|
||||
downtime_info: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sample_bucket_stats() -> BucketStats {
|
||||
let mut stats = BucketStats {
|
||||
uptime: 42,
|
||||
..Default::default()
|
||||
};
|
||||
stats.replication_stats.replica_count = 2;
|
||||
stats.replication_stats.replica_size = 128;
|
||||
stats.replication_stats.replicated_count = 9;
|
||||
stats.replication_stats.replicated_size = 4096;
|
||||
let target = stats
|
||||
.replication_stats
|
||||
.stats
|
||||
.entry("arn:minio:replication::t:b".to_string())
|
||||
.or_default();
|
||||
target.replicated_count = 9;
|
||||
target.replicated_size = 4096;
|
||||
target.failed.count = 3;
|
||||
target.failed.size = 900;
|
||||
target.bandwidth_limit_bytes_per_sec = 1024;
|
||||
target.current_bandwidth_bytes_per_sec = 512.5;
|
||||
stats
|
||||
.replication_stats
|
||||
.q_stat
|
||||
.curr
|
||||
.now_count
|
||||
.store(4, std::sync::atomic::Ordering::Relaxed);
|
||||
stats
|
||||
.replication_stats
|
||||
.q_stat
|
||||
.curr
|
||||
.now_bytes
|
||||
.store(1200, std::sync::atomic::Ordering::Relaxed);
|
||||
stats.replication_stats.q_stat = stats.replication_stats.q_stat.snapshot();
|
||||
stats
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metrics_wire_matches_minio_go_tags() {
|
||||
let stats = sample_bucket_stats();
|
||||
let json = serde_json::to_value(MetricsWire::from(&stats.replication_stats)).expect("v1 wire should serialize");
|
||||
|
||||
assert_eq!(json["replicaCount"], 2);
|
||||
assert_eq!(json["replicaSize"], 128);
|
||||
assert_eq!(json["replicationCount"], 9);
|
||||
assert_eq!(json["completedReplicationSize"], 4096);
|
||||
assert_eq!(json["queued"]["curr"]["count"], 4.0);
|
||||
assert_eq!(json["queued"]["curr"]["bytes"], 1200.0);
|
||||
let target = &json["Stats"]["arn:minio:replication::t:b"];
|
||||
assert_eq!(target["replicationCount"], 9);
|
||||
assert_eq!(target["completedReplicationSize"], 4096);
|
||||
assert_eq!(target["limitInBits"], 1024);
|
||||
assert_eq!(target["currentBandwidth"], 512.5);
|
||||
// failed is the madmin TimedErrStats envelope, not the internal
|
||||
// {count,size} pair.
|
||||
assert_eq!(target["failed"]["totals"]["count"], 3.0);
|
||||
assert_eq!(target["failed"]["totals"]["bytes"], 900);
|
||||
assert!(target["failed"].get("count").is_none());
|
||||
// Aggregate failed mirrors the per-target totals.
|
||||
assert_eq!(json["failed"]["totals"]["count"], 3.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metrics_v2_wire_synthesizes_queue_node() {
|
||||
let stats = sample_bucket_stats();
|
||||
let json = serde_json::to_value(MetricsV2Wire::from_stats(&stats, "node-1:9000")).expect("v2 wire should serialize");
|
||||
|
||||
assert_eq!(json["uptime"], 42);
|
||||
assert_eq!(json["currStats"]["replicaCount"], 2);
|
||||
let node = &json["queueStats"]["nodes"][0];
|
||||
assert_eq!(node["nodeName"], "node-1:9000");
|
||||
assert_eq!(node["uptime"], 42);
|
||||
assert_eq!(node["queueStats"]["curr"]["count"], 4.0);
|
||||
// The queue peak is emitted under both the minio-go tag (`peak`) and
|
||||
// the MinIO server tag (`max`).
|
||||
assert_eq!(node["queueStats"]["peak"], node["queueStats"]["max"]);
|
||||
assert!(node["activeWorkers"].get("curr").is_some());
|
||||
assert!(node["transferSummary"].get("Total").is_some());
|
||||
assert_eq!(json["downtimeInfo"], serde_json::json!({}));
|
||||
}
|
||||
|
||||
/// minio-go's transferSummary labels mean >= 128 MiB for Large; the
|
||||
/// producer must bin on the same boundary (MIN_LARGE_OBJ_SIZE, shared
|
||||
/// with the worker-pool split), or a 2 MiB replication shows under Large
|
||||
/// while Small stays zero.
|
||||
#[test]
|
||||
fn transfer_summary_bins_on_the_128_mib_boundary() {
|
||||
const MIB: i64 = 1024 * 1024;
|
||||
let mut stats = BucketStats::default();
|
||||
let stat = stats
|
||||
.replication_stats
|
||||
.stats
|
||||
.entry("arn:minio:replication::t:b".to_string())
|
||||
.or_default();
|
||||
stat.update_xfer_rate(2 * MIB, std::time::Duration::from_secs(1));
|
||||
stat.update_xfer_rate(127 * MIB, std::time::Duration::from_secs(1));
|
||||
stat.update_xfer_rate(128 * MIB, std::time::Duration::from_secs(1));
|
||||
|
||||
let json = serde_json::to_value(MetricsV2Wire::from_stats(&stats, "node-1")).expect("v2 wire should serialize");
|
||||
let summary = &json["queueStats"]["nodes"][0]["tgtTransferStats"]["arn:minio:replication::t:b"];
|
||||
let small_peak = summary["Small"]["peakRate"].as_f64().expect("Small peakRate");
|
||||
let large_peak = summary["Large"]["peakRate"].as_f64().expect("Large peakRate");
|
||||
assert!(
|
||||
(small_peak - (127 * MIB) as f64).abs() < 1.0,
|
||||
"2 MiB and 127 MiB transfers must bin as Small (peak {small_peak})"
|
||||
);
|
||||
assert!(
|
||||
(large_peak - (128 * MIB) as f64).abs() < 1.0,
|
||||
"exactly 128 MiB must bin as Large (peak {large_peak})"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transfer_summaries_average_active_bins_and_targets() {
|
||||
let mut stats = BucketStats::default();
|
||||
let first = stats.replication_stats.stats.entry("target-a".to_string()).or_default();
|
||||
first.xfer_rate_sml.avg = 50.0;
|
||||
first.xfer_rate_sml.curr = 40.0;
|
||||
first.xfer_rate_sml.peak = 60.0;
|
||||
first.xfer_rate_lrg.avg = 100.0;
|
||||
first.xfer_rate_lrg.curr = 80.0;
|
||||
first.xfer_rate_lrg.peak = 120.0;
|
||||
|
||||
let second = stats.replication_stats.stats.entry("target-b".to_string()).or_default();
|
||||
second.xfer_rate_sml.avg = 30.0;
|
||||
second.xfer_rate_sml.curr = 20.0;
|
||||
second.xfer_rate_sml.peak = 40.0;
|
||||
|
||||
let json = serde_json::to_value(MetricsV2Wire::from_stats(&stats, "node-1")).expect("v2 wire should serialize");
|
||||
let node = &json["queueStats"]["nodes"][0];
|
||||
let target_a = &node["tgtTransferStats"]["target-a"]["Total"];
|
||||
assert_eq!(target_a["avgRate"], 75.0);
|
||||
assert_eq!(target_a["currRate"], 60.0);
|
||||
assert_eq!(target_a["peakRate"], 120.0);
|
||||
|
||||
let summary = &node["transferSummary"];
|
||||
assert_eq!(summary["Small"]["avgRate"], 40.0);
|
||||
assert_eq!(summary["Small"]["currRate"], 30.0);
|
||||
assert_eq!(summary["Large"]["avgRate"], 50.0);
|
||||
assert_eq!(summary["Total"]["avgRate"], 90.0);
|
||||
assert_eq!(summary["Total"]["currRate"], 70.0);
|
||||
assert_eq!(summary["Total"]["peakRate"], 120.0);
|
||||
}
|
||||
|
||||
/// Review regression: both metrics endpoints aggregate first, and the
|
||||
/// FailStats merge drops the process-local samples — the rolling windows
|
||||
/// must survive a peer-RPC round trip plus aggregation and still reach
|
||||
/// the wire body.
|
||||
#[test]
|
||||
fn failure_windows_survive_aggregation_before_serialization() {
|
||||
// Node A: live failure; the windows are stamped at the collection
|
||||
// point (get_latest_replication_stats calls refresh_windows before
|
||||
// the stats cross the wire), never on the failure hot path.
|
||||
let mut node_a = crate::admin::storage_api::replication::BucketReplicationStat::default();
|
||||
node_a.fail_stats.add_size(512, None::<&std::io::Error>);
|
||||
node_a.fail_stats.refresh_windows();
|
||||
node_a.failed = node_a.fail_stats.to_metric();
|
||||
|
||||
// Node A's stats cross the peer RPC wire: the samples are dropped,
|
||||
// the window snapshots travel.
|
||||
let encoded = rmp_serde::to_vec_named(&node_a).expect("stat should encode");
|
||||
let remote: crate::admin::storage_api::replication::BucketReplicationStat =
|
||||
rmp_serde::from_slice(&encoded).expect("stat should decode");
|
||||
|
||||
// Aggregation merges the remote stat with an empty local one.
|
||||
let merged_fail = remote.fail_stats.merge(&Default::default());
|
||||
let aggregated = crate::admin::storage_api::replication::BucketReplicationStat {
|
||||
failed: merged_fail.to_metric(),
|
||||
fail_stats: merged_fail,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mut stats = BucketStats::default();
|
||||
stats
|
||||
.replication_stats
|
||||
.stats
|
||||
.insert("arn:minio:replication::t:b".to_string(), aggregated);
|
||||
|
||||
let json = serde_json::to_value(MetricsWire::from(&stats.replication_stats)).expect("wire should serialize");
|
||||
let failed = &json["Stats"]["arn:minio:replication::t:b"]["failed"];
|
||||
assert_eq!(failed["totals"]["count"], 1.0);
|
||||
assert_eq!(
|
||||
failed["lastMinute"]["count"], 1.0,
|
||||
"the rolling minute window must survive RPC + aggregation"
|
||||
);
|
||||
assert_eq!(failed["lastMinute"]["bytes"], 512);
|
||||
assert_eq!(failed["lastHour"]["count"], 1.0);
|
||||
}
|
||||
|
||||
/// Pin the intra-cluster peer-RPC wire format of the internal stats: it
|
||||
/// is msgpack with the Rust field names as map keys
|
||||
/// (`rmp_serde::to_vec_named` in node_service.rs). If someone "fixes"
|
||||
/// the interop bug by renaming the internal serde fields instead of using
|
||||
/// these DTOs, this test fails and points them here.
|
||||
#[test]
|
||||
fn internal_bucket_stats_rpc_wire_stays_snake_case() {
|
||||
let stats = sample_bucket_stats();
|
||||
let encoded = rmp_serde::to_vec_named(&stats).expect("internal stats should encode");
|
||||
let value: serde_json::Value = rmp_serde::from_slice(&encoded).expect("named msgpack should decode generically");
|
||||
|
||||
assert!(
|
||||
value.get("replication_stats").is_some(),
|
||||
"peer RPC key replication_stats must not be renamed"
|
||||
);
|
||||
assert!(value["replication_stats"].get("q_stat").is_some());
|
||||
assert!(value.get("queue_stats").is_some());
|
||||
assert!(value.get("proxy_stats").is_some());
|
||||
|
||||
let decoded: BucketStats = rmp_serde::from_slice(&encoded).expect("round-trip through the peer RPC wire");
|
||||
assert_eq!(decoded.replication_stats.replica_count, 2);
|
||||
}
|
||||
}
|
||||
+67
-13
@@ -1548,7 +1548,8 @@ async fn build_replication_metrics_response(
|
||||
let bucket_stats = apply_replication_metrics_bandwidth_report(bucket_stats, collect_replication_metrics_bandwidth(bucket));
|
||||
let bucket_stats = apply_replication_metrics_runtime_fields(bucket_stats, route, replication_metrics_uptime_seconds());
|
||||
|
||||
let body = serialize_replication_metrics_body(&bucket_stats, route)?;
|
||||
let node_name = crate::runtime_sources::current_local_node_name().await.unwrap_or_default();
|
||||
let body = serialize_replication_metrics_body(&bucket_stats, route, &node_name)?;
|
||||
|
||||
let mut resp = S3Response::with_status(Body::from(body), StatusCode::OK);
|
||||
resp.headers
|
||||
@@ -1608,12 +1609,24 @@ fn apply_replication_metrics_runtime_fields(
|
||||
bucket_stats
|
||||
}
|
||||
|
||||
fn serialize_replication_metrics_body(bucket_stats: &BucketStats, route: ReplicationExtRoute) -> S3Result<Vec<u8>> {
|
||||
/// Serialize the metrics body in the minio-go wire shapes
|
||||
/// (`replication.Metrics` for v1, `replication.MetricsV2` for v2). The
|
||||
/// internal `BucketStats` serde names are the intra-cluster peer-RPC wire
|
||||
/// format and must never appear here — see
|
||||
/// `crate::admin::replication_metrics_wire`.
|
||||
fn serialize_replication_metrics_body(
|
||||
bucket_stats: &BucketStats,
|
||||
route: ReplicationExtRoute,
|
||||
node_name: &str,
|
||||
) -> S3Result<Vec<u8>> {
|
||||
use crate::admin::replication_metrics_wire::{MetricsV2Wire, MetricsWire};
|
||||
match route {
|
||||
ReplicationExtRoute::MetricsV1 => {
|
||||
serde_json::to_vec(&bucket_stats.replication_stats).map_err(|e| s3_error!(InternalError, "{e}"))
|
||||
serde_json::to_vec(&MetricsWire::from(&bucket_stats.replication_stats)).map_err(|e| s3_error!(InternalError, "{e}"))
|
||||
}
|
||||
ReplicationExtRoute::MetricsV2 => {
|
||||
serde_json::to_vec(&MetricsV2Wire::from_stats(bucket_stats, node_name)).map_err(|e| s3_error!(InternalError, "{e}"))
|
||||
}
|
||||
ReplicationExtRoute::MetricsV2 => serde_json::to_vec(bucket_stats).map_err(|e| s3_error!(InternalError, "{e}")),
|
||||
ReplicationExtRoute::Check | ReplicationExtRoute::ResetStart | ReplicationExtRoute::ResetStatus => {
|
||||
Err(s3_error!(InternalError, "invalid route for metrics response"))
|
||||
}
|
||||
@@ -4147,22 +4160,37 @@ mod tests {
|
||||
assert!(err.message().unwrap_or_default().contains("rule-stale"));
|
||||
}
|
||||
|
||||
/// The v1 body must decode into minio-go `replication.Metrics` (exact
|
||||
/// json tags); Go's decoder matches case-insensitively but does not
|
||||
/// ignore underscores, so the internal snake_case names read as all-zero.
|
||||
#[test]
|
||||
fn serialize_replication_metrics_body_v1_returns_replication_stats_only() {
|
||||
fn serialize_replication_metrics_body_v1_returns_minio_go_metrics_shape() {
|
||||
let mut stats = BucketStats {
|
||||
uptime: 99,
|
||||
..Default::default()
|
||||
};
|
||||
stats.replication_stats.replica_count = 7;
|
||||
stats.replication_stats.replicated_size = 2048;
|
||||
stats
|
||||
.replication_stats
|
||||
.stats
|
||||
.entry("arn:minio:replication::t:b".to_string())
|
||||
.or_default()
|
||||
.replicated_count = 5;
|
||||
stats.proxy_stats.put_total = 3;
|
||||
|
||||
let body =
|
||||
serialize_replication_metrics_body(&stats, ReplicationExtRoute::MetricsV1).expect("metrics v1 body should serialize");
|
||||
let body = serialize_replication_metrics_body(&stats, ReplicationExtRoute::MetricsV1, "node-1:9000")
|
||||
.expect("metrics v1 body should serialize");
|
||||
let payload: serde_json::Value = serde_json::from_slice(&body).expect("body should be json");
|
||||
|
||||
assert_eq!(payload["replica_count"], 7);
|
||||
assert_eq!(payload["replicaCount"], 7);
|
||||
assert_eq!(payload["completedReplicationSize"], 2048);
|
||||
assert_eq!(payload["Stats"]["arn:minio:replication::t:b"]["replicationCount"], 5);
|
||||
assert!(payload.get("uptime").is_none());
|
||||
assert!(payload.get("proxy_stats").is_none());
|
||||
// The internal snake_case names must not leak into the wire body.
|
||||
assert!(payload.get("replica_count").is_none());
|
||||
assert!(payload.get("q_stat").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -4248,22 +4276,48 @@ mod tests {
|
||||
assert_eq!(target.current_bandwidth_bytes_per_sec, 3000.0);
|
||||
}
|
||||
|
||||
/// The v2 body must decode into minio-go `replication.MetricsV2`
|
||||
/// (`uptime`/`currStats`/`queueStats`); `mc replicate status` reads
|
||||
/// `currStats` and `queueStats.nodes` and silently shows zeros when the
|
||||
/// keys do not match.
|
||||
#[test]
|
||||
fn serialize_replication_metrics_body_v2_returns_full_bucket_stats() {
|
||||
fn serialize_replication_metrics_body_v2_returns_minio_go_metrics_v2_shape() {
|
||||
let mut stats = BucketStats {
|
||||
uptime: 99,
|
||||
..Default::default()
|
||||
};
|
||||
stats.replication_stats.replica_count = 7;
|
||||
stats
|
||||
.replication_stats
|
||||
.q_stat
|
||||
.curr
|
||||
.now_count
|
||||
.store(4, std::sync::atomic::Ordering::Relaxed);
|
||||
stats
|
||||
.replication_stats
|
||||
.q_stat
|
||||
.curr
|
||||
.now_bytes
|
||||
.store(1200, std::sync::atomic::Ordering::Relaxed);
|
||||
stats.replication_stats.q_stat = stats.replication_stats.q_stat.snapshot();
|
||||
stats.proxy_stats.put_total = 3;
|
||||
|
||||
let body =
|
||||
serialize_replication_metrics_body(&stats, ReplicationExtRoute::MetricsV2).expect("metrics v2 body should serialize");
|
||||
let body = serialize_replication_metrics_body(&stats, ReplicationExtRoute::MetricsV2, "node-1:9000")
|
||||
.expect("metrics v2 body should serialize");
|
||||
let payload: serde_json::Value = serde_json::from_slice(&body).expect("body should be json");
|
||||
|
||||
assert_eq!(payload["uptime"], 99);
|
||||
assert_eq!(payload["replication_stats"]["replica_count"], 7);
|
||||
assert_eq!(payload["proxy_stats"]["put_total"], 3);
|
||||
assert_eq!(payload["currStats"]["replicaCount"], 7);
|
||||
assert_eq!(payload["currStats"]["queued"]["curr"]["count"], 4.0);
|
||||
// The queue snapshot must surface at least one node: mc derives the
|
||||
// worker/queue panels from queueStats.nodes and treats an empty list
|
||||
// as "no data".
|
||||
assert_eq!(payload["queueStats"]["nodes"][0]["queueStats"]["curr"]["count"], 4.0);
|
||||
assert_eq!(payload["queueStats"]["nodes"][0]["uptime"], 99);
|
||||
// The internal snake_case names must not leak into the wire body.
|
||||
assert!(payload.get("replication_stats").is_none());
|
||||
assert!(payload.get("queue_stats").is_none());
|
||||
assert!(payload.get("proxy_stats").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -18,28 +18,21 @@
|
||||
//! `config/site-replication/state.json` is mutated by read-modify-write
|
||||
//! sequences spread over many call sites: admin handlers, the retry-event
|
||||
//! writers on every hook broadcast path, and the service-side reload driven
|
||||
//! over node RPC. Historically only some of them held the process-local
|
||||
//! mutex and none held a distributed lock across the whole RMW, so
|
||||
//! concurrent writers overwrote each other (single-process for the unlocked
|
||||
//! writers, cross-node for everyone).
|
||||
//! over node RPC.
|
||||
//!
|
||||
//! `with_site_replication_state_lock` is the single transaction boundary:
|
||||
//! it holds the process-local mutex AND the distributed config-object write
|
||||
//! lock (the pattern proven by the repair state,
|
||||
//! `update_site_replication_repair_state`) for the duration of the caller's
|
||||
//! closure. All IO inside the closure must use the `*_no_lock` config
|
||||
//! helpers — the locked variants would self-deadlock on the same object
|
||||
//! lock. Do not perform peer network calls or take other config locks
|
||||
//! it holds the distributed config-object write lock (the pattern proven by
|
||||
//! the repair state, `update_site_replication_repair_state`) for the
|
||||
//! duration of the caller's closure. The object lock is the sole mechanism —
|
||||
//! it is the only thing that can serialize two nodes of the same site, so a
|
||||
//! process-local lock must never be reintroduced in front of it as if it
|
||||
//! added protection. All IO inside the closure must use the `*_no_lock`
|
||||
//! config helpers — the locked variants would self-deadlock on the same
|
||||
//! object lock. Do not perform peer network calls or take other config locks
|
||||
//! inside the closure.
|
||||
//!
|
||||
//! The process-local mutex is transitional: call sites still outside this
|
||||
//! primitive serialize against migrated ones through it. Once every RMW
|
||||
//! call site goes through here (P1-15 PR2) it will be removed, leaving the
|
||||
//! object lock as the only mechanism.
|
||||
//!
|
||||
//! Lock order (unchanged from the historical comment next to the mutex):
|
||||
//! lifecycle -> bucket operation -> repair admission -> state (process
|
||||
//! mutex, then state object lock) -> per-bucket metadata.
|
||||
//! Lock order: lifecycle -> bucket operation -> repair admission
|
||||
//! -> state object lock -> per-bucket metadata.
|
||||
|
||||
use crate::admin::storage_api::runtime::ECStore;
|
||||
use crate::admin::storage_api::s3::{S3Error, S3ErrorCode, S3Result};
|
||||
@@ -53,24 +46,8 @@ use super::runtime_sources::current_object_store_handle;
|
||||
/// byte-level tolerant reload on the service side.
|
||||
pub(crate) const SITE_REPLICATION_STATE_PATH: &str = "config/site-replication/state.json";
|
||||
|
||||
/// Transitional process-local mutex — see the module docs. Stays private to
|
||||
/// this module (owner-local static, enforced by
|
||||
/// `scripts/check_architecture_migration_rules.sh`); callers go through
|
||||
/// [`site_replication_state_process_guard`].
|
||||
static SITE_REPLICATION_STATE_LOCK: std::sync::LazyLock<tokio::sync::Mutex<()>> =
|
||||
std::sync::LazyLock::new(|| tokio::sync::Mutex::new(()));
|
||||
|
||||
/// Owner helper for the transitional process mutex: the RMW call sites in
|
||||
/// `handlers::site_replication` that PR2 has not migrated to
|
||||
/// [`with_site_replication_state_lock`] yet hold this guard so they stay
|
||||
/// mutually exclusive with the migrated ones. Removed together with the
|
||||
/// mutex once every call site runs inside the transaction boundary.
|
||||
pub(crate) async fn site_replication_state_process_guard() -> tokio::sync::MutexGuard<'static, ()> {
|
||||
SITE_REPLICATION_STATE_LOCK.lock().await
|
||||
}
|
||||
|
||||
/// Run `operation` under the site-replication state transaction boundary:
|
||||
/// process mutex first, then the distributed state-object write lock.
|
||||
/// the distributed state-object write lock.
|
||||
pub(crate) async fn with_site_replication_state_lock<T, F, Fut>(operation: F) -> S3Result<T>
|
||||
where
|
||||
T: Send + 'static,
|
||||
@@ -83,21 +60,11 @@ where
|
||||
|
||||
/// Context-store variant for callers that resolve their store from an
|
||||
/// explicit [`AppContext`] (the service-side reload driven over node RPC).
|
||||
///
|
||||
/// This is the whole boundary: a state-object write lock serializes writers
|
||||
/// in *different* processes, which is what two nodes of one site are and
|
||||
/// what a process mutex could never cover.
|
||||
pub(crate) async fn with_site_replication_state_lock_on<T, F, Fut>(store: Arc<ECStore>, operation: F) -> S3Result<T>
|
||||
where
|
||||
T: Send + 'static,
|
||||
F: FnOnce() -> Fut + Send + 'static,
|
||||
Fut: std::future::Future<Output = S3Result<T>> + Send + 'static,
|
||||
{
|
||||
let _process_guard = SITE_REPLICATION_STATE_LOCK.lock().await;
|
||||
with_site_replication_state_object_lock(store, operation).await
|
||||
}
|
||||
|
||||
/// The distributed half of the boundary on its own: the state-object write
|
||||
/// lock, without the process mutex. This is the only thing that serializes
|
||||
/// writers in *different* processes (the mutex cannot), so it is also what
|
||||
/// the separate-nodes regression test drives.
|
||||
pub(crate) async fn with_site_replication_state_object_lock<T, F, Fut>(store: Arc<ECStore>, operation: F) -> S3Result<T>
|
||||
where
|
||||
T: Send + 'static,
|
||||
F: FnOnce() -> Fut + Send + 'static,
|
||||
|
||||
@@ -321,6 +321,34 @@ pub(crate) mod metadata_sys {
|
||||
crate::storage::storage_api::acquire_bucket_metadata_transaction_lock(bucket).await
|
||||
}
|
||||
|
||||
pub(crate) async fn acquire_bucket_metadata_transaction_lock_for_incarnation(
|
||||
bucket: &str,
|
||||
expected_incarnation_id: uuid::Uuid,
|
||||
) -> Result<super::ecstore_bucket::metadata_sys::BucketMetadataMutationGuard> {
|
||||
super::ecstore_bucket::metadata_sys::acquire_bucket_metadata_transaction_lock_for_incarnation(
|
||||
bucket,
|
||||
expected_incarnation_id,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn update_under_transaction_lock(
|
||||
guard: &super::ecstore_bucket::metadata_sys::BucketMetadataMutationGuard,
|
||||
bucket: &str,
|
||||
config_file: &str,
|
||||
data: Vec<u8>,
|
||||
) -> Result<OffsetDateTime> {
|
||||
super::ecstore_bucket::metadata_sys::update_under_transaction_lock(guard, bucket, config_file, data).await
|
||||
}
|
||||
|
||||
pub(crate) async fn delete_under_transaction_lock(
|
||||
guard: &super::ecstore_bucket::metadata_sys::BucketMetadataMutationGuard,
|
||||
bucket: &str,
|
||||
config_file: &str,
|
||||
) -> Result<OffsetDateTime> {
|
||||
super::ecstore_bucket::metadata_sys::delete_under_transaction_lock(guard, bucket, config_file).await
|
||||
}
|
||||
|
||||
pub(crate) async fn update_bucket_targets_under_transaction_lock(
|
||||
guard: &super::ecstore_bucket::metadata_sys::BucketMetadataMutationGuard,
|
||||
bucket: &str,
|
||||
@@ -417,6 +445,10 @@ pub(crate) mod replication {
|
||||
};
|
||||
pub(crate) type BucketReplicationResyncStatus = super::ecstore_bucket::replication::BucketReplicationResyncStatus;
|
||||
pub(crate) type BucketStats = super::ecstore_bucket::replication::BucketStats;
|
||||
pub(crate) type BucketReplicationStats = super::ecstore_bucket::replication::BucketReplicationStats;
|
||||
pub(crate) type BucketReplicationStat = super::ecstore_bucket::replication::BucketReplicationStat;
|
||||
pub(crate) type InQueueMetric = super::ecstore_bucket::replication::InQueueMetric;
|
||||
pub(crate) type XferStats = super::ecstore_bucket::replication::XferStats;
|
||||
pub(crate) type ReplicationStatusType = super::ecstore_bucket::replication::ReplicationStatusType;
|
||||
pub(crate) type ResyncOpts = super::ecstore_bucket::replication::ResyncOpts;
|
||||
pub(crate) type ResyncStatusType = super::ecstore_bucket::replication::ResyncStatusType;
|
||||
|
||||
@@ -1158,6 +1158,19 @@ fn lifecycle_has_expiry_rules(config: &BucketLifecycleConfiguration) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
/// Status-independent presence of the expiry subset that site replication
|
||||
/// propagates (`replicateILMExpiry`): expiration / noncurrent-version
|
||||
/// expiration only. Distinct from [`lifecycle_has_expiry_rules`], which
|
||||
/// filters on ENABLED for scanner scheduling — editing a Disabled expiry rule
|
||||
/// must still advance the replication axis. Del-marker expiration and
|
||||
/// abort-multipart are site-local and never travel.
|
||||
fn lifecycle_rules_have_expiry(config: &BucketLifecycleConfiguration) -> bool {
|
||||
config
|
||||
.rules
|
||||
.iter()
|
||||
.any(|rule| rule.expiration.is_some() || rule.noncurrent_version_expiration.is_some())
|
||||
}
|
||||
|
||||
fn lifecycle_has_abort_multipart_rules(config: &BucketLifecycleConfiguration) -> bool {
|
||||
config.rules.iter().any(|rule| {
|
||||
rule.status == ExpirationStatus::from_static(ExpirationStatus::ENABLED)
|
||||
@@ -2186,7 +2199,24 @@ impl DefaultBucketUsecase {
|
||||
return Err(s3_error!(InvalidArgument, "{err}"));
|
||||
}
|
||||
|
||||
input_cfg.expiry_updated_at = Some(Timestamp::from(time::OffsetDateTime::now_utc()));
|
||||
// Stamp the expiry axis only when the expiry subset can have changed
|
||||
// (MinIO: HasExpiry() || expiryRuleRemoved). Site-replication peers
|
||||
// judge lc-config staleness on this axis; a transition-only edit that
|
||||
// advanced it would let this site's stale expiry subset shadow — and
|
||||
// roll back — a newer peer expiry edit fleet-wide.
|
||||
let previous_expiry_updated_at = match metadata_sys::get_lifecycle_config(&bucket).await {
|
||||
Ok((previous, _)) => {
|
||||
if lifecycle_rules_have_expiry(&input_cfg) || lifecycle_rules_have_expiry(&previous) {
|
||||
Some(Timestamp::from(time::OffsetDateTime::now_utc()))
|
||||
} else {
|
||||
previous.expiry_updated_at
|
||||
}
|
||||
}
|
||||
// No previous config (or unreadable): stamping is the
|
||||
// conservative pre-existing behavior.
|
||||
Err(_) => lifecycle_rules_have_expiry(&input_cfg).then(|| Timestamp::from(time::OffsetDateTime::now_utc())),
|
||||
};
|
||||
input_cfg.expiry_updated_at = previous_expiry_updated_at;
|
||||
let data = serialize_config(&input_cfg)?;
|
||||
update_bucket_config_for_incarnation(&bucket, BUCKET_LIFECYCLE_CONFIG, data, expected_incarnation_id)
|
||||
.await
|
||||
@@ -2197,7 +2227,14 @@ impl DefaultBucketUsecase {
|
||||
let mut item = sr_bucket_meta_item(bucket.clone(), "lc-config");
|
||||
item.expiry_lc_config =
|
||||
Some(serialize_config(&input_cfg).and_then(|bytes| String::from_utf8(bytes).map_err(to_internal_error))?);
|
||||
item.expiry_updated_at = item.updated_at;
|
||||
// The item travels with the expiry axis, not the wall clock: a site
|
||||
// whose expiry knowledge is old (or absent — UNIX_EPOCH) must not
|
||||
// out-rank newer peer expiry state at the receivers.
|
||||
item.expiry_updated_at = input_cfg
|
||||
.expiry_updated_at
|
||||
.clone()
|
||||
.map(time::OffsetDateTime::from)
|
||||
.or(Some(time::OffsetDateTime::UNIX_EPOCH));
|
||||
if let Err(err) = site_replication_bucket_meta_hook(item).await {
|
||||
warn!(bucket = %bucket, error = ?err, "site replication bucket lifecycle hook failed");
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ use super::storage_api::multipart_usecase::bucket::{
|
||||
replication::{must_replicate_object, schedule_object_replication},
|
||||
versioning_sys::BucketVersioningSys,
|
||||
};
|
||||
use super::storage_api::multipart_usecase::compression::{is_disk_compressible, is_multipart_disk_compression_enabled};
|
||||
#[cfg(test)]
|
||||
use super::storage_api::multipart_usecase::contract::http::HTTPPreconditions;
|
||||
use super::storage_api::multipart_usecase::contract::multipart::{CompletePart, MultipartOperations as _, MultipartUploadResult};
|
||||
@@ -39,7 +40,7 @@ use super::storage_api::multipart_usecase::error::{StorageError, is_err_object_n
|
||||
use super::storage_api::multipart_usecase::helper::OperationHelper;
|
||||
#[cfg(test)]
|
||||
use super::storage_api::multipart_usecase::io::{DecryptReader, EncryptReader, HardLimitReader, boxed_reader, wrap_reader};
|
||||
use super::storage_api::multipart_usecase::io::{HashReader, WriteEncryption, WritePlan};
|
||||
use super::storage_api::multipart_usecase::io::{HashReader, WriteEncryption, WritePlan, compression_metadata_value};
|
||||
use super::storage_api::multipart_usecase::object_utils::to_s3s_etag;
|
||||
use super::storage_api::multipart_usecase::options::{
|
||||
copy_src_opts, extract_metadata_from_mime, get_complete_multipart_upload_opts_with_replication_authorization,
|
||||
@@ -210,6 +211,28 @@ fn create_multipart_upload_metadata(
|
||||
metadata
|
||||
}
|
||||
|
||||
/// A multipart session advertises disk compression only when the staged-rollout
|
||||
/// switch (`RUSTFS_COMPRESSION_MULTIPART_ENABLED`) is on, the object key/headers
|
||||
/// qualify, AND the session is not an SSE-C ciphertext-passthrough replication
|
||||
/// session, which must preserve source bytes verbatim.
|
||||
///
|
||||
/// The rollout switch defaults to off so a rolling upgrade never creates new
|
||||
/// compressed multipart objects while pre-fix nodes (whose decompressor is not
|
||||
/// resumable) may still serve reads. Enable it once the fleet has converged on a
|
||||
/// fixed build; the default flips per the `multipart-compression-default-off-window`
|
||||
/// entry in docs/architecture/compat-cleanup-register.md.
|
||||
///
|
||||
/// Each part is compressed as an independent stream; the GET path decodes across part
|
||||
/// boundaries (see `ReadTransform::Compressed`), so the session may advertise
|
||||
/// object-level compression again.
|
||||
///
|
||||
/// Unlike single PUT there is no `MIN_DISK_COMPRESSIBLE_SIZE` floor here: the total
|
||||
/// object size is unknown at CreateMultipartUpload time, so tiny multipart objects pay
|
||||
/// the (harmless) framing overhead. This is a deliberate trade-off, not a bug.
|
||||
fn should_advertise_session_compression(multipart_enabled: bool, ciphertext_passthrough: bool, disk_compressible: bool) -> bool {
|
||||
multipart_enabled && !ciphertext_passthrough && disk_compressible
|
||||
}
|
||||
|
||||
async fn validate_table_catalog_object_mutation(bucket: &str, key: &str) -> S3Result<()> {
|
||||
table_catalog::validate_bucket_object_mutation(bucket, key)
|
||||
.await
|
||||
@@ -837,8 +860,17 @@ impl DefaultMultipartUsecase {
|
||||
None => (None, None),
|
||||
};
|
||||
|
||||
// Multipart parts are independent physical streams. Advertising object-level
|
||||
// compression here would make GET decode the completed object as one stream.
|
||||
if should_advertise_session_compression(
|
||||
is_multipart_disk_compression_enabled(),
|
||||
ciphertext_passthrough,
|
||||
is_disk_compressible(&req.headers, &key),
|
||||
) {
|
||||
rustfs_utils::http::insert_str(
|
||||
&mut metadata,
|
||||
rustfs_utils::http::SUFFIX_COMPRESSION,
|
||||
compression_metadata_value(CompressionAlgorithm::default()),
|
||||
);
|
||||
}
|
||||
|
||||
let mt2 = metadata.clone();
|
||||
let mut opts: ObjectOptions =
|
||||
@@ -1632,6 +1664,31 @@ mod tests {
|
||||
DefaultMultipartUsecase::without_context()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_compression_is_advertised_only_for_non_passthrough_compressible_uploads() {
|
||||
// (multipart_enabled, ciphertext_passthrough, disk_compressible, expected)
|
||||
let cases = [
|
||||
(true, false, false, false),
|
||||
(true, false, true, true),
|
||||
(true, true, false, false),
|
||||
(true, true, true, false),
|
||||
// The staged-rollout switch keeps multipart compression dark by
|
||||
// default regardless of the other gates.
|
||||
(false, false, true, false),
|
||||
(false, false, false, false),
|
||||
(false, true, true, false),
|
||||
(false, true, false, false),
|
||||
];
|
||||
|
||||
for (multipart_enabled, ciphertext_passthrough, disk_compressible, expected) in cases {
|
||||
assert_eq!(
|
||||
should_advertise_session_compression(multipart_enabled, ciphertext_passthrough, disk_compressible),
|
||||
expected,
|
||||
"multipart_enabled={multipart_enabled} ciphertext_passthrough={ciphertext_passthrough} disk_compressible={disk_compressible}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quota_accounting_uses_logical_size_when_available() {
|
||||
let mut metadata = HashMap::new();
|
||||
|
||||
@@ -2241,10 +2241,11 @@ fn get_object_resume_control(ctx: GetObjectResumeContext) -> GetObjectResumeCont
|
||||
/// disks" failures keep the existing fail-loud behavior.
|
||||
fn is_object_relocation_error(err: &std::io::Error) -> bool {
|
||||
let Some(inner) = err.get_ref() else { return false };
|
||||
matches!(
|
||||
inner.downcast_ref::<StorageError>(),
|
||||
Some(StorageError::FileNotFound | StorageError::ObjectNotFound(..) | StorageError::InsufficientReadQuorum(..))
|
||||
)
|
||||
match inner.downcast_ref::<StorageError>() {
|
||||
Some(StorageError::FileNotFound | StorageError::ObjectNotFound(..) | StorageError::InsufficientReadQuorum(..)) => true,
|
||||
Some(StorageError::Io(source)) => source.kind() == std::io::ErrorKind::NotFound,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the S3 request-body inter-chunk read timeout from the environment.
|
||||
@@ -13163,6 +13164,7 @@ mod tests {
|
||||
StorageError::FileNotFound,
|
||||
StorageError::ObjectNotFound("test-bucket".to_string(), "relocated-object".to_string()),
|
||||
StorageError::InsufficientReadQuorum("test-bucket".to_string(), "relocated-object".to_string()),
|
||||
StorageError::Io(std::io::Error::new(std::io::ErrorKind::NotFound, "relocated shard disappeared")),
|
||||
] {
|
||||
let reopen_count = Arc::new(AtomicUsize::new(0));
|
||||
let control = counting_resume_control(Arc::clone(&reopen_count), |emitted| {
|
||||
|
||||
@@ -942,7 +942,9 @@ pub(crate) mod concurrency {
|
||||
}
|
||||
|
||||
pub(crate) mod compression {
|
||||
pub(crate) use crate::storage::storage_api::ecstore_compression::{MIN_DISK_COMPRESSIBLE_SIZE, is_disk_compressible};
|
||||
pub(crate) use crate::storage::storage_api::ecstore_compression::{
|
||||
MIN_DISK_COMPRESSIBLE_SIZE, is_disk_compressible, is_multipart_disk_compression_enabled,
|
||||
};
|
||||
}
|
||||
|
||||
pub(crate) mod deadlock_detector {
|
||||
@@ -1153,7 +1155,7 @@ pub(crate) mod multipart_usecase {
|
||||
}
|
||||
|
||||
pub(crate) use super::{
|
||||
access, bucket, data_usage, error, helper, io, object_utils, options, request_context, s3_api, set_disk, sse,
|
||||
access, bucket, compression, data_usage, error, helper, io, object_utils, options, request_context, s3_api, set_disk, sse,
|
||||
};
|
||||
pub(crate) use crate::storage::storage_api::{ECStore, StorageObjectInfo, StorageObjectOptions, StoragePutObjReader};
|
||||
}
|
||||
|
||||
+49
-4
@@ -326,7 +326,11 @@ impl From<StorageError> for ApiError {
|
||||
_ => S3ErrorCode::InternalError,
|
||||
};
|
||||
|
||||
let message = if matches!(&err, StorageError::QuotaExceeded { .. }) || code == S3ErrorCode::InternalError {
|
||||
let message = if matches!(&err, StorageError::QuotaExceeded { .. }) {
|
||||
err.to_string()
|
||||
} else if code == S3ErrorCode::InternalError && matches!(&err, StorageError::Io(_)) {
|
||||
ApiError::error_code_to_message(&code)
|
||||
} else if code == S3ErrorCode::InternalError {
|
||||
err.to_string()
|
||||
} else if let StorageError::InvalidArgument(_, _, reason) = &err
|
||||
&& !reason.is_empty()
|
||||
@@ -525,6 +529,25 @@ mod tests {
|
||||
assert!(api_error.source.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn storage_io_internal_error_redacts_public_message_and_retains_source() {
|
||||
let sensitive_path = "/sensitive/storage/path";
|
||||
let api_error = ApiError::from(StorageError::Io(IoError::new(
|
||||
ErrorKind::PermissionDenied,
|
||||
format!("permission denied: {sensitive_path}"),
|
||||
)));
|
||||
|
||||
assert_eq!(api_error.code, S3ErrorCode::InternalError);
|
||||
assert_eq!(api_error.message, ApiError::error_code_to_message(&S3ErrorCode::InternalError));
|
||||
assert!(!api_error.message.contains(sensitive_path));
|
||||
let source = api_error
|
||||
.source
|
||||
.as_deref()
|
||||
.and_then(|source| source.downcast_ref::<StorageError>())
|
||||
.expect("API error should retain the storage error source");
|
||||
assert!(matches!(source, StorageError::Io(io_error) if io_error.to_string().contains(sensitive_path)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_kms_service_unavailable_maps_to_retryable_error() {
|
||||
let api_error = ApiError::from(StorageError::other(KmsUnavailableError));
|
||||
@@ -669,14 +692,36 @@ mod tests {
|
||||
assert!(api_error.source.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_api_error_from_storage_io_copy_object_terminal_error_stays_internal() {
|
||||
let io_error = IoError::other(StorageError::FileCorrupt);
|
||||
let storage_error: StorageError = io_error.into();
|
||||
assert!(matches!(storage_error, StorageError::FileCorrupt));
|
||||
|
||||
let api_error: ApiError = storage_error.into();
|
||||
|
||||
assert_eq!(api_error.code, S3ErrorCode::InternalError);
|
||||
let source = api_error
|
||||
.source
|
||||
.as_deref()
|
||||
.and_then(|source| source.downcast_ref::<StorageError>())
|
||||
.expect("API error should retain the storage error source");
|
||||
assert!(matches!(source, StorageError::FileCorrupt));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_api_error_from_iam_error() {
|
||||
let iam_error = rustfs_iam::error::Error::other("IAM test error");
|
||||
let api_error: ApiError = iam_error.into();
|
||||
|
||||
// IAM error is first converted to StorageError, then to ApiError
|
||||
assert!(api_error.source.is_some());
|
||||
assert!(api_error.message.contains("test error"));
|
||||
assert_eq!(api_error.code, S3ErrorCode::InternalError);
|
||||
assert_eq!(api_error.message, ApiError::error_code_to_message(&S3ErrorCode::InternalError));
|
||||
let source = api_error
|
||||
.source
|
||||
.as_deref()
|
||||
.and_then(|source| source.downcast_ref::<StorageError>())
|
||||
.expect("API error should retain the storage error source");
|
||||
assert!(matches!(source, StorageError::Io(io_error) if io_error.to_string().contains("IAM test error")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1478,7 +1478,7 @@ async fn retain_table_data_plane_publication_guard<T>(
|
||||
.map_err(|err| s3_error!(InternalError, "failed to acquire table publication guard: {}", err))?;
|
||||
let mut state = retained.state.lock();
|
||||
state.keys.insert(key);
|
||||
state.guards.push(guard);
|
||||
state.guards.push(Box::new(guard));
|
||||
drop(state);
|
||||
req.extensions.insert(retained);
|
||||
Ok(())
|
||||
|
||||
@@ -39,6 +39,11 @@ pub(crate) struct ListMultipartUploadsParams {
|
||||
pub(crate) fn build_list_parts_output(res: ListPartsInfo) -> ListPartsOutput {
|
||||
let owner = rustfs_owner();
|
||||
let initiator = rustfs_initiator();
|
||||
let transformed_parts = rustfs_utils::http::contains_key_str(&res.user_defined, rustfs_utils::http::SUFFIX_COMPRESSION)
|
||||
|| res
|
||||
.user_defined
|
||||
.keys()
|
||||
.any(|key| rustfs_utils::http::is_object_encryption_marker(key));
|
||||
|
||||
ListPartsOutput {
|
||||
bucket: Some(res.bucket),
|
||||
@@ -51,7 +56,14 @@ pub(crate) fn build_list_parts_output(res: ListPartsInfo) -> ListPartsOutput {
|
||||
e_tag: p.etag.map(|etag| to_s3s_etag(&etag)),
|
||||
last_modified: p.last_mod.map(Timestamp::from),
|
||||
part_number: p.part_num.try_into().ok(),
|
||||
size: p.size.try_into().ok(),
|
||||
// Compressed parts store fewer bytes than the client sent; S3
|
||||
// semantics report the uploaded (logical) size, matching
|
||||
// GetObjectAttributes ObjectParts.
|
||||
size: if p.actual_size > 0 || (transformed_parts && p.actual_size == 0) {
|
||||
Some(p.actual_size)
|
||||
} else {
|
||||
p.size.try_into().ok()
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.collect(),
|
||||
@@ -247,6 +259,116 @@ mod tests {
|
||||
assert_eq!(output.initiator, Some(rustfs_initiator()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_parts_output_reports_logical_size_for_compressed_parts() {
|
||||
let input = ListPartsInfo {
|
||||
bucket: "bucket-a".to_string(),
|
||||
object: "obj-a".to_string(),
|
||||
upload_id: "upload-a".to_string(),
|
||||
parts: vec![PartInfo {
|
||||
part_num: 1,
|
||||
// Stored (compressed) bytes on disk vs. the logical size the client uploaded.
|
||||
size: 1_024,
|
||||
actual_size: 8_388_608,
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let output = build_list_parts_output(input);
|
||||
let parts = output.parts.as_ref().expect("parts should be present");
|
||||
|
||||
assert_eq!(parts.len(), 1);
|
||||
assert_eq!(
|
||||
parts[0].size,
|
||||
Some(8_388_608),
|
||||
"compressed parts must report the uploaded logical size, not the stored size"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_parts_output_reports_zero_logical_size_for_compressed_parts() {
|
||||
let mut user_defined = std::collections::HashMap::new();
|
||||
rustfs_utils::http::insert_str(&mut user_defined, rustfs_utils::http::SUFFIX_COMPRESSION, "S2".to_string());
|
||||
let input = ListPartsInfo {
|
||||
user_defined,
|
||||
parts: vec![PartInfo {
|
||||
part_num: 1,
|
||||
// Legacy SSE writes an 8-byte end record for an empty part.
|
||||
size: 8,
|
||||
actual_size: 0,
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let output = build_list_parts_output(input);
|
||||
let parts = output.parts.as_ref().expect("parts should be present");
|
||||
|
||||
assert_eq!(parts[0].size, Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_parts_output_reports_zero_logical_size_for_encrypted_parts() {
|
||||
let input = ListPartsInfo {
|
||||
user_defined: std::collections::HashMap::from([(
|
||||
rustfs_utils::http::AMZ_SERVER_SIDE_ENCRYPTION.to_string(),
|
||||
"AES256".to_string(),
|
||||
)]),
|
||||
parts: vec![
|
||||
PartInfo {
|
||||
part_num: 1,
|
||||
size: 8,
|
||||
actual_size: 0,
|
||||
..Default::default()
|
||||
},
|
||||
PartInfo {
|
||||
part_num: 2,
|
||||
size: 8,
|
||||
actual_size: -1,
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let output = build_list_parts_output(input);
|
||||
let parts = output.parts.as_ref().expect("parts should be present");
|
||||
|
||||
assert_eq!(parts[0].size, Some(0));
|
||||
assert_eq!(parts[1].size, Some(8));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_parts_output_falls_back_to_stored_size_when_actual_size_unknown() {
|
||||
let input = ListPartsInfo {
|
||||
parts: vec![
|
||||
PartInfo {
|
||||
part_num: 1,
|
||||
size: 1_024,
|
||||
// Uncompressed parts leave actual_size unset.
|
||||
actual_size: 0,
|
||||
..Default::default()
|
||||
},
|
||||
PartInfo {
|
||||
part_num: 2,
|
||||
size: 1_024,
|
||||
// Legacy/unknown sentinel must not leak a negative size to clients.
|
||||
actual_size: -1,
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let output = build_list_parts_output(input);
|
||||
let parts = output.parts.as_ref().expect("parts should be present");
|
||||
|
||||
assert_eq!(parts.len(), 2);
|
||||
assert_eq!(parts[0].size, Some(1024));
|
||||
assert_eq!(parts[1].size, Some(1024));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_parts_output_normalizes_legacy_storage_class_and_handles_overflow_markers() {
|
||||
let input = ListPartsInfo {
|
||||
|
||||
@@ -4565,11 +4565,9 @@ mod tests {
|
||||
})
|
||||
.await
|
||||
.expect_err("mismatched kms context should fail");
|
||||
assert!(
|
||||
err.message.contains("context") || err.message.contains("Context"),
|
||||
"unexpected error for mismatched kms context: {}",
|
||||
err.message
|
||||
);
|
||||
assert_eq!(err.code, S3ErrorCode::InternalError);
|
||||
assert_eq!(err.message, ApiError::error_code_to_message(&S3ErrorCode::InternalError));
|
||||
assert_eq!(super::kms_data_plane_error_class(&err), "context_mismatch");
|
||||
|
||||
manager.stop().await.expect("kms service should stop cleanly");
|
||||
reset_sse_dek_provider();
|
||||
@@ -5336,7 +5334,16 @@ mod tests {
|
||||
|
||||
let error = TestSseDekProvider::decrypt_dek(&envelope, [0x55u8; 32])
|
||||
.expect_err("unknown JSON envelope versions must fail closed");
|
||||
assert!(error.message.contains("Unsupported encrypted DEK format version"));
|
||||
assert_eq!(error.code, S3ErrorCode::InternalError);
|
||||
assert_eq!(error.message, ApiError::error_code_to_message(&S3ErrorCode::InternalError));
|
||||
let source = error
|
||||
.source
|
||||
.as_deref()
|
||||
.and_then(|source| source.downcast_ref::<StorageError>())
|
||||
.expect("API error should retain the storage error source");
|
||||
assert!(matches!(source, StorageError::Io(io_error) if io_error
|
||||
.to_string()
|
||||
.contains("Unsupported encrypted DEK format version")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -5894,10 +5901,16 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_map_get_object_reader_error_leaves_non_ssec_errors_unchanged() {
|
||||
fn test_map_get_object_reader_error_redacts_non_ssec_internal_errors() {
|
||||
let err = map_get_object_reader_error(StorageError::other("plain io failure"));
|
||||
assert_eq!(err.code, S3ErrorCode::InternalError);
|
||||
assert_eq!(err.message, "Io error: plain io failure");
|
||||
assert_eq!(err.message, ApiError::error_code_to_message(&S3ErrorCode::InternalError));
|
||||
let source = err
|
||||
.source
|
||||
.as_deref()
|
||||
.and_then(|source| source.downcast_ref::<StorageError>())
|
||||
.expect("API error should retain the storage error source");
|
||||
assert!(matches!(source, StorageError::Io(io_error) if io_error.to_string().contains("plain io failure")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -409,7 +409,9 @@ pub(crate) mod ecstore_client {
|
||||
}
|
||||
|
||||
pub(crate) mod ecstore_compression {
|
||||
pub(crate) use rustfs_ecstore::api::compression::{MIN_DISK_COMPRESSIBLE_SIZE, is_disk_compressible};
|
||||
pub(crate) use rustfs_ecstore::api::compression::{
|
||||
MIN_DISK_COMPRESSIBLE_SIZE, is_disk_compressible, is_multipart_disk_compression_enabled,
|
||||
};
|
||||
}
|
||||
|
||||
pub(crate) mod ecstore_cluster {
|
||||
|
||||
@@ -16,6 +16,8 @@ use std::io::Read;
|
||||
|
||||
use super::super::*;
|
||||
|
||||
const AVRO_ZSTANDARD_MAX_WINDOW_LOG: u32 = 27;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub(crate) struct ManifestDataFileReference {
|
||||
pub location: String,
|
||||
@@ -66,6 +68,7 @@ pub(crate) struct DecodedManifestList {
|
||||
pub(crate) struct DecodedManifest {
|
||||
pub references: Vec<ManifestDataFileReference>,
|
||||
pub decoded_size: usize,
|
||||
pub partition_spec_id: Option<i32>,
|
||||
}
|
||||
|
||||
pub(crate) fn manifest_paths_from_manifest_list_avro(data: &[u8]) -> TableCatalogStoreResult<Vec<String>> {
|
||||
@@ -92,6 +95,25 @@ pub(crate) fn decode_manifest_list_avro(data: &[u8]) -> TableCatalogStoreResult<
|
||||
.map_err(|err| TableCatalogStoreError::Invalid(format!("failed to read manifest list Avro: {err}")))?;
|
||||
let format_version =
|
||||
avro_record_format_version(reader.writer_schema(), &["sequence_number", "min_sequence_number"], "manifest list")?;
|
||||
if format_version == 2 {
|
||||
let apache_avro::Schema::Record(record) = reader.writer_schema() else {
|
||||
return Err(TableCatalogStoreError::Invalid("manifest list Avro schema must be a record".to_string()));
|
||||
};
|
||||
for field in [
|
||||
"added_files_count",
|
||||
"existing_files_count",
|
||||
"deleted_files_count",
|
||||
"added_rows_count",
|
||||
"existing_rows_count",
|
||||
"deleted_rows_count",
|
||||
] {
|
||||
if !record.lookup.contains_key(field) {
|
||||
return Err(TableCatalogStoreError::Invalid(format!(
|
||||
"Iceberg v2 manifest list Avro schema is missing {field}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut manifest_paths = Vec::new();
|
||||
for value in reader {
|
||||
if manifest_paths.len() >= TABLE_MANIFEST_AVRO_MAX_RECORDS {
|
||||
@@ -115,24 +137,12 @@ pub(crate) fn decode_manifest_list_avro(data: &[u8]) -> TableCatalogStoreResult<
|
||||
sequence_number: avro_record_field(&value, "sequence_number").and_then(avro_i64_value),
|
||||
min_sequence_number: avro_record_field(&value, "min_sequence_number").and_then(avro_i64_value),
|
||||
added_snapshot_id: avro_record_field(&value, "added_snapshot_id").and_then(avro_i64_value),
|
||||
added_files_count: avro_record_field(&value, "added_files_count")
|
||||
.and_then(avro_i32_value)
|
||||
.and_then(|value| u64::try_from(value).ok()),
|
||||
existing_files_count: avro_record_field(&value, "existing_files_count")
|
||||
.and_then(avro_i32_value)
|
||||
.and_then(|value| u64::try_from(value).ok()),
|
||||
deleted_files_count: avro_record_field(&value, "deleted_files_count")
|
||||
.and_then(avro_i32_value)
|
||||
.and_then(|value| u64::try_from(value).ok()),
|
||||
added_rows_count: avro_record_field(&value, "added_rows_count")
|
||||
.and_then(avro_i64_value)
|
||||
.and_then(|value| u64::try_from(value).ok()),
|
||||
existing_rows_count: avro_record_field(&value, "existing_rows_count")
|
||||
.and_then(avro_i64_value)
|
||||
.and_then(|value| u64::try_from(value).ok()),
|
||||
deleted_rows_count: avro_record_field(&value, "deleted_rows_count")
|
||||
.and_then(avro_i64_value)
|
||||
.and_then(|value| u64::try_from(value).ok()),
|
||||
added_files_count: avro_nullable_non_negative_i32(&value, "added_files_count", "manifest list")?,
|
||||
existing_files_count: avro_nullable_non_negative_i32(&value, "existing_files_count", "manifest list")?,
|
||||
deleted_files_count: avro_nullable_non_negative_i32(&value, "deleted_files_count", "manifest list")?,
|
||||
added_rows_count: avro_nullable_non_negative_i64(&value, "added_rows_count", "manifest list")?,
|
||||
existing_rows_count: avro_nullable_non_negative_i64(&value, "existing_rows_count", "manifest list")?,
|
||||
deleted_rows_count: avro_nullable_non_negative_i64(&value, "deleted_rows_count", "manifest list")?,
|
||||
});
|
||||
}
|
||||
Ok(DecodedManifestList {
|
||||
@@ -171,6 +181,17 @@ pub(crate) fn decode_manifest_avro(data: &[u8]) -> TableCatalogStoreResult<Decod
|
||||
.map_err(|err| TableCatalogStoreError::Invalid(format!("failed to read manifest Avro: {err}")))?;
|
||||
let format_version =
|
||||
avro_record_format_version(reader.writer_schema(), &["sequence_number", "file_sequence_number"], "manifest")?;
|
||||
let partition_spec_id = reader
|
||||
.user_metadata()
|
||||
.get("partition-spec-id")
|
||||
.map(|value| {
|
||||
std::str::from_utf8(value)
|
||||
.ok()
|
||||
.and_then(|value| value.parse::<i32>().ok())
|
||||
.filter(|value| *value >= 0)
|
||||
.ok_or_else(|| TableCatalogStoreError::Invalid("manifest partition-spec-id metadata is invalid".to_string()))
|
||||
})
|
||||
.transpose()?;
|
||||
let mut files = Vec::new();
|
||||
for value in reader {
|
||||
if files.len() >= TABLE_MANIFEST_AVRO_MAX_RECORDS {
|
||||
@@ -202,6 +223,9 @@ pub(crate) fn decode_manifest_avro(data: &[u8]) -> TableCatalogStoreResult<Decod
|
||||
)));
|
||||
}
|
||||
};
|
||||
let partition = avro_record_field(data_file, "partition")
|
||||
.and_then(avro_record_value_fields)
|
||||
.ok_or_else(|| TableCatalogStoreError::Invalid("manifest data file partition must be a record".to_string()))?;
|
||||
files.push(ManifestDataFileReference {
|
||||
location: file_path.to_string(),
|
||||
format_version,
|
||||
@@ -218,15 +242,14 @@ pub(crate) fn decode_manifest_avro(data: &[u8]) -> TableCatalogStoreResult<Decod
|
||||
file_size_bytes: avro_record_field(data_file, "file_size_in_bytes")
|
||||
.and_then(avro_i64_value)
|
||||
.and_then(|value| u64::try_from(value).ok()),
|
||||
partition: avro_record_field(data_file, "partition")
|
||||
.and_then(avro_record_value_fields)
|
||||
.unwrap_or_default(),
|
||||
partition,
|
||||
sort_order_id: avro_record_field(data_file, "sort_order_id").and_then(avro_i32_value),
|
||||
});
|
||||
}
|
||||
Ok(DecodedManifest {
|
||||
references: files,
|
||||
decoded_size,
|
||||
partition_spec_id,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -240,6 +263,8 @@ pub(crate) async fn decode_manifest_avro_async(data: Vec<u8>) -> TableCatalogSto
|
||||
enum AvroContainerCodec {
|
||||
Null,
|
||||
Deflate,
|
||||
Snappy,
|
||||
Zstandard,
|
||||
}
|
||||
|
||||
fn validate_avro_container(data: &[u8]) -> TableCatalogStoreResult<usize> {
|
||||
@@ -300,6 +325,8 @@ fn validate_avro_container(data: &[u8]) -> TableCatalogStoreResult<usize> {
|
||||
let codec = match codec.unwrap_or(b"null") {
|
||||
b"null" => AvroContainerCodec::Null,
|
||||
b"deflate" => AvroContainerCodec::Deflate,
|
||||
b"snappy" => AvroContainerCodec::Snappy,
|
||||
b"zstandard" => AvroContainerCodec::Zstandard,
|
||||
codec => {
|
||||
return Err(TableCatalogStoreError::Unsupported(format!(
|
||||
"Avro codec {} is not supported for table commit validation",
|
||||
@@ -369,6 +396,34 @@ fn avro_block_decoded_size(codec: AvroContainerCodec, block: &[u8], remaining_si
|
||||
usize::try_from(decoded_size)
|
||||
.map_err(|_| TableCatalogStoreError::Invalid("Avro decoded data size is invalid".to_string()))
|
||||
}
|
||||
AvroContainerCodec::Snappy => {
|
||||
let data_end = block
|
||||
.len()
|
||||
.checked_sub(4)
|
||||
.ok_or_else(|| TableCatalogStoreError::Invalid("Avro snappy block is missing its checksum".to_string()))?;
|
||||
let decoded_size = snap::raw::decompress_len(&block[..data_end])
|
||||
.map_err(|err| TableCatalogStoreError::Invalid(format!("failed to inspect Avro snappy block: {err}")))?;
|
||||
if decoded_size > remaining_size {
|
||||
return Err(TableCatalogStoreError::Invalid("Avro decoded data exceeds the commit limit".to_string()));
|
||||
}
|
||||
Ok(decoded_size)
|
||||
}
|
||||
AvroContainerCodec::Zstandard => {
|
||||
let limit = remaining_size
|
||||
.checked_add(1)
|
||||
.ok_or_else(|| TableCatalogStoreError::Invalid("Avro decoded data size limit overflowed".to_string()))?;
|
||||
let limit = u64::try_from(limit)
|
||||
.map_err(|_| TableCatalogStoreError::Invalid("Avro decoded data size limit is invalid".to_string()))?;
|
||||
let mut decoder = zstd::stream::read::Decoder::new(block)
|
||||
.map_err(|err| TableCatalogStoreError::Invalid(format!("failed to decompress Avro zstandard block: {err}")))?;
|
||||
decoder
|
||||
.window_log_max(AVRO_ZSTANDARD_MAX_WINDOW_LOG)
|
||||
.map_err(|err| TableCatalogStoreError::Invalid(format!("failed to bound Avro zstandard window: {err}")))?;
|
||||
let decoded_size = std::io::copy(&mut decoder.take(limit), &mut std::io::sink())
|
||||
.map_err(|err| TableCatalogStoreError::Invalid(format!("failed to decompress Avro zstandard block: {err}")))?;
|
||||
usize::try_from(decoded_size)
|
||||
.map_err(|_| TableCatalogStoreError::Invalid("Avro decoded data size is invalid".to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -445,6 +500,40 @@ fn avro_record_value_fields(value: &apache_avro::types::Value) -> Option<Vec<(St
|
||||
)
|
||||
}
|
||||
|
||||
fn avro_nullable_non_negative_i32(
|
||||
value: &apache_avro::types::Value,
|
||||
field: &str,
|
||||
label: &str,
|
||||
) -> TableCatalogStoreResult<Option<u64>> {
|
||||
let Some(value) = avro_record_field(value, field) else {
|
||||
return Ok(None);
|
||||
};
|
||||
match avro_non_union_value(value) {
|
||||
apache_avro::types::Value::Null => Ok(None),
|
||||
apache_avro::types::Value::Int(value) => u64::try_from(*value)
|
||||
.map(Some)
|
||||
.map_err(|_| TableCatalogStoreError::Invalid(format!("{label} field {field} must be a non-negative int"))),
|
||||
_ => Err(TableCatalogStoreError::Invalid(format!("{label} field {field} must be a nullable int"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn avro_nullable_non_negative_i64(
|
||||
value: &apache_avro::types::Value,
|
||||
field: &str,
|
||||
label: &str,
|
||||
) -> TableCatalogStoreResult<Option<u64>> {
|
||||
let Some(value) = avro_record_field(value, field) else {
|
||||
return Ok(None);
|
||||
};
|
||||
match avro_non_union_value(value) {
|
||||
apache_avro::types::Value::Null => Ok(None),
|
||||
apache_avro::types::Value::Long(value) => u64::try_from(*value)
|
||||
.map(Some)
|
||||
.map_err(|_| TableCatalogStoreError::Invalid(format!("{label} field {field} must be a non-negative long"))),
|
||||
_ => Err(TableCatalogStoreError::Invalid(format!("{label} field {field} must be a nullable long"))),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn avro_non_union_value(value: &apache_avro::types::Value) -> &apache_avro::types::Value {
|
||||
match value {
|
||||
apache_avro::types::Value::Union(_, inner) => avro_non_union_value(inner),
|
||||
@@ -472,3 +561,127 @@ fn avro_i64_value(value: &apache_avro::types::Value) -> Option<i64> {
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rejects_v2_manifest_lists_without_required_count_fields() {
|
||||
let schema = apache_avro::Schema::parse_str(
|
||||
r#"{
|
||||
"type": "record",
|
||||
"name": "manifest_file",
|
||||
"fields": [
|
||||
{"name": "manifest_path", "type": "string"},
|
||||
{"name": "manifest_length", "type": "long"},
|
||||
{"name": "partition_spec_id", "type": "int"},
|
||||
{"name": "content", "type": "int"},
|
||||
{"name": "sequence_number", "type": "long"},
|
||||
{"name": "min_sequence_number", "type": "long"},
|
||||
{"name": "added_snapshot_id", "type": "long"}
|
||||
]
|
||||
}"#,
|
||||
)
|
||||
.expect("incomplete manifest-list schema should parse");
|
||||
let data = apache_avro::Writer::new(&schema, Vec::new())
|
||||
.expect("manifest-list writer should initialize")
|
||||
.into_inner()
|
||||
.expect("manifest-list bytes should flush");
|
||||
|
||||
let error = match decode_manifest_list_avro(&data) {
|
||||
Ok(_) => panic!("v2 count fields must be declared in the writer schema"),
|
||||
Err(error) => error,
|
||||
};
|
||||
assert_eq!(
|
||||
error,
|
||||
TableCatalogStoreError::Invalid("Iceberg v2 manifest list Avro schema is missing added_files_count".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_negative_nullable_manifest_list_counts() {
|
||||
let value = apache_avro::types::Value::Record(vec![
|
||||
("added_files_count".to_string(), apache_avro::types::Value::Int(-1)),
|
||||
("added_rows_count".to_string(), apache_avro::types::Value::Long(-1)),
|
||||
]);
|
||||
|
||||
assert_eq!(
|
||||
avro_nullable_non_negative_i32(&value, "added_files_count", "manifest list")
|
||||
.expect_err("negative file counts must be rejected"),
|
||||
TableCatalogStoreError::Invalid("manifest list field added_files_count must be a non-negative int".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
avro_nullable_non_negative_i64(&value, "added_rows_count", "manifest list")
|
||||
.expect_err("negative row counts must be rejected"),
|
||||
TableCatalogStoreError::Invalid("manifest list field added_rows_count must be a non-negative long".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_manifest_partition_with_non_record_schema() {
|
||||
let schema = apache_avro::Schema::parse_str(
|
||||
r#"{
|
||||
"type": "record",
|
||||
"name": "manifest_entry",
|
||||
"fields": [
|
||||
{"name": "status", "type": "int"},
|
||||
{"name": "snapshot_id", "type": "long"},
|
||||
{
|
||||
"name": "data_file",
|
||||
"type": {
|
||||
"type": "record",
|
||||
"name": "data_file",
|
||||
"fields": [
|
||||
{"name": "file_path", "type": "string"},
|
||||
{"name": "record_count", "type": "long"},
|
||||
{"name": "file_size_in_bytes", "type": "long"},
|
||||
{"name": "partition", "type": "string"}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
)
|
||||
.expect("manifest schema should parse");
|
||||
let mut writer = apache_avro::Writer::new(&schema, Vec::new()).expect("manifest writer should initialize");
|
||||
writer
|
||||
.append_value(apache_avro::types::Value::Record(vec![
|
||||
("status".to_string(), apache_avro::types::Value::Int(1)),
|
||||
("snapshot_id".to_string(), apache_avro::types::Value::Long(1)),
|
||||
(
|
||||
"data_file".to_string(),
|
||||
apache_avro::types::Value::Record(vec![
|
||||
(
|
||||
"file_path".to_string(),
|
||||
apache_avro::types::Value::String("s3://warehouse/tables/table-id/data/file.parquet".to_string()),
|
||||
),
|
||||
("record_count".to_string(), apache_avro::types::Value::Long(1)),
|
||||
("file_size_in_bytes".to_string(), apache_avro::types::Value::Long(1)),
|
||||
("partition".to_string(), apache_avro::types::Value::String("not-a-record".to_string())),
|
||||
]),
|
||||
),
|
||||
]))
|
||||
.expect("manifest record should append");
|
||||
let data = writer.into_inner().expect("manifest bytes should flush");
|
||||
|
||||
let error = match decode_manifest_avro(&data) {
|
||||
Ok(_) => panic!("manifest partitions must preserve their record shape"),
|
||||
Err(error) => error,
|
||||
};
|
||||
assert_eq!(
|
||||
error,
|
||||
TableCatalogStoreError::Invalid("manifest data file partition must be a record".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_oversized_zstandard_windows() {
|
||||
// Non-single-segment frame with a 2^28-byte window and one empty final block.
|
||||
let compressed = [0x28, 0xb5, 0x2f, 0xfd, 0x00, 0x90, 0x01, 0x00, 0x00];
|
||||
|
||||
let error = avro_block_decoded_size(AvroContainerCodec::Zstandard, &compressed, TABLE_MANIFEST_AVRO_MAX_DECODED_SIZE)
|
||||
.expect_err("zstandard windows larger than the manifest decode budget must be rejected");
|
||||
assert!(matches!(error, TableCatalogStoreError::Invalid(_)));
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -111,8 +111,12 @@ const TABLE_MANIFEST_AVRO_MAX_DECODED_SIZE: usize = 128 * 1024 * 1024;
|
||||
const TABLE_MANIFEST_AVRO_MAX_RECORDS: usize = 1_000_000;
|
||||
const TABLE_MANIFEST_AVRO_MAX_HEADER_ENTRIES: usize = 1_024;
|
||||
const TABLE_COMMIT_MAX_MANIFESTS: usize = 10_000;
|
||||
const TABLE_COMMIT_MAX_MANIFEST_TRAVERSALS: usize = 20_000;
|
||||
const TABLE_COMMIT_MAX_AVRO_BYTES: usize = 512 * 1024 * 1024;
|
||||
const TABLE_COMMIT_MAX_FILE_REFERENCES: usize = 1_000_000;
|
||||
const TABLE_COMMIT_MAX_STATISTICS_OBJECTS: usize = 1_024;
|
||||
const TABLE_COMMIT_MAX_STATISTICS_BYTES: usize = 512 * 1024 * 1024;
|
||||
const TABLE_STATISTICS_FILE_MAX_SIZE: usize = 128 * 1024 * 1024;
|
||||
pub(crate) const TABLE_COMMIT_OBJECT_VALIDATION_CONCURRENCY: usize = 16;
|
||||
pub const TABLE_RESERVED_PREFIX: &str = BUCKET_TABLE_RESERVED_PREFIX;
|
||||
const WAREHOUSE_ROOT: &str = "warehouses";
|
||||
|
||||
@@ -216,7 +216,7 @@ where
|
||||
Ok(fence)
|
||||
}
|
||||
|
||||
pub(super) async fn acquire_table_bucket_registry_write_permit(&self) -> TableCatalogStoreResult<Box<dyn Send>> {
|
||||
pub(super) async fn acquire_table_bucket_registry_write_permit(&self) -> TableCatalogStoreResult<TableCatalogLockGuard> {
|
||||
let fence_path = self.paths.backing_migration_global_fence_path();
|
||||
let lock_path = self.paths.backing_migration_global_fence_lock_path();
|
||||
let guard = self.backend.acquire_read_lock(self.catalog_bucket(), &lock_path).await?;
|
||||
@@ -235,7 +235,7 @@ where
|
||||
pub(super) async fn acquire_object_backed_catalog_write_permit(
|
||||
&self,
|
||||
table_bucket: &str,
|
||||
) -> TableCatalogStoreResult<Box<dyn Send>> {
|
||||
) -> TableCatalogStoreResult<TableCatalogLockGuard> {
|
||||
let lock_path = self.paths.backing_migration_fence_lock_path(table_bucket);
|
||||
let guard = self.backend.acquire_read_lock(self.catalog_bucket(), &lock_path).await?;
|
||||
if self.read_backing_migration_fence(table_bucket).await?.is_some() {
|
||||
@@ -290,7 +290,7 @@ where
|
||||
async fn collect_bucket_snapshot_with_locks(
|
||||
&self,
|
||||
table_bucket: &str,
|
||||
guards: &mut Vec<Box<dyn Send>>,
|
||||
guards: &mut Vec<TableCatalogLockGuard>,
|
||||
) -> TableCatalogStoreResult<StrongTableCatalogBucketSnapshot> {
|
||||
let bucket_path = self.paths.table_bucket_entry_path(table_bucket);
|
||||
guards.push(self.backend.acquire_write_lock(self.catalog_bucket(), &bucket_path).await?);
|
||||
|
||||
@@ -262,6 +262,29 @@ pub(crate) trait TableCatalogStore: Send + Sync {
|
||||
|
||||
async fn create_view(&self, entry: ViewEntry) -> TableCatalogStoreResult<()>;
|
||||
|
||||
async fn create_view_with_publication(
|
||||
&self,
|
||||
entry: ViewEntry,
|
||||
publication: &(dyn TableCommitPublication + Sync),
|
||||
) -> TableCatalogStoreResult<()> {
|
||||
publication.begin_table_bucket(&entry.table_bucket).await?;
|
||||
if !publication.holds_table_bucket(&entry.table_bucket) {
|
||||
return Err(TableCatalogStoreError::Internal(
|
||||
"view creation requires a table-bucket publication fence".to_string(),
|
||||
));
|
||||
}
|
||||
publication
|
||||
.prepare(&entry.table_bucket, &entry.namespace, &entry.view)
|
||||
.await?;
|
||||
if !publication.holds_table(&entry.table_bucket, &entry.namespace, &entry.view) {
|
||||
return Err(TableCatalogStoreError::Internal(
|
||||
"view creation requires a view publication fence".to_string(),
|
||||
));
|
||||
}
|
||||
let _publication_completion = TableCommitPublicationCompletion::new(publication);
|
||||
self.create_view(entry).await
|
||||
}
|
||||
|
||||
async fn list_views(&self, table_bucket: &str, namespace: &str) -> TableCatalogStoreResult<Vec<ViewEntry>>;
|
||||
|
||||
async fn list_views_page(
|
||||
@@ -283,6 +306,32 @@ pub(crate) trait TableCatalogStore: Send + Sync {
|
||||
|
||||
async fn replace_view(&self, request: ViewCommitRequest) -> TableCatalogStoreResult<ViewCommitResult>;
|
||||
|
||||
async fn replace_view_with_publication(
|
||||
&self,
|
||||
request: ViewCommitRequest,
|
||||
table_bucket_fence_required: bool,
|
||||
publication: &(dyn TableCommitPublication + Sync),
|
||||
) -> TableCatalogStoreResult<ViewCommitResult> {
|
||||
if table_bucket_fence_required {
|
||||
publication.begin_table_bucket(&request.table_bucket).await?;
|
||||
if !publication.holds_table_bucket(&request.table_bucket) {
|
||||
return Err(TableCatalogStoreError::Internal(
|
||||
"view replacement requires a table-bucket publication fence".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
publication
|
||||
.prepare(&request.table_bucket, &request.namespace, &request.view)
|
||||
.await?;
|
||||
if !publication.holds_table(&request.table_bucket, &request.namespace, &request.view) {
|
||||
return Err(TableCatalogStoreError::Internal(
|
||||
"view replacement requires a view publication fence".to_string(),
|
||||
));
|
||||
}
|
||||
let _publication_completion = TableCommitPublicationCompletion::new(publication);
|
||||
self.replace_view(request).await
|
||||
}
|
||||
|
||||
async fn drop_view(&self, table_bucket: &str, namespace: &str, view: &str) -> TableCatalogStoreResult<()>;
|
||||
|
||||
async fn get_commit_by_id(
|
||||
@@ -338,7 +387,7 @@ struct TableCommitLockPublication<'a, B> {
|
||||
struct TableCommitLockPublicationState {
|
||||
table_bucket: Option<String>,
|
||||
table: Option<(String, String, String)>,
|
||||
guards: Vec<Box<dyn Send>>,
|
||||
guards: Vec<TableCatalogLockGuard>,
|
||||
}
|
||||
|
||||
impl<'a, B> TableCommitLockPublication<'a, B> {
|
||||
@@ -409,15 +458,17 @@ where
|
||||
}
|
||||
|
||||
fn holds_table_bucket(&self, table_bucket: &str) -> bool {
|
||||
self.state.lock().table_bucket.as_deref() == Some(table_bucket)
|
||||
let state = self.state.lock();
|
||||
state.table_bucket.as_deref() == Some(table_bucket) && state.guards.iter().all(|guard| !guard.is_lock_lost())
|
||||
}
|
||||
|
||||
fn holds_table(&self, table_bucket: &str, namespace: &str, table: &str) -> bool {
|
||||
self.state
|
||||
.lock()
|
||||
let state = self.state.lock();
|
||||
state
|
||||
.table
|
||||
.as_ref()
|
||||
.is_some_and(|held| held.0 == table_bucket && held.1 == namespace && held.2 == table)
|
||||
&& state.guards.iter().all(|guard| !guard.is_lock_lost())
|
||||
}
|
||||
|
||||
fn complete(&self) {
|
||||
@@ -438,6 +489,32 @@ pub(crate) struct TableCatalogObjectMetadata {
|
||||
pub mod_time: Option<OffsetDateTime>,
|
||||
}
|
||||
|
||||
pub(crate) struct TableCatalogLockGuard {
|
||||
_guard: Box<dyn Send>,
|
||||
lock_lost: Option<Arc<rustfs_lock::distributed_lock::LockLostSignal>>,
|
||||
}
|
||||
|
||||
impl TableCatalogLockGuard {
|
||||
pub(crate) fn stable(guard: impl Send + 'static) -> Self {
|
||||
Self {
|
||||
_guard: Box::new(guard),
|
||||
lock_lost: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn namespace(guard: rustfs_lock::NamespaceLockGuard) -> Self {
|
||||
let lock_lost = guard.lock_lost_signal();
|
||||
Self {
|
||||
_guard: Box::new(guard),
|
||||
lock_lost,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn is_lock_lost(&self) -> bool {
|
||||
self.lock_lost.as_ref().is_some_and(|signal| signal.is_lost())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct TableCatalogObjectListPage {
|
||||
pub objects: Vec<String>,
|
||||
@@ -588,11 +665,11 @@ pub(crate) trait TableCatalogObjectBackend: Clone + Send + Sync + 'static {
|
||||
Ok(TableCatalogObjectListPage { objects, is_truncated })
|
||||
}
|
||||
|
||||
async fn acquire_read_lock(&self, bucket: &str, object: &str) -> TableCatalogStoreResult<Box<dyn Send>> {
|
||||
async fn acquire_read_lock(&self, bucket: &str, object: &str) -> TableCatalogStoreResult<TableCatalogLockGuard> {
|
||||
self.acquire_write_lock(bucket, object).await
|
||||
}
|
||||
|
||||
async fn acquire_write_lock(&self, bucket: &str, object: &str) -> TableCatalogStoreResult<Box<dyn Send>>;
|
||||
async fn acquire_write_lock(&self, bucket: &str, object: &str) -> TableCatalogStoreResult<TableCatalogLockGuard>;
|
||||
|
||||
async fn begin_table_bucket_commit_publication(&self, _table_bucket: &str) -> TableCatalogStoreResult<()> {
|
||||
Ok(())
|
||||
@@ -1169,6 +1246,17 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
async fn create_view_with_publication(
|
||||
&self,
|
||||
entry: ViewEntry,
|
||||
publication: &(dyn TableCommitPublication + Sync),
|
||||
) -> TableCatalogStoreResult<()> {
|
||||
match self {
|
||||
Self::ObjectBacked(store) => store.create_view_with_publication(entry, publication).await,
|
||||
Self::DurableStrong(store) => store.create_view_with_publication(entry, publication).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_views(&self, table_bucket: &str, namespace: &str) -> TableCatalogStoreResult<Vec<ViewEntry>> {
|
||||
match self {
|
||||
Self::ObjectBacked(store) => store.list_views(table_bucket, namespace).await,
|
||||
@@ -1203,6 +1291,26 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
async fn replace_view_with_publication(
|
||||
&self,
|
||||
request: ViewCommitRequest,
|
||||
table_bucket_fence_required: bool,
|
||||
publication: &(dyn TableCommitPublication + Sync),
|
||||
) -> TableCatalogStoreResult<ViewCommitResult> {
|
||||
match self {
|
||||
Self::ObjectBacked(store) => {
|
||||
store
|
||||
.replace_view_with_publication(request, table_bucket_fence_required, publication)
|
||||
.await
|
||||
}
|
||||
Self::DurableStrong(store) => {
|
||||
store
|
||||
.replace_view_with_publication(request, table_bucket_fence_required, publication)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn drop_view(&self, table_bucket: &str, namespace: &str, view: &str) -> TableCatalogStoreResult<()> {
|
||||
match self {
|
||||
Self::ObjectBacked(store) => store.drop_view(table_bucket, namespace, view).await,
|
||||
@@ -1686,7 +1794,7 @@ where
|
||||
})
|
||||
}
|
||||
|
||||
async fn acquire_write_lock(&self, bucket: &str, object: &str) -> TableCatalogStoreResult<Box<dyn Send>> {
|
||||
async fn acquire_write_lock(&self, bucket: &str, object: &str) -> TableCatalogStoreResult<TableCatalogLockGuard> {
|
||||
let lock = self
|
||||
.store
|
||||
.new_ns_lock(bucket, object)
|
||||
@@ -1696,10 +1804,10 @@ where
|
||||
.get_write_lock(get_lock_acquire_timeout())
|
||||
.await
|
||||
.map_err(|err| TableCatalogStoreError::Internal(format!("failed to acquire catalog table lock: {err}")))?;
|
||||
Ok(Box::new(guard))
|
||||
Ok(TableCatalogLockGuard::namespace(guard))
|
||||
}
|
||||
|
||||
async fn acquire_read_lock(&self, bucket: &str, object: &str) -> TableCatalogStoreResult<Box<dyn Send>> {
|
||||
async fn acquire_read_lock(&self, bucket: &str, object: &str) -> TableCatalogStoreResult<TableCatalogLockGuard> {
|
||||
let lock = self
|
||||
.store
|
||||
.new_ns_lock(bucket, object)
|
||||
@@ -1709,7 +1817,7 @@ where
|
||||
.get_read_lock(get_lock_acquire_timeout())
|
||||
.await
|
||||
.map_err(|err| TableCatalogStoreError::Internal(format!("failed to acquire catalog migration lock: {err}")))?;
|
||||
Ok(Box::new(guard))
|
||||
Ok(TableCatalogLockGuard::namespace(guard))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1036,6 +1036,20 @@ where
|
||||
.await
|
||||
}
|
||||
|
||||
async fn restore_table_warehouse_index_after_failed_drop(&self, entry: &TableEntry, reason: &'static str) {
|
||||
if let Err(err) = self.reserve_table_warehouse_index(entry).await {
|
||||
tracing::warn!(
|
||||
table_bucket = %entry.table_bucket,
|
||||
namespace = %entry.namespace,
|
||||
table = %entry.table,
|
||||
table_id = %entry.table_id,
|
||||
reason,
|
||||
error = %err,
|
||||
"failed to restore table warehouse index after table drop stopped"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete_table_warehouse_index_if_changed(&self, current: &TableEntry, next: &TableEntry) {
|
||||
let Ok(current_index) = table_warehouse_index_entry(current) else {
|
||||
return;
|
||||
@@ -1316,6 +1330,15 @@ where
|
||||
}
|
||||
self.ensure_table_warehouse_prefix_available(&entry).await?;
|
||||
let reservation = self.reserve_table_warehouse_index(&entry).await?;
|
||||
if !publication.holds_table_bucket(&entry.table_bucket)
|
||||
|| !publication.holds_table(&entry.table_bucket, &entry.namespace, &entry.table)
|
||||
{
|
||||
self.delete_created_table_warehouse_index(&entry, reservation, "table publication fence lost")
|
||||
.await;
|
||||
return Err(TableCatalogStoreError::Internal(
|
||||
"table registration publication fence was lost before catalog update".to_string(),
|
||||
));
|
||||
}
|
||||
let result = self
|
||||
.write_entry_unlocked(self.catalog_bucket(), &table_path, &entry, precondition)
|
||||
.await;
|
||||
@@ -1327,7 +1350,25 @@ where
|
||||
}
|
||||
|
||||
async fn write_view_entry(&self, entry: ViewEntry, precondition: TableCatalogPutPrecondition) -> TableCatalogStoreResult<()> {
|
||||
let publication = TableCommitLockPublication::new(&self.backend);
|
||||
self.write_view_entry_with_publication(entry, precondition, &publication)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn write_view_entry_with_publication(
|
||||
&self,
|
||||
entry: ViewEntry,
|
||||
precondition: TableCatalogPutPrecondition,
|
||||
publication: &(dyn TableCommitPublication + Sync),
|
||||
) -> TableCatalogStoreResult<()> {
|
||||
validate_view_entry_version_and_id(&entry)?;
|
||||
publication.begin_table_bucket(&entry.table_bucket).await?;
|
||||
if !publication.holds_table_bucket(&entry.table_bucket) {
|
||||
return Err(TableCatalogStoreError::Internal(
|
||||
"view creation requires a table-bucket publication fence".to_string(),
|
||||
));
|
||||
}
|
||||
let _publication_completion = TableCommitPublicationCompletion::new(publication);
|
||||
self.require_table_bucket(&entry.table_bucket).await?;
|
||||
let namespace = parse_namespace_for_store(&entry.namespace)?;
|
||||
let view = parse_table_for_store(&entry.view)?;
|
||||
@@ -1353,6 +1394,17 @@ where
|
||||
entry.table_bucket, entry.namespace, entry.view
|
||||
)));
|
||||
}
|
||||
// Preserve catalog -> publication -> object lock order across rolling upgrades.
|
||||
publication
|
||||
.prepare(&entry.table_bucket, &entry.namespace, &entry.view)
|
||||
.await?;
|
||||
if !publication.holds_table_bucket(&entry.table_bucket)
|
||||
|| !publication.holds_table(&entry.table_bucket, &entry.namespace, &entry.view)
|
||||
{
|
||||
return Err(TableCatalogStoreError::Internal(
|
||||
"view creation publication fence was lost before catalog update".to_string(),
|
||||
));
|
||||
}
|
||||
self.write_entry_unlocked(self.catalog_bucket(), &view_path, &entry, precondition)
|
||||
.await
|
||||
}
|
||||
@@ -4493,16 +4545,16 @@ where
|
||||
validate_commit_metadata_digest(&request, &new_metadata_object)?;
|
||||
let table_bucket = request.table_bucket.clone();
|
||||
let metadata_location = request.new_metadata_location.clone();
|
||||
let next_warehouse_location = tokio::task::spawn_blocking(move || {
|
||||
table_metadata_warehouse_location(&table_bucket, &metadata_location, &new_metadata_object)
|
||||
let next_metadata_state = tokio::task::spawn_blocking(move || {
|
||||
table_metadata_commit_state(&table_bucket, &metadata_location, &new_metadata_object)
|
||||
})
|
||||
.await
|
||||
.map_err(|err| TableCatalogStoreError::Internal(format!("table metadata parser task failed: {err}")))??;
|
||||
if next_warehouse_location
|
||||
let warehouse_relocation = next_metadata_state
|
||||
.warehouse_location
|
||||
.as_ref()
|
||||
.is_some_and(|warehouse_location| warehouse_location != ¤t.warehouse_location)
|
||||
&& !publication.holds_table_bucket(&request.table_bucket)
|
||||
{
|
||||
.is_some_and(|warehouse_location| warehouse_location != ¤t.warehouse_location);
|
||||
if warehouse_relocation && !publication.holds_table_bucket(&request.table_bucket) {
|
||||
return table_commit_result(
|
||||
&request.table_bucket,
|
||||
&request.namespace,
|
||||
@@ -4537,9 +4589,12 @@ where
|
||||
|
||||
let mut next = current.clone();
|
||||
next.metadata_location = staged_commit_log.new_metadata_location.clone();
|
||||
if let Some(warehouse_location) = next_warehouse_location {
|
||||
if let Some(warehouse_location) = next_metadata_state.warehouse_location {
|
||||
next.warehouse_location = warehouse_location;
|
||||
}
|
||||
if let Some(format_version) = next_metadata_state.format_version {
|
||||
next.format_version = format_version;
|
||||
}
|
||||
next.version_token = staged_commit_log.new_version_token.clone();
|
||||
next.generation = current.generation.saturating_add(1);
|
||||
if next.warehouse_location != current.warehouse_location {
|
||||
@@ -4585,6 +4640,24 @@ where
|
||||
);
|
||||
}
|
||||
|
||||
if !publication.holds_table(&request.table_bucket, &request.namespace, &request.table)
|
||||
|| (warehouse_relocation && !publication.holds_table_bucket(&request.table_bucket))
|
||||
{
|
||||
self.delete_created_table_warehouse_index(&next, reservation, "table publication fence lost")
|
||||
.await;
|
||||
return table_commit_result(
|
||||
&request.table_bucket,
|
||||
&request.namespace,
|
||||
&request.table,
|
||||
&request.commit_id,
|
||||
&request.operation,
|
||||
commit_started,
|
||||
Err(TableCatalogStoreError::Internal(
|
||||
"table commit publication fence was lost before pointer update".to_string(),
|
||||
)),
|
||||
);
|
||||
}
|
||||
|
||||
let cas_started = Instant::now();
|
||||
let cas_result = self
|
||||
.write_entry_unlocked(
|
||||
@@ -4662,20 +4735,21 @@ where
|
||||
)));
|
||||
};
|
||||
self.delete_owned_table_warehouse_index_for_drop(&entry).await?;
|
||||
if !publication.holds_table_bucket(table_bucket)
|
||||
|| !publication.holds_table(table_bucket, &namespace.public_name(), table.as_str())
|
||||
{
|
||||
self.restore_table_warehouse_index_after_failed_drop(&entry, "table publication fence lost")
|
||||
.await;
|
||||
return Err(TableCatalogStoreError::Internal(
|
||||
"table drop publication fence was lost before catalog update".to_string(),
|
||||
));
|
||||
}
|
||||
if let Err(err) = self.backend.delete_object_unlocked(self.catalog_bucket(), &object).await {
|
||||
match self.read_table_with_etag_unlocked(table_bucket, &namespace, &table).await {
|
||||
Ok(None) => return Ok(()),
|
||||
Ok(Some((current, _))) if current == entry => {
|
||||
if let Err(restore_err) = self.reserve_table_warehouse_index(&entry).await {
|
||||
tracing::warn!(
|
||||
table_bucket = %entry.table_bucket,
|
||||
namespace = %entry.namespace,
|
||||
table = %entry.table,
|
||||
table_id = %entry.table_id,
|
||||
error = %restore_err,
|
||||
"failed to restore table warehouse index after table entry delete failure"
|
||||
);
|
||||
}
|
||||
self.restore_table_warehouse_index_after_failed_drop(&entry, "table entry delete failed")
|
||||
.await;
|
||||
}
|
||||
Ok(Some(_)) => {
|
||||
return Err(TableCatalogStoreError::Internal(format!(
|
||||
@@ -4703,6 +4777,15 @@ where
|
||||
self.write_view_entry(entry, TableCatalogPutPrecondition::IfAbsent).await
|
||||
}
|
||||
|
||||
async fn create_view_with_publication(
|
||||
&self,
|
||||
entry: ViewEntry,
|
||||
publication: &(dyn TableCommitPublication + Sync),
|
||||
) -> TableCatalogStoreResult<()> {
|
||||
self.write_view_entry_with_publication(entry, TableCatalogPutPrecondition::IfAbsent, publication)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn list_views(&self, table_bucket: &str, namespace: &str) -> TableCatalogStoreResult<Vec<ViewEntry>> {
|
||||
let namespace = parse_namespace_for_store(namespace)?;
|
||||
let mut entries = Vec::new();
|
||||
@@ -4757,8 +4840,26 @@ where
|
||||
}
|
||||
|
||||
async fn replace_view(&self, request: ViewCommitRequest) -> TableCatalogStoreResult<ViewCommitResult> {
|
||||
let publication = TableCommitLockPublication::new(&self.backend);
|
||||
self.replace_view_with_publication(request, true, &publication).await
|
||||
}
|
||||
|
||||
async fn replace_view_with_publication(
|
||||
&self,
|
||||
request: ViewCommitRequest,
|
||||
table_bucket_fence_required: bool,
|
||||
publication: &(dyn TableCommitPublication + Sync),
|
||||
) -> TableCatalogStoreResult<ViewCommitResult> {
|
||||
let namespace = parse_namespace_for_store(&request.namespace)?;
|
||||
let view = parse_table_for_store(&request.view)?;
|
||||
if table_bucket_fence_required {
|
||||
publication.begin_table_bucket(&request.table_bucket).await?;
|
||||
if !publication.holds_table_bucket(&request.table_bucket) {
|
||||
return Err(TableCatalogStoreError::Internal(
|
||||
"view replacement requires a table-bucket publication fence".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
let _migration_guard = self.acquire_object_backed_catalog_write_permit(&request.table_bucket).await?;
|
||||
let namespace_path = self.paths.namespace_entry_path(&request.table_bucket, &namespace);
|
||||
let _namespace_guard = self
|
||||
@@ -4767,6 +4868,16 @@ where
|
||||
.await?;
|
||||
let view_path = self.paths.view_entry_path(&request.table_bucket, &namespace, &view);
|
||||
let _guard = self.backend.acquire_write_lock(self.catalog_bucket(), &view_path).await?;
|
||||
// Preserve catalog -> publication -> object lock order across rolling upgrades.
|
||||
publication
|
||||
.prepare(&request.table_bucket, &request.namespace, &request.view)
|
||||
.await?;
|
||||
if !publication.holds_table(&request.table_bucket, &request.namespace, &request.view) {
|
||||
return Err(TableCatalogStoreError::Internal(
|
||||
"view replacement requires a table publication fence".to_string(),
|
||||
));
|
||||
}
|
||||
let _publication_completion = TableCommitPublicationCompletion::new(publication);
|
||||
let Some((current, current_etag)) = self
|
||||
.read_view_with_etag_unlocked(&request.table_bucket, &namespace, &view)
|
||||
.await?
|
||||
@@ -4814,6 +4925,14 @@ where
|
||||
})
|
||||
.await
|
||||
.map_err(|err| TableCatalogStoreError::Internal(format!("view metadata parser task failed: {err}")))??;
|
||||
let warehouse_relocation = next_warehouse_location
|
||||
.as_deref()
|
||||
.is_some_and(|location| location != current.warehouse_location);
|
||||
if warehouse_relocation && !publication.holds_table_bucket(&request.table_bucket) {
|
||||
return Err(TableCatalogStoreError::Internal(
|
||||
"view warehouse relocation requires a table-bucket publication fence".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let mut next = current;
|
||||
next.metadata_location = request.new_metadata_location;
|
||||
@@ -4822,13 +4941,40 @@ where
|
||||
}
|
||||
next.version_token = format!("token-{}", Uuid::new_v4());
|
||||
next.generation = next.generation.saturating_add(1);
|
||||
self.write_entry_unlocked(
|
||||
self.catalog_bucket(),
|
||||
&view_path,
|
||||
&next,
|
||||
TableCatalogPutPrecondition::IfMatch(current_etag),
|
||||
)
|
||||
.await?;
|
||||
if !publication.holds_table(&request.table_bucket, &request.namespace, &request.view)
|
||||
|| ((table_bucket_fence_required || warehouse_relocation) && !publication.holds_table_bucket(&request.table_bucket))
|
||||
{
|
||||
return Err(TableCatalogStoreError::Internal(
|
||||
"view replacement publication fence was lost before catalog update".to_string(),
|
||||
));
|
||||
}
|
||||
let write_result = self
|
||||
.write_entry_unlocked(
|
||||
self.catalog_bucket(),
|
||||
&view_path,
|
||||
&next,
|
||||
TableCatalogPutPrecondition::IfMatch(current_etag),
|
||||
)
|
||||
.await;
|
||||
if let Err(err) = write_result {
|
||||
match self
|
||||
.read_view_with_etag_unlocked(&request.table_bucket, &namespace, &view)
|
||||
.await
|
||||
{
|
||||
Ok(Some((persisted, _))) if persisted == next => {}
|
||||
Ok(_) => return Err(err),
|
||||
Err(read_err) => {
|
||||
tracing::warn!(
|
||||
table_bucket = %request.table_bucket,
|
||||
namespace = %request.namespace,
|
||||
view = %request.view,
|
||||
error = %read_err,
|
||||
"failed to verify view state after an ambiguous catalog update"
|
||||
);
|
||||
return Err(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(ViewCommitResult { view: next })
|
||||
}
|
||||
|
||||
|
||||
@@ -554,7 +554,7 @@ where
|
||||
|
||||
// Ordinary mutations hold the global migration read lock before the local write lock; migration takes the
|
||||
// write side before invoking its dedicated snapshot mutation methods.
|
||||
async fn acquire_snapshot_write_permit(&self) -> TableCatalogStoreResult<Box<dyn Send>> {
|
||||
async fn acquire_snapshot_write_permit(&self) -> TableCatalogStoreResult<TableCatalogLockGuard> {
|
||||
let lock_path = TableCatalogObjectPaths::default().backing_migration_global_fence_lock_path();
|
||||
self.object_backend.acquire_read_lock(RUSTFS_META_BUCKET, &lock_path).await
|
||||
}
|
||||
@@ -1775,7 +1775,7 @@ where
|
||||
request: &TableCommitRequest,
|
||||
namespace: &Namespace,
|
||||
table: &IdentifierSegment,
|
||||
next_warehouse_location: Option<String>,
|
||||
next_metadata_state: TableMetadataCommitState,
|
||||
) -> TableCatalogStoreResult<TableCommitResult> {
|
||||
let key = Self::table_key(&request.table_bucket, namespace, table);
|
||||
let current = Self::validate_new_table_commit_locked(state, &key, request)?;
|
||||
@@ -1802,9 +1802,12 @@ where
|
||||
|
||||
let mut next = current;
|
||||
next.metadata_location = commit_log.new_metadata_location.clone();
|
||||
if let Some(warehouse_location) = next_warehouse_location {
|
||||
if let Some(warehouse_location) = next_metadata_state.warehouse_location {
|
||||
next.warehouse_location = warehouse_location;
|
||||
}
|
||||
if let Some(format_version) = next_metadata_state.format_version {
|
||||
next.format_version = format_version;
|
||||
}
|
||||
Self::ensure_table_warehouse_prefix_available_locked(state, &next, &key)?;
|
||||
next.version_token = commit_log.new_version_token.clone();
|
||||
next.generation = next.generation.saturating_add(1);
|
||||
@@ -2170,6 +2173,7 @@ where
|
||||
let _write_guard = self.write_lock.lock().await;
|
||||
self.hydrate_state().await?;
|
||||
let key = Self::table_key(&entry.table_bucket, &namespace, &table);
|
||||
let publication_identity = (entry.table_bucket.clone(), entry.namespace.clone(), entry.table.clone());
|
||||
let (snapshot, precondition, postcondition) = {
|
||||
let state = self.state.lock().await;
|
||||
Self::require_table_bucket_in_state(&state, &entry.table_bucket)?;
|
||||
@@ -2198,6 +2202,13 @@ where
|
||||
StrongSnapshotWritePostcondition::TablePresent(entry),
|
||||
)
|
||||
};
|
||||
if !publication.holds_table_bucket(&publication_identity.0)
|
||||
|| !publication.holds_table(&publication_identity.0, &publication_identity.1, &publication_identity.2)
|
||||
{
|
||||
return Err(TableCatalogStoreError::Internal(
|
||||
"table registration publication fence was lost before snapshot update".to_string(),
|
||||
));
|
||||
}
|
||||
self.finalize_snapshot_write(snapshot, precondition, postcondition).await
|
||||
}
|
||||
|
||||
@@ -2479,9 +2490,15 @@ where
|
||||
let result = match prepared_result {
|
||||
Ok((result, Some((snapshot, precondition)))) => {
|
||||
let postcondition = Self::commit_write_postcondition(&request.table_bucket, &result.commit_log);
|
||||
self.finalize_snapshot_write(snapshot, precondition, postcondition)
|
||||
.await
|
||||
.map(|_| result)
|
||||
if !publication.holds_table(&request.table_bucket, &request.namespace, &request.table) {
|
||||
Err(TableCatalogStoreError::Internal(
|
||||
"table commit publication fence was lost before snapshot update".to_string(),
|
||||
))
|
||||
} else {
|
||||
self.finalize_snapshot_write(snapshot, precondition, postcondition)
|
||||
.await
|
||||
.map(|_| result)
|
||||
}
|
||||
}
|
||||
Ok((result, None)) => Ok(result),
|
||||
Err(err) => Err(err),
|
||||
@@ -2519,8 +2536,8 @@ where
|
||||
validate_commit_metadata_digest(&request, &new_metadata_object)?;
|
||||
let table_bucket = request.table_bucket.clone();
|
||||
let metadata_location = request.new_metadata_location.clone();
|
||||
let next_warehouse_location = tokio::task::spawn_blocking(move || {
|
||||
table_metadata_warehouse_location(&table_bucket, &metadata_location, &new_metadata_object)
|
||||
let next_metadata_state = tokio::task::spawn_blocking(move || {
|
||||
table_metadata_commit_state(&table_bucket, &metadata_location, &new_metadata_object)
|
||||
})
|
||||
.await
|
||||
.map_err(|err| TableCatalogStoreError::Internal(format!("table metadata parser task failed: {err}")))??;
|
||||
@@ -2539,11 +2556,11 @@ where
|
||||
))
|
||||
})?
|
||||
};
|
||||
if next_warehouse_location
|
||||
let warehouse_relocation = next_metadata_state
|
||||
.warehouse_location
|
||||
.as_ref()
|
||||
.is_some_and(|warehouse_location| warehouse_location != ¤t_warehouse_location)
|
||||
&& !publication.holds_table_bucket(&request.table_bucket)
|
||||
{
|
||||
.is_some_and(|warehouse_location| warehouse_location != ¤t_warehouse_location);
|
||||
if warehouse_relocation && !publication.holds_table_bucket(&request.table_bucket) {
|
||||
return table_commit_result(
|
||||
&request.table_bucket,
|
||||
&request.namespace,
|
||||
@@ -2561,7 +2578,7 @@ where
|
||||
let prepared_result = {
|
||||
let state = self.state.lock().await;
|
||||
let (precondition, mut draft_state) = Self::snapshot_draft_context_locked(&state);
|
||||
match Self::apply_commit_locked(&mut draft_state, &request, &namespace, &table, next_warehouse_location) {
|
||||
match Self::apply_commit_locked(&mut draft_state, &request, &namespace, &table, next_metadata_state) {
|
||||
Ok(result) => Self::snapshot_from_mutated_state_locked(&mut draft_state, self.snapshot_write_version)
|
||||
.map(|snapshot| (result, snapshot, precondition)),
|
||||
Err(err) => Err(err),
|
||||
@@ -2570,7 +2587,16 @@ where
|
||||
let result = match prepared_result {
|
||||
Ok((result, snapshot, precondition)) => {
|
||||
let postcondition = Self::commit_write_postcondition(&request.table_bucket, &result.commit_log);
|
||||
match self.finalize_snapshot_write(snapshot, precondition, postcondition).await {
|
||||
let snapshot_result = if publication.holds_table(&request.table_bucket, &request.namespace, &request.table)
|
||||
&& (!warehouse_relocation || publication.holds_table_bucket(&request.table_bucket))
|
||||
{
|
||||
self.finalize_snapshot_write(snapshot, precondition, postcondition).await
|
||||
} else {
|
||||
Err(TableCatalogStoreError::Internal(
|
||||
"table commit publication fence was lost before snapshot update".to_string(),
|
||||
))
|
||||
};
|
||||
match snapshot_result {
|
||||
Ok(()) => Ok(result),
|
||||
Err(err) => {
|
||||
let replay = {
|
||||
@@ -2647,13 +2673,26 @@ where
|
||||
},
|
||||
)
|
||||
};
|
||||
if !publication.holds_table_bucket(table_bucket)
|
||||
|| !publication.holds_table(table_bucket, &namespace.public_name(), table.as_str())
|
||||
{
|
||||
return Err(TableCatalogStoreError::Internal(
|
||||
"table drop publication fence was lost before snapshot update".to_string(),
|
||||
));
|
||||
}
|
||||
self.finalize_snapshot_write(snapshot, precondition, postcondition).await
|
||||
}
|
||||
|
||||
async fn create_view(&self, entry: ViewEntry) -> TableCatalogStoreResult<()> {
|
||||
let _migration_guard = self.acquire_snapshot_write_permit().await?;
|
||||
let _write_guard = self.write_lock.lock().await;
|
||||
self.hydrate_state().await?;
|
||||
let publication = TableCommitLockPublication::new(&self.object_backend);
|
||||
self.create_view_with_publication(entry, &publication).await
|
||||
}
|
||||
|
||||
async fn create_view_with_publication(
|
||||
&self,
|
||||
entry: ViewEntry,
|
||||
publication: &(dyn TableCommitPublication + Sync),
|
||||
) -> TableCatalogStoreResult<()> {
|
||||
validate_view_entry_version_and_id(&entry)?;
|
||||
validate_view_warehouse_location(&entry.table_bucket, &entry.warehouse_location)?;
|
||||
let namespace = parse_namespace_for_store(&entry.namespace)?;
|
||||
@@ -2663,7 +2702,26 @@ where
|
||||
"view metadata location must be inside the view metadata directory".to_string(),
|
||||
));
|
||||
}
|
||||
publication.begin_table_bucket(&entry.table_bucket).await?;
|
||||
if !publication.holds_table_bucket(&entry.table_bucket) {
|
||||
return Err(TableCatalogStoreError::Internal(
|
||||
"view creation requires a table-bucket publication fence".to_string(),
|
||||
));
|
||||
}
|
||||
let _publication_completion = TableCommitPublicationCompletion::new(publication);
|
||||
let _migration_guard = self.acquire_snapshot_write_permit().await?;
|
||||
publication
|
||||
.prepare(&entry.table_bucket, &entry.namespace, &entry.view)
|
||||
.await?;
|
||||
if !publication.holds_table(&entry.table_bucket, &entry.namespace, &entry.view) {
|
||||
return Err(TableCatalogStoreError::Internal(
|
||||
"view creation requires a table publication fence".to_string(),
|
||||
));
|
||||
}
|
||||
let _write_guard = self.write_lock.lock().await;
|
||||
self.hydrate_state().await?;
|
||||
let key = Self::table_key(&entry.table_bucket, &namespace, &view);
|
||||
let publication_identity = (entry.table_bucket.clone(), entry.namespace.clone(), entry.view.clone());
|
||||
let (snapshot, precondition, postcondition) = {
|
||||
let state = self.state.lock().await;
|
||||
Self::require_table_bucket_in_state(&state, &entry.table_bucket)?;
|
||||
@@ -2682,6 +2740,13 @@ where
|
||||
StrongSnapshotWritePostcondition::ViewPresent(entry),
|
||||
)
|
||||
};
|
||||
if !publication.holds_table_bucket(&publication_identity.0)
|
||||
|| !publication.holds_table(&publication_identity.0, &publication_identity.1, &publication_identity.2)
|
||||
{
|
||||
return Err(TableCatalogStoreError::Internal(
|
||||
"view creation publication fence was lost before snapshot update".to_string(),
|
||||
));
|
||||
}
|
||||
self.finalize_snapshot_write(snapshot, precondition, postcondition).await
|
||||
}
|
||||
|
||||
@@ -2751,11 +2816,38 @@ where
|
||||
}
|
||||
|
||||
async fn replace_view(&self, request: ViewCommitRequest) -> TableCatalogStoreResult<ViewCommitResult> {
|
||||
let publication = TableCommitLockPublication::new(&self.object_backend);
|
||||
self.replace_view_with_publication(request, true, &publication).await
|
||||
}
|
||||
|
||||
async fn replace_view_with_publication(
|
||||
&self,
|
||||
request: ViewCommitRequest,
|
||||
table_bucket_fence_required: bool,
|
||||
publication: &(dyn TableCommitPublication + Sync),
|
||||
) -> TableCatalogStoreResult<ViewCommitResult> {
|
||||
if table_bucket_fence_required {
|
||||
publication.begin_table_bucket(&request.table_bucket).await?;
|
||||
if !publication.holds_table_bucket(&request.table_bucket) {
|
||||
return Err(TableCatalogStoreError::Internal(
|
||||
"view replacement requires a table-bucket publication fence".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
let _publication_completion = TableCommitPublicationCompletion::new(publication);
|
||||
let _migration_guard = self.acquire_snapshot_write_permit().await?;
|
||||
let write_guard = self.write_lock.lock().await;
|
||||
self.hydrate_state().await?;
|
||||
let namespace = parse_namespace_for_store(&request.namespace)?;
|
||||
let view = parse_table_for_store(&request.view)?;
|
||||
publication
|
||||
.prepare(&request.table_bucket, &request.namespace, &request.view)
|
||||
.await?;
|
||||
if !publication.holds_table(&request.table_bucket, &request.namespace, &request.view) {
|
||||
return Err(TableCatalogStoreError::Internal(
|
||||
"view replacement requires a table publication fence".to_string(),
|
||||
));
|
||||
}
|
||||
let write_guard = self.write_lock.lock().await;
|
||||
self.hydrate_state().await?;
|
||||
let key = Self::table_key(&request.table_bucket, &namespace, &view);
|
||||
let expected_view_id = {
|
||||
let state = self.state.lock().await;
|
||||
@@ -2798,7 +2890,7 @@ where
|
||||
|
||||
let _write_guard = self.write_lock.lock().await;
|
||||
self.hydrate_state().await?;
|
||||
let (snapshot, precondition, next, postcondition) = {
|
||||
let (snapshot, precondition, next, postcondition, warehouse_relocation) = {
|
||||
let state = self.state.lock().await;
|
||||
Self::ensure_identifier_is_unambiguous_locked(&state, &key)?;
|
||||
let Some(current) = state.views.get(&key).cloned() else {
|
||||
@@ -2828,6 +2920,14 @@ where
|
||||
"current view metadata location does not match expected location".to_string(),
|
||||
));
|
||||
}
|
||||
let warehouse_relocation = next_warehouse_location
|
||||
.as_deref()
|
||||
.is_some_and(|location| location != current.warehouse_location);
|
||||
if warehouse_relocation && !publication.holds_table_bucket(&request.table_bucket) {
|
||||
return Err(TableCatalogStoreError::Internal(
|
||||
"view warehouse relocation requires a table-bucket publication fence".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let mut next = current;
|
||||
next.metadata_location = request.new_metadata_location;
|
||||
@@ -2843,8 +2943,16 @@ where
|
||||
precondition,
|
||||
next.clone(),
|
||||
StrongSnapshotWritePostcondition::ViewPresent(next),
|
||||
warehouse_relocation,
|
||||
)
|
||||
};
|
||||
if !publication.holds_table(&request.table_bucket, &request.namespace, &request.view)
|
||||
|| ((table_bucket_fence_required || warehouse_relocation) && !publication.holds_table_bucket(&request.table_bucket))
|
||||
{
|
||||
return Err(TableCatalogStoreError::Internal(
|
||||
"view replacement publication fence was lost before snapshot update".to_string(),
|
||||
));
|
||||
}
|
||||
self.finalize_snapshot_write(snapshot, precondition, postcondition).await?;
|
||||
Ok(ViewCommitResult { view: next })
|
||||
}
|
||||
|
||||
@@ -26,10 +26,7 @@ use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::sync::Arc;
|
||||
use time::OffsetDateTime;
|
||||
|
||||
use super::{
|
||||
StrongTableCatalogRuntime, TableCatalogObject, TableCatalogObjectBackend, TableCatalogObjectMetadata,
|
||||
TableCatalogPutPrecondition, TableCatalogStoreError, TableCatalogStoreResult, TableCommitPublication,
|
||||
};
|
||||
use super::*;
|
||||
|
||||
pub(crate) fn table_metadata_json(table_uuid: &str, location: &str) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
@@ -58,23 +55,35 @@ pub(crate) fn table_metadata_json(table_uuid: &str, location: &str) -> serde_jso
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn manifest_list_avro_bytes(manifest_paths: &[&str], sequence_number: i64, snapshot_id: i64) -> Vec<u8> {
|
||||
let manifests = manifest_paths
|
||||
.iter()
|
||||
.map(|manifest_path| (*manifest_path, 0, sequence_number, snapshot_id))
|
||||
.collect::<Vec<_>>();
|
||||
manifest_list_avro_entries_with_partition_specs(&manifests)
|
||||
}
|
||||
|
||||
pub(crate) fn manifest_list_avro_entries(manifests: &[(&str, i64, i64)]) -> Vec<u8> {
|
||||
pub(crate) fn manifest_list_avro_bytes(manifests: &[(&str, usize)], sequence_number: i64, snapshot_id: i64) -> Vec<u8> {
|
||||
let manifests = manifests
|
||||
.iter()
|
||||
.map(|(manifest_path, sequence_number, snapshot_id)| (*manifest_path, 0, *sequence_number, *snapshot_id))
|
||||
.map(|(manifest_path, manifest_length)| (*manifest_path, *manifest_length, 0, sequence_number, snapshot_id))
|
||||
.collect::<Vec<_>>();
|
||||
manifest_list_avro_entries_with_partition_specs(&manifests)
|
||||
}
|
||||
|
||||
pub(crate) fn manifest_list_avro_entries_with_partition_specs(manifests: &[(&str, i32, i64, i64)]) -> Vec<u8> {
|
||||
pub(crate) fn manifest_list_avro_entries(manifests: &[(&str, usize, i64, i64)]) -> Vec<u8> {
|
||||
let manifests = manifests
|
||||
.iter()
|
||||
.map(|(manifest_path, manifest_length, sequence_number, snapshot_id)| {
|
||||
(*manifest_path, *manifest_length, 0, *sequence_number, *snapshot_id)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
manifest_list_avro_entries_with_partition_specs(&manifests)
|
||||
}
|
||||
|
||||
pub(crate) fn manifest_list_avro_entries_with_partition_specs(manifests: &[(&str, usize, i32, i64, i64)]) -> Vec<u8> {
|
||||
let manifests = manifests
|
||||
.iter()
|
||||
.map(|(path, length, spec_id, sequence_number, snapshot_id)| {
|
||||
(*path, *length, *spec_id, 0, *sequence_number, *snapshot_id)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
manifest_list_avro_entries_with_content(&manifests)
|
||||
}
|
||||
|
||||
pub(crate) fn manifest_list_avro_entries_with_content(manifests: &[(&str, usize, i32, i32, i64, i64)]) -> Vec<u8> {
|
||||
let schema = apache_avro::Schema::parse_str(
|
||||
r#"
|
||||
{
|
||||
@@ -100,16 +109,19 @@ pub(crate) fn manifest_list_avro_entries_with_partition_specs(manifests: &[(&str
|
||||
)
|
||||
.expect("manifest list avro schema should parse");
|
||||
let mut writer = apache_avro::Writer::new(&schema, Vec::new()).expect("manifest list writer should initialize");
|
||||
for (manifest_path, partition_spec_id, sequence_number, snapshot_id) in manifests {
|
||||
for (manifest_path, manifest_length, partition_spec_id, content, sequence_number, snapshot_id) in manifests {
|
||||
writer
|
||||
.append_value(apache_avro::types::Value::Record(vec![
|
||||
(
|
||||
"manifest_path".to_string(),
|
||||
apache_avro::types::Value::String((*manifest_path).to_string()),
|
||||
),
|
||||
("manifest_length".to_string(), apache_avro::types::Value::Long(1)),
|
||||
(
|
||||
"manifest_length".to_string(),
|
||||
apache_avro::types::Value::Long(i64::try_from(*manifest_length).expect("test manifest length should fit")),
|
||||
),
|
||||
("partition_spec_id".to_string(), apache_avro::types::Value::Int(*partition_spec_id)),
|
||||
("content".to_string(), apache_avro::types::Value::Int(0)),
|
||||
("content".to_string(), apache_avro::types::Value::Int(*content)),
|
||||
("sequence_number".to_string(), apache_avro::types::Value::Long(*sequence_number)),
|
||||
("min_sequence_number".to_string(), apache_avro::types::Value::Long(*sequence_number)),
|
||||
("added_snapshot_id".to_string(), apache_avro::types::Value::Long(*snapshot_id)),
|
||||
@@ -125,7 +137,86 @@ pub(crate) fn manifest_list_avro_entries_with_partition_specs(manifests: &[(&str
|
||||
writer.into_inner().expect("manifest list avro bytes should flush")
|
||||
}
|
||||
|
||||
pub(crate) fn manifest_list_avro_entries_with_nullable_counts(manifests: &[(&str, usize, i32, i64, i64)]) -> Vec<u8> {
|
||||
let schema = apache_avro::Schema::parse_str(
|
||||
r#"
|
||||
{
|
||||
"type": "record",
|
||||
"name": "manifest_file",
|
||||
"fields": [
|
||||
{"name": "manifest_path", "type": "string"},
|
||||
{"name": "manifest_length", "type": "long"},
|
||||
{"name": "partition_spec_id", "type": "int"},
|
||||
{"name": "content", "type": "int"},
|
||||
{"name": "sequence_number", "type": "long"},
|
||||
{"name": "min_sequence_number", "type": "long"},
|
||||
{"name": "added_snapshot_id", "type": "long"},
|
||||
{"name": "added_files_count", "type": ["null", "int"], "default": null},
|
||||
{"name": "existing_files_count", "type": ["null", "int"], "default": null},
|
||||
{"name": "deleted_files_count", "type": ["null", "int"], "default": null},
|
||||
{"name": "added_rows_count", "type": ["null", "long"], "default": null},
|
||||
{"name": "existing_rows_count", "type": ["null", "long"], "default": null},
|
||||
{"name": "deleted_rows_count", "type": ["null", "long"], "default": null}
|
||||
]
|
||||
}
|
||||
"#,
|
||||
)
|
||||
.expect("manifest list avro schema should parse");
|
||||
let mut writer = apache_avro::Writer::new(&schema, Vec::new()).expect("manifest list writer should initialize");
|
||||
for (manifest_path, manifest_length, partition_spec_id, sequence_number, snapshot_id) in manifests {
|
||||
writer
|
||||
.append_value(apache_avro::types::Value::Record(vec![
|
||||
(
|
||||
"manifest_path".to_string(),
|
||||
apache_avro::types::Value::String((*manifest_path).to_string()),
|
||||
),
|
||||
(
|
||||
"manifest_length".to_string(),
|
||||
apache_avro::types::Value::Long(i64::try_from(*manifest_length).expect("test manifest length should fit")),
|
||||
),
|
||||
("partition_spec_id".to_string(), apache_avro::types::Value::Int(*partition_spec_id)),
|
||||
("content".to_string(), apache_avro::types::Value::Int(0)),
|
||||
("sequence_number".to_string(), apache_avro::types::Value::Long(*sequence_number)),
|
||||
("min_sequence_number".to_string(), apache_avro::types::Value::Long(*sequence_number)),
|
||||
("added_snapshot_id".to_string(), apache_avro::types::Value::Long(*snapshot_id)),
|
||||
(
|
||||
"added_files_count".to_string(),
|
||||
apache_avro::types::Value::Union(0, Box::new(apache_avro::types::Value::Null)),
|
||||
),
|
||||
(
|
||||
"existing_files_count".to_string(),
|
||||
apache_avro::types::Value::Union(0, Box::new(apache_avro::types::Value::Null)),
|
||||
),
|
||||
(
|
||||
"deleted_files_count".to_string(),
|
||||
apache_avro::types::Value::Union(0, Box::new(apache_avro::types::Value::Null)),
|
||||
),
|
||||
(
|
||||
"added_rows_count".to_string(),
|
||||
apache_avro::types::Value::Union(0, Box::new(apache_avro::types::Value::Null)),
|
||||
),
|
||||
(
|
||||
"existing_rows_count".to_string(),
|
||||
apache_avro::types::Value::Union(0, Box::new(apache_avro::types::Value::Null)),
|
||||
),
|
||||
(
|
||||
"deleted_rows_count".to_string(),
|
||||
apache_avro::types::Value::Union(0, Box::new(apache_avro::types::Value::Null)),
|
||||
),
|
||||
]))
|
||||
.expect("manifest list record should append");
|
||||
}
|
||||
writer.into_inner().expect("manifest list avro bytes should flush")
|
||||
}
|
||||
|
||||
pub(crate) fn manifest_avro_bytes(files: &[(&str, i32, i32, i64, i64)]) -> Vec<u8> {
|
||||
manifest_avro_bytes_with_partition_spec(files, None)
|
||||
}
|
||||
|
||||
pub(crate) fn manifest_avro_bytes_with_partition_spec(
|
||||
files: &[(&str, i32, i32, i64, i64)],
|
||||
partition_spec_id: Option<i32>,
|
||||
) -> Vec<u8> {
|
||||
let schema = apache_avro::Schema::parse_str(
|
||||
r#"
|
||||
{
|
||||
@@ -144,6 +235,7 @@ pub(crate) fn manifest_avro_bytes(files: &[(&str, i32, i32, i64, i64)]) -> Vec<u
|
||||
"fields": [
|
||||
{"name": "content", "type": "int"},
|
||||
{"name": "file_path", "type": "string"},
|
||||
{"name": "partition", "type": {"type": "record", "name": "partition", "fields": []}},
|
||||
{"name": "record_count", "type": "long"},
|
||||
{"name": "file_size_in_bytes", "type": "long"}
|
||||
]
|
||||
@@ -155,6 +247,11 @@ pub(crate) fn manifest_avro_bytes(files: &[(&str, i32, i32, i64, i64)]) -> Vec<u
|
||||
)
|
||||
.expect("manifest avro schema should parse");
|
||||
let mut writer = apache_avro::Writer::new(&schema, Vec::new()).expect("manifest writer should initialize");
|
||||
if let Some(partition_spec_id) = partition_spec_id {
|
||||
writer
|
||||
.add_user_metadata("partition-spec-id".to_string(), partition_spec_id.to_string())
|
||||
.expect("manifest partition spec metadata should write");
|
||||
}
|
||||
for (file_path, content, status, snapshot_id, sequence_number) in files {
|
||||
writer
|
||||
.append_value(apache_avro::types::Value::Record(vec![
|
||||
@@ -167,6 +264,7 @@ pub(crate) fn manifest_avro_bytes(files: &[(&str, i32, i32, i64, i64)]) -> Vec<u
|
||||
apache_avro::types::Value::Record(vec![
|
||||
("content".to_string(), apache_avro::types::Value::Int(*content)),
|
||||
("file_path".to_string(), apache_avro::types::Value::String((*file_path).to_string())),
|
||||
("partition".to_string(), apache_avro::types::Value::Record(Vec::new())),
|
||||
("record_count".to_string(), apache_avro::types::Value::Long(1)),
|
||||
("file_size_in_bytes".to_string(), apache_avro::types::Value::Long(1)),
|
||||
]),
|
||||
@@ -203,6 +301,7 @@ pub(crate) fn manifest_avro_bytes_with_nullable_sequences(files: &[(&str, i32, i
|
||||
"fields": [
|
||||
{"name": "content", "type": "int"},
|
||||
{"name": "file_path", "type": "string"},
|
||||
{"name": "partition", "type": {"type": "record", "name": "partition", "fields": []}},
|
||||
{"name": "record_count", "type": "long"},
|
||||
{"name": "file_size_in_bytes", "type": "long"}
|
||||
]
|
||||
@@ -226,6 +325,7 @@ pub(crate) fn manifest_avro_bytes_with_nullable_sequences(files: &[(&str, i32, i
|
||||
apache_avro::types::Value::Record(vec![
|
||||
("content".to_string(), apache_avro::types::Value::Int(*content)),
|
||||
("file_path".to_string(), apache_avro::types::Value::String((*file_path).to_string())),
|
||||
("partition".to_string(), apache_avro::types::Value::Record(Vec::new())),
|
||||
("record_count".to_string(), apache_avro::types::Value::Long(1)),
|
||||
("file_size_in_bytes".to_string(), apache_avro::types::Value::Long(1)),
|
||||
]),
|
||||
@@ -287,7 +387,7 @@ pub(crate) struct BlockingObjectPublication {
|
||||
backend: TestCatalogObjectBackend,
|
||||
object: String,
|
||||
started: Arc<tokio::sync::Notify>,
|
||||
guard: Arc<parking_lot::Mutex<Option<Box<dyn Send>>>>,
|
||||
guard: Arc<parking_lot::Mutex<Option<TableCatalogLockGuard>>>,
|
||||
}
|
||||
|
||||
impl BlockingObjectPublication {
|
||||
@@ -815,7 +915,7 @@ impl TableCatalogObjectBackend for TestCatalogObjectBackend {
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn acquire_write_lock(&self, bucket: &str, object: &str) -> TableCatalogStoreResult<Box<dyn Send>> {
|
||||
async fn acquire_write_lock(&self, bucket: &str, object: &str) -> TableCatalogStoreResult<TableCatalogLockGuard> {
|
||||
self.lock_attempts.lock().await.push((bucket.to_string(), object.to_string()));
|
||||
{
|
||||
let mut state = self.state.lock().await;
|
||||
@@ -831,10 +931,10 @@ impl TableCatalogObjectBackend for TestCatalogObjectBackend {
|
||||
.or_insert_with(|| std::sync::Arc::new(tokio::sync::RwLock::new(())))
|
||||
.clone()
|
||||
};
|
||||
Ok(Box::new(lock.write_owned().await))
|
||||
Ok(TableCatalogLockGuard::stable(lock.write_owned().await))
|
||||
}
|
||||
|
||||
async fn acquire_read_lock(&self, bucket: &str, object: &str) -> TableCatalogStoreResult<Box<dyn Send>> {
|
||||
async fn acquire_read_lock(&self, bucket: &str, object: &str) -> TableCatalogStoreResult<TableCatalogLockGuard> {
|
||||
// The admin fake implemented only acquire_write_lock, so the trait's
|
||||
// default read->write delegation made read acquisitions observable in
|
||||
// lock_attempts as well; keep that (backlog#1837 PR2).
|
||||
@@ -853,7 +953,7 @@ impl TableCatalogObjectBackend for TestCatalogObjectBackend {
|
||||
.or_insert_with(|| std::sync::Arc::new(tokio::sync::RwLock::new(())))
|
||||
.clone()
|
||||
};
|
||||
Ok(Box::new(lock.read_owned().await))
|
||||
Ok(TableCatalogLockGuard::stable(lock.read_owned().await))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -939,3 +1039,647 @@ impl TestCatalogObjectBackend {
|
||||
.expect("lock acquisition attempts should be observable");
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub(crate) struct TestCatalogPublishPause {
|
||||
started: Arc<tokio::sync::Notify>,
|
||||
release: Arc<tokio::sync::Notify>,
|
||||
}
|
||||
|
||||
impl TestCatalogPublishPause {
|
||||
pub(crate) async fn wait_started(&self) {
|
||||
self.started.notified().await;
|
||||
}
|
||||
|
||||
pub(crate) fn release(&self) {
|
||||
self.release.notify_one();
|
||||
}
|
||||
}
|
||||
|
||||
// --- TableCatalogStore test doubles (backlog#1837 PR3) ---
|
||||
//
|
||||
// Two deliberately different shapes, per the issue's ruling: NoopTableCatalogStore
|
||||
// is a pure stub whose methods answer "nothing here", used where a store must
|
||||
// exist but never matter; TestTableCatalogStore is a stateful fake with commit
|
||||
// pauses and failure injection. Both live here so a TableCatalogStore trait
|
||||
// change is one file to update instead of two.
|
||||
|
||||
pub(crate) struct NoopTableCatalogStore;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl TableCatalogStore for NoopTableCatalogStore {
|
||||
async fn get_table_bucket(&self, _table_bucket: &str) -> TableCatalogStoreResult<Option<TableBucketEntry>> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn put_table_bucket(&self, _entry: TableBucketEntry) -> TableCatalogStoreResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn create_namespace(&self, _entry: NamespaceEntry) -> TableCatalogStoreResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn list_namespaces(&self, _table_bucket: &str) -> TableCatalogStoreResult<Vec<NamespaceEntry>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
async fn get_namespace(&self, _table_bucket: &str, _namespace: &str) -> TableCatalogStoreResult<Option<NamespaceEntry>> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn drop_namespace(&self, _table_bucket: &str, _namespace: &str) -> TableCatalogStoreResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn create_table(&self, _entry: TableEntry) -> TableCatalogStoreResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn register_table(&self, _entry: TableEntry) -> TableCatalogStoreResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn register_table_with_publication(
|
||||
&self,
|
||||
entry: TableEntry,
|
||||
publication: &(dyn TableCommitPublication + Sync),
|
||||
) -> TableCatalogStoreResult<()> {
|
||||
publication.begin_table_bucket(&entry.table_bucket).await?;
|
||||
if !publication.holds_table_bucket(&entry.table_bucket) {
|
||||
return Err(TableCatalogStoreError::Internal(
|
||||
"table registration requires a table-bucket publication fence".to_string(),
|
||||
));
|
||||
}
|
||||
let _publication_completion = TableCommitPublicationCompletion::new(publication);
|
||||
publication
|
||||
.prepare(&entry.table_bucket, &entry.namespace, &entry.table)
|
||||
.await?;
|
||||
if !publication.holds_table(&entry.table_bucket, &entry.namespace, &entry.table) {
|
||||
return Err(TableCatalogStoreError::Internal(
|
||||
"table registration requires a table publication fence".to_string(),
|
||||
));
|
||||
}
|
||||
self.register_table(entry).await
|
||||
}
|
||||
|
||||
async fn list_tables(&self, _table_bucket: &str, _namespace: &str) -> TableCatalogStoreResult<Vec<TableEntry>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
async fn list_all_tables(&self, _table_bucket: &str) -> TableCatalogStoreResult<Vec<TableEntry>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
async fn load_table(
|
||||
&self,
|
||||
_table_bucket: &str,
|
||||
_namespace: &str,
|
||||
_table: &str,
|
||||
) -> TableCatalogStoreResult<Option<TableEntry>> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn commit_table(&self, request: TableCommitRequest) -> TableCatalogStoreResult<TableCommitResult> {
|
||||
let table = TableEntry {
|
||||
version: TABLE_CATALOG_ENTRY_VERSION,
|
||||
table_bucket: request.table_bucket,
|
||||
namespace: request.namespace,
|
||||
table: request.table,
|
||||
table_id: "table-id".to_string(),
|
||||
table_uuid: "table-uuid".to_string(),
|
||||
format: "ICEBERG".to_string(),
|
||||
format_version: 2,
|
||||
warehouse_location: "s3://analytics/tables/table-id".to_string(),
|
||||
metadata_location: request.new_metadata_location.clone(),
|
||||
version_token: "token-v2".to_string(),
|
||||
generation: 2,
|
||||
state: TableCatalogEntryState::Active,
|
||||
properties: BTreeMap::new(),
|
||||
created_at: None,
|
||||
updated_at: None,
|
||||
};
|
||||
let commit_log = CommitLogEntry {
|
||||
version: TABLE_CATALOG_ENTRY_VERSION,
|
||||
commit_id: request.commit_id,
|
||||
idempotency_key: request.idempotency_key,
|
||||
table_id: table.table_id.clone(),
|
||||
operation: request.operation,
|
||||
expected_version_token: request.expected_version_token,
|
||||
new_version_token: table.version_token.clone(),
|
||||
previous_metadata_location: request.expected_metadata_location,
|
||||
new_metadata_location: table.metadata_location.clone(),
|
||||
requirements: request.requirements,
|
||||
status: CommitLogStatus::Committed,
|
||||
writer: request.writer,
|
||||
created_at: None,
|
||||
updated_at: None,
|
||||
};
|
||||
|
||||
Ok(TableCommitResult { table, commit_log })
|
||||
}
|
||||
|
||||
async fn commit_table_with_publication(
|
||||
&self,
|
||||
request: TableCommitRequest,
|
||||
publication: &(dyn TableCommitPublication + Sync),
|
||||
) -> TableCatalogStoreResult<TableCommitResult> {
|
||||
publication
|
||||
.prepare(&request.table_bucket, &request.namespace, &request.table)
|
||||
.await?;
|
||||
if !publication.holds_table(&request.table_bucket, &request.namespace, &request.table) {
|
||||
return Err(TableCatalogStoreError::Internal(
|
||||
"table commit requires a table publication fence".to_string(),
|
||||
));
|
||||
}
|
||||
let _publication_completion = TableCommitPublicationCompletion::new(publication);
|
||||
self.commit_table(request).await
|
||||
}
|
||||
|
||||
async fn drop_table(&self, _table_bucket: &str, _namespace: &str, _table: &str) -> TableCatalogStoreResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn create_view(&self, _entry: ViewEntry) -> TableCatalogStoreResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn list_views(&self, _table_bucket: &str, _namespace: &str) -> TableCatalogStoreResult<Vec<ViewEntry>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
async fn load_view(&self, _table_bucket: &str, _namespace: &str, _view: &str) -> TableCatalogStoreResult<Option<ViewEntry>> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn replace_view(&self, request: ViewCommitRequest) -> TableCatalogStoreResult<ViewCommitResult> {
|
||||
Ok(ViewCommitResult {
|
||||
view: ViewEntry {
|
||||
version: TABLE_CATALOG_ENTRY_VERSION,
|
||||
table_bucket: request.table_bucket,
|
||||
namespace: request.namespace,
|
||||
view: request.view,
|
||||
view_id: "view-id".to_string(),
|
||||
view_uuid: "view-uuid".to_string(),
|
||||
format: "ICEBERG_VIEW".to_string(),
|
||||
format_version: 1,
|
||||
warehouse_location: "s3://analytics/views/view-id".to_string(),
|
||||
metadata_location: request.new_metadata_location,
|
||||
version_token: "token-v2".to_string(),
|
||||
generation: 2,
|
||||
state: TableCatalogEntryState::Active,
|
||||
properties: BTreeMap::new(),
|
||||
created_at: None,
|
||||
updated_at: None,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async fn drop_view(&self, _table_bucket: &str, _namespace: &str, _view: &str) -> TableCatalogStoreResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_commit_by_id(
|
||||
&self,
|
||||
_table_bucket: &str,
|
||||
_table_id: &str,
|
||||
_commit_id: &str,
|
||||
) -> TableCatalogStoreResult<Option<CommitLogEntry>> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn get_commit_by_idempotency_key(
|
||||
&self,
|
||||
_table_bucket: &str,
|
||||
_table_id: &str,
|
||||
_idempotency_key: &str,
|
||||
) -> TableCatalogStoreResult<Option<CommitLogEntry>> {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct TestTableCatalogStore {
|
||||
pub(crate) table_buckets: tokio::sync::Mutex<Vec<crate::table_catalog::TableBucketEntry>>,
|
||||
pub(crate) namespaces: tokio::sync::Mutex<Vec<crate::table_catalog::NamespaceEntry>>,
|
||||
pub(crate) tables: tokio::sync::Mutex<Vec<crate::table_catalog::TableEntry>>,
|
||||
pub(crate) views: tokio::sync::Mutex<Vec<crate::table_catalog::ViewEntry>>,
|
||||
pub(crate) commits: tokio::sync::Mutex<Vec<crate::table_catalog::CommitLogEntry>>,
|
||||
pub(crate) fail_put_table_bucket: tokio::sync::Mutex<bool>,
|
||||
pub(crate) register_table_pause: Option<TestCatalogPublishPause>,
|
||||
pub(crate) commit_table_pause: Option<TestCatalogPublishPause>,
|
||||
pub(crate) create_view_pause: Option<TestCatalogPublishPause>,
|
||||
pub(crate) replace_view_pause: Option<TestCatalogPublishPause>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl crate::table_catalog::TableCatalogStore for TestTableCatalogStore {
|
||||
async fn get_table_bucket(
|
||||
&self,
|
||||
table_bucket: &str,
|
||||
) -> crate::table_catalog::TableCatalogStoreResult<Option<crate::table_catalog::TableBucketEntry>> {
|
||||
Ok(self
|
||||
.table_buckets
|
||||
.lock()
|
||||
.await
|
||||
.iter()
|
||||
.find(|entry| entry.table_bucket == table_bucket)
|
||||
.cloned())
|
||||
}
|
||||
|
||||
async fn put_table_bucket(
|
||||
&self,
|
||||
entry: crate::table_catalog::TableBucketEntry,
|
||||
) -> crate::table_catalog::TableCatalogStoreResult<()> {
|
||||
let mut fail_put_table_bucket = self.fail_put_table_bucket.lock().await;
|
||||
if *fail_put_table_bucket {
|
||||
*fail_put_table_bucket = false;
|
||||
return Err(crate::table_catalog::TableCatalogStoreError::Internal(
|
||||
"injected table bucket write failure".to_string(),
|
||||
));
|
||||
}
|
||||
drop(fail_put_table_bucket);
|
||||
|
||||
let mut table_buckets = self.table_buckets.lock().await;
|
||||
table_buckets.retain(|existing| existing.table_bucket != entry.table_bucket);
|
||||
table_buckets.push(entry);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn create_namespace(
|
||||
&self,
|
||||
entry: crate::table_catalog::NamespaceEntry,
|
||||
) -> crate::table_catalog::TableCatalogStoreResult<()> {
|
||||
if self.get_table_bucket(&entry.table_bucket).await?.is_none() {
|
||||
return Err(crate::table_catalog::TableCatalogStoreError::NotFound(format!(
|
||||
"table bucket {}",
|
||||
entry.table_bucket
|
||||
)));
|
||||
}
|
||||
self.namespaces.lock().await.push(entry);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn list_namespaces(
|
||||
&self,
|
||||
table_bucket: &str,
|
||||
) -> crate::table_catalog::TableCatalogStoreResult<Vec<crate::table_catalog::NamespaceEntry>> {
|
||||
Ok(self
|
||||
.namespaces
|
||||
.lock()
|
||||
.await
|
||||
.iter()
|
||||
.filter(|entry| entry.table_bucket == table_bucket)
|
||||
.cloned()
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn get_namespace(
|
||||
&self,
|
||||
table_bucket: &str,
|
||||
namespace: &str,
|
||||
) -> crate::table_catalog::TableCatalogStoreResult<Option<crate::table_catalog::NamespaceEntry>> {
|
||||
Ok(self
|
||||
.namespaces
|
||||
.lock()
|
||||
.await
|
||||
.iter()
|
||||
.find(|entry| entry.table_bucket == table_bucket && entry.namespace == namespace)
|
||||
.cloned())
|
||||
}
|
||||
|
||||
async fn update_namespace_properties(
|
||||
&self,
|
||||
table_bucket: &str,
|
||||
namespace: &str,
|
||||
update: crate::table_catalog::NamespacePropertiesUpdate,
|
||||
) -> crate::table_catalog::TableCatalogStoreResult<crate::table_catalog::NamespacePropertiesUpdateResult> {
|
||||
let mut namespaces = self.namespaces.lock().await;
|
||||
let entry = namespaces
|
||||
.iter_mut()
|
||||
.find(|entry| entry.table_bucket == table_bucket && entry.namespace == namespace)
|
||||
.ok_or_else(|| {
|
||||
crate::table_catalog::TableCatalogStoreError::NotFound(format!("namespace {table_bucket}/{namespace}"))
|
||||
})?;
|
||||
Ok(update.apply_to(entry))
|
||||
}
|
||||
|
||||
async fn drop_namespace(&self, table_bucket: &str, namespace: &str) -> crate::table_catalog::TableCatalogStoreResult<()> {
|
||||
self.namespaces
|
||||
.lock()
|
||||
.await
|
||||
.retain(|entry| !(entry.table_bucket == table_bucket && entry.namespace == namespace));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn create_table(&self, entry: crate::table_catalog::TableEntry) -> crate::table_catalog::TableCatalogStoreResult<()> {
|
||||
if self.get_table_bucket(&entry.table_bucket).await?.is_none() {
|
||||
return Err(crate::table_catalog::TableCatalogStoreError::NotFound(format!(
|
||||
"table bucket {}",
|
||||
entry.table_bucket
|
||||
)));
|
||||
}
|
||||
if self.get_namespace(&entry.table_bucket, &entry.namespace).await?.is_none() {
|
||||
return Err(crate::table_catalog::TableCatalogStoreError::NotFound(format!(
|
||||
"namespace {}/{}",
|
||||
entry.table_bucket, entry.namespace
|
||||
)));
|
||||
}
|
||||
self.tables.lock().await.push(entry);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn register_table(&self, entry: crate::table_catalog::TableEntry) -> crate::table_catalog::TableCatalogStoreResult<()> {
|
||||
if self.get_table_bucket(&entry.table_bucket).await?.is_none() {
|
||||
return Err(crate::table_catalog::TableCatalogStoreError::NotFound(format!(
|
||||
"table bucket {}",
|
||||
entry.table_bucket
|
||||
)));
|
||||
}
|
||||
if self.get_namespace(&entry.table_bucket, &entry.namespace).await?.is_none() {
|
||||
return Err(crate::table_catalog::TableCatalogStoreError::NotFound(format!(
|
||||
"namespace {}/{}",
|
||||
entry.table_bucket, entry.namespace
|
||||
)));
|
||||
}
|
||||
if let Some(pause) = &self.register_table_pause {
|
||||
pause.started.notify_one();
|
||||
pause.release.notified().await;
|
||||
}
|
||||
self.tables.lock().await.push(entry);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn register_table_with_publication(
|
||||
&self,
|
||||
entry: crate::table_catalog::TableEntry,
|
||||
publication: &(dyn crate::table_catalog::TableCommitPublication + Sync),
|
||||
) -> crate::table_catalog::TableCatalogStoreResult<()> {
|
||||
publication.begin_table_bucket(&entry.table_bucket).await?;
|
||||
if !publication.holds_table_bucket(&entry.table_bucket) {
|
||||
return Err(crate::table_catalog::TableCatalogStoreError::Internal(
|
||||
"table registration requires a table-bucket publication fence".to_string(),
|
||||
));
|
||||
}
|
||||
let _publication_completion = crate::table_catalog::TableCommitPublicationCompletion::new(publication);
|
||||
publication
|
||||
.prepare(&entry.table_bucket, &entry.namespace, &entry.table)
|
||||
.await?;
|
||||
if !publication.holds_table(&entry.table_bucket, &entry.namespace, &entry.table) {
|
||||
return Err(crate::table_catalog::TableCatalogStoreError::Internal(
|
||||
"table registration requires a table publication fence".to_string(),
|
||||
));
|
||||
}
|
||||
self.register_table(entry).await
|
||||
}
|
||||
|
||||
async fn list_tables(
|
||||
&self,
|
||||
table_bucket: &str,
|
||||
namespace: &str,
|
||||
) -> crate::table_catalog::TableCatalogStoreResult<Vec<crate::table_catalog::TableEntry>> {
|
||||
Ok(self
|
||||
.tables
|
||||
.lock()
|
||||
.await
|
||||
.iter()
|
||||
.filter(|entry| entry.table_bucket == table_bucket && entry.namespace == namespace)
|
||||
.cloned()
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn list_all_tables(
|
||||
&self,
|
||||
table_bucket: &str,
|
||||
) -> crate::table_catalog::TableCatalogStoreResult<Vec<crate::table_catalog::TableEntry>> {
|
||||
Ok(self
|
||||
.tables
|
||||
.lock()
|
||||
.await
|
||||
.iter()
|
||||
.filter(|entry| entry.table_bucket == table_bucket)
|
||||
.cloned()
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn load_table(
|
||||
&self,
|
||||
table_bucket: &str,
|
||||
namespace: &str,
|
||||
table: &str,
|
||||
) -> crate::table_catalog::TableCatalogStoreResult<Option<crate::table_catalog::TableEntry>> {
|
||||
Ok(self
|
||||
.tables
|
||||
.lock()
|
||||
.await
|
||||
.iter()
|
||||
.find(|entry| entry.table_bucket == table_bucket && entry.namespace == namespace && entry.table == table)
|
||||
.cloned())
|
||||
}
|
||||
|
||||
async fn commit_table(
|
||||
&self,
|
||||
request: crate::table_catalog::TableCommitRequest,
|
||||
) -> crate::table_catalog::TableCatalogStoreResult<crate::table_catalog::TableCommitResult> {
|
||||
let mut tables = self.tables.lock().await;
|
||||
let Some(index) = tables.iter().position(|entry| {
|
||||
entry.table_bucket == request.table_bucket && entry.namespace == request.namespace && entry.table == request.table
|
||||
}) else {
|
||||
return Err(crate::table_catalog::TableCatalogStoreError::NotFound(format!(
|
||||
"table {}/{}/{}",
|
||||
request.table_bucket, request.namespace, request.table
|
||||
)));
|
||||
};
|
||||
|
||||
let current = tables[index].clone();
|
||||
if current.version_token != request.expected_version_token {
|
||||
return Err(crate::table_catalog::TableCatalogStoreError::Conflict(
|
||||
"current table version token does not match expected token".to_string(),
|
||||
));
|
||||
}
|
||||
if current.metadata_location != request.expected_metadata_location {
|
||||
return Err(crate::table_catalog::TableCatalogStoreError::Conflict(
|
||||
"current table metadata location does not match expected location".to_string(),
|
||||
));
|
||||
}
|
||||
if let Some(pause) = &self.commit_table_pause {
|
||||
pause.started.notify_one();
|
||||
pause.release.notified().await;
|
||||
}
|
||||
|
||||
let mut next = current.clone();
|
||||
next.metadata_location = request.new_metadata_location.clone();
|
||||
next.version_token = "token-committed".to_string();
|
||||
next.generation = next.generation.saturating_add(1);
|
||||
tables[index] = next.clone();
|
||||
drop(tables);
|
||||
|
||||
let commit_log = crate::table_catalog::CommitLogEntry {
|
||||
version: crate::table_catalog::TABLE_CATALOG_ENTRY_VERSION,
|
||||
commit_id: request.commit_id,
|
||||
idempotency_key: request.idempotency_key,
|
||||
table_id: current.table_id,
|
||||
operation: request.operation,
|
||||
expected_version_token: request.expected_version_token,
|
||||
new_version_token: next.version_token.clone(),
|
||||
previous_metadata_location: request.expected_metadata_location,
|
||||
new_metadata_location: request.new_metadata_location,
|
||||
requirements: request.requirements,
|
||||
status: crate::table_catalog::CommitLogStatus::Committed,
|
||||
writer: request.writer,
|
||||
created_at: None,
|
||||
updated_at: None,
|
||||
};
|
||||
self.commits.lock().await.push(commit_log.clone());
|
||||
|
||||
Ok(crate::table_catalog::TableCommitResult { table: next, commit_log })
|
||||
}
|
||||
|
||||
async fn commit_table_with_publication(
|
||||
&self,
|
||||
request: crate::table_catalog::TableCommitRequest,
|
||||
publication: &(dyn crate::table_catalog::TableCommitPublication + Sync),
|
||||
) -> crate::table_catalog::TableCatalogStoreResult<crate::table_catalog::TableCommitResult> {
|
||||
publication
|
||||
.prepare(&request.table_bucket, &request.namespace, &request.table)
|
||||
.await?;
|
||||
if !publication.holds_table(&request.table_bucket, &request.namespace, &request.table) {
|
||||
return Err(crate::table_catalog::TableCatalogStoreError::Internal(
|
||||
"table commit requires a table publication fence".to_string(),
|
||||
));
|
||||
}
|
||||
let _publication_completion = crate::table_catalog::TableCommitPublicationCompletion::new(publication);
|
||||
self.commit_table(request).await
|
||||
}
|
||||
|
||||
async fn drop_table(
|
||||
&self,
|
||||
table_bucket: &str,
|
||||
namespace: &str,
|
||||
table: &str,
|
||||
) -> crate::table_catalog::TableCatalogStoreResult<()> {
|
||||
self.tables
|
||||
.lock()
|
||||
.await
|
||||
.retain(|entry| !(entry.table_bucket == table_bucket && entry.namespace == namespace && entry.table == table));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn create_view(&self, entry: crate::table_catalog::ViewEntry) -> crate::table_catalog::TableCatalogStoreResult<()> {
|
||||
if self.get_table_bucket(&entry.table_bucket).await?.is_none() {
|
||||
return Err(crate::table_catalog::TableCatalogStoreError::NotFound(format!(
|
||||
"table bucket {}",
|
||||
entry.table_bucket
|
||||
)));
|
||||
}
|
||||
if self.get_namespace(&entry.table_bucket, &entry.namespace).await?.is_none() {
|
||||
return Err(crate::table_catalog::TableCatalogStoreError::NotFound(format!(
|
||||
"namespace {}/{}",
|
||||
entry.table_bucket, entry.namespace
|
||||
)));
|
||||
}
|
||||
if let Some(pause) = &self.create_view_pause {
|
||||
pause.started.notify_one();
|
||||
pause.release.notified().await;
|
||||
}
|
||||
self.views.lock().await.push(entry);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn list_views(
|
||||
&self,
|
||||
table_bucket: &str,
|
||||
namespace: &str,
|
||||
) -> crate::table_catalog::TableCatalogStoreResult<Vec<crate::table_catalog::ViewEntry>> {
|
||||
Ok(self
|
||||
.views
|
||||
.lock()
|
||||
.await
|
||||
.iter()
|
||||
.filter(|entry| entry.table_bucket == table_bucket && entry.namespace == namespace)
|
||||
.cloned()
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn load_view(
|
||||
&self,
|
||||
table_bucket: &str,
|
||||
namespace: &str,
|
||||
view: &str,
|
||||
) -> crate::table_catalog::TableCatalogStoreResult<Option<crate::table_catalog::ViewEntry>> {
|
||||
Ok(self
|
||||
.views
|
||||
.lock()
|
||||
.await
|
||||
.iter()
|
||||
.find(|entry| entry.table_bucket == table_bucket && entry.namespace == namespace && entry.view == view)
|
||||
.cloned())
|
||||
}
|
||||
|
||||
async fn replace_view(
|
||||
&self,
|
||||
request: crate::table_catalog::ViewCommitRequest,
|
||||
) -> crate::table_catalog::TableCatalogStoreResult<crate::table_catalog::ViewCommitResult> {
|
||||
let mut views = self.views.lock().await;
|
||||
let Some(index) = views.iter().position(|entry| {
|
||||
entry.table_bucket == request.table_bucket && entry.namespace == request.namespace && entry.view == request.view
|
||||
}) else {
|
||||
return Err(crate::table_catalog::TableCatalogStoreError::NotFound(format!(
|
||||
"view {}/{}/{}",
|
||||
request.table_bucket, request.namespace, request.view
|
||||
)));
|
||||
};
|
||||
let current = views[index].clone();
|
||||
if current.version_token != request.expected_version_token {
|
||||
return Err(crate::table_catalog::TableCatalogStoreError::Conflict(
|
||||
"current view version token does not match expected token".to_string(),
|
||||
));
|
||||
}
|
||||
if current.metadata_location != request.expected_metadata_location {
|
||||
return Err(crate::table_catalog::TableCatalogStoreError::Conflict(
|
||||
"current view metadata location does not match expected location".to_string(),
|
||||
));
|
||||
}
|
||||
if let Some(pause) = &self.replace_view_pause {
|
||||
pause.started.notify_one();
|
||||
pause.release.notified().await;
|
||||
}
|
||||
let mut next = current;
|
||||
next.metadata_location = request.new_metadata_location;
|
||||
next.version_token = "token-view-committed".to_string();
|
||||
next.generation = next.generation.saturating_add(1);
|
||||
views[index] = next.clone();
|
||||
Ok(crate::table_catalog::ViewCommitResult { view: next })
|
||||
}
|
||||
|
||||
async fn drop_view(
|
||||
&self,
|
||||
table_bucket: &str,
|
||||
namespace: &str,
|
||||
view: &str,
|
||||
) -> crate::table_catalog::TableCatalogStoreResult<()> {
|
||||
self.views
|
||||
.lock()
|
||||
.await
|
||||
.retain(|entry| !(entry.table_bucket == table_bucket && entry.namespace == namespace && entry.view == view));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_commit_by_id(
|
||||
&self,
|
||||
_table_bucket: &str,
|
||||
_table_id: &str,
|
||||
_commit_id: &str,
|
||||
) -> crate::table_catalog::TableCatalogStoreResult<Option<crate::table_catalog::CommitLogEntry>> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn get_commit_by_idempotency_key(
|
||||
&self,
|
||||
_table_bucket: &str,
|
||||
_table_id: &str,
|
||||
_idempotency_key: &str,
|
||||
) -> crate::table_catalog::TableCatalogStoreResult<Option<crate::table_catalog::CommitLogEntry>> {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
+1676
-361
File diff suppressed because it is too large
Load Diff
@@ -4033,7 +4033,7 @@ if [[ -s "$ECSTORE_REMOTE_TIER_DELETE_STATE_BYPASS_HITS_FILE" ]]; then
|
||||
report_failure "remote tier delete state access must stay behind ECStore tier sweeper owner helpers: $(paste -sd '; ' "$ECSTORE_REMOTE_TIER_DELETE_STATE_BYPASS_HITS_FILE")"
|
||||
fi
|
||||
|
||||
RUSTFS_OWNER_LOCAL_STATIC_NAMES='(KEYSTONE_AUTH|KEYSTONE_MAPPER|KEYSTONE_CONFIG|LICENSE_STATE|LICENSE_VERIFIER|CPU_CONT_GUARD|PROFILING_CANCEL_TOKEN|MEMORY_SYSTEM|DIAL9_TELEMETRY_GUARD|DISPLAY_CONFIG_SNAPSHOT|GLOBAL_CONFIG_SNAPSHOT|BUFFER_CONFIG_SINGLETON|BUFFER_PROFILE_ENABLED|LEGACY_CREDENTIAL_WARNED_KEYS|CONSOLE_CONFIG|ACTIVE_HTTP_REQUESTS|USE_STARSHARD_CACHE|BUCKET_CACHE_SMALL|BUCKET_CACHE_LARGE|GLOBAL_SSE_DEK_PROVIDER|SSE_TEST_LOCK|AUTH_FS|LOCK_STATS|DEADLOCK_DETECTOR|GET_OBJECT_BUFFER_THRESHOLD_WARNED|GET_READER_STREAM_BUFFER_SIZE_OVERRIDE|OBJECT_SEEK_SUPPORT_THRESHOLD|OBJECT_SEEK_SUPPORT_CONCURRENCY_THRESHOLDS|SUPPORTED_HEADERS|SITE_REPLICATION_PEER_CLIENT|SITE_REPLICATION_STATE_LOCK|AUDIT_MODULE_ENABLED|NOTIFY_MODULE_ENABLED|PERSISTED_NOTIFY_MODULE_ENABLED|PERSISTED_AUDIT_MODULE_ENABLED|PERSISTED_MODULE_SWITCH_CONFIGURED|DELETE_TAIL_TOTAL|DELETE_CLEANUP_TOTAL|DELETE_REPLICATION_TOTAL|DELETE_NOTIFY_TOTAL|EMBEDDED_SERVER_STARTED|TEST_OUTBOUND_TLS_GENERATION|TEST_REMAINING_FAILURES|CAPACITY_DIRTY_SCOPE_ENV|CAPACITY_DIRTY_SCOPE_INIT|GLOBAL_ENV)'
|
||||
RUSTFS_OWNER_LOCAL_STATIC_NAMES='(KEYSTONE_AUTH|KEYSTONE_MAPPER|KEYSTONE_CONFIG|LICENSE_STATE|LICENSE_VERIFIER|CPU_CONT_GUARD|PROFILING_CANCEL_TOKEN|MEMORY_SYSTEM|DIAL9_TELEMETRY_GUARD|DISPLAY_CONFIG_SNAPSHOT|GLOBAL_CONFIG_SNAPSHOT|BUFFER_CONFIG_SINGLETON|BUFFER_PROFILE_ENABLED|LEGACY_CREDENTIAL_WARNED_KEYS|CONSOLE_CONFIG|ACTIVE_HTTP_REQUESTS|USE_STARSHARD_CACHE|BUCKET_CACHE_SMALL|BUCKET_CACHE_LARGE|GLOBAL_SSE_DEK_PROVIDER|SSE_TEST_LOCK|AUTH_FS|LOCK_STATS|DEADLOCK_DETECTOR|GET_OBJECT_BUFFER_THRESHOLD_WARNED|GET_READER_STREAM_BUFFER_SIZE_OVERRIDE|OBJECT_SEEK_SUPPORT_THRESHOLD|OBJECT_SEEK_SUPPORT_CONCURRENCY_THRESHOLDS|SUPPORTED_HEADERS|SITE_REPLICATION_PEER_CLIENT|AUDIT_MODULE_ENABLED|NOTIFY_MODULE_ENABLED|PERSISTED_NOTIFY_MODULE_ENABLED|PERSISTED_AUDIT_MODULE_ENABLED|PERSISTED_MODULE_SWITCH_CONFIGURED|DELETE_TAIL_TOTAL|DELETE_CLEANUP_TOTAL|DELETE_REPLICATION_TOTAL|DELETE_NOTIFY_TOTAL|EMBEDDED_SERVER_STARTED|TEST_OUTBOUND_TLS_GENERATION|TEST_REMAINING_FAILURES|CAPACITY_DIRTY_SCOPE_ENV|CAPACITY_DIRTY_SCOPE_INIT|GLOBAL_ENV)'
|
||||
|
||||
(
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
@@ -984,7 +984,9 @@ trace_hot_spans=(
|
||||
"crates/ecstore/src/store/object.rs:handle_get_object_info"
|
||||
"crates/ecstore/src/set_disk/ops/object.rs:get_object_info"
|
||||
"crates/ecstore/src/store/mod.rs:list_objects_v2"
|
||||
"crates/ecstore/src/store/list.rs:handle_list_objects_v2"
|
||||
# The ECStore handle_list_objects_v2 forwarder was folded into the trait impl
|
||||
# above, so store/mod.rs now carries this hot path's TRACE requirement
|
||||
# directly (backlog#1821).
|
||||
"crates/ecstore/src/core/sets.rs:list_objects_v2"
|
||||
"crates/ecstore/src/set_disk/ops/list.rs:list_objects_v2"
|
||||
"rustfs/src/app/bucket_usecase.rs:execute_list_objects_v2"
|
||||
|
||||
@@ -209,6 +209,7 @@ export RUSTFS_NS_SCANNER_INTERVAL=60 # Object scanning interval in seconds
|
||||
|
||||
# Storage level compression (compression at object storage level)
|
||||
# export RUSTFS_COMPRESSION_ENABLED=true # Whether to enable storage-level compression for objects
|
||||
# export RUSTFS_COMPRESSION_MULTIPART_ENABLED=true # Additionally compress multipart uploads (staged rollout switch: enable only after the whole fleet runs a build with the resumable decompressor; see docs/architecture/compat-cleanup-register.md)
|
||||
|
||||
# HTTP Response Compression (whitelist-based, aligned with MinIO)
|
||||
# By default, HTTP response compression is DISABLED (aligned with MinIO behavior)
|
||||
|
||||
@@ -195,6 +195,13 @@ IFS= read -r -d '' expected_docker_automatic_guard <<'EOF' || true
|
||||
EOF
|
||||
expected_docker_automatic_guard=${expected_docker_automatic_guard%$'\n'}
|
||||
require_job_if "$docker_workflow" "build-check" "$expected_docker_automatic_guard"
|
||||
require_line "$docker_workflow" ' source_ref: ${{ steps.check.outputs.source_ref }}' "Docker source ref output"
|
||||
require_line "$docker_workflow" ' source_ref="$HEAD_SHA"' "automatic Docker source ref"
|
||||
require_line "$docker_workflow" ' source_ref="$tag_ref"' "manual Docker source ref"
|
||||
require_line "$docker_workflow" ' ref: ${{ needs.build-check.outputs.source_ref }}' "Docker release source checkout"
|
||||
require_line "$docker_workflow" ' SOURCE_REVISION="$(git rev-parse HEAD)"' "Docker source revision resolution"
|
||||
require_line "$docker_workflow" ' LABELS="$LABELS,org.opencontainers.image.revision=$SOURCE_REVISION"' "Docker revision label"
|
||||
require_absent "$docker_workflow" 'org.opencontainers.image.revision=${{ github.sha }}' "Docker revision must not use the workflow branch SHA"
|
||||
|
||||
docker_manual_guard=$(awk '
|
||||
$0 == " *-preview*)" { in_preview = 1 }
|
||||
|
||||
@@ -132,18 +132,8 @@ def failure_probe_plan(warehouse: str, namespace: str, table: str, rest_path: st
|
||||
"expected-version-token": "stale-token-from-previous-load",
|
||||
"expected-metadata-location": "current-metadata-location-from-load-table",
|
||||
"new-metadata-location": f"s3://{warehouse}/tables/table-id/metadata/conflict_probe.metadata.json",
|
||||
"requirements": [
|
||||
{
|
||||
"type": "assert-current-snapshot-id",
|
||||
"snapshot-id": 0,
|
||||
}
|
||||
],
|
||||
"updates": [
|
||||
{
|
||||
"action": "set-current-schema",
|
||||
"schema-id": 0,
|
||||
}
|
||||
],
|
||||
"requirements": [],
|
||||
"updates": [],
|
||||
},
|
||||
),
|
||||
probe_step(
|
||||
@@ -157,6 +147,8 @@ def failure_probe_plan(warehouse: str, namespace: str, table: str, rest_path: st
|
||||
"expected-version-token": "current-version-token-from-load-table",
|
||||
"expected-metadata-location": "current-metadata-location-from-load-table",
|
||||
"new-metadata-location": f"s3://{warehouse}/tables/table-id/metadata/does_not_exist.metadata.json",
|
||||
"requirements": [],
|
||||
"updates": [],
|
||||
},
|
||||
),
|
||||
probe_step(
|
||||
|
||||
@@ -48,6 +48,8 @@ class FailureCoverageTest(unittest.TestCase):
|
||||
self.assertIn("expected-version-token", by_name["stale-token-commit-conflict"]["body"])
|
||||
self.assertIn("expected-metadata-location", by_name["stale-token-commit-conflict"]["body"])
|
||||
self.assertIn("new-metadata-location", by_name["stale-token-commit-conflict"]["body"])
|
||||
self.assertEqual(by_name["stale-token-commit-conflict"]["body"]["requirements"], [])
|
||||
self.assertEqual(by_name["stale-token-commit-conflict"]["body"]["updates"], [])
|
||||
self.assertNotIn("base", by_name["stale-token-commit-conflict"]["body"])
|
||||
self.assertEqual(
|
||||
by_name["diagnostics-after-finalization-gap"]["path"],
|
||||
@@ -56,6 +58,8 @@ class FailureCoverageTest(unittest.TestCase):
|
||||
self.assertEqual(by_name["diagnostics-after-finalization-gap"]["method"], "GET")
|
||||
self.assertEqual(by_name["recovery-repairs-idempotency-index"]["method"], "POST")
|
||||
self.assertIn("does_not_exist.metadata.json", json.dumps(by_name["missing-metadata-object-rejected"]))
|
||||
self.assertEqual(by_name["missing-metadata-object-rejected"]["body"]["requirements"], [])
|
||||
self.assertEqual(by_name["missing-metadata-object-rejected"]["body"]["updates"], [])
|
||||
self.assertNotIn("base", by_name["missing-metadata-object-rejected"]["body"])
|
||||
|
||||
def test_cli_prints_failure_matrix_and_probe_plan(self) -> None:
|
||||
|
||||
Reference in New Issue
Block a user