mirror of
https://github.com/rustfs/rustfs.git
synced 2026-10-27 14:27:10 +00:00
Compare commits
71 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 331fb89628 | |||
| 2b243d8659 | |||
| c52570d385 | |||
| e0b1635abf | |||
| f0f2b0cf48 | |||
| 0882e99c32 | |||
| 802ac8a1bd | |||
| 9408b90f13 | |||
| 08ac1ec218 | |||
| 388d1f17e6 | |||
| 22c093e66b | |||
| 24ab34a5ee | |||
| 9068d83400 | |||
| 20fc897036 | |||
| a86117590c | |||
| ceb53fef85 | |||
| 4e0ff037ec | |||
| fa0ac13e9b | |||
| c7848df9a0 | |||
| 4502a1f9b0 | |||
| 93d51d172f | |||
| b80590f1a7 | |||
| 025ed4c6d1 | |||
| 3b428bffef | |||
| da52303f2a | |||
| a79c6072f1 | |||
| ab512768d5 | |||
| adf17bbfba | |||
| fb5c002a30 | |||
| 8a94c64934 | |||
| 9ca0e9b717 | |||
| bd83bd3f6b | |||
| 34803e8165 | |||
| 9af2f7c04e | |||
| 9d6f48c325 | |||
| e7e6556f83 | |||
| 0a6aeda191 | |||
| 0e0f656ae1 | |||
| a57a3ae51d | |||
| 5d8e143505 | |||
| d6fe5c30ce | |||
| ac6cf8a50d | |||
| a7188a16b4 | |||
| 0195732a1f | |||
| 0ef133bbb0 | |||
| 5228463a1c | |||
| dd90bb7488 | |||
| 0018bbf5d7 | |||
| 13ee76a4a9 | |||
| 3dc09c9397 | |||
| 71c0efa97d | |||
| 4166b9503f | |||
| 201b5f62d2 | |||
| 902c97ce05 | |||
| 7ad4fa439e | |||
| 1266d4c270 | |||
| acd26afd8b | |||
| 5924a55931 | |||
| 2d2679a710 | |||
| 257c9f11ae | |||
| 1324e296f8 | |||
| 38263ba896 | |||
| ba2047b184 | |||
| da42266bfb | |||
| 367f13f3f9 | |||
| 0263d5fcb9 | |||
| 93a64fdca3 | |||
| 9ecb00d388 | |||
| b8889fccc9 | |||
| a424d10fa0 | |||
| 072b57b375 |
@@ -45,11 +45,13 @@ Update this file only when an advisory adds or changes a reusable lesson, affect
|
||||
- `GHSA-6r96-hmgc-726c`: request headers collided with lowercase server-derived condition keys such as `userid`, `groups`, `versionid`, and JWT/LDAP claims. Lesson: never let caller-controlled headers append to or replace server-derived policy context; reserve trusted condition keys and keep intentional request-header keys separate.
|
||||
- `GHSA-v9cp-qfw9-9pfp`: quantified negated string conditions applied negation after aggregation, transposing `ForAllValues` and `ForAnyValue` semantics. Lesson: push negation into the per-value predicate for quantified operators and test partially overlapping multi-value sets.
|
||||
- `GHSA-5w8r-p896-6vq2`: OPA policy mode skipped `ExistingObjectTag/*` loading, so tagged objects looked untagged to external policies. Lesson: external authorization plugins need the same object-tag and request context as built-in policy evaluation before they decide.
|
||||
- `GHSA-9wgh-5r68-49p7`: substituted policy-variable values could recreate `${...}` and loop inside a single resolution pass, never reaching the outer iteration limit. Lesson: bound substitutions within each pass and test cycles formed across otherwise literal resolver values; confirm whether attacker-controlled claims can reach those values before assigning remote impact.
|
||||
|
||||
### S3 object actions, copy, multipart, and upload policy validation
|
||||
|
||||
- `GHSA-g8w9-qw9q-fghr` and `GHSA-xm99-m3gq-83g8`: a valid presigned `PutObject` accepted extra `x-amz-*` headers omitted from `SignedHeaders`; `x-amz-copy-source` could turn that upload capability into a cross-bucket read performed as the signer. Lesson: a presigned URL is a bounded capability; inspect all received security-sensitive headers and reject unsigned ones before selecting the S3 operation or reaching storage.
|
||||
- `GHSA-3ppv-fx5m-m749`: explicit `versionId` reads and copy sources authorized `s3:GetObject` instead of `s3:GetObjectVersion`. Lesson: version-specific object access must select version-specific actions for direct reads, `CopyObject`, and `UploadPartCopy`, with tests proving the backend is not reached on denial.
|
||||
- `GHSA-cg33-pxfp-qcj3` and `GHSA-65wq-gjw8-xr9w`: `HeadObject` and object-tagging paths authorized current-object actions while operating on an explicit historical `versionId`. Lesson: select the corresponding version action for metadata and tag reads and writes too; audit every operation that accepts a version ID.
|
||||
- `GHSA-gjph-xcgw-4p7w`: `PutObject` and browser POST accepted a caller-supplied `versionId` and replaced an unlocked historical version with only `s3:PutObject`. Lesson: only replication-authorized writes may choose an existing version ID; ordinary uploads must not rewrite version history.
|
||||
- `GHSA-x298-9x87-fvjq`: anonymous `ListObjectVersions` fell back to `ListBucket` and returned before public-access-block gates. Lesson: compatibility fallbacks must converge on the same post-authorization checks as direct grants, especially `RestrictPublicBuckets` and anonymous data-plane denies.
|
||||
- `GHSA-mx42-j6wv-px98`: `UploadPartCopy` missed source authorization and allowed cross-bucket object exfiltration. Lesson: multipart copy must enforce the same source and destination contract as `CopyObject`.
|
||||
@@ -110,6 +112,7 @@ Update this file only when an advisory adds or changes a reusable lesson, affect
|
||||
|
||||
- `GHSA-j548-9grx-fh4f`: Object Lock enforcement treated unreadable, fabricated, or unparsable bucket metadata as absent configuration and allowed retained objects to be deleted or expired. Lesson: retention must fail closed unless Object Lock absence is authoritative, and every delete, lifecycle, scanner, force-delete, and default-retention path needs the same state distinction.
|
||||
- `GHSA-28w4-rp2p-636w` and `GHSA-c7hx-vr97-45rg`: an authorized `DeleteBucket` with `x-rustfs-force-delete: true` removed active COMPLIANCE-retained versions even though ordinary version deletion was denied. Lesson: bucket force-delete must enforce retention across every version, including against root/owner callers.
|
||||
- `GHSA-q23w-fp7p-472v`: `PutObjectRetention` policy conditions read `x-amz-object-lock-*` headers while the handler applied mode and date from the XML body. Lesson: authorize the parsed retention that will be stored, or reject mismatched header/body values; compare retention dates as instants so offsets cannot change the applied bound.
|
||||
|
||||
### Serde deserialization and input validation
|
||||
|
||||
@@ -143,13 +146,13 @@ rg -n "deny_unknown_fields|serde.default|as u32|as usize|as i32" rustfs crates
|
||||
- IAM fixes: include import/update/list service-account cases with attacker-controlled parent, claims, access key, secret key, and policy.
|
||||
- Copy/upload fixes: include cross-bucket, cross-user, source-denied, destination-denied, copy-source-condition, and multipart completion cases.
|
||||
- Presigned upload fixes: include a valid presign with extra unsigned tagging, redirect, storage-class, and cross-bucket copy-source headers; require rejection before source or destination storage access, and verify explicitly signed equivalents still work.
|
||||
- Version-action fixes: include historical UUID, explicit current version, `null`, range, partNumber, presigned, STS/session, service-account, anonymous bucket-policy, copy source, and multipart-copy source cases.
|
||||
- Version-action fixes: include historical UUID, explicit current version, `null`, range, partNumber, presigned, STS/session, service-account, anonymous bucket-policy, copy source, multipart-copy source, `HeadObject`, and tag read/write cases.
|
||||
- Version-write fixes: a `PutObject`-only principal must not replace an existing version through `PutObject` or browser POST `?versionId=`; verify replication-authorized writes and versioned, suspended, and unversioned buckets separately.
|
||||
- Policy-condition fixes: include reserved-key header collisions, missing keys, partially overlapping multi-value sets, plugin mode, and built-in policy mode.
|
||||
- Policy-condition fixes: include reserved-key header collisions, missing keys, partially overlapping multi-value sets, cyclic variable substitutions, plugin mode, and built-in policy mode.
|
||||
- Path fixes: include encoded traversal, absolute path, nested traversal, archive entries with `..`, valid object keys that resemble traversal text but should be rejected, and canonical bucket/prefix boundary checks; test per-key `DeleteObjects` errors and prefix-scoped denials on versioned buckets.
|
||||
- Logging fixes: assert redacted output for structs and response bodies that may contain credentials.
|
||||
- IAM export fixes: assert exported archives omit plaintext user and service-account secrets unless the format deliberately encrypts or seals them.
|
||||
- RPC auth fixes: include captured metadata replay across two concrete methods, stale timestamps, wrong path, wrong method surrogate, wrong secret, and valid same-method calls.
|
||||
- Browser/CORS fixes: assert no credentials on reflected/default origins, correct behavior for explicit allowlists, and no same-origin script execution for previewed object content.
|
||||
- SSE fixes: inspect stored bytes and verify API metadata, read-back behavior, and on-disk ciphertext together; for ETags, upload equal plaintext under different keys and assert list-only callers cannot derive or correlate the plaintext digest, including multipart objects.
|
||||
- Object Lock fixes: include unreadable metadata, fabricated metadata defaults, unparsable config, confirmed absent config, COMPLIANCE/GOVERNANCE retention, lifecycle expiry, scanner sweeps, and force-delete paths; an authorized force-delete must preserve active COMPLIANCE versions.
|
||||
- Object Lock fixes: include unreadable metadata, fabricated metadata defaults, unparsable config, confirmed absent config, COMPLIANCE/GOVERNANCE retention, lifecycle expiry, scanner sweeps, and force-delete paths; an authorized force-delete must preserve active COMPLIANCE versions. For `PutObjectRetention`, test conflicting header/body values and offset dates against policy bounds and read-back retention.
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
sha256-darwin=98dff0961c6eb21d6128bfd2d4a6100a624ddf1fb7db05dba360a5d3d0a310c2
|
||||
sha256-linux=890f5925b87e1bf3710c356253f3b2276d901e156df9ccc04ec9315374de6d22
|
||||
sha256-darwin=1520cd3027c3a6924afc6a204eddd5475bc30f0109d4c2847456a66177263244
|
||||
sha256-linux=26155b7f1c5bf6bf596ff24a4136e6767ea4ee6461c32db7fbe1400791da2c28
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
sha256-darwin=efe68675f4676a0d2901242c5815b1f7af5cdec8231f7cccd408f6dacf5c75db
|
||||
sha256-linux=003ed6480935a0a95680cc061be143722fbd37bd477b8ff98607b1a064f3aef1
|
||||
sha256-darwin=347a0d4e5e547d5388703062ce66a66167a845b6e57ec591ccb4c732a567c68d
|
||||
sha256-linux=4f9efa47194279185bb68249e82c788fa147abe944b6c764050e3d47f31e7926
|
||||
|
||||
@@ -52,6 +52,9 @@ script-tests: ## Run shell script tests
|
||||
./scripts/check_embedded_secrets.sh --self-test
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/check_test_wiring.py --self-test
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/test_e2e_binary.py
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/test_release_build_workflow.py
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/test_release_glibc.py
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/test_upgrade_workflow.py
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/test_migration_gate_evidence.py
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/ci_gate.py --self-test
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/check_security_coverage.py --self-test
|
||||
|
||||
@@ -88,6 +88,11 @@ threads-required = "num-test-threads"
|
||||
filter = 'package(rustfs-ecstore) & test(/^(bucket::lifecycle::bucket_lifecycle_ops::tests::manual_transition_worker_result_recovery_marks_unknown_for_corrupt_marker|services::rebalance::entry::tests::real_rebalance_run_fence_loss_blocks_multipart_publication|store::init::tests::(batch_transitioned_delete_uses_free_version_per_item|decommission_entry_(allows_free_version_consumed_before_source_lock|rejects_subquorum_free_version_conflict_and_retains_source|skips_cleanup_only_marker_when_free_version_is_present)|dispatched_tier_delete_recovery_(checks_later_pool_then_commits_after_source_removal|finds_directory_source_on_encoded_set|retains_journal_on_source_metadata_error)|force_tier_remove_blocks_on_physical_free_version_hidden_by_other_pool|legacy_unknown_transition_delete_falls_back_for_single_batch_and_blocks_prefix|multi_pool_(recursive_prefix_rejects_legacy_or_hidden_merge_loser_before_delete|same_remote_tuple_(batch|single)_delete_waits_for_all_sources|same_tuple_recursive_prefix_uses_one_journal_owner|transitioned_delete_persists_one_free_version_per_remote_tuple)|recursive_prefix_partial_(pool|set)_failure_keeps_prepared_cleanup_owners|restored_transitioned_delete_uses_free_version_as_cleanup_owner|stable_transitioned_recursive_prefix_delete_uses_journal_owners|suspended_null_transition_delete_uses_free_version_as_sole_owner|tier_mutation_peer_handler_applies_prepare_commit_and_abort_idempotently|transition_response_loss_persists_unknown_outcome_for_provider_recovery|transition_transaction_recovery_(drops_record_after_confirmed_local_commit|keeps_cleanup_pending_local_commit)|transitioned_delete_(free_version_replays_after_store_restart|local_quorum_failure_rolls_back_without_cleanup_owner|uses_free_version_as_cleanup_owner)|versioned_delete_marker_keeps_transitioned_source_and_remote_object|versioned_explicit_transition_delete_preserves_other_version_then_allows_bucket_delete))$/)'
|
||||
setup = 'ecstore-large-stack'
|
||||
|
||||
# These cross-crate real-storage fixtures need the same bounded stack as ECStore tests.
|
||||
[[profile.default.scripts]]
|
||||
filter = '(package(rustfs) & test(/^(admin::handlers::site_replication::tests::durable_iam_state_apply_iam_item_rejects_a_stale_recreate_after_a_replicated_delete|admin::service::federated_identity::tests::(binding_keeps_explicit_iam_mapping_strict|markerless_legacy_oidc_session_crosses_auth_and_iam_deletion_boundaries)|storage::scanner_dirty_journal::tests::(clear_callback_keeps_unknown_coverage_until_all_pending_work_is_confirmed|owned_journals_keep_peer_records_after_interleaved_writes_and_clear))$/)) | (package(rustfs-heal) & ((binary(heal_b5_versioned_regression_test) & test(/^serial_tests::(test_heal_unversioned_bucket_e2e|test_historical_null_recursive_heal_restores_physical_versions|test_recursive_deep_heal_reports_stale_delete_marker_repair)$/)) | (binary(usage_observation_read_repair_test) & test(=usage_observation_read_repair_survives_snapshot_cleanup)))) | (package(rustfs-test-utils) & test(=data_usage_snapshot_tests::data_usage_snapshot_recovery_wires_primary_backup_and_negative_cache))'
|
||||
setup = 'ecstore-base-stack'
|
||||
|
||||
[[profile.default.scripts]]
|
||||
filter = 'package(rustfs-ecstore) | package(rustfs-s3select-api) | package(rustfs-scanner) | (package(rustfs) & test(/^(app::multipart_usecase::tests::concurrent_completions_share_durable_bucket_quota_reservations|app::object::delete::tests::compressed_delete_requests_update_observed_usage_without_releasing_quota_floor|app::object::internal_put::tests::internal_multipart_roundtrip_completes_and_abort_leaves_nothing|app::object::restore::tests::execute_restore_object_maps_failures_to_typed_s3_errors|storage::access::tests::(delete_object_access_captures_authorized_bucket_incarnation|copy_operations_reject_recreated_source_bucket_after_authorization|request_slot_keeps_bucket_policy_bound_to_its_store))$/))'
|
||||
setup = 'ecstore-base-stack'
|
||||
@@ -339,6 +344,11 @@ threads-required = "num-test-threads"
|
||||
filter = 'package(rustfs-ecstore) & test(/^(bucket::lifecycle::bucket_lifecycle_ops::tests::manual_transition_worker_result_recovery_marks_unknown_for_corrupt_marker|services::rebalance::entry::tests::real_rebalance_run_fence_loss_blocks_multipart_publication|store::init::tests::(batch_transitioned_delete_uses_free_version_per_item|decommission_entry_(allows_free_version_consumed_before_source_lock|rejects_subquorum_free_version_conflict_and_retains_source|skips_cleanup_only_marker_when_free_version_is_present)|dispatched_tier_delete_recovery_(checks_later_pool_then_commits_after_source_removal|finds_directory_source_on_encoded_set|retains_journal_on_source_metadata_error)|force_tier_remove_blocks_on_physical_free_version_hidden_by_other_pool|legacy_unknown_transition_delete_falls_back_for_single_batch_and_blocks_prefix|multi_pool_(recursive_prefix_rejects_legacy_or_hidden_merge_loser_before_delete|same_remote_tuple_(batch|single)_delete_waits_for_all_sources|same_tuple_recursive_prefix_uses_one_journal_owner|transitioned_delete_persists_one_free_version_per_remote_tuple)|recursive_prefix_partial_(pool|set)_failure_keeps_prepared_cleanup_owners|restored_transitioned_delete_uses_free_version_as_cleanup_owner|stable_transitioned_recursive_prefix_delete_uses_journal_owners|suspended_null_transition_delete_uses_free_version_as_sole_owner|tier_mutation_peer_handler_applies_prepare_commit_and_abort_idempotently|transition_response_loss_persists_unknown_outcome_for_provider_recovery|transition_transaction_recovery_(drops_record_after_confirmed_local_commit|keeps_cleanup_pending_local_commit)|transitioned_delete_(free_version_replays_after_store_restart|local_quorum_failure_rolls_back_without_cleanup_owner|uses_free_version_as_cleanup_owner)|versioned_delete_marker_keeps_transitioned_source_and_remote_object|versioned_explicit_transition_delete_preserves_other_version_then_allows_bucket_delete))$/)'
|
||||
setup = 'ecstore-large-stack'
|
||||
|
||||
# These cross-crate real-storage fixtures need the same bounded stack as ECStore tests.
|
||||
[[profile.ci.scripts]]
|
||||
filter = '(package(rustfs) & test(/^(admin::handlers::site_replication::tests::durable_iam_state_apply_iam_item_rejects_a_stale_recreate_after_a_replicated_delete|admin::service::federated_identity::tests::(binding_keeps_explicit_iam_mapping_strict|markerless_legacy_oidc_session_crosses_auth_and_iam_deletion_boundaries)|storage::scanner_dirty_journal::tests::(clear_callback_keeps_unknown_coverage_until_all_pending_work_is_confirmed|owned_journals_keep_peer_records_after_interleaved_writes_and_clear))$/)) | (package(rustfs-heal) & ((binary(heal_b5_versioned_regression_test) & test(/^serial_tests::(test_heal_unversioned_bucket_e2e|test_historical_null_recursive_heal_restores_physical_versions|test_recursive_deep_heal_reports_stale_delete_marker_repair)$/)) | (binary(usage_observation_read_repair_test) & test(=usage_observation_read_repair_survives_snapshot_cleanup)))) | (package(rustfs-test-utils) & test(=data_usage_snapshot_tests::data_usage_snapshot_recovery_wires_primary_backup_and_negative_cache))'
|
||||
setup = 'ecstore-base-stack'
|
||||
|
||||
[[profile.ci.scripts]]
|
||||
filter = 'package(rustfs-ecstore) | package(rustfs-s3select-api) | package(rustfs-scanner) | (package(rustfs) & test(/^(app::multipart_usecase::tests::concurrent_completions_share_durable_bucket_quota_reservations|app::object::delete::tests::compressed_delete_requests_update_observed_usage_without_releasing_quota_floor|app::object::internal_put::tests::internal_multipart_roundtrip_completes_and_abort_leaves_nothing|app::object::restore::tests::execute_restore_object_maps_failures_to_typed_s3_errors|storage::access::tests::(delete_object_access_captures_authorized_bucket_incarnation|copy_operations_reject_recreated_source_bucket_after_authorization|request_slot_keeps_bucket_policy_bound_to_its_store))$/))'
|
||||
setup = 'ecstore-base-stack'
|
||||
|
||||
@@ -1748,7 +1748,7 @@
|
||||
"uid": "${datasource}"
|
||||
},
|
||||
"editorMode": "code",
|
||||
"expr": "max by (job, bucket) (rustfs_cluster_usage_buckets_objects_count{job=~\"$job\", bucket=~\"$bucket\"})",
|
||||
"expr": "max by (rustfs_cluster_id, job, bucket) (rustfs_cluster_usage_buckets_objects_count{rustfs_cluster_id=\"$storage_cluster\", job=~\"$job\", bucket=~\"$bucket\"})",
|
||||
"legendFormat": "{{bucket}}",
|
||||
"range": true,
|
||||
"refId": "A"
|
||||
@@ -1848,7 +1848,7 @@
|
||||
"uid": "${datasource}"
|
||||
},
|
||||
"editorMode": "code",
|
||||
"expr": "max by (job, bucket) (rustfs_cluster_usage_buckets_total_bytes{job=~\"$job\", bucket=~\"$bucket\"})",
|
||||
"expr": "max by (rustfs_cluster_id, job, bucket) (rustfs_cluster_usage_buckets_total_bytes{rustfs_cluster_id=\"$storage_cluster\", job=~\"$job\", bucket=~\"$bucket\"})",
|
||||
"legendFormat": "{{bucket}}",
|
||||
"range": true,
|
||||
"refId": "A"
|
||||
|
||||
@@ -61,6 +61,7 @@ scrape_configs:
|
||||
otlp:
|
||||
promote_resource_attributes:
|
||||
- service.instance.id
|
||||
- process.pid
|
||||
- service.name
|
||||
- service.namespace
|
||||
- cloud.availability_zone
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
evaluation_interval: 1m
|
||||
tests:
|
||||
- name: bucket usage keeps clusters separate and deduplicates reporting nodes
|
||||
interval: 1m
|
||||
input_series:
|
||||
- series: rustfs_cluster_usage_buckets_total_bytes{rustfs_cluster_id="a",job="rustfs",bucket="shared",service_instance_id="a1"}
|
||||
values: '4096 1024'
|
||||
- series: rustfs_cluster_usage_buckets_total_bytes{rustfs_cluster_id="a",job="rustfs",bucket="shared",service_instance_id="a2"}
|
||||
values: '4096 1024'
|
||||
- series: rustfs_cluster_usage_buckets_total_bytes{rustfs_cluster_id="b",job="rustfs",bucket="shared",service_instance_id="b1"}
|
||||
values: '65536 65536'
|
||||
- series: rustfs_cluster_usage_buckets_total_bytes{rustfs_cluster_id="a",job="rustfs",bucket="empty",service_instance_id="a1"}
|
||||
values: '0 0'
|
||||
- series: rustfs_cluster_usage_buckets_objects_count{rustfs_cluster_id="a",job="rustfs",bucket="shared",service_instance_id="a1"}
|
||||
values: '3 1'
|
||||
- series: rustfs_cluster_usage_buckets_objects_count{rustfs_cluster_id="a",job="rustfs",bucket="shared",service_instance_id="a2"}
|
||||
values: '3 1'
|
||||
- series: rustfs_cluster_usage_buckets_objects_count{rustfs_cluster_id="b",job="rustfs",bucket="shared",service_instance_id="b1"}
|
||||
values: '70 70'
|
||||
- series: rustfs_cluster_usage_buckets_objects_count{rustfs_cluster_id="a",job="rustfs",bucket="empty",service_instance_id="a1"}
|
||||
values: '0 0'
|
||||
promql_expr_test:
|
||||
- expr: max by (rustfs_cluster_id, job, bucket) (rustfs_cluster_usage_buckets_total_bytes{rustfs_cluster_id="a",job=~"rustfs",bucket=~".*"})
|
||||
eval_time: 1m
|
||||
exp_samples:
|
||||
- labels: '{rustfs_cluster_id="a",job="rustfs",bucket="shared"}'
|
||||
value: 1024
|
||||
- labels: '{rustfs_cluster_id="a",job="rustfs",bucket="empty"}'
|
||||
value: 0
|
||||
- expr: max by (rustfs_cluster_id, job, bucket) (rustfs_cluster_usage_buckets_objects_count{rustfs_cluster_id="a",job=~"rustfs",bucket=~".*"})
|
||||
eval_time: 1m
|
||||
exp_samples:
|
||||
- labels: '{rustfs_cluster_id="a",job="rustfs",bucket="shared"}'
|
||||
value: 1
|
||||
- labels: '{rustfs_cluster_id="a",job="rustfs",bucket="empty"}'
|
||||
value: 0
|
||||
- expr: max by (rustfs_cluster_id, job, bucket) (rustfs_cluster_usage_buckets_total_bytes)
|
||||
eval_time: 1m
|
||||
exp_samples:
|
||||
- labels: '{rustfs_cluster_id="a",job="rustfs",bucket="shared"}'
|
||||
value: 1024
|
||||
- labels: '{rustfs_cluster_id="a",job="rustfs",bucket="empty"}'
|
||||
value: 0
|
||||
- labels: '{rustfs_cluster_id="b",job="rustfs",bucket="shared"}'
|
||||
value: 65536
|
||||
- expr: rustfs_cluster_usage_buckets_total_bytes{rustfs_cluster_id="a",bucket="unknown"}
|
||||
eval_time: 1m
|
||||
exp_samples: []
|
||||
@@ -0,0 +1,39 @@
|
||||
# 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.
|
||||
|
||||
name: Set up Docker Compose
|
||||
description: Install the verified Compose CLI used by native Linux x86_64 Connect acceptance
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Install verified Docker Compose CLI plugin
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
[[ $(uname -s) == Linux && $(uname -m) == x86_64 ]]
|
||||
plugin_dir="${DOCKER_CONFIG:-$HOME/.docker}/cli-plugins"
|
||||
mkdir -p "$plugin_dir"
|
||||
compose_download=$(mktemp "${RUNNER_TEMP:?}/connect-compose.XXXXXX")
|
||||
compose_staged=$(mktemp "$plugin_dir/.docker-compose.XXXXXX")
|
||||
trap 'rm -f "$compose_download" "$compose_staged"' EXIT
|
||||
curl --fail --location --retry 3 \
|
||||
https://github.com/docker/compose/releases/download/v2.40.3/docker-compose-linux-x86_64 \
|
||||
--output "$compose_download"
|
||||
printf '%s %s\n' \
|
||||
dba9d98e1ba5bfe11d88c99b9bd32fc4a0624a30fafe68eea34d61a3e42fd372 \
|
||||
"$compose_download" | sha256sum --check --strict
|
||||
install -m 0755 "$compose_download" "$compose_staged"
|
||||
mv -f "$compose_staged" "$plugin_dir/docker-compose"
|
||||
[[ $(docker compose version --short) == 2.40.3 ]]
|
||||
@@ -36,6 +36,16 @@ inputs:
|
||||
description: "Install cross-compilation tools"
|
||||
required: false
|
||||
default: "false"
|
||||
zig-version:
|
||||
description: >-
|
||||
Zig version for cargo-zigbuild builds. Pinned so the `<target>.<glibc>`
|
||||
baseline semantics cannot drift with an untested zig release.
|
||||
required: false
|
||||
default: "0.17.0"
|
||||
cargo-zigbuild-version:
|
||||
description: "cargo-zigbuild version installed for cross builds."
|
||||
required: false
|
||||
default: "0.23.4"
|
||||
target:
|
||||
description: "Target architecture to add"
|
||||
required: false
|
||||
@@ -99,10 +109,14 @@ runs:
|
||||
- name: Install Zig
|
||||
if: inputs.install-cross-tools == 'true'
|
||||
uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2
|
||||
with:
|
||||
version: ${{ inputs.zig-version }}
|
||||
|
||||
- name: Install cargo-zigbuild
|
||||
if: inputs.install-cross-tools == 'true'
|
||||
uses: taiki-e/install-action@a21ae4029b089b9ddc45704028756f51ab8abe48 # cargo-zigbuild
|
||||
with:
|
||||
tool: cargo-zigbuild@${{ inputs.cargo-zigbuild-version }}
|
||||
|
||||
- name: Install cargo-nextest
|
||||
if: inputs.install-test-tools == 'true'
|
||||
@@ -126,5 +140,5 @@ runs:
|
||||
# normalises, so cargo fingerprints stay valid.
|
||||
cache-all-crates: false
|
||||
cache-on-failure: true
|
||||
shared-key: ${{ inputs.cache-shared-key }}
|
||||
shared-key: ${{ inputs.cache-shared-key }}${{ inputs.install-cross-tools == 'true' && format('-zig-{0}-zigbuild-{1}', inputs.zig-version, inputs.cargo-zigbuild-version) || '' }}
|
||||
save-if: ${{ inputs.cache-save-if }}
|
||||
|
||||
+56
-39
@@ -213,12 +213,12 @@ jobs:
|
||||
fi
|
||||
|
||||
all='{"include":[
|
||||
{"target_id":"linux-x86_64-musl","os":"sm-standard-4","target":"x86_64-unknown-linux-musl","cross":false,"platform":"linux","rustflags":"","features":""},
|
||||
{"target_id":"linux-aarch64-musl","os":"sm-standard-4-arm","target":"aarch64-unknown-linux-musl","cross":false,"platform":"linux","rustflags":"","features":""},
|
||||
{"target_id":"linux-x86_64-gnu","os":"sm-standard-4","target":"x86_64-unknown-linux-gnu","cross":false,"platform":"linux","rustflags":"","features":"pyroscope"},
|
||||
{"target_id":"linux-aarch64-gnu","os":"sm-standard-4-arm","target":"aarch64-unknown-linux-gnu","cross":false,"platform":"linux","rustflags":"","features":"pyroscope"},
|
||||
{"target_id":"macos-aarch64","os":"macos-latest","target":"aarch64-apple-darwin","cross":false,"platform":"macos","rustflags":"","features":"pyroscope"},
|
||||
{"target_id":"windows-x86_64","os":"windows-latest","target":"x86_64-pc-windows-msvc","cross":false,"platform":"windows","rustflags":"","features":""}
|
||||
{"target_id":"linux-x86_64-musl","os":"sm-standard-4","target":"x86_64-unknown-linux-musl","cross":false,"platform":"linux","rustflags":"","features":"","glibc":""},
|
||||
{"target_id":"linux-aarch64-musl","os":"sm-standard-4-arm","target":"aarch64-unknown-linux-musl","cross":false,"platform":"linux","rustflags":"","features":"","glibc":""},
|
||||
{"target_id":"linux-x86_64-gnu","os":"sm-standard-4","target":"x86_64-unknown-linux-gnu","cross":true,"platform":"linux","rustflags":"","features":"pyroscope","glibc":"2.31"},
|
||||
{"target_id":"linux-aarch64-gnu","os":"sm-standard-4-arm","target":"aarch64-unknown-linux-gnu","cross":true,"platform":"linux","rustflags":"","features":"pyroscope","glibc":"2.31"},
|
||||
{"target_id":"macos-aarch64","os":"macos-latest","target":"aarch64-apple-darwin","cross":false,"platform":"macos","rustflags":"","features":"pyroscope","glibc":""},
|
||||
{"target_id":"windows-x86_64","os":"windows-latest","target":"x86_64-pc-windows-msvc","cross":false,"platform":"windows","rustflags":"","features":"","glibc":""}
|
||||
]}'
|
||||
|
||||
if [[ "${selected}" == "all" ]]; then
|
||||
@@ -246,7 +246,8 @@ jobs:
|
||||
echo "Selected platforms: ${selected}"
|
||||
|
||||
build-rustfs:
|
||||
name: Build RustFS
|
||||
# Acceptance workflows select artifacts by this stable platform name.
|
||||
name: Build RustFS (${{ matrix.target_id }})
|
||||
needs: [ build-check, prepare-platform-matrix ]
|
||||
if: needs.build-check.outputs.should_build == 'true' && needs.prepare-platform-matrix.result == 'success'
|
||||
runs-on: ${{ matrix.os }}
|
||||
@@ -273,7 +274,7 @@ jobs:
|
||||
with:
|
||||
rust-version: stable
|
||||
target: ${{ matrix.target }}
|
||||
cache-shared-key: build-${{ matrix.target }}
|
||||
cache-shared-key: build-${{ matrix.target }}${{ matrix.glibc && format('-glibc-{0}', matrix.glibc) || '' }}
|
||||
# main only. A cache saved on refs/tags/X is scoped to that tag: no
|
||||
# other tag, no main run and no PR can restore it, so every release
|
||||
# cycle wrote up to 12 entries of 1-2GB (preview tag plus final tag,
|
||||
@@ -410,35 +411,53 @@ jobs:
|
||||
shell: bash
|
||||
env:
|
||||
BUILD_TARGET: ${{ matrix.target }}
|
||||
BUILD_OS: ${{ matrix.platform }}-${{ startsWith(matrix.target, 'aarch64-') && 'aarch64' || 'x86_64' }}
|
||||
run: |
|
||||
# Force rebuild by touching build.rs
|
||||
touch rustfs/build.rs
|
||||
|
||||
BINARY_NAMES=(rustfs)
|
||||
if [[ "${{ matrix.platform }}" == "linux" ]]; then
|
||||
BINARY_NAMES+=(rustfs-cli)
|
||||
fi
|
||||
FEATURE_ARGS=()
|
||||
if [[ -n "${{ matrix.features }}" ]]; then
|
||||
FEATURE_ARGS=(--features "${{ matrix.features }}")
|
||||
fi
|
||||
|
||||
# Build one binary at a time so release LTO links cannot exhaust a
|
||||
# self-hosted runner by running concurrently.
|
||||
for binary in "${BINARY_NAMES[@]}"; do
|
||||
PROFILE_ARGS=()
|
||||
if [[ "$binary" == "rustfs" && "$BUILD_TARGET" == *-linux-gnu && "${{ matrix.features }}" == "pyroscope" ]]; then
|
||||
# The in-process profiler resolves names from the final executable.
|
||||
# Retain function symbols without enabling DWARF source information.
|
||||
PROFILE_ARGS=(--config 'profile.release.strip="none"')
|
||||
fi
|
||||
if [[ "${{ matrix.cross }}" == "true" ]]; then
|
||||
# All cross targets in the matrix are Linux; zigbuild handles them.
|
||||
cargo zigbuild --release --target ${{ matrix.target }} "${FEATURE_ARGS[@]}" "${PROFILE_ARGS[@]}" -p rustfs --bin "$binary"
|
||||
else
|
||||
cargo build --release --target ${{ matrix.target }} "${FEATURE_ARGS[@]}" "${PROFILE_ARGS[@]}" -p rustfs --bin "$binary"
|
||||
fi
|
||||
done
|
||||
# gnu targets pin an explicit glibc baseline (`<target>.<glibc>` for
|
||||
# cargo-zigbuild) so release binaries link no symbols newer than it
|
||||
# and run on older distros such as RHEL/Rocky 9 (glibc 2.34); #8062.
|
||||
TARGET_ARG="$BUILD_TARGET"
|
||||
if [[ -n "${{ matrix.glibc }}" ]]; then
|
||||
TARGET_ARG="${TARGET_ARG}.${{ matrix.glibc }}"
|
||||
fi
|
||||
|
||||
PROFILE_ARGS=()
|
||||
if [[ "$BUILD_TARGET" == *-linux-gnu && "${{ matrix.features }}" == "pyroscope" ]]; then
|
||||
# The in-process profiler resolves names from the final executable.
|
||||
# Retain function symbols without enabling DWARF source information.
|
||||
PROFILE_ARGS=(--config 'profile.release.strip="none"')
|
||||
fi
|
||||
if [[ "${{ matrix.cross }}" == "true" ]]; then
|
||||
cargo zigbuild --release --locked --target "$TARGET_ARG" "${FEATURE_ARGS[@]}" "${PROFILE_ARGS[@]}" -p rustfs --bin rustfs
|
||||
else
|
||||
cargo build --release --locked --target "$TARGET_ARG" "${FEATURE_ARGS[@]}" "${PROFILE_ARGS[@]}" -p rustfs --bin rustfs
|
||||
fi
|
||||
|
||||
# All release targets use same-architecture runners, including GNU
|
||||
# builds that use Zig only to select the glibc ABI floor.
|
||||
bin="target/${{ matrix.target }}/release/rustfs"
|
||||
if [[ -f "${bin}.exe" ]]; then
|
||||
bin="${bin}.exe"
|
||||
fi
|
||||
version_output=$("$bin" --version)
|
||||
if ! grep -Fxq "build target : ${BUILD_TARGET}" <<< "$version_output" || \
|
||||
! grep -Fxq "build os : ${BUILD_OS}" <<< "$version_output" || \
|
||||
! grep -Fxq "rust channel : stable-${BUILD_TARGET}" <<< "$version_output"; then
|
||||
echo "release binary reports an unexpected compile target" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -n "${{ matrix.glibc }}" ]]; then
|
||||
python3 scripts/release/check_glibc.py "${{ matrix.glibc }}" \
|
||||
"target/${{ matrix.target }}/release/rustfs"
|
||||
fi
|
||||
|
||||
- name: Verify macOS binary has no dynamic liblzma dependency
|
||||
if: matrix.platform == 'macos'
|
||||
@@ -554,9 +573,6 @@ jobs:
|
||||
fi
|
||||
|
||||
BINARY_FILES=("$BINARY_NAME")
|
||||
if [[ "${{ matrix.platform }}" == "linux" ]]; then
|
||||
BINARY_FILES+=("rustfs-cli")
|
||||
fi
|
||||
|
||||
if [[ "$TARGET" == *-linux-gnu && "${{ matrix.features }}" == "pyroscope" ]]; then
|
||||
BINARY_FILES+=(rustfs.cpu-symbol-catalog.json rustfs.cpu-symbol-catalog.sha256)
|
||||
@@ -671,7 +687,7 @@ jobs:
|
||||
echo "🔧 Build type: ${BUILD_TYPE}"
|
||||
echo "📊 Version: ${VERSION}"
|
||||
|
||||
- name: Verify packaged Linux binaries
|
||||
- name: Verify packaged Linux binary
|
||||
if: matrix.platform == 'linux'
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -679,7 +695,7 @@ jobs:
|
||||
|
||||
package_file="${{ steps.package.outputs.package_file }}"
|
||||
archive_members="$(unzip -Z1 "$package_file" | sort)"
|
||||
expected_members=$'rustfs\nrustfs-cli'
|
||||
expected_members='rustfs'
|
||||
target="${{ matrix.target }}"
|
||||
features="${{ matrix.features }}"
|
||||
if [[ "$target" == *-linux-gnu && "$features" == "pyroscope" ]]; then
|
||||
@@ -691,13 +707,14 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Every Linux target builds natively, so executing the binaries here
|
||||
# musl targets build natively; the gnu legs are same-arch
|
||||
# cargo-zigbuild builds pinned to the release glibc baseline. Either
|
||||
# way the binaries run on their runner, so executing them here
|
||||
# proves each artifact matches its runner architecture.
|
||||
cli_dir="$(mktemp -d)"
|
||||
trap 'rm -rf "$cli_dir"' EXIT
|
||||
unzip -q "$package_file" rustfs rustfs-cli -d "$cli_dir"
|
||||
"$cli_dir/rustfs" --version
|
||||
"$cli_dir/rustfs-cli" --help >/dev/null
|
||||
binary_dir="$(mktemp -d)"
|
||||
trap 'rm -rf "$binary_dir"' EXIT
|
||||
unzip -q "$package_file" rustfs -d "$binary_dir"
|
||||
"$binary_dir/rustfs" --version
|
||||
|
||||
- name: Verify packaged console
|
||||
if: matrix.target == 'x86_64-unknown-linux-gnu' || matrix.target == 'aarch64-unknown-linux-gnu'
|
||||
|
||||
@@ -99,7 +99,7 @@ env:
|
||||
RUST_BACKTRACE: 1
|
||||
|
||||
jobs:
|
||||
# Readers: test-and-lint, test-ilm-integration-serial, build-rustfs-debug-binary,
|
||||
# Readers: test-and-lint (workspace and ILM), build-rustfs-debug-binary,
|
||||
# e2e-tests, e2e-full.
|
||||
warm-ci-dev:
|
||||
name: Warm ci-dev
|
||||
|
||||
+50
-84
@@ -168,13 +168,10 @@ jobs:
|
||||
echo "started_at=$(date --utc --iso-8601=seconds)"
|
||||
} > artifacts/test-and-lint/run-metadata.txt
|
||||
|
||||
# Clippy runs before the test pass: lint failures are the most common
|
||||
# CI-only breakage and should surface in minutes, not after 20+ minutes
|
||||
# of tests.
|
||||
# Sampled too: clippy is the natural control arm for any CARGO_BUILD_JOBS
|
||||
# experiment, since --all-targets is check-only for workspace members and
|
||||
# never links the ~100 test binaries the limit exists to throttle.
|
||||
# Keep full Clippy validation on main, merge groups, and scheduled/manual
|
||||
# runs. PRs retain the complete default test suite and compile-free checks.
|
||||
- name: Run clippy lints
|
||||
if: github.event_name != 'pull_request'
|
||||
run: |
|
||||
./scripts/ci/resource_sampler.sh start clippy
|
||||
trap './scripts/ci/resource_sampler.sh stop' EXIT
|
||||
@@ -283,6 +280,49 @@ jobs:
|
||||
- name: Verify rebalance/decommission migration proofs
|
||||
run: ./scripts/check_migration_gate_count.sh evidence artifacts/test-and-lint/core-test-listing.json target/nextest/ci/junit.xml
|
||||
|
||||
# The workspace already compiled these ignored tests. Keep the same Cargo
|
||||
# package selection to reuse their binaries, then run them serially in
|
||||
# separate processes because they share fixed ports and global state.
|
||||
# test_transition_and_restore_flows was re-enabled by rustfs/backlog#1303:
|
||||
# its "missing xl.meta on disk2" was a test-util bug (open_disk hardcoded
|
||||
# disk_index 0), not an EC metadata-distribution issue.
|
||||
# restore_object_usecase_reports_ongoing_conflict_and_completion was
|
||||
# re-enabled by backlog#1304 (restore accepts serialize on a short CAS
|
||||
# guard; the copy-back no longer holds the #4877 whole-copy-back lock,
|
||||
# so the mid-restore ongoing read and fast 409 rejection it asserts are
|
||||
# the implemented contract).
|
||||
- name: Run ignored ILM integration tests serially
|
||||
if: needs.classify-changes.outputs.mode == 'full'
|
||||
env:
|
||||
# Match the preceding workspace build and reuse its feature graph.
|
||||
CARGO_BUILD_JOBS: ${{ (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && '3' || '2' }}
|
||||
run: |
|
||||
mkdir -p artifacts/ilm-integration
|
||||
set +e
|
||||
NEXTEST_HIDE_PROGRESS_BAR=1 timeout --verbose --signal=TERM --kill-after=30s 80m \
|
||||
cargo nextest run --profile default -j1 --run-ignored ignored-only \
|
||||
--all --exclude e2e_test \
|
||||
-E 'binary(lifecycle_integration_test) or (package(rustfs) and test(lifecycle_transition_api_test))' \
|
||||
--status-level all --final-status-level all \
|
||||
2>&1 | tee artifacts/ilm-integration/nextest.log
|
||||
status=${PIPESTATUS[0]}
|
||||
{
|
||||
echo "exit_status=${status}"
|
||||
echo "finished_at=$(date --utc --iso-8601=seconds)"
|
||||
echo
|
||||
echo "Remaining test-related processes:"
|
||||
pgrep -af 'cargo|nextest|target/.*/deps/' || true
|
||||
} > artifacts/ilm-integration/diagnostics.txt
|
||||
exit "${status}"
|
||||
|
||||
- name: Upload ILM test diagnostics
|
||||
if: always() && needs.classify-changes.outputs.mode == 'full'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: ilm-integration-${{ github.run_number }}-${{ github.run_attempt }}
|
||||
path: artifacts/ilm-integration
|
||||
retention-days: 3
|
||||
|
||||
# This gate builds into a fresh target directory to isolate the E2E root.
|
||||
# Give its cold build a separate budget from workspace tests and migration proofs.
|
||||
offline-enrollment-e2e:
|
||||
@@ -314,83 +354,11 @@ jobs:
|
||||
- name: Check offline enrollment E2E root boundary
|
||||
run: ./scripts/check_offline_enrollment_e2e.sh
|
||||
|
||||
# Dedicated serial lane for the ILM / lifecycle integration tests. These tests
|
||||
# drive the object layer through process-global singletons (the GLOBAL_ENV
|
||||
# ECStore, the global tier-config manager, background-expiry workers) and bind
|
||||
# fixed ports (9002 for the scanner suite, 9003 for the app suite), so they are
|
||||
# marked #[ignore] to keep them out of the default parallel `cargo nextest run
|
||||
# --all` pass. Run them here explicitly with `--run-ignored ignored-only` and
|
||||
# `-j1` so no two of them share global state or race for a port at once.
|
||||
# serial_test's #[serial] does NOT serialize across nextest's process-per-test
|
||||
# boundary (see .config/nextest.toml); `-j1` is what actually serializes them.
|
||||
# See rustfs/backlog#1148 (ilm-1) and #1155.
|
||||
test-ilm-integration-serial:
|
||||
name: ILM Integration (serial)
|
||||
if: needs.classify-changes.outputs.mode == 'full' && (github.event_name != 'pull_request' || github.event.action != 'closed')
|
||||
needs: [ quick-checks, classify-changes ]
|
||||
runs-on: sm-standard-4
|
||||
timeout-minutes: 90
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Rust environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
rust-version: stable
|
||||
cache-shared-key: ci-dev
|
||||
cache-save-if: 'false'
|
||||
install-build-packaging-tools: 'false'
|
||||
|
||||
# test_transition_and_restore_flows was re-enabled by rustfs/backlog#1303:
|
||||
# its "missing xl.meta on disk2" was a test-util bug (open_disk hardcoded
|
||||
# disk_index 0), not an EC metadata-distribution issue.
|
||||
# restore_object_usecase_reports_ongoing_conflict_and_completion was
|
||||
# re-enabled by backlog#1304 (restore accepts serialize on a short CAS
|
||||
# guard; the copy-back no longer holds the #4877 whole-copy-back lock,
|
||||
# so the mid-restore ongoing read and fast 409 rejection it asserts are
|
||||
# the implemented contract).
|
||||
- name: Run ignored ILM integration tests serially
|
||||
env:
|
||||
# Match the measured Test and Lint link budget. The default exposed
|
||||
# all 14 pod CPUs and a cold cache spent the full 80m compiling
|
||||
# without starting one ILM test (main run 32982910990).
|
||||
CARGO_BUILD_JOBS: ${{ (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && '3' || '2' }}
|
||||
run: |
|
||||
mkdir -p artifacts/ilm-integration
|
||||
set +e
|
||||
NEXTEST_HIDE_PROGRESS_BAR=1 timeout --verbose --signal=TERM --kill-after=30s 80m \
|
||||
cargo nextest run -j1 --run-ignored ignored-only \
|
||||
-p rustfs-scanner -p rustfs \
|
||||
-E 'binary(lifecycle_integration_test) or (package(rustfs) and test(lifecycle_transition_api_test))' \
|
||||
--status-level all --final-status-level all \
|
||||
2>&1 | tee artifacts/ilm-integration/nextest.log
|
||||
status=${PIPESTATUS[0]}
|
||||
{
|
||||
echo "exit_status=${status}"
|
||||
echo "finished_at=$(date --utc --iso-8601=seconds)"
|
||||
echo
|
||||
echo "Remaining test-related processes:"
|
||||
pgrep -af 'cargo|nextest|target/.*/deps/' || true
|
||||
} > artifacts/ilm-integration/diagnostics.txt
|
||||
exit "${status}"
|
||||
|
||||
- name: Upload ILM test diagnostics
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: ilm-integration-${{ github.run_number }}-${{ github.run_attempt }}
|
||||
path: |
|
||||
artifacts/ilm-integration
|
||||
target/nextest/ci/junit.xml
|
||||
|
||||
test-and-lint-rio-v2:
|
||||
name: Test and Lint (rio-v2)
|
||||
if: needs.classify-changes.outputs.mode == 'full' && (github.event_name != 'pull_request' || github.event.action != 'closed')
|
||||
# Alternate feature configurations run on main, merge groups, and scheduled
|
||||
# or manual validation; the default workspace remains the PR gate.
|
||||
if: needs.classify-changes.outputs.mode == 'full' && github.event_name != 'pull_request'
|
||||
needs: [ quick-checks, classify-changes ]
|
||||
runs-on: sm-standard-4
|
||||
timeout-minutes: 90
|
||||
@@ -506,7 +474,7 @@ jobs:
|
||||
|
||||
test-and-lint-protocols:
|
||||
name: "Test and Lint (${{ matrix.features.name }})"
|
||||
if: needs.classify-changes.outputs.mode == 'full' && (github.event_name != 'pull_request' || github.event.action != 'closed')
|
||||
if: needs.classify-changes.outputs.mode == 'full' && github.event_name != 'pull_request'
|
||||
needs: [ quick-checks, classify-changes ]
|
||||
runs-on: sm-standard-4
|
||||
timeout-minutes: 90
|
||||
@@ -1264,7 +1232,6 @@ jobs:
|
||||
- quick-checks
|
||||
- test-and-lint
|
||||
- offline-enrollment-e2e
|
||||
- test-ilm-integration-serial
|
||||
- test-and-lint-rio-v2
|
||||
- connect-short-credential-boundary
|
||||
- test-and-lint-protocols
|
||||
@@ -1298,7 +1265,6 @@ jobs:
|
||||
- quick-checks
|
||||
- test-and-lint
|
||||
- offline-enrollment-e2e
|
||||
- test-ilm-integration-serial
|
||||
- test-and-lint-rio-v2
|
||||
- test-and-lint-protocols
|
||||
- build-rustfs-debug-binary
|
||||
|
||||
@@ -57,6 +57,14 @@ jobs:
|
||||
ref: ${{ inputs.connect_sha }}
|
||||
token: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
|
||||
- name: Set up Docker Compose
|
||||
uses: ./.github/actions/setup-docker-compose
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.14"
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
@@ -94,7 +102,7 @@ jobs:
|
||||
' <<<"$run" >/dev/null
|
||||
jobs=$(gh api --paginate --slurp "repos/${GITHUB_REPOSITORY}/actions/runs/${BUILD_RUN_ID}/jobs?per_page=100")
|
||||
jq -e '
|
||||
[.[].jobs[] | select(.name | test("^Build RustFS \\(linux-x86_64-gnu, [a-z0-9-]+, x86_64-unknown-linux-gnu, false, linux, pyroscope\\)$"))] as $matches
|
||||
[.[].jobs[] | select(.name | test("^Build RustFS \\(linux-x86_64-gnu\\)$"))] as $matches
|
||||
| ($matches | length) == 1 and $matches[0].conclusion == "success"
|
||||
' <<<"$jobs" >/dev/null
|
||||
artifact=$(gh api "repos/${GITHUB_REPOSITORY}/actions/artifacts/${ARTIFACT_ID}")
|
||||
@@ -137,7 +145,7 @@ jobs:
|
||||
assert outer.testzip() is None
|
||||
with zipfile.ZipFile(io.BytesIO(outer.read(package))) as inner:
|
||||
entries = inner.infolist()
|
||||
assert {item.filename for item in entries} == {'rustfs', 'rustfs-cli'} and len(entries) == 2
|
||||
assert {item.filename for item in entries} == {'rustfs', 'rustfs.cpu-symbol-catalog.json', 'rustfs.cpu-symbol-catalog.sha256'} and len(entries) == 3
|
||||
assert all(regular(item) and 0 < item.file_size <= 1073741824 for item in entries)
|
||||
assert inner.testzip() is None
|
||||
pathlib.Path('binary').mkdir()
|
||||
@@ -162,7 +170,7 @@ jobs:
|
||||
connect-harness/web/node_modules/.bin/playwright install --with-deps chromium
|
||||
make -C connect-harness e2e-connected-dispatch-check
|
||||
(cd connect-harness && ./tests/connectivity/diagnostic-schedule-evidence.test.sh)
|
||||
RUSTFS_BINARY="$GITHUB_WORKSPACE/binary/rustfs" \
|
||||
TMPDIR="$GITHUB_WORKSPACE" RUSTFS_BINARY="$GITHUB_WORKSPACE/binary/rustfs" \
|
||||
RUSTFS_WORKTREE="$GITHUB_WORKSPACE/rustfs-source" \
|
||||
CONNECT_E2E_DIAGNOSTIC_SCHEDULE_EVIDENCE="$GITHUB_WORKSPACE/diagnostic-schedule-evidence.json" \
|
||||
make -C connect-harness e2e-connected E2E_SCENARIO=diagnostic-schedule
|
||||
|
||||
@@ -57,6 +57,14 @@ jobs:
|
||||
ref: ${{ inputs.connect_sha }}
|
||||
token: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
|
||||
- name: Set up Docker Compose
|
||||
uses: ./.github/actions/setup-docker-compose
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.14"
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
@@ -127,7 +135,7 @@ jobs:
|
||||
' <<<"$run" >/dev/null
|
||||
jobs=$(gh api --paginate --slurp "repos/rustfs/rustfs/actions/runs/${BUILD_RUN_ID}/jobs?per_page=100")
|
||||
jq -e '
|
||||
[.[].jobs[] | select(.name | test("^Build RustFS \\(linux-x86_64-gnu, [a-z0-9-]+, x86_64-unknown-linux-gnu, false, linux, pyroscope\\)$"))] as $matches
|
||||
[.[].jobs[] | select(.name | test("^Build RustFS \\(linux-x86_64-gnu\\)$"))] as $matches
|
||||
| ($matches | length) == 1 and $matches[0].conclusion == "success"
|
||||
' <<<"$jobs" >/dev/null
|
||||
artifact=$(gh api "repos/rustfs/rustfs/actions/artifacts/${ARTIFACT_ID}")
|
||||
@@ -172,7 +180,7 @@ jobs:
|
||||
assert outer.testzip() is None
|
||||
with zipfile.ZipFile(io.BytesIO(outer.read(package))) as inner:
|
||||
entries = inner.infolist()
|
||||
assert {entry.filename for entry in entries} == {'rustfs', 'rustfs-cli', 'rustfs.cpu-symbol-catalog.json', 'rustfs.cpu-symbol-catalog.sha256'} and len(entries) == 4
|
||||
assert {entry.filename for entry in entries} == {'rustfs', 'rustfs.cpu-symbol-catalog.json', 'rustfs.cpu-symbol-catalog.sha256'} and len(entries) == 3
|
||||
assert all(regular(entry) and 0 < entry.file_size <= 1073741824 for entry in entries)
|
||||
assert inner.testzip() is None
|
||||
pathlib.Path('binary').mkdir()
|
||||
@@ -199,7 +207,7 @@ jobs:
|
||||
connect-harness/web/node_modules/.bin/playwright install --with-deps chromium
|
||||
make -C connect-harness e2e-connected-dispatch-check
|
||||
(cd connect-harness && ./tests/connectivity/health-service-evidence.test.sh)
|
||||
RUSTFS_BINARY="$GITHUB_WORKSPACE/binary/rustfs" \
|
||||
TMPDIR="$GITHUB_WORKSPACE" RUSTFS_BINARY="$GITHUB_WORKSPACE/binary/rustfs" \
|
||||
RUSTFS_WORKTREE="$GITHUB_WORKSPACE/rustfs-source" \
|
||||
CONNECT_E2E_HEALTH_SERVICE_EVIDENCE="$GITHUB_WORKSPACE/health-service-evidence.json" \
|
||||
make -C connect-harness e2e-connected E2E_SCENARIO=health-service
|
||||
|
||||
@@ -73,6 +73,14 @@ jobs:
|
||||
ref: ${{ inputs.connect_sha }}
|
||||
token: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
|
||||
- name: Set up Docker Compose
|
||||
uses: ./.github/actions/setup-docker-compose
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.14"
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
@@ -107,7 +115,7 @@ jobs:
|
||||
[[ $(jq -r '.head_repository.full_name' <<<"$run") == "$GITHUB_REPOSITORY" ]]
|
||||
[[ $(jq -r '.name' <<<"$run") == "Build and Release" ]]
|
||||
|
||||
expected_job='Build RustFS (linux-x86_64-gnu, sm-standard-4, x86_64-unknown-linux-gnu, false, linux, pyroscope)'
|
||||
expected_job='Build RustFS (linux-x86_64-gnu)'
|
||||
jobs=$(gh api --paginate --slurp "repos/${GITHUB_REPOSITORY}/actions/runs/${BUILD_RUN_ID}/jobs?per_page=100")
|
||||
jq -e --arg name "$expected_job" '
|
||||
[.[].jobs[] | select(.name == $name)] as $matches
|
||||
@@ -154,7 +162,7 @@ jobs:
|
||||
npm --prefix connect-harness/web ci
|
||||
connect-harness/web/node_modules/.bin/playwright install --with-deps chromium
|
||||
make -C connect-harness e2e-connected-dispatch-check
|
||||
RUSTFS_BINARY="$GITHUB_WORKSPACE/binary/rustfs" \
|
||||
TMPDIR="$GITHUB_WORKSPACE" RUSTFS_BINARY="$GITHUB_WORKSPACE/binary/rustfs" \
|
||||
RUSTFS_WORKTREE="$GITHUB_WORKSPACE/rustfs-source" \
|
||||
CONNECT_E2E_PERFORMANCE_DRIVE_EVIDENCE="$GITHUB_WORKSPACE/performance-drive-service-job-evidence.json" \
|
||||
make -C connect-harness e2e-connected E2E_SCENARIO=performance-drive
|
||||
|
||||
@@ -73,6 +73,14 @@ jobs:
|
||||
ref: ${{ inputs.connect_sha }}
|
||||
token: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
|
||||
- name: Set up Docker Compose
|
||||
uses: ./.github/actions/setup-docker-compose
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.14"
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
@@ -107,7 +115,7 @@ jobs:
|
||||
[[ $(jq -r '.head_repository.full_name' <<<"$run") == "$GITHUB_REPOSITORY" ]]
|
||||
[[ $(jq -r '.name' <<<"$run") == "Build and Release" ]]
|
||||
|
||||
expected_job='Build RustFS (linux-x86_64-gnu, sm-standard-4, x86_64-unknown-linux-gnu, false, linux, pyroscope)'
|
||||
expected_job='Build RustFS (linux-x86_64-gnu)'
|
||||
jobs=$(gh api --paginate --slurp "repos/${GITHUB_REPOSITORY}/actions/runs/${BUILD_RUN_ID}/jobs?per_page=100")
|
||||
jq -e --arg name "$expected_job" '
|
||||
[.[].jobs[] | select(.name == $name)] as $matches
|
||||
@@ -154,7 +162,7 @@ jobs:
|
||||
npm --prefix connect-harness/web ci
|
||||
connect-harness/web/node_modules/.bin/playwright install --with-deps chromium
|
||||
make -C connect-harness e2e-connected-dispatch-check
|
||||
RUSTFS_BINARY="$GITHUB_WORKSPACE/binary/rustfs" \
|
||||
TMPDIR="$GITHUB_WORKSPACE" RUSTFS_BINARY="$GITHUB_WORKSPACE/binary/rustfs" \
|
||||
RUSTFS_WORKTREE="$GITHUB_WORKSPACE/rustfs-source" \
|
||||
CONNECT_E2E_PERFORMANCE_NETWORK_EVIDENCE="$GITHUB_WORKSPACE/performance-network-service-job-evidence.json" \
|
||||
make -C connect-harness e2e-connected E2E_SCENARIO=performance-network
|
||||
|
||||
@@ -77,6 +77,14 @@ jobs:
|
||||
ref: ${{ inputs.connect_sha }}
|
||||
token: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
|
||||
- name: Set up Docker Compose
|
||||
uses: ./.github/actions/setup-docker-compose
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.14"
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
@@ -118,7 +126,7 @@ jobs:
|
||||
[[ $(jq -r '.head_repository.full_name' <<<"$run") == "$GITHUB_REPOSITORY" ]]
|
||||
[[ $(jq -r '.name' <<<"$run") == "Build and Release" ]]
|
||||
|
||||
expected_job='Build RustFS (linux-x86_64-gnu, sm-standard-4, x86_64-unknown-linux-gnu, false, linux, pyroscope)'
|
||||
expected_job='Build RustFS (linux-x86_64-gnu)'
|
||||
jobs=$(github_api "repos/${GITHUB_REPOSITORY}/actions/runs/${BUILD_RUN_ID}/jobs?per_page=100")
|
||||
jq -e --arg name "$expected_job" '
|
||||
[.jobs[] | select(.name == $name)] as $matches
|
||||
@@ -173,7 +181,7 @@ jobs:
|
||||
npm --prefix connect-harness/web ci
|
||||
connect-harness/web/node_modules/.bin/playwright install --with-deps chromium
|
||||
make -C connect-harness e2e-connected-dispatch-check
|
||||
RUSTFS_BINARY="$GITHUB_WORKSPACE/binary/rustfs" \
|
||||
TMPDIR="$GITHUB_WORKSPACE" RUSTFS_BINARY="$GITHUB_WORKSPACE/binary/rustfs" \
|
||||
RUSTFS_WORKTREE="$GITHUB_WORKSPACE/rustfs-source" \
|
||||
CONNECT_E2E_CPU_CATALOG="$GITHUB_WORKSPACE/binary/rustfs.cpu-symbol-catalog.json" \
|
||||
CONNECT_E2E_CPU_CATALOG_SHA256="$CATALOG_SHA256" \
|
||||
|
||||
@@ -57,6 +57,14 @@ jobs:
|
||||
ref: ${{ inputs.connect_sha }}
|
||||
token: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
|
||||
- name: Set up Docker Compose
|
||||
uses: ./.github/actions/setup-docker-compose
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.14"
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
@@ -127,7 +135,7 @@ jobs:
|
||||
' <<<"$run" >/dev/null
|
||||
jobs=$(gh api --paginate --slurp "repos/rustfs/rustfs/actions/runs/${BUILD_RUN_ID}/jobs?per_page=100")
|
||||
jq -e '
|
||||
[.[].jobs[] | select(.name | test("^Build RustFS \\(linux-x86_64-gnu, [a-z0-9-]+, x86_64-unknown-linux-gnu, false, linux, pyroscope\\)$"))] as $matches
|
||||
[.[].jobs[] | select(.name | test("^Build RustFS \\(linux-x86_64-gnu\\)$"))] as $matches
|
||||
| ($matches | length) == 1 and $matches[0].conclusion == "success"
|
||||
' <<<"$jobs" >/dev/null
|
||||
artifact=$(gh api "repos/rustfs/rustfs/actions/artifacts/${ARTIFACT_ID}")
|
||||
@@ -172,7 +180,7 @@ jobs:
|
||||
assert outer.testzip() is None
|
||||
with zipfile.ZipFile(io.BytesIO(outer.read(package))) as inner:
|
||||
entries = inner.infolist()
|
||||
expected = {'rustfs', 'rustfs-cli', 'rustfs.cpu-symbol-catalog.json', 'rustfs.cpu-symbol-catalog.sha256'}
|
||||
expected = {'rustfs', 'rustfs.cpu-symbol-catalog.json', 'rustfs.cpu-symbol-catalog.sha256'}
|
||||
assert {entry.filename for entry in entries} == expected and len(entries) == len(expected)
|
||||
assert all(regular(entry) and 0 < entry.file_size <= 1073741824 for entry in entries)
|
||||
assert inner.testzip() is None
|
||||
@@ -198,7 +206,7 @@ jobs:
|
||||
connect-harness/web/node_modules/.bin/playwright install --with-deps chromium
|
||||
make -C connect-harness e2e-connected-dispatch-check
|
||||
(cd connect-harness && ./tests/connectivity/profile-memory-evidence.test.sh)
|
||||
RUSTFS_BINARY="$GITHUB_WORKSPACE/binary/rustfs" \
|
||||
TMPDIR="$GITHUB_WORKSPACE" RUSTFS_BINARY="$GITHUB_WORKSPACE/binary/rustfs" \
|
||||
RUSTFS_WORKTREE="$GITHUB_WORKSPACE/rustfs-source" \
|
||||
CONNECT_E2E_PROFILE_MEMORY_EVIDENCE="$GITHUB_WORKSPACE/profile-memory-evidence.json" \
|
||||
make -C connect-harness e2e-connected E2E_SCENARIO=profile-memory
|
||||
|
||||
@@ -81,6 +81,14 @@ jobs:
|
||||
ref: ${{ inputs.connect_sha }}
|
||||
token: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
|
||||
- name: Set up Docker Compose
|
||||
uses: ./.github/actions/setup-docker-compose
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.14"
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
@@ -148,7 +156,7 @@ jobs:
|
||||
[[ $(jq -r '.head_repository.full_name' <<<"$run") == "$GITHUB_REPOSITORY" ]]
|
||||
[[ $(jq -r '.name' <<<"$run") == "Build and Release" ]]
|
||||
|
||||
expected_job='Build RustFS (linux-x86_64-gnu, sm-standard-4, x86_64-unknown-linux-gnu, false, linux, pyroscope)'
|
||||
expected_job='Build RustFS (linux-x86_64-gnu)'
|
||||
jobs=$(gh api --paginate --slurp "repos/${GITHUB_REPOSITORY}/actions/runs/${BUILD_RUN_ID}/jobs?per_page=100")
|
||||
jq -e --arg name "$expected_job" '
|
||||
[.[].jobs[] | select(.name == $name)] as $matches
|
||||
@@ -201,7 +209,7 @@ jobs:
|
||||
npm --prefix connect-harness/web ci
|
||||
connect-harness/web/node_modules/.bin/playwright install --with-deps chromium
|
||||
make -C connect-harness e2e-connected-dispatch-check
|
||||
RUSTFS_BINARY="$GITHUB_WORKSPACE/binary/rustfs" \
|
||||
TMPDIR="$GITHUB_WORKSPACE" RUSTFS_BINARY="$GITHUB_WORKSPACE/binary/rustfs" \
|
||||
RUSTFS_WORKTREE="$GITHUB_WORKSPACE/rustfs-source" \
|
||||
CONNECT_E2E_PROFILE_THREADS_EVIDENCE="$GITHUB_WORKSPACE/profile-threads-service-job-evidence.json" \
|
||||
make -C connect-harness e2e-connected E2E_SCENARIO=profile-threads
|
||||
|
||||
@@ -73,6 +73,14 @@ jobs:
|
||||
ref: ${{ inputs.connect_sha }}
|
||||
token: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
|
||||
- name: Set up Docker Compose
|
||||
uses: ./.github/actions/setup-docker-compose
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.14"
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
@@ -107,7 +115,7 @@ jobs:
|
||||
[[ $(jq -r '.head_repository.full_name' <<<"$run") == "$GITHUB_REPOSITORY" ]]
|
||||
[[ $(jq -r '.name' <<<"$run") == "Build and Release" ]]
|
||||
|
||||
expected_job='Build RustFS (linux-x86_64-gnu, sm-standard-4, x86_64-unknown-linux-gnu, false, linux, pyroscope)'
|
||||
expected_job='Build RustFS (linux-x86_64-gnu)'
|
||||
jobs=$(gh api --paginate --slurp "repos/${GITHUB_REPOSITORY}/actions/runs/${BUILD_RUN_ID}/jobs?per_page=100")
|
||||
jq -e --arg name "$expected_job" '
|
||||
[.[].jobs[] | select(.name == $name)] as $matches
|
||||
@@ -154,7 +162,7 @@ jobs:
|
||||
npm --prefix connect-harness/web ci
|
||||
connect-harness/web/node_modules/.bin/playwright install --with-deps chromium
|
||||
make -C connect-harness e2e-connected-dispatch-check
|
||||
RUSTFS_BINARY="$GITHUB_WORKSPACE/binary/rustfs" \
|
||||
TMPDIR="$GITHUB_WORKSPACE" RUSTFS_BINARY="$GITHUB_WORKSPACE/binary/rustfs" \
|
||||
RUSTFS_WORKTREE="$GITHUB_WORKSPACE/rustfs-source" \
|
||||
CONNECT_E2E_TOP_API_EVIDENCE="$GITHUB_WORKSPACE/top-api-service-job-evidence.json" \
|
||||
make -C connect-harness e2e-connected E2E_SCENARIO=top-api
|
||||
|
||||
@@ -68,6 +68,14 @@ jobs:
|
||||
ref: ${{ inputs.connect_sha }}
|
||||
token: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
|
||||
- name: Set up Docker Compose
|
||||
uses: ./.github/actions/setup-docker-compose
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.14"
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
@@ -138,7 +146,7 @@ jobs:
|
||||
' <<<"$run" >/dev/null
|
||||
jobs=$(gh api --paginate --slurp "repos/rustfs/rustfs/actions/runs/${BUILD_RUN_ID}/jobs?per_page=100")
|
||||
jq -e '
|
||||
[.[].jobs[] | select(.name | test("^Build RustFS \\(linux-x86_64-gnu, [a-z0-9-]+, x86_64-unknown-linux-gnu, false, linux, pyroscope\\)$"))] as $matches
|
||||
[.[].jobs[] | select(.name | test("^Build RustFS \\(linux-x86_64-gnu\\)$"))] as $matches
|
||||
| ($matches | length) == 1 and $matches[0].conclusion == "success"
|
||||
' <<<"$jobs" >/dev/null
|
||||
artifact=$(gh api "repos/rustfs/rustfs/actions/artifacts/${ARTIFACT_ID}")
|
||||
@@ -183,7 +191,7 @@ jobs:
|
||||
assert outer.testzip() is None
|
||||
with zipfile.ZipFile(io.BytesIO(outer.read(package))) as inner:
|
||||
entries = inner.infolist()
|
||||
expected = {'rustfs', 'rustfs-cli', 'rustfs.cpu-symbol-catalog.json', 'rustfs.cpu-symbol-catalog.sha256'}
|
||||
expected = {'rustfs', 'rustfs.cpu-symbol-catalog.json', 'rustfs.cpu-symbol-catalog.sha256'}
|
||||
assert {entry.filename for entry in entries} == expected and len(entries) == len(expected)
|
||||
assert all(regular(entry) and 0 < entry.file_size <= 1073741824 for entry in entries)
|
||||
assert inner.testzip() is None
|
||||
@@ -213,7 +221,7 @@ jobs:
|
||||
npm --prefix connect-harness/web ci
|
||||
connect-harness/web/node_modules/.bin/playwright install --with-deps chromium
|
||||
make -C connect-harness e2e-connected-dispatch-check
|
||||
RUSTFS_BINARY="$GITHUB_WORKSPACE/binary/rustfs" \
|
||||
TMPDIR="$GITHUB_WORKSPACE" RUSTFS_BINARY="$GITHUB_WORKSPACE/binary/rustfs" \
|
||||
RUSTFS_WORKTREE="$GITHUB_WORKSPACE/rustfs-source" \
|
||||
CONNECT_E2E_TOP_DISK_EVIDENCE="$GITHUB_WORKSPACE/top-disk-service-job-evidence.json" \
|
||||
make -C connect-harness e2e-connected E2E_SCENARIO=top-disk
|
||||
|
||||
@@ -73,6 +73,14 @@ jobs:
|
||||
ref: ${{ inputs.connect_sha }}
|
||||
token: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
|
||||
- name: Set up Docker Compose
|
||||
uses: ./.github/actions/setup-docker-compose
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.14"
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
@@ -107,7 +115,7 @@ jobs:
|
||||
[[ $(jq -r '.head_repository.full_name' <<<"$run") == "$GITHUB_REPOSITORY" ]]
|
||||
[[ $(jq -r '.name' <<<"$run") == "Build and Release" ]]
|
||||
|
||||
expected_job='Build RustFS (linux-x86_64-gnu, sm-standard-4, x86_64-unknown-linux-gnu, false, linux, pyroscope)'
|
||||
expected_job='Build RustFS (linux-x86_64-gnu)'
|
||||
jobs=$(gh api --paginate --slurp "repos/${GITHUB_REPOSITORY}/actions/runs/${BUILD_RUN_ID}/jobs?per_page=100")
|
||||
jq -e --arg name "$expected_job" '
|
||||
[.[].jobs[] | select(.name == $name)] as $matches
|
||||
@@ -154,7 +162,7 @@ jobs:
|
||||
npm --prefix connect-harness/web ci
|
||||
connect-harness/web/node_modules/.bin/playwright install --with-deps chromium
|
||||
make -C connect-harness e2e-connected-dispatch-check
|
||||
RUSTFS_BINARY="$GITHUB_WORKSPACE/binary/rustfs" \
|
||||
TMPDIR="$GITHUB_WORKSPACE" RUSTFS_BINARY="$GITHUB_WORKSPACE/binary/rustfs" \
|
||||
RUSTFS_WORKTREE="$GITHUB_WORKSPACE/rustfs-source" \
|
||||
CONNECT_E2E_TOP_LOCKS_EVIDENCE="$GITHUB_WORKSPACE/top-locks-service-job-evidence.json" \
|
||||
make -C connect-harness e2e-connected E2E_SCENARIO=top-locks
|
||||
|
||||
@@ -73,6 +73,14 @@ jobs:
|
||||
ref: ${{ inputs.connect_sha }}
|
||||
token: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
|
||||
- name: Set up Docker Compose
|
||||
uses: ./.github/actions/setup-docker-compose
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.14"
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
@@ -107,7 +115,7 @@ jobs:
|
||||
[[ $(jq -r '.head_repository.full_name' <<<"$run") == "$GITHUB_REPOSITORY" ]]
|
||||
[[ $(jq -r '.name' <<<"$run") == "Build and Release" ]]
|
||||
|
||||
expected_job='Build RustFS (linux-x86_64-gnu, sm-standard-4, x86_64-unknown-linux-gnu, false, linux, pyroscope)'
|
||||
expected_job='Build RustFS (linux-x86_64-gnu)'
|
||||
jobs=$(gh api --paginate --slurp "repos/${GITHUB_REPOSITORY}/actions/runs/${BUILD_RUN_ID}/jobs?per_page=100")
|
||||
jq -e --arg name "$expected_job" '
|
||||
[.[].jobs[] | select(.name == $name)] as $matches
|
||||
@@ -154,7 +162,7 @@ jobs:
|
||||
npm --prefix connect-harness/web ci
|
||||
connect-harness/web/node_modules/.bin/playwright install --with-deps chromium
|
||||
make -C connect-harness e2e-connected-dispatch-check
|
||||
RUSTFS_BINARY="$GITHUB_WORKSPACE/binary/rustfs" \
|
||||
TMPDIR="$GITHUB_WORKSPACE" RUSTFS_BINARY="$GITHUB_WORKSPACE/binary/rustfs" \
|
||||
RUSTFS_WORKTREE="$GITHUB_WORKSPACE/rustfs-source" \
|
||||
CONNECT_E2E_TOP_RPC_EVIDENCE="$GITHUB_WORKSPACE/top-rpc-service-job-evidence.json" \
|
||||
make -C connect-harness e2e-connected E2E_SCENARIO=top-rpc
|
||||
|
||||
@@ -96,38 +96,8 @@ jobs:
|
||||
overwrite: true
|
||||
|
||||
upgrade:
|
||||
name: ${{ matrix.name }}
|
||||
name: Upgrade compatibility
|
||||
needs: build
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
# The two `_from_rc2_` tests keep their names: they assert
|
||||
# release-independent object contracts and pass unchanged against the
|
||||
# newer pinned source, so renaming them would only churn history and
|
||||
# the CI required-check names. UPGRADE_SOURCE_VERSION above is the
|
||||
# single source of truth for which release they actually run against.
|
||||
- name: Direct upgrade from the previous release
|
||||
test: direct_upgrade_from_rc2_preserves_object_contracts
|
||||
artifact: direct-upgrade
|
||||
- name: Mixed-version rolling upgrade from the previous release
|
||||
test: rolling_upgrade_from_rc2_preserves_mixed_version_contracts
|
||||
artifact: mixed-version-upgrade
|
||||
- name: Bucket configuration survives the upgrade
|
||||
test: direct_upgrade_from_previous_release_preserves_bucket_configuration
|
||||
artifact: bucket-config-upgrade
|
||||
- name: Rollback reads current bucket metadata
|
||||
test: rollback_to_previous_release_reads_current_bucket_metadata
|
||||
artifact: bucket-config-rollback
|
||||
- name: ODM configuration recovery after rc.5 rollback
|
||||
test: rc5_rollback_requires_restoring_odm_configuration
|
||||
artifact: odm-config-rollback
|
||||
- name: Multipart layouts survive the rc.5 upgrade
|
||||
test: direct_upgrade_from_rc5_preserves_multipart_layouts
|
||||
artifact: multipart-layout-upgrade
|
||||
- name: rc.5 multipart replication baseline
|
||||
test: rc5_baseline_replicates_multipart_layouts
|
||||
artifact: multipart-layout-baseline
|
||||
runs-on: sm-standard-2
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
@@ -142,8 +112,7 @@ jobs:
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
cache-shared-key: e2e-upgrade-tests
|
||||
# One main-branch consumer warms the shared test-harness dependencies.
|
||||
cache-save-if: ${{ github.ref == 'refs/heads/main' && matrix.artifact == 'direct-upgrade' }}
|
||||
cache-save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
install-build-packaging-tools: "false"
|
||||
install-test-tools: "false"
|
||||
|
||||
@@ -170,22 +139,49 @@ jobs:
|
||||
echo "RUSTFS_UPGRADE_SOURCE_BINARY=$SOURCE_DIR/rustfs" >> "$GITHUB_ENV"
|
||||
echo "RUSTFS_E2E_LOG_DIR=$RUNNER_TEMP/rustfs-upgrade-logs" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Run upgrade compatibility test
|
||||
# Reuse one compiled harness, while keeping each case in its own process.
|
||||
# Continue after a failure to retain the former matrix's complete verdict.
|
||||
- name: Run upgrade compatibility tests
|
||||
shell: bash
|
||||
env:
|
||||
RUSTFS_SCANNER_HEAL_G09_EVIDENCE_DIR: ${{ runner.temp }}/rustfs-upgrade-g09-evidence/${{ matrix.artifact }}
|
||||
NO_PROXY: 127.0.0.1,localhost,::1
|
||||
run: |
|
||||
export no_proxy="$NO_PROXY"
|
||||
python3 scripts/e2e_binary.py run -- cargo test --locked -p e2e_test \
|
||||
"upgrade_compatibility_test::${{ matrix.test }}" \
|
||||
-- --ignored --exact --nocapture
|
||||
cases=(
|
||||
direct-upgrade:direct_upgrade_from_rc2_preserves_object_contracts
|
||||
mixed-version-upgrade:rolling_upgrade_from_rc2_preserves_mixed_version_contracts
|
||||
bucket-config-upgrade:direct_upgrade_from_previous_release_preserves_bucket_configuration
|
||||
bucket-config-rollback:rollback_to_previous_release_reads_current_bucket_metadata
|
||||
odm-config-rollback:rc5_rollback_requires_restoring_odm_configuration
|
||||
multipart-layout-upgrade:direct_upgrade_from_rc5_preserves_multipart_layouts
|
||||
multipart-layout-baseline:rc5_baseline_replicates_multipart_layouts
|
||||
)
|
||||
status=0
|
||||
for entry in "${cases[@]}"; do
|
||||
artifact="${entry%%:*}"
|
||||
test="${entry#*:}"
|
||||
export RUSTFS_SCANNER_HEAL_G09_EVIDENCE_DIR="$RUNNER_TEMP/rustfs-upgrade-g09-evidence/$artifact"
|
||||
export RUSTFS_E2E_LOG_DIR="$RUNNER_TEMP/rustfs-upgrade-logs/$artifact"
|
||||
mkdir -p "$RUSTFS_E2E_LOG_DIR"
|
||||
echo "::group::$test"
|
||||
if python3 scripts/e2e_binary.py run -- cargo test --locked -p e2e_test \
|
||||
"upgrade_compatibility_test::$test" -- --ignored --exact --nocapture \
|
||||
2>&1 | tee "$RUSTFS_E2E_LOG_DIR/test.log"; then
|
||||
echo "PASS: $test"
|
||||
else
|
||||
echo "::error::Upgrade compatibility failed: $test"
|
||||
status=1
|
||||
fi
|
||||
echo "::endgroup::"
|
||||
done
|
||||
exit "$status"
|
||||
|
||||
- name: Upload scanner/heal G09 evidence
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: ${{ matrix.artifact }}-g09-evidence-${{ github.run_number }}
|
||||
path: ${{ runner.temp }}/rustfs-upgrade-g09-evidence/${{ matrix.artifact }}
|
||||
name: upgrade-g09-evidence-${{ github.run_number }}
|
||||
path: ${{ runner.temp }}/rustfs-upgrade-g09-evidence
|
||||
if-no-files-found: ignore
|
||||
retention-days: 14
|
||||
|
||||
@@ -193,7 +189,7 @@ jobs:
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: ${{ matrix.artifact }}-server-logs-${{ github.run_number }}
|
||||
name: upgrade-server-logs-${{ github.run_number }}
|
||||
path: ${{ runner.temp }}/rustfs-upgrade-logs
|
||||
if-no-files-found: warn
|
||||
retention-days: 14
|
||||
|
||||
@@ -80,13 +80,24 @@ jobs:
|
||||
- name: Setup Rust environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
cache-shared-key: build-x86_64-unknown-linux-gnu
|
||||
cache-shared-key: build-x86_64-unknown-linux-gnu-glibc-2.31
|
||||
cache-save-if: 'false'
|
||||
install-cross-tools: 'true'
|
||||
target: x86_64-unknown-linux-gnu
|
||||
install-build-packaging-tools: 'false'
|
||||
install-test-tools: 'false'
|
||||
|
||||
- name: Build RustFS
|
||||
run: cargo build --release --locked --target x86_64-unknown-linux-gnu -p rustfs --bins
|
||||
# Pin the glibc baseline (`<target>.<glibc>` for cargo-zigbuild) so
|
||||
# nightlies run on older distros such as RHEL/Rocky 9 (glibc 2.34); #8062.
|
||||
run: cargo zigbuild --release --locked --target x86_64-unknown-linux-gnu.2.31 -p rustfs --bins
|
||||
|
||||
- name: Verify glibc symbol baseline
|
||||
run: |
|
||||
python3 scripts/release/check_glibc.py 2.31 \
|
||||
target/x86_64-unknown-linux-gnu/release/rustfs \
|
||||
target/x86_64-unknown-linux-gnu/release/rustfs-cli
|
||||
target/x86_64-unknown-linux-gnu/release/rustfs --version | grep -Fx 'build target : x86_64-unknown-linux-gnu'
|
||||
|
||||
- name: Build DEB package
|
||||
id: deb
|
||||
|
||||
@@ -20,8 +20,9 @@ on:
|
||||
- ".github/workflows/oidc-keycloak.yml"
|
||||
- "crates/config/src/constants/oidc.rs"
|
||||
- "crates/iam/src/federation/**"
|
||||
- "crates/iam/src/oidc.rs"
|
||||
- "crates/iam/src/oidc/**"
|
||||
- "rustfs/src/admin/handlers/oidc.rs"
|
||||
- "rustfs/src/admin/service/oidc_config.rs"
|
||||
- "rustfs/src/admin/handlers/sts.rs"
|
||||
- "scripts/test/oidc_keycloak_live.sh"
|
||||
- "scripts/test/fixtures/keycloak-rustfs-ci-realm.json"
|
||||
@@ -31,8 +32,9 @@ on:
|
||||
- ".github/workflows/oidc-keycloak.yml"
|
||||
- "crates/config/src/constants/oidc.rs"
|
||||
- "crates/iam/src/federation/**"
|
||||
- "crates/iam/src/oidc.rs"
|
||||
- "crates/iam/src/oidc/**"
|
||||
- "rustfs/src/admin/handlers/oidc.rs"
|
||||
- "rustfs/src/admin/service/oidc_config.rs"
|
||||
- "rustfs/src/admin/handlers/sts.rs"
|
||||
- "scripts/test/oidc_keycloak_live.sh"
|
||||
- "scripts/test/fixtures/keycloak-rustfs-ci-realm.json"
|
||||
|
||||
@@ -90,3 +90,4 @@ worktrees/*
|
||||
# insta scratch files; the accepted .snap files ARE the assertions and are committed
|
||||
*.snap.new
|
||||
*.pending-snap
|
||||
.mimosa
|
||||
Vendored
+21
-27
@@ -7,7 +7,9 @@
|
||||
"name": "Debug(only) executable 'rustfs'",
|
||||
"env": {
|
||||
"RUST_LOG": "rustfs=info,ecstore=info,s3s=info,iam=info",
|
||||
"RUSTFS_SKIP_BACKGROUND_TASK": "on"
|
||||
"RUSTFS_SKIP_BACKGROUND_TASK": "on",
|
||||
// Local test volumes share one physical disk.
|
||||
"RUSTFS_UNSAFE_BYPASS_DISK_CHECK": "true"
|
||||
//"RUSTFS_OBS_LOG_DIRECTORY": "./deploy/logs",
|
||||
// "RUSTFS_POLICY_PLUGIN_URL":"http://localhost:8181/v1/data/rustfs/authz/allow",
|
||||
// "RUSTFS_POLICY_PLUGIN_AUTH_TOKEN":"your-opa-token"
|
||||
@@ -44,6 +46,7 @@
|
||||
"env": {
|
||||
"RUST_LOG": "rustfs=debug,ecstore=info,s3s=debug,iam=debug",
|
||||
"RUSTFS_SKIP_BACKGROUND_TASK": "on",
|
||||
"RUSTFS_UNSAFE_BYPASS_DISK_CHECK": "true",
|
||||
//"RUSTFS_OBS_LOG_DIRECTORY": "./deploy/logs",
|
||||
// "RUSTFS_POLICY_PLUGIN_URL":"http://localhost:8181/v1/data/rustfs/authz/allow",
|
||||
// "RUSTFS_POLICY_PLUGIN_AUTH_TOKEN":"your-opa-token"
|
||||
@@ -117,7 +120,8 @@
|
||||
"env": {
|
||||
"RUSTFS_ACCESS_KEY": "rustfsadmin",
|
||||
"RUSTFS_SECRET_KEY": "rustfsadmin",
|
||||
//"RUSTFS_VOLUMES": "./target/volume/test{1...4}",
|
||||
"RUSTFS_VOLUMES": "./target/volume/test{1...4}",
|
||||
"RUSTFS_UNSAFE_BYPASS_DISK_CHECK": "true",
|
||||
"RUSTFS_ADDRESS": ":9000",
|
||||
"RUSTFS_CONSOLE_ENABLE": "true",
|
||||
// "RUSTFS_OBS_TRACE_ENDPOINT": "http://127.0.0.1:4318/v1/traces", // jeager otlp http endpoint
|
||||
@@ -135,41 +139,30 @@
|
||||
{
|
||||
"type": "lldb",
|
||||
"request": "launch",
|
||||
"name": "Debug test_lifecycle_transition_basic",
|
||||
"name": "Debug test_transition_and_restore_flows",
|
||||
"cargo": {
|
||||
"args": [
|
||||
"test",
|
||||
"--no-run",
|
||||
"-p",
|
||||
"rustfs-scanner",
|
||||
"--test",
|
||||
"lifecycle_integration_test",
|
||||
"serial_tests::test_lifecycle_transition_basic",
|
||||
"-j",
|
||||
"1"
|
||||
]
|
||||
],
|
||||
"filter": {
|
||||
"name": "lifecycle_integration_test",
|
||||
"kind": "test"
|
||||
}
|
||||
},
|
||||
"args": [],
|
||||
"cwd": "${workspaceFolder}"
|
||||
},
|
||||
{
|
||||
"name": "Debug executable target/debug/test",
|
||||
"type": "lldb",
|
||||
"request": "launch",
|
||||
"program": "${workspaceFolder}/target/debug/deps/lifecycle_integration_test-5915cbfcab491b3b",
|
||||
"args": [
|
||||
"--skip",
|
||||
"test_lifecycle_expiry_basic",
|
||||
"--skip",
|
||||
"test_lifecycle_expiry_deletemarker",
|
||||
//"--skip",
|
||||
//"test_lifecycle_transition_basic",
|
||||
],
|
||||
"cwd": "${workspaceFolder}",
|
||||
//"stopAtEntry": false,
|
||||
//"preLaunchTask": "cargo build",
|
||||
"sourceLanguages": [
|
||||
"rust"
|
||||
"serial_tests::test_transition_and_restore_flows",
|
||||
"--exact",
|
||||
"--ignored",
|
||||
"--test-threads=1"
|
||||
],
|
||||
"cwd": "${workspaceFolder}"
|
||||
},
|
||||
{
|
||||
"name": "Debug executable target/debug/rustfs with sse kms",
|
||||
@@ -285,7 +278,7 @@
|
||||
"RUSTFS_COMPRESSION_ENABLED": "true",
|
||||
"RUSTFS_KMS_ENABLE": "true",
|
||||
"RUSTFS_KMS_BACKEND": "local",
|
||||
"RUSTFS_KMS_KEY_DIR": "./target/rio-v2/kms-key-dir",
|
||||
"RUSTFS_KMS_KEY_DIR": "${workspaceFolder}/target/rio-v2/kms-key-dir",
|
||||
"RUSTFS_KMS_LOCAL_MASTER_KEY": "my-secret-key", // Some Password
|
||||
"RUSTFS_KMS_DEFAULT_KEY_ID": "rustfs-master-key",
|
||||
},
|
||||
@@ -307,6 +300,7 @@
|
||||
"RUSTFS_ACCESS_KEY": "rustfsadmin",
|
||||
"RUSTFS_SECRET_KEY": "rustfsadmin",
|
||||
"RUSTFS_VOLUMES": "./target/e2e-test/test{1...4}",
|
||||
"RUSTFS_UNSAFE_BYPASS_DISK_CHECK": "true",
|
||||
"RUSTFS_REGION": "us-east-1",
|
||||
"RUSTFS_ADDRESS": ":9000",
|
||||
"RUSTFS_CONSOLE_ENABLE": "true",
|
||||
@@ -315,7 +309,7 @@
|
||||
|
||||
"RUSTFS_KMS_ENABLE": "true",
|
||||
"RUSTFS_KMS_BACKEND": "local",
|
||||
"RUSTFS_KMS_KEY_DIR": "./target/e2e-key-dir",
|
||||
"RUSTFS_KMS_KEY_DIR": "${workspaceFolder}/target/e2e-key-dir",
|
||||
"RUSTFS_KMS_LOCAL_MASTER_KEY": "my-secret-key", // Some Password
|
||||
"RUSTFS_KMS_DEFAULT_KEY_ID": "rustfs-master-key",
|
||||
},
|
||||
|
||||
Generated
+337
-470
File diff suppressed because it is too large
Load Diff
+76
-78
@@ -18,7 +18,6 @@ members = [
|
||||
"crates/audit", # Audit target management system with multi-target fan-out
|
||||
"crates/checksums", # client checksums
|
||||
"crates/common", # Shared utilities and data structures
|
||||
"crates/capture-authority", # Durable capture authority storage
|
||||
"crates/data-usage", # Shared data usage models and algorithms
|
||||
"crates/config", # Configuration management
|
||||
"crates/connect-symbol-catalog", # Release-only CPU profile symbol catalog generator
|
||||
@@ -75,7 +74,7 @@ edition = "2024"
|
||||
license = "Apache-2.0"
|
||||
repository = "https://github.com/rustfs/rustfs"
|
||||
rust-version = "1.99.0"
|
||||
version = "1.0.1"
|
||||
version = "1.0.3"
|
||||
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"]
|
||||
@@ -92,56 +91,56 @@ redundant_clone = "warn"
|
||||
|
||||
[workspace.dependencies]
|
||||
# RustFS Internal Crates
|
||||
rustfs = { path = "./rustfs", version = "1.0.1" }
|
||||
rustfs-heal = { path = "crates/heal", version = "1.0.1" }
|
||||
rustfs-heal-contracts = { path = "crates/heal-contracts", version = "1.0.1" }
|
||||
rustfs-scanner-metrics = { path = "crates/scanner-metrics", version = "1.0.1" }
|
||||
rustfs-audit = { path = "crates/audit", version = "1.0.1" }
|
||||
rustfs-checksums = { path = "crates/checksums", version = "1.0.1" }
|
||||
rustfs-common = { path = "crates/common", version = "1.0.1" }
|
||||
rustfs-data-usage = { path = "crates/data-usage", version = "1.0.1" }
|
||||
rustfs-config = { path = "./crates/config", version = "1.0.1" }
|
||||
rustfs-concurrency = { path = "./crates/concurrency", version = "1.0.1" }
|
||||
rustfs-credentials = { path = "crates/credentials", version = "1.0.1" }
|
||||
rustfs-crypto = { path = "crates/crypto", version = "1.0.1" }
|
||||
rustfs-ecstore = { path = "crates/ecstore", version = "1.0.1" }
|
||||
rustfs-filemeta = { path = "crates/filemeta", version = "1.0.1" }
|
||||
rustfs-iam = { path = "crates/iam", version = "1.0.1" }
|
||||
rustfs-keystone = { path = "crates/keystone", version = "1.0.1" }
|
||||
rustfs-license = { path = "crates/license", version = "1.0.1" }
|
||||
rustfs-lifecycle = { path = "crates/lifecycle", version = "1.0.1" }
|
||||
rustfs-kms = { path = "crates/kms", version = "1.0.1" }
|
||||
rustfs-lock = { path = "crates/lock", version = "1.0.1" }
|
||||
rustfs-madmin = { path = "crates/madmin", version = "1.0.1" }
|
||||
rustfs-notify = { path = "crates/notify", version = "1.0.1" }
|
||||
rustfs-io-metrics = { path = "crates/io-metrics", version = "1.0.1" }
|
||||
rustfs-io-core = { path = "crates/io-core", version = "1.0.1" }
|
||||
rustfs-object-capacity = { path = "crates/object-capacity", version = "1.0.1" }
|
||||
rustfs-object-data-cache = { path = "crates/object-data-cache", version = "1.0.1", default-features = false }
|
||||
rustfs-log-analyzer = { path = "crates/log-analyzer", version = "1.0.1" }
|
||||
rustfs-obs = { path = "crates/obs", version = "1.0.1" }
|
||||
rustfs-policy = { path = "crates/policy", version = "1.0.1" }
|
||||
rustfs-protos = { path = "crates/protos", version = "1.0.1" }
|
||||
rustfs-protocols = { path = "crates/protocols", version = "1.0.1" }
|
||||
rustfs-replication = { path = "crates/replication", version = "1.0.1" }
|
||||
rustfs-rio = { path = "crates/rio", version = "1.0.1" }
|
||||
rustfs-rio-v2 = { path = "crates/rio-v2", version = "1.0.1" }
|
||||
rustfs-s3-client = { path = "crates/s3-client", version = "1.0.1" }
|
||||
rustfs-s3-types = { path = "crates/s3-types", version = "1.0.1" }
|
||||
rustfs-s3-ops = { path = "crates/s3-ops", version = "1.0.1" }
|
||||
rustfs-s3select-api = { path = "crates/s3select-api", version = "1.0.1" }
|
||||
rustfs-s3select-query = { path = "crates/s3select-query", version = "1.0.1" }
|
||||
rustfs-scanner = { path = "crates/scanner", version = "1.0.1" }
|
||||
rustfs-security-governance = { path = "crates/security-governance", version = "1.0.1" }
|
||||
rustfs-extension-schema = { path = "crates/extension-schema", version = "1.0.1" }
|
||||
rustfs-signer = { path = "crates/signer", version = "1.0.1" }
|
||||
rustfs-storage-api = { path = "crates/storage-api", version = "1.0.1" }
|
||||
rustfs-trusted-proxies = { path = "crates/trusted-proxies", version = "1.0.1" }
|
||||
rustfs-targets = { path = "crates/targets", version = "1.0.1" }
|
||||
rustfs-test-utils = { path = "crates/test-utils", version = "1.0.1" }
|
||||
rustfs-tls-runtime = { path = "crates/tls-runtime", version = "1.0.1" }
|
||||
rustfs-utils = { path = "crates/utils", version = "1.0.1" }
|
||||
rustfs-zip = { path = "./crates/zip", version = "1.0.1" }
|
||||
rustfs = { path = "./rustfs", version = "1.0.3" }
|
||||
rustfs-heal = { path = "crates/heal", version = "1.0.3" }
|
||||
rustfs-heal-contracts = { path = "crates/heal-contracts", version = "1.0.3" }
|
||||
rustfs-scanner-metrics = { path = "crates/scanner-metrics", version = "1.0.3" }
|
||||
rustfs-audit = { path = "crates/audit", version = "1.0.3" }
|
||||
rustfs-checksums = { path = "crates/checksums", version = "1.0.3" }
|
||||
rustfs-common = { path = "crates/common", version = "1.0.3" }
|
||||
rustfs-data-usage = { path = "crates/data-usage", version = "1.0.3" }
|
||||
rustfs-config = { path = "./crates/config", version = "1.0.3" }
|
||||
rustfs-concurrency = { path = "./crates/concurrency", version = "1.0.3" }
|
||||
rustfs-credentials = { path = "crates/credentials", version = "1.0.3" }
|
||||
rustfs-crypto = { path = "crates/crypto", version = "1.0.3" }
|
||||
rustfs-ecstore = { path = "crates/ecstore", version = "1.0.3" }
|
||||
rustfs-filemeta = { path = "crates/filemeta", version = "1.0.3" }
|
||||
rustfs-iam = { path = "crates/iam", version = "1.0.3" }
|
||||
rustfs-keystone = { path = "crates/keystone", version = "1.0.3" }
|
||||
rustfs-license = { path = "crates/license", version = "1.0.3" }
|
||||
rustfs-lifecycle = { path = "crates/lifecycle", version = "1.0.3" }
|
||||
rustfs-kms = { path = "crates/kms", version = "1.0.3" }
|
||||
rustfs-lock = { path = "crates/lock", version = "1.0.3" }
|
||||
rustfs-madmin = { path = "crates/madmin", version = "1.0.3" }
|
||||
rustfs-notify = { path = "crates/notify", version = "1.0.3" }
|
||||
rustfs-io-metrics = { path = "crates/io-metrics", version = "1.0.3" }
|
||||
rustfs-io-core = { path = "crates/io-core", version = "1.0.3" }
|
||||
rustfs-object-capacity = { path = "crates/object-capacity", version = "1.0.3" }
|
||||
rustfs-object-data-cache = { path = "crates/object-data-cache", version = "1.0.3", default-features = false }
|
||||
rustfs-log-analyzer = { path = "crates/log-analyzer", version = "1.0.3" }
|
||||
rustfs-obs = { path = "crates/obs", version = "1.0.3" }
|
||||
rustfs-policy = { path = "crates/policy", version = "1.0.3" }
|
||||
rustfs-protos = { path = "crates/protos", version = "1.0.3" }
|
||||
rustfs-protocols = { path = "crates/protocols", version = "1.0.3" }
|
||||
rustfs-replication = { path = "crates/replication", version = "1.0.3" }
|
||||
rustfs-rio = { path = "crates/rio", version = "1.0.3" }
|
||||
rustfs-rio-v2 = { path = "crates/rio-v2", version = "1.0.3" }
|
||||
rustfs-s3-client = { path = "crates/s3-client", version = "1.0.3" }
|
||||
rustfs-s3-types = { path = "crates/s3-types", version = "1.0.3" }
|
||||
rustfs-s3-ops = { path = "crates/s3-ops", version = "1.0.3" }
|
||||
rustfs-s3select-api = { path = "crates/s3select-api", version = "1.0.3" }
|
||||
rustfs-s3select-query = { path = "crates/s3select-query", version = "1.0.3" }
|
||||
rustfs-scanner = { path = "crates/scanner", version = "1.0.3" }
|
||||
rustfs-security-governance = { path = "crates/security-governance", version = "1.0.3" }
|
||||
rustfs-extension-schema = { path = "crates/extension-schema", version = "1.0.3" }
|
||||
rustfs-signer = { path = "crates/signer", version = "1.0.3" }
|
||||
rustfs-storage-api = { path = "crates/storage-api", version = "1.0.3" }
|
||||
rustfs-trusted-proxies = { path = "crates/trusted-proxies", version = "1.0.3" }
|
||||
rustfs-targets = { path = "crates/targets", version = "1.0.3" }
|
||||
rustfs-test-utils = { path = "crates/test-utils", version = "1.0.3" }
|
||||
rustfs-tls-runtime = { path = "crates/tls-runtime", version = "1.0.3" }
|
||||
rustfs-utils = { path = "crates/utils", version = "1.0.3" }
|
||||
rustfs-zip = { path = "./crates/zip", version = "1.0.3" }
|
||||
|
||||
# Async Runtime and Networking
|
||||
async-channel = "2.5.0"
|
||||
@@ -157,8 +156,9 @@ futures-lite = "2.6.1"
|
||||
futures-util = "0.3.34"
|
||||
pollster = "1.0.1"
|
||||
pulsar = { default-features = false, version = "6.9.0" }
|
||||
lapin = { default-features = false, version = "4.12.0" }
|
||||
lapin = { default-features = false, version = "4.12.2" }
|
||||
hyper = { version = "1.12.0" }
|
||||
h2 = { version = "0.4.20" }
|
||||
hyper-rustls = { default-features = false, version = "0.27.10" }
|
||||
hyper-util = { version = "0.1.21" }
|
||||
http = "1.5.0"
|
||||
@@ -169,11 +169,11 @@ minlz = "1.2.3"
|
||||
reqwest = "0.13.5"
|
||||
rustfs-kafka-async = { version = "1.4.0" }
|
||||
socket2 = { version = "0.6.5" }
|
||||
tokio = { version = "1.53.2" }
|
||||
tokio = { version = "1.53.3" }
|
||||
tokio-rustls = { default-features = false, version = "0.26.6" }
|
||||
tokio-stream = { version = "0.1.19" }
|
||||
tokio-test = "0.4.6"
|
||||
tokio-util = { version = "0.7.19" }
|
||||
tokio-util = { version = "0.7.20" }
|
||||
tonic = { version = "0.14.6" }
|
||||
tonic-prost = { version = "0.14.6" }
|
||||
tonic-prost-build = { version = "0.14.6" }
|
||||
@@ -181,7 +181,7 @@ tower = { version = "0.5.3" }
|
||||
tower-http = { version = "0.7.1" }
|
||||
h3 = "0.0.8"
|
||||
h3-quinn = "0.0.10"
|
||||
quinn = { version = "0.11.11", default-features = false, features = [
|
||||
quinn = { version = "0.11.12", default-features = false, features = [
|
||||
"futures-io",
|
||||
"runtime-tokio",
|
||||
"rustls-aws-lc-rs",
|
||||
@@ -201,7 +201,7 @@ rmp-serde = { version = "1.3.1" }
|
||||
serde = { version = "1.0.229" }
|
||||
serde_ignored = { version = "0.1" }
|
||||
serde_with = { version = "3", default-features = false, features = ["macros", "std"] }
|
||||
serde_json = { version = "1.0.151" }
|
||||
serde_json = { version = "1.0.154" }
|
||||
serde_urlencoded = "0.7.1"
|
||||
|
||||
# Cryptography and Security
|
||||
@@ -225,7 +225,7 @@ rustls-native-certs = "0.8"
|
||||
rustls-pki-types = "1.15.1"
|
||||
x509-parser = "0.18.1"
|
||||
sha1 = "0.11.0"
|
||||
sha2 = "0.11.0"
|
||||
sha2 = "0.11.1"
|
||||
subtle = "2.6"
|
||||
zeroize = { version = "1.9.1" }
|
||||
proptest = "1"
|
||||
@@ -269,7 +269,7 @@ crossbeam-queue = "0.3.14"
|
||||
crossbeam-channel = "0.5.17"
|
||||
crossbeam-deque = "0.8.8"
|
||||
crossbeam-utils = "0.8.23"
|
||||
datafusion = { default-features = false, version = "55.1.0" }
|
||||
datafusion = { default-features = false, version = "55.2.0" }
|
||||
derive_builder = "0.20.2"
|
||||
ed25519-dalek = "3.0.0"
|
||||
enumset = "1.1.14"
|
||||
@@ -315,21 +315,19 @@ ratelimit = "2.0.0"
|
||||
rayon = "1.12.0"
|
||||
rustfs-erasure-codec = { version = "9.0.0" }
|
||||
reed-solomon-simd = "3.1.0"
|
||||
openraft = { version = "=0.9.25", default-features = false, features = ["serde"] }
|
||||
redb = "=4.3.0"
|
||||
regex = { version = "1.13.1" }
|
||||
rumqttc = { package = "rumqttc-next", version = "0.34.0" }
|
||||
redis = { version = "1.7.1" }
|
||||
rustify = { version = "0.7", default-features = false }
|
||||
rustix = { version = "1.1.5" }
|
||||
rust-embed = { version = "8.12.0" }
|
||||
rust-embed = { version = "8.13.0" }
|
||||
rustc-hash = { version = "2.1.3" }
|
||||
s3s = { git = "https://github.com/s3s-project/s3s", rev = "5761ddfe4c509fcac87e3b99fd5bd9e176b442c0", features = ["minio"] }
|
||||
s3s-sigv4 = { git = "https://github.com/s3s-project/s3s", rev = "5761ddfe4c509fcac87e3b99fd5bd9e176b442c0" }
|
||||
serial_test = "4.0.1"
|
||||
shadow-rs = { default-features = false, version = "2.0.0" }
|
||||
siphasher = "1.0.4"
|
||||
smallvec = { version = "1.16.2" }
|
||||
smallvec = { version = "1.16.3" }
|
||||
compact_str = "0.10.0"
|
||||
snap = "1.1.2"
|
||||
starshard = { version = "2.4.0" }
|
||||
@@ -343,12 +341,12 @@ tracing = { version = "0.1.44" }
|
||||
tracing-appender = "0.2.5"
|
||||
tracing-core = "0.1.36"
|
||||
tracing-error = "0.2.1"
|
||||
tracing-opentelemetry = { version = "0.34" }
|
||||
tracing-opentelemetry = { version = "0.34.0" }
|
||||
tracing-subscriber = { version = "0.3.23" }
|
||||
transform-stream = "0.3.1"
|
||||
url = "2.5.8"
|
||||
urlencoding = "2.1.3"
|
||||
uuid = { version = "1.27.0" }
|
||||
uuid = { version = "1.28.0" }
|
||||
vaultrs = { version = "0.8.0" }
|
||||
tar = "0.4.46"
|
||||
walkdir = "2.5.0"
|
||||
@@ -356,26 +354,26 @@ winapi-util = "0.1.11"
|
||||
windows = { version = "0.62.2" }
|
||||
windows-sys = "0.61.2"
|
||||
xxhash-rust = { version = "0.8.19" }
|
||||
zip = "8.6.0"
|
||||
zstd = "0.14.0"
|
||||
zip = "9.0.3"
|
||||
zstd = "0.14.2"
|
||||
|
||||
# Observability and Metrics
|
||||
metrics = "0.24.6"
|
||||
metrics-util = "0.20"
|
||||
dial9 = { version = "0.5.3", default-features = false }
|
||||
opentelemetry = { version = "0.33.0" }
|
||||
opentelemetry-appender-tracing = { version = "0.33.0" }
|
||||
opentelemetry-otlp = { version = "0.33.0" }
|
||||
opentelemetry-proto = { version = "0.33.0", default-features = false, features = ["metrics", "gen-tonic-messages"] }
|
||||
opentelemetry_sdk = { version = "0.33.0" }
|
||||
opentelemetry-semantic-conventions = { version = "0.33.0" }
|
||||
opentelemetry-stdout = { version = "0.33.0" }
|
||||
pyroscope = { version = "2.1.1" }
|
||||
dial9 = { version = "0.5.4", default-features = false }
|
||||
opentelemetry = { version = "0.33.1" }
|
||||
opentelemetry-appender-tracing = { version = "0.33.1" }
|
||||
opentelemetry-otlp = { version = "0.33.1" }
|
||||
opentelemetry-proto = { version = "0.33.1", default-features = false, features = ["metrics", "gen-tonic-messages"] }
|
||||
opentelemetry_sdk = { version = "0.33.1" }
|
||||
opentelemetry-semantic-conventions = { version = "0.33.1" }
|
||||
opentelemetry-stdout = { version = "0.33.1" }
|
||||
pyroscope = { version = "2.1.2" }
|
||||
|
||||
# FTP and SFTP
|
||||
libunftp = { version = "0.23.1" }
|
||||
unftp-core = "0.1.0"
|
||||
suppaftp = { version = "12.1.1" }
|
||||
suppaftp = { version = "12.2.0" }
|
||||
rcgen = { version = "0.14.10", default-features = false, features = ["aws_lc_rs", "crypto", "pem"] }
|
||||
russh = { version = "0.64.1" }
|
||||
russh-sftp = "3.0.1"
|
||||
@@ -386,7 +384,7 @@ dav-server = "0.11.0"
|
||||
# Performance Analysis and Memory Profiling
|
||||
rustfs-mimalloc = { version = "0.5.6" }
|
||||
# Preserve Unicode focus filters until rustfs/backlog#2302 is resolved.
|
||||
hotpath = { version = "0.28.5", default-features = false }
|
||||
hotpath = { version = "0.28.6", default-features = false }
|
||||
# Snapshot testing for output format regression detection
|
||||
insta = { version = "1.49" }
|
||||
|
||||
|
||||
@@ -51,6 +51,8 @@ unparseable = "unparseable"
|
||||
# Disaster-recovery objectives: recovery time and recovery point.
|
||||
RTO = "RTO"
|
||||
rto = "rto"
|
||||
# rustix OFlags variant (POSIX O_WRONLY equivalent).
|
||||
WRONLY = "WRONLY"
|
||||
|
||||
[files]
|
||||
extend-exclude = []
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
[package]
|
||||
name = "rustfs-capture-authority"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Durable local storage for capture authority"
|
||||
|
||||
[dependencies]
|
||||
openraft = { workspace = true }
|
||||
redb = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
thiserror = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
url = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -1,862 +0,0 @@
|
||||
fn read_native_range_in<R: std::ops::RangeBounds<u64>>(
|
||||
tx: &redb::ReadTransaction,
|
||||
range: R,
|
||||
) -> Result<Vec<crate::CaptureRaftEntry>, StoreError> {
|
||||
use std::ops::Bound::{Excluded, Included, Unbounded};
|
||||
let logs = tx.open_table(LOGS)?;
|
||||
let start = match range.start_bound() {
|
||||
Included(value) => *value,
|
||||
Excluded(value) => match value.checked_add(1) {
|
||||
Some(value) => value,
|
||||
None => return Ok(Vec::new()),
|
||||
},
|
||||
Unbounded => 0,
|
||||
};
|
||||
let end = match range.end_bound() {
|
||||
Included(value) => *value,
|
||||
Excluded(value) => match value.checked_sub(1) {
|
||||
Some(value) => value,
|
||||
None => return Ok(Vec::new()),
|
||||
},
|
||||
Unbounded => u64::MAX,
|
||||
};
|
||||
if start > end {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let mut entries = Vec::new();
|
||||
for row in logs.range(start..=end)? {
|
||||
let (key, value) = row?;
|
||||
let entry = decode_log(value.value())?;
|
||||
if entry.id.index != key.value() {
|
||||
return Err(StoreError::CorruptRecord);
|
||||
}
|
||||
entries.push(decode_native_entry(entry)?);
|
||||
}
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
use crate::{
|
||||
LOGS, LogId, META, Store, StoreError, committed::validate_committed, created::*, decode_log, encode_id, encode_log,
|
||||
next_index, number,
|
||||
};
|
||||
use redb::{ReadTransaction, ReadableDatabase, ReadableTable, ReadableTableMetadata, TableDefinition};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
pub(crate) const HEADS: TableDefinition<&[u8], &[u8]> = TableDefinition::new("heads");
|
||||
pub(crate) const VERSIONS: TableDefinition<&[u8], &[u8]> = TableDefinition::new("versions");
|
||||
pub(crate) const EVENTS: TableDefinition<&[u8], &[u8]> = TableDefinition::new("events");
|
||||
pub(crate) const OPERATIONS: TableDefinition<&[u8], &[u8]> = TableDefinition::new("operations");
|
||||
pub(crate) const APPLY_RESULTS: TableDefinition<u64, &[u8]> = TableDefinition::new("apply_results");
|
||||
|
||||
fn version_key(object: &ObjectIdentity, version: [u8; 16]) -> Result<Vec<u8>, StoreError> {
|
||||
let mut b = object_bytes(object)?;
|
||||
b.extend(version);
|
||||
Ok(b)
|
||||
}
|
||||
fn decode_version(b: &[u8]) -> Result<CreatedVersion, StoreError> {
|
||||
let mut d = Decoder::new(b);
|
||||
let v = CreatedVersion {
|
||||
prepared: d.prepared()?,
|
||||
decision_log_id: d.id()?,
|
||||
binding_revision: d.u64()?,
|
||||
};
|
||||
d.finish()?;
|
||||
Ok(v)
|
||||
}
|
||||
fn decode_event(b: &[u8]) -> Result<CreatedEvent, StoreError> {
|
||||
let mut d = Decoder::new(b);
|
||||
let e = CreatedEvent {
|
||||
object: d.object()?,
|
||||
prepared: d.prepared()?,
|
||||
decision_log_id: d.id()?,
|
||||
binding: d.binding()?,
|
||||
};
|
||||
d.finish()?;
|
||||
Ok(e)
|
||||
}
|
||||
fn decode_operation(b: &[u8]) -> Result<CreatedOperation, StoreError> {
|
||||
let mut d = Decoder::new(b);
|
||||
let len = usize::try_from(d.u64()?).map_err(|_| StoreError::CorruptRecord)?;
|
||||
let command = b
|
||||
.get(8..8usize.checked_add(len).ok_or(StoreError::CorruptRecord)?)
|
||||
.ok_or(StoreError::CorruptRecord)?;
|
||||
DecideCreated::decode(command)?;
|
||||
let mut d = Decoder::new(&b[8 + len..]);
|
||||
let result = d.result()?;
|
||||
d.finish()?;
|
||||
Ok(CreatedOperation {
|
||||
canonical_command: command.to_vec(),
|
||||
result,
|
||||
})
|
||||
}
|
||||
fn decode_apply(b: &[u8]) -> Result<CreatedApplyResult, StoreError> {
|
||||
let mut d = Decoder::new(b);
|
||||
let r = CreatedApplyResult {
|
||||
log_id: d.id()?,
|
||||
result: d.result()?,
|
||||
};
|
||||
d.finish()?;
|
||||
Ok(r)
|
||||
}
|
||||
fn read_binding(meta: &impl ReadableTable<&'static str, &'static [u8]>) -> Result<CaptureBinding, StoreError> {
|
||||
let b = meta.get("capture_binding")?.ok_or(StoreError::BindingNotPinned)?;
|
||||
let mut d = Decoder::new(b.value());
|
||||
let binding = d.binding()?;
|
||||
d.finish()?;
|
||||
Ok(binding)
|
||||
}
|
||||
fn read_applied(meta: &impl ReadableTable<&'static str, &'static [u8]>) -> Result<Option<LogId>, StoreError> {
|
||||
let b = meta.get("last_applied")?.ok_or(StoreError::CorruptRecord)?;
|
||||
let mut d = Decoder::new(b.value());
|
||||
let id = match d.take::<1>()?[0] {
|
||||
0 => None,
|
||||
1 => Some(d.id()?),
|
||||
_ => return Err(StoreError::CorruptRecord),
|
||||
};
|
||||
d.finish()?;
|
||||
Ok(id)
|
||||
}
|
||||
fn required<T>(value: Option<T>) -> Result<T, StoreError> {
|
||||
value.ok_or(StoreError::CorruptRecord)
|
||||
}
|
||||
fn encode_native_entry(entry: &crate::CaptureRaftEntry) -> Result<Vec<u8>, StoreError> {
|
||||
let id: LogId = entry.log_id.into();
|
||||
let (kind, body) = match &entry.payload {
|
||||
openraft::EntryPayload::Blank => (0, vec![]),
|
||||
openraft::EntryPayload::Normal(command) => (1, command.created().encode()?),
|
||||
openraft::EntryPayload::Membership(membership) => {
|
||||
let stored = openraft::StoredMembership::new(Some(entry.log_id), membership.clone());
|
||||
let checked = crate::CaptureMembership::try_from(&stored)?;
|
||||
(2, checked.encode()?)
|
||||
}
|
||||
};
|
||||
let mut payload = b"CENR\x01".to_vec();
|
||||
payload.push(kind);
|
||||
payload.extend(
|
||||
u64::try_from(body.len())
|
||||
.map_err(|_| StoreError::CorruptRecord)?
|
||||
.to_be_bytes(),
|
||||
);
|
||||
payload.extend(body);
|
||||
encode_log(&crate::LogEntry { id, payload })
|
||||
}
|
||||
|
||||
pub(crate) fn decode_native_entry(entry: crate::LogEntry) -> Result<crate::CaptureRaftEntry, StoreError> {
|
||||
let bytes = &entry.payload;
|
||||
if bytes.len() < 14 || &bytes[..5] != b"CENR\x01" {
|
||||
return Err(StoreError::CorruptRecord);
|
||||
}
|
||||
let length = usize::try_from(number(&bytes[6..14])?).map_err(|_| StoreError::CorruptRecord)?;
|
||||
let body = &bytes[14..];
|
||||
if length != body.len() {
|
||||
return Err(StoreError::CorruptRecord);
|
||||
}
|
||||
let payload = match bytes[5] {
|
||||
0 if body.is_empty() => openraft::EntryPayload::Blank,
|
||||
1 => openraft::EntryPayload::Normal(crate::CaptureCommand::try_created(DecideCreated::decode(body)?)?),
|
||||
2 => {
|
||||
let membership = crate::CaptureMembership::decode(body).map_err(|_| StoreError::CorruptRecord)?;
|
||||
if *membership.log_id() != Some(entry.id) {
|
||||
return Err(StoreError::CorruptRecord);
|
||||
}
|
||||
let stored = openraft::StoredMembership::try_from(&membership).map_err(|_| StoreError::CorruptRecord)?;
|
||||
openraft::EntryPayload::Membership(stored.membership().clone())
|
||||
}
|
||||
_ => return Err(StoreError::CorruptRecord),
|
||||
};
|
||||
Ok(crate::CaptureRaftEntry {
|
||||
log_id: entry.id.into(),
|
||||
payload,
|
||||
})
|
||||
}
|
||||
|
||||
fn apply_created(
|
||||
tx: &redb::WriteTransaction,
|
||||
id: LogId,
|
||||
command: &DecideCreated,
|
||||
canonical: &[u8],
|
||||
binding: &CaptureBinding,
|
||||
) -> Result<CreatedResult, StoreError> {
|
||||
let mut operations = tx.open_table(OPERATIONS)?;
|
||||
let existing = operations
|
||||
.get(command.operation_id.as_slice())?
|
||||
.map(|v| decode_operation(v.value()))
|
||||
.transpose()?;
|
||||
let result = if let Some(existing) = existing {
|
||||
if existing.canonical_command == canonical {
|
||||
existing.result
|
||||
} else {
|
||||
CreatedResult::OperationConflict
|
||||
}
|
||||
} else {
|
||||
let mut heads = tx.open_table(HEADS)?;
|
||||
let mut versions = tx.open_table(VERSIONS)?;
|
||||
let object = object_bytes(&command.object)?;
|
||||
let key = version_key(&command.object, command.prepared.version_id)?;
|
||||
let actual = heads
|
||||
.get(object.as_slice())?
|
||||
.map(|v| {
|
||||
let mut d = Decoder::new(v.value());
|
||||
let id = d.uuid()?;
|
||||
d.finish()?;
|
||||
Ok::<_, StoreError>(id)
|
||||
})
|
||||
.transpose()?;
|
||||
let outcome = if command.binding_revision != binding.revision {
|
||||
CreatedResult::BindingMismatch
|
||||
} else if actual != command.expected_head {
|
||||
CreatedResult::HeadMismatch { actual_head: actual }
|
||||
} else if versions.get(key.as_slice())?.is_some() {
|
||||
CreatedResult::VersionConflict
|
||||
} else {
|
||||
let mut version = Vec::new();
|
||||
put_prepared(&mut version, &command.prepared);
|
||||
version.extend(encode_id(id));
|
||||
version.extend(binding.revision.to_be_bytes());
|
||||
versions.insert(key.as_slice(), version.as_slice())?;
|
||||
let mut event = object;
|
||||
put_prepared(&mut event, &command.prepared);
|
||||
event.extend(encode_id(id));
|
||||
event.extend(binding_bytes(&binding)?);
|
||||
tx.open_table(EVENTS)?
|
||||
.insert(command.operation_id.as_slice(), event.as_slice())?;
|
||||
heads.insert(object_bytes(&command.object)?.as_slice(), command.prepared.version_id.as_slice())?;
|
||||
CreatedResult::Created {
|
||||
version_id: command.prepared.version_id,
|
||||
event_id: command.operation_id,
|
||||
decision_log_id: id,
|
||||
}
|
||||
};
|
||||
let mut op = u64::try_from(canonical.len())
|
||||
.map_err(|_| StoreError::CorruptRecord)?
|
||||
.to_be_bytes()
|
||||
.to_vec();
|
||||
op.extend(canonical);
|
||||
op.extend(result_bytes(&outcome));
|
||||
operations.insert(command.operation_id.as_slice(), op.as_slice())?;
|
||||
outcome
|
||||
};
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn validate_response_kind(entry: &crate::CaptureRaftEntry, response: &crate::CaptureResponse) -> Result<(), StoreError> {
|
||||
let kind = match entry.payload {
|
||||
openraft::EntryPayload::Blank => crate::CaptureResponseKind::Blank,
|
||||
openraft::EntryPayload::Normal(_) => crate::CaptureResponseKind::Created,
|
||||
openraft::EntryPayload::Membership(_) => crate::CaptureResponseKind::Membership,
|
||||
};
|
||||
if response.kind() != kind || *response.log_id() != LogId::from(entry.log_id) {
|
||||
return Err(StoreError::CorruptRecord);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn encode_response(response: &crate::CaptureResponse) -> Result<Vec<u8>, StoreError> {
|
||||
let (kind, body) = match response.kind() {
|
||||
crate::CaptureResponseKind::Blank => (0, vec![]),
|
||||
crate::CaptureResponseKind::Created => (1, result_bytes(response.created_result().ok_or(StoreError::CorruptRecord)?)),
|
||||
crate::CaptureResponseKind::Membership => (2, vec![]),
|
||||
};
|
||||
let mut bytes = b"CRSP\x01".to_vec();
|
||||
bytes.push(kind);
|
||||
bytes.extend(encode_id(*response.log_id()));
|
||||
bytes.extend(
|
||||
u64::try_from(body.len())
|
||||
.map_err(|_| StoreError::CorruptRecord)?
|
||||
.to_be_bytes(),
|
||||
);
|
||||
bytes.extend(body);
|
||||
Ok(bytes)
|
||||
}
|
||||
fn decode_response(bytes: &[u8]) -> Result<crate::CaptureResponse, StoreError> {
|
||||
if bytes.len() < 38
|
||||
|| &bytes[..5] != b"CRSP\x01"
|
||||
|| number(&bytes[30..38])? != u64::try_from(bytes.len() - 38).map_err(|_| StoreError::CorruptRecord)?
|
||||
{
|
||||
return Err(StoreError::CorruptRecord);
|
||||
}
|
||||
let id = crate::decode_id(&bytes[6..30])?;
|
||||
match bytes[5] {
|
||||
0 if bytes.len() == 38 => crate::CaptureResponse::blank(id),
|
||||
1 => {
|
||||
let mut body = encode_id(id);
|
||||
body.extend(&bytes[38..]);
|
||||
crate::CaptureResponse::created(decode_apply(&body)?)
|
||||
}
|
||||
2 if bytes.len() == 38 => crate::CaptureResponse::membership(id),
|
||||
_ => Err(StoreError::CorruptRecord),
|
||||
}
|
||||
}
|
||||
fn entry_command(entry: &crate::LogEntry, native: bool) -> Result<DecideCreated, StoreError> {
|
||||
if native {
|
||||
match decode_native_entry(entry.clone())?.payload {
|
||||
openraft::EntryPayload::Normal(command) => Ok(command.created().clone()),
|
||||
_ => Err(StoreError::CorruptRecord),
|
||||
}
|
||||
} else {
|
||||
DecideCreated::decode(&entry.payload)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn native_application_state(tx: &ReadTransaction) -> Result<(Option<LogId>, crate::CaptureMembership), StoreError> {
|
||||
let meta = tx.open_table(META)?;
|
||||
if number(required(meta.get("schema")?)?.value())? != 4 {
|
||||
return Err(StoreError::UnsupportedSchema);
|
||||
}
|
||||
let last = read_applied(&meta)?;
|
||||
let membership = crate::CaptureMembership::decode(required(meta.get("native_membership")?)?.value())
|
||||
.map_err(|_| StoreError::CorruptRecord)?;
|
||||
if membership
|
||||
.log_id()
|
||||
.is_some_and(|id| last.is_none_or(|last| id.index > last.index))
|
||||
{
|
||||
return Err(StoreError::CorruptRecord);
|
||||
}
|
||||
Ok((last, membership))
|
||||
}
|
||||
|
||||
impl Store {
|
||||
/// Explicitly opt an empty, bound store into native entry persistence.
|
||||
pub fn enable_native_empty(&mut self) -> Result<(), StoreError> {
|
||||
let tx = self.write_transaction()?;
|
||||
{
|
||||
let mut meta = tx.open_table(META)?;
|
||||
let schema = number(required(meta.get("schema")?)?.value())?;
|
||||
if !matches!(schema, 3 | 4) {
|
||||
return Err(StoreError::UnsupportedSchema);
|
||||
}
|
||||
read_binding(&meta)?;
|
||||
if schema == 4 {
|
||||
let membership = crate::CaptureMembership::decode(required(meta.get("native_membership")?)?.value())
|
||||
.map_err(|_| StoreError::CorruptRecord)?;
|
||||
let last = read_applied(&meta)?;
|
||||
if membership
|
||||
.log_id()
|
||||
.is_some_and(|id| last.is_none_or(|last| id.index > last.index))
|
||||
|| (last.is_none() && (!membership.configs().is_empty() || !membership.nodes().is_empty()))
|
||||
{
|
||||
return Err(StoreError::CorruptRecord);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
if read_applied(&meta)?.is_some() || !tx.open_table(LOGS)?.is_empty()? {
|
||||
return Err(StoreError::UnsupportedSchema);
|
||||
}
|
||||
for table in [HEADS, VERSIONS, EVENTS, OPERATIONS] {
|
||||
if !tx.open_table(table)?.is_empty()? {
|
||||
return Err(StoreError::UnsupportedSchema);
|
||||
}
|
||||
}
|
||||
if !tx.open_table(APPLY_RESULTS)?.is_empty()? || meta.get("committed")?.is_some() || meta.get("tail")?.is_some() {
|
||||
return Err(StoreError::UnsupportedSchema);
|
||||
}
|
||||
if meta.get("native_membership")?.is_some() {
|
||||
return Err(StoreError::CorruptRecord);
|
||||
}
|
||||
let membership = crate::CaptureMembership::new(None, vec![], BTreeMap::new())?;
|
||||
meta.insert("native_membership", membership.encode()?.as_slice())?;
|
||||
meta.insert("schema", 4u64.to_be_bytes().as_slice())?;
|
||||
}
|
||||
self.commit(tx)
|
||||
}
|
||||
pub fn append_native(&mut self, entries: &[crate::CaptureRaftEntry]) -> Result<(), StoreError> {
|
||||
let tx = self.write_transaction()?;
|
||||
{
|
||||
let mut meta = tx.open_table(META)?;
|
||||
if number(required(meta.get("schema")?)?.value())? != 4 {
|
||||
return Err(StoreError::UnsupportedSchema);
|
||||
}
|
||||
if entries.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let tail = meta.get("tail")?.map(|value| crate::decode_id(value.value())).transpose()?;
|
||||
let mut next = next_index(tail.map(|id| id.index))?;
|
||||
let mut encoded = Vec::with_capacity(entries.len());
|
||||
for (position, entry) in entries.iter().enumerate() {
|
||||
if entry.log_id.index != next {
|
||||
return Err(StoreError::InvalidLogSequence);
|
||||
}
|
||||
encoded.push(encode_native_entry(entry)?);
|
||||
if position + 1 < entries.len() {
|
||||
next = next_index(Some(next))?;
|
||||
}
|
||||
}
|
||||
let mut logs = tx.open_table(LOGS)?;
|
||||
for (entry, bytes) in entries.iter().zip(&encoded) {
|
||||
logs.insert(entry.log_id.index, bytes.as_slice())?;
|
||||
}
|
||||
let last = entries.last().ok_or(StoreError::InvalidLogSequence)?;
|
||||
meta.insert("tail", encode_id(last.log_id.into()).as_slice())?;
|
||||
}
|
||||
self.commit(tx)
|
||||
}
|
||||
pub fn read_native_entry(&self, index: u64) -> Result<Option<crate::CaptureRaftEntry>, StoreError> {
|
||||
let tx = self.database.begin_read()?;
|
||||
if number(required(tx.open_table(META)?.get("schema")?)?.value())? != 4 {
|
||||
return Err(StoreError::UnsupportedSchema);
|
||||
}
|
||||
tx.open_table(LOGS)?
|
||||
.get(index)?
|
||||
.map(|value| {
|
||||
let entry = decode_log(value.value())?;
|
||||
if entry.id.index != index {
|
||||
return Err(StoreError::CorruptRecord);
|
||||
}
|
||||
decode_native_entry(entry)
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
/// Read typed native entries within the requested bounds.
|
||||
pub fn read_native_range<R: std::ops::RangeBounds<u64>>(&self, range: R) -> Result<Vec<crate::CaptureRaftEntry>, StoreError> {
|
||||
let tx = self.database.begin_read()?;
|
||||
if number(required(tx.open_table(META)?.get("schema")?)?.value())? != 4 {
|
||||
return Err(StoreError::UnsupportedSchema);
|
||||
}
|
||||
read_native_range_in(&tx, range)
|
||||
}
|
||||
pub fn read_native_entries(&self, start: u64, end: u64) -> Result<Vec<crate::CaptureRaftEntry>, StoreError> {
|
||||
let tx = self.database.begin_read()?;
|
||||
if number(required(tx.open_table(META)?.get("schema")?)?.value())? != 4 {
|
||||
return Err(StoreError::UnsupportedSchema);
|
||||
}
|
||||
if start > end {
|
||||
return Err(StoreError::InvalidLogSequence);
|
||||
}
|
||||
read_native_range_in(&tx, start..end)
|
||||
}
|
||||
|
||||
pub fn apply_next_native(&mut self, id: LogId) -> Result<crate::CaptureResponse, StoreError> {
|
||||
let tx = self.write_transaction()?;
|
||||
let response;
|
||||
{
|
||||
let mut meta = tx.open_table(META)?;
|
||||
if number(required(meta.get("schema")?)?.value())? != 4 {
|
||||
return Err(StoreError::UnsupportedSchema);
|
||||
}
|
||||
let binding = read_binding(&meta)?;
|
||||
let logs = tx.open_table(LOGS)?;
|
||||
let committed = validate_committed(&meta, &logs)?.ok_or(StoreError::InvalidCommittedLog)?;
|
||||
let entry = decode_log(required(logs.get(id.index)?)?.value())?;
|
||||
if entry.id != id || id.index > committed.index {
|
||||
return Err(StoreError::InvalidCommittedLog);
|
||||
}
|
||||
let native = decode_native_entry(entry)?;
|
||||
let last = read_applied(&meta)?;
|
||||
let mut results = tx.open_table(APPLY_RESULTS)?;
|
||||
if last.is_some_and(|last| id.index <= last.index) {
|
||||
let saved = decode_response(required(results.get(id.index)?)?.value())?;
|
||||
if *saved.log_id() != id {
|
||||
return Err(StoreError::InvalidApplySequence);
|
||||
}
|
||||
validate_response_kind(&native, &saved)?;
|
||||
return Ok(saved);
|
||||
}
|
||||
if id.index != next_index(last.map(|last| last.index))? {
|
||||
return Err(StoreError::InvalidApplySequence);
|
||||
}
|
||||
response = match native.payload {
|
||||
openraft::EntryPayload::Blank => crate::CaptureResponse::blank(id)?,
|
||||
openraft::EntryPayload::Normal(command) => {
|
||||
let canonical = command.created().encode()?;
|
||||
let result = apply_created(&tx, id, command.created(), &canonical, &binding)?;
|
||||
crate::CaptureResponse::created(CreatedApplyResult { log_id: id, result })?
|
||||
}
|
||||
openraft::EntryPayload::Membership(membership) => {
|
||||
let stored = openraft::StoredMembership::new(Some(id.into()), membership);
|
||||
let checked = crate::CaptureMembership::try_from(&stored)?;
|
||||
meta.insert("native_membership", checked.encode()?.as_slice())?;
|
||||
crate::CaptureResponse::membership(id)?
|
||||
}
|
||||
};
|
||||
results.insert(id.index, encode_response(&response)?.as_slice())?;
|
||||
let mut last = vec![1];
|
||||
last.extend(encode_id(id));
|
||||
meta.insert("last_applied", last.as_slice())?;
|
||||
}
|
||||
self.commit(tx)?;
|
||||
Ok(response)
|
||||
}
|
||||
pub fn native_apply_response(&self, index: u64) -> Result<Option<crate::CaptureResponse>, StoreError> {
|
||||
let tx = self.database.begin_read()?;
|
||||
if number(required(tx.open_table(META)?.get("schema")?)?.value())? != 4 {
|
||||
return Err(StoreError::UnsupportedSchema);
|
||||
}
|
||||
let results = tx.open_table(APPLY_RESULTS)?;
|
||||
results.get(index)?.map(|v| decode_response(v.value())).transpose()
|
||||
}
|
||||
pub fn native_application_state(&self) -> Result<(Option<LogId>, crate::CaptureMembership), StoreError> {
|
||||
native_application_state(&self.database.begin_read()?)
|
||||
}
|
||||
/// Bootstrap the immutable capture policy. The caller must be the trusted
|
||||
/// deployment provisioning owner. This crate does not authenticate callers;
|
||||
/// do not expose this public method through an ordinary object request API.
|
||||
/// Prepared identities assert identity only, not physical data durability.
|
||||
pub fn pin_capture_binding(&mut self, binding: CaptureBinding) -> Result<(), StoreError> {
|
||||
let encoded = binding_bytes(&binding)?;
|
||||
let tx = self.write_transaction()?;
|
||||
{
|
||||
let mut meta = tx.open_table(META)?;
|
||||
if matches!(number(required(meta.get("schema")?)?.value())?, 3 | 4) {
|
||||
if read_binding(&meta)? == binding {
|
||||
return Ok(());
|
||||
}
|
||||
return Err(StoreError::BindingConflict);
|
||||
}
|
||||
if meta.get("capture_binding")?.is_some() || meta.get("last_applied")?.is_some() {
|
||||
return Err(StoreError::CorruptRecord);
|
||||
}
|
||||
for table in [HEADS, VERSIONS, EVENTS, OPERATIONS] {
|
||||
if !tx.open_table(table)?.is_empty()? {
|
||||
return Err(StoreError::CorruptRecord);
|
||||
}
|
||||
}
|
||||
if !tx.open_table(APPLY_RESULTS)?.is_empty()? {
|
||||
return Err(StoreError::CorruptRecord);
|
||||
}
|
||||
meta.insert("capture_binding", encoded.as_slice())?;
|
||||
meta.insert("last_applied", &[0][..])?;
|
||||
meta.insert("schema", 3u64.to_be_bytes().as_slice())?;
|
||||
}
|
||||
self.commit(tx)
|
||||
}
|
||||
/// Apply the next exact persisted committed Created command atomically.
|
||||
pub fn apply_next_created(&mut self, id: LogId) -> Result<CreatedResult, StoreError> {
|
||||
let tx = self.write_transaction()?;
|
||||
let result;
|
||||
{
|
||||
let mut meta = tx.open_table(META)?;
|
||||
if number(required(meta.get("schema")?)?.value())? != 3 {
|
||||
return Err(StoreError::UnsupportedSchema);
|
||||
}
|
||||
let binding = read_binding(&meta)?;
|
||||
let logs = tx.open_table(LOGS)?;
|
||||
let committed = validate_committed(&meta, &logs)?.ok_or(StoreError::InvalidCommittedLog)?;
|
||||
let entry = decode_log(required(logs.get(id.index)?)?.value())?;
|
||||
if entry.id != id || id.index > committed.index {
|
||||
return Err(StoreError::InvalidCommittedLog);
|
||||
}
|
||||
let last = read_applied(&meta)?;
|
||||
let mut results = tx.open_table(APPLY_RESULTS)?;
|
||||
if last.is_some_and(|last| id.index <= last.index) {
|
||||
let saved = decode_apply(required(results.get(id.index)?)?.value())?;
|
||||
if saved.log_id != id {
|
||||
return Err(StoreError::InvalidApplySequence);
|
||||
}
|
||||
return Ok(saved.result);
|
||||
}
|
||||
if id.index != next_index(last.map(|last| last.index))? {
|
||||
return Err(StoreError::InvalidApplySequence);
|
||||
}
|
||||
let command = DecideCreated::decode(&entry.payload)?;
|
||||
result = apply_created(&tx, id, &command, &entry.payload, &binding)?;
|
||||
let mut applied = encode_id(id);
|
||||
applied.extend(result_bytes(&result));
|
||||
results.insert(id.index, applied.as_slice())?;
|
||||
let mut last = vec![1];
|
||||
last.extend(encode_id(id));
|
||||
meta.insert("last_applied", last.as_slice())?;
|
||||
}
|
||||
self.commit(tx)?;
|
||||
Ok(result)
|
||||
}
|
||||
pub fn capture_binding(&self) -> Result<Option<CaptureBinding>, StoreError> {
|
||||
let tx = self.database.begin_read()?;
|
||||
let meta = tx.open_table(META)?;
|
||||
if !matches!(number(required(meta.get("schema")?)?.value())?, 3 | 4) {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(read_binding(&meta)?))
|
||||
}
|
||||
pub fn last_applied(&self) -> Result<Option<LogId>, StoreError> {
|
||||
let tx = self.database.begin_read()?;
|
||||
let meta = tx.open_table(META)?;
|
||||
if !matches!(number(required(meta.get("schema")?)?.value())?, 3 | 4) {
|
||||
return Ok(None);
|
||||
}
|
||||
read_applied(&meta)
|
||||
}
|
||||
pub fn created_head(&self, object: &ObjectIdentity) -> Result<Option<[u8; 16]>, StoreError> {
|
||||
let tx = self.database.begin_read()?;
|
||||
let t = tx.open_table(HEADS)?;
|
||||
t.get(object_bytes(object)?.as_slice())?
|
||||
.map(|v| {
|
||||
let mut d = Decoder::new(v.value());
|
||||
let id = d.uuid()?;
|
||||
d.finish()?;
|
||||
Ok(id)
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
pub fn created_version(&self, object: &ObjectIdentity, version: [u8; 16]) -> Result<Option<CreatedVersion>, StoreError> {
|
||||
let tx = self.database.begin_read()?;
|
||||
let t = tx.open_table(VERSIONS)?;
|
||||
t.get(version_key(object, version)?.as_slice())?
|
||||
.map(|v| decode_version(v.value()))
|
||||
.transpose()
|
||||
}
|
||||
pub fn created_event(&self, operation: [u8; 16]) -> Result<Option<CreatedEvent>, StoreError> {
|
||||
let tx = self.database.begin_read()?;
|
||||
let t = tx.open_table(EVENTS)?;
|
||||
t.get(operation.as_slice())?.map(|v| decode_event(v.value())).transpose()
|
||||
}
|
||||
pub fn created_operation(&self, operation: [u8; 16]) -> Result<Option<CreatedOperation>, StoreError> {
|
||||
let tx = self.database.begin_read()?;
|
||||
let t = tx.open_table(OPERATIONS)?;
|
||||
t.get(operation.as_slice())?.map(|v| decode_operation(v.value())).transpose()
|
||||
}
|
||||
pub fn created_apply_result(&self, index: u64) -> Result<Option<CreatedApplyResult>, StoreError> {
|
||||
let tx = self.database.begin_read()?;
|
||||
let t = tx.open_table(APPLY_RESULTS)?;
|
||||
if number(required(tx.open_table(META)?.get("schema")?)?.value())? == 4 {
|
||||
t.get(index)?
|
||||
.map(|v| {
|
||||
decode_response(v.value()).map(|r| {
|
||||
r.created_result().cloned().map(|result| CreatedApplyResult {
|
||||
log_id: *r.log_id(),
|
||||
result,
|
||||
})
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
.map(Option::flatten)
|
||||
} else {
|
||||
t.get(index)?.map(|v| decode_apply(v.value())).transpose()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn validate_application(tx: &ReadTransaction) -> Result<(), StoreError> {
|
||||
let meta = tx.open_table(META)?;
|
||||
let schema = number(required(meta.get("schema")?)?.value())?;
|
||||
let native = schema == 4;
|
||||
if !matches!(schema, 3 | 4) {
|
||||
return Ok(());
|
||||
}
|
||||
let binding = read_binding(&meta).map_err(|error| match error {
|
||||
StoreError::BindingNotPinned => StoreError::CorruptRecord,
|
||||
other => other,
|
||||
})?;
|
||||
let last = read_applied(&meta)?;
|
||||
let logs = tx.open_table(LOGS)?;
|
||||
let committed = validate_committed(&meta, &logs)?;
|
||||
let heads = tx.open_table(HEADS)?;
|
||||
let versions = tx.open_table(VERSIONS)?;
|
||||
let events = tx.open_table(EVENTS)?;
|
||||
let operations = tx.open_table(OPERATIONS)?;
|
||||
let results = tx.open_table(APPLY_RESULTS)?;
|
||||
if last.is_some_and(|last| committed.is_none_or(|c| last.index > c.index)) {
|
||||
return Err(StoreError::CorruptRecord);
|
||||
}
|
||||
let mut next = Some(0);
|
||||
let mut observed = None;
|
||||
let mut first_operations = BTreeMap::new();
|
||||
let mut expected_membership = crate::CaptureMembership::new(None, vec![], BTreeMap::new())?;
|
||||
let mut replay_operations = BTreeMap::<[u8; 16], (Vec<u8>, CreatedResult)>::new();
|
||||
let mut replay_heads = BTreeMap::<Vec<u8>, [u8; 16]>::new();
|
||||
let mut replay_versions = std::collections::BTreeSet::new();
|
||||
for row in results.iter()? {
|
||||
let (k, v) = row?;
|
||||
let entry = decode_log(required(logs.get(k.value())?)?.value())?;
|
||||
let r = if native {
|
||||
let response = decode_response(v.value())?;
|
||||
let native_entry = decode_native_entry(entry.clone())?;
|
||||
validate_response_kind(&native_entry, &response)?;
|
||||
if next != Some(k.value()) || response.log_id().index != k.value() {
|
||||
return Err(StoreError::CorruptRecord);
|
||||
}
|
||||
observed = Some(*response.log_id());
|
||||
next = k.value().checked_add(1);
|
||||
match native_entry.payload {
|
||||
openraft::EntryPayload::Membership(membership) => {
|
||||
expected_membership =
|
||||
crate::CaptureMembership::try_from(&openraft::StoredMembership::new(Some(entry.id.into()), membership))?;
|
||||
continue;
|
||||
}
|
||||
openraft::EntryPayload::Blank => continue,
|
||||
openraft::EntryPayload::Normal(_) => CreatedApplyResult {
|
||||
log_id: *response.log_id(),
|
||||
result: response.created_result().cloned().ok_or(StoreError::CorruptRecord)?,
|
||||
},
|
||||
}
|
||||
} else {
|
||||
decode_apply(v.value())?
|
||||
};
|
||||
if !native && (next != Some(k.value()) || r.log_id.index != k.value()) {
|
||||
return Err(StoreError::CorruptRecord);
|
||||
}
|
||||
if entry.id != r.log_id {
|
||||
return Err(StoreError::CorruptRecord);
|
||||
}
|
||||
let c = entry_command(&entry, native)?;
|
||||
let canonical = c.encode()?;
|
||||
let op = decode_operation(required(operations.get(c.operation_id.as_slice())?)?.value())?;
|
||||
let canonical_matches = op.canonical_command == canonical;
|
||||
let expected = if canonical_matches {
|
||||
op.result
|
||||
} else {
|
||||
CreatedResult::OperationConflict
|
||||
};
|
||||
if native {
|
||||
let replay = if let Some((previous, result)) = replay_operations.get(&c.operation_id) {
|
||||
if *previous == canonical {
|
||||
result.clone()
|
||||
} else {
|
||||
CreatedResult::OperationConflict
|
||||
}
|
||||
} else {
|
||||
let object = object_bytes(&c.object)?;
|
||||
let key = version_key(&c.object, c.prepared.version_id)?;
|
||||
let actual = replay_heads.get(&object).copied();
|
||||
let result = if c.binding_revision != binding.revision {
|
||||
CreatedResult::BindingMismatch
|
||||
} else if actual != c.expected_head {
|
||||
CreatedResult::HeadMismatch { actual_head: actual }
|
||||
} else if replay_versions.contains(&key) {
|
||||
CreatedResult::VersionConflict
|
||||
} else {
|
||||
replay_heads.insert(object, c.prepared.version_id);
|
||||
replay_versions.insert(key);
|
||||
CreatedResult::Created {
|
||||
version_id: c.prepared.version_id,
|
||||
event_id: c.operation_id,
|
||||
decision_log_id: r.log_id,
|
||||
}
|
||||
};
|
||||
replay_operations.insert(c.operation_id, (canonical.clone(), result.clone()));
|
||||
result
|
||||
};
|
||||
if replay != r.result {
|
||||
return Err(StoreError::CorruptRecord);
|
||||
}
|
||||
}
|
||||
if expected != r.result {
|
||||
return Err(StoreError::CorruptRecord);
|
||||
}
|
||||
if canonical_matches {
|
||||
first_operations.entry(c.operation_id).or_insert(r.log_id);
|
||||
}
|
||||
observed = Some(r.log_id);
|
||||
next = k.value().checked_add(1)
|
||||
}
|
||||
if observed != last {
|
||||
return Err(StoreError::CorruptRecord);
|
||||
}
|
||||
if native {
|
||||
if native_application_state(tx)?.1 != expected_membership
|
||||
|| heads.len()? != u64::try_from(replay_heads.len()).map_err(|_| StoreError::CorruptRecord)?
|
||||
|| versions.len()? != u64::try_from(replay_versions.len()).map_err(|_| StoreError::CorruptRecord)?
|
||||
{
|
||||
return Err(StoreError::CorruptRecord);
|
||||
}
|
||||
for (object, id) in replay_heads {
|
||||
if required(heads.get(object.as_slice())?)?.value() != id {
|
||||
return Err(StoreError::CorruptRecord);
|
||||
}
|
||||
}
|
||||
}
|
||||
for row in operations.iter()? {
|
||||
let (k, v) = row?;
|
||||
let op = decode_operation(v.value())?;
|
||||
let c = DecideCreated::decode(&op.canonical_command)?;
|
||||
if k.value() != c.operation_id {
|
||||
return Err(StoreError::CorruptRecord);
|
||||
}
|
||||
let first = required(first_operations.get(&c.operation_id))?;
|
||||
if let CreatedResult::Created {
|
||||
version_id,
|
||||
event_id,
|
||||
decision_log_id,
|
||||
} = op.result
|
||||
{
|
||||
if version_id != c.prepared.version_id
|
||||
|| event_id != c.operation_id
|
||||
|| c.binding_revision != binding.revision
|
||||
|| decision_log_id != *first
|
||||
{
|
||||
return Err(StoreError::CorruptRecord);
|
||||
}
|
||||
let event = decode_event(required(events.get(event_id.as_slice())?)?.value())?;
|
||||
if event
|
||||
!= (CreatedEvent {
|
||||
object: c.object.clone(),
|
||||
prepared: c.prepared.clone(),
|
||||
decision_log_id,
|
||||
binding: binding.clone(),
|
||||
})
|
||||
{
|
||||
return Err(StoreError::CorruptRecord);
|
||||
}
|
||||
} else if events.get(c.operation_id.as_slice())?.is_some() {
|
||||
return Err(StoreError::CorruptRecord);
|
||||
}
|
||||
}
|
||||
for row in events.iter()? {
|
||||
let (k, v) = row?;
|
||||
let e = decode_event(v.value())?;
|
||||
if e.binding != binding {
|
||||
return Err(StoreError::CorruptRecord);
|
||||
}
|
||||
let op = decode_operation(required(operations.get(k.value())?)?.value())?;
|
||||
let c = DecideCreated::decode(&op.canonical_command)?;
|
||||
if c.object != e.object
|
||||
|| c.prepared != e.prepared
|
||||
|| op.result
|
||||
!= (CreatedResult::Created {
|
||||
version_id: e.prepared.version_id,
|
||||
event_id: c.operation_id,
|
||||
decision_log_id: e.decision_log_id,
|
||||
})
|
||||
{
|
||||
return Err(StoreError::CorruptRecord);
|
||||
}
|
||||
let version =
|
||||
decode_version(required(versions.get(version_key(&e.object, e.prepared.version_id)?.as_slice())?)?.value())?;
|
||||
if version
|
||||
!= (CreatedVersion {
|
||||
prepared: e.prepared.clone(),
|
||||
decision_log_id: e.decision_log_id,
|
||||
binding_revision: binding.revision,
|
||||
})
|
||||
{
|
||||
return Err(StoreError::CorruptRecord);
|
||||
}
|
||||
}
|
||||
for row in versions.iter()? {
|
||||
let (k, v) = row?;
|
||||
let version = decode_version(v.value())?;
|
||||
let mut d = Decoder::new(k.value());
|
||||
let object = d.object()?;
|
||||
let id = d.uuid()?;
|
||||
d.finish()?;
|
||||
if id != version.prepared.version_id || version.binding_revision != binding.revision {
|
||||
return Err(StoreError::CorruptRecord);
|
||||
}
|
||||
let entry = decode_log(required(logs.get(version.decision_log_id.index)?)?.value())?;
|
||||
let c = entry_command(&entry, native)?;
|
||||
if entry.id != version.decision_log_id
|
||||
|| c.object != object
|
||||
|| c.prepared != version.prepared
|
||||
|| events.get(c.operation_id.as_slice())?.is_none()
|
||||
{
|
||||
return Err(StoreError::CorruptRecord);
|
||||
}
|
||||
let head = required(heads.get(object_bytes(&object)?.as_slice())?)?;
|
||||
let mut d = Decoder::new(head.value());
|
||||
let head_id = d.uuid()?;
|
||||
d.finish()?;
|
||||
let current = decode_version(required(versions.get(version_key(&object, head_id)?.as_slice())?)?.value())?;
|
||||
if current.decision_log_id.index < version.decision_log_id.index {
|
||||
return Err(StoreError::CorruptRecord);
|
||||
}
|
||||
}
|
||||
for row in heads.iter()? {
|
||||
let (k, v) = row?;
|
||||
let mut d = Decoder::new(k.value());
|
||||
let object = d.object()?;
|
||||
d.finish()?;
|
||||
let mut d = Decoder::new(v.value());
|
||||
let id = d.uuid()?;
|
||||
d.finish()?;
|
||||
decode_version(required(versions.get(version_key(&object, id)?.as_slice())?)?.value())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,531 +0,0 @@
|
||||
use super::*;
|
||||
use crate::application::{APPLY_RESULTS, EVENTS, OPERATIONS, VERSIONS};
|
||||
use crate::tests::CrashBackend;
|
||||
use redb::ReadableTableMetadata;
|
||||
use std::{
|
||||
io::{BufRead, BufReader, Write},
|
||||
process::{Command, Stdio},
|
||||
sync::mpsc,
|
||||
time::Duration,
|
||||
};
|
||||
fn identity() -> NodeIdentity {
|
||||
NodeIdentity {
|
||||
cluster_id: [7; 16],
|
||||
node_id: 0,
|
||||
}
|
||||
}
|
||||
fn binding() -> CaptureBinding {
|
||||
CaptureBinding {
|
||||
revision: 9,
|
||||
binding_id: [8; 16],
|
||||
target_id: [9; 16],
|
||||
}
|
||||
}
|
||||
fn command() -> DecideCreated {
|
||||
DecideCreated {
|
||||
operation_id: [1; 16],
|
||||
object: ObjectIdentity {
|
||||
bucket_incarnation: [2; 16],
|
||||
key: vec![0, 255, 3],
|
||||
},
|
||||
expected_head: None,
|
||||
prepared: PreparedIdentity {
|
||||
version_id: [3; 16],
|
||||
preparation_id: [4; 16],
|
||||
content_digest: [5; 32],
|
||||
content_length: 123,
|
||||
},
|
||||
binding_revision: 9,
|
||||
}
|
||||
}
|
||||
fn id(index: u64) -> LogId {
|
||||
LogId {
|
||||
term: 8,
|
||||
leader_node: 0,
|
||||
index,
|
||||
}
|
||||
}
|
||||
fn append(store: &mut Store, index: u64, c: &DecideCreated) {
|
||||
store
|
||||
.append(&[LogEntry {
|
||||
id: id(index),
|
||||
payload: c.encode().expect("encode command"),
|
||||
}])
|
||||
.expect("append command");
|
||||
}
|
||||
fn apply(store: &mut Store, index: u64, c: &DecideCreated) -> CreatedResult {
|
||||
append(store, index, c);
|
||||
store.save_committed(id(index)).expect("commit log");
|
||||
store.apply_next_created(id(index)).expect("apply command")
|
||||
}
|
||||
fn success(c: &DecideCreated, index: u64) -> CreatedResult {
|
||||
CreatedResult::Created {
|
||||
version_id: c.prepared.version_id,
|
||||
event_id: c.operation_id,
|
||||
decision_log_id: id(index),
|
||||
}
|
||||
}
|
||||
fn complete(store: &Store) {
|
||||
let c = command();
|
||||
assert_eq!(store.capture_binding().expect("binding"), Some(binding()));
|
||||
assert_eq!(
|
||||
store.created_head(&c.object).expect("head"),
|
||||
Some(c.prepared.version_id),
|
||||
"U11 acknowledged head lost"
|
||||
);
|
||||
assert_eq!(
|
||||
store.created_version(&c.object, c.prepared.version_id).expect("version"),
|
||||
Some(CreatedVersion {
|
||||
prepared: c.prepared.clone(),
|
||||
decision_log_id: id(0),
|
||||
binding_revision: 9
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
store.created_event(c.operation_id).expect("event"),
|
||||
Some(CreatedEvent {
|
||||
object: c.object.clone(),
|
||||
prepared: c.prepared.clone(),
|
||||
decision_log_id: id(0),
|
||||
binding: binding()
|
||||
}),
|
||||
"U11 complete event missing"
|
||||
);
|
||||
assert_eq!(
|
||||
store.created_operation(c.operation_id).expect("operation"),
|
||||
Some(CreatedOperation {
|
||||
canonical_command: c.encode().expect("encode"),
|
||||
result: success(&c, 0)
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
store.created_apply_result(0).expect("result"),
|
||||
Some(CreatedApplyResult {
|
||||
log_id: id(0),
|
||||
result: success(&c, 0)
|
||||
})
|
||||
);
|
||||
assert_eq!(store.last_applied().expect("last applied"), Some(id(0)));
|
||||
}
|
||||
fn fixture() -> (tempfile::TempDir, Store) {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let mut store = Store::initialize(dir.path().join("db"), identity()).expect("initialize");
|
||||
store.pin_capture_binding(binding()).expect("pin");
|
||||
(dir, store)
|
||||
}
|
||||
fn reopen(path: &std::path::Path) -> Store {
|
||||
let result = Store::open(path, identity());
|
||||
assert!(result.is_ok(), "U11 complete reopen: {:?}", result.as_ref().err());
|
||||
result.expect("asserted successful reopen")
|
||||
}
|
||||
#[test]
|
||||
fn created_codec_is_canonical_and_strict() {
|
||||
let c = command();
|
||||
let b = c.encode().expect("encode");
|
||||
let mut golden = b"CRTD\x01".to_vec();
|
||||
golden.extend([1; 16]);
|
||||
golden.extend([2; 16]);
|
||||
golden.extend(3u32.to_be_bytes());
|
||||
golden.extend([0, 255, 3]);
|
||||
golden.push(0);
|
||||
golden.extend([3; 16]);
|
||||
golden.extend([4; 16]);
|
||||
golden.extend([5; 32]);
|
||||
golden.extend(123u64.to_be_bytes());
|
||||
golden.extend(9u64.to_be_bytes());
|
||||
assert_eq!(b, golden);
|
||||
assert_eq!(DecideCreated::decode(&b).expect("decode"), c);
|
||||
let mut variants = Vec::new();
|
||||
let mut v = c.clone();
|
||||
v.operation_id = [6; 16];
|
||||
variants.push(v);
|
||||
let mut v = c.clone();
|
||||
v.object.bucket_incarnation = [6; 16];
|
||||
variants.push(v);
|
||||
let mut v = c.clone();
|
||||
v.object.key.push(4);
|
||||
variants.push(v);
|
||||
let mut v = c.clone();
|
||||
v.expected_head = Some([6; 16]);
|
||||
variants.push(v);
|
||||
let mut v = c.clone();
|
||||
v.prepared.version_id = [6; 16];
|
||||
variants.push(v);
|
||||
let mut v = c.clone();
|
||||
v.prepared.preparation_id = [6; 16];
|
||||
variants.push(v);
|
||||
let mut v = c.clone();
|
||||
v.prepared.content_digest = [6; 32];
|
||||
variants.push(v);
|
||||
let mut v = c.clone();
|
||||
v.prepared.content_length += 1;
|
||||
variants.push(v);
|
||||
let mut v = c.clone();
|
||||
v.binding_revision += 1;
|
||||
variants.push(v);
|
||||
for v in variants {
|
||||
assert_ne!(v.encode().expect("variant"), b)
|
||||
}
|
||||
for n in 0..b.len() {
|
||||
assert!(DecideCreated::decode(&b[..n]).is_err())
|
||||
}
|
||||
let mut bad = b.clone();
|
||||
bad[4] = 2;
|
||||
assert!(DecideCreated::decode(&bad).is_err());
|
||||
let mut bad = b.clone();
|
||||
bad[44] = 2;
|
||||
assert!(DecideCreated::decode(&bad).is_err());
|
||||
let mut bad = b.clone();
|
||||
bad.push(0);
|
||||
assert!(DecideCreated::decode(&bad).is_err());
|
||||
let mut bad = b;
|
||||
bad[37..41].copy_from_slice(&4097u32.to_be_bytes());
|
||||
assert!(DecideCreated::decode(&bad).is_err());
|
||||
let mut bad = c.clone();
|
||||
bad.operation_id = [0; 16];
|
||||
assert!(bad.encode().is_err());
|
||||
bad = c;
|
||||
bad.object.key = vec![0; MAX_OBJECT_KEY_LENGTH + 1];
|
||||
assert!(bad.encode().is_err());
|
||||
}
|
||||
#[test]
|
||||
fn created_committed_order_and_identity_are_enforced() {
|
||||
let (_dir, mut s) = fixture();
|
||||
let c = command();
|
||||
append(&mut s, 0, &c);
|
||||
append(&mut s, 1, &c);
|
||||
assert!(s.apply_next_created(id(0)).is_err());
|
||||
assert_eq!(s.last_applied().expect("no apply"), None);
|
||||
s.save_committed(id(1)).expect("committed");
|
||||
assert!(matches!(s.apply_next_created(id(1)), Err(StoreError::InvalidApplySequence)));
|
||||
let mut wrong = id(0);
|
||||
wrong.term += 1;
|
||||
assert!(s.apply_next_created(wrong).is_err());
|
||||
wrong = id(0);
|
||||
wrong.leader_node += 1;
|
||||
assert!(s.apply_next_created(wrong).is_err());
|
||||
assert_eq!(s.apply_next_created(id(0)).expect("first"), success(&c, 0));
|
||||
complete(&s);
|
||||
assert_eq!(s.apply_next_created(id(0)).expect("retry"), success(&c, 0));
|
||||
complete(&s);
|
||||
assert!(s.apply_next_created(wrong).is_err());
|
||||
assert_eq!(s.apply_next_created(id(1)).expect("duplicate log"), success(&c, 0));
|
||||
assert_eq!(s.last_applied().expect("last"), Some(id(1)));
|
||||
assert_eq!(
|
||||
s.created_apply_result(1).expect("result"),
|
||||
Some(CreatedApplyResult {
|
||||
log_id: id(1),
|
||||
result: success(&c, 0)
|
||||
})
|
||||
);
|
||||
let tx = s.database.begin_read().expect("read");
|
||||
assert_eq!(tx.open_table(EVENTS).expect("events").len().expect("len"), 1);
|
||||
}
|
||||
#[test]
|
||||
fn created_head_version_event_and_dedup_reopen_together() {
|
||||
let (dir, mut s) = fixture();
|
||||
let c = command();
|
||||
assert_eq!(apply(&mut s, 0, &c), success(&c, 0));
|
||||
drop(s);
|
||||
let mut s = reopen(&dir.path().join("db"));
|
||||
complete(&s);
|
||||
assert_eq!(apply(&mut s, 1, &c), success(&c, 0));
|
||||
let mut changed = c.clone();
|
||||
changed.prepared.content_digest = [6; 32];
|
||||
assert_eq!(
|
||||
apply(&mut s, 2, &changed),
|
||||
CreatedResult::OperationConflict,
|
||||
"U11 full canonical operation conflict"
|
||||
);
|
||||
assert_eq!(
|
||||
s.created_operation(c.operation_id)
|
||||
.expect("original")
|
||||
.expect("operation")
|
||||
.result,
|
||||
success(&c, 0)
|
||||
);
|
||||
let mut stale = c.clone();
|
||||
stale.operation_id = [6; 16];
|
||||
stale.prepared.version_id = [6; 16];
|
||||
assert_eq!(
|
||||
apply(&mut s, 3, &stale),
|
||||
CreatedResult::HeadMismatch {
|
||||
actual_head: Some(c.prepared.version_id)
|
||||
}
|
||||
);
|
||||
let mut reuse = c.clone();
|
||||
reuse.operation_id = [7; 16];
|
||||
reuse.expected_head = Some(c.prepared.version_id);
|
||||
assert_eq!(apply(&mut s, 4, &reuse), CreatedResult::VersionConflict);
|
||||
let mut replacement = stale.clone();
|
||||
replacement.operation_id = [10; 16];
|
||||
replacement.expected_head = Some(c.prepared.version_id);
|
||||
assert_eq!(apply(&mut s, 5, &replacement), success(&replacement, 5));
|
||||
assert_eq!(s.created_head(&c.object).expect("head"), Some(replacement.prepared.version_id));
|
||||
assert_eq!(
|
||||
s.created_version(&c.object, c.prepared.version_id)
|
||||
.expect("old version")
|
||||
.expect("version")
|
||||
.decision_log_id,
|
||||
id(0)
|
||||
);
|
||||
assert!(s.created_event(c.operation_id).expect("old event").is_some());
|
||||
assert!(s.created_event(replacement.operation_id).expect("new event").is_some());
|
||||
assert!(s.created_event(stale.operation_id).expect("rejection event").is_none());
|
||||
assert_eq!(
|
||||
apply(&mut s, 6, &stale),
|
||||
CreatedResult::HeadMismatch {
|
||||
actual_head: Some(c.prepared.version_id)
|
||||
}
|
||||
);
|
||||
drop(s);
|
||||
let s = reopen(&dir.path().join("db"));
|
||||
assert_eq!(s.last_applied().expect("last"), Some(id(6)));
|
||||
let tx = s.database.begin_read().expect("read");
|
||||
assert_eq!(tx.open_table(EVENTS).expect("events").len().expect("len"), 2);
|
||||
}
|
||||
#[test]
|
||||
fn created_binding_is_pinned_and_not_caller_selected() {
|
||||
let (dir, mut s) = fixture();
|
||||
let mut bad = command();
|
||||
bad.binding_revision = 10;
|
||||
assert_eq!(apply(&mut s, 0, &bad), CreatedResult::BindingMismatch);
|
||||
assert!(s.created_head(&bad.object).expect("head").is_none());
|
||||
assert!(s.created_event(bad.operation_id).expect("event").is_none());
|
||||
assert_eq!(
|
||||
s.created_operation(bad.operation_id)
|
||||
.expect("operation")
|
||||
.expect("saved")
|
||||
.result,
|
||||
CreatedResult::BindingMismatch
|
||||
);
|
||||
s.pin_capture_binding(binding()).expect("idempotent pin");
|
||||
let mut changed = binding();
|
||||
changed.target_id = [10; 16];
|
||||
assert!(matches!(s.pin_capture_binding(changed), Err(StoreError::BindingConflict)));
|
||||
drop(s);
|
||||
let s = reopen(&dir.path().join("db"));
|
||||
assert_eq!(s.capture_binding().expect("binding"), Some(binding()));
|
||||
}
|
||||
#[test]
|
||||
fn created_schema_compatibility_and_corruption_are_checked() {
|
||||
let dir = tempfile::tempdir().expect("dir");
|
||||
let path = dir.path().join("db");
|
||||
let s = Store::initialize(&path, identity()).expect("schema1");
|
||||
assert_eq!(s.capture_binding().expect("unmigrated"), None);
|
||||
drop(s);
|
||||
let mut s = reopen(&path);
|
||||
assert_eq!(s.last_applied().expect("no apply"), None);
|
||||
append(&mut s, 0, &command());
|
||||
s.save_committed(id(0)).expect("schema2");
|
||||
drop(s);
|
||||
let mut s = reopen(&path);
|
||||
assert_eq!(s.capture_binding().expect("schema2 unchanged"), None);
|
||||
s.pin_capture_binding(binding()).expect("explicit upgrade");
|
||||
drop(s);
|
||||
let mut s = reopen(&path);
|
||||
assert_eq!(s.capture_binding().expect("schema3"), Some(binding()));
|
||||
s.apply_next_created(id(0)).expect("apply");
|
||||
drop(s);
|
||||
let db = Database::open(&path).expect("raw db");
|
||||
let tx = db.begin_write().expect("write corruption");
|
||||
tx.open_table(EVENTS)
|
||||
.expect("events")
|
||||
.remove(command().operation_id.as_slice())
|
||||
.expect("remove reference");
|
||||
tx.commit().expect("commit corruption");
|
||||
drop(db);
|
||||
let result = Store::open(&path, identity());
|
||||
assert!(
|
||||
matches!(result, Err(StoreError::CorruptRecord)),
|
||||
"U11 missing event rejected before expect"
|
||||
);
|
||||
let (dir, mut s) = fixture();
|
||||
assert_eq!(s.read_committed().expect("empty committed"), None);
|
||||
append(&mut s, 0, &command());
|
||||
s.save_committed(id(0)).expect("committed schema3");
|
||||
assert_eq!(s.capture_binding().expect("no downgrade"), Some(binding()));
|
||||
drop(s);
|
||||
let path = dir.path().join("db");
|
||||
let db = Database::open(&path).expect("raw db");
|
||||
let tx = db.begin_write().expect("write");
|
||||
let mut marker = vec![1];
|
||||
marker.extend(encode_id(id(0)));
|
||||
tx.open_table(META)
|
||||
.expect("meta")
|
||||
.insert("last_applied", marker.as_slice())
|
||||
.expect("corrupt applied");
|
||||
tx.commit().expect("commit");
|
||||
drop(db);
|
||||
assert!(
|
||||
matches!(Store::open(&path, identity()), Err(StoreError::CorruptRecord)),
|
||||
"U11 last applied missing result rejected"
|
||||
);
|
||||
let (dir, s) = fixture();
|
||||
drop(s);
|
||||
let path = dir.path().join("db");
|
||||
let db = Database::open(&path).expect("raw pinned db");
|
||||
let tx = db.begin_write().expect("missing binding write");
|
||||
tx.open_table(META)
|
||||
.expect("meta")
|
||||
.remove("capture_binding")
|
||||
.expect("remove binding");
|
||||
tx.commit().expect("binding corruption commit");
|
||||
drop(db);
|
||||
assert!(
|
||||
matches!(Store::open(&path, identity()), Err(StoreError::CorruptRecord)),
|
||||
"U11 missing pinned binding rejected"
|
||||
);
|
||||
|
||||
let (dir, mut s) = fixture();
|
||||
let c = command();
|
||||
assert_eq!(apply(&mut s, 0, &c), success(&c, 0));
|
||||
assert_eq!(apply(&mut s, 1, &c), success(&c, 0));
|
||||
drop(s);
|
||||
let path = dir.path().join("db");
|
||||
let db = Database::open(&path).expect("raw duplicate db");
|
||||
let tx = db.begin_write().expect("retarget decision write");
|
||||
let mut key = created::object_bytes(&c.object).expect("object key");
|
||||
key.extend(c.prepared.version_id);
|
||||
let mut version = Vec::new();
|
||||
created::put_prepared(&mut version, &c.prepared);
|
||||
version.extend(encode_id(id(1)));
|
||||
version.extend(binding().revision.to_be_bytes());
|
||||
tx.open_table(VERSIONS)
|
||||
.expect("versions")
|
||||
.insert(key.as_slice(), version.as_slice())
|
||||
.expect("retarget version");
|
||||
let mut event = created::object_bytes(&c.object).expect("event object");
|
||||
created::put_prepared(&mut event, &c.prepared);
|
||||
event.extend(encode_id(id(1)));
|
||||
event.extend(created::binding_bytes(&binding()).expect("binding bytes"));
|
||||
tx.open_table(EVENTS)
|
||||
.expect("events")
|
||||
.insert(c.operation_id.as_slice(), event.as_slice())
|
||||
.expect("retarget event");
|
||||
let canonical = c.encode().expect("canonical");
|
||||
let mut operation = u64::try_from(canonical.len()).expect("length").to_be_bytes().to_vec();
|
||||
operation.extend(canonical);
|
||||
operation.extend(created::result_bytes(&success(&c, 1)));
|
||||
tx.open_table(OPERATIONS)
|
||||
.expect("operations")
|
||||
.insert(c.operation_id.as_slice(), operation.as_slice())
|
||||
.expect("retarget original response");
|
||||
for index in 0..=1 {
|
||||
let mut result = encode_id(id(index));
|
||||
result.extend(created::result_bytes(&success(&c, 1)));
|
||||
tx.open_table(APPLY_RESULTS)
|
||||
.expect("results")
|
||||
.insert(index, result.as_slice())
|
||||
.expect("retarget duplicate response");
|
||||
}
|
||||
tx.commit().expect("coherent retarget commit");
|
||||
drop(db);
|
||||
assert!(
|
||||
matches!(Store::open(&path, identity()), Err(StoreError::CorruptRecord)),
|
||||
"U11 original decision cannot retarget duplicate log"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn created_process_child() {
|
||||
if let Some(path) = std::env::var_os("U11_CHILD_DB") {
|
||||
let mut s = Store::initialize(path, identity()).expect("child init");
|
||||
s.pin_capture_binding(binding()).expect("child pin");
|
||||
assert_eq!(apply(&mut s, 0, &command()), success(&command(), 0));
|
||||
complete(&s);
|
||||
println!("U11_ACK");
|
||||
std::io::stdout().flush().expect("flush ACK");
|
||||
loop {
|
||||
std::thread::park()
|
||||
}
|
||||
}
|
||||
let (_dir, mut s) = fixture();
|
||||
assert_eq!(apply(&mut s, 0, &command()), success(&command(), 0));
|
||||
complete(&s);
|
||||
}
|
||||
#[test]
|
||||
fn created_acknowledged_apply_survives_child_kill() {
|
||||
let dir = tempfile::tempdir().expect("dir");
|
||||
let path = dir.path().join("db");
|
||||
let mut child = Command::new(std::env::current_exe().expect("test executable"))
|
||||
.args(["--exact", "application_tests::created_process_child", "--nocapture"])
|
||||
.env("U11_CHILD_DB", &path)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::inherit())
|
||||
.spawn()
|
||||
.expect("spawn");
|
||||
let stdout = child.stdout.take().expect("stdout");
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let reader = std::thread::spawn(move || {
|
||||
for line in BufReader::new(stdout).lines() {
|
||||
let line = line.expect("child line");
|
||||
if line.ends_with("U11_ACK") {
|
||||
let _ = tx.send(());
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
let acknowledged = rx.recv_timeout(Duration::from_secs(20));
|
||||
child.kill().expect("kill child");
|
||||
child.wait().expect("wait child");
|
||||
reader.join().expect("reader cleanup");
|
||||
assert!(acknowledged.is_ok(), "U11 bounded child ACK missing");
|
||||
complete(&reopen(&path));
|
||||
}
|
||||
#[test]
|
||||
fn created_power_cut_before_and_after_apply_flush() {
|
||||
let (dir, mut s) = fixture();
|
||||
append(&mut s, 0, &command());
|
||||
s.save_committed(id(0)).expect("committed");
|
||||
drop(s);
|
||||
let path = dir.path().join("db");
|
||||
let backend = CrashBackend::new(&path);
|
||||
let mut s = Store::from_database(backend.database(), identity()).expect("backend");
|
||||
assert_eq!(s.apply_next_created(id(0)).expect("apply ACK"), success(&command(), 0));
|
||||
backend.cut();
|
||||
drop(s);
|
||||
complete(&reopen(&path));
|
||||
for sync in 1..=2 {
|
||||
for after in [false, true] {
|
||||
let (dir, mut s) = fixture();
|
||||
append(&mut s, 0, &command());
|
||||
s.save_committed(id(0)).expect("committed");
|
||||
drop(s);
|
||||
let path = dir.path().join("db");
|
||||
let backend = CrashBackend::new(&path);
|
||||
let mut s = Store::from_database(backend.database(), identity()).expect("backend");
|
||||
let (entered, resume) = backend.pause_sync(sync, after);
|
||||
let writer = std::thread::spawn(move || {
|
||||
let result = s.apply_next_created(id(0));
|
||||
(s, result)
|
||||
});
|
||||
entered.wait();
|
||||
backend.cut();
|
||||
resume.wait();
|
||||
let (s, result) = writer.join().expect("writer");
|
||||
drop(s);
|
||||
let reopened = Store::open(&path, identity());
|
||||
assert!(
|
||||
reopened.is_ok(),
|
||||
"U11 apply cut must reopen whole old/new state sync={sync} after={after}: {:?}",
|
||||
reopened.as_ref().err()
|
||||
);
|
||||
let s = reopened.expect("asserted reopen");
|
||||
if s.last_applied().expect("last").is_some() {
|
||||
complete(&s)
|
||||
} else {
|
||||
assert!(result.is_err());
|
||||
assert_eq!(s.created_head(&command().object).expect("old head"), None);
|
||||
assert_eq!(
|
||||
s.created_version(&command().object, command().prepared.version_id)
|
||||
.expect("old version"),
|
||||
None
|
||||
);
|
||||
assert_eq!(s.created_event(command().operation_id).expect("old event"), None);
|
||||
assert_eq!(s.created_operation(command().operation_id).expect("old op"), None);
|
||||
assert_eq!(s.created_apply_result(0).expect("old result"), None)
|
||||
}
|
||||
eprintln!("U11 cut boundary sync={sync} after={after}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
use crate::{LOGS, LogId, META, Store, StoreError, decode_id, decode_log, encode_id, number};
|
||||
use redb::{ReadableDatabase, ReadableTable};
|
||||
|
||||
pub(crate) fn validate_committed(
|
||||
meta: &impl ReadableTable<&'static str, &'static [u8]>,
|
||||
logs: &impl ReadableTable<u64, &'static [u8]>,
|
||||
) -> Result<Option<LogId>, StoreError> {
|
||||
let schema = number(meta.get("schema")?.ok_or(StoreError::CorruptRecord)?.value())?;
|
||||
let marker = meta.get("committed")?;
|
||||
match (schema, marker) {
|
||||
(1 | 3 | 4, None) => {
|
||||
if matches!(schema, 3 | 4) && meta.get("last_applied")?.is_none_or(|v| v.value() != [0]) {
|
||||
return Err(StoreError::CorruptRecord);
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
(2 | 3 | 4, Some(marker)) => {
|
||||
let id = decode_id(marker.value())?;
|
||||
let record = logs.get(id.index)?.ok_or(StoreError::CorruptRecord)?;
|
||||
if decode_log(record.value())?.id != id {
|
||||
return Err(StoreError::CorruptRecord);
|
||||
}
|
||||
Ok(Some(id))
|
||||
}
|
||||
(1 | 2, _) => Err(StoreError::CorruptRecord),
|
||||
_ => Err(StoreError::UnsupportedSchema),
|
||||
}
|
||||
}
|
||||
|
||||
impl Store {
|
||||
/// Read the exact committed boundary, validating it against the stored log.
|
||||
pub fn read_committed(&self) -> Result<Option<LogId>, StoreError> {
|
||||
let tx = self.database.begin_read()?;
|
||||
validate_committed(&tx.open_table(META)?, &tx.open_table(LOGS)?)
|
||||
}
|
||||
|
||||
/// Persist a monotonic committed boundary supplied by the authority owner.
|
||||
pub fn save_committed(&mut self, id: LogId) -> Result<(), StoreError> {
|
||||
let tx = self.write_transaction()?;
|
||||
{
|
||||
let mut meta = tx.open_table(META)?;
|
||||
let logs = tx.open_table(LOGS)?;
|
||||
let previous = validate_committed(&meta, &logs)?;
|
||||
let record = logs.get(id.index)?.ok_or(StoreError::InvalidCommittedLog)?;
|
||||
if decode_log(record.value())?.id != id {
|
||||
return Err(StoreError::InvalidCommittedLog);
|
||||
}
|
||||
if let Some(previous) = previous {
|
||||
if id.index < previous.index {
|
||||
return Err(StoreError::CommittedRegression);
|
||||
}
|
||||
if id == previous {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
meta.insert("committed", encode_id(id).as_slice())?;
|
||||
if previous.is_none() && !matches!(number(meta.get("schema")?.ok_or(StoreError::CorruptRecord)?.value())?, 3 | 4) {
|
||||
meta.insert("schema", 2u64.to_be_bytes().as_slice())?;
|
||||
}
|
||||
}
|
||||
self.commit(tx)
|
||||
}
|
||||
}
|
||||
@@ -1,242 +0,0 @@
|
||||
use crate::{LogId, StoreError, decode_id, encode_id};
|
||||
|
||||
pub const MAX_OBJECT_KEY_LENGTH: usize = 4096;
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ObjectIdentity {
|
||||
pub bucket_incarnation: [u8; 16],
|
||||
pub key: Vec<u8>,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PreparedIdentity {
|
||||
pub version_id: [u8; 16],
|
||||
pub preparation_id: [u8; 16],
|
||||
pub content_digest: [u8; 32],
|
||||
pub content_length: u64,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DecideCreated {
|
||||
pub operation_id: [u8; 16],
|
||||
pub object: ObjectIdentity,
|
||||
pub expected_head: Option<[u8; 16]>,
|
||||
pub prepared: PreparedIdentity,
|
||||
pub binding_revision: u64,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CaptureBinding {
|
||||
pub revision: u64,
|
||||
pub binding_id: [u8; 16],
|
||||
pub target_id: [u8; 16],
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum CreatedResult {
|
||||
Created {
|
||||
version_id: [u8; 16],
|
||||
event_id: [u8; 16],
|
||||
decision_log_id: LogId,
|
||||
},
|
||||
HeadMismatch {
|
||||
actual_head: Option<[u8; 16]>,
|
||||
},
|
||||
OperationConflict,
|
||||
BindingMismatch,
|
||||
VersionConflict,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CreatedVersion {
|
||||
pub prepared: PreparedIdentity,
|
||||
pub decision_log_id: LogId,
|
||||
pub binding_revision: u64,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CreatedEvent {
|
||||
pub object: ObjectIdentity,
|
||||
pub prepared: PreparedIdentity,
|
||||
pub decision_log_id: LogId,
|
||||
pub binding: CaptureBinding,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CreatedOperation {
|
||||
pub canonical_command: Vec<u8>,
|
||||
pub result: CreatedResult,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CreatedApplyResult {
|
||||
pub log_id: LogId,
|
||||
pub result: CreatedResult,
|
||||
}
|
||||
|
||||
pub(crate) struct Decoder<'a> {
|
||||
bytes: &'a [u8],
|
||||
}
|
||||
impl<'a> Decoder<'a> {
|
||||
pub(crate) fn new(bytes: &'a [u8]) -> Self {
|
||||
Self { bytes }
|
||||
}
|
||||
pub(crate) fn take<const N: usize>(&mut self) -> Result<[u8; N], StoreError> {
|
||||
let a = self
|
||||
.bytes
|
||||
.get(..N)
|
||||
.ok_or(StoreError::CorruptRecord)?
|
||||
.try_into()
|
||||
.map_err(|_| StoreError::CorruptRecord)?;
|
||||
self.bytes = &self.bytes[N..];
|
||||
Ok(a)
|
||||
}
|
||||
pub(crate) fn u64(&mut self) -> Result<u64, StoreError> {
|
||||
Ok(u64::from_be_bytes(self.take()?))
|
||||
}
|
||||
pub(crate) fn uuid(&mut self) -> Result<[u8; 16], StoreError> {
|
||||
let id = self.take()?;
|
||||
if id == [0; 16] {
|
||||
return Err(StoreError::CorruptRecord);
|
||||
}
|
||||
Ok(id)
|
||||
}
|
||||
pub(crate) fn option(&mut self) -> Result<Option<[u8; 16]>, StoreError> {
|
||||
match self.take::<1>()?[0] {
|
||||
0 => Ok(None),
|
||||
1 => Ok(Some(self.uuid()?)),
|
||||
_ => Err(StoreError::CorruptRecord),
|
||||
}
|
||||
}
|
||||
pub(crate) fn object(&mut self) -> Result<ObjectIdentity, StoreError> {
|
||||
let bucket_incarnation = self.uuid()?;
|
||||
let len = usize::try_from(u32::from_be_bytes(self.take()?)).map_err(|_| StoreError::CorruptRecord)?;
|
||||
if len > MAX_OBJECT_KEY_LENGTH {
|
||||
return Err(StoreError::CorruptRecord);
|
||||
}
|
||||
let key = self.bytes.get(..len).ok_or(StoreError::CorruptRecord)?.to_vec();
|
||||
self.bytes = &self.bytes[len..];
|
||||
Ok(ObjectIdentity { bucket_incarnation, key })
|
||||
}
|
||||
pub(crate) fn prepared(&mut self) -> Result<PreparedIdentity, StoreError> {
|
||||
Ok(PreparedIdentity {
|
||||
version_id: self.uuid()?,
|
||||
preparation_id: self.uuid()?,
|
||||
content_digest: self.take()?,
|
||||
content_length: self.u64()?,
|
||||
})
|
||||
}
|
||||
pub(crate) fn id(&mut self) -> Result<LogId, StoreError> {
|
||||
decode_id(&self.take::<24>()?)
|
||||
}
|
||||
pub(crate) fn binding(&mut self) -> Result<CaptureBinding, StoreError> {
|
||||
Ok(CaptureBinding {
|
||||
revision: self.u64()?,
|
||||
binding_id: self.uuid()?,
|
||||
target_id: self.uuid()?,
|
||||
})
|
||||
}
|
||||
pub(crate) fn finish(self) -> Result<(), StoreError> {
|
||||
if self.bytes.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(StoreError::CorruptRecord)
|
||||
}
|
||||
}
|
||||
pub(crate) fn result(&mut self) -> Result<CreatedResult, StoreError> {
|
||||
match self.take::<1>()?[0] {
|
||||
0 => Ok(CreatedResult::Created {
|
||||
version_id: self.uuid()?,
|
||||
event_id: self.uuid()?,
|
||||
decision_log_id: self.id()?,
|
||||
}),
|
||||
1 => Ok(CreatedResult::HeadMismatch {
|
||||
actual_head: self.option()?,
|
||||
}),
|
||||
2 => Ok(CreatedResult::OperationConflict),
|
||||
3 => Ok(CreatedResult::BindingMismatch),
|
||||
4 => Ok(CreatedResult::VersionConflict),
|
||||
_ => Err(StoreError::CorruptRecord),
|
||||
}
|
||||
}
|
||||
}
|
||||
pub(crate) fn put_option(b: &mut Vec<u8>, id: Option<[u8; 16]>) {
|
||||
b.push(u8::from(id.is_some()));
|
||||
if let Some(id) = id {
|
||||
b.extend(id)
|
||||
}
|
||||
}
|
||||
pub(crate) fn object_bytes(o: &ObjectIdentity) -> Result<Vec<u8>, StoreError> {
|
||||
if o.bucket_incarnation == [0; 16] || o.key.len() > MAX_OBJECT_KEY_LENGTH {
|
||||
return Err(StoreError::CorruptRecord);
|
||||
}
|
||||
let capacity = 20usize.checked_add(o.key.len()).ok_or(StoreError::CorruptRecord)?;
|
||||
let mut b = Vec::with_capacity(capacity);
|
||||
b.extend(o.bucket_incarnation);
|
||||
b.extend(
|
||||
u32::try_from(o.key.len())
|
||||
.map_err(|_| StoreError::CorruptRecord)?
|
||||
.to_be_bytes(),
|
||||
);
|
||||
b.extend(&o.key);
|
||||
Ok(b)
|
||||
}
|
||||
pub(crate) fn put_prepared(b: &mut Vec<u8>, p: &PreparedIdentity) {
|
||||
b.extend(p.version_id);
|
||||
b.extend(p.preparation_id);
|
||||
b.extend(p.content_digest);
|
||||
b.extend(p.content_length.to_be_bytes())
|
||||
}
|
||||
pub(crate) fn binding_bytes(v: &CaptureBinding) -> Result<Vec<u8>, StoreError> {
|
||||
let mut b = v.revision.to_be_bytes().to_vec();
|
||||
b.extend(v.binding_id);
|
||||
b.extend(v.target_id);
|
||||
let mut d = Decoder::new(&b);
|
||||
d.binding()?;
|
||||
d.finish()?;
|
||||
Ok(b)
|
||||
}
|
||||
pub(crate) fn result_bytes(r: &CreatedResult) -> Vec<u8> {
|
||||
let mut b = Vec::new();
|
||||
match r {
|
||||
CreatedResult::Created {
|
||||
version_id,
|
||||
event_id,
|
||||
decision_log_id,
|
||||
} => {
|
||||
b.push(0);
|
||||
b.extend(version_id);
|
||||
b.extend(event_id);
|
||||
b.extend(encode_id(*decision_log_id))
|
||||
}
|
||||
CreatedResult::HeadMismatch { actual_head } => {
|
||||
b.push(1);
|
||||
put_option(&mut b, *actual_head)
|
||||
}
|
||||
CreatedResult::OperationConflict => b.push(2),
|
||||
CreatedResult::BindingMismatch => b.push(3),
|
||||
CreatedResult::VersionConflict => b.push(4),
|
||||
}
|
||||
b
|
||||
}
|
||||
impl DecideCreated {
|
||||
pub fn encode(&self) -> Result<Vec<u8>, StoreError> {
|
||||
let object = object_bytes(&self.object)?;
|
||||
let capacity = object.len().checked_add(134).ok_or(StoreError::CorruptRecord)?;
|
||||
let mut b = Vec::with_capacity(capacity);
|
||||
b.extend(b"CRTD\x01");
|
||||
b.extend(self.operation_id);
|
||||
b.extend(object);
|
||||
put_option(&mut b, self.expected_head);
|
||||
put_prepared(&mut b, &self.prepared);
|
||||
b.extend(self.binding_revision.to_be_bytes());
|
||||
Self::decode(&b)?;
|
||||
Ok(b)
|
||||
}
|
||||
pub fn decode(bytes: &[u8]) -> Result<Self, StoreError> {
|
||||
let mut d = Decoder::new(bytes);
|
||||
if d.take::<5>()? != *b"CRTD\x01" {
|
||||
return Err(StoreError::CorruptRecord);
|
||||
}
|
||||
let c = Self {
|
||||
operation_id: d.uuid()?,
|
||||
object: d.object()?,
|
||||
expected_head: d.option()?,
|
||||
prepared: d.prepared()?,
|
||||
binding_revision: d.u64()?,
|
||||
};
|
||||
d.finish()?;
|
||||
Ok(c)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,371 +0,0 @@
|
||||
//! Validated capture peer metadata and its independent CNOD v1 codec.
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer, ser::SerializeStruct};
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct CaptureNode {
|
||||
rpc_uri: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum CaptureNodeError {
|
||||
#[error("invalid RPC URI")]
|
||||
InvalidRpcUri,
|
||||
#[error("invalid RPC URI")]
|
||||
InvalidRpcUriParse(#[source] url::ParseError),
|
||||
#[error("RPC URI too long")]
|
||||
RpcUriTooLong,
|
||||
#[error("invalid node magic")]
|
||||
InvalidNodeMagic,
|
||||
#[error("unsupported node version")]
|
||||
UnsupportedNodeVersion,
|
||||
#[error("invalid node length")]
|
||||
InvalidNodeLength,
|
||||
#[error("truncated node record")]
|
||||
TruncatedNodeRecord,
|
||||
#[error("invalid node UTF-8")]
|
||||
InvalidNodeUtf8(#[source] std::str::Utf8Error),
|
||||
#[error("trailing node bytes")]
|
||||
TrailingNodeBytes,
|
||||
}
|
||||
impl CaptureNodeError {
|
||||
pub fn code(&self) -> &'static str {
|
||||
match self {
|
||||
Self::InvalidRpcUri | Self::InvalidRpcUriParse(_) => "invalid_rpc_uri",
|
||||
Self::RpcUriTooLong => "rpc_uri_too_long",
|
||||
Self::InvalidNodeMagic => "invalid_node_magic",
|
||||
Self::UnsupportedNodeVersion => "unsupported_node_version",
|
||||
Self::InvalidNodeLength => "invalid_node_length",
|
||||
Self::TruncatedNodeRecord => "truncated_node_record",
|
||||
Self::InvalidNodeUtf8(_) => "invalid_node_utf8",
|
||||
Self::TrailingNodeBytes => "trailing_node_bytes",
|
||||
}
|
||||
}
|
||||
}
|
||||
impl CaptureNode {
|
||||
pub fn new(rpc_uri: String) -> Result<Self, CaptureNodeError> {
|
||||
let node = Self { rpc_uri };
|
||||
node.validate()?;
|
||||
Ok(node)
|
||||
}
|
||||
|
||||
pub fn rpc_uri(&self) -> &str {
|
||||
&self.rpc_uri
|
||||
}
|
||||
|
||||
pub fn validate(&self) -> Result<(), CaptureNodeError> {
|
||||
let uri = &self.rpc_uri;
|
||||
if uri.len() > 4096 {
|
||||
return Err(CaptureNodeError::RpcUriTooLong);
|
||||
}
|
||||
if uri.is_empty() || uri.chars().any(|c| c.is_whitespace() || c.is_control() || c == '\\') {
|
||||
return Err(CaptureNodeError::InvalidRpcUri);
|
||||
}
|
||||
let rest = uri
|
||||
.strip_prefix("http://")
|
||||
.or_else(|| uri.strip_prefix("https://"))
|
||||
.ok_or(CaptureNodeError::InvalidRpcUri)?;
|
||||
if uri.contains('?') || uri.contains('#') {
|
||||
return Err(CaptureNodeError::InvalidRpcUri);
|
||||
}
|
||||
let (authority, path) = rest.split_once('/').map_or((rest, ""), |(a, p)| (a, p));
|
||||
// An empty suffix after the first slash is the only allowed path.
|
||||
if !path.is_empty() || authority.contains('@') {
|
||||
return Err(CaptureNodeError::InvalidRpcUri);
|
||||
}
|
||||
let (host, port) = if authority.starts_with('[') {
|
||||
let end = authority.find(']').ok_or(CaptureNodeError::InvalidRpcUri)?;
|
||||
let port = authority
|
||||
.get(end + 1..)
|
||||
.and_then(|suffix| suffix.strip_prefix(':'))
|
||||
.ok_or(CaptureNodeError::InvalidRpcUri)?;
|
||||
(&authority[..=end], port)
|
||||
} else {
|
||||
authority.split_once(':').ok_or(CaptureNodeError::InvalidRpcUri)?
|
||||
};
|
||||
if host.is_empty() || port.is_empty() || !port.bytes().all(|b| b.is_ascii_digit()) {
|
||||
return Err(CaptureNodeError::InvalidRpcUri);
|
||||
}
|
||||
let port = port.parse::<u16>().map_err(|_| CaptureNodeError::InvalidRpcUri)?;
|
||||
if port == 0 {
|
||||
return Err(CaptureNodeError::InvalidRpcUri);
|
||||
}
|
||||
let parsed_url = url::Url::parse(uri).map_err(CaptureNodeError::InvalidRpcUriParse)?;
|
||||
if parsed_url.host().is_none() {
|
||||
return Err(CaptureNodeError::InvalidRpcUri);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn encode(&self) -> Result<Vec<u8>, CaptureNodeError> {
|
||||
self.validate()?;
|
||||
let length = u32::try_from(self.rpc_uri.len()).map_err(|_| CaptureNodeError::InvalidNodeLength)?;
|
||||
let mut bytes = b"CNOD\x01".to_vec();
|
||||
bytes.extend_from_slice(&length.to_be_bytes());
|
||||
bytes.extend_from_slice(self.rpc_uri.as_bytes());
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
pub fn decode(bytes: &[u8]) -> Result<Self, CaptureNodeError> {
|
||||
if bytes.len() < 9 {
|
||||
return Err(CaptureNodeError::TruncatedNodeRecord);
|
||||
}
|
||||
if &bytes[..4] != b"CNOD" {
|
||||
return Err(CaptureNodeError::InvalidNodeMagic);
|
||||
}
|
||||
if bytes[4] != 1 {
|
||||
return Err(CaptureNodeError::UnsupportedNodeVersion);
|
||||
}
|
||||
let length = u32::from_be_bytes(bytes[5..9].try_into().map_err(|_| CaptureNodeError::InvalidNodeLength)?);
|
||||
if !(1..=4096).contains(&length) {
|
||||
return Err(CaptureNodeError::InvalidNodeLength);
|
||||
}
|
||||
let length = usize::try_from(length).map_err(|_| CaptureNodeError::InvalidNodeLength)?;
|
||||
let end = 9_usize.checked_add(length).ok_or(CaptureNodeError::InvalidNodeLength)?;
|
||||
if bytes.len() < end {
|
||||
return Err(CaptureNodeError::TruncatedNodeRecord);
|
||||
}
|
||||
if bytes.len() != end {
|
||||
return Err(CaptureNodeError::TrailingNodeBytes);
|
||||
}
|
||||
let uri = std::str::from_utf8(&bytes[9..end]).map_err(CaptureNodeError::InvalidNodeUtf8)?;
|
||||
Self::new(uri.to_owned())
|
||||
}
|
||||
}
|
||||
impl Serialize for CaptureNode {
|
||||
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
self.validate().map_err(serde::ser::Error::custom)?;
|
||||
let mut state = serializer.serialize_struct("CaptureNode", 1)?;
|
||||
state.serialize_field("rpc_uri", &self.rpc_uri)?;
|
||||
state.end()
|
||||
}
|
||||
}
|
||||
impl<'de> Deserialize<'de> for CaptureNode {
|
||||
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Fields {
|
||||
rpc_uri: String,
|
||||
}
|
||||
let fields = Fields::deserialize(deserializer).map_err(|_| serde::de::Error::custom("invalid capture node fields"))?;
|
||||
Self::new(fields.rpc_uri).map_err(serde::de::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::error::Error;
|
||||
|
||||
fn record(payload: &[u8]) -> Vec<u8> {
|
||||
let mut bytes = b"CNOD\x01".to_vec();
|
||||
bytes.extend_from_slice(&u32::try_from(payload.len()).expect("test payload length").to_be_bytes());
|
||||
bytes.extend_from_slice(payload);
|
||||
bytes
|
||||
}
|
||||
fn reject_uri(uri: &str) {
|
||||
let result = CaptureNode::new(uri.to_owned());
|
||||
assert!(result.is_err(), "URI should reject: {uri:?}");
|
||||
assert_eq!(result.expect_err("rejected URI").code(), "invalid_rpc_uri");
|
||||
}
|
||||
fn reject_record(bytes: &[u8], code: &str) {
|
||||
let result = CaptureNode::decode(bytes);
|
||||
assert!(result.is_err(), "record should reject with {code}");
|
||||
assert_eq!(result.expect_err("rejected record").code(), code);
|
||||
}
|
||||
#[test]
|
||||
fn actual_openraft_node_bound() {
|
||||
fn require_node<T: openraft::Node>() {}
|
||||
require_node::<CaptureNode>();
|
||||
assert_eq!(CaptureNode::default().rpc_uri(), "");
|
||||
}
|
||||
#[test]
|
||||
fn default_and_empty_rejected_at_every_boundary() {
|
||||
let node = CaptureNode::default();
|
||||
assert_eq!(node.rpc_uri(), "");
|
||||
reject_uri("");
|
||||
assert_eq!(node.validate().expect_err("empty validation").code(), "invalid_rpc_uri");
|
||||
assert!(serde_json::to_string(&node).is_err());
|
||||
assert!(serde_json::from_str::<CaptureNode>(r#"{"rpc_uri":""}"#).is_err());
|
||||
assert_eq!(node.encode().expect_err("empty encoding").code(), "invalid_rpc_uri");
|
||||
reject_record(&record(b""), "invalid_node_length");
|
||||
}
|
||||
#[test]
|
||||
fn accepted_uris_preserve_exact_bytes() {
|
||||
for uri in [
|
||||
"http://node:80",
|
||||
"https://node:443",
|
||||
"http://node:1",
|
||||
"https://node:65535/",
|
||||
"http://127.0.0.1:9000",
|
||||
"https://[::1]:443/",
|
||||
"http://[2001:db8::1]:1",
|
||||
"http://NoDe:00080/",
|
||||
] {
|
||||
let result = CaptureNode::new(uri.to_owned());
|
||||
assert!(result.is_ok(), "valid URI construction: {uri}");
|
||||
let node = result.expect("valid URI");
|
||||
assert_eq!(node.rpc_uri(), uri, "preserve explicit default port and raw URI");
|
||||
assert_eq!(node.encode().expect("encode URI"), record(uri.as_bytes()));
|
||||
assert_eq!(CaptureNode::decode(&record(uri.as_bytes())).expect("decode URI"), node);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn golden_cnod_bytes() {
|
||||
let node = CaptureNode {
|
||||
rpc_uri: "http://node:80".to_owned(),
|
||||
};
|
||||
let golden = b"CNOD\x01\x00\x00\x00\x0ehttp://node:80";
|
||||
assert_eq!(node.encode().ok().as_deref(), Some(golden.as_slice()), "golden CNOD bytes");
|
||||
}
|
||||
#[test]
|
||||
fn valid_cnod_decode() {
|
||||
let golden = b"CNOD\x01\x00\x00\x00\x0ehttp://node:80";
|
||||
let decoded = CaptureNode::decode(golden);
|
||||
assert!(decoded.is_ok(), "golden decode succeeds");
|
||||
assert_eq!(decoded.expect("golden decoded node").rpc_uri(), "http://node:80");
|
||||
}
|
||||
#[test]
|
||||
fn serde_shape_roundtrip_and_strict_fields() {
|
||||
let node = CaptureNode::new("https://node:443/".to_owned()).expect("serde node");
|
||||
let json = r#"{"rpc_uri":"https://node:443/"}"#;
|
||||
assert_eq!(serde_json::to_string(&node).expect("serialize node"), json);
|
||||
assert_eq!(serde_json::from_str::<CaptureNode>(json).expect("deserialize node"), node);
|
||||
for json in [
|
||||
r#"{}"#,
|
||||
r#"{"rpc_uri":1}"#,
|
||||
r#"{"rpc_uri":null}"#,
|
||||
r#"{"rpc_uri":"http://node:1","extra":0}"#,
|
||||
r#"{"rpc_uri":"http://node:1","rpc_uri":"http://node:2"}"#,
|
||||
] {
|
||||
assert!(serde_json::from_str::<CaptureNode>(json).is_err(), "strict fields: {json}");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn credentials_and_raw_uri_shapes_reject() {
|
||||
for uri in [
|
||||
"http://user@node:1",
|
||||
"http://user:password@node:1",
|
||||
"http://@node:1",
|
||||
"HTTP://node:1",
|
||||
"Https://node:1",
|
||||
"http://node",
|
||||
"http://node:",
|
||||
"http://node:0",
|
||||
"http://node:-1",
|
||||
"http://node:+1",
|
||||
"http://node:65536",
|
||||
"http://node:999999999999999999999",
|
||||
"http://[::1:1",
|
||||
"http://[::1]extra:1",
|
||||
"http://[invalid]:1",
|
||||
"http://::1:1",
|
||||
"http://:1",
|
||||
"/disk/path",
|
||||
"file:///disk/path",
|
||||
"http://node:1/./",
|
||||
"http://node:1/../",
|
||||
"http://node:1/%2e/",
|
||||
"http://node:1/%2E%2e/",
|
||||
"http://node:1/path",
|
||||
"http://node:1\\",
|
||||
" http://node:1",
|
||||
"http://node:1\n",
|
||||
"http://no\tde:1",
|
||||
"http://node:1\u{00a0}",
|
||||
"http://node:1\u{0085}",
|
||||
"http://node:1\0",
|
||||
] {
|
||||
reject_uri(uri);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn query_and_fragment_markers_reject() {
|
||||
for uri in [
|
||||
"http://node:1?",
|
||||
"http://node:1?x=y",
|
||||
"http://node:1/?",
|
||||
"http://node:1#",
|
||||
"http://node:1#fragment",
|
||||
"http://node:1/#",
|
||||
] {
|
||||
reject_uri(uri);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn utf8_byte_length_boundaries() {
|
||||
for length in [4095, 4096] {
|
||||
let prefix = "http://node:";
|
||||
let uri = format!("{prefix}{}1", "0".repeat(length - prefix.len() - 1));
|
||||
assert_eq!(uri.len(), length);
|
||||
assert!(url::Url::parse(&uri).is_ok(), "boundary fixture accepted by actual URL parser");
|
||||
let node = CaptureNode::new(uri.clone()).expect("boundary node");
|
||||
assert_eq!(node.rpc_uri(), uri);
|
||||
assert_eq!(
|
||||
CaptureNode::decode(&node.encode().expect("boundary encoding")).expect("boundary decoding"),
|
||||
node
|
||||
);
|
||||
}
|
||||
let uri = format!("http://node:{}1", "0".repeat(4097 - "http://node:".len() - 1));
|
||||
assert_eq!(CaptureNode::new(uri).expect_err("oversize URI").code(), "rpc_uri_too_long");
|
||||
let unicode = format!("http://{}:1", "é".repeat(2044));
|
||||
assert!(unicode.chars().count() < 4096);
|
||||
assert!(unicode.len() > 4096);
|
||||
assert_eq!(CaptureNode::new(unicode).expect_err("UTF-8 byte limit").code(), "rpc_uri_too_long");
|
||||
}
|
||||
#[test]
|
||||
fn cnod_headers_and_lengths_reject_exact_codes() {
|
||||
let valid = record(b"http://node:80");
|
||||
for end in 0..9 {
|
||||
reject_record(&valid[..end], "truncated_node_record");
|
||||
}
|
||||
let mut bad = valid.clone();
|
||||
bad[0] = b'X';
|
||||
reject_record(&bad, "invalid_node_magic");
|
||||
bad = valid.clone();
|
||||
bad[4] = 2;
|
||||
reject_record(&bad, "unsupported_node_version");
|
||||
for length in [0_u32, 4097, u32::MAX] {
|
||||
bad = b"CNOD\x01".to_vec();
|
||||
bad.extend_from_slice(&length.to_be_bytes());
|
||||
reject_record(&bad, "invalid_node_length");
|
||||
}
|
||||
for end in 9..valid.len() {
|
||||
reject_record(&valid[..end], "truncated_node_record");
|
||||
}
|
||||
reject_record(&record(&[0xff]), "invalid_node_utf8");
|
||||
reject_record(&record(&[0xc3, 0x28]), "invalid_node_utf8");
|
||||
reject_record(&record(b"invalid URI"), "invalid_rpc_uri");
|
||||
}
|
||||
#[test]
|
||||
fn trailing_bytes_reject() {
|
||||
for tail in [&b"x"[..], &b"xyz"[..]] {
|
||||
let mut bytes = record(b"http://node:80");
|
||||
bytes.extend_from_slice(tail);
|
||||
reject_record(&bytes, "trailing_node_bytes");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn errors_and_sources_redact_input() {
|
||||
let sentinel = "http://SECRET_USER:SECRET_PASSWORD@node:1";
|
||||
let error = CaptureNode::new(sentinel.to_owned()).expect_err("credentials reject");
|
||||
let mut current: &dyn Error = &error;
|
||||
loop {
|
||||
let rendered = format!("{current} {current:?}");
|
||||
assert!(!rendered.contains("SECRET"));
|
||||
match current.source() {
|
||||
Some(source) => current = source,
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
let json = format!(r#"{{"rpc_uri":"{sentinel}"}}"#);
|
||||
let error = serde_json::from_str::<CaptureNode>(&json).expect_err("serde credentials reject");
|
||||
assert!(!format!("{error} {error:?}").contains("SECRET"));
|
||||
let json = format!(r#"{{"{sentinel}":0}}"#);
|
||||
let error = serde_json::from_str::<CaptureNode>(&json).expect_err("unknown fields reject");
|
||||
assert!(!format!("{error} {error:?}").contains("SECRET"));
|
||||
let error = CaptureNode::new("http://[bad]:1".to_owned()).expect_err("parser rejection");
|
||||
assert!(error.source().is_some(), "retain URL parser source");
|
||||
let error = CaptureNode::decode(&record(&[0xff])).expect_err("UTF-8 rejection");
|
||||
assert!(error.source().is_some(), "retain UTF-8 source");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,43 +0,0 @@
|
||||
use crate::{LOGS, META, Store, StoreError, decode_id, decode_log, encode_id};
|
||||
use redb::ReadableTable;
|
||||
|
||||
impl Store {
|
||||
/// Atomically remove every log entry at or after `from`.
|
||||
pub fn truncate_suffix(&mut self, from: u64) -> Result<(), StoreError> {
|
||||
let tx = self.write_transaction()?;
|
||||
{
|
||||
let mut meta = tx.open_table(META)?;
|
||||
let logs = tx.open_table(LOGS)?;
|
||||
if crate::committed::validate_committed(&meta, &logs)?.is_some_and(|id| from <= id.index) {
|
||||
return Err(StoreError::CommittedLogProtected);
|
||||
}
|
||||
drop(logs);
|
||||
let tail = meta.get("tail")?.map(|v| decode_id(v.value())).transpose()?;
|
||||
if tail.is_none_or(|id| from > id.index) {
|
||||
return Ok(());
|
||||
}
|
||||
let mut logs = tx.open_table(LOGS)?;
|
||||
let new_tail = if from == 0 {
|
||||
None
|
||||
} else {
|
||||
let index = from - 1;
|
||||
let record = logs.get(index)?.ok_or(StoreError::InvalidLogSequence)?;
|
||||
let id = decode_log(record.value())?.id;
|
||||
if id.index != index {
|
||||
return Err(StoreError::CorruptRecord);
|
||||
}
|
||||
Some(id)
|
||||
};
|
||||
logs.retain(|index, _| index < from)?;
|
||||
match new_tail {
|
||||
Some(id) => {
|
||||
meta.insert("tail", encode_id(id).as_slice())?;
|
||||
}
|
||||
None => {
|
||||
meta.remove("tail")?;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.commit(tx)
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@ hotpath-alloc = ["hotpath", "hotpath/hotpath-alloc"]
|
||||
hotpath-cpu = ["hotpath", "hotpath/hotpath-cpu"]
|
||||
|
||||
[dependencies]
|
||||
arc-swap.workspace = true
|
||||
hotpath.workspace = true
|
||||
tokio = { workspace = true, features = ["fs", "rt-multi-thread"] }
|
||||
tonic = { workspace = true, features = ["gzip", "deflate"] }
|
||||
|
||||
@@ -12,9 +12,10 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use arc_swap::ArcSwap;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::LazyLock;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, LazyLock};
|
||||
use tokio::sync::RwLock;
|
||||
use tonic::transport::Channel;
|
||||
|
||||
@@ -27,6 +28,42 @@ pub static GLOBAL_ROOT_CERT: LazyLock<RwLock<Option<Vec<u8>>>> = LazyLock::new(|
|
||||
pub static GLOBAL_MTLS_IDENTITY: LazyLock<RwLock<Option<MtlsIdentityPem>>> = LazyLock::new(|| RwLock::new(None));
|
||||
pub static GLOBAL_OUTBOUND_TLS_GENERATION: LazyLock<AtomicU64> = LazyLock::new(|| AtomicU64::new(0));
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct OutboundTlsSnapshot {
|
||||
pub generation: u64,
|
||||
pub root_ca_pem: Option<Vec<u8>>,
|
||||
pub mtls_identity: Option<MtlsIdentityPem>,
|
||||
}
|
||||
|
||||
static GLOBAL_OUTBOUND_TLS_STATE: LazyLock<ArcSwap<OutboundTlsSnapshot>> =
|
||||
LazyLock::new(|| ArcSwap::from_pointee(OutboundTlsSnapshot::default()));
|
||||
|
||||
pub fn get_global_outbound_tls_snapshot() -> Arc<OutboundTlsSnapshot> {
|
||||
GLOBAL_OUTBOUND_TLS_STATE.load_full()
|
||||
}
|
||||
|
||||
/// Publish identity, trust and generation together; the individual globals are legacy views.
|
||||
pub async fn publish_global_outbound_tls_snapshot(snapshot: OutboundTlsSnapshot) -> u64 {
|
||||
GLOBAL_OUTBOUND_TLS_STATE.rcu(|current| {
|
||||
if current.generation != 0
|
||||
&& current.root_ca_pem == snapshot.root_ca_pem
|
||||
&& current.mtls_identity == snapshot.mtls_identity
|
||||
{
|
||||
return Arc::clone(current);
|
||||
}
|
||||
Arc::new(OutboundTlsSnapshot {
|
||||
generation: snapshot.generation.max(current.generation.saturating_add(1)),
|
||||
root_ca_pem: snapshot.root_ca_pem.clone(),
|
||||
mtls_identity: snapshot.mtls_identity.clone(),
|
||||
})
|
||||
});
|
||||
let accepted = get_global_outbound_tls_snapshot();
|
||||
*GLOBAL_ROOT_CERT.write().await = accepted.root_ca_pem.clone();
|
||||
*GLOBAL_MTLS_IDENTITY.write().await = accepted.mtls_identity.clone();
|
||||
GLOBAL_OUTBOUND_TLS_GENERATION.store(accepted.generation, Ordering::Relaxed);
|
||||
accepted.generation
|
||||
}
|
||||
|
||||
/// Log level to use when reporting cached gRPC connection eviction.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum ConnectionEvictionLogLevel {
|
||||
@@ -85,17 +122,27 @@ pub async fn get_global_addr() -> String {
|
||||
/// # Arguments
|
||||
/// * `cert` - A vector of bytes representing the PEM-encoded root CA certificate.
|
||||
pub async fn set_global_root_cert(cert: Vec<u8>) {
|
||||
GLOBAL_OUTBOUND_TLS_STATE.rcu(|current| {
|
||||
let mut next = (**current).clone();
|
||||
next.root_ca_pem = Some(cert.clone());
|
||||
Arc::new(next)
|
||||
});
|
||||
*GLOBAL_ROOT_CERT.write().await = Some(cert);
|
||||
}
|
||||
|
||||
/// Clear the global root CA certificate for outbound gRPC clients.
|
||||
pub async fn clear_global_root_cert() {
|
||||
GLOBAL_OUTBOUND_TLS_STATE.rcu(|current| {
|
||||
let mut next = (**current).clone();
|
||||
next.root_ca_pem = None;
|
||||
Arc::new(next)
|
||||
});
|
||||
*GLOBAL_ROOT_CERT.write().await = None;
|
||||
}
|
||||
|
||||
/// Get the global root CA certificate for outbound gRPC clients.
|
||||
pub async fn get_global_root_cert() -> Option<Vec<u8>> {
|
||||
GLOBAL_ROOT_CERT.read().await.clone()
|
||||
get_global_outbound_tls_snapshot().root_ca_pem.clone()
|
||||
}
|
||||
|
||||
/// Set the global mTLS identity (cert+key PEM) for outbound gRPC clients.
|
||||
@@ -105,22 +152,32 @@ pub async fn get_global_root_cert() -> Option<Vec<u8>> {
|
||||
/// # Arguments
|
||||
/// * `identity` - An optional MtlsIdentityPem struct containing the cert and key PEM.
|
||||
pub async fn set_global_mtls_identity(identity: Option<MtlsIdentityPem>) {
|
||||
GLOBAL_OUTBOUND_TLS_STATE.rcu(|current| {
|
||||
let mut next = (**current).clone();
|
||||
next.mtls_identity = identity.clone();
|
||||
Arc::new(next)
|
||||
});
|
||||
*GLOBAL_MTLS_IDENTITY.write().await = identity;
|
||||
}
|
||||
|
||||
/// Get the global mTLS identity for outbound gRPC clients.
|
||||
pub async fn get_global_mtls_identity() -> Option<MtlsIdentityPem> {
|
||||
GLOBAL_MTLS_IDENTITY.read().await.clone()
|
||||
get_global_outbound_tls_snapshot().mtls_identity.clone()
|
||||
}
|
||||
|
||||
/// Set the global outbound TLS generation.
|
||||
pub fn set_global_outbound_tls_generation(generation: u64) {
|
||||
GLOBAL_OUTBOUND_TLS_STATE.rcu(|current| {
|
||||
let mut next = (**current).clone();
|
||||
next.generation = generation;
|
||||
Arc::new(next)
|
||||
});
|
||||
GLOBAL_OUTBOUND_TLS_GENERATION.store(generation, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Get the global outbound TLS generation.
|
||||
pub fn get_global_outbound_tls_generation() -> u64 {
|
||||
GLOBAL_OUTBOUND_TLS_GENERATION.load(Ordering::Relaxed)
|
||||
get_global_outbound_tls_snapshot().generation
|
||||
}
|
||||
|
||||
/// Evict a stale/dead connection from the global connection cache.
|
||||
@@ -194,8 +251,77 @@ pub async fn clear_all_connections() {
|
||||
///
|
||||
/// When present, gRPC clients will present this identity to servers requesting/requiring mTLS.
|
||||
/// When absent, clients proceed with standard server-authenticated TLS.
|
||||
#[derive(Clone, Debug)]
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct MtlsIdentityPem {
|
||||
pub cert_pem: Vec<u8>,
|
||||
pub key_pem: Vec<u8>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for MtlsIdentityPem {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("MtlsIdentityPem")
|
||||
.field("cert_bytes", &self.cert_pem.len())
|
||||
.field("key_pem", &"[REDACTED]")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tls_snapshot_tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn outbound_snapshot_publication_is_coherent_and_skips_unchanged_material() {
|
||||
let start = get_global_outbound_tls_generation().saturating_add(1);
|
||||
let snapshot = OutboundTlsSnapshot {
|
||||
generation: start,
|
||||
root_ca_pem: Some(vec![1]),
|
||||
mtls_identity: Some(MtlsIdentityPem {
|
||||
cert_pem: vec![1],
|
||||
key_pem: vec![1],
|
||||
}),
|
||||
};
|
||||
let first = publish_global_outbound_tls_snapshot(snapshot.clone()).await;
|
||||
assert_eq!(
|
||||
publish_global_outbound_tls_snapshot(OutboundTlsSnapshot {
|
||||
generation: first.saturating_add(1),
|
||||
..snapshot
|
||||
})
|
||||
.await,
|
||||
first
|
||||
);
|
||||
let writer = tokio::spawn(async move {
|
||||
for tag in 2_u8..=255 {
|
||||
publish_global_outbound_tls_snapshot(OutboundTlsSnapshot {
|
||||
generation: start.saturating_add(u64::from(tag)),
|
||||
root_ca_pem: Some(vec![tag]),
|
||||
mtls_identity: Some(MtlsIdentityPem {
|
||||
cert_pem: vec![tag],
|
||||
key_pem: vec![tag],
|
||||
}),
|
||||
})
|
||||
.await;
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
});
|
||||
while !writer.is_finished() {
|
||||
let state = get_global_outbound_tls_snapshot();
|
||||
let identity = state.mtls_identity.as_ref().expect("identity in complete snapshot");
|
||||
assert_eq!(state.root_ca_pem.as_ref().expect("roots in complete snapshot"), &identity.cert_pem);
|
||||
assert_eq!(identity.cert_pem, identity.key_pem);
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
writer.await.expect("writer completes");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mtls_identity_debug_redacts_private_key() {
|
||||
let identity = MtlsIdentityPem {
|
||||
cert_pem: b"public certificate".to_vec(),
|
||||
key_pem: b"PRIVATE-KEY-MARKER".to_vec(),
|
||||
};
|
||||
let debug = format!("{identity:?}");
|
||||
assert!(!debug.contains("PRIVATE-KEY-MARKER"));
|
||||
assert!(debug.contains("[REDACTED]"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,11 @@ pub const ENV_OBS_PROFILING_EXPORT_ENABLED: &str = "RUSTFS_OBS_PROFILING_EXPORT_
|
||||
/// PUT records multiple timers and histograms when attribution is active.
|
||||
pub const ENV_OBS_PUT_STAGE_METRICS_ENABLED: &str = "RUSTFS_OBS_PUT_STAGE_METRICS_ENABLED";
|
||||
|
||||
/// Enables sampled in-process GetObject stage summaries without OTLP histograms.
|
||||
pub const ENV_OBS_GET_STAGE_LOCAL_SUMMARY_ENABLED: &str = "RUSTFS_OBS_GET_STAGE_LOCAL_SUMMARY_ENABLED";
|
||||
pub const ENV_OBS_GET_STAGE_LOCAL_SUMMARY_SAMPLE_RATE: &str = "RUSTFS_OBS_GET_STAGE_LOCAL_SUMMARY_SAMPLE_RATE";
|
||||
pub const ENV_OBS_GET_STAGE_LOCAL_SUMMARY_INTERVAL_SECS: &str = "RUSTFS_OBS_GET_STAGE_LOCAL_SUMMARY_INTERVAL_SECS";
|
||||
|
||||
pub const ENV_OBS_LOGGER_LEVEL: &str = "RUSTFS_OBS_LOGGER_LEVEL";
|
||||
pub const ENV_OBS_LOG_STDOUT_ENABLED: &str = "RUSTFS_OBS_LOG_STDOUT_ENABLED";
|
||||
pub const ENV_OBS_LOG_DIRECTORY: &str = "RUSTFS_OBS_LOG_DIRECTORY";
|
||||
@@ -159,6 +164,15 @@ mod tests {
|
||||
assert_eq!(ENV_OBS_LOGS_EXPORT_ENABLED, "RUSTFS_OBS_LOGS_EXPORT_ENABLED");
|
||||
assert_eq!(ENV_OBS_PROFILING_EXPORT_ENABLED, "RUSTFS_OBS_PROFILING_EXPORT_ENABLED");
|
||||
assert_eq!(ENV_OBS_PUT_STAGE_METRICS_ENABLED, "RUSTFS_OBS_PUT_STAGE_METRICS_ENABLED");
|
||||
assert_eq!(ENV_OBS_GET_STAGE_LOCAL_SUMMARY_ENABLED, "RUSTFS_OBS_GET_STAGE_LOCAL_SUMMARY_ENABLED");
|
||||
assert_eq!(
|
||||
ENV_OBS_GET_STAGE_LOCAL_SUMMARY_SAMPLE_RATE,
|
||||
"RUSTFS_OBS_GET_STAGE_LOCAL_SUMMARY_SAMPLE_RATE"
|
||||
);
|
||||
assert_eq!(
|
||||
ENV_OBS_GET_STAGE_LOCAL_SUMMARY_INTERVAL_SECS,
|
||||
"RUSTFS_OBS_GET_STAGE_LOCAL_SUMMARY_INTERVAL_SECS"
|
||||
);
|
||||
// Test log cleanup related env keys
|
||||
assert_eq!(ENV_OBS_LOG_MAX_TOTAL_SIZE_BYTES, "RUSTFS_OBS_LOG_MAX_TOTAL_SIZE_BYTES");
|
||||
assert_eq!(ENV_OBS_LOG_MAX_SINGLE_FILE_SIZE_BYTES, "RUSTFS_OBS_LOG_MAX_SINGLE_FILE_SIZE_BYTES");
|
||||
|
||||
@@ -1542,6 +1542,15 @@ impl RustFSTestClusterEnvironment {
|
||||
self.start_node_from_binary(node_idx, &binary_path).await
|
||||
}
|
||||
|
||||
/// Start a process and wait only for TCP, allowing tests to bring up a
|
||||
/// quorum one node at a time before the S3 service can become ready.
|
||||
pub async fn start_node_process(&mut self, node_idx: usize) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let binary_path = rustfs_binary_path();
|
||||
let volumes_arg = self.build_volumes_arg();
|
||||
self.spawn_node(node_idx, &binary_path, &volumes_arg)?;
|
||||
self.wait_for_node_ready(&self.nodes[node_idx].address, node_idx).await
|
||||
}
|
||||
|
||||
/// Start one stopped cluster node with a specific RustFS binary while
|
||||
/// preserving the cluster's volume layout and that node's data directory.
|
||||
pub async fn start_node_from_binary(
|
||||
|
||||
@@ -860,10 +860,12 @@ pub(crate) async fn start_rebalance(cluster: &RustFSTestClusterEnvironment) -> T
|
||||
if status.is_success() {
|
||||
break response;
|
||||
}
|
||||
if status == StatusCode::INTERNAL_SERVER_ERROR
|
||||
&& response.contains("requires a live fleet capability proof")
|
||||
&& Instant::now() < deadline
|
||||
{
|
||||
// The init stage answers 503 + Retry-After while the cluster capability
|
||||
// proof is still missing; older binaries surfaced the same condition as
|
||||
// a 500 carrying the proof marker.
|
||||
let waiting_for_readiness = status == StatusCode::SERVICE_UNAVAILABLE
|
||||
|| (status == StatusCode::INTERNAL_SERVER_ERROR && response.contains("requires a live fleet capability proof"));
|
||||
if waiting_for_readiness && Instant::now() < deadline {
|
||||
sleep(Duration::from_secs(1)).await;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1191,11 +1191,59 @@ mod tests {
|
||||
.into())
|
||||
}
|
||||
|
||||
fn scanner_cycle_completion_budget(
|
||||
cycle_end: u64,
|
||||
interval: Option<u64>,
|
||||
now: u64,
|
||||
active: bool,
|
||||
) -> Result<Duration, &'static str> {
|
||||
if cycle_end > now {
|
||||
return Err("scanner cycle timestamp is in the future");
|
||||
}
|
||||
if active {
|
||||
return Ok(Duration::from_secs(60));
|
||||
}
|
||||
let interval = interval
|
||||
.filter(|interval| *interval != 0)
|
||||
.ok_or("scanner schedule has no positive interval")?;
|
||||
let next_cycle = cycle_end
|
||||
.checked_add(interval)
|
||||
.ok_or("scanner schedule timestamp overflows")?;
|
||||
let budget = next_cycle
|
||||
.saturating_sub(now)
|
||||
.checked_add(60)
|
||||
.ok_or("scanner completion budget overflows")?;
|
||||
Ok(Duration::from_secs(budget))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scanner_cycle_completion_budget_accounts_for_captured_retry_schedule() {
|
||||
assert_eq!(
|
||||
scanner_cycle_completion_budget(1_791_815_390, Some(256), 1_791_815_447, false),
|
||||
Ok(Duration::from_secs(259)),
|
||||
);
|
||||
assert_eq!(scanner_cycle_completion_budget(100, Some(1), 110, false), Ok(Duration::from_secs(60)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scanner_cycle_completion_budget_does_not_grant_planned_sleep_to_active_cycle() {
|
||||
assert_eq!(scanner_cycle_completion_budget(100, Some(256), 110, true), Ok(Duration::from_secs(60)));
|
||||
assert_eq!(scanner_cycle_completion_budget(0, None, 110, true), Ok(Duration::from_secs(60)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scanner_cycle_completion_budget_rejects_invalid_schedule() {
|
||||
for (end, interval, now) in [(101, 1, 100), (100, 0, 100), (u64::MAX, 1, u64::MAX), (1, u64::MAX - 1, 1)] {
|
||||
assert!(scanner_cycle_completion_budget(end, Some(interval), now, false).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for_scanner_cycle_after(
|
||||
cluster: &RustFSTestClusterEnvironment,
|
||||
previous_cycle_end: u64,
|
||||
) -> Result<u64, Box<dyn Error + Send + Sync>> {
|
||||
let deadline = Instant::now() + Duration::from_secs(60);
|
||||
let mut deadline = Instant::now() + Duration::from_secs(60);
|
||||
let mut schedule_accounted = false;
|
||||
loop {
|
||||
let mut latest_cycle_end = 0;
|
||||
let mut versions_observed = false;
|
||||
@@ -1237,14 +1285,36 @@ mod tests {
|
||||
let versions_scanned = metrics["versions_scanned"]
|
||||
.as_u64()
|
||||
.ok_or("scanner status is missing its version-coverage counter")?;
|
||||
latest_cycle_end = latest_cycle_end.max(cycle_end);
|
||||
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)?.as_secs();
|
||||
if cycle_end > now {
|
||||
return Err("scanner cycle timestamp is in the future".into());
|
||||
}
|
||||
let schedule = &status["cycle_schedule"];
|
||||
if !schedule_accounted && schedule["execution_role"].as_str() == Some("leader") {
|
||||
let active = metrics["current_cycle_active"]
|
||||
.as_bool()
|
||||
.ok_or("scanner status is missing its active-cycle flag")?;
|
||||
if active || (cycle_end != 0 && schedule["effective_interval_available"].as_bool() == Some(true)) {
|
||||
let interval = schedule["effective_interval_seconds"].as_u64();
|
||||
let budget = scanner_cycle_completion_budget(cycle_end, interval, now, active)?;
|
||||
deadline = Instant::now().checked_add(budget).ok_or("scanner deadline overflows")?;
|
||||
schedule_accounted = true;
|
||||
}
|
||||
}
|
||||
// Superseded walks complete all sets while withholding quota authority.
|
||||
// Deferred and partial attempts do not certify completed scanning.
|
||||
if matches!(metrics["last_cycle_result"].as_str(), Some("success" | "superseded")) {
|
||||
latest_cycle_end = latest_cycle_end.max(cycle_end);
|
||||
}
|
||||
versions_observed |= versions_scanned > 0;
|
||||
observations.push(format!(
|
||||
"node{node_index}: end={cycle_end}, versions={versions_scanned}, cycle={}, active={}, leader={}, result={}",
|
||||
"node{node_index}: end={cycle_end}, versions={versions_scanned}, cycle={}, active={}, leader={}, result={}, partial_reason={}, schedule={}",
|
||||
metrics["current_cycle"],
|
||||
metrics["current_cycle_active"],
|
||||
metrics["leader_lock_state"],
|
||||
metrics["last_cycle_result"],
|
||||
metrics["last_cycle_partial_reason"],
|
||||
schedule,
|
||||
));
|
||||
}
|
||||
// The coordinator records cycle completion, but remote workers
|
||||
@@ -1555,7 +1625,8 @@ mod tests {
|
||||
}
|
||||
|
||||
if background_enabled {
|
||||
wait_for_scanner_cycle_after(&cluster, 0).await?;
|
||||
let baseline_cycle_floor = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)?.as_secs();
|
||||
wait_for_scanner_cycle_after(&cluster, baseline_cycle_floor).await?;
|
||||
}
|
||||
|
||||
cluster.stop_node(1)?;
|
||||
@@ -1898,11 +1969,16 @@ mod tests {
|
||||
let status_body = signed_admin_post(&status_url, None, &cluster.access_key, &cluster.secret_key).await?;
|
||||
let status: serde_json::Value = serde_json::from_str(&status_body)
|
||||
.map_err(|err| format!("pre-interrupt background heal status is not JSON ({err}): {status_body}"))?;
|
||||
if background_enabled || only_admin_heal_is_active(&status) {
|
||||
let admin_active = background_rejoin_heal_evidence
|
||||
|| (status["state"].as_str() == Some("active")
|
||||
&& status["healOperations"]["activeBySource"]["admin"].as_u64() == Some(1));
|
||||
if admin_active && (background_enabled || only_admin_heal_is_active(&status)) {
|
||||
break status;
|
||||
}
|
||||
if Instant::now() >= partial_deadline {
|
||||
return Err(format!("non-admin heal did not drain before the interruption deadline: {status}").into());
|
||||
return Err(
|
||||
format!("heal tasks did not reach the required interruption state before the deadline: {status}").into(),
|
||||
);
|
||||
}
|
||||
sleep(Duration::from_millis(50)).await;
|
||||
};
|
||||
@@ -2562,4 +2638,157 @@ mod tests {
|
||||
};
|
||||
assert!(!outage_candidate_targets_replacement(&insufficient_peer_shards, 12, replacement_set_slot));
|
||||
}
|
||||
async fn two_node_heal_status(
|
||||
cluster: &RustFSTestClusterEnvironment,
|
||||
bucket: Option<&str>,
|
||||
recreate: bool,
|
||||
scan_mode: u8,
|
||||
) -> Result<serde_json::Value, Box<dyn Error + Send + Sync>> {
|
||||
let url = format!("{}/rustfs/admin/v3/heal/{}", cluster.nodes[0].url, bucket.unwrap_or_default());
|
||||
let options = serde_json::json!({
|
||||
"recursive": true, "dryRun": false, "remove": false,
|
||||
"recreate": recreate, "scanMode": scan_mode, "updateParity": false, "nolock": false,
|
||||
});
|
||||
let started =
|
||||
start_root_heal_when_control_ready(&url, &options.to_string(), &cluster.access_key, &cluster.secret_key).await?;
|
||||
let started: serde_json::Value = serde_json::from_str(&started)?;
|
||||
let token = started["clientToken"]
|
||||
.as_str()
|
||||
.ok_or_else(|| format!("missing heal token: {started}"))?;
|
||||
let status_url = format!("{url}?clientToken={token}");
|
||||
let deadline = Instant::now() + Duration::from_secs(240);
|
||||
loop {
|
||||
let body = timeout(
|
||||
Duration::from_secs(20),
|
||||
signed_admin_post(&status_url, None, &cluster.access_key, &cluster.secret_key),
|
||||
)
|
||||
.await??;
|
||||
let status: serde_json::Value = serde_json::from_str(&body)?;
|
||||
match status["summary"].as_str() {
|
||||
Some("finished" | "stopped") => return Ok(status),
|
||||
Some("running") if Instant::now() < deadline => sleep(Duration::from_millis(250)).await,
|
||||
_ => return Err(format!("heal did not reach a terminal state: {status}").into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn verify_two_node_directory_recovery(
|
||||
remove_bucket: bool,
|
||||
scan_mode: u8,
|
||||
bucket_scoped: bool,
|
||||
recreate: bool,
|
||||
) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
init_logging();
|
||||
let mut cluster = RustFSTestClusterEnvironment::new(2).await?;
|
||||
// Isolate explicit administrator recovery from scanner, MRF, and disk healing.
|
||||
for key in [
|
||||
"RUSTFS_HEAL_AUTO_HEAL_ENABLE",
|
||||
"RUSTFS_HEAL_MRF_ENABLE",
|
||||
"RUSTFS_SCANNER_ENABLED",
|
||||
"RUSTFS_PUT_RENAME_EARLY_ACK_ENABLE",
|
||||
] {
|
||||
cluster.set_env(key, "false");
|
||||
}
|
||||
cluster.start().await?;
|
||||
let source = cluster.create_s3_client(0)?;
|
||||
let target = cluster.create_s3_client(1)?;
|
||||
let bucket = "two-node-directory-recovery";
|
||||
source.create_bucket().bucket(bucket).send().await?;
|
||||
let objects = [
|
||||
("empty", Vec::new()),
|
||||
("nested/inline", vec![0x3c; 1024]),
|
||||
("nested/external", (0_u8..=255).cycle().take(1024 * 1024).collect::<Vec<_>>()),
|
||||
];
|
||||
for (key, bytes) in &objects {
|
||||
source
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(*key)
|
||||
.body(ByteStream::from(bytes.clone()))
|
||||
.send()
|
||||
.await?;
|
||||
}
|
||||
let target_bucket = Path::new(&cluster.nodes[1].data_dir).join(bucket);
|
||||
let original = objects
|
||||
.iter()
|
||||
.map(|(key, _)| census_object_version_on_disk(Path::new(&cluster.nodes[1].data_dir), bucket, key, None))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
for census in &original {
|
||||
assert!(census.has_xl_meta, "fixture must have a target metadata copy: {census:?}");
|
||||
}
|
||||
assert!(
|
||||
!original[2].present_part_fingerprints.is_empty(),
|
||||
"large fixture must exercise external parts"
|
||||
);
|
||||
let removed = Path::new(&cluster.temp_dir).join("removed");
|
||||
tokio::fs::create_dir(&removed).await?;
|
||||
if remove_bucket {
|
||||
tokio::fs::rename(&target_bucket, removed.join(bucket)).await?;
|
||||
} else {
|
||||
for (index, (key, _)) in objects.iter().enumerate() {
|
||||
tokio::fs::rename(target_bucket.join(key), removed.join(index.to_string())).await?;
|
||||
}
|
||||
}
|
||||
let status = two_node_heal_status(&cluster, bucket_scoped.then_some(bucket), recreate, scan_mode).await?;
|
||||
assert_eq!(status["summary"], "finished", "administrator recovery should complete: {status}");
|
||||
assert_eq!(status["outcome"]["execution"]["state"], "completed");
|
||||
assert_eq!(status["outcome"]["coverage"], "complete");
|
||||
for ((key, _), before) in objects.iter().zip(&original) {
|
||||
let after = census_object_version_on_disk(Path::new(&cluster.nodes[1].data_dir), bucket, key, None)?;
|
||||
assert!(after.has_xl_meta, "metadata must be restored before GET: {key}");
|
||||
assert_eq!(
|
||||
after.present_part_fingerprints, before.present_part_fingerprints,
|
||||
"external shard bytes must match: {key}"
|
||||
);
|
||||
assert_eq!(
|
||||
after.inline_data_fingerprint, before.inline_data_fingerprint,
|
||||
"inline shard bytes must match: {key}"
|
||||
);
|
||||
}
|
||||
cluster.stop_node(0)?;
|
||||
for (key, expected) in &objects {
|
||||
let actual = timeout(Duration::from_secs(30), async {
|
||||
target
|
||||
.get_object()
|
||||
.bucket(bucket)
|
||||
.key(*key)
|
||||
.send()
|
||||
.await?
|
||||
.body
|
||||
.collect()
|
||||
.await
|
||||
.map(|body| body.into_bytes())
|
||||
.map_err(Box::<dyn Error + Send + Sync>::from)
|
||||
})
|
||||
.await??;
|
||||
assert_eq!(actual.len(), expected.len(), "target-only read length: {key}");
|
||||
assert_eq!(sha256_hex(&actual), sha256_hex(expected), "target-only content: {key}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_two_node_bucket_discovery_and_explicit_recreate() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
verify_two_node_directory_recovery(true, 2, true, true).await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_two_node_missing_bucket_default_cluster_recovery() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
verify_two_node_directory_recovery(true, 2, false, false).await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_two_node_missing_bucket_default_scoped_recovery() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
verify_two_node_directory_recovery(true, 2, true, false).await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_two_node_object_directory_recovery_normal() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
verify_two_node_directory_recovery(false, 1, false, false).await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_two_node_object_directory_recovery_deep() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
verify_two_node_directory_recovery(false, 2, false, false).await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -264,7 +264,7 @@ async fn call_make_volume_response(
|
||||
request: MakeVolumeRequest,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Response<MakeVolumeResponse>, Status> {
|
||||
let mut client = node_service_time_out_client_no_auth(&url.to_string())
|
||||
let mut client = node_service_time_out_client_no_auth(url)
|
||||
.await
|
||||
.map_err(|err| Status::unavailable(format!("cannot reach the node service: {err}")))?;
|
||||
let mut rpc_request = Request::new(request);
|
||||
@@ -273,7 +273,7 @@ async fn call_make_volume_response(
|
||||
}
|
||||
|
||||
async fn call_ping_response(url: &str, headers: HeaderMap) -> Result<Response<PingResponse>, Status> {
|
||||
let mut client = node_service_time_out_client_no_auth(&url.to_string())
|
||||
let mut client = node_service_time_out_client_no_auth(url)
|
||||
.await
|
||||
.map_err(|err| Status::unavailable(format!("cannot reach the node service: {err}")))?;
|
||||
let mut rpc_request = Request::new(PingRequest {
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use crate::common::RustFSTestClusterEnvironment;
|
||||
use crate::common::{ClusterTopology, RustFSTestClusterEnvironment};
|
||||
use aws_sdk_s3::Client;
|
||||
use aws_sdk_s3::error::SdkError;
|
||||
use bytes::Bytes;
|
||||
@@ -185,21 +185,113 @@ async fn wait_for_quorum_read_admission(clients: &[Client], bucket: &str) -> Tes
|
||||
return Ok(());
|
||||
}
|
||||
if tokio::time::Instant::now() >= deadline {
|
||||
return Err(format!("read quorum did not become available after lease convergence for {bucket}").into());
|
||||
let diagnostics = clients[0].get_object().bucket(bucket).key("warm-small").send().await;
|
||||
return Err(format!(
|
||||
"read quorum did not become available after lease convergence for {bucket}: GET={diagnostics:?}"
|
||||
)
|
||||
.into());
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_cold_degraded_object_mutations_require_only_storage_quorum() -> TestResult {
|
||||
crate::common::init_logging();
|
||||
let mut cluster = RustFSTestClusterEnvironment::new(4).await?;
|
||||
cluster.set_env("RUSTFS_STORAGE_CLASS_STANDARD", "EC:2");
|
||||
cluster.set_env("RUSTFS_PUT_RENAME_EARLY_ACK_ENABLE", "false");
|
||||
cluster.set_env("RUSTFS_OBS_METRICS_EXPORT_ENABLED", "false");
|
||||
cluster.set_env("RUSTFS_GET_OBJECT_METADATA_CACHE_DISTRIBUTED_ENABLE", "false");
|
||||
cluster.start().await?;
|
||||
let clients = cluster
|
||||
.create_all_clients()?
|
||||
.into_iter()
|
||||
.map(|client| {
|
||||
Client::from_conf(
|
||||
client
|
||||
.config()
|
||||
.to_builder()
|
||||
.retry_config(aws_sdk_s3::config::retry::RetryConfig::standard().with_max_attempts(1))
|
||||
.build(),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let bucket = "cold-degraded-mutations";
|
||||
clients[0].create_bucket().bucket(bucket).send().await?;
|
||||
// No object mutation may warm a process-wide configuration probe before
|
||||
// the missing peer is introduced. Three shards still satisfy EC:2 writes.
|
||||
cluster.stop_node(3)?;
|
||||
let admission_deadline = Instant::now() + Duration::from_secs(30);
|
||||
for client in &clients[..3] {
|
||||
loop {
|
||||
match client
|
||||
.get_object()
|
||||
.bucket(bucket)
|
||||
.key("never-created-readiness-probe")
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Err(error) if error.as_service_error().and_then(|error| error.meta().code()) == Some("NoSuchKey") => break,
|
||||
Err(error)
|
||||
if error.raw_response().is_some_and(|response| response.status().as_u16() == 503)
|
||||
&& Instant::now() < admission_deadline =>
|
||||
{
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
Err(error) => return Err(error.into()),
|
||||
Ok(_) => panic!("a cold readiness probe must not create an object"),
|
||||
}
|
||||
}
|
||||
}
|
||||
let body = b"first write while one peer is offline";
|
||||
clients[0]
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key("object")
|
||||
.body(Bytes::from_static(body).into())
|
||||
.send()
|
||||
.await?;
|
||||
for client in &clients[..3] {
|
||||
assert_quorum_object_body(client, bucket, "object", body).await?;
|
||||
}
|
||||
// This coordinator has not previously committed an object mutation either.
|
||||
clients[1].delete_object().bucket(bucket).key("object").send().await?;
|
||||
let error = clients[2]
|
||||
.get_object()
|
||||
.bucket(bucket)
|
||||
.key("object")
|
||||
.send()
|
||||
.await
|
||||
.expect_err("acknowledged deletion must be visible at another entrypoint");
|
||||
assert_eq!(error.as_service_error().and_then(|error| error.meta().code()), Some("NoSuchKey"));
|
||||
|
||||
cluster.stop_node(2)?;
|
||||
let error = clients[0]
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key("below-quorum")
|
||||
.body(Bytes::from_static(body).into())
|
||||
.send()
|
||||
.await
|
||||
.expect_err("removing the cache gate must not weaken storage write quorum");
|
||||
assert_eq!(error.as_service_error().and_then(|error| error.meta().code()), Some("ServiceUnavailable"));
|
||||
cluster.stop();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_degraded_cluster_read_quorum_follows_erasure_layout() -> TestResult {
|
||||
crate::common::init_logging();
|
||||
|
||||
for (node_count, parity) in [(4, 2), (6, 3), (6, 2)] {
|
||||
for (node_count, parity) in [(3, 1), (4, 2), (6, 3), (6, 2), (4, 0)] {
|
||||
let read_quorum = node_count - parity;
|
||||
let write_quorum = read_quorum + usize::from(read_quorum == parity);
|
||||
let mut cluster = RustFSTestClusterEnvironment::new(node_count).await?;
|
||||
cluster.set_env("RUSTFS_STORAGE_CLASS_STANDARD", format!("EC:{parity}"));
|
||||
if parity == 0 {
|
||||
cluster.set_env("RUSTFS_STORAGE_CLASS_RRS", "EC:0");
|
||||
}
|
||||
// Wait for every seed fanout before removing any physical shard.
|
||||
cluster.set_env("RUSTFS_PUT_RENAME_EARLY_ACK_ENABLE", "false");
|
||||
cluster.set_env("RUSTFS_OBS_METRICS_EXPORT_ENABLED", "false");
|
||||
@@ -338,6 +430,277 @@ async fn test_degraded_cluster_read_quorum_follows_erasure_layout() -> TestResul
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_cold_start_read_quorum_follows_erasure_layout() -> TestResult {
|
||||
crate::common::init_logging();
|
||||
for (node_count, parity) in [(3, 1), (4, 2), (6, 3), (6, 2), (4, 0)] {
|
||||
let read_quorum = node_count - parity;
|
||||
let write_quorum = read_quorum + usize::from(read_quorum == parity);
|
||||
let mut cluster = RustFSTestClusterEnvironment::new(node_count).await?;
|
||||
cluster.set_env("RUSTFS_STORAGE_CLASS_STANDARD", format!("EC:{parity}"));
|
||||
if parity == 0 {
|
||||
cluster.set_env("RUSTFS_STORAGE_CLASS_RRS", "EC:0");
|
||||
}
|
||||
cluster.set_env("RUSTFS_PUT_RENAME_EARLY_ACK_ENABLE", "false");
|
||||
cluster.set_env("RUSTFS_OBS_METRICS_EXPORT_ENABLED", "false");
|
||||
cluster.set_env("RUST_LOG", "warn");
|
||||
cluster.start().await?;
|
||||
let clients = cluster
|
||||
.create_all_clients()?
|
||||
.into_iter()
|
||||
.map(|client| {
|
||||
Client::from_conf(
|
||||
client
|
||||
.config()
|
||||
.to_builder()
|
||||
.retry_config(aws_sdk_s3::config::retry::RetryConfig::standard().with_max_attempts(1))
|
||||
.build(),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let bucket = format!("cold-read-quorum-{node_count}-{parity}");
|
||||
clients[0].create_bucket().bucket(&bucket).send().await?;
|
||||
let small = b"cold startup follows the stored EC layout".to_vec();
|
||||
let large = vec![73; 256 * 1024];
|
||||
for (key, body) in [
|
||||
("warm-small", &small),
|
||||
("warm-large", &large),
|
||||
("cold-large", &large),
|
||||
("below-quorum", &large),
|
||||
] {
|
||||
clients[0]
|
||||
.put_object()
|
||||
.bucket(&bucket)
|
||||
.key(key)
|
||||
.body(Bytes::copy_from_slice(body).into())
|
||||
.send()
|
||||
.await?;
|
||||
for node in &cluster.nodes {
|
||||
let census =
|
||||
crate::chaos::census_object_version_on_disk(std::path::Path::new(&node.data_dir), &bucket, key, None)?;
|
||||
assert!(census.is_complete(), "seed shard must exist before cold startup: {census:?}");
|
||||
assert_eq!((census.data_blocks, census.parity_blocks), (Some(read_quorum), Some(parity)));
|
||||
}
|
||||
}
|
||||
for node in 0..node_count {
|
||||
cluster.stop_node(node)?;
|
||||
}
|
||||
// Rebuild every process's in-memory state below read quorum, then
|
||||
// cross the EC boundary. A format bootstrap must not admit S3 reads
|
||||
// merely because half the peers are online (e.g. six disks, EC:2).
|
||||
for node in 0..read_quorum - 1 {
|
||||
cluster.start_node_process(node).await?;
|
||||
}
|
||||
let error = clients[0]
|
||||
.get_object()
|
||||
.bucket(&bucket)
|
||||
.key("below-quorum")
|
||||
.send()
|
||||
.await
|
||||
.expect_err("cold startup below the configured EC read quorum must stay unavailable");
|
||||
assert_eq!(error.raw_response().map(|response| response.status().as_u16()), Some(503));
|
||||
cluster.start_node_process(read_quorum - 1).await?;
|
||||
wait_for_quorum_read_admission(&clients[..read_quorum], &bucket).await?;
|
||||
for client in clients.iter().take(read_quorum) {
|
||||
assert_quorum_object_body(client, &bucket, "warm-small", &small).await?;
|
||||
assert_quorum_object_body(client, &bucket, "cold-large", &large).await?;
|
||||
let write = client
|
||||
.put_object()
|
||||
.bucket(&bucket)
|
||||
.key("cold-quorum-write")
|
||||
.body(Bytes::copy_from_slice(&small).into())
|
||||
.send()
|
||||
.await;
|
||||
if read_quorum >= write_quorum {
|
||||
write?;
|
||||
assert_quorum_object_body(client, &bucket, "cold-quorum-write", &small).await?;
|
||||
} else {
|
||||
let error = write.expect_err("cold startup at read quorum must preserve the EC write quorum");
|
||||
assert_eq!(error.raw_response().map(|response| response.status().as_u16()), Some(503));
|
||||
}
|
||||
}
|
||||
for node in read_quorum..node_count {
|
||||
cluster.start_node(node).await?;
|
||||
}
|
||||
for client in &clients {
|
||||
assert_quorum_object_body(client, &bucket, "cold-large", &large).await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_four_by_six_cold_start_at_read_quorum() -> TestResult {
|
||||
crate::common::init_logging();
|
||||
let mut cluster = RustFSTestClusterEnvironment::with_topology(ClusterTopology::single_pool_multidrive(4, 6)).await?;
|
||||
// Explicit URLs are consumed in order. Place one drive from each node in
|
||||
// every set, matching a four-host, six-drive-per-host deployment.
|
||||
let volumes = (0..6)
|
||||
.flat_map(|drive| {
|
||||
cluster
|
||||
.nodes
|
||||
.iter()
|
||||
.map(move |node| format!("http://{}{}", node.address, node.data_dirs[drive]))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
cluster.set_env("RUSTFS_VOLUMES", volumes);
|
||||
cluster.set_env("RUSTFS_ERASURE_SET_DRIVE_COUNT", "4");
|
||||
cluster.set_env("RUSTFS_STORAGE_CLASS_STANDARD", "EC:2");
|
||||
cluster.set_env("RUSTFS_PUT_RENAME_EARLY_ACK_ENABLE", "false");
|
||||
cluster.set_env("RUSTFS_OBS_METRICS_EXPORT_ENABLED", "false");
|
||||
cluster.set_env("RUST_LOG", "error");
|
||||
cluster.start().await?;
|
||||
let clients = cluster
|
||||
.create_all_clients()?
|
||||
.into_iter()
|
||||
.map(|client| {
|
||||
Client::from_conf(
|
||||
client
|
||||
.config()
|
||||
.to_builder()
|
||||
.retry_config(aws_sdk_s3::config::retry::RetryConfig::standard().with_max_attempts(1))
|
||||
.build(),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let bucket = "cold-start-read-quorum";
|
||||
clients[0].create_bucket().bucket(bucket).send().await?;
|
||||
let mut fixtures = Vec::new();
|
||||
let mut covered = [[false; 2]; 6];
|
||||
for index in 0..128 {
|
||||
let key = match index {
|
||||
0 => "warm-small".to_string(),
|
||||
1 => "warm-large".to_string(),
|
||||
_ => format!("cold-{index}"),
|
||||
};
|
||||
let size_class = index % 2;
|
||||
let size = if size_class == 0 { 71 } else { 256 * 1024 };
|
||||
let body = (0..size)
|
||||
.map(|offset| u8::try_from(offset % 251).expect("bounded fixture byte"))
|
||||
.collect::<Vec<_>>();
|
||||
clients[0]
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(&key)
|
||||
.body(Bytes::copy_from_slice(&body).into())
|
||||
.send()
|
||||
.await?;
|
||||
for node in &cluster.nodes {
|
||||
let mut complete_shards = 0;
|
||||
for (drive, path) in node.data_dirs.iter().enumerate() {
|
||||
let census = crate::chaos::census_object_version_on_disk(std::path::Path::new(path), bucket, &key, None)?;
|
||||
if census.has_xl_meta {
|
||||
assert!(census.is_complete(), "seed shard must be complete before shutdown: {census:?}");
|
||||
assert_eq!((census.data_blocks, census.parity_blocks), (Some(2), Some(2)));
|
||||
covered[drive][size_class] = true;
|
||||
complete_shards += 1;
|
||||
}
|
||||
}
|
||||
assert_eq!(complete_shards, 1, "each node must own one shard of every seed object");
|
||||
}
|
||||
fixtures.push((key, body));
|
||||
if covered.iter().flatten().all(|covered| *covered) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
covered.iter().flatten().all(|covered| *covered),
|
||||
"fixtures must cover all six sets with inline and large objects"
|
||||
);
|
||||
|
||||
for first in 0..4 {
|
||||
for second in first + 1..4 {
|
||||
for node in 0..4 {
|
||||
cluster.stop_node(node)?;
|
||||
}
|
||||
cluster.start_node_process(first).await?;
|
||||
let error = clients[first]
|
||||
.get_object()
|
||||
.bucket(bucket)
|
||||
.key("warm-large")
|
||||
.send()
|
||||
.await
|
||||
.expect_err("one node cannot satisfy read quorum after a cold start");
|
||||
assert_eq!(error.raw_response().map(|response| response.status().as_u16()), Some(503));
|
||||
cluster.start_node_process(second).await?;
|
||||
wait_for_quorum_read_admission(&[clients[first].clone(), clients[second].clone()], bucket).await?;
|
||||
for node in [first, second] {
|
||||
for (key, body) in &fixtures {
|
||||
assert_quorum_object_body(&clients[node], bucket, key, body).await?;
|
||||
}
|
||||
let range = clients[node]
|
||||
.get_object()
|
||||
.bucket(bucket)
|
||||
.key("warm-large")
|
||||
.range("bytes=17-1023")
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(range.body.collect().await?.into_bytes().as_ref(), &fixtures[1].1[17..1024]);
|
||||
let listing = clients[node].list_objects_v2().bucket(bucket).send().await?;
|
||||
for (key, _) in &fixtures {
|
||||
assert!(
|
||||
listing.contents().iter().any(|object| object.key() == Some(key.as_str())),
|
||||
"listing omitted {key}"
|
||||
);
|
||||
}
|
||||
let error = clients[node]
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key("recovery-write")
|
||||
.body(Bytes::from_static(b"after recovery").into())
|
||||
.send()
|
||||
.await
|
||||
.expect_err("two nodes must not authorize a write");
|
||||
assert_eq!(error.raw_response().map(|response| response.status().as_u16()), Some(503));
|
||||
}
|
||||
let error = clients[first]
|
||||
.delete_object()
|
||||
.bucket(bucket)
|
||||
.key("warm-large")
|
||||
.send()
|
||||
.await
|
||||
.expect_err("two nodes must not authorize a delete");
|
||||
assert_eq!(error.raw_response().map(|response| response.status().as_u16()), Some(503));
|
||||
assert_quorum_object_body(&clients[first], bucket, "warm-large", &fixtures[1].1).await?;
|
||||
|
||||
let remaining = (0..4).filter(|node| *node != first && *node != second).collect::<Vec<_>>();
|
||||
cluster.start_node(remaining[0]).await?;
|
||||
let deadline = tokio::time::Instant::now() + Duration::from_secs(90);
|
||||
loop {
|
||||
match clients[first]
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key("recovery-write")
|
||||
.body(Bytes::from_static(b"after recovery").into())
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(_) => break,
|
||||
Err(error)
|
||||
if error.raw_response().is_some_and(|response| response.status().as_u16() == 503)
|
||||
&& tokio::time::Instant::now() < deadline =>
|
||||
{
|
||||
tokio::time::sleep(Duration::from_millis(200)).await
|
||||
}
|
||||
Err(error) => return Err(error.into()),
|
||||
}
|
||||
}
|
||||
for node in [first, second, remaining[0]] {
|
||||
assert_quorum_object_body(&clients[node], bucket, "recovery-write", b"after recovery").await?;
|
||||
}
|
||||
cluster.start_node(remaining[1]).await?;
|
||||
for client in &clients {
|
||||
for (key, body) in &fixtures {
|
||||
assert_quorum_object_body(client, bucket, key, body).await?;
|
||||
}
|
||||
assert_quorum_object_body(client, bucket, "recovery-write", b"after recovery").await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn put_object(client: Client, payload: Vec<u8>, writer_id: usize) -> Result<(), String> {
|
||||
client
|
||||
.put_object()
|
||||
|
||||
@@ -465,6 +465,13 @@ impl NodeService for MinimalLockNodeService {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
|
||||
async fn recover_delete_transaction(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::RecoverDeleteTransactionRequest>,
|
||||
) -> Result<Response<rustfs_protos::proto_gen::node_service::RecoverDeleteTransactionResponse>, Status> {
|
||||
Err(Status::unimplemented("delete recovery is not supported by the lock fixture"))
|
||||
}
|
||||
|
||||
async fn settle_part_transaction(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::SettlePartTransactionRequest>,
|
||||
|
||||
@@ -3945,7 +3945,15 @@ async fn test_bucket_replication_version_purge_of_non_inline_object_releases_sou
|
||||
(sse_key, sse_put.version_id().ok_or("SSE PUT omitted version ID")?.to_string()),
|
||||
(large_key, large_put.version_id().ok_or("large PUT omitted version ID")?.to_string()),
|
||||
];
|
||||
assert_replication_converged(&source_client, &source_bucket, &target_client, &target_bucket).await?;
|
||||
// Managed-SSE listings may omit ETags. Check the replicated version's body
|
||||
// before deleting it rather than comparing keyless listing fingerprints.
|
||||
for (key, version_id) in &purged {
|
||||
let source_body = get_version_body(&source_client, &source_bucket, key, version_id).await?;
|
||||
let expected_sha256: [u8; 32] = Sha256::digest(&source_body).into();
|
||||
wait_for_replicated_sha256(&target_client, &target_bucket, key, expected_sha256).await?;
|
||||
let target_body = get_version_body(&target_client, &target_bucket, key, version_id).await?;
|
||||
assert_eq!(target_body, source_body, "replicated version body differs for {key}/{version_id}");
|
||||
}
|
||||
|
||||
for (key, version_id) in &purged {
|
||||
source_client
|
||||
@@ -3956,10 +3964,6 @@ async fn test_bucket_replication_version_purge_of_non_inline_object_releases_sou
|
||||
.send()
|
||||
.await?;
|
||||
}
|
||||
assert_replication_converged(&source_client, &source_bucket, &target_client, &target_bucket).await?;
|
||||
let target_state = list_replication_state(&target_client, &target_bucket).await?;
|
||||
assert!(target_state.is_empty(), "target retained an explicitly purged version: {target_state:?}");
|
||||
|
||||
// The purge state is applied on the source asynchronously after the target
|
||||
// acknowledges the delete; only then does the retained version go away and
|
||||
// the bucket become deletable. A listing that is empty while DeleteBucket
|
||||
@@ -3984,6 +3988,9 @@ async fn test_bucket_replication_version_purge_of_non_inline_object_releases_sou
|
||||
}
|
||||
}
|
||||
|
||||
let target_state = list_replication_state(&target_client, &target_bucket).await?;
|
||||
assert!(target_state.is_empty(), "target retained an explicitly purged version: {target_state:?}");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -740,7 +740,7 @@ async fn test_bucket_tags_opa_contract() -> TestResult {
|
||||
.version_id(version)
|
||||
.send()
|
||||
.await?;
|
||||
opa.expect_bucket_tags("s3:GetObject", source, Some("finance")).await?;
|
||||
opa.expect_bucket_tags("s3:GetObjectVersion", source, Some("finance")).await?;
|
||||
client.list_objects_v2().bucket(source).send().await?;
|
||||
opa.expect_bucket_tags("s3:ListBucket", source, Some("finance")).await?;
|
||||
client.list_object_versions().bucket(source).send().await?;
|
||||
|
||||
@@ -2345,7 +2345,7 @@ fn layout_reported_parts(attributes: &aws_sdk_s3::operation::get_object_attribut
|
||||
attributes.object_parts().map(|parts| parts.parts().len())
|
||||
}
|
||||
|
||||
async fn assert_layout_readable(client: &Client, case: &LayoutCase, context: &str) -> TestResult {
|
||||
async fn assert_layout_full_body(client: &Client, case: &LayoutCase, context: &str) -> TestResult {
|
||||
let label = case.label();
|
||||
let head = layout_head(client, case).await?;
|
||||
assert_eq!(
|
||||
@@ -2362,7 +2362,12 @@ async fn assert_layout_readable(client: &Client, case: &LayoutCase, context: &st
|
||||
let full = layout_get(client, case, None, None).await?.body.collect().await?.into_bytes();
|
||||
assert_eq!(full.len(), case.body.len(), "{context}: {label}: full GET length");
|
||||
assert!(full == case.body, "{context}: {label}: full GET body must equal the rc.5 upload");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn assert_layout_readable(client: &Client, case: &LayoutCase, context: &str) -> TestResult {
|
||||
assert_layout_full_body(client, case, context).await?;
|
||||
let label = case.label();
|
||||
if case.is_multipart_layout() {
|
||||
let first = case.part_sizes[0];
|
||||
let range = format!("bytes={}-{}", first - 32, first + 31);
|
||||
@@ -2641,10 +2646,11 @@ async fn direct_upgrade_from_rc5_preserves_multipart_layouts() -> TestResult {
|
||||
"rc.5 wrote a legacy layout"
|
||||
);
|
||||
}
|
||||
// The rc.5 writer must itself still read what it wrote, so a later
|
||||
// failure is attributable to the upgrade rather than to the fixture.
|
||||
// Prove the rc.5 writer persisted every original byte before upgrading.
|
||||
// Its compressed cross-part range reader has a legacy limitation; the
|
||||
// upgraded reader must still satisfy every range and partNumber assertion.
|
||||
for case in &cases {
|
||||
assert_layout_readable(&old_client, case, "rc.5").await?;
|
||||
assert_layout_full_body(&old_client, case, "rc.5").await?;
|
||||
}
|
||||
|
||||
// Upgrade in place.
|
||||
|
||||
@@ -183,6 +183,7 @@ sha2 = { workspace = true }
|
||||
hex-simd = { workspace = true }
|
||||
tempfile.workspace = true
|
||||
hyper = { workspace = true, features = ["http2", "http1", "server"] }
|
||||
h2 = { workspace = true }
|
||||
hyper-util = { workspace = true, features = ["tokio", "server-auto", "server-graceful", "tracing"] }
|
||||
hyper-rustls = { workspace = true, default-features = false, features = ["native-tokio", "http1", "tls12", "logging", "http2", "aws-lc-rs"] }
|
||||
hostname.workspace = true
|
||||
@@ -200,8 +201,8 @@ md-5.workspace = true
|
||||
memmap2 = { workspace = true }
|
||||
libc.workspace = true
|
||||
# "process" adds getrlimit for the io_uring fd-cache RLIMIT_NOFILE gate
|
||||
# (backlog#1178); "fs" comes from the workspace default.
|
||||
rustix = { workspace = true, features = ["process", "fs"] }
|
||||
# (backlog#1178); "thread" provides cleanup affinity; "fs" is a workspace default.
|
||||
rustix = { workspace = true, features = ["process", "fs", "thread"] }
|
||||
rustfs-madmin.workspace = true
|
||||
reqwest = { workspace = true }
|
||||
aws-sdk-s3 = { workspace = true, default-features = false, features = ["sigv4a", "default-https-client", "rt-tokio"] }
|
||||
|
||||
@@ -199,10 +199,10 @@ pub mod bucket {
|
||||
get_object_lock_config, get_object_lock_config_state, get_on_demand_migration_config,
|
||||
get_on_demand_migration_config_in, get_public_access_block_config, get_quota_config, get_replication_config,
|
||||
get_request_payment_config, get_sse_config, get_tagging_config, get_versioning_config, get_website_config,
|
||||
init_bucket_metadata_sys, list_bucket_targets, reload_bucket_metadata, remove_bucket_metadata, set_bucket_metadata,
|
||||
update, update_bucket_targets_under_transaction_lock, update_config_with, update_if_incarnation,
|
||||
update_if_incarnation_at, update_quota_if_incarnation, update_quota_if_incarnation_at, update_under_transaction_lock,
|
||||
update_under_transaction_lock_at,
|
||||
init_bucket_metadata_sys, list_bucket_targets, reload_bucket_metadata, remove_bucket_metadata,
|
||||
remove_bucket_metadata_if_absent, set_bucket_metadata, update, update_bucket_targets_under_transaction_lock,
|
||||
update_config_with, update_if_incarnation, update_if_incarnation_at, update_quota_if_incarnation,
|
||||
update_quota_if_incarnation_at, update_under_transaction_lock, update_under_transaction_lock_at,
|
||||
};
|
||||
#[cfg(feature = "test-util")]
|
||||
pub use crate::bucket::metadata_sys::{ConfigWriteLockProbe, test_support};
|
||||
@@ -210,7 +210,8 @@ pub mod bucket {
|
||||
|
||||
pub mod migration {
|
||||
pub use crate::bucket::migration::{
|
||||
LegacyBlobDecryptFn, migration_startup_error, try_migrate_bucket_metadata, try_migrate_iam_config,
|
||||
LegacyBlobDecryptFn, migration_startup_error, try_migrate_bucket_metadata, try_migrate_bucket_metadata_for_buckets,
|
||||
try_migrate_iam_config,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -371,24 +372,6 @@ pub mod config {
|
||||
with_config_object_read_lock, with_config_object_write_lock, with_server_config_read_lock,
|
||||
with_server_config_write_lock,
|
||||
};
|
||||
|
||||
/// Read config bytes and metadata without treating an empty payload as absent.
|
||||
pub async fn read_config_preserve_empty_with_metadata_opts(
|
||||
api: std::sync::Arc<crate::store::ECStore>,
|
||||
file: &str,
|
||||
opts: &crate::object_api::ObjectOptions,
|
||||
) -> crate::error::Result<(Vec<u8>, crate::object_api::ObjectInfo)> {
|
||||
crate::config::com::read_config_preserve_empty_with_metadata_opts(api, file, opts).await
|
||||
}
|
||||
|
||||
pub async fn read_config_limited_preserve_empty_with_metadata_opts(
|
||||
api: std::sync::Arc<crate::store::ECStore>,
|
||||
file: &str,
|
||||
opts: &crate::object_api::ObjectOptions,
|
||||
max_bytes: usize,
|
||||
) -> crate::error::Result<(Vec<u8>, crate::object_api::ObjectInfo)> {
|
||||
crate::config::com::read_config_limited_preserve_empty_with_metadata_opts(api, file, opts, max_bytes).await
|
||||
}
|
||||
}
|
||||
|
||||
pub mod storageclass {
|
||||
@@ -440,7 +423,9 @@ pub mod disk {
|
||||
pub use crate::disk::os::{LocalPublicationPause, LocalPublicationStage};
|
||||
pub use crate::disk::{
|
||||
BATCH_READ_VERSION_MAX_ITEMS, BUCKET_META_PREFIX, BatchReadVersionItem, BatchReadVersionReq, BatchReadVersionResp,
|
||||
CheckPartsResp, ConditionalFileUpdate, DeleteOptions, Disk, DiskAPI, DiskInfo, DiskInfoOptions, DiskLocation, DiskOption,
|
||||
CheckPartsResp, ConditionalFileUpdate, DELETE_RECOVERY_PROTOCOL_VERSION, DeleteOptions, DeleteRecoveryAction,
|
||||
DeleteRecoveryCursor, DeleteRecoveryDigest, DeleteRecoveryInspection, DeleteRecoveryPage, DeleteRecoveryRpcOperation,
|
||||
DeleteRecoveryRpcReply, DeleteRecoveryState, Disk, DiskAPI, DiskInfo, DiskInfoOptions, DiskLocation, DiskOption,
|
||||
DiskStore, FileInfoVersions, FileReader, FileWriter, HEALING_MARKER_PATH, NsScannerOpenRequest, OldCurrentSize,
|
||||
PartTransactionAction, RUSTFS_META_BUCKET, ReadMultipleReq, ReadMultipleResp, ReadOptions, RenameDataResp,
|
||||
STORAGE_FORMAT_FILE, SnapshotLeaseToken, UpdateMetadataOpts, VolumeInfo, WalkDirOptions, new_disk,
|
||||
@@ -537,13 +522,14 @@ pub mod integrity {
|
||||
pub mod object {
|
||||
pub use crate::object_api::{
|
||||
BLOCK_SIZE_V2, ERASURE_ALGORITHM, EncryptionResolutionError, EncryptionResolutionErrorKind, GetObjectBodyCacheHook,
|
||||
GetObjectBodyCacheHookLookup, GetObjectBodySource, GetObjectReader, NamespaceLockFence, ObjectEncryptionResolver,
|
||||
ObjectInfo, ObjectLockConfigSnapshot, ObjectMutationHook, ObjectOptions, PutObjReader, QuotaAdmission,
|
||||
RangedDecompressReader, ReadEncryptionMaterial, ReadEncryptionMode, ReadEncryptionRequest,
|
||||
GetObjectBodyCacheHookLookup, GetObjectBodySource, GetObjectReadCondition, GetObjectReadMetadata, GetObjectReader,
|
||||
NamespaceLockFence, ObjectEncryptionResolver, ObjectInfo, ObjectLockConfigSnapshot, ObjectMutationHook, ObjectOptions,
|
||||
PutObjReader, QuotaAdmission, RangedDecompressReader, ReadEncryptionMaterial, ReadEncryptionMode, ReadEncryptionRequest,
|
||||
SCANNER_PUBLICATION_LEASE_FENCE_METADATA_KEY, ScannerPublicationCommitScope, ScannerPublicationCommitStartError,
|
||||
ScannerPublicationCommitState, ShardIntegrityWriteMode, StreamConsumer, WriteCommitGuard, WriteCompletion,
|
||||
get_object_body_cache_plaintext_len, lookup_get_object_body_cache_hook, register_get_object_body_cache_hook,
|
||||
register_object_mutation_hook, unregister_get_object_body_cache_hook, unregister_object_mutation_hook,
|
||||
with_get_object_read_condition,
|
||||
};
|
||||
pub use crate::store::{
|
||||
PrepareSelectObjectSnapshotError, PreparedGetObjectReader, SelectObjectSnapshot, SelectObjectSnapshotReadError,
|
||||
@@ -608,6 +594,7 @@ pub mod set_disk {
|
||||
#[cfg(feature = "test-util")]
|
||||
pub mod test_util {
|
||||
pub use crate::bucket::quota::reservation::fail_next_quota_ledger_save_for_test;
|
||||
pub use crate::set_disk::ConditionalReadBenchmarkMetadataGuard;
|
||||
pub use crate::set_disk::{MultipartCommitBarrier, MultipartCommitPause, PutObjectCommitBarrier, PutObjectCommitPause};
|
||||
|
||||
/// Keep a namespace commit pending until the returned owner is dropped.
|
||||
|
||||
@@ -2417,6 +2417,16 @@ fn is_slow_down(err: &Error) -> bool {
|
||||
matches!(err, Error::SlowDown)
|
||||
}
|
||||
|
||||
/// Lifecycle expiry is idempotent when the exact object or version has already
|
||||
/// disappeared between the scanner listing and the delete admission.
|
||||
///
|
||||
/// Keep this predicate narrower than bucket/volume "not found" helpers: an
|
||||
/// unavailable bucket or disk is an operational failure and must not be
|
||||
/// reported as a completed expiry.
|
||||
fn is_lifecycle_expiry_already_gone(err: &Error) -> bool {
|
||||
is_err_object_not_found(err) || is_err_version_not_found(err)
|
||||
}
|
||||
|
||||
/// Resolves the expiry worker count from the single documented knob,
|
||||
/// `RUSTFS_MAX_EXPIRY_WORKERS`: a set, parsable, non-zero value wins;
|
||||
/// anything else falls back to `min(cpus, 16)`. The historical
|
||||
@@ -4920,6 +4930,20 @@ async fn expire_transitioned_object_with_lock_lost_signal(
|
||||
//audit_log_lifecycle(*oi, ILMExpiry, tags, traceFn);
|
||||
Ok(dobj)
|
||||
}
|
||||
Err(err) if is_lifecycle_expiry_already_gone(&err) => {
|
||||
debug!(
|
||||
event = EVENT_LIFECYCLE_DELETE_COMPLETED,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_LIFECYCLE,
|
||||
bucket = %oi.bucket,
|
||||
object = %oi.name,
|
||||
operation = "delete_restored_object",
|
||||
reason = "already_gone",
|
||||
"Lifecycle delete target was already absent"
|
||||
);
|
||||
crate::object_api::notify_object_mutation(&oi.bucket, &oi.name).await;
|
||||
Ok(oi.clone())
|
||||
}
|
||||
Err(err) => Err(std::io::Error::other(err)),
|
||||
};
|
||||
}
|
||||
@@ -4930,6 +4954,8 @@ async fn expire_transitioned_object_with_lock_lost_signal(
|
||||
// pointing to a remote tier version that has already been removed. If this
|
||||
// only creates a delete marker, remote cleanup must be driven by persisted
|
||||
// free-version recovery rather than the visible delete result.
|
||||
// Keep remote transitioned not-found errors visible: local metadata
|
||||
// absence alone does not prove that the tier object was cleaned up.
|
||||
let dobj = match api.delete_object(&oi.bucket, &oi.name, opts).await {
|
||||
Ok(obj) => obj,
|
||||
Err(e) => {
|
||||
@@ -5802,6 +5828,20 @@ async fn apply_expiry_on_non_transitioned_objects_with_lock_lost_signal(
|
||||
.await
|
||||
{
|
||||
Ok(dobj) => dobj,
|
||||
Err(err) if is_lifecycle_expiry_already_gone(&err) => {
|
||||
debug!(
|
||||
event = EVENT_LIFECYCLE_DELETE_COMPLETED,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_LIFECYCLE,
|
||||
bucket = %oi.bucket,
|
||||
object = %oi.name,
|
||||
operation = "delete_object",
|
||||
reason = "already_gone",
|
||||
"Lifecycle delete target was already absent"
|
||||
);
|
||||
crate::object_api::notify_object_mutation(&oi.bucket, &oi.name).await;
|
||||
return true;
|
||||
}
|
||||
Err(e) => {
|
||||
error!(
|
||||
event = EVENT_LIFECYCLE_DELETE_FAILED,
|
||||
@@ -6041,18 +6081,19 @@ mod tests {
|
||||
cleanup_empty_multipart_sha_dirs_on_local_disks, cleanup_stale_multipart_uploads_once_at,
|
||||
enqueue_recovered_free_version_with_state, enqueue_transition_for_existing_objects_scoped,
|
||||
enqueue_transition_with_lifecycle, enqueue_transition_with_lifecycle_report, eval_action_from_lifecycle,
|
||||
get_lock_acquire_timeout, jitter_tier_free_version_recovery_delay, lifecycle_action_blocked_by_replication,
|
||||
lifecycle_delete_all_versions_replication_scan, lifecycle_deleted_object, lifecycle_replication_blocks_action,
|
||||
lifecycle_rule_has_date_expiration, lifecycle_version_delete_target, manual_transition_duration_elapsed,
|
||||
manual_transition_has_more_after_limit, manual_transition_recovery_progress_sink, manual_transition_version_marker,
|
||||
manual_transition_worker_failure_reason, mark_delete_opts_skip_decommissioned_on_remote_success,
|
||||
merge_stale_multipart_candidate, persist_manual_transition_job_progress_if_owned,
|
||||
persist_manual_transition_page_checkpoint, recover_manual_transition_job, recover_manual_transition_jobs,
|
||||
resolve_tier_free_version_recovery_enabled, resolve_transition_queue_capacity, resolve_transition_queue_send_timeout,
|
||||
resolve_transition_worker_count, resolve_transition_workers_absolute_max, run_tier_free_version_recovery_coordinator,
|
||||
run_tier_free_version_recovery_loop, select_restore_s3_location, set_lifecycle_observability_observer,
|
||||
set_recovered_free_version_enqueue_observer, should_defer_date_expiry_for_recent_config_update,
|
||||
transitioned_cleanup_tuple, transitioned_object_delete_opts, wait_for_tier_free_version_recovery,
|
||||
get_lock_acquire_timeout, is_lifecycle_expiry_already_gone, jitter_tier_free_version_recovery_delay,
|
||||
lifecycle_action_blocked_by_replication, lifecycle_delete_all_versions_replication_scan, lifecycle_deleted_object,
|
||||
lifecycle_replication_blocks_action, lifecycle_rule_has_date_expiration, lifecycle_version_delete_target,
|
||||
manual_transition_duration_elapsed, manual_transition_has_more_after_limit, manual_transition_recovery_progress_sink,
|
||||
manual_transition_version_marker, manual_transition_worker_failure_reason,
|
||||
mark_delete_opts_skip_decommissioned_on_remote_success, merge_stale_multipart_candidate,
|
||||
persist_manual_transition_job_progress_if_owned, persist_manual_transition_page_checkpoint,
|
||||
recover_manual_transition_job, recover_manual_transition_jobs, resolve_tier_free_version_recovery_enabled,
|
||||
resolve_transition_queue_capacity, resolve_transition_queue_send_timeout, resolve_transition_worker_count,
|
||||
resolve_transition_workers_absolute_max, run_tier_free_version_recovery_coordinator, run_tier_free_version_recovery_loop,
|
||||
select_restore_s3_location, set_lifecycle_observability_observer, set_recovered_free_version_enqueue_observer,
|
||||
should_defer_date_expiry_for_recent_config_update, transitioned_cleanup_tuple, transitioned_object_delete_opts,
|
||||
wait_for_tier_free_version_recovery,
|
||||
};
|
||||
#[cfg(feature = "test-util")]
|
||||
use super::{delete_free_version_remote_object_then, encode_dir_object, get_transitioned_object_reader_with_tier_manager};
|
||||
@@ -10511,6 +10552,26 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lifecycle_expiry_already_gone_only_accepts_object_or_version_absence() {
|
||||
assert!(is_lifecycle_expiry_already_gone(&Error::FileNotFound));
|
||||
assert!(is_lifecycle_expiry_already_gone(&Error::ObjectNotFound(
|
||||
"bucket".to_string(),
|
||||
"object".to_string(),
|
||||
)));
|
||||
assert!(is_lifecycle_expiry_already_gone(&Error::FileVersionNotFound));
|
||||
assert!(is_lifecycle_expiry_already_gone(&Error::VersionNotFound(
|
||||
"bucket".to_string(),
|
||||
"object".to_string(),
|
||||
"version".to_string(),
|
||||
)));
|
||||
|
||||
assert!(!is_lifecycle_expiry_already_gone(&Error::VolumeNotFound));
|
||||
assert!(!is_lifecycle_expiry_already_gone(&Error::DiskNotFound));
|
||||
assert!(!is_lifecycle_expiry_already_gone(&Error::ErasureReadQuorum));
|
||||
assert!(!is_lifecycle_expiry_already_gone(&Error::MethodNotAllowed));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn manual_transition_counts_already_transitioned_object() {
|
||||
let lc = latest_transition_lifecycle();
|
||||
@@ -13385,7 +13446,9 @@ mod tests {
|
||||
.bucket_incarnation_id_from_disk(&bucket)
|
||||
.await
|
||||
.expect("bucket incarnation");
|
||||
for (attempt_incarnation, expected) in [(Uuid::new_v4(), false), (incarnation, true), (incarnation, false)] {
|
||||
// Replaying the same scanner snapshot after the delete is an
|
||||
// already-gone success and must not be reported as a failure.
|
||||
for (attempt_incarnation, expected) in [(Uuid::new_v4(), false), (incarnation, true), (incarnation, true)] {
|
||||
assert_eq!(
|
||||
super::apply_expiry_on_non_transitioned_objects(
|
||||
ecstore.clone(),
|
||||
|
||||
@@ -178,6 +178,13 @@ pub(crate) fn object_lock_config_state_from_authoritative_metadata(bm: &BucketMe
|
||||
Ok(ObjectLockConfigState::ConfirmedAbsent)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub(crate) struct CachedQuotaMetadataSnapshot {
|
||||
pub(crate) quota: Option<BucketQuota>,
|
||||
pub(crate) bucket_incarnation: Uuid,
|
||||
pub(crate) quota_revision: OffsetDateTime,
|
||||
}
|
||||
|
||||
/// Convert the persisted serving-layer configuration into the storage-level
|
||||
/// [`DefaultRetention`](crate::bucket::object_lock::types::DefaultRetention)
|
||||
/// the WORM evaluation code consumes (rustfs/backlog#1842). A rule without a
|
||||
@@ -314,6 +321,33 @@ pub async fn remove_bucket_metadata(bucket: &str) -> Result<bool> {
|
||||
Ok(lock.remove(bucket).await)
|
||||
}
|
||||
|
||||
/// Remove a peer's cached metadata only when the bucket is absent under the
|
||||
/// namespace read fence. A delayed delete notification must not withdraw the
|
||||
/// metadata of a same-name bucket that has already been recreated.
|
||||
pub async fn remove_bucket_metadata_if_absent(api: Arc<ECStore>, bucket: &str) -> Result<bool> {
|
||||
if is_meta_bucketname(bucket) {
|
||||
return Ok(false);
|
||||
}
|
||||
// Lock order: bucket namespace read -> metadata system read (released)
|
||||
// -> metadata publish, matching peer reloads.
|
||||
let namespace_lock = api.new_ns_lock(bucket, bucket).await?;
|
||||
let namespace_guard = namespace_lock
|
||||
.get_read_lock(crate::set_disk::get_lock_acquire_timeout())
|
||||
.await?;
|
||||
let sys = bucket_metadata_sys_of(&api.ctx)?;
|
||||
let sys = sys.read().await.clone();
|
||||
if sys
|
||||
.bucket_exists(bucket, &namespace_guard, "peer bucket metadata removal existence check")
|
||||
.await?
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
await_bucket_namespace_operation(Some(&namespace_guard), bucket, "peer bucket metadata removal", async {
|
||||
Ok(sys.remove(bucket).await)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
fn start_refresh_buckets_metadata_loop(sys: Arc<RwLock<BucketMetadataSys>>) {
|
||||
let Some(cancel_token) = runtime_sources::background_services_cancel_token() else {
|
||||
warn!("bucket metadata refresh loop skipped because background cancellation token is not initialized");
|
||||
@@ -491,6 +525,36 @@ pub(crate) async fn get_bucket_incarnation_id_in(ctx: &crate::runtime::instance:
|
||||
sys.get_bucket_incarnation_id_from_disk(bucket).await
|
||||
}
|
||||
|
||||
/// Directory loss is recoverable only for a committed, non-retired generation.
|
||||
/// The caller retains the bucket lifecycle fence through the ensuing reconstruction.
|
||||
pub(crate) async fn validate_bucket_reconstruction_in(
|
||||
ctx: &crate::runtime::instance::InstanceContext,
|
||||
bucket: &str,
|
||||
expected: Uuid,
|
||||
) -> Result<()> {
|
||||
let api = bucket_metadata_sys_of(ctx)?.read().await.object_store();
|
||||
let lock = api
|
||||
.new_ns_lock(RUSTFS_META_BUCKET, &bucket_metadata_transaction_lock_key(bucket))
|
||||
.await?;
|
||||
let guard = lock.get_read_lock(crate::set_disk::get_lock_acquire_timeout()).await?;
|
||||
let (metadata, persisted) = load_bucket_metadata_parse_with_presence(api.clone(), bucket, true).await?;
|
||||
if !persisted
|
||||
|| !metadata.bucket_incarnation_sidecar
|
||||
|| expected.is_nil()
|
||||
|| metadata.bucket_incarnation_id != expected
|
||||
|| !metadata.bucket_creation_committed
|
||||
{
|
||||
return Err(Error::PreconditionFailed);
|
||||
}
|
||||
if crate::bucket::retirement::is_retired(api, bucket, expected).await? {
|
||||
return Err(Error::BucketNotFound(bucket.to_owned()));
|
||||
}
|
||||
if guard.is_lock_lost() {
|
||||
return Err(Error::PreconditionFailed);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate against disk while retaining an already-held Object Lock fence.
|
||||
pub(crate) async fn get_bucket_incarnation_id_for_options_in(
|
||||
ctx: &crate::runtime::instance::InstanceContext,
|
||||
@@ -1423,6 +1487,9 @@ fn quota_config_and_incarnation_from_authority(
|
||||
BucketMetadataAuthority::Authoritative(metadata)
|
||||
if metadata.bucket_incarnation_sidecar && !metadata.bucket_incarnation_id.is_nil() =>
|
||||
{
|
||||
if !metadata.quota_config_json.is_empty() && metadata.quota_config.is_none() {
|
||||
return Err(Error::other("persisted bucket quota configuration is invalid"));
|
||||
}
|
||||
Ok((
|
||||
metadata.quota_config.clone(),
|
||||
metadata.bucket_incarnation_id,
|
||||
@@ -1437,6 +1504,15 @@ fn quota_config_and_incarnation_from_authority(
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn get_cached_quota_config_and_incarnation_in(
|
||||
ctx: &crate::runtime::instance::InstanceContext,
|
||||
bucket: &str,
|
||||
) -> Result<Option<CachedQuotaMetadataSnapshot>> {
|
||||
let bucket_meta_sys_lock = bucket_metadata_sys_of(ctx)?;
|
||||
let bucket_meta_sys = bucket_meta_sys_lock.read().await.clone();
|
||||
bucket_meta_sys.cached_quota_config_and_incarnation(bucket).await
|
||||
}
|
||||
|
||||
pub async fn get_replication_config(bucket: &str) -> Result<(ReplicationConfiguration, OffsetDateTime)> {
|
||||
let bucket_meta_sys_lock = get_bucket_metadata_sys()?;
|
||||
let bucket_meta_sys = bucket_meta_sys_lock.read().await;
|
||||
@@ -2952,6 +3028,27 @@ impl BucketMetadataSys {
|
||||
}
|
||||
}
|
||||
|
||||
async fn cached_quota_config_and_incarnation(&self, bucket: &str) -> Result<Option<CachedQuotaMetadataSnapshot>> {
|
||||
let metadata = {
|
||||
let metadata_map = self.metadata_map.read().await;
|
||||
metadata_map.get(bucket).cloned()
|
||||
};
|
||||
let Some(metadata) = metadata else {
|
||||
return Ok(None);
|
||||
};
|
||||
if !metadata.bucket_incarnation_sidecar || metadata.bucket_incarnation_id.is_nil() {
|
||||
return Ok(None);
|
||||
}
|
||||
if !metadata.quota_config_json.is_empty() && metadata.quota_config.is_none() {
|
||||
return Err(Error::other("persisted bucket quota configuration is invalid"));
|
||||
}
|
||||
Ok(Some(CachedQuotaMetadataSnapshot {
|
||||
quota: metadata.quota_config.clone(),
|
||||
bucket_incarnation: metadata.bucket_incarnation_id,
|
||||
quota_revision: metadata.quota_config_updated_at,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn get_replication_config(&self, bucket: &str) -> Result<(ReplicationConfiguration, OffsetDateTime)> {
|
||||
let (bm, _) = self.get_config(bucket).await?;
|
||||
|
||||
@@ -4856,6 +4953,154 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn quota_admission_rejects_stale_cached_no_quota() {
|
||||
let (_dirs, store) = isolated_store_over_temp_disks().await;
|
||||
init_bucket_metadata_sys(store.clone(), Vec::new()).await;
|
||||
let sys = bucket_metadata_sys_of(&store.ctx).expect("scoped metadata system");
|
||||
let bucket = "stale-no-quota-admission";
|
||||
store
|
||||
.make_bucket(bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("create bucket");
|
||||
let (stale, _) = sys.read().await.get_config(bucket).await.expect("cache initial metadata");
|
||||
store
|
||||
.update_bucket_metadata_config(bucket, "quota.json", br#"{"quota":1}"#.to_vec())
|
||||
.await
|
||||
.expect("publish quota to authoritative storage");
|
||||
// Model a peer that has not received the newer configuration snapshot.
|
||||
sys.read().await.set(bucket.to_owned(), stale).await;
|
||||
assert!(
|
||||
get_cached_quota_config_and_incarnation_in(&store.ctx, bucket)
|
||||
.await
|
||||
.expect("read stale cache")
|
||||
.expect("cached bucket identity")
|
||||
.quota
|
||||
.is_none()
|
||||
);
|
||||
|
||||
temp_env::async_with_vars([("RUSTFS_QUOTA_BEGIN_SAFE_NO_QUOTA_CACHE_FAST_PATH", Some("true"))], async {
|
||||
let error = crate::bucket::quota::reservation::begin(&store.ctx, bucket, "object", &ObjectOptions::default(), 0, 0)
|
||||
.await
|
||||
.err()
|
||||
.expect("persisted quota must reject admission without its snapshot");
|
||||
assert!(matches!(error, Error::PartMissingOrCorrupt), "{error:?}");
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn peer_reload_clears_negative_cache_after_remote_bucket_creation() {
|
||||
let (dirs, node_a, node_b) = two_nodes_over_one_store().await;
|
||||
let bucket = "cross-node-negative-cache-refresh";
|
||||
let node_b_sys = node_b.read().await.clone();
|
||||
|
||||
assert_eq!(
|
||||
node_b_sys.get_bucket_incarnation_id(bucket).await.unwrap_err(),
|
||||
Error::BucketNotFound(bucket.to_string()),
|
||||
"the probe must populate node B's missing-bucket cache"
|
||||
);
|
||||
|
||||
for dir in &dirs {
|
||||
std::fs::create_dir_all(dir.path().join(bucket)).expect("physical bucket should exist before creation metadata");
|
||||
}
|
||||
let created = BucketMetadata::new(bucket);
|
||||
let created_incarnation = created.bucket_incarnation_id;
|
||||
node_a
|
||||
.read()
|
||||
.await
|
||||
.persist_new_and_set(created)
|
||||
.await
|
||||
.expect("node A should publish the created bucket metadata");
|
||||
|
||||
node_b_sys
|
||||
.reload_from_store(bucket)
|
||||
.await
|
||||
.expect("peer reload should clear the stale negative cache");
|
||||
assert_eq!(
|
||||
node_b_sys.get_bucket_incarnation_id(bucket).await.unwrap(),
|
||||
created_incarnation,
|
||||
"node B should resolve the newly created bucket immediately"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
#[serial]
|
||||
async fn peer_delete_clears_metadata_for_absent_bucket() {
|
||||
let (_dirs, store) = isolated_store_over_temp_disks().await;
|
||||
init_bucket_metadata_sys(store.clone(), Vec::new()).await;
|
||||
let sys = bucket_metadata_sys_of(&store.ctx).expect("metadata owner");
|
||||
let bucket = "peer-delete-absent-bucket";
|
||||
|
||||
store
|
||||
.make_bucket(bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("initial bucket creation");
|
||||
let cached = sys.read().await.get(bucket).await.expect("initial cached metadata");
|
||||
store
|
||||
.delete_bucket(bucket, &DeleteBucketOptions::default())
|
||||
.await
|
||||
.expect("bucket deletion");
|
||||
|
||||
// Model a peer that still caches the metadata after the physical delete.
|
||||
sys.read().await.set(bucket.to_string(), cached).await;
|
||||
assert!(
|
||||
remove_bucket_metadata_if_absent(store.clone(), bucket)
|
||||
.await
|
||||
.expect("conditional peer delete"),
|
||||
"the peer must evict the deleted bucket's cached metadata"
|
||||
);
|
||||
let sys = sys.read().await;
|
||||
assert!(matches!(sys.get(bucket).await, Err(Error::ConfigNotFound)));
|
||||
assert_eq!(
|
||||
sys.get_bucket_incarnation_id(bucket)
|
||||
.await
|
||||
.expect_err("deleted bucket lookup"),
|
||||
Error::BucketNotFound(bucket.to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
#[serial]
|
||||
async fn delayed_peer_delete_preserves_recreated_bucket_metadata() {
|
||||
let (_dirs, store) = isolated_store_over_temp_disks().await;
|
||||
init_bucket_metadata_sys(store.clone(), Vec::new()).await;
|
||||
let sys = bucket_metadata_sys_of(&store.ctx).expect("metadata owner");
|
||||
let bucket = "delayed-peer-delete-recreated-bucket";
|
||||
|
||||
store
|
||||
.make_bucket(bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("initial bucket creation");
|
||||
let old_incarnation = store.bucket_incarnation_id_from_disk(bucket).await.expect("old incarnation");
|
||||
store
|
||||
.delete_bucket(bucket, &DeleteBucketOptions::default())
|
||||
.await
|
||||
.expect("bucket deletion");
|
||||
store
|
||||
.make_bucket(bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("bucket recreation");
|
||||
let new_incarnation = store.bucket_incarnation_id_from_disk(bucket).await.expect("new incarnation");
|
||||
assert_ne!(old_incarnation, new_incarnation);
|
||||
|
||||
let removed = remove_bucket_metadata_if_absent(store.clone(), bucket)
|
||||
.await
|
||||
.expect("conditional peer delete");
|
||||
assert!(!removed, "a delayed delete must not remove metadata for a live recreated bucket");
|
||||
assert_eq!(
|
||||
sys.read()
|
||||
.await
|
||||
.get(bucket)
|
||||
.await
|
||||
.expect("recreated metadata should remain cached")
|
||||
.bucket_incarnation_id,
|
||||
new_incarnation,
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
#[serial]
|
||||
async fn stale_config_request_cannot_mutate_a_recreated_bucket() {
|
||||
|
||||
@@ -273,6 +273,30 @@ where
|
||||
|
||||
let buckets: Vec<String> = buckets_list.into_iter().map(|b| b.name).collect();
|
||||
|
||||
try_migrate_bucket_metadata_for_buckets(store, &buckets).await
|
||||
}
|
||||
|
||||
/// Migrate the already discovered startup candidates without repeating a
|
||||
/// majority-only namespace listing. Every migration write retains its quorum.
|
||||
pub async fn try_migrate_bucket_metadata_for_buckets<S>(store: Arc<S>, buckets: &[String]) -> Result<()>
|
||||
where
|
||||
S: ObjectIO<
|
||||
Error = Error,
|
||||
RangeSpec = HTTPRangeSpec,
|
||||
HeaderMap = HeaderMap,
|
||||
ObjectOptions = ObjectOptions,
|
||||
ObjectInfo = ObjectInfo,
|
||||
GetObjectReader = GetObjectReader,
|
||||
PutObjectReader = PutObjReader,
|
||||
> + ObjectOperations<
|
||||
Error = Error,
|
||||
ObjectInfo = ObjectInfo,
|
||||
ObjectOptions = ObjectOptions,
|
||||
FileInfo = FileInfo,
|
||||
ObjectToDelete = ObjectToDelete,
|
||||
DeletedObject = DeletedObject,
|
||||
>,
|
||||
{
|
||||
if buckets.is_empty() {
|
||||
debug!("No migrating bucket metadata found");
|
||||
return Ok(());
|
||||
|
||||
@@ -850,6 +850,11 @@ pub(crate) async fn begin(
|
||||
let snapshot_admission = opts.quota_admission;
|
||||
let data_movement = opts.data_movement;
|
||||
if crate::bucket::utils::is_meta_bucketname(bucket) {
|
||||
let meta_bucket_fast_path_started = rustfs_io_metrics::put_stage_timer();
|
||||
rustfs_io_metrics::record_put_object_stage_duration_from(
|
||||
rustfs_io_metrics::PUT_STAGE_PUT_OBJECT_QUOTA_BEGIN_META_BUCKET_FAST_PATH,
|
||||
meta_bucket_fast_path_started,
|
||||
);
|
||||
return Ok(QuotaContext {
|
||||
store: None,
|
||||
bucket: bucket.to_string(),
|
||||
@@ -906,9 +911,63 @@ pub(crate) async fn begin(
|
||||
});
|
||||
}
|
||||
|
||||
let metadata_lock_started = rustfs_io_metrics::put_stage_timer();
|
||||
let metadata_guard = metadata_sys::acquire_bucket_metadata_transaction_read_lock_for_options_in(ctx, bucket, opts).await?;
|
||||
rustfs_io_metrics::record_put_object_stage_duration_from(
|
||||
rustfs_io_metrics::PUT_STAGE_PUT_OBJECT_QUOTA_BEGIN_METADATA_LOCK,
|
||||
metadata_lock_started,
|
||||
);
|
||||
let cache_lookup_started = rustfs_io_metrics::put_stage_timer();
|
||||
let cached_quota_snapshot = if rustfs_io_metrics::put_stage_metrics_enabled() {
|
||||
match metadata_sys::get_cached_quota_config_and_incarnation_in(ctx, bucket).await {
|
||||
Ok(Some(snapshot)) => {
|
||||
rustfs_io_metrics::record_put_object_quota_cache_decision(
|
||||
"candidate",
|
||||
quota_cache_reason(snapshot.quota.as_ref()),
|
||||
);
|
||||
Some(snapshot)
|
||||
}
|
||||
Ok(None) => {
|
||||
rustfs_io_metrics::record_put_object_quota_cache_decision("miss", "no_authoritative_cache");
|
||||
None
|
||||
}
|
||||
Err(_) => {
|
||||
rustfs_io_metrics::record_put_object_quota_cache_decision("miss", "invalid_cached_quota");
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
rustfs_io_metrics::record_put_object_stage_duration_from(
|
||||
rustfs_io_metrics::PUT_STAGE_PUT_OBJECT_QUOTA_BEGIN_CACHE_LOOKUP,
|
||||
cache_lookup_started,
|
||||
);
|
||||
let config_read_started = rustfs_io_metrics::put_stage_timer();
|
||||
let (quota, bucket_incarnation, quota_revision) =
|
||||
metadata_sys::get_quota_config_and_incarnation_from_disk_for_options_in(ctx, bucket, opts, &metadata_guard).await?;
|
||||
if let Some(cached) = cached_quota_snapshot.as_ref() {
|
||||
let reason = quota_cache_reason(quota.as_ref());
|
||||
if cached.quota == quota && cached.bucket_incarnation == bucket_incarnation && cached.quota_revision == quota_revision {
|
||||
rustfs_io_metrics::record_put_object_quota_cache_decision("authoritative_match", reason);
|
||||
} else {
|
||||
rustfs_io_metrics::record_put_object_quota_cache_decision("authoritative_mismatch", reason);
|
||||
if cached.quota != quota {
|
||||
rustfs_io_metrics::record_put_object_quota_cache_decision("authoritative_mismatch_field", "quota");
|
||||
}
|
||||
if cached.bucket_incarnation != bucket_incarnation {
|
||||
rustfs_io_metrics::record_put_object_quota_cache_decision("authoritative_mismatch_field", "bucket_incarnation");
|
||||
}
|
||||
if cached.quota_revision != quota_revision {
|
||||
rustfs_io_metrics::record_put_object_quota_cache_decision("authoritative_mismatch_field", "quota_revision");
|
||||
}
|
||||
}
|
||||
}
|
||||
rustfs_io_metrics::record_put_object_stage_duration_from(
|
||||
rustfs_io_metrics::PUT_STAGE_PUT_OBJECT_QUOTA_BEGIN_CONFIG_READ,
|
||||
config_read_started,
|
||||
);
|
||||
let lock_lost_check_started = rustfs_io_metrics::put_stage_timer();
|
||||
if metadata_guard.is_lock_lost() {
|
||||
return Err(StorageError::NamespaceLockQuorumUnavailable {
|
||||
mode: "quota_config",
|
||||
@@ -918,6 +977,11 @@ pub(crate) async fn begin(
|
||||
achieved: 0,
|
||||
});
|
||||
}
|
||||
rustfs_io_metrics::record_put_object_stage_duration_from(
|
||||
rustfs_io_metrics::PUT_STAGE_PUT_OBJECT_QUOTA_BEGIN_LOCK_LOST_CHECK,
|
||||
lock_lost_check_started,
|
||||
);
|
||||
let policy_evaluate_started = rustfs_io_metrics::put_stage_timer();
|
||||
if quota
|
||||
.as_ref()
|
||||
.is_some_and(|quota| quota.has_unsupported_reservation_protocol())
|
||||
@@ -926,6 +990,11 @@ pub(crate) async fn begin(
|
||||
return Err(StorageError::PartMissingOrCorrupt);
|
||||
}
|
||||
let durable_quota = quota.as_ref().filter(|quota| quota.uses_durable_reservations());
|
||||
rustfs_io_metrics::record_put_object_stage_duration_from(
|
||||
rustfs_io_metrics::PUT_STAGE_PUT_OBJECT_QUOTA_BEGIN_POLICY_EVALUATE,
|
||||
policy_evaluate_started,
|
||||
);
|
||||
let capability_proof_started = rustfs_io_metrics::put_stage_timer();
|
||||
let capability_proof = if durable_quota.is_some() {
|
||||
Some(
|
||||
crate::services::notification_sys::acquire_cross_pool_fence_fleet_proof()
|
||||
@@ -934,7 +1003,12 @@ pub(crate) async fn begin(
|
||||
} else {
|
||||
None
|
||||
};
|
||||
rustfs_io_metrics::record_put_object_stage_duration_from(
|
||||
rustfs_io_metrics::PUT_STAGE_PUT_OBJECT_QUOTA_BEGIN_CAPABILITY_PROOF,
|
||||
capability_proof_started,
|
||||
);
|
||||
let durable_quota_limit = durable_quota.and_then(|quota| quota.quota);
|
||||
let snapshot_admission_started = rustfs_io_metrics::put_stage_timer();
|
||||
let reservation_protocol = durable_quota.and_then(|quota| quota.reservation_protocol);
|
||||
let snapshot_admission = match quota.as_ref().filter(|quota| !quota.uses_durable_reservations()) {
|
||||
Some(quota) => match (quota.quota, snapshot_admission) {
|
||||
@@ -954,6 +1028,10 @@ pub(crate) async fn begin(
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
rustfs_io_metrics::record_put_object_stage_duration_from(
|
||||
rustfs_io_metrics::PUT_STAGE_PUT_OBJECT_QUOTA_BEGIN_SNAPSHOT_ADMISSION,
|
||||
snapshot_admission_started,
|
||||
);
|
||||
let legacy_data_movement = durable_quota_limit.is_none()
|
||||
&& quota.as_ref().and_then(|quota| quota.quota).is_some()
|
||||
&& snapshot_admission.is_none()
|
||||
@@ -965,11 +1043,21 @@ pub(crate) async fn begin(
|
||||
.then(|| quota.as_ref().and_then(|quota| quota.quota))
|
||||
.flatten()
|
||||
});
|
||||
let object_store_lookup_started = rustfs_io_metrics::put_stage_timer();
|
||||
let store = if durable_quota_limit.is_some() {
|
||||
Some(metadata_sys::object_store_in(ctx).await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
rustfs_io_metrics::record_put_object_stage_duration_from(
|
||||
rustfs_io_metrics::PUT_STAGE_PUT_OBJECT_QUOTA_BEGIN_OBJECT_STORE_LOOKUP,
|
||||
object_store_lookup_started,
|
||||
);
|
||||
let context_build_started = rustfs_io_metrics::put_stage_timer();
|
||||
rustfs_io_metrics::record_put_object_stage_duration_from(
|
||||
rustfs_io_metrics::PUT_STAGE_PUT_OBJECT_QUOTA_BEGIN_CONTEXT_BUILD,
|
||||
context_build_started,
|
||||
);
|
||||
Ok(QuotaContext {
|
||||
store,
|
||||
bucket: bucket.to_string(),
|
||||
@@ -988,6 +1076,15 @@ pub(crate) async fn begin(
|
||||
})
|
||||
}
|
||||
|
||||
fn quota_cache_reason(quota: Option<&crate::bucket::quota::BucketQuota>) -> &'static str {
|
||||
match quota {
|
||||
None => "no_quota",
|
||||
Some(quota) if quota.uses_durable_reservations() => "durable_quota",
|
||||
Some(quota) if quota.quota.is_some() => "snapshot_quota",
|
||||
Some(_) => "quota_without_limit",
|
||||
}
|
||||
}
|
||||
|
||||
fn quota_capability_error(bucket: &str, object: &str) -> StorageError {
|
||||
StorageError::NamespaceLockQuorumUnavailable {
|
||||
mode: "quota_capability",
|
||||
|
||||
@@ -65,9 +65,9 @@ use super::replication_target_boundary::{
|
||||
SsecPassthroughGate, TargetClient, VersionIdentityCapability, is_replication_target_offline_error,
|
||||
replication_action_for_target_head, replication_complete_multipart_options, replication_delete_marker_purge_remove_options,
|
||||
replication_delete_remove_options, replication_force_delete_remove_options, replication_object_is_ssec_encrypted,
|
||||
replication_put_object_header_size, replication_put_object_options, replication_target_head_is_newer_null_version,
|
||||
resolve_read_api_version_id, ssec_passthrough_evidence_present, ssec_passthrough_gate, version_identity_capability_from_put,
|
||||
version_identity_drifted,
|
||||
replication_put_object_header_size, replication_put_object_options, replication_source_etag_is_comparable,
|
||||
replication_target_head_is_newer_null_version, resolve_read_api_version_id, ssec_passthrough_evidence_present,
|
||||
ssec_passthrough_gate, version_identity_capability_from_put, version_identity_drifted,
|
||||
};
|
||||
use super::replication_versioning_boundary::ReplicationVersioningStore;
|
||||
use super::runtime_boundary as runtime_sources;
|
||||
@@ -349,15 +349,22 @@ fn replica_etag_verification_enabled() -> bool {
|
||||
/// target that stores a transformed payload (e.g. undecoded `aws-chunked`
|
||||
/// frames, #6853) returns the ETag of what it actually wrote. Reporting
|
||||
/// COMPLETED over such a replica is silent corruption, so a decidable
|
||||
/// mismatch fails the replication instead. An SSE-C ciphertext passthrough
|
||||
/// transfer is exempt: the wire bytes are ciphertext while the source ETag is
|
||||
/// the plaintext MD5, and that path has its own HEAD-back audit.
|
||||
/// mismatch fails the replication instead. Encrypted sources are exempt: an
|
||||
/// SSE-C ciphertext passthrough sends ciphertext while the source ETag is the
|
||||
/// plaintext MD5 (that path has its own HEAD-back audit), and a managed-SSE
|
||||
/// (SSE-S3 / SSE-KMS) source stores a keyed fingerprint, or a legacy plaintext
|
||||
/// MD5, that the ETag of the target's re-encrypted replica can neither confirm
|
||||
/// nor refute. The checksum header on the replication PUT stays the integrity
|
||||
/// check for those objects.
|
||||
fn verify_single_part_replica(
|
||||
object_info: &ObjectInfo,
|
||||
response: &RemotePutObjectResponse,
|
||||
ciphertext_passthrough: bool,
|
||||
) -> std::result::Result<(), std::io::Error> {
|
||||
if ciphertext_passthrough || !replica_etag_verification_enabled() {
|
||||
if ciphertext_passthrough
|
||||
|| !replication_source_etag_is_comparable(&object_info.user_defined)
|
||||
|| !replica_etag_verification_enabled()
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
if single_part_replica_etag_mismatch(object_info.etag.as_deref(), response.etag.as_deref()) {
|
||||
@@ -448,6 +455,26 @@ fn is_version_id_format_mismatch(err: &SdkError<HeadObjectError>) -> bool {
|
||||
is_version_id_mismatch(code, raw_status)
|
||||
}
|
||||
|
||||
/// A version-addressed HEAD rejected because the target cannot address the
|
||||
/// source version id (AWS S3: bodiless 400). Narrower than
|
||||
/// [`is_version_id_format_mismatch`]: a bodiless 403 may be a genuine
|
||||
/// `s3:GetObjectVersion` denial, so it is not accepted where a mutation or a
|
||||
/// completion verdict follows instead of another non-destructive HEAD.
|
||||
fn rejects_source_version_id(err: &SdkError<HeadObjectError>) -> bool {
|
||||
is_version_id_format_mismatch(err) && !has_raw_status(err, 403)
|
||||
}
|
||||
|
||||
/// Whether the target's latest version of `object` is a delete marker. For
|
||||
/// targets that mint their own version ids, which cannot be asked about the
|
||||
/// source marker id: S3 answers a versionless HEAD on such a key with 404
|
||||
/// (405 on some targets) plus `x-amz-delete-marker: true`.
|
||||
async fn latest_target_version_is_delete_marker(tgt_client: &TargetClient, object: &str) -> bool {
|
||||
match head_object_for_worker(tgt_client, &tgt_client.bucket, object, None).await {
|
||||
Ok(head) => head.delete_marker == Some(true),
|
||||
Err(e) => raw_header_value(e.as_ref(), "x-amz-delete-marker").as_deref() == Some("true"),
|
||||
}
|
||||
}
|
||||
|
||||
async fn mark_replication_target_offline_if_needed(target_client: &Arc<TargetClient>, err: &(impl Display + ?Sized)) {
|
||||
if is_replication_target_offline_error(err) {
|
||||
ReplicationTargetStore::mark_target_offline(target_client).await;
|
||||
@@ -1846,7 +1873,17 @@ async fn verify_resync_head_result(
|
||||
// only absence proves completion. All other outcomes remain
|
||||
// failed (backlog#862 / #799 B13).
|
||||
let retryable = if roi.version_purge_status.is_empty() {
|
||||
matches!(classify_delete_marker_head_error(&err), DeleteMarkerHeadOutcome::Ambiguous)
|
||||
match classify_delete_marker_head_error(&err) {
|
||||
// A target that mints its own version ids never holds the
|
||||
// source marker id; verify the marker on the latest version.
|
||||
DeleteMarkerHeadOutcome::Ambiguous => {
|
||||
let mints_own = rejects_source_version_id(&err)
|
||||
|| ReplicationTargetStore::version_identity_capability(&target_client.arn)
|
||||
.version_addressing_unreliable();
|
||||
!(mints_own && latest_target_version_is_delete_marker(target_client, &roi.name).await)
|
||||
}
|
||||
DeleteMarkerHeadOutcome::Exists | DeleteMarkerHeadOutcome::Missing => false,
|
||||
}
|
||||
} else {
|
||||
!matches!(classify_delete_marker_head_error(&err), DeleteMarkerHeadOutcome::Missing)
|
||||
};
|
||||
@@ -3567,7 +3604,16 @@ async fn replicate_delete_to_target(
|
||||
}
|
||||
}
|
||||
|
||||
if !is_version_purge && dobj.delete_object.delete_marker && dobj.delete_object.delete_marker_version_id.is_some() {
|
||||
// A target that mints its own version ids never holds the source marker
|
||||
// id, so a version-addressed pre-check cannot succeed there; the marker
|
||||
// is created by the versionless DELETE below.
|
||||
let version_addressable =
|
||||
!ReplicationTargetStore::version_identity_capability(&tgt_client.arn).version_addressing_unreliable();
|
||||
if version_addressable
|
||||
&& !is_version_purge
|
||||
&& dobj.delete_object.delete_marker
|
||||
&& dobj.delete_object.delete_marker_version_id.is_some()
|
||||
{
|
||||
match head_object_for_worker(
|
||||
tgt_client.as_ref(),
|
||||
&tgt_client.bucket,
|
||||
@@ -3576,6 +3622,11 @@ async fn replicate_delete_to_target(
|
||||
)
|
||||
.await
|
||||
{
|
||||
// The verdict is not known yet (e.g. no PUT since restart) and the
|
||||
// target rejected the source id: learn it as the PUT path does.
|
||||
Err(e) if rejects_source_version_id(e.as_ref()) => {
|
||||
ReplicationTargetStore::record_version_identity_capability(&tgt_client.arn, VersionIdentityCapability::MintsOwn);
|
||||
}
|
||||
Ok(head) if head.delete_marker == Some(true) => {
|
||||
// Some targets expose an existing marker as a successful HEAD
|
||||
// with the marker bit instead of the usual 405. Treat that as
|
||||
@@ -5826,6 +5877,57 @@ mod tests {
|
||||
use super::super::replication_filemeta_boundary::ReplicateTargetDecision;
|
||||
use super::super::replication_object_decision_boundary::ReplicationMultipartPlanError;
|
||||
|
||||
#[test]
|
||||
fn verify_single_part_replica_exempts_managed_sse_sources() {
|
||||
// A new encrypted write stores a keyed fingerprint; a target that
|
||||
// computes its own ETag answers with the MD5 of the plaintext it
|
||||
// received. Both are 32 hex characters, so without the exemption the
|
||||
// audit would fail every SSE-S3 / SSE-KMS replica (rustfs/backlog).
|
||||
let fingerprint = "00112233445566778899aabbccddeeff";
|
||||
let replica_md5 = "ffeeddccbbaa99887766554433221100";
|
||||
let response = RemotePutObjectResponse {
|
||||
version_id: None,
|
||||
etag: Some(replica_md5.to_string()),
|
||||
};
|
||||
for sse in ["AES256", "aws:kms"] {
|
||||
let object_info = ObjectInfo {
|
||||
bucket: "source".to_string(),
|
||||
name: "encrypted".to_string(),
|
||||
etag: Some(fingerprint.to_string()),
|
||||
user_defined: HashMap::from([("x-amz-server-side-encryption".to_string(), sse.to_string())]).into(),
|
||||
..Default::default()
|
||||
};
|
||||
verify_single_part_replica(&object_info, &response, false)
|
||||
.unwrap_or_else(|err| panic!("{sse}: a managed-SSE source must not be audited against the replica MD5: {err}"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_single_part_replica_still_fails_plaintext_mismatch() {
|
||||
if !replica_etag_verification_enabled() {
|
||||
return;
|
||||
}
|
||||
let object_info = ObjectInfo {
|
||||
bucket: "source".to_string(),
|
||||
name: "plain".to_string(),
|
||||
etag: Some("00112233445566778899aabbccddeeff".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let mismatch = RemotePutObjectResponse {
|
||||
version_id: None,
|
||||
etag: Some("ffeeddccbbaa99887766554433221100".to_string()),
|
||||
};
|
||||
let err = verify_single_part_replica(&object_info, &mismatch, false)
|
||||
.expect_err("a plaintext source whose replica MD5 differs must fail the replication");
|
||||
assert_eq!(err.to_string(), REPLICA_ETAG_MISMATCH_ERROR);
|
||||
|
||||
let matching = RemotePutObjectResponse {
|
||||
version_id: None,
|
||||
etag: object_info.etag.clone(),
|
||||
};
|
||||
verify_single_part_replica(&object_info, &matching, false).expect("matching plaintext MD5s pass the audit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multipart_read_plan_preserves_legacy_plain_part_ranges() {
|
||||
const MIB: usize = 1024 * 1024;
|
||||
@@ -6675,6 +6777,93 @@ mod tests {
|
||||
assert!(seen[0].starts_with("HEAD "), "{seen:?}");
|
||||
}
|
||||
|
||||
/// AWS S3 answers a HEAD for a version id it did not mint with a bodiless
|
||||
/// 400. On a target with no verdict yet, that teaches `MintsOwn` and the
|
||||
/// marker is still created with one versionless DELETE.
|
||||
#[tokio::test]
|
||||
async fn live_delete_marker_worker_learns_mints_own_from_bodiless_400() {
|
||||
let (endpoint, server) = spawn_scripted_target_server(2, |line| {
|
||||
if line.starts_with("HEAD ") {
|
||||
empty_response("400 Bad Request")
|
||||
} else if line.starts_with("DELETE ") {
|
||||
empty_response("204 No Content")
|
||||
} else {
|
||||
empty_response("500 Unexpected")
|
||||
}
|
||||
});
|
||||
let target = test_target_client(endpoint);
|
||||
register_test_target(&target).await;
|
||||
let dobj = marker_creation_dobj(&target.arn);
|
||||
|
||||
let rinfo = replicate_delete_to_target(&dobj, target.clone(), None).await;
|
||||
|
||||
assert_eq!(rinfo.replication_status, ReplicationStatusType::Completed, "{rinfo:?}");
|
||||
assert_eq!(
|
||||
ReplicationTargetStore::version_identity_capability(&target.arn),
|
||||
VersionIdentityCapability::MintsOwn
|
||||
);
|
||||
let seen = server.join().expect("test HTTP server should finish");
|
||||
assert_eq!(seen.len(), 2, "{seen:?}");
|
||||
assert!(seen[0].starts_with("HEAD "), "{seen:?}");
|
||||
assert!(seen[1].starts_with("DELETE "), "{seen:?}");
|
||||
assert!(!seen[1].contains("versionId="), "marker creation must remain versionless: {seen:?}");
|
||||
}
|
||||
|
||||
/// A bodiless 403 on the version-addressed HEAD may be a genuine
|
||||
/// GetObjectVersion denial on an id-adopting target. It must fail closed:
|
||||
/// no DELETE, and no `MintsOwn` verdict cached for the ARN.
|
||||
#[tokio::test]
|
||||
async fn live_delete_marker_worker_keeps_bodiless_403_failure_closed() {
|
||||
let (endpoint, server) = spawn_scripted_target_server(1, |line| {
|
||||
if line.starts_with("HEAD ") {
|
||||
empty_response("403 Forbidden")
|
||||
} else if line.starts_with("DELETE ") {
|
||||
empty_response("204 No Content")
|
||||
} else {
|
||||
empty_response("500 Unexpected")
|
||||
}
|
||||
});
|
||||
let target = test_target_client(endpoint);
|
||||
register_test_target(&target).await;
|
||||
let dobj = marker_creation_dobj(&target.arn);
|
||||
|
||||
let rinfo = replicate_delete_to_target(&dobj, target.clone(), None).await;
|
||||
|
||||
assert_eq!(rinfo.replication_status, ReplicationStatusType::Failed, "{rinfo:?}");
|
||||
assert_ne!(
|
||||
ReplicationTargetStore::version_identity_capability(&target.arn),
|
||||
VersionIdentityCapability::MintsOwn
|
||||
);
|
||||
let seen = server.join().expect("test HTTP server should finish");
|
||||
assert_eq!(seen.len(), 1, "a denied HEAD must not trigger DELETE: {seen:?}");
|
||||
assert!(seen[0].starts_with("HEAD "), "{seen:?}");
|
||||
}
|
||||
|
||||
/// A target already known to mint its own version ids is never asked for
|
||||
/// the source marker id: the marker is created by the versionless DELETE.
|
||||
#[tokio::test]
|
||||
async fn live_delete_marker_worker_skips_version_head_on_mints_own_target() {
|
||||
let (endpoint, server) = spawn_scripted_target_server(1, |line| {
|
||||
if line.starts_with("DELETE ") {
|
||||
empty_response("204 No Content")
|
||||
} else {
|
||||
empty_response("500 Unexpected")
|
||||
}
|
||||
});
|
||||
let target = test_target_client(endpoint);
|
||||
register_test_target(&target).await;
|
||||
ReplicationTargetStore::record_version_identity_capability(&target.arn, VersionIdentityCapability::MintsOwn);
|
||||
let dobj = marker_creation_dobj(&target.arn);
|
||||
|
||||
let rinfo = replicate_delete_to_target(&dobj, target.clone(), None).await;
|
||||
|
||||
assert_eq!(rinfo.replication_status, ReplicationStatusType::Completed, "{rinfo:?}");
|
||||
let seen = server.join().expect("test HTTP server should finish");
|
||||
assert_eq!(seen.len(), 1, "no version-addressed HEAD on a MintsOwn target: {seen:?}");
|
||||
assert!(seen[0].starts_with("DELETE "), "{seen:?}");
|
||||
assert!(!seen[0].contains("versionId="), "marker creation must remain versionless: {seen:?}");
|
||||
}
|
||||
|
||||
/// A target answers `HEAD ?versionId=<delete marker>` with a bodiless
|
||||
/// 405, so the SDK reports no error code. That is the marker having
|
||||
/// propagated, not a failure (rustfs/backlog#2479).
|
||||
@@ -6765,6 +6954,76 @@ mod tests {
|
||||
server.join().expect("test HTTP server should finish");
|
||||
}
|
||||
|
||||
/// AWS answers `HEAD ?versionId=<source marker id>` with a bodiless 400.
|
||||
/// The marker is verified through the latest version instead: a
|
||||
/// versionless 404 with `x-amz-delete-marker: true` proves it propagated.
|
||||
#[tokio::test]
|
||||
async fn resync_verification_accepts_latest_delete_marker_when_target_rejects_source_id() {
|
||||
let (endpoint, server) = spawn_scripted_target_server(2, |line| {
|
||||
if line.contains("versionId=") {
|
||||
empty_response("400 Bad Request")
|
||||
} else {
|
||||
"HTTP/1.1 404 Not Found\r\nX-Amz-Delete-Marker: true\r\nX-Amz-Version-Id: target-marker\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
|
||||
.to_string()
|
||||
}
|
||||
});
|
||||
let target = test_target_client(endpoint);
|
||||
let roi = delete_marker_roi();
|
||||
let mut st = TargetReplicationResyncStatus::default();
|
||||
|
||||
let head_result =
|
||||
head_object_for_worker(target.as_ref(), &target.bucket, &roi.name, roi.version_id.map(|v| v.to_string())).await;
|
||||
let (size, err) = verify_resync_head_result(head_result, &roi, &mut st, &target).await;
|
||||
|
||||
assert!(err.is_none(), "{err:?}");
|
||||
assert_eq!((size, st.replicated_count, st.failed_count), (0, 1, 0));
|
||||
let seen = server.join().expect("test HTTP server should finish");
|
||||
assert!(!seen[1].contains("versionId="), "the re-check must address the latest version: {seen:?}");
|
||||
}
|
||||
|
||||
/// A live latest version on a target that rejects the source id means the
|
||||
/// marker has not propagated: the verification stays failed.
|
||||
#[tokio::test]
|
||||
async fn resync_verification_fails_delete_marker_when_latest_target_version_is_live() {
|
||||
let (endpoint, server) = spawn_scripted_target_server(2, |line| {
|
||||
if line.contains("versionId=") {
|
||||
empty_response("400 Bad Request")
|
||||
} else {
|
||||
empty_response("200 OK")
|
||||
}
|
||||
});
|
||||
let target = test_target_client(endpoint);
|
||||
let roi = delete_marker_roi();
|
||||
let mut st = TargetReplicationResyncStatus::default();
|
||||
|
||||
let head_result =
|
||||
head_object_for_worker(target.as_ref(), &target.bucket, &roi.name, roi.version_id.map(|v| v.to_string())).await;
|
||||
let (size, err) = verify_resync_head_result(head_result, &roi, &mut st, &target).await;
|
||||
|
||||
assert!(err.is_some(), "a live latest version must not verify the marker");
|
||||
assert_eq!((size, st.replicated_count, st.failed_count), (0, 0, 1));
|
||||
server.join().expect("test HTTP server should finish");
|
||||
}
|
||||
|
||||
/// A bodiless 403 may be a genuine permission denial: it is not evidence
|
||||
/// that the target mints its own ids, so no latest-version re-check runs.
|
||||
#[tokio::test]
|
||||
async fn resync_verification_keeps_delete_marker_failed_on_bodiless_403() {
|
||||
let (endpoint, server) = spawn_scripted_target_server(1, |_| empty_response("403 Forbidden"));
|
||||
let target = test_target_client(endpoint);
|
||||
let roi = delete_marker_roi();
|
||||
let mut st = TargetReplicationResyncStatus::default();
|
||||
|
||||
let head_result =
|
||||
head_object_for_worker(target.as_ref(), &target.bucket, &roi.name, roi.version_id.map(|v| v.to_string())).await;
|
||||
let (size, err) = verify_resync_head_result(head_result, &roi, &mut st, &target).await;
|
||||
|
||||
assert!(err.is_some(), "a 403 must not count as a propagated delete marker");
|
||||
assert_eq!((size, st.replicated_count, st.failed_count), (0, 0, 1));
|
||||
let seen = server.join().expect("test HTTP server should finish");
|
||||
assert_eq!(seen.len(), 1, "{seen:?}");
|
||||
}
|
||||
|
||||
/// Serves exactly `requests` connections, answering each from
|
||||
/// `respond(request_line)`, and returns the request lines it saw. Reads
|
||||
/// the whole request (headers plus `Content-Length` body, honoring
|
||||
|
||||
@@ -157,6 +157,19 @@ pub(crate) fn replication_object_is_ssec_encrypted(user_defined: &HashMap<String
|
||||
rustfs_replication::is_ssec_encrypted(user_defined)
|
||||
}
|
||||
|
||||
/// Whether the stored ETag of a replication source can be compared with the
|
||||
/// ETag a target returns for the bytes it received. Only an unencrypted source
|
||||
/// stores the MD5 of the bytes that go on the wire. A managed-SSE (SSE-S3 /
|
||||
/// SSE-KMS) or SSE-C source stores a keyed fingerprint (new writes) or a legacy
|
||||
/// plaintext MD5, and the target re-encrypts what it receives, so neither
|
||||
/// equality nor inequality of the two ETags proves anything about the replica.
|
||||
pub(crate) fn replication_source_etag_is_comparable(user_defined: &HashMap<String, String>) -> bool {
|
||||
matches!(
|
||||
classify_replication_source_encryption(user_defined),
|
||||
ReplicationSourceEncryption::Plaintext
|
||||
)
|
||||
}
|
||||
|
||||
/// HeadObjectOutput adapter over the pure SSE-C passthrough evidence
|
||||
/// judgment owned by `rustfs-replication`: extract the echoed
|
||||
/// customer-algorithm header and let the crate-owned policy decide.
|
||||
@@ -578,6 +591,22 @@ mod tests {
|
||||
use time::Duration;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[test]
|
||||
fn replication_source_etag_is_comparable_only_for_plaintext_sources() {
|
||||
assert!(replication_source_etag_is_comparable(&HashMap::new()));
|
||||
for (key, value) in [
|
||||
(metadata_keys::SERVER_SIDE_ENCRYPTION, "AES256"),
|
||||
(metadata_keys::SERVER_SIDE_ENCRYPTION, "aws:kms"),
|
||||
(SSEC_ALGORITHM_HEADER, "AES256"),
|
||||
] {
|
||||
let metadata = HashMap::from([(key.to_string(), value.to_string())]);
|
||||
assert!(
|
||||
!replication_source_etag_is_comparable(&metadata),
|
||||
"{key}={value} must not expose its stored ETag to the replica audit"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// backlog#1735 A3b SSE key-case finding. Every RustFS writer persists the
|
||||
/// SSE intent as lowercase `x-amz-server-side-encryption`
|
||||
/// (`encryption_material_to_metadata`; header-derived keys come from a
|
||||
|
||||
@@ -24,10 +24,11 @@ use crate::cluster::rpc::{gen_tonic_replay_scope_headers, gen_tonic_signature_he
|
||||
#[cfg(test)]
|
||||
use crate::cluster::rpc::{tonic_boot_epoch_challenge, tonic_boot_epoch_response_headers};
|
||||
use crate::disk::error::{DiskError, Error as DiskErrorType, RpcStatusError};
|
||||
#[cfg(test)]
|
||||
use crate::runtime::sources as runtime_sources;
|
||||
use http::{Request as HttpRequest, Response as HttpResponse, Uri};
|
||||
use rustfs_protos::{
|
||||
ChannelClass, create_new_channel, get_channel_for_class,
|
||||
ChannelClass, get_channel_for_class,
|
||||
proto_gen::node_service::{
|
||||
heal_control_service_client::HealControlServiceClient, node_service_client::NodeServiceClient,
|
||||
scanner_control_service_client::ScannerControlServiceClient,
|
||||
@@ -53,7 +54,7 @@ use super::context_propagation::{inject_request_id_into_metadata, inject_trace_c
|
||||
/// 3. Subsequent calls will attempt fresh connections
|
||||
/// 4. If node is still down, connection will fail fast (3s timeout)
|
||||
pub async fn node_service_time_out_client(
|
||||
addr: &String,
|
||||
addr: &str,
|
||||
interceptor: TonicInterceptor,
|
||||
) -> Result<NodeServiceClient<InterceptedService<AuthenticatedChannel, TonicInterceptor>>, Box<dyn Error>> {
|
||||
// Default to the latency-sensitive control channel; bulk `bytes` RPCs opt in via the
|
||||
@@ -66,12 +67,9 @@ pub(crate) async fn scanner_control_time_out_client(
|
||||
interceptor: TonicInterceptor,
|
||||
) -> crate::error::Result<ScannerControlServiceClient<InterceptedService<AuthenticatedChannel, TonicInterceptor>>> {
|
||||
let interceptor = interceptor.with_rpc_audience(addr)?;
|
||||
let channel = match runtime_sources::cached_node_channel(addr).await {
|
||||
Some(channel) => channel,
|
||||
None => create_new_channel(addr)
|
||||
.await
|
||||
.map_err(|err| crate::error::Error::other(err.to_string()))?,
|
||||
};
|
||||
let channel = get_channel_for_class(addr, ChannelClass::Control)
|
||||
.await
|
||||
.map_err(|err| crate::error::Error::other(err.to_string()))?;
|
||||
let channel = ReplayScopeChannel::new(channel, interceptor.replay_scope_audience());
|
||||
let limit = rustfs_protos::scoped_dirty_usage::SCOPED_DIRTY_USAGE_MAX_REQUEST_BYTES as usize;
|
||||
Ok(ScannerControlServiceClient::with_interceptor(channel, interceptor)
|
||||
@@ -84,10 +82,7 @@ pub async fn heal_control_time_out_client(
|
||||
interceptor: TonicInterceptor,
|
||||
) -> Result<HealControlServiceClient<InterceptedService<AuthenticatedChannel, TonicInterceptor>>, Box<dyn Error>> {
|
||||
let interceptor = interceptor.with_rpc_audience(addr)?;
|
||||
let channel = match runtime_sources::cached_node_channel(addr).await {
|
||||
Some(channel) => channel,
|
||||
None => create_new_channel(addr).await?,
|
||||
};
|
||||
let channel = get_channel_for_class(addr, ChannelClass::Control).await?;
|
||||
let max_message_size = rustfs_protos::HEAL_CONTROL_RPC_MAX_MESSAGE_SIZE;
|
||||
let channel = ReplayScopeChannel::new(channel, interceptor.replay_scope_audience());
|
||||
Ok(HealControlServiceClient::with_interceptor(channel, interceptor)
|
||||
@@ -100,10 +95,7 @@ pub async fn tier_mutation_control_time_out_client(
|
||||
interceptor: TonicInterceptor,
|
||||
) -> Result<TierMutationControlServiceClient<InterceptedService<AuthenticatedChannel, TonicInterceptor>>, Box<dyn Error>> {
|
||||
let interceptor = interceptor.with_rpc_audience(addr)?;
|
||||
let channel = match runtime_sources::cached_node_channel(addr).await {
|
||||
Some(channel) => channel,
|
||||
None => create_new_channel(addr).await?,
|
||||
};
|
||||
let channel = get_channel_for_class(addr, ChannelClass::Control).await?;
|
||||
let max_message_size = rustfs_protos::TIER_MUTATION_RPC_MAX_MESSAGE_SIZE;
|
||||
let channel = ReplayScopeChannel::new(channel, interceptor.replay_scope_audience());
|
||||
Ok(TierMutationControlServiceClient::with_interceptor(channel, interceptor)
|
||||
@@ -118,22 +110,12 @@ pub async fn tier_mutation_control_time_out_client(
|
||||
/// from lock/health RPCs; everything else uses [`ChannelClass::Control`]. When isolation is
|
||||
/// disabled the two classes resolve to the same cached channel, i.e. legacy behavior.
|
||||
pub async fn node_service_time_out_client_for_class(
|
||||
addr: &String,
|
||||
addr: &str,
|
||||
interceptor: TonicInterceptor,
|
||||
class: ChannelClass,
|
||||
) -> Result<NodeServiceClient<InterceptedService<AuthenticatedChannel, TonicInterceptor>>, Box<dyn Error>> {
|
||||
let interceptor = interceptor.with_rpc_audience(addr)?;
|
||||
let channel = match class {
|
||||
ChannelClass::Control => match runtime_sources::cached_node_channel(addr).await {
|
||||
Some(channel) => {
|
||||
debug!("Using cached gRPC channel for: {}", addr);
|
||||
channel
|
||||
}
|
||||
// No cached connection, create new one.
|
||||
None => create_new_channel(addr).await?,
|
||||
},
|
||||
ChannelClass::Bulk => get_channel_for_class(addr, ChannelClass::Bulk).await?,
|
||||
};
|
||||
let channel = get_channel_for_class(addr, class).await?;
|
||||
|
||||
let max_message_size = rustfs_protos::internode_rpc_max_message_size();
|
||||
let channel = ReplayScopeChannel::new(channel, interceptor.replay_scope_audience());
|
||||
@@ -142,8 +124,24 @@ pub async fn node_service_time_out_client_for_class(
|
||||
.max_encoding_message_size(max_message_size))
|
||||
}
|
||||
|
||||
pub(crate) async fn node_service_delete_recovery_client(
|
||||
addr: &str,
|
||||
interceptor: TonicInterceptor,
|
||||
timeout: std::time::Duration,
|
||||
) -> Result<NodeServiceClient<InterceptedService<AuthenticatedChannel, TonicInterceptor>>, DiskError> {
|
||||
let interceptor = interceptor.with_rpc_audience(addr).map_err(DiskError::from)?;
|
||||
let channel = rustfs_protos::create_channel_with_timeout(addr, timeout)
|
||||
.await
|
||||
.map_err(|error| DiskError::RemoteClientUnavailable(error.to_string()))?;
|
||||
let channel = ReplayScopeChannel::new(channel, interceptor.replay_scope_audience());
|
||||
let max_message_size = rustfs_protos::internode_rpc_max_message_size();
|
||||
Ok(NodeServiceClient::with_interceptor(channel, interceptor)
|
||||
.max_decoding_message_size(max_message_size)
|
||||
.max_encoding_message_size(max_message_size))
|
||||
}
|
||||
|
||||
pub async fn node_service_time_out_client_no_auth(
|
||||
addr: &String,
|
||||
addr: &str,
|
||||
) -> Result<NodeServiceClient<InterceptedService<AuthenticatedChannel, TonicInterceptor>>, Box<dyn Error>> {
|
||||
node_service_time_out_client(addr, TonicInterceptor::NoOp(NoOpInterceptor)).await
|
||||
}
|
||||
@@ -355,6 +353,13 @@ fn apply_peer_replay_response(
|
||||
}
|
||||
}
|
||||
|
||||
fn rename_data_grpc_stage(path: &str) -> bool {
|
||||
matches!(
|
||||
path,
|
||||
"/node_service.NodeService/RenameData" | "/node_service.NodeService/RenameDataAtIncarnation"
|
||||
)
|
||||
}
|
||||
|
||||
impl<S, ReqBody, ResBody> Service<HttpRequest<ReqBody>> for ReplayScopeChannel<S>
|
||||
where
|
||||
S: Service<HttpRequest<ReqBody>, Response = HttpResponse<ResBody>>,
|
||||
@@ -372,6 +377,12 @@ where
|
||||
}
|
||||
|
||||
fn call(&mut self, mut request: HttpRequest<ReqBody>) -> Self::Future {
|
||||
let observe_rename_data = rustfs_io_metrics::put_stage_metrics_enabled() && rename_data_grpc_stage(request.uri().path());
|
||||
let request_scope_started = if observe_rename_data {
|
||||
rustfs_io_metrics::put_stage_timer()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let authenticated = self.audience.as_ref().is_some_and(|_| {
|
||||
request
|
||||
.headers()
|
||||
@@ -406,11 +417,30 @@ where
|
||||
}
|
||||
}
|
||||
}
|
||||
rustfs_io_metrics::record_put_object_stage_duration_from(
|
||||
rustfs_io_metrics::PUT_STAGE_SET_DISK_RENAME_REMOTE_CLIENT_REQUEST_SCOPE,
|
||||
request_scope_started,
|
||||
);
|
||||
|
||||
let audience = self.audience.clone();
|
||||
let future = self.inner.call(request);
|
||||
Box::pin(async move {
|
||||
let response = future.await?;
|
||||
let response = if observe_rename_data {
|
||||
rustfs_io_metrics::observe_put_stage_future(
|
||||
future,
|
||||
rustfs_io_metrics::PUT_STAGE_SET_DISK_RENAME_REMOTE_CLIENT_TRANSPORT_CALL,
|
||||
rustfs_io_metrics::PUT_STAGE_SET_DISK_RENAME_REMOTE_CLIENT_TRANSPORT_CALL_POLL_PENDING_COUNT,
|
||||
rustfs_io_metrics::PUT_STAGE_SET_DISK_RENAME_REMOTE_CLIENT_TRANSPORT_CALL_FIRST_PENDING_TO_READY,
|
||||
)
|
||||
.await?
|
||||
} else {
|
||||
future.await?
|
||||
};
|
||||
let replay_response_started = if observe_rename_data {
|
||||
rustfs_io_metrics::put_stage_timer()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let (Some(audience), Some(challenge)) = (audience, challenge) {
|
||||
let response_state = verify_tonic_peer_replay_capabilities_response(&audience, challenge, response.headers());
|
||||
if let Err(error) = &response_state
|
||||
@@ -428,6 +458,10 @@ where
|
||||
}
|
||||
apply_peer_replay_response(audience, sent_state, response_state);
|
||||
}
|
||||
rustfs_io_metrics::record_put_object_stage_duration_from(
|
||||
rustfs_io_metrics::PUT_STAGE_SET_DISK_RENAME_REMOTE_CLIENT_REPLAY_RESPONSE,
|
||||
replay_response_started,
|
||||
);
|
||||
Ok(response)
|
||||
})
|
||||
}
|
||||
@@ -546,6 +580,126 @@ mod tests {
|
||||
use tracing_opentelemetry::OpenTelemetrySpanExt;
|
||||
use tracing_subscriber::{Registry, layer::SubscriberExt};
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_recovery_channel_uses_its_scoped_deadline() {
|
||||
use bytes::Bytes;
|
||||
use http_body_util::StreamBody;
|
||||
use hyper::{body::Frame, service::service_fn};
|
||||
use hyper_util::rt::{TokioExecutor, TokioIo};
|
||||
use rustfs_protos::proto_gen::node_service::{RecoverDeleteTransactionRequest, RecoverDeleteTransactionResponse};
|
||||
use tonic_prost::prost::Message;
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("bind recovery peer");
|
||||
let addr = format!("http://{}", listener.local_addr().expect("peer address"));
|
||||
let server = tokio::spawn(async move {
|
||||
loop {
|
||||
let (stream, _) = listener.accept().await.expect("accept recovery connection");
|
||||
tokio::spawn(async move {
|
||||
let service = service_fn(|_request: HttpRequest<hyper::body::Incoming>| async {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
|
||||
let payload = RecoverDeleteTransactionResponse {
|
||||
success: true,
|
||||
protocol_version: 1,
|
||||
result: Bytes::new(),
|
||||
error: None,
|
||||
}
|
||||
.encode_to_vec();
|
||||
let mut data = vec![0];
|
||||
data.extend_from_slice(&u32::try_from(payload.len()).expect("small response").to_be_bytes());
|
||||
data.extend_from_slice(&payload);
|
||||
let mut trailers = http::HeaderMap::new();
|
||||
trailers.insert("grpc-status", "0".parse().expect("status"));
|
||||
let frames: Vec<Result<Frame<Bytes>, std::convert::Infallible>> =
|
||||
vec![Ok(Frame::data(data.into())), Ok(Frame::trailers(trailers))];
|
||||
let mut response = HttpResponse::new(StreamBody::new(futures::stream::iter(frames)));
|
||||
response
|
||||
.headers_mut()
|
||||
.insert("content-type", "application/grpc".parse().expect("content type"));
|
||||
Ok::<_, std::convert::Infallible>(response)
|
||||
});
|
||||
let _ = hyper::server::conn::http2::Builder::new(TokioExecutor::new())
|
||||
.serve_connection(TokioIo::new(stream), service)
|
||||
.await;
|
||||
});
|
||||
}
|
||||
});
|
||||
let request = || RecoverDeleteTransactionRequest {
|
||||
disk: "disk".into(),
|
||||
volume: "bucket".into(),
|
||||
protocol_version: 1,
|
||||
operation: Bytes::new(),
|
||||
};
|
||||
// Seed an existing short-lived control channel to reproduce the inherited deadline.
|
||||
let control = tonic::transport::Endpoint::from_shared(addr.clone())
|
||||
.expect("test endpoint")
|
||||
.timeout(std::time::Duration::from_millis(50))
|
||||
.connect()
|
||||
.await
|
||||
.expect("control connect");
|
||||
rustfs_common::cache_connection(addr.clone(), control).await;
|
||||
let mut foreground = node_service_time_out_client(&addr, TonicInterceptor::NoOp(NoOpInterceptor))
|
||||
.await
|
||||
.expect("control client");
|
||||
let error = foreground
|
||||
.recover_delete_transaction(request())
|
||||
.await
|
||||
.expect_err("short control deadline");
|
||||
assert!(matches!(error.code(), tonic::Code::Cancelled | tonic::Code::DeadlineExceeded));
|
||||
// The 2-second test override bypasses the production range parser, which rejects 2.
|
||||
let mut recovery = node_service_delete_recovery_client(
|
||||
&addr,
|
||||
TonicInterceptor::NoOp(NoOpInterceptor),
|
||||
std::time::Duration::from_secs(2),
|
||||
)
|
||||
.await
|
||||
.expect("scoped recovery client");
|
||||
assert!(
|
||||
recovery
|
||||
.recover_delete_transaction(request())
|
||||
.await
|
||||
.expect("scoped deadline must survive control deadline")
|
||||
.into_inner()
|
||||
.success
|
||||
);
|
||||
server.abort();
|
||||
rustfs_protos::evict_failed_connection(&addr).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rpc_client_factories_do_not_reuse_https_channels_without_tls_generation() {
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("reserve local address");
|
||||
let addr = format!("https://{}", listener.local_addr().expect("local address"));
|
||||
drop(listener);
|
||||
for factory in 0..4 {
|
||||
let placeholder = tonic::transport::Endpoint::from_static("http://127.0.0.1:1").connect_lazy();
|
||||
runtime_sources::cache_test_node_channel(addr.clone(), placeholder).await;
|
||||
let interceptor = TonicInterceptor::NoOp(NoOpInterceptor);
|
||||
let _result = match factory {
|
||||
0 => scanner_control_time_out_client(&addr, interceptor)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(|e| e.to_string()),
|
||||
1 => heal_control_time_out_client(&addr, interceptor)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(|e| e.to_string()),
|
||||
2 => tier_mutation_control_time_out_client(&addr, interceptor)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(|e| e.to_string()),
|
||||
_ => node_service_time_out_client(&addr, interceptor)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(|e| e.to_string()),
|
||||
};
|
||||
assert!(
|
||||
!runtime_sources::test_node_channel_is_cached(&addr).await,
|
||||
"factory {factory} must evict an unbound HTTPS cache entry before attempting a fresh dial"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct EpochProofService {
|
||||
audience: String,
|
||||
|
||||
@@ -289,13 +289,9 @@ async fn client(
|
||||
>,
|
||||
NetworkPeerProbeError,
|
||||
> {
|
||||
node_service_time_out_client_for_class(
|
||||
&address.to_owned(),
|
||||
TonicInterceptor::Signature(gen_tonic_signature_interceptor()),
|
||||
class,
|
||||
)
|
||||
.await
|
||||
.map_err(|_| NetworkPeerProbeError::Unreachable)
|
||||
node_service_time_out_client_for_class(address, TonicInterceptor::Signature(gen_tonic_signature_interceptor()), class)
|
||||
.await
|
||||
.map_err(|_| NetworkPeerProbeError::Unreachable)
|
||||
}
|
||||
|
||||
fn ping_request(payload: &[u8]) -> PingRequest {
|
||||
@@ -329,6 +325,24 @@ fn map_status(status: tonic::Status) -> NetworkPeerProbeError {
|
||||
match status.code() {
|
||||
Code::Unavailable | Code::Unknown => NetworkPeerProbeError::Unreachable,
|
||||
Code::DeadlineExceeded => NetworkPeerProbeError::TimedOut,
|
||||
Code::Cancelled | Code::Internal => {
|
||||
let mut source = std::error::Error::source(&status);
|
||||
while let Some(error) = source {
|
||||
if error
|
||||
.downcast_ref::<h2::Error>()
|
||||
.is_some_and(|error| matches!(error.reason(), Some(h2::Reason::CANCEL | h2::Reason::NO_ERROR)))
|
||||
{
|
||||
return NetworkPeerProbeError::Unreachable;
|
||||
}
|
||||
source = if let Some(error) = error.downcast_ref::<std::io::Error>() {
|
||||
// io::Error::source skips its immediate custom error.
|
||||
error.get_ref().map(|inner| inner as &(dyn std::error::Error + 'static))
|
||||
} else {
|
||||
error.source()
|
||||
};
|
||||
}
|
||||
NetworkPeerProbeError::ProtocolFailure
|
||||
}
|
||||
_ => NetworkPeerProbeError::ProtocolFailure,
|
||||
}
|
||||
}
|
||||
@@ -339,6 +353,35 @@ mod tests {
|
||||
use crate::layout::endpoint::Endpoint;
|
||||
use crate::layout::endpoints::{Endpoints, PoolEndpoints};
|
||||
|
||||
#[test]
|
||||
fn peer_transport_resets_are_unreachable_without_reclassifying_application_errors() {
|
||||
for reason in [h2::Reason::CANCEL, h2::Reason::NO_ERROR] {
|
||||
let status = tonic::Status::from_error(Box::new(h2::Error::from(reason)));
|
||||
assert!(matches!(status.code(), Code::Cancelled | Code::Internal));
|
||||
let code = status.code();
|
||||
assert_eq!(map_status(status), NetworkPeerProbeError::Unreachable);
|
||||
let mut wrapped = tonic::Status::new(code, "wrapped transport reset");
|
||||
wrapped.set_source(Arc::new(std::io::Error::other(h2::Error::from(reason))));
|
||||
assert_eq!(map_status(wrapped), NetworkPeerProbeError::Unreachable);
|
||||
}
|
||||
for reason in [h2::Reason::PROTOCOL_ERROR, h2::Reason::COMPRESSION_ERROR] {
|
||||
let status = tonic::Status::from_error(Box::new(h2::Error::from(reason)));
|
||||
assert_eq!(map_status(status), NetworkPeerProbeError::ProtocolFailure);
|
||||
}
|
||||
for status in [
|
||||
tonic::Status::cancelled("peer cancelled the application request"),
|
||||
tonic::Status::internal("peer rejected the probe"),
|
||||
tonic::Status::unauthenticated("authentication rejected"),
|
||||
tonic::Status::permission_denied("authorization rejected"),
|
||||
] {
|
||||
assert_eq!(map_status(status), NetworkPeerProbeError::ProtocolFailure);
|
||||
}
|
||||
assert_eq!(
|
||||
map_status(tonic::Status::deadline_exceeded("probe deadline elapsed")),
|
||||
NetworkPeerProbeError::TimedOut
|
||||
);
|
||||
}
|
||||
|
||||
fn pacing(duration: Duration) -> Arc<DiagnosticPacing> {
|
||||
NetworkPeerProbeClient::from_endpoint_pools(&topology())
|
||||
.with_diagnostic_pacing(Instant::now(), duration)
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
|
||||
use crate::cluster::rpc::client::{
|
||||
AuthenticatedChannel, TonicInterceptor, gen_tonic_signature_interceptor, is_network_like_disk_error,
|
||||
node_service_time_out_client, node_service_time_out_client_for_class, node_service_time_out_client_no_auth,
|
||||
node_service_delete_recovery_client, node_service_time_out_client, node_service_time_out_client_for_class,
|
||||
node_service_time_out_client_no_auth,
|
||||
};
|
||||
use crate::cluster::rpc::internode_data_transport::{
|
||||
InternodeDataTransport, NsScannerCapabilityRequest, NsScannerStreamRequest, ReadStreamRequest, WalkDirStreamRequest,
|
||||
@@ -22,10 +23,11 @@ use crate::cluster::rpc::internode_data_transport::{
|
||||
};
|
||||
use crate::disk::error::{Error, Result};
|
||||
use crate::disk::{
|
||||
BatchReadVersionReq, BatchReadVersionResp, CheckPartsResp, ConditionalFileUpdate, DeleteOptions, DiskAPI, DiskInfo,
|
||||
DiskInfoOptions, DiskLocation, DiskOption, FileInfoVersions, FileReader, FileWriter, PartTransactionAction, ReadMultipleReq,
|
||||
ReadMultipleResp, ReadOptions, RenameDataResp, SnapshotLeaseToken, UpdateMetadataOpts, VolumeInfo, WalkDirOptions,
|
||||
batch_read_version_one_by_one,
|
||||
BatchReadVersionReq, BatchReadVersionResp, CheckPartsResp, ConditionalFileUpdate, DELETE_RECOVERY_PROTOCOL_VERSION,
|
||||
DeleteOptions, DeleteRecoveryAction, DeleteRecoveryCursor, DeleteRecoveryDigest, DeleteRecoveryInspection,
|
||||
DeleteRecoveryPage, DeleteRecoveryRpcOperation, DeleteRecoveryRpcReply, DiskAPI, DiskInfo, DiskInfoOptions, DiskLocation,
|
||||
DiskOption, FileInfoVersions, FileReader, FileWriter, PartTransactionAction, ReadMultipleReq, ReadMultipleResp, ReadOptions,
|
||||
RenameDataResp, SnapshotLeaseToken, UpdateMetadataOpts, VolumeInfo, WalkDirOptions, batch_read_version_one_by_one,
|
||||
disk_store::{
|
||||
DEFAULT_RUSTFS_DRIVE_ACTIVE_MONITORING, ENV_RUSTFS_DRIVE_ACTIVE_MONITORING, SKIP_IF_SUCCESS_BEFORE,
|
||||
get_drive_active_check_interval, get_drive_active_check_timeout, get_drive_disk_info_timeout, get_drive_list_dir_timeout,
|
||||
@@ -59,10 +61,10 @@ use rustfs_protos::proto_gen::node_service::{
|
||||
CompareAndUpdateFileRequest, DeletePathsRequest, DeleteRequest, DeleteVersionRequest, DeleteVersionsRequest,
|
||||
DeleteVersionsResponse, DeleteVolumeRequest, DiskInfoRequest, ListDirRequest, ListVolumesRequest, MakeVolumeRequest,
|
||||
MakeVolumesRequest, PreparePartTransactionRequest, ReadAllRequest, ReadMetadataRequest, ReadMultipleRequest,
|
||||
ReadMultipleResponse, ReadPartsRequest, ReadVersionRequest, ReadXlRequest, RenameDataRequest, RenameFileRequest,
|
||||
SettlePartTransactionRequest, SnapshotLeaseReleaseRequest, SnapshotLeaseRenewRequest, SnapshotLeaseRequest,
|
||||
SnapshotLeaseResponse, StatVolumeRequest, UpdateMetadataRequest, VerifyFileRequest, WriteAllRequest, WriteMetadataRequest,
|
||||
node_service_client::NodeServiceClient,
|
||||
ReadMultipleResponse, ReadPartsRequest, ReadVersionRequest, ReadXlRequest, RecoverDeleteTransactionRequest,
|
||||
RenameDataRequest, RenameFileRequest, SettlePartTransactionRequest, SnapshotLeaseReleaseRequest, SnapshotLeaseRenewRequest,
|
||||
SnapshotLeaseRequest, SnapshotLeaseResponse, StatVolumeRequest, UpdateMetadataRequest, VerifyFileRequest, WriteAllRequest,
|
||||
WriteMetadataRequest, node_service_client::NodeServiceClient,
|
||||
};
|
||||
use serde::{Serialize, de::DeserializeOwned};
|
||||
use std::{
|
||||
@@ -142,6 +144,45 @@ fn decode_delete_versions_errors(response: DeleteVersionsResponse, expected_len:
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn decode_delete_recovery_batch_errors(codes: Vec<Option<u32>>, expected_len: usize) -> Result<Vec<Option<DiskError>>> {
|
||||
if codes.len() != expected_len {
|
||||
return Err(Error::other("malformed recoverable batch delete response length"));
|
||||
}
|
||||
codes
|
||||
.into_iter()
|
||||
.map(|code| match code {
|
||||
None => Ok(None),
|
||||
Some(code) => DiskError::from_u32(code)
|
||||
.map(Some)
|
||||
.ok_or_else(|| Error::other("invalid recoverable batch delete error code")),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn validate_delete_recovery_inspection(
|
||||
volume: &str,
|
||||
object: &str,
|
||||
transaction: Uuid,
|
||||
inspection: &DeleteRecoveryInspection,
|
||||
) -> Result<()> {
|
||||
if inspection.protocol_version != DELETE_RECOVERY_PROTOCOL_VERSION
|
||||
|| inspection.id.object != object
|
||||
|| inspection.id.transaction != transaction
|
||||
|| transaction.is_nil()
|
||||
{
|
||||
return Err(Error::other("delete recovery inspection does not match request"));
|
||||
}
|
||||
if let Some(journal) = &inspection.journal
|
||||
&& (journal.protocol_version != DELETE_RECOVERY_PROTOCOL_VERSION
|
||||
|| journal.volume != volume
|
||||
|| journal.object != object
|
||||
|| journal.transaction != transaction)
|
||||
{
|
||||
return Err(Error::other("delete recovery journal does not match request"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn snapshot_lease_token_from_response(response: SnapshotLeaseResponse) -> Result<SnapshotLeaseToken> {
|
||||
if !response.success {
|
||||
return Err(response.error.unwrap_or_default().into());
|
||||
@@ -858,6 +899,75 @@ fn spawn_control_channel_prewarm(addr: String) {
|
||||
}
|
||||
|
||||
impl RemoteDisk {
|
||||
async fn recover_delete_transaction(
|
||||
&self,
|
||||
volume: &str,
|
||||
operation: DeleteRecoveryRpcOperation,
|
||||
) -> Result<DeleteRecoveryRpcReply> {
|
||||
let payload = rmp_serde::to_vec_named(&operation)?;
|
||||
if payload.len() > operation.max_wire_payload_bytes() {
|
||||
return Err(Error::other("delete recovery operation exceeds payload limit"));
|
||||
}
|
||||
let settle_timeout = if matches!(&operation, DeleteRecoveryRpcOperation::Settle { .. }) {
|
||||
Some(crate::disk::get_delete_recovery_timeout()?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
self.execute_with_timeout(
|
||||
|| async {
|
||||
let mut client = if let Some(timeout) = settle_timeout {
|
||||
if let Some(error) = self.offline_bypass_error() {
|
||||
return Err(error);
|
||||
}
|
||||
node_service_delete_recovery_client(
|
||||
&self.addr,
|
||||
TonicInterceptor::Signature(gen_tonic_signature_interceptor()),
|
||||
timeout,
|
||||
)
|
||||
.await?
|
||||
} else {
|
||||
self.get_client().await?
|
||||
};
|
||||
let mut request = Request::new(RecoverDeleteTransactionRequest {
|
||||
disk: self.endpoint.to_string(),
|
||||
volume: volume.to_owned(),
|
||||
protocol_version: DELETE_RECOVERY_PROTOCOL_VERSION,
|
||||
operation: payload.into(),
|
||||
});
|
||||
let canonical_body = rustfs_protos::canonical_recover_delete_transaction_request_body(request.get_ref())
|
||||
.map_err(|_| Error::other("recover_delete_transaction request length cannot be represented"))?;
|
||||
crate::cluster::rpc::set_tonic_canonical_body_digest(&mut request, &canonical_body).map_err(Error::other)?;
|
||||
// UNIMPLEMENTED is deliberately propagated: older peers cannot settle transactions.
|
||||
let response = client
|
||||
.recover_delete_transaction(request)
|
||||
.await
|
||||
.map_err(|status| {
|
||||
if status.code() == Code::Unimplemented {
|
||||
DiskError::UnsupportedDisk
|
||||
} else {
|
||||
status.into()
|
||||
}
|
||||
})?
|
||||
.into_inner();
|
||||
if response.protocol_version != DELETE_RECOVERY_PROTOCOL_VERSION {
|
||||
return Err(DiskError::UnsupportedDisk);
|
||||
}
|
||||
if !response.success {
|
||||
return Err(response
|
||||
.error
|
||||
.ok_or_else(|| Error::other("missing delete recovery error"))?
|
||||
.into());
|
||||
}
|
||||
if response.error.is_some() {
|
||||
return Err(Error::other("malformed delete recovery success"));
|
||||
}
|
||||
rustfs_protos::decode_delete_recovery_payload(&response.result).map_err(Into::into)
|
||||
},
|
||||
settle_timeout.unwrap_or_else(get_max_timeout_duration),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn rename_file_with_durability(
|
||||
&self,
|
||||
src_volume: &str,
|
||||
@@ -1920,6 +2030,12 @@ fn encode_msgpack<T: Serialize>(value: &T) -> Result<Vec<u8>> {
|
||||
encode_msgpack_with_capacity(value, MSGPACK_ENCODE_CAPACITY_HINT)
|
||||
}
|
||||
|
||||
// Optional fields are omitted by serde; named maps keep rollback flags and
|
||||
// transaction UUIDs independent of field order across peer versions.
|
||||
fn encode_delete_options_msgpack(value: &DeleteOptions) -> Result<Vec<u8>> {
|
||||
Ok(rmp_serde::to_vec_named(value)?)
|
||||
}
|
||||
|
||||
fn encode_file_info_msgpack(value: &FileInfo) -> Result<Vec<u8>> {
|
||||
encode_msgpack_with_capacity(value, FILE_INFO_MSGPACK_ENCODE_CAPACITY_HINT)
|
||||
}
|
||||
@@ -2163,6 +2279,7 @@ impl RemoteDisk {
|
||||
self.execute_with_timeout_for_op(
|
||||
"rename_data",
|
||||
|| async {
|
||||
let prepare_started = rustfs_io_metrics::put_stage_timer();
|
||||
let file_info = compat_json(fi)?;
|
||||
let file_info_bin = encode_file_info_msgpack(fi)?;
|
||||
let mut client = self.get_client().await?;
|
||||
@@ -2190,24 +2307,67 @@ impl RemoteDisk {
|
||||
} else {
|
||||
attach_mutation_body_digest(&mut request, canonical_body, "rename_data")?;
|
||||
}
|
||||
rustfs_io_metrics::record_put_object_stage_duration_from(
|
||||
rustfs_io_metrics::PUT_STAGE_SET_DISK_RENAME_REMOTE_CLIENT_PREPARE,
|
||||
prepare_started,
|
||||
);
|
||||
|
||||
let response = if incarnation_bound {
|
||||
// Older peers return Unimplemented before mutation; never downgrade.
|
||||
client.rename_data_at_incarnation(request).await?
|
||||
} else {
|
||||
client.rename_data(request).await?
|
||||
}
|
||||
.into_inner();
|
||||
let rpc_started = rustfs_io_metrics::put_stage_timer();
|
||||
let response = rustfs_io_metrics::observe_put_stage_future(
|
||||
async {
|
||||
if incarnation_bound {
|
||||
// Older peers return Unimplemented before mutation; never downgrade.
|
||||
client.rename_data_at_incarnation(request).await
|
||||
} else {
|
||||
client.rename_data(request).await
|
||||
}
|
||||
},
|
||||
rustfs_io_metrics::PUT_STAGE_SET_DISK_RENAME_REMOTE_CLIENT_RPC_AWAIT,
|
||||
rustfs_io_metrics::PUT_STAGE_SET_DISK_RENAME_REMOTE_CLIENT_RPC_AWAIT_POLL_PENDING_COUNT,
|
||||
rustfs_io_metrics::PUT_STAGE_SET_DISK_RENAME_REMOTE_CLIENT_RPC_AWAIT_FIRST_PENDING_TO_READY,
|
||||
)
|
||||
.await?;
|
||||
let response_process_started = rustfs_io_metrics::put_stage_timer();
|
||||
let response_headers_started = rustfs_io_metrics::put_stage_timer();
|
||||
let _ = response.metadata().len();
|
||||
rustfs_io_metrics::record_put_object_stage_duration_from(
|
||||
rustfs_io_metrics::PUT_STAGE_SET_DISK_RENAME_REMOTE_CLIENT_RESPONSE_HEADERS,
|
||||
response_headers_started,
|
||||
);
|
||||
let into_inner_started = rustfs_io_metrics::put_stage_timer();
|
||||
let response = response.into_inner();
|
||||
rustfs_io_metrics::record_put_object_stage_duration_from(
|
||||
rustfs_io_metrics::PUT_STAGE_SET_DISK_RENAME_REMOTE_CLIENT_INTO_INNER,
|
||||
into_inner_started,
|
||||
);
|
||||
rustfs_io_metrics::record_put_object_stage_duration_from(
|
||||
rustfs_io_metrics::PUT_STAGE_SET_DISK_RENAME_REMOTE_CLIENT_RPC,
|
||||
rpc_started,
|
||||
);
|
||||
|
||||
let success_check_started = rustfs_io_metrics::put_stage_timer();
|
||||
if !response.success {
|
||||
return Err(response.error.unwrap_or_default().into());
|
||||
}
|
||||
rustfs_io_metrics::record_put_object_stage_duration_from(
|
||||
rustfs_io_metrics::PUT_STAGE_SET_DISK_RENAME_REMOTE_CLIENT_SUCCESS_CHECK,
|
||||
success_check_started,
|
||||
);
|
||||
|
||||
let decode_started = rustfs_io_metrics::put_stage_timer();
|
||||
let rename_data_resp = decode_msgpack_or_json::<RenameDataResp>(
|
||||
&response.rename_data_resp_bin,
|
||||
&response.rename_data_resp,
|
||||
"RenameDataResp",
|
||||
)?;
|
||||
rustfs_io_metrics::record_put_object_stage_duration_from(
|
||||
rustfs_io_metrics::PUT_STAGE_SET_DISK_RENAME_REMOTE_CLIENT_DECODE,
|
||||
decode_started,
|
||||
);
|
||||
rustfs_io_metrics::record_put_object_stage_duration_from(
|
||||
rustfs_io_metrics::PUT_STAGE_SET_DISK_RENAME_REMOTE_CLIENT_RESPONSE_PROCESS,
|
||||
response_process_started,
|
||||
);
|
||||
|
||||
Ok(rename_data_resp)
|
||||
},
|
||||
@@ -2566,7 +2726,7 @@ impl DiskAPI for RemoteDisk {
|
||||
// `_bin` support for DeleteVersion is new (grpc-optimization P2); always dual-write
|
||||
// JSON + msgpack until its fallback counter has read zero across a release window.
|
||||
let file_info_bin = encode_file_info_msgpack(&fi)?;
|
||||
let opts_bin = encode_msgpack(&opts)?;
|
||||
let opts_bin = encode_delete_options_msgpack(&opts)?;
|
||||
let conditional_marker = opts.expected_delete_marker.is_some();
|
||||
let file_info = serde_json::to_string(&fi)?;
|
||||
let opts = serde_json::to_string(&opts)?;
|
||||
@@ -2638,7 +2798,7 @@ impl DiskAPI for RemoteDisk {
|
||||
|
||||
// `_bin` support for DeleteVersions is new (grpc-optimization P2); always dual-write JSON +
|
||||
// msgpack until its fallback counter has read zero across a release window.
|
||||
let opts_bin = match encode_msgpack(&opts) {
|
||||
let opts_bin = match encode_delete_options_msgpack(&opts) {
|
||||
Ok(opts_bin) => opts_bin,
|
||||
Err(err) => {
|
||||
let mut errors = Vec::with_capacity(versions.len());
|
||||
@@ -3617,6 +3777,138 @@ impl DiskAPI for RemoteDisk {
|
||||
}
|
||||
|
||||
#[tracing::instrument(level = "trace", skip_all)]
|
||||
async fn delete_version_recoverable(
|
||||
&self,
|
||||
volume: &str,
|
||||
object: &str,
|
||||
file_info: FileInfo,
|
||||
force_del_marker: bool,
|
||||
opts: DeleteOptions,
|
||||
) -> Result<()> {
|
||||
match self
|
||||
.recover_delete_transaction(
|
||||
volume,
|
||||
DeleteRecoveryRpcOperation::ApplyVersion {
|
||||
object: object.to_owned(),
|
||||
file_info: Box::new(file_info),
|
||||
force_del_marker,
|
||||
opts,
|
||||
},
|
||||
)
|
||||
.await?
|
||||
{
|
||||
DeleteRecoveryRpcReply::AppliedVersion => Ok(()),
|
||||
_ => Err(Error::other("unexpected recoverable delete reply")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete_versions_recoverable(
|
||||
&self,
|
||||
volume: &str,
|
||||
versions: Vec<FileInfoVersions>,
|
||||
opts: DeleteOptions,
|
||||
) -> Vec<Option<DiskError>> {
|
||||
let expected_len = versions.len();
|
||||
let result = match self
|
||||
.recover_delete_transaction(volume, DeleteRecoveryRpcOperation::ApplyVersions { versions, opts })
|
||||
.await
|
||||
{
|
||||
Ok(DeleteRecoveryRpcReply::AppliedVersions(codes)) => decode_delete_recovery_batch_errors(codes, expected_len),
|
||||
Ok(_) => Err(Error::other("unexpected recoverable batch delete reply")),
|
||||
Err(error) => Err(error),
|
||||
};
|
||||
match result {
|
||||
Ok(errors) => errors,
|
||||
Err(error) => vec![Some(error); expected_len],
|
||||
}
|
||||
}
|
||||
|
||||
async fn discover_delete_transactions(
|
||||
&self,
|
||||
volume: &str,
|
||||
object: Option<&str>,
|
||||
cursor: Option<DeleteRecoveryCursor>,
|
||||
limit: u32,
|
||||
) -> Result<DeleteRecoveryPage> {
|
||||
match self
|
||||
.recover_delete_transaction(
|
||||
volume,
|
||||
DeleteRecoveryRpcOperation::Discover {
|
||||
object: object.map(str::to_owned),
|
||||
cursor,
|
||||
limit,
|
||||
},
|
||||
)
|
||||
.await?
|
||||
{
|
||||
DeleteRecoveryRpcReply::Discovered(page) => {
|
||||
if object.is_some_and(|object| page.transactions.iter().any(|id| id.object != object)) {
|
||||
return Err(Error::other("delete recovery discovery escaped requested object"));
|
||||
}
|
||||
if page.transactions.len() > usize::try_from(limit).map_err(|_| Error::other("invalid discovery limit"))?
|
||||
|| page
|
||||
.transactions
|
||||
.iter()
|
||||
.any(|id| id.transaction.is_nil() || id.object.is_empty())
|
||||
{
|
||||
return Err(Error::other("malformed delete discovery page"));
|
||||
}
|
||||
Ok(page)
|
||||
}
|
||||
_ => Err(Error::other("unexpected delete discovery reply")),
|
||||
}
|
||||
}
|
||||
async fn inspect_delete_transaction(
|
||||
&self,
|
||||
volume: &str,
|
||||
object: &str,
|
||||
transaction: Uuid,
|
||||
) -> Result<DeleteRecoveryInspection> {
|
||||
match self
|
||||
.recover_delete_transaction(
|
||||
volume,
|
||||
DeleteRecoveryRpcOperation::Inspect {
|
||||
object: object.to_owned(),
|
||||
transaction,
|
||||
},
|
||||
)
|
||||
.await?
|
||||
{
|
||||
DeleteRecoveryRpcReply::Inspected(inspection) => {
|
||||
validate_delete_recovery_inspection(volume, object, transaction, &inspection)?;
|
||||
Ok(inspection)
|
||||
}
|
||||
_ => Err(Error::other("unexpected delete inspection reply")),
|
||||
}
|
||||
}
|
||||
async fn settle_delete_transaction(
|
||||
&self,
|
||||
volume: &str,
|
||||
object: &str,
|
||||
transaction: Uuid,
|
||||
expected_fingerprint: DeleteRecoveryDigest,
|
||||
action: DeleteRecoveryAction,
|
||||
) -> Result<DeleteRecoveryInspection> {
|
||||
match self
|
||||
.recover_delete_transaction(
|
||||
volume,
|
||||
DeleteRecoveryRpcOperation::Settle {
|
||||
object: object.to_owned(),
|
||||
transaction,
|
||||
expected_fingerprint,
|
||||
action,
|
||||
},
|
||||
)
|
||||
.await?
|
||||
{
|
||||
DeleteRecoveryRpcReply::Settled(inspection) => {
|
||||
validate_delete_recovery_inspection(volume, object, transaction, &inspection)?;
|
||||
Ok(inspection)
|
||||
}
|
||||
_ => Err(Error::other("unexpected delete settlement reply")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn settle_part_transaction(&self, volume: &str, path: &str, action: PartTransactionAction) -> Result<()> {
|
||||
self.execute_with_timeout(
|
||||
|| async {
|
||||
@@ -4030,6 +4322,45 @@ mod tests {
|
||||
|
||||
static INIT: Once = Once::new();
|
||||
|
||||
#[test]
|
||||
fn recoverable_batch_delete_rejects_invalid_error_codes_and_length() {
|
||||
let errors = decode_delete_recovery_batch_errors(vec![None, Some(DiskError::FileNotFound.to_u32())], 2)
|
||||
.expect("valid per-item errors");
|
||||
assert!(errors[0].is_none());
|
||||
assert_eq!(errors[1], Some(DiskError::FileNotFound));
|
||||
assert!(decode_delete_recovery_batch_errors(vec![None], 2).is_err());
|
||||
assert!(decode_delete_recovery_batch_errors(vec![Some(0)], 1).is_err());
|
||||
assert!(decode_delete_recovery_batch_errors(vec![Some(u32::MAX)], 1).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_recovery_inspection_rejects_wrong_transaction_object_and_protocol() {
|
||||
let transaction = Uuid::new_v4();
|
||||
let inspection = DeleteRecoveryInspection {
|
||||
protocol_version: DELETE_RECOVERY_PROTOCOL_VERSION,
|
||||
id: crate::disk::DeleteTransactionId {
|
||||
object: "object".into(),
|
||||
transaction,
|
||||
},
|
||||
state: crate::disk::DeleteRecoveryState::Absent,
|
||||
journal: None,
|
||||
current_digest: None,
|
||||
current_versions: Vec::new(),
|
||||
backup_digest: None,
|
||||
rollback_data_complete: false,
|
||||
commit_data_safe: false,
|
||||
explicit_delete_safe: false,
|
||||
fingerprint: [0; 32],
|
||||
};
|
||||
validate_delete_recovery_inspection("bucket", "object", transaction, &inspection).expect("matching inspection");
|
||||
assert!(validate_delete_recovery_inspection("bucket", "other", transaction, &inspection).is_err());
|
||||
assert!(validate_delete_recovery_inspection("bucket", "object", Uuid::new_v4(), &inspection).is_err());
|
||||
assert!(validate_delete_recovery_inspection("bucket", "object", Uuid::nil(), &inspection).is_err());
|
||||
let mut incompatible = inspection;
|
||||
incompatible.protocol_version += 1;
|
||||
assert!(validate_delete_recovery_inspection("bucket", "object", transaction, &incompatible).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compare_and_update_wire_outcome_fails_closed() {
|
||||
assert_eq!(
|
||||
@@ -4063,32 +4394,19 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retired_marker_options_preserve_legacy_positional_wire_shape() {
|
||||
fn delete_options_named_wire_preserves_rollback_and_legacy_marker_fields() {
|
||||
#[derive(Debug, serde::Serialize, serde::Deserialize, PartialEq)]
|
||||
struct LegacyDeleteOptions {
|
||||
recursive: bool,
|
||||
immediate: bool,
|
||||
undo_write: bool,
|
||||
#[serde(default)]
|
||||
undo_delete: bool,
|
||||
old_data_dir: Option<Uuid>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
expected_delete_marker: Option<rustfs_filemeta::MetaDeleteMarker>,
|
||||
}
|
||||
let legacy = LegacyDeleteOptions {
|
||||
recursive: false,
|
||||
immediate: false,
|
||||
undo_write: false,
|
||||
undo_delete: false,
|
||||
old_data_dir: None,
|
||||
};
|
||||
let original = encode_msgpack(&legacy).unwrap();
|
||||
let current = encode_msgpack(&DeleteOptions::default()).unwrap();
|
||||
assert_eq!(current, original, "ordinary deletes must retain the older peer's positional payload");
|
||||
assert_eq!(rmp_serde::from_slice::<LegacyDeleteOptions>(¤t).unwrap(), legacy);
|
||||
assert!(
|
||||
rmp_serde::from_slice::<DeleteOptions>(&original)
|
||||
.unwrap()
|
||||
.expected_delete_marker
|
||||
.is_none()
|
||||
);
|
||||
let transaction = Uuid::new_v4();
|
||||
let mut marker = FileInfo {
|
||||
deleted: true,
|
||||
version_id: Some(Uuid::new_v4()),
|
||||
@@ -4097,12 +4415,69 @@ mod tests {
|
||||
};
|
||||
marker.set_delete_marker_incarnation(Uuid::new_v4());
|
||||
let marker = rustfs_filemeta::MetaDeleteMarker::from(marker);
|
||||
for expected_delete_marker in [None, Some(marker)] {
|
||||
for (undo_write, undo_delete) in [(true, false), (false, true)] {
|
||||
let legacy = LegacyDeleteOptions {
|
||||
recursive: false,
|
||||
immediate: false,
|
||||
undo_write,
|
||||
undo_delete,
|
||||
old_data_dir: Some(transaction),
|
||||
expected_delete_marker: expected_delete_marker.clone(),
|
||||
};
|
||||
// Older senders use positional arrays. Their unchanged field prefix and
|
||||
// missing optional fields must also decode on the named-map sender's peer.
|
||||
for (encoding, payload) in [
|
||||
("positional", rmp_serde::to_vec(&legacy).expect("encode legacy positional options")),
|
||||
("named", rmp_serde::to_vec_named(&legacy).expect("encode legacy named options")),
|
||||
] {
|
||||
let decoded: DeleteOptions = rmp_serde::from_slice(&payload).expect("decode legacy options on current peer");
|
||||
assert_eq!(decoded.undo_write, undo_write, "{encoding} undo-write flag");
|
||||
assert_eq!(decoded.undo_delete, undo_delete, "{encoding} undo-delete flag");
|
||||
assert_eq!(decoded.old_data_dir, Some(transaction), "{encoding} transaction UUID");
|
||||
assert_eq!(decoded.expected_delete_marker, expected_delete_marker, "{encoding} marker precondition");
|
||||
assert!(decoded.delete_recovery_bucket_incarnation.is_none(), "{encoding} omitted incarnation");
|
||||
assert!(!decoded.recoverable_delete, "{encoding} omitted recovery flag");
|
||||
}
|
||||
for incarnation in [None, Some(Uuid::new_v4())] {
|
||||
let current = DeleteOptions {
|
||||
undo_write,
|
||||
undo_delete,
|
||||
old_data_dir: Some(transaction),
|
||||
expected_delete_marker: expected_delete_marker.clone(),
|
||||
delete_recovery_bucket_incarnation: incarnation,
|
||||
recoverable_delete: true,
|
||||
..Default::default()
|
||||
};
|
||||
let payload = encode_delete_options_msgpack(¤t).expect("encode current named options");
|
||||
let decoded: DeleteOptions = rmp_serde::from_slice(&payload).expect("decode current options on current peer");
|
||||
assert_eq!(decoded.undo_write, undo_write);
|
||||
assert_eq!(decoded.undo_delete, undo_delete);
|
||||
assert_eq!(decoded.old_data_dir, Some(transaction));
|
||||
assert_eq!(decoded.expected_delete_marker, expected_delete_marker);
|
||||
assert_eq!(decoded.delete_recovery_bucket_incarnation, incarnation);
|
||||
assert!(decoded.recoverable_delete);
|
||||
let decoded: LegacyDeleteOptions =
|
||||
rmp_serde::from_slice(&payload).expect("decode current options on legacy peer");
|
||||
assert_eq!(decoded, legacy, "unknown recovery fields cannot shift rollback or marker values");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_nonempty_delete_options_do_not_fall_back_to_json() {
|
||||
let options = DeleteOptions {
|
||||
expected_delete_marker: Some(marker.clone()),
|
||||
undo_write: true,
|
||||
old_data_dir: Some(Uuid::new_v4()),
|
||||
..Default::default()
|
||||
};
|
||||
let decoded: DeleteOptions = rmp_serde::from_slice(&encode_msgpack(&options).unwrap()).unwrap();
|
||||
assert_eq!(decoded.expected_delete_marker, Some(marker));
|
||||
let json = serde_json::to_string(&options).expect("valid compatibility JSON");
|
||||
assert!(decode_msgpack_or_json::<DeleteOptions>(&[0xc1], &json, "DeleteOptions").is_err());
|
||||
let decoded: DeleteOptions =
|
||||
decode_msgpack_or_json(&[], &json, "DeleteOptions").expect("absent MessagePack permits JSON compatibility");
|
||||
assert!(decoded.undo_write);
|
||||
assert_eq!(decoded.old_data_dir, options.old_data_dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -4785,6 +5160,8 @@ mod tests {
|
||||
cleanup_data_dir: Some(Uuid::new_v4()),
|
||||
sign: Some(vec![0x14, 0x35]),
|
||||
old_current_size: Some(crate::disk::OldCurrentSize::Present(64 * 1024)),
|
||||
old_current_source_checked: false,
|
||||
old_current_source: None,
|
||||
};
|
||||
let json = serde_json::to_string(&response).expect("legacy rename_data JSON response should encode");
|
||||
let decode_before = rustfs_io_metrics::internode_metrics::global_internode_metrics().msgpack_json_decode_total_for_test();
|
||||
@@ -5187,6 +5564,8 @@ mod tests {
|
||||
cleanup_data_dir: Some(Uuid::new_v4()),
|
||||
sign: Some(vec![1_u8; 32]),
|
||||
old_current_size: Some(crate::disk::OldCurrentSize::Present(4096)),
|
||||
old_current_source_checked: false,
|
||||
old_current_source: None,
|
||||
};
|
||||
let json = serde_json::to_vec(&response).expect("rename data response json should encode");
|
||||
let named_msgpack = rmp_serde::encode::to_vec_named(&response).expect("rename data response named msgpack should encode");
|
||||
|
||||
@@ -495,22 +495,10 @@ pub(crate) async fn read_config_preserve_empty<S>(api: Arc<S>, file: &str) -> Re
|
||||
where
|
||||
S: EcstoreObjectIO,
|
||||
{
|
||||
let (data, _obj) = read_config_preserve_empty_with_metadata_opts(api, file, &ObjectOptions::default()).await?;
|
||||
let (data, _obj) = read_config_with_metadata_inner(api, file, &ObjectOptions::default(), true, None).await?;
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
/// Preserve an empty config payload, its metadata, and caller-supplied read options.
|
||||
pub(crate) async fn read_config_preserve_empty_with_metadata_opts<S>(
|
||||
api: Arc<S>,
|
||||
file: &str,
|
||||
opts: &ObjectOptions,
|
||||
) -> Result<(Vec<u8>, ObjectInfo)>
|
||||
where
|
||||
S: EcstoreObjectIO,
|
||||
{
|
||||
read_config_with_metadata_inner(api, file, opts, true, None).await
|
||||
}
|
||||
|
||||
pub async fn read_config_no_lock<S>(api: Arc<S>, file: &str) -> Result<Vec<u8>>
|
||||
where
|
||||
S: ObjectIO<
|
||||
@@ -539,13 +527,15 @@ pub(crate) async fn read_config_no_lock_preserve_empty_with_metadata<S>(api: Arc
|
||||
where
|
||||
S: EcstoreObjectIO,
|
||||
{
|
||||
read_config_preserve_empty_with_metadata_opts(
|
||||
read_config_with_metadata_inner(
|
||||
api,
|
||||
file,
|
||||
&ObjectOptions {
|
||||
no_lock: true,
|
||||
..Default::default()
|
||||
},
|
||||
true,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -2825,10 +2815,9 @@ mod tests {
|
||||
config_task_join_error, configs_semantically_equal, decode_server_config_blob, encode_server_config_blob,
|
||||
heal_config_descriptor, is_standard_object_server_config, lookup_configs, map_system_metadata_write_error,
|
||||
new_and_save_server_config, read_config, read_config_no_lock_preserve_empty_with_metadata, read_config_preserve_empty,
|
||||
read_config_preserve_empty_with_metadata_opts, read_config_with_metadata, read_config_without_migrate,
|
||||
read_server_config_snapshot, save_config_with_opts_inner, save_server_config, save_server_config_snapshot,
|
||||
save_server_config_snapshot_with_generation, server_config_transaction_lock_path, should_warn_ignored_scalar_section,
|
||||
storage_class_kvs_mut,
|
||||
read_config_with_metadata, read_config_without_migrate, read_server_config_snapshot, save_config_with_opts_inner,
|
||||
save_server_config, save_server_config_snapshot, save_server_config_snapshot_with_generation,
|
||||
server_config_transaction_lock_path, should_warn_ignored_scalar_section, storage_class_kvs_mut,
|
||||
};
|
||||
use crate::config::{audit, heal, notify, oidc, scanner};
|
||||
use crate::disk::{RUSTFS_META_BUCKET, endpoint::Endpoint};
|
||||
@@ -5076,7 +5065,6 @@ mod tests {
|
||||
heal_replacement: Option<Vec<u8>>,
|
||||
heal_calls: AtomicUsize,
|
||||
write_calls: AtomicUsize,
|
||||
last_get_no_lock: AtomicBool,
|
||||
last_put_no_lock: AtomicBool,
|
||||
last_put_preconditions: Mutex<Option<HTTPPreconditions>>,
|
||||
revision: AtomicUsize,
|
||||
@@ -5092,7 +5080,6 @@ mod tests {
|
||||
heal_replacement,
|
||||
heal_calls: AtomicUsize::new(0),
|
||||
write_calls: AtomicUsize::new(0),
|
||||
last_get_no_lock: AtomicBool::new(false),
|
||||
last_put_no_lock: AtomicBool::new(false),
|
||||
last_put_preconditions: Mutex::new(None),
|
||||
revision: AtomicUsize::new(1),
|
||||
@@ -5148,9 +5135,8 @@ mod tests {
|
||||
_object: &str,
|
||||
_range: Option<HTTPRangeSpec>,
|
||||
_h: HeaderMap,
|
||||
opts: &ObjectOptions,
|
||||
_opts: &ObjectOptions,
|
||||
) -> Result<GetObjectReader> {
|
||||
self.last_get_no_lock.store(opts.no_lock, Ordering::SeqCst);
|
||||
let data = match &*self.state.lock().expect("state lock poisoned") {
|
||||
RecoveryReadState::Missing => return Err(Error::ConfigNotFound),
|
||||
RecoveryReadState::Blob(data) => data.clone(),
|
||||
@@ -5445,60 +5431,6 @@ mod tests {
|
||||
assert!(data.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_config_preserve_empty_metadata_keeps_empty_and_options() {
|
||||
let store = Arc::new(RecoveryMockStore::new(RecoveryReadState::Blob(Vec::new()), None));
|
||||
for no_lock in [false, true] {
|
||||
let opts = ObjectOptions {
|
||||
no_lock,
|
||||
..Default::default()
|
||||
};
|
||||
let (data, info) = read_config_preserve_empty_with_metadata_opts(store.clone(), "config/empty.json", &opts)
|
||||
.await
|
||||
.expect("an existing empty config must retain its payload and metadata");
|
||||
assert!(data.is_empty());
|
||||
assert_eq!(info.size, 0);
|
||||
assert_eq!(info.actual_size, 0);
|
||||
assert_eq!(info.etag.as_deref(), Some("config-1"));
|
||||
assert_eq!(info.data_dir, Some(uuid::Uuid::from_u128(1)));
|
||||
assert_eq!(store.last_get_no_lock.load(Ordering::SeqCst), no_lock);
|
||||
}
|
||||
let err = read_config(store, "config/empty.json")
|
||||
.await
|
||||
.expect_err("the existing reader must still treat empty configs as missing");
|
||||
assert!(matches!(err, Error::ConfigNotFound));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_config_preserve_empty_metadata_keeps_payload_and_metadata() {
|
||||
let payload = b"original config bytes".to_vec();
|
||||
let store = Arc::new(RecoveryMockStore::new(RecoveryReadState::Blob(payload.clone()), None));
|
||||
let (data, info) =
|
||||
read_config_preserve_empty_with_metadata_opts(store, "config/nonempty.json", &ObjectOptions::default())
|
||||
.await
|
||||
.expect("a nonempty config must retain its payload and metadata");
|
||||
assert_eq!(data, payload);
|
||||
let size = i64::try_from(payload.len()).expect("test payload size fits i64");
|
||||
assert_eq!(info.size, size);
|
||||
assert_eq!(info.actual_size, size);
|
||||
assert_eq!(info.etag.as_deref(), Some("config-1"));
|
||||
assert_eq!(info.data_dir, Some(uuid::Uuid::from_u128(1)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_config_preserve_empty_metadata_preserves_read_errors() {
|
||||
let missing = Arc::new(RecoveryMockStore::new(RecoveryReadState::Missing, None));
|
||||
let err = read_config_preserve_empty_with_metadata_opts(missing, "config/missing.json", &ObjectOptions::default())
|
||||
.await
|
||||
.expect_err("physical absence must remain ConfigNotFound");
|
||||
assert!(matches!(err, Error::ConfigNotFound));
|
||||
let quorum = Arc::new(RecoveryMockStore::new(RecoveryReadState::QuorumError, None));
|
||||
let err = read_config_preserve_empty_with_metadata_opts(quorum, "config/quorum.json", &ObjectOptions::default())
|
||||
.await
|
||||
.expect_err("a quorum failure must retain its typed read error");
|
||||
assert!(matches!(err, Error::ErasureReadQuorum));
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl StorageAdminApi for RecoveryMockStore {
|
||||
type BackendInfo = rustfs_madmin::BackendInfo;
|
||||
|
||||
@@ -3691,6 +3691,55 @@ fn activation_pool_meta_lock_error(err: rustfs_lock::LockError) -> Error {
|
||||
}
|
||||
}
|
||||
|
||||
fn rebalance_worker_read_lock_error(err: rustfs_lock::LockError, object: &str) -> Error {
|
||||
match err {
|
||||
rustfs_lock::LockError::QuorumNotReached { required, achieved } => Error::NamespaceLockQuorumUnavailable {
|
||||
mode: "read",
|
||||
bucket: RUSTFS_META_BUCKET.to_string(),
|
||||
object: object.to_string(),
|
||||
required,
|
||||
achieved,
|
||||
},
|
||||
other => Error::Lock(other),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read-only admission to an already durable rebalance run. Keeping this
|
||||
/// separate from the activation writer prevents admission from authorizing a
|
||||
/// pool or rebalance metadata write under shared namespace locks.
|
||||
pub(crate) struct PoolRebalanceWorkerFence {
|
||||
pool_meta_guard: rustfs_lock::NamespaceLockGuard,
|
||||
rebalance_meta_guard: rustfs_lock::NamespaceLockGuard,
|
||||
fleet_proof: Option<crate::services::notification_sys::CrossPoolFenceFleetProofToken>,
|
||||
}
|
||||
|
||||
impl PoolRebalanceWorkerFence {
|
||||
pub(crate) fn set_fleet_proof(
|
||||
&mut self,
|
||||
fleet_proof: Option<crate::services::notification_sys::CrossPoolFenceFleetProofToken>,
|
||||
) {
|
||||
self.fleet_proof = fleet_proof;
|
||||
}
|
||||
|
||||
pub(crate) fn ensure_held(&self) -> Result<()> {
|
||||
if self.pool_meta_guard.is_lock_lost()
|
||||
|| self.pool_meta_guard.is_released()
|
||||
|| self.rebalance_meta_guard.is_lock_lost()
|
||||
|| self.rebalance_meta_guard.is_released()
|
||||
{
|
||||
return Err(Error::other("worker admission lock lost before rebalance activation"));
|
||||
}
|
||||
if self
|
||||
.fleet_proof
|
||||
.as_ref()
|
||||
.is_some_and(|proof| !crate::services::notification_sys::cross_pool_fence_fleet_proof_matches(proof))
|
||||
{
|
||||
return Err(Error::other(POOL_ACTIVATION_FLEET_PROOF_EXPIRED));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct PoolRebalanceActivationFence {
|
||||
pool_meta_guard: Arc<rustfs_lock::NamespaceLockGuard>,
|
||||
rebalance_meta_guard: Arc<rustfs_lock::NamespaceLockGuard>,
|
||||
@@ -3762,6 +3811,35 @@ tokio::task_local! {
|
||||
pub(crate) static REBALANCE_ACTIVATION_LOCK_ATTEMPT: Arc<tokio::sync::Notify>;
|
||||
}
|
||||
|
||||
pub(crate) async fn acquire_pool_rebalance_worker_locks<S>(pool: Arc<S>) -> Result<PoolRebalanceWorkerFence>
|
||||
where
|
||||
S: crate::storage_api_contracts::namespace::NamespaceLocking<
|
||||
Error = Error,
|
||||
NamespaceLock = rustfs_lock::NamespaceLockWrapper,
|
||||
>,
|
||||
{
|
||||
// Match activation and entry admission: rebalance.bin -> pool.bin. Joining
|
||||
// the same durable run must coexist with entries retaining their run fence.
|
||||
let rebalance_meta_lock = pool.new_ns_lock(RUSTFS_META_BUCKET, REBAL_META_NAME).await?;
|
||||
#[cfg(test)]
|
||||
let _ = REBALANCE_ACTIVATION_LOCK_ATTEMPT.try_with(|attempted| attempted.notify_one());
|
||||
let rebalance_meta_guard = rebalance_meta_lock
|
||||
.get_read_lock(get_lock_acquire_timeout())
|
||||
.await
|
||||
.map_err(|err| rebalance_worker_read_lock_error(err, REBAL_META_NAME))?;
|
||||
let pool_meta_lock = pool.new_ns_lock(RUSTFS_META_BUCKET, POOL_META_NAME).await?;
|
||||
let pool_meta_guard = pool_meta_lock
|
||||
.get_read_lock(get_lock_acquire_timeout())
|
||||
.await
|
||||
.map_err(|err| rebalance_worker_read_lock_error(err, POOL_META_NAME))?;
|
||||
|
||||
Ok(PoolRebalanceWorkerFence {
|
||||
pool_meta_guard,
|
||||
rebalance_meta_guard,
|
||||
fleet_proof: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn acquire_pool_rebalance_activation_locks<S>(
|
||||
pool: Arc<S>,
|
||||
fleet_proof: Option<crate::services::notification_sys::CrossPoolFenceFleetProofToken>,
|
||||
@@ -12430,6 +12508,18 @@ impl ECStore {
|
||||
Ok(pool_meta)
|
||||
}
|
||||
|
||||
pub(crate) async fn load_runtime_pool_meta_under_worker_fence(
|
||||
&self,
|
||||
write_state: &mut PoolMetaWriteState,
|
||||
worker_fence: &PoolRebalanceWorkerFence,
|
||||
operation: &str,
|
||||
) -> Result<PoolMeta> {
|
||||
worker_fence.ensure_held()?;
|
||||
let pool_meta = self.load_runtime_pool_meta_observing(write_state, operation).await?;
|
||||
worker_fence.ensure_held()?;
|
||||
Ok(pool_meta)
|
||||
}
|
||||
|
||||
pub(crate) async fn load_runtime_pool_meta_under_activation_fence(
|
||||
&self,
|
||||
write_state: &mut PoolMetaWriteState,
|
||||
|
||||
@@ -15,7 +15,9 @@
|
||||
use crate::cluster::rpc::{
|
||||
ScannerBucketListing, TonicInterceptor, gen_tonic_signature_interceptor, node_service_time_out_client,
|
||||
};
|
||||
use crate::data_usage::{DATA_USAGE_CACHE_NAME, DATA_USAGE_ROOT, load_data_usage_from_backend_cached};
|
||||
use crate::data_usage::{
|
||||
DATA_USAGE_CACHE_NAME, DATA_USAGE_ROOT, load_admin_data_usage_from_backend_cached as load_server_info_usage_snapshot,
|
||||
};
|
||||
use crate::error::{Error, Result};
|
||||
use crate::{
|
||||
disk::endpoint::{Endpoint, EndpointType},
|
||||
@@ -42,7 +44,11 @@ use std::{
|
||||
use time::OffsetDateTime;
|
||||
use tokio::time::timeout;
|
||||
use tonic::Request;
|
||||
use tracing::warn;
|
||||
use tracing::{error, trace, warn};
|
||||
|
||||
const EVENT_SERVER_PING: &str = "server_ping";
|
||||
const LOG_COMPONENT_ECSTORE: &str = "ecstore";
|
||||
const LOG_SUBSYSTEM_DIAGNOSTICS: &str = "diagnostics";
|
||||
|
||||
use shadow_rs::shadow;
|
||||
|
||||
@@ -157,11 +163,22 @@ async fn is_server_resolvable(endpoint: &Endpoint) -> Result<()> {
|
||||
|
||||
let response: PingResponse = client.ping(request).await?.into_inner();
|
||||
|
||||
let ping_response_body = flatbuffers::root::<PingBody>(&response.body);
|
||||
if let Err(e) = ping_response_body {
|
||||
eprintln!("{e}");
|
||||
if flatbuffers::root::<PingBody>(&response.body).is_err() {
|
||||
error!(
|
||||
event = EVENT_SERVER_PING,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_DIAGNOSTICS,
|
||||
result = "invalid_response",
|
||||
"Server ping response decoding failed"
|
||||
);
|
||||
} else {
|
||||
println!("ping_resp:body(flatbuffer): {ping_response_body:?}");
|
||||
trace!(
|
||||
event = EVENT_SERVER_PING,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_DIAGNOSTICS,
|
||||
result = "success",
|
||||
"Server ping completed"
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -294,7 +311,7 @@ pub async fn get_server_info(get_pools: bool) -> InfoMessage {
|
||||
if let Some(store) = runtime_sources::object_store_handle() {
|
||||
mode = ITEM_ONLINE;
|
||||
apply_data_usage_result(
|
||||
load_data_usage_from_backend_cached(store.clone()).await,
|
||||
load_server_info_usage_snapshot(store.clone()).await,
|
||||
&mut buckets,
|
||||
&mut objects,
|
||||
&mut versions,
|
||||
@@ -967,6 +984,93 @@ mod tests {
|
||||
assert_eq!(usage.size, 6);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn server_info_reads_complete_observation_without_promoting_authority() {
|
||||
use crate::bucket::metadata_sys::test_support::isolated_store_over_temp_disks;
|
||||
use crate::config::com::{read_config, save_config};
|
||||
use crate::data_usage::{
|
||||
DATA_USAGE_OBJ_NAME_PATH, DATA_USAGE_OBSERVED_OBJ_NAME_PATH, invalidate_data_usage_snapshot_cache,
|
||||
load_data_usage_from_backend_cached,
|
||||
};
|
||||
use rustfs_data_usage::{BucketUsageInfo, DataUsageInfo};
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
let (_dirs, store) = isolated_store_over_temp_disks().await;
|
||||
let primary = DataUsageInfo {
|
||||
last_update: Some(SystemTime::UNIX_EPOCH + Duration::from_secs(1)),
|
||||
scanner_epoch: Some(1),
|
||||
usage_snapshot_bootstrap_pending: true,
|
||||
usage_snapshot_converged: Some(false),
|
||||
..Default::default()
|
||||
};
|
||||
let observed = DataUsageInfo {
|
||||
last_update: Some(SystemTime::UNIX_EPOCH + Duration::from_secs(2)),
|
||||
scanner_epoch: Some(1),
|
||||
scanner_cycle: Some(1),
|
||||
buckets_count: 1,
|
||||
objects_total_count: 3,
|
||||
versions_total_count: 4,
|
||||
delete_markers_total_count: 1,
|
||||
objects_total_size: 6,
|
||||
buckets_usage: [(
|
||||
"bucket".to_string(),
|
||||
BucketUsageInfo {
|
||||
objects_count: 3,
|
||||
versions_count: 4,
|
||||
delete_markers_count: 1,
|
||||
size: 6,
|
||||
..Default::default()
|
||||
},
|
||||
)]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
usage_snapshot_complete: true,
|
||||
usage_snapshot_converged: Some(false),
|
||||
usage_snapshot_authoritative_baseline: Some(primary.snapshot_identity()),
|
||||
..Default::default()
|
||||
};
|
||||
let primary_bytes = serde_json::to_vec(&primary).expect("bootstrap should encode");
|
||||
save_config(store.clone(), DATA_USAGE_OBJ_NAME_PATH.as_str(), primary_bytes.clone())
|
||||
.await
|
||||
.expect("bootstrap should persist");
|
||||
save_config(
|
||||
store.clone(),
|
||||
DATA_USAGE_OBSERVED_OBJ_NAME_PATH.as_str(),
|
||||
serde_json::to_vec(&observed).expect("observation should encode"),
|
||||
)
|
||||
.await
|
||||
.expect("observation should persist");
|
||||
invalidate_data_usage_snapshot_cache().await;
|
||||
|
||||
let displayed = super::load_server_info_usage_snapshot(store.clone())
|
||||
.await
|
||||
.expect("server info should load the complete observation");
|
||||
assert_eq!(displayed.usage_snapshot_converged, Some(false));
|
||||
let mut buckets = rustfs_madmin::Buckets::default();
|
||||
let mut objects = rustfs_madmin::Objects::default();
|
||||
let mut versions = rustfs_madmin::Versions::default();
|
||||
let mut delete_markers = rustfs_madmin::DeleteMarkers::default();
|
||||
let mut usage = rustfs_madmin::Usage::default();
|
||||
apply_data_usage_result(Ok(displayed), &mut buckets, &mut objects, &mut versions, &mut delete_markers, &mut usage);
|
||||
assert!(objects.error.is_none());
|
||||
assert_eq!(
|
||||
(buckets.count, objects.count, versions.count, delete_markers.count, usage.size),
|
||||
(1, 3, 4, 1, 6)
|
||||
);
|
||||
let quota = load_data_usage_from_backend_cached(store.clone())
|
||||
.await
|
||||
.expect("quota should retain its bootstrap state");
|
||||
assert!(!quota.is_complete_bucket_usage_snapshot());
|
||||
assert_eq!(
|
||||
read_config(store, DATA_USAGE_OBJ_NAME_PATH.as_str())
|
||||
.await
|
||||
.expect("primary should remain readable"),
|
||||
primary_bytes
|
||||
);
|
||||
invalidate_data_usage_snapshot_cache().await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn incomplete_data_usage_is_unavailable_in_server_info() {
|
||||
let mut buckets = rustfs_madmin::Buckets::default();
|
||||
|
||||
@@ -182,6 +182,7 @@ pub(crate) const GET_METADATA_CACHE_REASON_DELETE_MARKER: &str = "delete_marker"
|
||||
pub(crate) const GET_METADATA_CACHE_REASON_DIST_ERASURE: &str = "dist_erasure";
|
||||
pub(crate) const GET_METADATA_CACHE_REASON_INCL_FREE_VERSIONS: &str = "incl_free_versions";
|
||||
pub(crate) const GET_METADATA_CACHE_REASON_INSUFFICIENT_CACHED_QUORUM: &str = "insufficient_cached_quorum";
|
||||
pub(crate) const GET_METADATA_CACHE_REASON_BODY_LAYOUT_REQUIRED: &str = "body_layout_required";
|
||||
pub(crate) const GET_METADATA_CACHE_REASON_META_BUCKET: &str = "meta_bucket";
|
||||
pub(crate) const GET_METADATA_CACHE_REASON_NO_LOCK: &str = "no_lock";
|
||||
pub(crate) const GET_METADATA_CACHE_REASON_NOT_FOUND_OR_EXPIRED: &str = "not_found_or_expired";
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,7 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use crate::disk::cleanup_runtime;
|
||||
use crate::disk::io_admission::{
|
||||
AdmissionReader, BoxedChunkReader, DiskIoAdmission, DiskIoClass, DiskIoContext, current_disk_io_context,
|
||||
with_disk_io_context, with_disk_io_permit,
|
||||
@@ -466,12 +467,11 @@ impl LocalDiskWrapper {
|
||||
self.track_disk_health_mutation(
|
||||
"delete_version",
|
||||
DiskMetricMutation::Delete,
|
||||
|| async {
|
||||
|| {
|
||||
Box::pin(
|
||||
self.disk
|
||||
.delete_version_with_namespace_owner(volume, path, fi, force_del_marker, opts, namespace_owner),
|
||||
)
|
||||
.await
|
||||
},
|
||||
get_max_timeout_duration(),
|
||||
)
|
||||
@@ -485,10 +485,10 @@ impl LocalDiskWrapper {
|
||||
opts: DeleteOptions,
|
||||
namespace_owner: Option<Arc<dyn Send + Sync>>,
|
||||
) -> Result<()> {
|
||||
self.track_disk_health_mutation(
|
||||
self.track_cleanup_mutation(
|
||||
"delete",
|
||||
DiskMetricMutation::Delete,
|
||||
|| async { Box::pin(self.disk.delete_with_namespace_owner(volume, path, opts, namespace_owner)).await },
|
||||
|| async { Box::pin(self.cleanup_delete(volume, path, opts, namespace_owner)).await },
|
||||
get_max_timeout_duration(),
|
||||
)
|
||||
.await
|
||||
@@ -1476,6 +1476,28 @@ impl LocalDiskWrapper {
|
||||
)
|
||||
}
|
||||
|
||||
async fn cleanup_delete(
|
||||
&self,
|
||||
volume: &str,
|
||||
path: &str,
|
||||
options: DeleteOptions,
|
||||
owner: Option<Arc<dyn Send + Sync>>,
|
||||
) -> Result<()> {
|
||||
if !cleanup_runtime::enabled()? {
|
||||
return self.disk.delete_with_namespace_owner(volume, path, options, owner).await;
|
||||
}
|
||||
let disk = self.disk.clone();
|
||||
let volume = volume.to_owned();
|
||||
let path = path.to_owned();
|
||||
let guarded = owner.is_some();
|
||||
let operation = async move { disk.delete_with_namespace_owner(&volume, &path, options, owner).await };
|
||||
if guarded {
|
||||
cleanup_runtime::guarded_disk_operation(operation).await
|
||||
} else {
|
||||
cleanup_runtime::disk_operation(operation).await
|
||||
}
|
||||
}
|
||||
|
||||
/// Run a delete under an owned coordinator task when a publication guard
|
||||
/// is present. This keeps the guard alive if the RPC waiter is cancelled
|
||||
/// while the local namespace mutation is still in progress.
|
||||
@@ -1494,12 +1516,13 @@ impl LocalDiskWrapper {
|
||||
} else {
|
||||
get_max_timeout_duration()
|
||||
};
|
||||
let cleanup_owner = external_guard.clone();
|
||||
run_owned_mutation(external_guard, move || async move {
|
||||
operation
|
||||
.track_disk_health_mutation(
|
||||
.track_cleanup_mutation(
|
||||
"delete",
|
||||
DiskMetricMutation::Delete,
|
||||
|| async { operation.disk.delete(&volume, &path, options).await },
|
||||
|| async { operation.cleanup_delete(&volume, &path, options, cleanup_owner).await },
|
||||
timeout_duration,
|
||||
)
|
||||
.await
|
||||
@@ -1816,6 +1839,8 @@ impl LocalDiskWrapper {
|
||||
undo_delete: false,
|
||||
old_data_dir: None,
|
||||
expected_delete_marker: None,
|
||||
recoverable_delete: false,
|
||||
delete_recovery_bucket_incarnation: None,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
@@ -2001,10 +2026,86 @@ impl LocalDiskWrapper {
|
||||
}
|
||||
|
||||
/// Get the current disk ID
|
||||
pub(crate) async fn delete_versions_with_namespace_owner(
|
||||
&self,
|
||||
volume: &str,
|
||||
versions: Vec<FileInfoVersions>,
|
||||
opts: DeleteOptions,
|
||||
namespace_owner: Option<Arc<dyn Send + Sync>>,
|
||||
) -> Vec<Option<Error>> {
|
||||
let version_count = versions.len();
|
||||
self.metrics.record_operation_call("delete_versions");
|
||||
// Check if disk is faulty before proceeding
|
||||
if self.health.is_faulty() {
|
||||
self.metrics.record_availability_error();
|
||||
return vec![Some(DiskError::FaultyDisk); versions.len()];
|
||||
}
|
||||
|
||||
// Check if disk is stale
|
||||
if let Err(e) = self.check_disk_stale().await {
|
||||
self.metrics.record_availability_error();
|
||||
return vec![Some(e); versions.len()];
|
||||
}
|
||||
|
||||
let _io_permit = match self.acquire_disk_io_permit().await {
|
||||
Ok(permit) => permit,
|
||||
Err(error) => return vec![Some(error); version_count],
|
||||
};
|
||||
|
||||
// Record operation start
|
||||
self.health.last_started.store(current_unix_nanos(), Ordering::Relaxed);
|
||||
let waiting_guard = self.health.waiting_guard();
|
||||
let metric_waiting_guard = self.metrics.waiting_guard();
|
||||
let started = Instant::now();
|
||||
|
||||
// Execute the operation
|
||||
let result = self
|
||||
.disk
|
||||
.delete_versions_with_namespace_owner(volume, versions, opts, namespace_owner)
|
||||
.await;
|
||||
self.metrics.record_operation_latency("delete_versions", started.elapsed());
|
||||
self.record_batch_delete_error_metrics(&result);
|
||||
|
||||
drop(waiting_guard);
|
||||
drop(metric_waiting_guard);
|
||||
let has_err = result.iter().any(|e| e.is_some());
|
||||
if !has_err {
|
||||
// Log success and decrement waiting counter
|
||||
self.health.record_operation_success(&self.endpoint(), "operation_success");
|
||||
self.metrics.record_mutation_success(DiskMetricMutation::Delete);
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
pub async fn get_current_disk_id(&self) -> Option<Uuid> {
|
||||
*self.disk_id.read().await
|
||||
}
|
||||
|
||||
pub(crate) async fn settle_delete_transaction_with_namespace_owner(
|
||||
&self,
|
||||
volume: &str,
|
||||
object: &str,
|
||||
transaction: uuid::Uuid,
|
||||
expected_fingerprint: crate::disk::DeleteRecoveryDigest,
|
||||
action: crate::disk::DeleteRecoveryAction,
|
||||
namespace_owner: Option<Arc<dyn Send + Sync>>,
|
||||
) -> Result<crate::disk::DeleteRecoveryInspection> {
|
||||
self.track_disk_health_mutation(
|
||||
"settle_delete_transaction",
|
||||
DiskMetricMutation::Delete,
|
||||
|| {
|
||||
Box::pin(async {
|
||||
self.disk
|
||||
.delete_recovery_settle(volume, object, transaction, expected_fingerprint, action, namespace_owner)
|
||||
.await
|
||||
})
|
||||
},
|
||||
crate::disk::get_delete_recovery_timeout()?,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Track disk health for an operation.
|
||||
/// This method should wrap disk operations to ensure health checking.
|
||||
pub async fn track_disk_health<T, F, Fut>(&self, operation: F, timeout_duration: Duration) -> Result<T>
|
||||
@@ -2015,6 +2116,31 @@ impl LocalDiskWrapper {
|
||||
self.track_disk_health_with_op("unknown", operation, timeout_duration).await
|
||||
}
|
||||
|
||||
/// Cleanup latency includes waiting behind a node-wide execution budget,
|
||||
/// so a waiter deadline alone is not evidence that this disk is faulty.
|
||||
/// The active disk health probe retains its original timeout policy.
|
||||
async fn track_cleanup_mutation<T, F, Fut>(
|
||||
&self,
|
||||
op: &'static str,
|
||||
mutation: DiskMetricMutation,
|
||||
operation: F,
|
||||
timeout_duration: Duration,
|
||||
) -> Result<T>
|
||||
where
|
||||
F: FnOnce() -> Fut,
|
||||
Fut: std::future::Future<Output = Result<T>>,
|
||||
{
|
||||
// Runtime initialization must not prevent control-plane release of
|
||||
// reader tokens. Disk I/O entries still propagate configuration errors.
|
||||
let action = if cleanup_runtime::enabled().unwrap_or(false) {
|
||||
TimeoutHealthAction::IgnoreFailure
|
||||
} else {
|
||||
TimeoutHealthAction::MarkFailure
|
||||
};
|
||||
self.track_disk_health_with_op_timeout_action_and_mutation(op, operation, timeout_duration, action, mutation)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn track_disk_health_mutation<T, F, Fut>(
|
||||
&self,
|
||||
op: &'static str,
|
||||
@@ -2195,6 +2321,87 @@ impl LocalDiskWrapper {
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl DiskAPI for LocalDiskWrapper {
|
||||
async fn delete_version_recoverable(
|
||||
&self,
|
||||
volume: &str,
|
||||
object: &str,
|
||||
file_info: FileInfo,
|
||||
force_del_marker: bool,
|
||||
mut opts: DeleteOptions,
|
||||
) -> Result<()> {
|
||||
crate::disk::validate_delete_recovery_options(&opts)?;
|
||||
if object.starts_with('/') {
|
||||
return Err(DiskError::InvalidPath);
|
||||
}
|
||||
opts.recoverable_delete = true;
|
||||
self.delete_version(volume, object, file_info, force_del_marker, opts).await
|
||||
}
|
||||
async fn delete_versions_recoverable(
|
||||
&self,
|
||||
volume: &str,
|
||||
versions: Vec<FileInfoVersions>,
|
||||
mut opts: DeleteOptions,
|
||||
) -> Vec<Option<DiskError>> {
|
||||
if let Err(err) = crate::disk::validate_delete_recovery_options(&opts) {
|
||||
return vec![Some(err); versions.len()];
|
||||
}
|
||||
if versions.iter().any(|version| version.name.starts_with('/')) {
|
||||
return vec![Some(DiskError::InvalidPath); versions.len()];
|
||||
}
|
||||
opts.recoverable_delete = true;
|
||||
self.delete_versions(volume, versions, opts).await
|
||||
}
|
||||
async fn discover_delete_transactions(
|
||||
&self,
|
||||
volume: &str,
|
||||
object: Option<&str>,
|
||||
cursor: Option<crate::disk::DeleteRecoveryCursor>,
|
||||
limit: u32,
|
||||
) -> Result<crate::disk::DeleteRecoveryPage> {
|
||||
self.track_disk_health_with_op(
|
||||
"discover_delete_transactions",
|
||||
|| async {
|
||||
self.disk
|
||||
.discover_delete_transactions(volume, object, cursor.clone(), limit)
|
||||
.await
|
||||
},
|
||||
get_max_timeout_duration(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
async fn inspect_delete_transaction(
|
||||
&self,
|
||||
volume: &str,
|
||||
object: &str,
|
||||
transaction: uuid::Uuid,
|
||||
) -> Result<crate::disk::DeleteRecoveryInspection> {
|
||||
self.track_disk_health_with_op(
|
||||
"inspect_delete_transaction",
|
||||
|| async { self.disk.inspect_delete_transaction(volume, object, transaction).await },
|
||||
get_max_timeout_duration(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
async fn settle_delete_transaction(
|
||||
&self,
|
||||
volume: &str,
|
||||
object: &str,
|
||||
transaction: uuid::Uuid,
|
||||
expected_fingerprint: crate::disk::DeleteRecoveryDigest,
|
||||
action: crate::disk::DeleteRecoveryAction,
|
||||
) -> Result<crate::disk::DeleteRecoveryInspection> {
|
||||
self.track_disk_health_mutation(
|
||||
"settle_delete_transaction",
|
||||
DiskMetricMutation::Delete,
|
||||
|| async {
|
||||
self.disk
|
||||
.settle_delete_transaction(volume, object, transaction, expected_fingerprint, action)
|
||||
.await
|
||||
},
|
||||
crate::disk::get_delete_recovery_timeout()?,
|
||||
)
|
||||
.await
|
||||
}
|
||||
fn has_replacement_mount_lease(&self) -> bool {
|
||||
self.disk.has_replacement_mount_lease()
|
||||
}
|
||||
@@ -2408,46 +2615,7 @@ impl DiskAPI for LocalDiskWrapper {
|
||||
}
|
||||
|
||||
async fn delete_versions(&self, volume: &str, versions: Vec<FileInfoVersions>, opts: DeleteOptions) -> Vec<Option<Error>> {
|
||||
let version_count = versions.len();
|
||||
self.metrics.record_operation_call("delete_versions");
|
||||
// Check if disk is faulty before proceeding
|
||||
if self.health.is_faulty() {
|
||||
self.metrics.record_availability_error();
|
||||
return vec![Some(DiskError::FaultyDisk); versions.len()];
|
||||
}
|
||||
|
||||
// Check if disk is stale
|
||||
if let Err(e) = self.check_disk_stale().await {
|
||||
self.metrics.record_availability_error();
|
||||
return vec![Some(e); versions.len()];
|
||||
}
|
||||
|
||||
let _io_permit = match self.acquire_disk_io_permit().await {
|
||||
Ok(permit) => permit,
|
||||
Err(error) => return vec![Some(error); version_count],
|
||||
};
|
||||
|
||||
// Record operation start
|
||||
self.health.last_started.store(current_unix_nanos(), Ordering::Relaxed);
|
||||
let waiting_guard = self.health.waiting_guard();
|
||||
let metric_waiting_guard = self.metrics.waiting_guard();
|
||||
let started = Instant::now();
|
||||
|
||||
// Execute the operation
|
||||
let result = self.disk.delete_versions(volume, versions, opts).await;
|
||||
self.metrics.record_operation_latency("delete_versions", started.elapsed());
|
||||
self.record_batch_delete_error_metrics(&result);
|
||||
|
||||
drop(waiting_guard);
|
||||
drop(metric_waiting_guard);
|
||||
let has_err = result.iter().any(|e| e.is_some());
|
||||
if !has_err {
|
||||
// Log success and decrement waiting counter
|
||||
self.health.record_operation_success(&self.endpoint(), "operation_success");
|
||||
self.metrics.record_mutation_success(DiskMetricMutation::Delete);
|
||||
}
|
||||
|
||||
result
|
||||
self.delete_versions_with_namespace_owner(volume, versions, opts, None).await
|
||||
}
|
||||
|
||||
async fn delete_paths(&self, volume: &str, paths: &[String]) -> Result<()> {
|
||||
@@ -2470,9 +2638,37 @@ impl DiskAPI for LocalDiskWrapper {
|
||||
}
|
||||
|
||||
async fn release_snapshot_lease(&self, volume: &str, path: &str, token: SnapshotLeaseToken) -> Result<()> {
|
||||
self.track_disk_health_with_op(
|
||||
// Quota fence release is control-plane work and may wait for in-flight
|
||||
// mutations. Keep its original waiter lifecycle instead of creating
|
||||
// detached cleanup tasks for repeated fence-release RPCs.
|
||||
if volume == super::RUSTFS_META_BUCKET && super::is_quota_mutation_fence_path(path) {
|
||||
return self
|
||||
.track_disk_health_with_op(
|
||||
"release_snapshot_lease",
|
||||
|| async { self.disk.release_snapshot_lease(volume, path, token).await },
|
||||
get_max_timeout_duration(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
self.track_cleanup_mutation(
|
||||
"release_snapshot_lease",
|
||||
|| async { self.disk.release_snapshot_lease(volume, path, token).await },
|
||||
DiskMetricMutation::None,
|
||||
|| async {
|
||||
if self.disk.release_snapshot_lease_without_cleanup(volume, path, token).await {
|
||||
return Ok(());
|
||||
}
|
||||
if !cleanup_runtime::enabled().unwrap_or(false) {
|
||||
// The raw release removes the token before trying the
|
||||
// reclaim budget. Initialization failure therefore keeps
|
||||
// the cleanup intent and data, without leaking a reader
|
||||
// or falling back to unbudgeted filesystem deletion.
|
||||
return self.disk.release_snapshot_lease(volume, path, token).await;
|
||||
}
|
||||
let disk = self.disk.clone();
|
||||
let volume = volume.to_owned();
|
||||
let path = path.to_owned();
|
||||
cleanup_runtime::release_lease(async move { disk.release_snapshot_lease(&volume, &path, token).await }).await
|
||||
},
|
||||
get_max_timeout_duration(),
|
||||
)
|
||||
.await
|
||||
@@ -2497,10 +2693,22 @@ impl DiskAPI for LocalDiskWrapper {
|
||||
} else {
|
||||
get_max_timeout_duration()
|
||||
};
|
||||
self.track_disk_health_mutation(
|
||||
self.track_cleanup_mutation(
|
||||
"delete_data_dir",
|
||||
DiskMetricMutation::Delete,
|
||||
|| async { self.disk.delete_data_dir(volume, path, opts).await },
|
||||
|| async {
|
||||
if !cleanup_runtime::enabled()? {
|
||||
return self.disk.delete_data_dir(volume, path, opts).await;
|
||||
}
|
||||
let disk = self.disk.clone();
|
||||
let volume = volume.to_owned();
|
||||
let path = path.to_owned();
|
||||
cleanup_runtime::disk_operation(async move {
|
||||
let _scope = scope;
|
||||
disk.delete_data_dir(&volume, &path, opts).await
|
||||
})
|
||||
.await
|
||||
},
|
||||
timeout,
|
||||
)
|
||||
.await
|
||||
@@ -2747,10 +2955,10 @@ impl DiskAPI for LocalDiskWrapper {
|
||||
}
|
||||
|
||||
async fn delete(&self, volume: &str, path: &str, opt: DeleteOptions) -> Result<()> {
|
||||
self.track_disk_health_mutation(
|
||||
self.track_cleanup_mutation(
|
||||
"delete",
|
||||
DiskMetricMutation::Delete,
|
||||
|| async { self.disk.delete(volume, path, opt).await },
|
||||
|| async { self.cleanup_delete(volume, path, opt, None).await },
|
||||
get_max_timeout_duration(),
|
||||
)
|
||||
.await
|
||||
@@ -2783,11 +2991,25 @@ impl DiskAPI for LocalDiskWrapper {
|
||||
}
|
||||
|
||||
async fn write_all(&self, volume: &str, path: &str, data: Bytes) -> Result<()> {
|
||||
self.track_disk_health_mutation(
|
||||
let cleanup = path.rsplit('/').next() == Some(cleanup_runtime::OLD_DATA_CLEANUP_RECEIPT_FILE);
|
||||
self.track_disk_health_with_op_timeout_action_and_mutation(
|
||||
"write_all",
|
||||
DiskMetricMutation::Write,
|
||||
|| async { self.disk.write_all(volume, path, data).await },
|
||||
|| async {
|
||||
if !cleanup || !cleanup_runtime::enabled()? {
|
||||
return self.disk.write_all(volume, path, data).await;
|
||||
}
|
||||
let disk = self.disk.clone();
|
||||
let volume = volume.to_owned();
|
||||
let path = path.to_owned();
|
||||
cleanup_runtime::disk_operation(async move { disk.write_all(&volume, &path, data).await }).await
|
||||
},
|
||||
get_max_timeout_duration(),
|
||||
if cleanup && cleanup_runtime::enabled()? {
|
||||
TimeoutHealthAction::IgnoreFailure
|
||||
} else {
|
||||
TimeoutHealthAction::MarkFailure
|
||||
},
|
||||
DiskMetricMutation::Write,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -3180,6 +3402,256 @@ mod tests {
|
||||
let _ = walk.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_cleanup_config_does_not_leak_snapshot_readers() {
|
||||
const CHILD: &str = "RUSTFS_CLEANUP_INVALID_LEASE_TEST_CHILD";
|
||||
if std::env::var_os(CHILD).is_none() {
|
||||
let output = tokio::task::spawn_blocking(|| {
|
||||
std::process::Command::new(std::env::current_exe().expect("test executable"))
|
||||
.args([
|
||||
"--exact",
|
||||
"disk::disk_store::tests::invalid_cleanup_config_does_not_leak_snapshot_readers",
|
||||
"--nocapture",
|
||||
])
|
||||
.env(CHILD, "1")
|
||||
.env("RUSTFS_CLEANUP_ISOLATE_ENABLE", "true")
|
||||
.env("RUSTFS_CLEANUP_ASYNC_THREADS", "0")
|
||||
.output()
|
||||
.expect("run with invalid cleanup configuration")
|
||||
})
|
||||
.await
|
||||
.expect("child process task");
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
assert!(String::from_utf8_lossy(&output.stdout).contains("1 passed"));
|
||||
return;
|
||||
}
|
||||
let dir = tempfile::tempdir().expect("temporary disk");
|
||||
let endpoint = Endpoint::try_from(dir.path().to_str().expect("UTF-8 path")).expect("endpoint");
|
||||
let disk = Arc::new(LocalDisk::new(&endpoint, false).await.expect("local disk"));
|
||||
let wrapper = LocalDiskWrapper::new(disk.clone(), false);
|
||||
let volume = "invalid-cleanup-config";
|
||||
let path = "object/data";
|
||||
wrapper.make_volume(volume).await.expect("bucket");
|
||||
wrapper
|
||||
.write_all(volume, "object/data/part.1", Bytes::from_static(b"live shard"))
|
||||
.await
|
||||
.expect("write shard");
|
||||
let token = wrapper.acquire_snapshot_lease(volume, path).await.expect("GET lease");
|
||||
wrapper
|
||||
.release_snapshot_lease(volume, path, token)
|
||||
.await
|
||||
.expect("control-only release must not depend on cleanup initialization");
|
||||
assert!(matches!(
|
||||
wrapper.renew_snapshot_lease(volume, path, token).await,
|
||||
Err(DiskError::FileNotFound)
|
||||
));
|
||||
|
||||
let token = wrapper
|
||||
.acquire_snapshot_lease(volume, path)
|
||||
.await
|
||||
.expect("reader of old data");
|
||||
// Register reclaim intent directly; this only updates the lease
|
||||
// registry while a reader is present and performs no filesystem I/O.
|
||||
assert_eq!(
|
||||
disk.delete_data_dir(
|
||||
volume,
|
||||
path,
|
||||
DeleteOptions {
|
||||
recursive: true,
|
||||
..Default::default()
|
||||
}
|
||||
)
|
||||
.await
|
||||
.expect("defer reclaim"),
|
||||
DataDirDeleteStatus::Deferred
|
||||
);
|
||||
assert!(
|
||||
wrapper.release_snapshot_lease(volume, path, token).await.is_err(),
|
||||
"invalid configuration must still reject reclamation"
|
||||
);
|
||||
assert!(
|
||||
matches!(wrapper.renew_snapshot_lease(volume, path, token).await, Err(DiskError::FileNotFound)),
|
||||
"failed reclamation must not strand a reader token"
|
||||
);
|
||||
assert_eq!(
|
||||
wrapper.read_all(volume, "object/data/part.1").await.expect("retained data"),
|
||||
Bytes::from_static(b"live shard"),
|
||||
"configuration failure must not fall back to unbudgeted deletion"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rpc_cleanup_entries_share_disk_budget() {
|
||||
const CHILD: &str = "RUSTFS_CLEANUP_RPC_TEST_CHILD";
|
||||
if std::env::var_os(CHILD).is_none() {
|
||||
let output = tokio::task::spawn_blocking(|| {
|
||||
std::process::Command::new(std::env::current_exe().unwrap())
|
||||
.args([
|
||||
"--exact",
|
||||
"disk::disk_store::tests::rpc_cleanup_entries_share_disk_budget",
|
||||
"--nocapture",
|
||||
])
|
||||
.env(CHILD, "1")
|
||||
.env("RUSTFS_CLEANUP_ISOLATE_ENABLE", "true")
|
||||
.env("RUSTFS_CLEANUP_ASYNC_THREADS", "1")
|
||||
.env("RUSTFS_CLEANUP_DISK_MAX_PENDING", "1")
|
||||
.env("RUSTFS_CLEANUP_DISK_WORKERS", "1")
|
||||
.env("RUSTFS_CLEANUP_GC_WORKERS", "1")
|
||||
.env("RUSTFS_CLEANUP_CPUS", "none")
|
||||
.env_remove("RUSTFS_PUT_RENAME_TAIL_CLEANUP_COUNTERFACTUAL_SKIP")
|
||||
.env_remove("RUSTFS_PUT_RENAME_TAIL_CLEANUP_DEFER_HOLD_WORKER")
|
||||
.env_remove("RUSTFS_PUT_RENAME_TAIL_CLEANUP_ZERO_TARGET_TMP_DELETE_SKIP")
|
||||
.output()
|
||||
.unwrap()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
assert!(String::from_utf8_lossy(&output.stdout).contains("1 passed"));
|
||||
return;
|
||||
}
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let endpoint = Endpoint::try_from(dir.path().to_str().unwrap()).unwrap();
|
||||
let disk = Arc::new(LocalDisk::new(&endpoint, false).await.unwrap());
|
||||
let wrapper = LocalDiskWrapper::new(disk, false);
|
||||
let volume = "cleanup-receiver";
|
||||
let path = "object/old-data";
|
||||
let body_path = format!("{path}/part.1");
|
||||
let receipt_path = format!("{path}/{}", cleanup_runtime::OLD_DATA_CLEANUP_RECEIPT_FILE);
|
||||
wrapper.make_volume(volume).await.unwrap();
|
||||
wrapper
|
||||
.write_all(volume, &body_path, Bytes::from_static(b"old body"))
|
||||
.await
|
||||
.unwrap();
|
||||
let read_lease = wrapper
|
||||
.acquire_snapshot_lease(volume, path)
|
||||
.await
|
||||
.expect("ordinary GET lease");
|
||||
let (entered, entry) = tokio::sync::oneshot::channel();
|
||||
let (release, released) = std::sync::mpsc::channel();
|
||||
let occupied = tokio::spawn(cleanup_runtime::disk_operation(async move {
|
||||
entered.send(()).unwrap();
|
||||
// Deliberately stall the only cleanup scheduler to prove that
|
||||
// ordinary GET completion does not dispatch to it.
|
||||
released
|
||||
.recv_timeout(Duration::from_secs(30))
|
||||
.expect("release stalled scheduler");
|
||||
Ok(())
|
||||
}));
|
||||
tokio::time::timeout(Duration::from_secs(20), entry).await.unwrap().unwrap();
|
||||
tokio::time::timeout(Duration::from_secs(5), wrapper.release_snapshot_lease(volume, path, read_lease))
|
||||
.await
|
||||
.expect("ordinary GET release must progress while the cleanup scheduler is stalled")
|
||||
.expect("release without reclamation");
|
||||
let fence_path = super::super::quota_mutation_fence_path(volume, "object");
|
||||
let fence = wrapper
|
||||
.acquire_snapshot_lease(super::super::RUSTFS_META_BUCKET, &fence_path)
|
||||
.await
|
||||
.unwrap();
|
||||
wrapper
|
||||
.release_snapshot_lease(super::super::RUSTFS_META_BUCKET, &fence_path, fence)
|
||||
.await
|
||||
.unwrap();
|
||||
let timeout = wrapper
|
||||
.track_cleanup_mutation(
|
||||
"delete",
|
||||
DiskMetricMutation::Delete,
|
||||
std::future::pending::<Result<()>>,
|
||||
Duration::from_millis(10),
|
||||
)
|
||||
.await;
|
||||
assert!(matches!(timeout, Err(DiskError::Timeout)));
|
||||
assert!(!wrapper.health.is_faulty(), "cleanup queue delay must not mark a healthy disk faulty");
|
||||
let options = DeleteOptions {
|
||||
recursive: true,
|
||||
..Default::default()
|
||||
};
|
||||
let drops = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
// This is the same entry point used after RPC authentication and
|
||||
// scanner publication-lease validation by handle_delete.
|
||||
let result = wrapper
|
||||
.delete_with_publication_guard(volume, path, options.clone(), Some(Arc::new(DropProbe(drops.clone()))))
|
||||
.await;
|
||||
assert!(matches!(result, Err(DiskError::Io(err)) if err.kind() == io::ErrorKind::WouldBlock));
|
||||
assert_eq!(drops.load(Ordering::SeqCst), 1);
|
||||
assert!(matches!(wrapper.delete_data_dir(volume, path, options.clone()).await,
|
||||
Err(DiskError::Io(err)) if err.kind() == io::ErrorKind::WouldBlock));
|
||||
assert!(matches!(wrapper.write_all(volume, &receipt_path, Bytes::from_static(b"receipt")).await,
|
||||
Err(DiskError::Io(err)) if err.kind() == io::ErrorKind::WouldBlock));
|
||||
assert_eq!(wrapper.read_all(volume, &body_path).await.unwrap(), Bytes::from_static(b"old body"));
|
||||
release.send(()).unwrap();
|
||||
occupied.await.unwrap().unwrap();
|
||||
wrapper
|
||||
.write_all(volume, &receipt_path, Bytes::from_static(b"receipt"))
|
||||
.await
|
||||
.unwrap();
|
||||
wrapper
|
||||
.delete_with_publication_guard(volume, path, options, None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(wrapper.read_all(volume, &body_path).await, Err(DiskError::FileNotFound)));
|
||||
// Snapshot readers defer old-data cleanup. Saturation must release
|
||||
// their token while retaining reclaim intent, rather than leaking a
|
||||
// live token or performing an unbudgeted delete on the RPC runtime.
|
||||
wrapper
|
||||
.write_all(volume, &body_path, Bytes::from_static(b"leased body"))
|
||||
.await
|
||||
.unwrap();
|
||||
let lease = wrapper.acquire_snapshot_lease(volume, path).await.unwrap();
|
||||
assert_eq!(
|
||||
wrapper
|
||||
.delete_data_dir(
|
||||
volume,
|
||||
path,
|
||||
DeleteOptions {
|
||||
recursive: true,
|
||||
..Default::default()
|
||||
}
|
||||
)
|
||||
.await
|
||||
.unwrap(),
|
||||
DataDirDeleteStatus::Deferred
|
||||
);
|
||||
let (entered, entry) = tokio::sync::oneshot::channel();
|
||||
let (release, released) = tokio::sync::oneshot::channel();
|
||||
let occupied = tokio::spawn(cleanup_runtime::disk_operation(async move {
|
||||
entered.send(()).unwrap();
|
||||
released.await.unwrap();
|
||||
Ok(())
|
||||
}));
|
||||
tokio::time::timeout(Duration::from_secs(20), entry).await.unwrap().unwrap();
|
||||
assert!(matches!(wrapper.release_snapshot_lease(volume, path, lease).await,
|
||||
Err(DiskError::Io(err)) if err.kind() == io::ErrorKind::WouldBlock));
|
||||
assert_eq!(wrapper.read_all(volume, &body_path).await.unwrap(), Bytes::from_static(b"leased body"));
|
||||
release.send(()).unwrap();
|
||||
occupied.await.unwrap().unwrap();
|
||||
assert_eq!(
|
||||
wrapper
|
||||
.delete_data_dir(
|
||||
volume,
|
||||
path,
|
||||
DeleteOptions {
|
||||
recursive: true,
|
||||
..Default::default()
|
||||
}
|
||||
)
|
||||
.await
|
||||
.unwrap(),
|
||||
DataDirDeleteStatus::Deleted
|
||||
);
|
||||
assert!(matches!(wrapper.read_all(volume, &body_path).await, Err(DiskError::FileNotFound)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn owned_mutation_keeps_publication_guard_after_waiter_cancellation() {
|
||||
let drops = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
|
||||
@@ -131,6 +131,15 @@ pub fn build_write_quorum_failure_summary(
|
||||
|
||||
fn dominant_error_label(errors: &[Option<Error>], ignored_errs: &[Error], dominant_error: Option<&Error>) -> &'static str {
|
||||
let Some(dominant_error) = dominant_error else {
|
||||
// The reducer also returns None when every failure is ignored for
|
||||
// quorum voting. That case contains no successful nil entry.
|
||||
if !errors.is_empty() && errors.iter().all(Option::is_some) {
|
||||
return if errors.iter().all(|error| error.as_ref() == Some(&Error::DiskNotFound)) {
|
||||
"disk_not_found"
|
||||
} else {
|
||||
"all_ignored"
|
||||
};
|
||||
}
|
||||
return "nil_dominated";
|
||||
};
|
||||
|
||||
@@ -387,9 +396,9 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_write_quorum_failure_summary_all_ignored_is_nil_dominated() {
|
||||
// All disks offline (all DiskNotFound). The dominant error must be None and
|
||||
// the label must be "nil_dominated" rather than falling back to "other_error".
|
||||
fn test_build_write_quorum_failure_summary_all_missing_is_disk_not_found() {
|
||||
// Ignoring unavailable disks for quorum reduction must not imply a
|
||||
// successful nil entry in the diagnostic label.
|
||||
let errors = vec![
|
||||
Some(Error::DiskNotFound),
|
||||
Some(Error::DiskNotFound),
|
||||
@@ -397,7 +406,21 @@ mod tests {
|
||||
];
|
||||
let summary = build_write_quorum_failure_summary(&errors, OBJECT_OP_IGNORED_ERRS, 2);
|
||||
assert_eq!(summary.dominant_error, None);
|
||||
assert_eq!(summary.dominant_error_label, "nil_dominated");
|
||||
assert_eq!(summary.achieved, 0);
|
||||
assert_eq!(summary.dominant_error_label, "disk_not_found");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_write_quorum_failure_summary_all_ignored_has_no_success() {
|
||||
let errors = [Some(Error::FaultyDisk), Some(Error::DiskNotFound)];
|
||||
let summary = build_write_quorum_failure_summary(&errors, OBJECT_OP_IGNORED_ERRS, 2);
|
||||
assert_eq!(summary.dominant_error, None);
|
||||
assert_eq!(summary.achieved, 0);
|
||||
assert_eq!(summary.dominant_error_label, "all_ignored");
|
||||
assert_eq!(
|
||||
reduce_write_quorum_errs(&errors, OBJECT_OP_IGNORED_ERRS, 2),
|
||||
Some(Error::ErasureWriteQuorum)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -223,12 +223,6 @@ pub fn rename_std(from: impl AsRef<Path>, to: impl AsRef<Path>) -> io::Result<()
|
||||
std::fs::rename(from, to)
|
||||
}
|
||||
|
||||
#[cfg(any(not(windows), test))]
|
||||
#[tracing::instrument(level = "debug", skip_all)]
|
||||
pub async fn read_file(path: impl AsRef<Path>) -> io::Result<Vec<u8>> {
|
||||
fs::read(path.as_ref()).await
|
||||
}
|
||||
|
||||
// Bucket existence cache - reduces statx syscalls for repeated bucket checks
|
||||
|
||||
/// Cache for bucket directory existence checks.
|
||||
@@ -614,28 +608,6 @@ mod tests {
|
||||
assert_eq!(content, "test content");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_read_file() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let file_path = temp_dir.path().join("test_read.txt");
|
||||
|
||||
let test_content = b"This is test content for reading";
|
||||
tokio::fs::write(&file_path, test_content).await.unwrap();
|
||||
|
||||
// Read file
|
||||
let read_content = read_file(&file_path).await.unwrap();
|
||||
assert_eq!(read_content, test_content);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_read_file_nonexistent() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let file_path = temp_dir.path().join("nonexistent.txt");
|
||||
|
||||
// Should fail for non-existent file
|
||||
assert!(read_file(&file_path).await.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_same_file() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
|
||||
+2145
-540
File diff suppressed because it is too large
Load Diff
@@ -47,6 +47,33 @@ use tokio::fs;
|
||||
use tracing::{info, warn};
|
||||
use uuid::Uuid;
|
||||
|
||||
fn transitioned_old_current_source(
|
||||
dst_meta_existed: bool,
|
||||
dst_meta_unparsable: bool,
|
||||
xlmeta: &FileMeta,
|
||||
volume: &str,
|
||||
path: &str,
|
||||
version_id: Uuid,
|
||||
) -> (bool, Option<FileInfo>) {
|
||||
if dst_meta_unparsable {
|
||||
return (false, None);
|
||||
}
|
||||
if !dst_meta_existed {
|
||||
return (true, None);
|
||||
}
|
||||
let Ok(old_current) = xlmeta.into_fileinfo(volume, path, "", false, false, true) else {
|
||||
return (false, None);
|
||||
};
|
||||
if old_current.version_id.unwrap_or_default() != version_id {
|
||||
return (true, None);
|
||||
}
|
||||
if old_current.transition_status == rustfs_filemeta::TRANSITION_COMPLETE {
|
||||
(true, Some(old_current))
|
||||
} else {
|
||||
(true, None)
|
||||
}
|
||||
}
|
||||
|
||||
/// Hold later repair publications after admitting one baseline object. The
|
||||
/// fixture arms this on one replacement disk before rejoining the cluster.
|
||||
#[cfg(feature = "e2e-test-hooks")]
|
||||
@@ -214,7 +241,7 @@ pub(super) async fn lock_rename_commit_directories(
|
||||
publication_root: &os::PublicationRoot,
|
||||
mutation_lease: Arc<os::NamespaceMutationLease>,
|
||||
) -> Result<os::RenameCommitGuard> {
|
||||
#[cfg(windows)]
|
||||
#[cfg(any(windows, target_os = "linux"))]
|
||||
let result = {
|
||||
let source_parent = source_parent.to_path_buf();
|
||||
let destination_parent = destination_parent.to_path_buf();
|
||||
@@ -222,7 +249,7 @@ pub(super) async fn lock_rename_commit_directories(
|
||||
let publication_root = publication_root.clone();
|
||||
os::run_blocking_namespace_operation(mutation_lease, move || {
|
||||
let result = os::prepare_rename_commit_guard(&source_parent, &destination_parent, &base_dir, &publication_root);
|
||||
#[cfg(test)]
|
||||
#[cfg(all(test, windows))]
|
||||
if result.is_ok() {
|
||||
run_destination_commit_directory_preparation(&destination_parent);
|
||||
}
|
||||
@@ -230,7 +257,7 @@ pub(super) async fn lock_rename_commit_directories(
|
||||
})
|
||||
.await
|
||||
};
|
||||
#[cfg(not(windows))]
|
||||
#[cfg(not(any(windows, target_os = "linux")))]
|
||||
let result = {
|
||||
let _ = mutation_lease;
|
||||
os::prepare_rename_commit_guard(source_parent, destination_parent, base_dir, publication_root)
|
||||
@@ -244,12 +271,12 @@ pub(super) async fn lock_rename_commit_directories(
|
||||
result.map_err(to_file_error).map_err(DiskError::from)
|
||||
}
|
||||
|
||||
async fn read_rename_destination_metadata(
|
||||
async fn prepare_rename_destination_metadata(
|
||||
file_path: &Path,
|
||||
rename_commit_guard: &os::RenameCommitGuard,
|
||||
mutation_lease: Arc<os::NamespaceMutationLease>,
|
||||
) -> Result<Option<Bytes>> {
|
||||
#[cfg(windows)]
|
||||
#[cfg(any(windows, target_os = "linux"))]
|
||||
let result = {
|
||||
let file_path = file_path.to_path_buf();
|
||||
let rename_commit_guard = rename_commit_guard.clone();
|
||||
@@ -258,10 +285,10 @@ async fn read_rename_destination_metadata(
|
||||
})
|
||||
.await
|
||||
};
|
||||
#[cfg(not(windows))]
|
||||
#[cfg(not(any(windows, target_os = "linux")))]
|
||||
let _ = (rename_commit_guard, mutation_lease);
|
||||
#[cfg(not(windows))]
|
||||
let result = match super::super::fs::read_file(file_path).await {
|
||||
#[cfg(not(any(windows, target_os = "linux")))]
|
||||
let result = match fs::read(file_path).await {
|
||||
Ok(data) => Ok(Some(data)),
|
||||
Err(err) if err.kind() == ErrorKind::NotFound => Ok(None),
|
||||
Err(err) => Err(err),
|
||||
@@ -398,7 +425,7 @@ impl LocalDisk {
|
||||
|
||||
// xl.meta path
|
||||
let src_file_path = self.io_get_object_path(src_volume, format!("{}/{}", src_path, STORAGE_FORMAT_FILE).as_str())?;
|
||||
let dst_file_path = self.io_get_object_path(dst_volume, format!("{}/{}", dst_path, STORAGE_FORMAT_FILE).as_str())?;
|
||||
let dst_file_path = self.io_get_object_open_path(dst_volume, format!("{}/{}", dst_path, STORAGE_FORMAT_FILE).as_str())?;
|
||||
|
||||
// data_dir path
|
||||
let has_data_dir_path = {
|
||||
@@ -453,8 +480,7 @@ impl LocalDisk {
|
||||
fs::create_dir_all(src_file_parent).await.map_err(to_file_error)?;
|
||||
}
|
||||
// Acquire the common trees before reading destination metadata. On
|
||||
// Windows this pins the object directory identity across metadata
|
||||
// preparation, data publication, rollback backup, and final commit.
|
||||
// Windows this pins the object directory identity across publication.
|
||||
let rename_commit_guard = lock_rename_commit_directories(
|
||||
src_file_parent,
|
||||
dst_file_parent,
|
||||
@@ -472,9 +498,12 @@ impl LocalDisk {
|
||||
source_volume_dir: &src_volume_dir,
|
||||
data_paths: has_data_dir_path.as_ref(),
|
||||
};
|
||||
let has_dst_buf =
|
||||
read_rename_destination_metadata(commit.destination_metadata, &commit.directory_guard, commit.mutation_lease.clone())
|
||||
.await?;
|
||||
let has_dst_buf = prepare_rename_destination_metadata(
|
||||
commit.destination_metadata,
|
||||
&commit.directory_guard,
|
||||
commit.mutation_lease.clone(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
if no_inline {
|
||||
// Non-inline: read xl.meta, parse, write, rename data dir, rename xl.meta
|
||||
@@ -508,6 +537,14 @@ impl LocalDisk {
|
||||
}
|
||||
|
||||
let version_id = fi.version_id.unwrap_or_default();
|
||||
let (old_current_source_checked, old_current_source) = transitioned_old_current_source(
|
||||
has_dst_buf.is_some(),
|
||||
dst_meta_unparsable,
|
||||
&xlmeta,
|
||||
dst_volume,
|
||||
dst_path,
|
||||
version_id,
|
||||
);
|
||||
let has_old_data_dir = xlmeta.find_unshared_data_dir_for_version(Some(version_id));
|
||||
let old_version_exists = xlmeta.find_version(Some(version_id)).is_ok();
|
||||
let rollback_data_dir = has_old_data_dir.or_else(|| {
|
||||
@@ -798,7 +835,9 @@ impl LocalDisk {
|
||||
&& let Some(parent) = dst_file_path.parent()
|
||||
{
|
||||
let fsync_started = rustfs_io_metrics::put_stage_timer();
|
||||
if let Err(err) = os::fsync_dst_dir_group_commit(parent, Some(commit.mutation_lease.clone())).await {
|
||||
if let Err(err) =
|
||||
os::fsync_commit_directory(parent, &commit.directory_guard, commit.mutation_lease.clone(), None).await
|
||||
{
|
||||
rustfs_io_metrics::record_put_object_stage_duration_from(
|
||||
rustfs_io_metrics::PUT_STAGE_SET_DISK_RENAME_DST_DIR_FSYNC,
|
||||
fsync_started,
|
||||
@@ -897,6 +936,8 @@ impl LocalDisk {
|
||||
cleanup_data_dir: has_old_data_dir,
|
||||
sign: version_signature,
|
||||
old_current_size,
|
||||
old_current_source_checked,
|
||||
old_current_source,
|
||||
})
|
||||
} else {
|
||||
// Inline metadata preparation is blocking. The transaction lease is
|
||||
@@ -910,6 +951,8 @@ impl LocalDisk {
|
||||
None
|
||||
};
|
||||
let dst_path_for_failpoint = dst_path.to_string();
|
||||
let dst_volume_for_source = dst_volume.to_string();
|
||||
let dst_path_for_source = dst_path.to_string();
|
||||
#[cfg(windows)]
|
||||
let source_parent = src_file_parent.to_path_buf();
|
||||
let rename_commit_guard_for_preparation = commit.directory_guard.clone();
|
||||
@@ -954,6 +997,14 @@ impl LocalDisk {
|
||||
};
|
||||
|
||||
let version_id = fi.version_id.unwrap_or_default();
|
||||
let (old_current_source_checked, old_current_source) = transitioned_old_current_source(
|
||||
has_dst_buf.is_some(),
|
||||
dst_meta_unparsable,
|
||||
&xlmeta,
|
||||
&dst_volume_for_source,
|
||||
&dst_path_for_source,
|
||||
version_id,
|
||||
);
|
||||
let old_data_dir = xlmeta.find_unshared_data_dir_for_version(Some(version_id));
|
||||
let old_version_exists = xlmeta.find_version(Some(version_id)).is_ok();
|
||||
let rollback_data_dir = old_data_dir.or_else(|| {
|
||||
@@ -1003,6 +1054,8 @@ impl LocalDisk {
|
||||
old_data_dir,
|
||||
version_signature,
|
||||
old_current_size,
|
||||
old_current_source_checked,
|
||||
old_current_source,
|
||||
staged_rollback_path,
|
||||
has_dst_buf.is_none(),
|
||||
prepared_metadata_source,
|
||||
@@ -1022,6 +1075,8 @@ impl LocalDisk {
|
||||
cleanup_data_dir,
|
||||
version_signature,
|
||||
old_current_size,
|
||||
old_current_source_checked,
|
||||
old_current_source,
|
||||
mut local_rollback_path,
|
||||
destination_was_absent,
|
||||
prepared_metadata_source,
|
||||
@@ -1147,10 +1202,11 @@ impl LocalDisk {
|
||||
&& let Some(dst_parent) = dst_file_path.parent()
|
||||
{
|
||||
let fsync_started = rustfs_io_metrics::put_stage_timer();
|
||||
if let Err(err) = os::fsync_dst_dir_group_commit_or_namespace_file_sync_limit(
|
||||
if let Err(err) = os::fsync_commit_directory(
|
||||
dst_parent,
|
||||
&commit.directory_guard,
|
||||
commit.mutation_lease.clone(),
|
||||
admission,
|
||||
Some(admission),
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -1262,6 +1318,8 @@ impl LocalDisk {
|
||||
cleanup_data_dir,
|
||||
sign: version_signature,
|
||||
old_current_size,
|
||||
old_current_source_checked,
|
||||
old_current_source,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,7 @@
|
||||
|
||||
// #730: disk abstractions still carry staged health and direct-I/O migration paths.
|
||||
|
||||
pub(crate) mod cleanup_runtime;
|
||||
pub mod disk_store;
|
||||
pub mod endpoint;
|
||||
pub mod error;
|
||||
@@ -230,6 +231,105 @@ pub enum Disk {
|
||||
}
|
||||
|
||||
impl Disk {
|
||||
pub(crate) async fn delete_versions_with_namespace_owner(
|
||||
&self,
|
||||
volume: &str,
|
||||
versions: Vec<FileInfoVersions>,
|
||||
opts: DeleteOptions,
|
||||
namespace_owner: Option<Arc<dyn Send + Sync>>,
|
||||
) -> Vec<Option<Error>> {
|
||||
match self {
|
||||
Self::Local(disk) => {
|
||||
disk.delete_versions_with_namespace_owner(volume, versions, opts, namespace_owner)
|
||||
.await
|
||||
}
|
||||
Self::Remote(disk) => {
|
||||
let _owner = namespace_owner;
|
||||
disk.delete_versions(volume, versions, opts).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn delete_versions_recoverable_with_namespace_owner(
|
||||
&self,
|
||||
volume: &str,
|
||||
versions: Vec<FileInfoVersions>,
|
||||
mut opts: DeleteOptions,
|
||||
namespace_owner: Option<Arc<dyn Send + Sync>>,
|
||||
) -> Vec<Option<Error>> {
|
||||
if let Err(error) = validate_delete_recovery_options(&opts) {
|
||||
return vec![Some(error); versions.len()];
|
||||
}
|
||||
if versions.iter().any(|version| version.name.starts_with('/')) {
|
||||
return vec![Some(DiskError::InvalidPath); versions.len()];
|
||||
}
|
||||
opts.recoverable_delete = true;
|
||||
match self {
|
||||
Self::Local(disk) => {
|
||||
disk.delete_versions_with_namespace_owner(volume, versions, opts, namespace_owner)
|
||||
.await
|
||||
}
|
||||
Self::Remote(disk) => {
|
||||
let _owner = namespace_owner;
|
||||
disk.delete_versions_recoverable(volume, versions, opts).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn settle_delete_transaction_with_namespace_owner(
|
||||
&self,
|
||||
volume: &str,
|
||||
object: &str,
|
||||
transaction: Uuid,
|
||||
expected_fingerprint: DeleteRecoveryDigest,
|
||||
action: DeleteRecoveryAction,
|
||||
namespace_owner: Option<Arc<dyn Send + Sync>>,
|
||||
) -> Result<DeleteRecoveryInspection> {
|
||||
match self {
|
||||
Self::Local(disk) => {
|
||||
disk.settle_delete_transaction_with_namespace_owner(
|
||||
volume,
|
||||
object,
|
||||
transaction,
|
||||
expected_fingerprint,
|
||||
action,
|
||||
namespace_owner,
|
||||
)
|
||||
.await
|
||||
}
|
||||
Self::Remote(disk) => {
|
||||
let _owner = namespace_owner;
|
||||
disk.settle_delete_transaction(volume, object, transaction, expected_fingerprint, action)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn delete_version_recoverable_with_namespace_owner(
|
||||
&self,
|
||||
volume: &str,
|
||||
path: &str,
|
||||
fi: FileInfo,
|
||||
force_del_marker: bool,
|
||||
mut opts: DeleteOptions,
|
||||
namespace_owner: Option<Arc<dyn Send + Sync>>,
|
||||
) -> Result<()> {
|
||||
validate_delete_recovery_options(&opts)?;
|
||||
if path.starts_with('/') {
|
||||
return Err(DiskError::InvalidPath);
|
||||
}
|
||||
opts.recoverable_delete = true;
|
||||
match self {
|
||||
Self::Local(disk) => {
|
||||
disk.delete_version_with_namespace_owner(volume, path, fi, force_del_marker, opts, namespace_owner)
|
||||
.await
|
||||
}
|
||||
Self::Remote(disk) => {
|
||||
disk.delete_version_recoverable(volume, path, fi, force_del_marker, opts)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
pub(crate) async fn set_disk_id_state(&self, id: Option<Uuid>) -> Result<()> {
|
||||
match self {
|
||||
Disk::Local(local_disk) => {
|
||||
@@ -257,6 +357,70 @@ impl Disk {
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl DiskAPI for Disk {
|
||||
async fn delete_version_recoverable(
|
||||
&self,
|
||||
volume: &str,
|
||||
object: &str,
|
||||
file_info: FileInfo,
|
||||
force_del_marker: bool,
|
||||
opts: DeleteOptions,
|
||||
) -> Result<()> {
|
||||
self.delete_version_recoverable_with_namespace_owner(volume, object, file_info, force_del_marker, opts, None)
|
||||
.await
|
||||
}
|
||||
async fn delete_versions_recoverable(
|
||||
&self,
|
||||
volume: &str,
|
||||
versions: Vec<FileInfoVersions>,
|
||||
opts: DeleteOptions,
|
||||
) -> Vec<Option<DiskError>> {
|
||||
match self {
|
||||
Self::Local(disk) => disk.delete_versions_recoverable(volume, versions, opts).await,
|
||||
Self::Remote(disk) => disk.delete_versions_recoverable(volume, versions, opts).await,
|
||||
}
|
||||
}
|
||||
async fn discover_delete_transactions(
|
||||
&self,
|
||||
volume: &str,
|
||||
object: Option<&str>,
|
||||
cursor: Option<DeleteRecoveryCursor>,
|
||||
limit: u32,
|
||||
) -> Result<DeleteRecoveryPage> {
|
||||
match self {
|
||||
Self::Local(disk) => disk.discover_delete_transactions(volume, object, cursor, limit).await,
|
||||
Self::Remote(disk) => disk.discover_delete_transactions(volume, object, cursor, limit).await,
|
||||
}
|
||||
}
|
||||
async fn inspect_delete_transaction(
|
||||
&self,
|
||||
volume: &str,
|
||||
object: &str,
|
||||
transaction: Uuid,
|
||||
) -> Result<DeleteRecoveryInspection> {
|
||||
match self {
|
||||
Self::Local(disk) => disk.inspect_delete_transaction(volume, object, transaction).await,
|
||||
Self::Remote(disk) => disk.inspect_delete_transaction(volume, object, transaction).await,
|
||||
}
|
||||
}
|
||||
async fn settle_delete_transaction(
|
||||
&self,
|
||||
volume: &str,
|
||||
object: &str,
|
||||
transaction: Uuid,
|
||||
expected_fingerprint: DeleteRecoveryDigest,
|
||||
action: DeleteRecoveryAction,
|
||||
) -> Result<DeleteRecoveryInspection> {
|
||||
match self {
|
||||
Self::Local(disk) => {
|
||||
disk.settle_delete_transaction(volume, object, transaction, expected_fingerprint, action)
|
||||
.await
|
||||
}
|
||||
Self::Remote(disk) => {
|
||||
disk.settle_delete_transaction(volume, object, transaction, expected_fingerprint, action)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
fn to_string(&self) -> String {
|
||||
match self {
|
||||
Disk::Local(local_disk) => local_disk.to_string(),
|
||||
@@ -1246,6 +1410,51 @@ pub trait DiskAPI: Debug + Send + Sync + 'static {
|
||||
Err(DiskError::MethodNotAllowed)
|
||||
}
|
||||
async fn delete(&self, volume: &str, path: &str, opt: DeleteOptions) -> Result<()>;
|
||||
async fn discover_delete_transactions(
|
||||
&self,
|
||||
_volume: &str,
|
||||
_object: Option<&str>,
|
||||
_cursor: Option<DeleteRecoveryCursor>,
|
||||
_limit: u32,
|
||||
) -> Result<DeleteRecoveryPage> {
|
||||
Err(DiskError::MethodNotAllowed)
|
||||
}
|
||||
async fn delete_version_recoverable(
|
||||
&self,
|
||||
_volume: &str,
|
||||
_object: &str,
|
||||
_file_info: FileInfo,
|
||||
_force_del_marker: bool,
|
||||
_opts: DeleteOptions,
|
||||
) -> Result<()> {
|
||||
Err(DiskError::MethodNotAllowed)
|
||||
}
|
||||
async fn delete_versions_recoverable(
|
||||
&self,
|
||||
_volume: &str,
|
||||
versions: Vec<FileInfoVersions>,
|
||||
_opts: DeleteOptions,
|
||||
) -> Vec<Option<DiskError>> {
|
||||
vec![Some(DiskError::MethodNotAllowed); versions.len()]
|
||||
}
|
||||
async fn inspect_delete_transaction(
|
||||
&self,
|
||||
_volume: &str,
|
||||
_object: &str,
|
||||
_transaction: Uuid,
|
||||
) -> Result<DeleteRecoveryInspection> {
|
||||
Err(DiskError::MethodNotAllowed)
|
||||
}
|
||||
async fn settle_delete_transaction(
|
||||
&self,
|
||||
_volume: &str,
|
||||
_object: &str,
|
||||
_transaction: Uuid,
|
||||
_expected_fingerprint: DeleteRecoveryDigest,
|
||||
_action: DeleteRecoveryAction,
|
||||
) -> Result<DeleteRecoveryInspection> {
|
||||
Err(DiskError::MethodNotAllowed)
|
||||
}
|
||||
// VerifyFile
|
||||
async fn verify_file(&self, volume: &str, path: &str, fi: &FileInfo) -> Result<CheckPartsResp>;
|
||||
// CheckParts
|
||||
@@ -1568,6 +1777,14 @@ pub struct RenameDataResp {
|
||||
/// "cannot vote", never as `Absent`.
|
||||
#[serde(default)]
|
||||
pub old_current_size: Option<OldCurrentSize>,
|
||||
/// Whether this disk decoded enough of the overwritten current version to
|
||||
/// decide if a transitioned source has to be returned.
|
||||
#[serde(default)]
|
||||
pub old_current_source_checked: bool,
|
||||
/// Transitioned old-current source used by PUT free-version cleanup receipt.
|
||||
/// Empty when the old current is not transitioned or no source was known.
|
||||
#[serde(default)]
|
||||
pub old_current_source: Option<FileInfo>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
@@ -1582,6 +1799,269 @@ pub struct DeleteOptions {
|
||||
/// Remote calls carrying it must use DeleteRetiredMarker, never DeleteVersion.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub expected_delete_marker: Option<rustfs_filemeta::MetaDeleteMarker>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub delete_recovery_bucket_incarnation: Option<Uuid>,
|
||||
#[serde(default)]
|
||||
pub recoverable_delete: bool,
|
||||
}
|
||||
|
||||
pub const DELETE_RECOVERY_PROTOCOL_VERSION: u32 = 1;
|
||||
pub(crate) fn get_delete_recovery_timeout() -> Result<std::time::Duration> {
|
||||
let value = std::env::var("RUSTFS_DELETE_RECOVERY_TIMEOUT_SECS");
|
||||
match value {
|
||||
Ok(value) => parse_delete_recovery_timeout(Some(&value)),
|
||||
Err(std::env::VarError::NotPresent) => parse_delete_recovery_timeout(None),
|
||||
Err(std::env::VarError::NotUnicode(_)) => Err(DiskError::other("invalid delete recovery timeout configuration")),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_delete_recovery_timeout(value: Option<&str>) -> Result<std::time::Duration> {
|
||||
let seconds = match value {
|
||||
None => 900,
|
||||
Some(value) => value
|
||||
.parse::<u64>()
|
||||
.ok()
|
||||
.filter(|seconds| (30..=86400).contains(seconds))
|
||||
.ok_or_else(|| DiskError::other("delete recovery timeout must be an integer between 30 and 86400 seconds"))?,
|
||||
};
|
||||
Ok(std::time::Duration::from_secs(seconds))
|
||||
}
|
||||
|
||||
pub(crate) fn validate_delete_recovery_options(opts: &DeleteOptions) -> Result<()> {
|
||||
if opts.old_data_dir.is_none_or(|id| id.is_nil())
|
||||
|| opts.undo_write
|
||||
|| opts.undo_delete
|
||||
|| opts.recursive
|
||||
|| opts.immediate
|
||||
|| opts.expected_delete_marker.is_some()
|
||||
|| opts.delete_recovery_bucket_incarnation.is_some_and(|id| id.is_nil())
|
||||
{
|
||||
return Err(DiskError::FileCorrupt);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub type DeleteRecoveryDigest = [u8; 32];
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct DeleteTransactionId {
|
||||
pub object: String,
|
||||
pub transaction: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct DeleteRecoveryCursor {
|
||||
pub token: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct DeleteRecoveryPage {
|
||||
pub transactions: Vec<DeleteTransactionId>,
|
||||
pub next: Option<DeleteRecoveryCursor>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct DeleteRecoveryJournal {
|
||||
pub protocol_version: u32,
|
||||
pub volume: String,
|
||||
pub object: String,
|
||||
pub transaction: Uuid,
|
||||
pub bucket_incarnation: Option<Uuid>,
|
||||
pub original_digest: Option<DeleteRecoveryDigest>,
|
||||
pub target_digest: Option<DeleteRecoveryDigest>,
|
||||
pub data_dirs: Vec<Uuid>,
|
||||
pub deleted_versions: Vec<Uuid>,
|
||||
pub requests: Vec<FileInfo>,
|
||||
pub original_versions: Vec<DeleteRecoveryVersionIdentity>,
|
||||
#[serde(default)]
|
||||
pub predecessors: Vec<Uuid>,
|
||||
}
|
||||
|
||||
impl DeleteRecoveryJournal {
|
||||
/// Checks source generations only; the coordinator must supply a certified
|
||||
/// decision and the receiver must retain its namespace/incarnation fences.
|
||||
pub(crate) fn matches_adoption_source(&self, current: &[DeleteRecoveryVersionIdentity]) -> bool {
|
||||
if self.requests.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let mut originals = std::collections::HashMap::with_capacity(self.original_versions.len());
|
||||
for version in &self.original_versions {
|
||||
originals.entry(version.version_id.unwrap_or_default()).or_insert(version);
|
||||
}
|
||||
let mut locals = std::collections::HashMap::with_capacity(current.len());
|
||||
for version in current {
|
||||
locals.entry(version.version_id.unwrap_or_default()).or_insert(version);
|
||||
}
|
||||
self.requests.iter().all(|request| {
|
||||
if request.deleted && request.mark_deleted {
|
||||
return current.first() == self.original_versions.first();
|
||||
}
|
||||
let id = request.version_id.unwrap_or_default();
|
||||
let Some(original) = originals.get(&id) else {
|
||||
return false;
|
||||
};
|
||||
match locals.get(&id) {
|
||||
Some(local) => local == original,
|
||||
None => !id.is_nil(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Matches immutable local preparation intent against an independently
|
||||
/// inspected same-transaction commit receipt. This match is not authority
|
||||
/// by itself; the coordinator must supply the certified receipt.
|
||||
pub(crate) fn matches_prepared_authority(&self, authority: &Self) -> bool {
|
||||
if self.protocol_version != DELETE_RECOVERY_PROTOCOL_VERSION
|
||||
|| authority.protocol_version != DELETE_RECOVERY_PROTOCOL_VERSION
|
||||
|| self.transaction.is_nil()
|
||||
|| self.transaction != authority.transaction
|
||||
|| self.volume != authority.volume
|
||||
|| self.object != authority.object
|
||||
|| self.bucket_incarnation != authority.bucket_incarnation
|
||||
|| self.bucket_incarnation.is_some_and(|id| id.is_nil())
|
||||
|| self.requests.is_empty()
|
||||
|| self.requests != authority.requests
|
||||
|| self.deleted_versions.iter().collect::<std::collections::HashSet<_>>()
|
||||
!= authority.deleted_versions.iter().collect::<std::collections::HashSet<_>>()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let sources: std::collections::HashMap<_, _> = self
|
||||
.original_versions
|
||||
.iter()
|
||||
.map(|version| (version.version_id.unwrap_or_default(), version))
|
||||
.collect();
|
||||
let certified: std::collections::HashMap<_, _> = authority
|
||||
.original_versions
|
||||
.iter()
|
||||
.map(|version| (version.version_id.unwrap_or_default(), version))
|
||||
.collect();
|
||||
if sources.len() != self.original_versions.len() || certified.len() != authority.original_versions.len() {
|
||||
return false;
|
||||
}
|
||||
self.requests.iter().all(|request| {
|
||||
let (local, remote) = if request.deleted && request.mark_deleted {
|
||||
(self.original_versions.first(), authority.original_versions.first())
|
||||
} else {
|
||||
let id = request.version_id.unwrap_or_default();
|
||||
(sources.get(&id).copied(), certified.get(&id).copied())
|
||||
};
|
||||
match (local, remote) {
|
||||
(Some(local), Some(remote)) => local == remote,
|
||||
(None, None) => self.original_digest.is_none() && authority.original_digest.is_none(),
|
||||
_ => false,
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct DeleteRecoveryVersionIdentity {
|
||||
pub version_id: Option<Uuid>,
|
||||
pub mod_time: Option<OffsetDateTime>,
|
||||
pub data_dir: Option<Uuid>,
|
||||
pub deleted: bool,
|
||||
pub metadata_digest: DeleteRecoveryDigest,
|
||||
}
|
||||
|
||||
impl DeleteRecoveryVersionIdentity {
|
||||
pub(crate) fn from_file_info(info: FileInfo) -> error::Result<Self> {
|
||||
local::delete_recovery::generation(info)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum DeleteRecoveryState {
|
||||
Absent,
|
||||
NewPrepared,
|
||||
QuorumCommitted,
|
||||
CommitFinished,
|
||||
LegacyAmbiguous,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct DeleteRecoveryInspection {
|
||||
pub protocol_version: u32,
|
||||
pub id: DeleteTransactionId,
|
||||
pub state: DeleteRecoveryState,
|
||||
pub journal: Option<DeleteRecoveryJournal>,
|
||||
pub current_digest: Option<DeleteRecoveryDigest>,
|
||||
pub current_versions: Vec<DeleteRecoveryVersionIdentity>,
|
||||
pub backup_digest: Option<DeleteRecoveryDigest>,
|
||||
pub rollback_data_complete: bool,
|
||||
pub commit_data_safe: bool,
|
||||
pub explicit_delete_safe: bool,
|
||||
pub fingerprint: DeleteRecoveryDigest,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum DeleteRecoveryAction {
|
||||
PublishCommit,
|
||||
FinishCommit,
|
||||
FinalizeCommit,
|
||||
Rollback,
|
||||
DiscardLegacy,
|
||||
AdoptCommit { authority: Box<DeleteRecoveryJournal> },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub enum DeleteRecoveryRpcOperation {
|
||||
Discover {
|
||||
object: Option<String>,
|
||||
cursor: Option<DeleteRecoveryCursor>,
|
||||
limit: u32,
|
||||
},
|
||||
Inspect {
|
||||
object: String,
|
||||
transaction: Uuid,
|
||||
},
|
||||
Settle {
|
||||
object: String,
|
||||
transaction: Uuid,
|
||||
expected_fingerprint: DeleteRecoveryDigest,
|
||||
action: DeleteRecoveryAction,
|
||||
},
|
||||
ApplyVersion {
|
||||
object: String,
|
||||
file_info: Box<FileInfo>,
|
||||
force_del_marker: bool,
|
||||
opts: DeleteOptions,
|
||||
},
|
||||
ApplyVersions {
|
||||
versions: Vec<FileInfoVersions>,
|
||||
opts: DeleteOptions,
|
||||
},
|
||||
}
|
||||
|
||||
impl DeleteRecoveryRpcOperation {
|
||||
/// Full metadata application and adoption share the existing mutation envelope.
|
||||
pub fn max_wire_payload_bytes(&self) -> usize {
|
||||
match self {
|
||||
Self::ApplyVersion { .. }
|
||||
| Self::ApplyVersions { .. }
|
||||
| Self::Settle {
|
||||
action: DeleteRecoveryAction::AdoptCommit { .. },
|
||||
..
|
||||
} => rustfs_protos::DELETE_RECOVERY_MAX_PAYLOAD_BYTES,
|
||||
_ => rustfs_protos::DELETE_RECOVERY_MAX_CONTROL_PAYLOAD_BYTES,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub enum DeleteRecoveryRpcReply {
|
||||
Discovered(DeleteRecoveryPage),
|
||||
Inspected(DeleteRecoveryInspection),
|
||||
Settled(DeleteRecoveryInspection),
|
||||
AppliedVersion,
|
||||
AppliedVersions(Vec<Option<u32>>),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -1872,6 +2352,8 @@ mod tests {
|
||||
undo_delete: false,
|
||||
old_data_dir: Some(Uuid::new_v4()),
|
||||
expected_delete_marker: None,
|
||||
delete_recovery_bucket_incarnation: None,
|
||||
recoverable_delete: false,
|
||||
};
|
||||
|
||||
assert!(opts.recursive);
|
||||
@@ -2044,6 +2526,8 @@ mod tests {
|
||||
cleanup_data_dir: Some(uuid),
|
||||
sign: Some(signature.clone()),
|
||||
old_current_size: Some(OldCurrentSize::Present(42)),
|
||||
old_current_source_checked: false,
|
||||
old_current_source: None,
|
||||
};
|
||||
|
||||
assert_eq!(resp.old_data_dir, Some(uuid));
|
||||
@@ -2064,6 +2548,8 @@ mod tests {
|
||||
cleanup_data_dir: Some(Uuid::new_v4()),
|
||||
sign: Some(vec![0x01, 0x02, 0x03]),
|
||||
old_current_size,
|
||||
old_current_source_checked: false,
|
||||
old_current_source: None,
|
||||
};
|
||||
|
||||
let encoded = rmp_serde::encode::to_vec_named(&resp).expect("named msgpack should encode");
|
||||
@@ -2101,6 +2587,8 @@ mod tests {
|
||||
assert_eq!(decoded.cleanup_data_dir, None);
|
||||
assert_eq!(decoded.sign, legacy.sign);
|
||||
assert_eq!(decoded.old_current_size, None);
|
||||
assert!(!decoded.old_current_source_checked);
|
||||
assert!(decoded.old_current_source.is_none());
|
||||
}
|
||||
|
||||
/// Test constants
|
||||
@@ -2296,3 +2784,183 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod delete_recovery_timeout_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn recovery_timeout_defaults_and_validates_range() {
|
||||
assert_eq!(
|
||||
parse_delete_recovery_timeout(None).expect("default timeout"),
|
||||
std::time::Duration::from_secs(900)
|
||||
);
|
||||
for seconds in ["30", "900", "86400"] {
|
||||
assert!(parse_delete_recovery_timeout(Some(seconds)).is_ok());
|
||||
}
|
||||
for value in ["", "invalid", "0", "2", "29", "86401", "-1", "18446744073709551616"] {
|
||||
assert!(parse_delete_recovery_timeout(Some(value)).is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod delete_recovery_wire_limit_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn boxed_apply_version_preserves_existing_wire_payload() {
|
||||
#[derive(Serialize)]
|
||||
enum PriorOperation<'a> {
|
||||
ApplyVersion {
|
||||
object: &'a str,
|
||||
file_info: &'a FileInfo,
|
||||
force_del_marker: bool,
|
||||
opts: &'a DeleteOptions,
|
||||
},
|
||||
}
|
||||
let file_info = FileInfo {
|
||||
name: "object".into(),
|
||||
version_id: Some(Uuid::new_v4()),
|
||||
metadata: std::collections::HashMap::from([("metadata".into(), "value".into())]),
|
||||
..Default::default()
|
||||
};
|
||||
let opts = DeleteOptions {
|
||||
old_data_dir: Some(Uuid::new_v4()),
|
||||
recoverable_delete: true,
|
||||
delete_recovery_bucket_incarnation: Some(Uuid::new_v4()),
|
||||
..Default::default()
|
||||
};
|
||||
let prior = PriorOperation::ApplyVersion {
|
||||
object: "object",
|
||||
file_info: &file_info,
|
||||
force_del_marker: true,
|
||||
opts: &opts,
|
||||
};
|
||||
let encoded = rmp_serde::to_vec_named(&prior).expect("encode the previous unboxed request shape");
|
||||
let operation = DeleteRecoveryRpcOperation::ApplyVersion {
|
||||
object: "object".into(),
|
||||
file_info: Box::new(file_info.clone()),
|
||||
force_del_marker: true,
|
||||
opts: opts.clone(),
|
||||
};
|
||||
assert_eq!(rmp_serde::to_vec_named(&operation).expect("encode boxed request"), encoded);
|
||||
assert_eq!(
|
||||
serde_json::to_value(&operation).expect("boxed request JSON"),
|
||||
serde_json::to_value(prior).expect("previous request JSON")
|
||||
);
|
||||
let decoded: DeleteRecoveryRpcOperation =
|
||||
rustfs_protos::decode_delete_recovery_payload(&encoded).expect("decode the previous payload");
|
||||
assert_eq!(rmp_serde::to_vec_named(&decoded).expect("re-encode decoded prior request"), encoded);
|
||||
}
|
||||
#[test]
|
||||
fn adoption_and_inspection_reply_support_large_version_journals() {
|
||||
let transaction = Uuid::new_v4();
|
||||
let request = FileInfo {
|
||||
metadata: std::collections::HashMap::from([("metadata".into(), "x".repeat(2048))]),
|
||||
..Default::default()
|
||||
};
|
||||
let journal = DeleteRecoveryJournal {
|
||||
protocol_version: DELETE_RECOVERY_PROTOCOL_VERSION,
|
||||
volume: "bucket".into(),
|
||||
object: "object".into(),
|
||||
transaction,
|
||||
bucket_incarnation: None,
|
||||
original_digest: None,
|
||||
target_digest: None,
|
||||
data_dirs: Vec::new(),
|
||||
deleted_versions: Vec::new(),
|
||||
requests: vec![request; 1000],
|
||||
original_versions: Vec::new(),
|
||||
predecessors: Vec::new(),
|
||||
};
|
||||
let operation = DeleteRecoveryRpcOperation::Settle {
|
||||
object: "object".into(),
|
||||
transaction,
|
||||
expected_fingerprint: [0; 32],
|
||||
action: DeleteRecoveryAction::AdoptCommit {
|
||||
authority: Box::new(journal.clone()),
|
||||
},
|
||||
};
|
||||
let encoded = rmp_serde::to_vec_named(&operation).expect("encode large adoption");
|
||||
assert!(encoded.len() > rustfs_protos::DELETE_RECOVERY_MAX_CONTROL_PAYLOAD_BYTES);
|
||||
assert!(encoded.len() < operation.max_wire_payload_bytes());
|
||||
let decoded: DeleteRecoveryRpcOperation =
|
||||
rustfs_protos::decode_delete_recovery_payload(&encoded).expect("large adoption can decode");
|
||||
assert_eq!(decoded.max_wire_payload_bytes(), rustfs_protos::DELETE_RECOVERY_MAX_PAYLOAD_BYTES);
|
||||
let reply = DeleteRecoveryRpcReply::Inspected(DeleteRecoveryInspection {
|
||||
protocol_version: DELETE_RECOVERY_PROTOCOL_VERSION,
|
||||
id: DeleteTransactionId {
|
||||
object: "object".into(),
|
||||
transaction,
|
||||
},
|
||||
state: DeleteRecoveryState::QuorumCommitted,
|
||||
journal: Some(journal),
|
||||
current_digest: None,
|
||||
current_versions: Vec::new(),
|
||||
backup_digest: None,
|
||||
rollback_data_complete: false,
|
||||
commit_data_safe: true,
|
||||
explicit_delete_safe: false,
|
||||
fingerprint: [0; 32],
|
||||
});
|
||||
let encoded = rmp_serde::to_vec_named(&reply).expect("encode large inspection");
|
||||
assert!(encoded.len() > rustfs_protos::DELETE_RECOVERY_MAX_CONTROL_PAYLOAD_BYTES);
|
||||
assert!(rustfs_protos::decode_delete_recovery_payload::<DeleteRecoveryRpcReply>(&encoded).is_ok());
|
||||
let inspect = DeleteRecoveryRpcOperation::Inspect {
|
||||
object: "object".into(),
|
||||
transaction,
|
||||
};
|
||||
assert_eq!(inspect.max_wire_payload_bytes(), rustfs_protos::DELETE_RECOVERY_MAX_CONTROL_PAYLOAD_BYTES);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod delete_recovery_adoption_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn source_matching_preserves_non_null_absence_and_rejects_foreign_or_missing_null() {
|
||||
let id = Uuid::new_v4();
|
||||
let source = DeleteRecoveryVersionIdentity {
|
||||
version_id: Some(id),
|
||||
mod_time: None,
|
||||
data_dir: None,
|
||||
deleted: false,
|
||||
metadata_digest: [1; 32],
|
||||
};
|
||||
let mut journal = DeleteRecoveryJournal {
|
||||
protocol_version: DELETE_RECOVERY_PROTOCOL_VERSION,
|
||||
volume: "bucket".into(),
|
||||
object: "object".into(),
|
||||
transaction: Uuid::new_v4(),
|
||||
bucket_incarnation: None,
|
||||
original_digest: None,
|
||||
target_digest: None,
|
||||
data_dirs: Vec::new(),
|
||||
deleted_versions: vec![id],
|
||||
requests: vec![FileInfo {
|
||||
version_id: Some(id),
|
||||
..Default::default()
|
||||
}],
|
||||
original_versions: vec![source.clone()],
|
||||
predecessors: Vec::new(),
|
||||
};
|
||||
assert!(journal.matches_adoption_source(&[]), "certified non-null removal can already be absent");
|
||||
assert!(journal.matches_adoption_source(std::slice::from_ref(&source)));
|
||||
let mut foreign = source.clone();
|
||||
foreign.metadata_digest = [2; 32];
|
||||
assert!(!journal.matches_adoption_source(std::slice::from_ref(&foreign)));
|
||||
journal.original_versions.push(foreign.clone());
|
||||
assert!(
|
||||
journal.matches_adoption_source(&[source.clone(), foreign.clone()]),
|
||||
"retain original first-match semantics"
|
||||
);
|
||||
assert!(!journal.matches_adoption_source(&[foreign, source]));
|
||||
journal.requests[0].version_id = None;
|
||||
journal.original_versions[0].version_id = None;
|
||||
assert!(!journal.matches_adoption_source(&[]), "null absence does not establish its generation");
|
||||
assert!(journal.matches_adoption_source(std::slice::from_ref(&journal.original_versions[0])));
|
||||
journal.requests.clear();
|
||||
assert!(!journal.matches_adoption_source(&journal.original_versions));
|
||||
}
|
||||
}
|
||||
|
||||
+836
-73
File diff suppressed because it is too large
Load Diff
@@ -174,10 +174,14 @@ async fn open_reader_source(
|
||||
metrics_path: Option<&'static str>,
|
||||
) -> disk::error::Result<Option<BoxedObjectReader>> {
|
||||
if let Some(data) = inline_data {
|
||||
#[cfg(test)]
|
||||
crate::set_disk::disk_call_counters::record_shard_open(path, true);
|
||||
let mut reader = Cursor::new(data);
|
||||
reader.set_position(u64::try_from(offset).map_err(|_| DiskError::FileCorrupt)?);
|
||||
Ok(Some(ShardReader::InMemory(reader)))
|
||||
} else if let Some(disk) = disk {
|
||||
#[cfg(test)]
|
||||
crate::set_disk::disk_call_counters::record_shard_open(path, false);
|
||||
open_disk_reader(disk, bucket, path, offset, length, use_mmap_read, metrics_path)
|
||||
.await
|
||||
.map(Some)
|
||||
|
||||
@@ -102,10 +102,14 @@ mod body_cache_hook;
|
||||
mod encryption;
|
||||
mod hook_slot;
|
||||
mod object_mutation_hook;
|
||||
mod read_condition;
|
||||
mod readers;
|
||||
mod types;
|
||||
pub(crate) mod write_commit_context;
|
||||
|
||||
pub use read_condition::{GetObjectReadCondition, GetObjectReadMetadata, with_get_object_read_condition};
|
||||
pub(crate) use read_condition::{get_object_read_condition_is_active, get_object_read_condition_is_terminal};
|
||||
|
||||
#[cfg(test)]
|
||||
mod persisted_metadata_keys_tests;
|
||||
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
// 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 rustfs_filemeta::{FileInfo, ObjectPartInfo};
|
||||
use std::future::Future;
|
||||
use std::sync::Arc;
|
||||
use time::OffsetDateTime;
|
||||
|
||||
/// Borrowed fields needed to decide whether an object read needs a body.
|
||||
pub struct GetObjectReadMetadata<'a> {
|
||||
pub bucket: &'a str,
|
||||
pub object: &'a str,
|
||||
pub etag: Option<&'a str>,
|
||||
pub mod_time: Option<OffsetDateTime>,
|
||||
pub parts: &'a [ObjectPartInfo],
|
||||
pub delete_marker: bool,
|
||||
}
|
||||
|
||||
impl<'a> GetObjectReadMetadata<'a> {
|
||||
pub(crate) fn from_file_info(bucket: &'a str, object: &'a str, info: &'a FileInfo) -> Self {
|
||||
Self {
|
||||
bucket,
|
||||
object,
|
||||
etag: info.metadata.get("etag").map(String::as_str),
|
||||
mod_time: info.mod_time,
|
||||
parts: &info.parts,
|
||||
delete_marker: info.deleted,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A pure decision on quorum-validated metadata. Authorization belongs to the caller.
|
||||
pub trait GetObjectReadCondition: Send + Sync {
|
||||
fn is_terminal(&self, metadata: GetObjectReadMetadata<'_>) -> bool;
|
||||
}
|
||||
|
||||
tokio::task_local! {
|
||||
static READ_CONDITION: Arc<dyn GetObjectReadCondition>;
|
||||
}
|
||||
|
||||
/// Applies a condition to this reader call, without inheriting it into shared producers.
|
||||
pub async fn with_get_object_read_condition<F: Future>(condition: Arc<dyn GetObjectReadCondition>, read: F) -> F::Output {
|
||||
READ_CONDITION.scope(condition, read).await
|
||||
}
|
||||
|
||||
pub(crate) fn get_object_read_condition_is_terminal(metadata: GetObjectReadMetadata<'_>) -> bool {
|
||||
READ_CONDITION
|
||||
.try_with(|condition| condition.is_terminal(metadata))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub(crate) fn get_object_read_condition_is_active() -> bool {
|
||||
READ_CONDITION.try_with(|_| ()).is_ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
struct Stop;
|
||||
impl GetObjectReadCondition for Stop {
|
||||
fn is_terminal(&self, _: GetObjectReadMetadata<'_>) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
fn metadata() -> GetObjectReadMetadata<'static> {
|
||||
GetObjectReadMetadata {
|
||||
bucket: "bucket",
|
||||
object: "object",
|
||||
etag: Some("etag"),
|
||||
mod_time: None,
|
||||
parts: &[],
|
||||
delete_marker: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_condition_scope_does_not_escape_or_enter_shared_producers() {
|
||||
assert!(!get_object_read_condition_is_terminal(metadata()));
|
||||
with_get_object_read_condition(Arc::new(Stop), async {
|
||||
assert!(get_object_read_condition_is_terminal(metadata()));
|
||||
assert!(
|
||||
!tokio::spawn(async { get_object_read_condition_is_terminal(metadata()) })
|
||||
.await
|
||||
.expect("shared producer")
|
||||
);
|
||||
})
|
||||
.await;
|
||||
assert!(!get_object_read_condition_is_terminal(metadata()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dropping_a_pending_read_removes_its_condition_scope() {
|
||||
let mut read = Box::pin(with_get_object_read_condition(Arc::new(Stop), async {
|
||||
assert!(get_object_read_condition_is_terminal(metadata()));
|
||||
std::future::pending::<()>().await;
|
||||
}));
|
||||
assert!(futures::poll!(&mut read).is_pending());
|
||||
drop(read);
|
||||
assert!(!get_object_read_condition_is_terminal(metadata()));
|
||||
}
|
||||
}
|
||||
@@ -528,6 +528,14 @@ pub struct GetObjectReader {
|
||||
}
|
||||
|
||||
impl GetObjectReader {
|
||||
pub(crate) fn condition_stopped(object_info: ObjectInfo) -> Self {
|
||||
Self {
|
||||
stream: Box::new(ConditionStoppedBody),
|
||||
object_info,
|
||||
buffered_body: None,
|
||||
body_source: GetObjectBodySource::Unprobed,
|
||||
}
|
||||
}
|
||||
/// Builds a fully materialized reader from a cache-coordinated body.
|
||||
pub fn from_cache_body(mut object_info: ObjectInfo, body: Bytes) -> Result<Self> {
|
||||
object_info.size = i64::try_from(body.len()).map_err(|_| Error::other("cached GET body length exceeds i64::MAX"))?;
|
||||
@@ -552,6 +560,17 @@ impl GetObjectReader {
|
||||
}
|
||||
}
|
||||
|
||||
struct ConditionStoppedBody;
|
||||
|
||||
impl AsyncRead for ConditionStoppedBody {
|
||||
fn poll_read(self: Pin<&mut Self>, _cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll<std::io::Result<()>> {
|
||||
if buf.remaining() == 0 {
|
||||
return Poll::Ready(Ok(()));
|
||||
}
|
||||
Poll::Ready(Err(std::io::Error::other("object read condition must be handled before body access")))
|
||||
}
|
||||
}
|
||||
|
||||
enum ReadTransform {
|
||||
// Written but never read by production code: the enclosing struct already
|
||||
// carries the same pair as `storage_offset`/`storage_length`. They survive
|
||||
|
||||
@@ -938,6 +938,10 @@ pub struct ObjectOptions {
|
||||
/// A genuine target write guard; loss fences alone do not authorize a borrowed write.
|
||||
#[doc(hidden)]
|
||||
pub write_lock_context: Option<WriteLockContext>,
|
||||
/// In-process delete ownership for late filesystem completion. Never
|
||||
/// serialized or copied into object metadata.
|
||||
#[doc(hidden)]
|
||||
pub delete_mutation_owner: Option<Arc<dyn Send + Sync>>,
|
||||
/// Internal read-only inspection must not enqueue metadata or payload repairs.
|
||||
pub suppress_read_repair: bool,
|
||||
/// Control-plane writers that immediately read or CAS the same namespace
|
||||
@@ -1002,6 +1006,9 @@ pub struct ObjectOptions {
|
||||
/// Proves an upper layer holds the bucket lifecycle sentinel. A separate
|
||||
/// fence avoids recursively acquiring the read lock behind a queued writer.
|
||||
pub bucket_lifecycle_lock_fence: Option<NamespaceLockFence>,
|
||||
/// Retains the actual lifecycle guard for an owned multipart commit. A
|
||||
/// lock-loss fence alone does not keep a cancelled caller's lock alive.
|
||||
pub bucket_lifecycle_lock_owner: Option<Arc<rustfs_lock::NamespaceLockGuard>>,
|
||||
pub replication_request: bool,
|
||||
/// True when the inbound request carried the
|
||||
/// `{x-rustfs-,x-minio-}source-proxy-request` header family with the
|
||||
@@ -1158,8 +1165,10 @@ impl std::fmt::Debug for ObjectOptions {
|
||||
.field("http_preconditions", &self.http_preconditions.is_some())
|
||||
.field("delete_replication", &self.delete_replication.is_some())
|
||||
.field("delete_replication_config_snapshot", &self.delete_replication_config_snapshot)
|
||||
.field("delete_mutation_owner", &self.delete_mutation_owner.is_some())
|
||||
.field("namespace_lock_fence", &self.namespace_lock_fence.is_some())
|
||||
.field("bucket_lifecycle_lock_fence", &self.bucket_lifecycle_lock_fence.is_some())
|
||||
.field("bucket_lifecycle_lock_owner", &self.bucket_lifecycle_lock_owner.is_some())
|
||||
.field("replication_request", &self.replication_request)
|
||||
.field("proxy_request", &self.proxy_request)
|
||||
.field("proxy_header_set", &self.proxy_header_set)
|
||||
|
||||
@@ -50,6 +50,7 @@ use s3s::dto::BucketLifecycleConfiguration;
|
||||
use s3s::region::Region;
|
||||
use tokio::sync::{OwnedRwLockReadGuard, RwLock};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
#[cfg(test)]
|
||||
use tonic::transport::Channel;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -251,10 +252,6 @@ pub(crate) async fn root_disk_threshold_for_erasure_disk() -> Option<u64> {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn cached_node_channel(addr: &str) -> Option<Channel> {
|
||||
rustfs_common::cached_connection(addr).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) async fn cache_test_node_channel(addr: String, channel: Channel) {
|
||||
rustfs_common::cache_connection(addr, channel).await;
|
||||
|
||||
@@ -18,8 +18,8 @@ use super::{
|
||||
encode_rebalance_stop_propagation_record,
|
||||
};
|
||||
use crate::core::pools::{
|
||||
PoolMeta, PoolRebalanceActivationFence, acquire_pool_activation_fleet_proof, acquire_pool_rebalance_activation_locks,
|
||||
pool_meta_has_active_decommission,
|
||||
PoolMeta, PoolRebalanceActivationFence, PoolRebalanceWorkerFence, acquire_pool_activation_fleet_proof,
|
||||
acquire_pool_rebalance_activation_locks, acquire_pool_rebalance_worker_locks, pool_meta_has_active_decommission,
|
||||
};
|
||||
use crate::error::{Error, Result};
|
||||
use crate::object_api::ObjectOptions;
|
||||
@@ -77,10 +77,33 @@ fn ensure_rebalance_activation_pool_meta_allowed(meta: &PoolMeta) -> Result<()>
|
||||
}
|
||||
|
||||
pub(super) enum RebalanceWorkerActivationFence {
|
||||
Ready(Box<PoolRebalanceActivationFence>),
|
||||
Ready(Box<PoolRebalanceWorkerFence>),
|
||||
NotStartedTerminal,
|
||||
}
|
||||
|
||||
async fn persisted_rebalance_requires_worker_activation<S>(pool: Arc<S>, expected_id: &str) -> Result<bool>
|
||||
where
|
||||
S: EcstoreObjectIO,
|
||||
{
|
||||
let mut persisted = RebalanceMeta::new();
|
||||
persisted
|
||||
.load_with_opts(
|
||||
pool,
|
||||
ObjectOptions {
|
||||
no_lock: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
if persisted.id != expected_id {
|
||||
return Err(Error::other(format!(
|
||||
"rebalance metadata changed before worker activation: expected {expected_id}, found {}",
|
||||
persisted.id
|
||||
)));
|
||||
}
|
||||
Ok(crate::services::rebalance::rebalance_requires_worker_activation(&persisted))
|
||||
}
|
||||
|
||||
pub(super) struct RebalanceRunGuard {
|
||||
_local_guard: tokio::sync::OwnedRwLockReadGuard<()>,
|
||||
persisted_guard: rustfs_lock::NamespaceLockGuard,
|
||||
@@ -577,48 +600,58 @@ impl ECStore {
|
||||
where
|
||||
S: EcstoreObjectIO + StorageNamespaceLocking<Error = Error, NamespaceLock = rustfs_lock::NamespaceLockWrapper>,
|
||||
{
|
||||
// Lock order: pool_meta_save_gate -> rebalance.bin -> pool.bin.
|
||||
// Lock order: pool_meta_save_gate -> rebalance.bin -> pool.bin. These
|
||||
// shared fences admit a peer without waiting for the run's own entries.
|
||||
let mut pool_meta_guard = self.pool_meta_save_gate.lock().await;
|
||||
pool_meta_guard.ensure_write_safe("rebalance worker activation")?;
|
||||
// Classify the durable rebalance record while holding both namespace
|
||||
// fences. A terminal record is a no-op and must not depend on the
|
||||
// notification subsystem having published a fleet proof yet.
|
||||
let mut activation_fence = acquire_pool_rebalance_activation_locks(pool.clone(), None).await?;
|
||||
let mut worker_fence = acquire_pool_rebalance_worker_locks(pool.clone()).await?;
|
||||
let pool_meta = self
|
||||
.load_runtime_pool_meta_under_activation_fence(&mut pool_meta_guard, &activation_fence, "rebalance worker activation")
|
||||
.load_runtime_pool_meta_under_worker_fence(&mut pool_meta_guard, &worker_fence, "rebalance worker activation")
|
||||
.await?;
|
||||
ensure_rebalance_activation_pool_meta_allowed(&pool_meta)?;
|
||||
|
||||
let mut persisted = RebalanceMeta::new();
|
||||
persisted
|
||||
.load_with_opts(
|
||||
pool,
|
||||
ObjectOptions {
|
||||
no_lock: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
if persisted.id != expected_id {
|
||||
return Err(Error::other(format!(
|
||||
"rebalance metadata changed before worker activation: expected {expected_id}, found {}",
|
||||
persisted.id
|
||||
)));
|
||||
}
|
||||
|
||||
activation_fence.ensure_held()?;
|
||||
if !crate::services::rebalance::rebalance_requires_worker_activation(&persisted) {
|
||||
let needs_activation = persisted_rebalance_requires_worker_activation(pool, expected_id).await?;
|
||||
worker_fence.ensure_held()?;
|
||||
if !needs_activation {
|
||||
return Ok(RebalanceWorkerActivationFence::NotStartedTerminal);
|
||||
}
|
||||
|
||||
// Active worker admission still requires the fail-closed fleet proof.
|
||||
// Attach it immediately before the final fence validation so expiry or
|
||||
// topology changes are checked again at every later commit boundary.
|
||||
let fleet_proof = acquire_pool_activation_fleet_proof(&self.ctx).await?;
|
||||
worker_fence.set_fleet_proof(fleet_proof);
|
||||
worker_fence.ensure_held()?;
|
||||
Ok(RebalanceWorkerActivationFence::Ready(Box::new(worker_fence)))
|
||||
}
|
||||
|
||||
pub(super) async fn fence_rebalance_worker_metadata_update<S>(
|
||||
&self,
|
||||
pool: Arc<S>,
|
||||
expected_id: &str,
|
||||
) -> Result<Option<Box<PoolRebalanceActivationFence>>>
|
||||
where
|
||||
S: EcstoreObjectIO + StorageNamespaceLocking<Error = Error, NamespaceLock = rustfs_lock::NamespaceLockWrapper>,
|
||||
{
|
||||
// The admission read fences must already be dropped. Reacquire in the
|
||||
// original order and revalidate every durable boundary across that gap.
|
||||
let mut pool_meta_guard = self.pool_meta_save_gate.lock().await;
|
||||
pool_meta_guard.ensure_write_safe("rebalance worker metadata update")?;
|
||||
let mut activation_fence = acquire_pool_rebalance_activation_locks(pool.clone(), None).await?;
|
||||
let pool_meta = self
|
||||
.load_runtime_pool_meta_under_activation_fence(
|
||||
&mut pool_meta_guard,
|
||||
&activation_fence,
|
||||
"rebalance worker metadata update",
|
||||
)
|
||||
.await?;
|
||||
ensure_rebalance_activation_pool_meta_allowed(&pool_meta)?;
|
||||
let needs_activation = persisted_rebalance_requires_worker_activation(pool, expected_id).await?;
|
||||
activation_fence.ensure_held()?;
|
||||
if !needs_activation {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let fleet_proof = acquire_pool_activation_fleet_proof(&self.ctx).await?;
|
||||
activation_fence.set_fleet_proof(fleet_proof);
|
||||
activation_fence.ensure_held()?;
|
||||
|
||||
Ok(RebalanceWorkerActivationFence::Ready(Box::new(activation_fence)))
|
||||
Ok(Some(Box::new(activation_fence)))
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip_all)]
|
||||
@@ -1816,6 +1849,107 @@ mod tests {
|
||||
assert_activation_locks_released(&store).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn rebalance_init_missing_fleet_proof_preserves_metadata_and_rechecks_competing_run() {
|
||||
let previous_id = "completed-before-capability-refresh";
|
||||
let terminal = RebalanceMeta {
|
||||
id: previous_id.to_string(),
|
||||
stopped_at: Some(OffsetDateTime::now_utc()),
|
||||
pool_stats: vec![RebalanceStats::default(); 2],
|
||||
..Default::default()
|
||||
};
|
||||
let (_temp_dirs, store, competing_store) = crate::services::rebalance::test_two_pool_stores(Some(terminal.clone())).await;
|
||||
let disk_stats = vec![
|
||||
DiskStat {
|
||||
total_space: 100,
|
||||
available_space: 0,
|
||||
},
|
||||
DiskStat {
|
||||
total_space: 100,
|
||||
available_space: 100,
|
||||
},
|
||||
];
|
||||
let initial_epoch = store.ctx.data_movement_operation_epoch();
|
||||
let proof_guard = crate::services::notification_sys::without_cross_pool_fence_fleet_proof_for_test();
|
||||
set_rebalance_disk_stats_override_for_test(store.id, disk_stats.clone());
|
||||
let err = store
|
||||
.init_rebalance_start(vec!["waiting-request".to_string()])
|
||||
.await
|
||||
.expect_err("initialization must fail closed while the live proof is missing");
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("pool activation requires a live fleet capability proof")
|
||||
);
|
||||
assert!(
|
||||
crate::core::pools::is_pool_activation_fleet_proof_error(&err),
|
||||
"the admin handler retries init only on this error shape: {err}"
|
||||
);
|
||||
assert_eq!(store.ctx.data_movement_operation_epoch(), initial_epoch);
|
||||
{
|
||||
let local = store.rebalance_meta.read().await;
|
||||
let local = local.as_ref().expect("failed initialization must preserve the previous run");
|
||||
assert_eq!(local.id, previous_id);
|
||||
assert_eq!(local.stopped_at, terminal.stopped_at);
|
||||
assert!(local.cancel.is_none(), "failed initialization must not start a worker");
|
||||
}
|
||||
let mut persisted = RebalanceMeta::new();
|
||||
persisted
|
||||
.load(store.pools[0].clone())
|
||||
.await
|
||||
.expect("failed initialization must leave durable metadata readable");
|
||||
assert_eq!(persisted.id, previous_id);
|
||||
assert_eq!(persisted.stopped_at, terminal.stopped_at);
|
||||
|
||||
drop(proof_guard);
|
||||
set_rebalance_disk_stats_override_for_test(competing_store.id, disk_stats.clone());
|
||||
let winning_id = competing_store
|
||||
.init_rebalance_start(vec!["winning-request".to_string()])
|
||||
.await
|
||||
.expect("a ready request must be able to initialize one run after the failed attempt");
|
||||
assert_ne!(winning_id, previous_id);
|
||||
|
||||
// The waiting request still holds its old local snapshot. Its retry must
|
||||
// recheck durable state under the activation locks before publishing.
|
||||
set_rebalance_disk_stats_override_for_test(store.id, disk_stats);
|
||||
let remote_conflict = store
|
||||
.init_rebalance_start(vec!["waiting-request".to_string()])
|
||||
.await
|
||||
.expect_err("the waiting request must not replace a durable competing run");
|
||||
assert!(
|
||||
matches!(
|
||||
crate::data_movement::data_movement_stage_source(&remote_conflict),
|
||||
Some(Error::RebalanceAlreadyRunning)
|
||||
),
|
||||
"the save-stage error must retain the typed competing-run conflict: {remote_conflict:?}"
|
||||
);
|
||||
let local_conflict = competing_store
|
||||
.init_rebalance_start(vec!["another-request".to_string()])
|
||||
.await
|
||||
.expect_err("a local retry must not replace its active run");
|
||||
assert!(
|
||||
matches!(local_conflict, Error::RebalanceAlreadyRunning),
|
||||
"the local admission check must report the competing-run conflict: {local_conflict:?}"
|
||||
);
|
||||
persisted
|
||||
.load(store.pools[0].clone())
|
||||
.await
|
||||
.expect("the competing run must remain durable after both rejected retries");
|
||||
assert_eq!(persisted.id, winning_id);
|
||||
assert_eq!(persisted.pool_stats[0].buckets, vec!["winning-request".to_string()]);
|
||||
assert_eq!(
|
||||
store
|
||||
.rebalance_meta
|
||||
.read()
|
||||
.await
|
||||
.as_ref()
|
||||
.expect("old local run must survive")
|
||||
.id,
|
||||
previous_id
|
||||
);
|
||||
assert_eq!(store.ctx.data_movement_operation_epoch(), initial_epoch + 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn rebalance_worker_skips_terminal_metadata_without_fleet_proof() {
|
||||
@@ -1874,6 +2008,128 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
async fn assert_worker_write_upgrade_revalidates_durable_state(change: &str) {
|
||||
let rebalance_id = "rebalance-worker-write-upgrade";
|
||||
let active = RebalanceMeta {
|
||||
id: rebalance_id.to_string(),
|
||||
percent_free_goal: 0.5,
|
||||
pool_stats: vec![
|
||||
RebalanceStats {
|
||||
participating: true,
|
||||
init_capacity: 100,
|
||||
info: RebalanceInfo {
|
||||
status: RebalStatus::Started,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
},
|
||||
RebalanceStats::default(),
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
let (_temp_dirs, store, peer) =
|
||||
crate::services::rebalance::test_two_pool_stores_with_isolated_node_contexts(Some(active.clone())).await;
|
||||
let arrived = Arc::new(tokio::sync::Notify::new());
|
||||
let release = Arc::new(tokio::sync::Notify::new());
|
||||
let worker = Arc::clone(&store);
|
||||
let mut tasks = tokio::task::JoinSet::new();
|
||||
tasks.spawn(
|
||||
super::super::runtime::REBALANCE_WORKER_WRITE_UPGRADE_BARRIER
|
||||
.scope((Arc::clone(&arrived), Arc::clone(&release)), async move {
|
||||
worker.start_rebalance_for_id(rebalance_id).await
|
||||
}),
|
||||
);
|
||||
tokio::time::timeout(std::time::Duration::from_secs(30), arrived.notified())
|
||||
.await
|
||||
.expect("empty-queue activation must release its read fences before requesting write authority");
|
||||
|
||||
let mut changed = active.clone();
|
||||
match change {
|
||||
"stopped" => {
|
||||
changed.stopped_at = Some(OffsetDateTime::now_utc());
|
||||
changed.pool_stats[0].info.status = RebalStatus::Stopped;
|
||||
}
|
||||
"replaced" => changed.id = "replacement-rebalance-run".to_string(),
|
||||
"decommission" => {
|
||||
let mut pool_meta = peer.pool_meta.read().await.clone();
|
||||
pool_meta.pools[0].decommission = Some(crate::core::pools::PoolDecommissionInfo {
|
||||
start_time: Some(OffsetDateTime::now_utc()),
|
||||
..Default::default()
|
||||
});
|
||||
pool_meta
|
||||
.save(peer.pools.clone())
|
||||
.await
|
||||
.expect("a competing pool metadata writer must acquire the released pool fence");
|
||||
}
|
||||
_ => panic!("unknown durable state transition"),
|
||||
}
|
||||
changed
|
||||
.save_with_opts(
|
||||
peer.pools[0].clone(),
|
||||
ObjectOptions {
|
||||
write_completion: crate::object_api::WriteCompletion::TailDrained,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("a competing run writer must acquire the released rebalance fence");
|
||||
release.notify_one();
|
||||
let result = tokio::time::timeout(std::time::Duration::from_secs(30), tasks.join_next())
|
||||
.await
|
||||
.expect("activation must finish after the durable transition")
|
||||
.expect("activation task must be tracked")
|
||||
.expect("activation must not panic");
|
||||
match change {
|
||||
"stopped" => result.expect("a durably stopped run must become an activation no-op"),
|
||||
"replaced" => assert!(
|
||||
result
|
||||
.expect_err("a replaced run must reject stale activation")
|
||||
.to_string()
|
||||
.contains("expected"),
|
||||
),
|
||||
"decommission" => assert!(matches!(result, Err(Error::DecommissionAlreadyRunning))),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
let mut persisted = RebalanceMeta::new();
|
||||
persisted
|
||||
.load(peer.pools[0].clone())
|
||||
.await
|
||||
.expect("the competing durable state must remain readable");
|
||||
assert_eq!(persisted.id, changed.id, "activation must not overwrite the run identity");
|
||||
assert_eq!(persisted.stopped_at, changed.stopped_at, "activation must not overwrite the stop marker");
|
||||
assert_eq!(persisted.pool_stats[0].info.status, changed.pool_stats[0].info.status);
|
||||
assert!(
|
||||
store
|
||||
.rebalance_meta
|
||||
.read()
|
||||
.await
|
||||
.as_ref()
|
||||
.expect("local metadata must remain present")
|
||||
.cancel
|
||||
.is_none(),
|
||||
"rejected activation must not install a worker token",
|
||||
);
|
||||
assert_activation_locks_released(&store).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn rebalance_worker_write_upgrade_rechecks_stop() {
|
||||
assert_worker_write_upgrade_revalidates_durable_state("stopped").await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn rebalance_worker_write_upgrade_rechecks_run_identity() {
|
||||
assert_worker_write_upgrade_revalidates_durable_state("replaced").await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn rebalance_worker_write_upgrade_rechecks_decommission() {
|
||||
assert_worker_write_upgrade_revalidates_durable_state("decommission").await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn rebalance_activation_adopts_commit_after_post_save_fence_loss() {
|
||||
|
||||
@@ -50,7 +50,7 @@ fn ensure_rebalance_entry_active(cancel: &CancellationToken) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
tokio::task_local! {
|
||||
static REBALANCE_ENTRY_RUN_FENCE_BARRIER: (Arc<tokio::sync::Notify>, Arc<tokio::sync::Notify>);
|
||||
}
|
||||
@@ -268,7 +268,7 @@ impl ECStore {
|
||||
self.rebalance_run_guard(rebalance_id.as_ref(), "rebalance entry")
|
||||
})
|
||||
.await?;
|
||||
#[cfg(test)]
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
if let Ok((arrived, release)) = REBALANCE_ENTRY_RUN_FENCE_BARRIER.try_with(Clone::clone) {
|
||||
arrived.notify_one();
|
||||
release.notified().await;
|
||||
@@ -836,7 +836,8 @@ pub mod test_util {
|
||||
_temp_dirs: Vec<tempfile::TempDir>,
|
||||
store: Arc<ECStore>,
|
||||
cancel: CancellationToken,
|
||||
barrier: crate::data_movement::SourceCleanupDeleteBarrier,
|
||||
entry_arrived: Arc<tokio::sync::Notify>,
|
||||
entry_release: Arc<tokio::sync::Notify>,
|
||||
stop_probe: super::super::control::RebalanceStopWaitProbe,
|
||||
entry_task: Option<tokio::task::JoinHandle<Result<RebalanceEntryOutcome>>>,
|
||||
}
|
||||
@@ -897,31 +898,36 @@ pub mod test_util {
|
||||
cached: Some(file_meta),
|
||||
reusable: false,
|
||||
};
|
||||
let barrier = crate::data_movement::SourceCleanupDeleteBarrier::install(bucket, object);
|
||||
let entry_arrived = Arc::new(tokio::sync::Notify::new());
|
||||
let entry_release = Arc::new(tokio::sync::Notify::new());
|
||||
let entry_store = Arc::clone(&store);
|
||||
let entry_set = Arc::clone(&set);
|
||||
let entry_cancel = cancel.clone();
|
||||
let entry_task = tokio::spawn(async move {
|
||||
entry_store
|
||||
.rebalance_entry(
|
||||
RebalanceEntryTarget {
|
||||
bucket: bucket.to_string(),
|
||||
pool_index: 0,
|
||||
},
|
||||
entry,
|
||||
entry_set,
|
||||
Arc::new(RebalanceBucketConfigs::default()),
|
||||
Arc::from(rebalance_id),
|
||||
entry_cancel,
|
||||
)
|
||||
.await
|
||||
});
|
||||
let entry_task = tokio::spawn(REBALANCE_ENTRY_RUN_FENCE_BARRIER.scope(
|
||||
(Arc::clone(&entry_arrived), Arc::clone(&entry_release)),
|
||||
async move {
|
||||
entry_store
|
||||
.rebalance_entry(
|
||||
RebalanceEntryTarget {
|
||||
bucket: bucket.to_string(),
|
||||
pool_index: 0,
|
||||
},
|
||||
entry,
|
||||
entry_set,
|
||||
Arc::new(RebalanceBucketConfigs::default()),
|
||||
Arc::from(rebalance_id),
|
||||
entry_cancel,
|
||||
)
|
||||
.await
|
||||
},
|
||||
));
|
||||
|
||||
Self {
|
||||
_temp_dirs,
|
||||
store,
|
||||
cancel,
|
||||
barrier,
|
||||
entry_arrived,
|
||||
entry_release,
|
||||
stop_probe: super::super::control::RebalanceStopWaitProbe::install(rebalance_id),
|
||||
entry_task: Some(entry_task),
|
||||
}
|
||||
@@ -932,7 +938,9 @@ pub mod test_util {
|
||||
}
|
||||
|
||||
pub async fn wait_until_entry_paused(&self) {
|
||||
self.barrier.wait_until_paused().await;
|
||||
tokio::time::timeout(std::time::Duration::from_secs(30), self.entry_arrived.notified())
|
||||
.await
|
||||
.expect("the entry should acquire its run fence before pausing");
|
||||
}
|
||||
|
||||
pub async fn wait_until_admission_cancelled(&self) {
|
||||
@@ -946,7 +954,7 @@ pub mod test_util {
|
||||
}
|
||||
|
||||
pub fn release_entry(&self) {
|
||||
self.barrier.release();
|
||||
self.entry_release.notify_one();
|
||||
}
|
||||
|
||||
pub async fn assert_entry_cancelled(&mut self) {
|
||||
@@ -964,7 +972,7 @@ pub mod test_util {
|
||||
|
||||
impl Drop for PausedRebalanceEntryTestFixture {
|
||||
fn drop(&mut self) {
|
||||
self.barrier.release();
|
||||
self.entry_release.notify_one();
|
||||
if let Some(task) = self.entry_task.take() {
|
||||
task.abort();
|
||||
}
|
||||
@@ -1432,7 +1440,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn real_rebalance_entry_progresses_while_peer_activation_waits_for_run_fence() {
|
||||
async fn real_rebalance_peer_activates_while_entry_retains_run_fence() {
|
||||
const REBALANCE_ID: &str = "rebalance-peer-activation-lock-order";
|
||||
let (_temp_dirs, store, peer) = crate::services::rebalance::test_two_pool_stores_with_isolated_node_contexts(Some(
|
||||
active_rebalance_meta(REBALANCE_ID),
|
||||
@@ -1448,7 +1456,7 @@ mod tests {
|
||||
let bucket = crate::disk::RUSTFS_META_BUCKET;
|
||||
let object = "rebalance-peer-activation-object";
|
||||
let version_id = uuid::Uuid::new_v4();
|
||||
let payload = b"entry must drain before peer activation takes the pool fence".repeat(1024);
|
||||
let payload = b"peer activation must not wait for this entry to drain".repeat(1024);
|
||||
let source_set = store.pools[0].get_disks_by_key(object);
|
||||
let target_set = store.pools[1].get_disks_by_key(object);
|
||||
let opts = ObjectOptions {
|
||||
@@ -1508,7 +1516,12 @@ mod tests {
|
||||
);
|
||||
tokio::time::timeout(StdDuration::from_secs(30), attempted.notified())
|
||||
.await
|
||||
.expect("peer activation must attempt the persisted rebalance write fence");
|
||||
.expect("peer activation must attempt the persisted rebalance fence");
|
||||
tokio::time::timeout(StdDuration::from_secs(30), activation_result)
|
||||
.await
|
||||
.expect("peer activation must finish while the entry remains paused")
|
||||
.expect("peer activation result should be sent")
|
||||
.expect("joining the same run must not require its active entry to release the run fence");
|
||||
release.notify_one();
|
||||
|
||||
tokio::time::timeout(StdDuration::from_secs(30), async {
|
||||
@@ -1523,10 +1536,6 @@ mod tests {
|
||||
})
|
||||
.await
|
||||
.expect("entry and peer activation must both make progress");
|
||||
activation_result
|
||||
.await
|
||||
.expect("peer activation result should be sent")
|
||||
.expect("peer activation must not time out behind the entry it blocks");
|
||||
|
||||
let mut reader = target_set
|
||||
.get_object_reader(bucket, object, None, HeaderMap::new(), &opts)
|
||||
|
||||
@@ -27,6 +27,11 @@ use tokio::time::{Duration, Instant};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
#[cfg(test)]
|
||||
tokio::task_local! {
|
||||
pub(super) static REBALANCE_WORKER_WRITE_UPGRADE_BARRIER: (Arc<tokio::sync::Notify>, Arc<tokio::sync::Notify>);
|
||||
}
|
||||
|
||||
pub(super) fn should_fail_repeated_rebalance_bucket_defer(
|
||||
deferred_buckets: &mut HashSet<String>,
|
||||
bucket: &str,
|
||||
@@ -213,6 +218,22 @@ impl ECStore {
|
||||
}
|
||||
|
||||
if must_persist {
|
||||
// Shared admission grants no write authority. Release it before
|
||||
// taking the exclusive metadata fences, then recheck stop/run/pool
|
||||
// state before publishing the locally staged terminal candidate.
|
||||
drop(activation_fence);
|
||||
#[cfg(test)]
|
||||
if let Ok((arrived, release)) = REBALANCE_WORKER_WRITE_UPGRADE_BARRIER.try_with(Clone::clone) {
|
||||
arrived.notify_one();
|
||||
release.notified().await;
|
||||
}
|
||||
let Some(activation_fence) = self
|
||||
.fence_rebalance_worker_metadata_update(pool.clone(), expected_id.as_ref())
|
||||
.await?
|
||||
else {
|
||||
cancel_tx.cancel();
|
||||
return Ok(false);
|
||||
};
|
||||
let save_result = resolve_rebalance_meta_save_result(
|
||||
self.save_rebalance_meta_under_activation_fence(
|
||||
pool,
|
||||
@@ -242,13 +263,14 @@ impl ECStore {
|
||||
cancel_tx.cancel();
|
||||
return Err(err);
|
||||
}
|
||||
} else {
|
||||
if let Err(err) = activation_fence.ensure_held() {
|
||||
let mut rebalance_meta = self.rebalance_meta.write().await;
|
||||
rollback_local_rebalance_worker_activation(rebalance_meta.as_mut(), expected_id.as_ref(), &rx);
|
||||
return Err(err);
|
||||
}
|
||||
drop(activation_fence);
|
||||
}
|
||||
if !must_persist && let Err(err) = activation_fence.ensure_held() {
|
||||
let mut rebalance_meta = self.rebalance_meta.write().await;
|
||||
rollback_local_rebalance_worker_activation(rebalance_meta.as_mut(), expected_id.as_ref(), &rx);
|
||||
return Err(err);
|
||||
}
|
||||
drop(activation_fence);
|
||||
|
||||
if activation_outcome != RebalanceLocalActivationOutcome::Started {
|
||||
return Ok(must_persist);
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
// 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.
|
||||
|
||||
//! Test-util-only controls for the paired read benchmark's dedicated bucket.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
static ACTIVE: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// Keeps metadata off the cache in the benchmark's isolated bucket so GET
|
||||
/// metadata reads exercise the configured slow disk. Body-cache policy
|
||||
/// remains intact.
|
||||
#[must_use]
|
||||
pub struct ConditionalReadBenchmarkMetadataGuard {
|
||||
_private: (),
|
||||
}
|
||||
|
||||
impl ConditionalReadBenchmarkMetadataGuard {
|
||||
pub fn acquire() -> Self {
|
||||
assert!(
|
||||
ACTIVE
|
||||
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
|
||||
.is_ok()
|
||||
);
|
||||
Self { _private: () }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ConditionalReadBenchmarkMetadataGuard {
|
||||
fn drop(&mut self) {
|
||||
ACTIVE.store(false, Ordering::Release);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn cache_bypass_applies(bucket: &str) -> bool {
|
||||
bucket == "conditional-bench" && ACTIVE.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn benchmark_metadata_profile_is_scoped_to_its_bucket_and_owner() {
|
||||
assert!(!cache_bypass_applies("conditional-bench"));
|
||||
let owner = ConditionalReadBenchmarkMetadataGuard::acquire();
|
||||
assert!(cache_bypass_applies("conditional-bench"));
|
||||
assert!(!cache_bypass_applies("other-bucket"));
|
||||
drop(owner);
|
||||
assert!(!cache_bypass_applies("conditional-bench"));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -66,6 +66,7 @@ use tokio::task::JoinSet;
|
||||
pub(in crate::set_disk) struct MetadataReadResult {
|
||||
pub(in crate::set_disk) slots: Vec<MetadataDiskObservation>,
|
||||
pub(in crate::set_disk) diagnostics: MetadataFanoutDiagnostics,
|
||||
pub(in crate::set_disk) condition_stopped: bool,
|
||||
}
|
||||
|
||||
impl MetadataReadResult {
|
||||
@@ -575,6 +576,7 @@ impl MetadataFanoutDiagnostics {
|
||||
|
||||
pub(in crate::set_disk) fn record_quorum_candidate_latency(&self, path: &'static str, read_quorum: usize) {
|
||||
if let Some(latency) = self.quorum_candidate_latency(read_quorum) {
|
||||
rustfs_io_metrics::record_get_object_metadata_quorum_result(path, "reached");
|
||||
rustfs_io_metrics::record_get_object_quorum_reached_latency(path, latency.as_secs_f64());
|
||||
}
|
||||
}
|
||||
@@ -655,6 +657,8 @@ async fn inline_body_miss_reason_from_observations<'a>(
|
||||
return Some(GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_BODY_VERIFY);
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
crate::set_disk::disk_call_counters::record(object, crate::set_disk::disk_call_counters::KIND_BODY_DECODE, 0);
|
||||
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),
|
||||
@@ -1078,7 +1082,11 @@ impl SetDisks {
|
||||
0,
|
||||
);
|
||||
}
|
||||
Ok(MetadataReadResult { slots, diagnostics })
|
||||
Ok(MetadataReadResult {
|
||||
slots,
|
||||
diagnostics,
|
||||
condition_stopped: false,
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -1121,6 +1129,7 @@ impl SetDisks {
|
||||
let mut next_fanout_index = 0usize;
|
||||
let mut scheduled_count = 0usize;
|
||||
let mut force_full_wait = false;
|
||||
let read_condition_active = read_data && crate::object_api::get_object_read_condition_is_active();
|
||||
let mut final_miss_reason_override = None;
|
||||
let mut non_inline_candidate_eligible = None;
|
||||
let mut single_pending_hedge_deadline = None;
|
||||
@@ -1258,12 +1267,28 @@ impl SetDisks {
|
||||
}
|
||||
}
|
||||
|
||||
if !force_full_wait
|
||||
if (!force_full_wait || read_condition_active)
|
||||
&& let Some(decision) = accumulator
|
||||
.early_stop_decision()
|
||||
.or_else(|| accumulator.version_early_stop_decision())
|
||||
{
|
||||
let should_return_early = if read_data {
|
||||
let condition_stopped = read_condition_active
|
||||
&& decision.reason == crate::diagnostics::get::GET_METADATA_EARLY_STOP_REASON_VALID_QUORUM
|
||||
&& accumulator.candidate.as_ref().is_some_and(|candidate| {
|
||||
!candidate.deleted
|
||||
&& crate::object_api::get_object_read_condition_is_terminal(
|
||||
crate::object_api::GetObjectReadMetadata::from_file_info(
|
||||
bucket.as_ref(),
|
||||
object.as_ref(),
|
||||
candidate,
|
||||
),
|
||||
)
|
||||
});
|
||||
let should_return_early = if condition_stopped {
|
||||
true
|
||||
} else if force_full_wait {
|
||||
false
|
||||
} else if read_data {
|
||||
match accumulator.candidate.as_ref() {
|
||||
Some(_candidate) if non_inline_candidate_eligible == Some(true) => {
|
||||
accumulator.candidate_has_read_reserve()
|
||||
@@ -1332,7 +1357,11 @@ impl SetDisks {
|
||||
cancelled_count,
|
||||
);
|
||||
let diagnostics = MetadataFanoutDiagnostics::new(fanout_start.elapsed(), observations);
|
||||
return Ok(MetadataReadResult { slots, diagnostics });
|
||||
return Ok(MetadataReadResult {
|
||||
slots,
|
||||
diagnostics,
|
||||
condition_stopped,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1410,7 +1439,11 @@ impl SetDisks {
|
||||
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);
|
||||
Ok(MetadataReadResult { slots, diagnostics })
|
||||
Ok(MetadataReadResult {
|
||||
slots,
|
||||
diagnostics,
|
||||
condition_stopped: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Test-only seam that records one per-disk `read_version` metadata RPC for
|
||||
|
||||
@@ -166,7 +166,7 @@ use sha2::Sha256;
|
||||
use std::hash::{BuildHasher, Hash, Hasher};
|
||||
use std::mem::{self};
|
||||
use std::pin::Pin;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, OnceLock};
|
||||
use std::task::{Context, Poll};
|
||||
use std::time::{Instant, SystemTime, UNIX_EPOCH};
|
||||
@@ -410,6 +410,12 @@ impl Drop for ObjectLockDiagGuard {
|
||||
}
|
||||
|
||||
let hold = self.acquired_at.elapsed();
|
||||
if self.op == "put_object_commit" && self.mode == "write" && rustfs_io_metrics::put_stage_metrics_enabled() {
|
||||
rustfs_io_metrics::record_put_object_stage_duration(
|
||||
rustfs_io_metrics::PUT_STAGE_PUT_OBJECT_COMMIT_NAMESPACE_LOCK_HELD,
|
||||
hold.as_secs_f64() * 1000.0,
|
||||
);
|
||||
}
|
||||
record_object_lock_diag_hold_duration(self.op, self.mode, hold);
|
||||
let threshold = get_object_lock_diag_slow_hold_threshold();
|
||||
if hold >= threshold {
|
||||
@@ -793,6 +799,11 @@ const DEFAULT_RUSTFS_GET_METADATA_TWO_PHASE_READ_PLAN_ENABLE: bool = false;
|
||||
const ENV_RUSTFS_GET_METADATA_EARLY_STOP_BOUNDED_FANOUT: &str = "RUSTFS_GET_METADATA_EARLY_STOP_BOUNDED_FANOUT";
|
||||
const DEFAULT_RUSTFS_GET_METADATA_EARLY_STOP_BOUNDED_FANOUT: bool = true;
|
||||
|
||||
#[cfg(feature = "test-util")]
|
||||
mod conditional_read_bench;
|
||||
#[cfg(feature = "test-util")]
|
||||
pub use conditional_read_bench::ConditionalReadBenchmarkMetadataGuard;
|
||||
|
||||
const ENV_RUSTFS_GET_METADATA_SLOWTAIL_FAULT_DELAY_MS: &str = "RUSTFS_GET_METADATA_SLOWTAIL_FAULT_DELAY_MS";
|
||||
const ENV_RUSTFS_GET_METADATA_SLOWTAIL_FAULT_DISKS: &str = "RUSTFS_GET_METADATA_SLOWTAIL_FAULT_DISKS";
|
||||
const ENV_RUSTFS_GET_METADATA_SLOWTAIL_FAULT_BUCKET: &str = "RUSTFS_GET_METADATA_SLOWTAIL_FAULT_BUCKET";
|
||||
@@ -874,6 +885,7 @@ mod ctx;
|
||||
mod metadata;
|
||||
mod ops;
|
||||
pub(crate) use ops::bucket::BucketInfoQuorum;
|
||||
pub(crate) use ops::delete_recovery::{DeleteRecoveryContext, DeleteRecoverySummary};
|
||||
pub(crate) use ops::heal::AbsenceProofRequest;
|
||||
#[cfg(test)]
|
||||
pub(crate) use ops::heal::DanglingDeleteFailure;
|
||||
@@ -918,16 +930,22 @@ pub use ops::heal_walk::HealWalkVersion;
|
||||
pub(in crate::set_disk) struct GetObjectFileInfo {
|
||||
owned: Option<OwnedGetObjectFileInfo>,
|
||||
shared: Option<Arc<GetObjectMetadataCacheEntry>>,
|
||||
condition_stopped: bool,
|
||||
late_metadata_fanout: Option<LateMetadataFanout>,
|
||||
}
|
||||
|
||||
struct OwnedGetObjectFileInfo {
|
||||
fi: FileInfo,
|
||||
parts_metadata: Vec<FileInfo>,
|
||||
online_disks: Vec<Option<DiskStore>>,
|
||||
late_metadata_fanout_disks: Option<Vec<Option<DiskStore>>>,
|
||||
}
|
||||
|
||||
type OwnedGetObjectFileInfoParts = (FileInfo, Vec<FileInfo>, Vec<Option<DiskStore>>, Option<Vec<Option<DiskStore>>>);
|
||||
enum LateMetadataFanout {
|
||||
CapturedDisks(Vec<Option<DiskStore>>),
|
||||
RefreshCurrentSet,
|
||||
}
|
||||
|
||||
type OwnedGetObjectFileInfoParts = (FileInfo, Vec<FileInfo>, Vec<Option<DiskStore>>, Option<LateMetadataFanout>);
|
||||
|
||||
impl GetObjectFileInfo {
|
||||
fn owned(fi: FileInfo, parts_metadata: Vec<FileInfo>, online_disks: Vec<Option<DiskStore>>) -> Self {
|
||||
@@ -936,9 +954,10 @@ impl GetObjectFileInfo {
|
||||
fi,
|
||||
parts_metadata,
|
||||
online_disks,
|
||||
late_metadata_fanout_disks: None,
|
||||
}),
|
||||
shared: None,
|
||||
condition_stopped: false,
|
||||
late_metadata_fanout: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -948,24 +967,27 @@ impl GetObjectFileInfo {
|
||||
online_disks: Vec<Option<DiskStore>>,
|
||||
late_metadata_fanout_disks: Vec<Option<DiskStore>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
owned: Some(OwnedGetObjectFileInfo {
|
||||
fi,
|
||||
parts_metadata,
|
||||
online_disks,
|
||||
late_metadata_fanout_disks: Some(late_metadata_fanout_disks),
|
||||
}),
|
||||
shared: None,
|
||||
}
|
||||
let mut snapshot = Self::owned(fi, parts_metadata, online_disks);
|
||||
snapshot.late_metadata_fanout = Some(LateMetadataFanout::CapturedDisks(late_metadata_fanout_disks));
|
||||
snapshot
|
||||
}
|
||||
|
||||
fn shared(entry: Arc<GetObjectMetadataCacheEntry>) -> Self {
|
||||
let condition_stopped = entry.capability == GetObjectMetadataCacheCapability::ConditionOnly;
|
||||
Self {
|
||||
owned: None,
|
||||
shared: Some(entry),
|
||||
condition_stopped,
|
||||
late_metadata_fanout: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn shared_with_late_metadata_refresh(entry: Arc<GetObjectMetadataCacheEntry>) -> Self {
|
||||
let mut snapshot = Self::shared(entry);
|
||||
snapshot.late_metadata_fanout = Some(LateMetadataFanout::RefreshCurrentSet);
|
||||
snapshot
|
||||
}
|
||||
|
||||
fn fi(&self) -> &FileInfo {
|
||||
match (&self.owned, &self.shared) {
|
||||
(Some(snapshot), None) => &snapshot.fi,
|
||||
@@ -991,9 +1013,7 @@ impl GetObjectFileInfo {
|
||||
}
|
||||
|
||||
fn has_late_metadata_fanout(&self) -> bool {
|
||||
self.owned
|
||||
.as_ref()
|
||||
.is_some_and(|snapshot| snapshot.late_metadata_fanout_disks.is_some())
|
||||
self.late_metadata_fanout.is_some()
|
||||
}
|
||||
|
||||
fn into_owned(self) -> (FileInfo, Vec<FileInfo>, Vec<Option<DiskStore>>) {
|
||||
@@ -1002,16 +1022,22 @@ impl GetObjectFileInfo {
|
||||
}
|
||||
|
||||
fn into_owned_with_late_metadata_fanout(self) -> OwnedGetObjectFileInfoParts {
|
||||
match (self.owned, self.shared) {
|
||||
(Some(snapshot), None) => (
|
||||
snapshot.fi,
|
||||
snapshot.parts_metadata,
|
||||
snapshot.online_disks,
|
||||
snapshot.late_metadata_fanout_disks,
|
||||
),
|
||||
let Self {
|
||||
owned,
|
||||
shared,
|
||||
late_metadata_fanout,
|
||||
..
|
||||
} = self;
|
||||
match (owned, shared) {
|
||||
(Some(snapshot), None) => (snapshot.fi, snapshot.parts_metadata, snapshot.online_disks, late_metadata_fanout),
|
||||
(None, Some(entry)) => match Arc::try_unwrap(entry) {
|
||||
Ok(entry) => (entry.fi, entry.parts_metadata, entry.online_disks, None),
|
||||
Err(entry) => (entry.fi.clone(), entry.parts_metadata.clone(), entry.online_disks.clone(), None),
|
||||
Ok(entry) => (entry.fi, entry.parts_metadata, entry.online_disks, late_metadata_fanout),
|
||||
Err(entry) => (
|
||||
entry.fi.clone(),
|
||||
entry.parts_metadata.clone(),
|
||||
entry.online_disks.clone(),
|
||||
late_metadata_fanout,
|
||||
),
|
||||
},
|
||||
_ => unreachable!("GET metadata snapshot representation must be exclusive"),
|
||||
}
|
||||
@@ -1143,6 +1169,248 @@ mod prepared_get_object_metadata_tests {
|
||||
assert!(take_prepared_get_object_metadata().is_none());
|
||||
}
|
||||
|
||||
struct StopReadForEtag(String);
|
||||
impl crate::object_api::GetObjectReadCondition for StopReadForEtag {
|
||||
fn is_terminal(&self, metadata: crate::object_api::GetObjectReadMetadata<'_>) -> bool {
|
||||
!metadata.delete_marker && metadata.etag == Some(self.0.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial(body_cache_hook)]
|
||||
async fn read_condition_stops_on_quorum_before_slow_disk_and_body_verification() {
|
||||
use crate::object_api::with_get_object_read_condition;
|
||||
for size in [4096, 1_300_000] {
|
||||
let (dirs, set) = make_local_set_disks(4, 2).await;
|
||||
let bucket = "read-condition-quorum";
|
||||
let object = object_with_initial_data_shards(bucket, &format!("read-condition-{size}"));
|
||||
let opts = ObjectOptions {
|
||||
no_lock: true,
|
||||
..Default::default()
|
||||
};
|
||||
set.make_bucket(bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("create bucket");
|
||||
let mut put = PutObjReader::from_vec(vec![b'v'; size]);
|
||||
let info = set.put_object(bucket, &object, &mut put, &opts).await.expect("write object");
|
||||
for dir in &dirs {
|
||||
let object_path = dir.path().join(bucket).join(&object);
|
||||
if size == 4096 {
|
||||
let path = object_path.join("xl.meta");
|
||||
let mut bytes = tokio::fs::read(&path).await.expect("read inline metadata");
|
||||
*bytes.last_mut().expect("inline payload") ^= 1;
|
||||
tokio::fs::write(path, bytes).await.expect("damage inline data");
|
||||
} else {
|
||||
tokio::fs::remove_dir_all(object_path.join(info.data_dir.expect("data directory").to_string()))
|
||||
.await
|
||||
.expect("remove shards");
|
||||
}
|
||||
}
|
||||
let slow = bounded_initial_parity_disk_index(bucket, &object);
|
||||
let barrier = rename_fanout_barrier::arm(&object, slow, rename_fanout_barrier::PHASE_READ_VERSION);
|
||||
let calls = disk_call_counters::observe(&object);
|
||||
let read_opts = ObjectOptions {
|
||||
metadata_cache_safe: true,
|
||||
..opts
|
||||
};
|
||||
let followup_opts = read_opts.clone();
|
||||
let condition = Arc::new(StopReadForEtag(info.etag.clone().expect("committed ETag")));
|
||||
let set_for_read = Arc::clone(&set);
|
||||
let object_for_read = object.clone();
|
||||
let mut read = tokio::spawn(async move {
|
||||
with_get_object_read_condition(
|
||||
condition,
|
||||
set_for_read.get_object_reader(bucket, &object_for_read, None, HeaderMap::new(), &read_opts),
|
||||
)
|
||||
.await
|
||||
});
|
||||
tokio::time::timeout(READ_VERSION_BARRIER_GUARD, barrier.wait_until_paused())
|
||||
.await
|
||||
.expect("slow metadata read is pending");
|
||||
let mut reader = tokio::time::timeout(READ_VERSION_BARRIER_GUARD, &mut read)
|
||||
.await
|
||||
.expect("condition must not wait for the slow disk or decode damaged data")
|
||||
.expect("reader task")
|
||||
.expect("metadata-only result");
|
||||
assert_eq!(reader.object_info.etag, info.etag);
|
||||
assert_eq!(
|
||||
calls.total(disk_call_counters::KIND_INLINE_SHARD_OPEN),
|
||||
0,
|
||||
"damaged inline data was never opened"
|
||||
);
|
||||
assert_eq!(
|
||||
calls.total(disk_call_counters::KIND_DISK_SHARD_OPEN),
|
||||
0,
|
||||
"missing shards were never opened"
|
||||
);
|
||||
assert_eq!(
|
||||
calls.total(disk_call_counters::KIND_BODY_DECODE),
|
||||
0,
|
||||
"the body verifier and decoder were never entered"
|
||||
);
|
||||
let mut body = Vec::new();
|
||||
assert!(
|
||||
reader.stream.read_to_end(&mut body).await.is_err(),
|
||||
"terminal metadata cannot be served as a successful body"
|
||||
);
|
||||
assert!(body.is_empty());
|
||||
let first_calls = calls.total(disk_call_counters::KIND_READ_VERSION);
|
||||
drop(reader);
|
||||
barrier.release();
|
||||
let snapshot = set
|
||||
.get_object_fileinfo_for_get_object_reader(bucket, &object, &followup_opts, true, true)
|
||||
.await
|
||||
.expect("fresh metadata after terminal scope");
|
||||
assert!(!snapshot.condition_stopped);
|
||||
assert!(
|
||||
calls.total(disk_call_counters::KIND_READ_VERSION) > first_calls,
|
||||
"an incomplete terminal snapshot must not satisfy the next metadata read from cache"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial(body_cache_hook)]
|
||||
async fn read_condition_skips_shard_open_and_decode_with_live_positive_controls() {
|
||||
for size in [4096, 1_300_000] {
|
||||
let (_dirs, set) = make_local_set_disks(4, 2).await;
|
||||
let bucket = "read-condition-body-counters";
|
||||
let object = object_with_initial_data_shards(bucket, &format!("body-counters-{size}"));
|
||||
let opts = ObjectOptions {
|
||||
no_lock: true,
|
||||
..Default::default()
|
||||
};
|
||||
let payload: Vec<_> = (0..size)
|
||||
.map(|index| u8::try_from(index % 251).expect("pattern byte"))
|
||||
.collect();
|
||||
set.make_bucket(bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("create bucket");
|
||||
let mut put = PutObjReader::from_vec(payload.clone());
|
||||
let info = set.put_object(bucket, &object, &mut put, &opts).await.expect("write object");
|
||||
let calls = disk_call_counters::observe(&object);
|
||||
let reader = crate::object_api::with_get_object_read_condition(
|
||||
Arc::new(StopReadForEtag(info.etag.expect("committed ETag"))),
|
||||
set.get_object_reader(bucket, &object, None, HeaderMap::new(), &opts),
|
||||
)
|
||||
.await
|
||||
.expect("terminal metadata reader");
|
||||
drop(reader);
|
||||
assert!(calls.total(disk_call_counters::KIND_READ_VERSION) > 0, "metadata really was read");
|
||||
assert_eq!(
|
||||
calls.total(disk_call_counters::KIND_INLINE_SHARD_OPEN),
|
||||
0,
|
||||
"no inline shard source opened"
|
||||
);
|
||||
assert_eq!(calls.total(disk_call_counters::KIND_DISK_SHARD_OPEN), 0, "no disk shard source opened");
|
||||
assert_eq!(
|
||||
calls.total(disk_call_counters::KIND_BODY_DECODE),
|
||||
0,
|
||||
"no body verifier or decoder entered"
|
||||
);
|
||||
|
||||
let mut reader = crate::object_api::with_get_object_read_condition(
|
||||
Arc::new(StopReadForEtag("different-etag".to_owned())),
|
||||
set.get_object_reader(bucket, &object, None, HeaderMap::new(), &opts),
|
||||
)
|
||||
.await
|
||||
.expect("unmatched condition opens the intact object");
|
||||
let mut body = Vec::new();
|
||||
reader.stream.read_to_end(&mut body).await.expect("positive-control body");
|
||||
assert_eq!(body, payload);
|
||||
let source_kind = if size == 4096 {
|
||||
disk_call_counters::KIND_INLINE_SHARD_OPEN
|
||||
} else {
|
||||
disk_call_counters::KIND_DISK_SHARD_OPEN
|
||||
};
|
||||
assert!(
|
||||
calls.total(source_kind) > 0,
|
||||
"the same source counter observes the positive control: size={size}, inline={}, disk={}, decode={}",
|
||||
calls.total(disk_call_counters::KIND_INLINE_SHARD_OPEN),
|
||||
calls.total(disk_call_counters::KIND_DISK_SHARD_OPEN),
|
||||
calls.total(disk_call_counters::KIND_BODY_DECODE),
|
||||
);
|
||||
assert!(
|
||||
calls.total(disk_call_counters::KIND_BODY_DECODE) > 0,
|
||||
"the same decode counter observes the positive control"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial(body_cache_hook)]
|
||||
async fn read_condition_rechecks_selected_metadata_before_body_access() {
|
||||
struct Changing(std::sync::atomic::AtomicUsize);
|
||||
impl crate::object_api::GetObjectReadCondition for Changing {
|
||||
fn is_terminal(&self, _: crate::object_api::GetObjectReadMetadata<'_>) -> bool {
|
||||
self.0.fetch_add(1, std::sync::atomic::Ordering::Relaxed) == 0
|
||||
}
|
||||
}
|
||||
let (_dirs, set) = make_local_set_disks(4, 2).await;
|
||||
let bucket = "read-condition-consistency";
|
||||
let object = object_with_initial_data_shards(bucket, "read-condition-consistency");
|
||||
let opts = ObjectOptions {
|
||||
no_lock: true,
|
||||
..Default::default()
|
||||
};
|
||||
set.make_bucket(bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("create bucket");
|
||||
let mut put = PutObjReader::from_vec(vec![b'x'; 4096]);
|
||||
set.put_object(bucket, &object, &mut put, &opts).await.expect("write object");
|
||||
let result = crate::object_api::with_get_object_read_condition(
|
||||
Arc::new(Changing(std::sync::atomic::AtomicUsize::new(0))),
|
||||
set.get_object_reader(bucket, &object, None, HeaderMap::new(), &opts),
|
||||
)
|
||||
.await;
|
||||
assert!(result.is_err(), "an unverified terminal snapshot must never become a 200 reader");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial(body_cache_hook)]
|
||||
async fn read_condition_miss_preserves_inline_early_stop_and_exact_body() {
|
||||
let (_dirs, set) = make_local_set_disks(4, 2).await;
|
||||
let bucket = "read-condition-miss";
|
||||
let object = object_with_initial_data_shards(bucket, "read-condition-miss");
|
||||
let opts = ObjectOptions {
|
||||
no_lock: true,
|
||||
..Default::default()
|
||||
};
|
||||
let payload: Vec<u8> = (0..8192)
|
||||
.map(|index| u8::try_from(index % 251).expect("pattern byte"))
|
||||
.collect();
|
||||
set.make_bucket(bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("create bucket");
|
||||
let mut put = PutObjReader::from_vec(payload.clone());
|
||||
set.put_object(bucket, &object, &mut put, &opts)
|
||||
.await
|
||||
.expect("write inline object");
|
||||
let slow = bounded_initial_parity_disk_index(bucket, &object);
|
||||
let barrier = rename_fanout_barrier::arm(&object, slow, rename_fanout_barrier::PHASE_READ_VERSION);
|
||||
let set_for_read = Arc::clone(&set);
|
||||
let object_for_read = object.clone();
|
||||
let mut read = tokio::spawn(async move {
|
||||
crate::object_api::with_get_object_read_condition(
|
||||
Arc::new(StopReadForEtag("other".to_owned())),
|
||||
set_for_read.get_object_reader(bucket, &object_for_read, None, HeaderMap::new(), &opts),
|
||||
)
|
||||
.await
|
||||
});
|
||||
tokio::time::timeout(READ_VERSION_BARRIER_GUARD, barrier.wait_until_paused())
|
||||
.await
|
||||
.expect("slow metadata read is pending");
|
||||
let mut reader = tokio::time::timeout(READ_VERSION_BARRIER_GUARD, &mut read)
|
||||
.await
|
||||
.expect("condition miss preserves the normal early stop")
|
||||
.expect("reader task")
|
||||
.expect("reader");
|
||||
let mut bytes = Vec::new();
|
||||
reader.stream.read_to_end(&mut bytes).await.expect("normal body");
|
||||
assert_eq!(bytes, payload);
|
||||
barrier.release();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_hit_consumers_release_snapshot_at_legacy_ownership() {
|
||||
let fi = FileInfo {
|
||||
@@ -1151,6 +1419,7 @@ mod prepared_get_object_metadata_tests {
|
||||
};
|
||||
let cached = Arc::new(GetObjectMetadataCacheEntry {
|
||||
created_at: Instant::now(),
|
||||
capability: GetObjectMetadataCacheCapability::Full,
|
||||
parts_metadata: vec![fi.clone()],
|
||||
fi,
|
||||
online_disks: vec![None],
|
||||
@@ -1159,11 +1428,11 @@ mod prepared_get_object_metadata_tests {
|
||||
let snapshot = GetObjectFileInfo::shared(Arc::clone(&cached));
|
||||
|
||||
assert!(snapshot.has_valid_representation());
|
||||
assert!(std::mem::size_of::<GetObjectFileInfo>() >= std::mem::size_of::<OwnedGetObjectFileInfo>());
|
||||
assert!(
|
||||
std::mem::size_of::<GetObjectFileInfo>()
|
||||
<= std::mem::size_of::<OwnedGetObjectFileInfo>() + 2 * std::mem::size_of::<usize>()
|
||||
);
|
||||
// Keep the original payload budget after moving the captured disks out
|
||||
// of OwnedGetObjectFileInfo.
|
||||
let owned_payload_size = std::mem::size_of::<(OwnedGetObjectFileInfo, Option<Vec<Option<DiskStore>>>)>();
|
||||
assert!(std::mem::size_of::<GetObjectFileInfo>() >= owned_payload_size);
|
||||
assert!(std::mem::size_of::<GetObjectFileInfo>() <= owned_payload_size + 2 * std::mem::size_of::<usize>());
|
||||
assert_eq!(Arc::strong_count(&cached), 2, "a cache hit must add one snapshot reference");
|
||||
assert_eq!(snapshot.fi().name, "object");
|
||||
assert_eq!(snapshot.parts_metadata().len(), 1);
|
||||
@@ -4083,6 +4352,9 @@ pub struct SetDisks {
|
||||
/// writes skip the global registry mutex (backlog#1315). `Arc` so clones of
|
||||
/// a set share one generation marker.
|
||||
capacity_dirty_generation: Arc<AtomicU64>,
|
||||
/// Per-set proof that successful rename_data peers return the old-current
|
||||
/// source capability needed before PUT may skip its pre-rename lookup.
|
||||
tier_free_version_rename_data_source_capable: Arc<AtomicBool>,
|
||||
/// Orphan prefixes whose last purge scan met data that can never be
|
||||
/// purged by listing (residue without a committed marker, an in-flight
|
||||
/// write), keyed by `bucket/prefix` with the time of that scan. Empty
|
||||
@@ -4338,6 +4610,14 @@ struct GetObjectMetadataCacheKey {
|
||||
hash: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct GetObjectMetadataCacheLookupKey<'a> {
|
||||
bucket: &'a str,
|
||||
object: &'a str,
|
||||
generation: u64,
|
||||
hash: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
struct GetObjectMetadataCacheGeneration {
|
||||
index: usize,
|
||||
@@ -4426,6 +4706,33 @@ impl GetObjectMetadataCacheKey {
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> GetObjectMetadataCacheLookupKey<'a> {
|
||||
fn new(bucket: &'a str, object: &'a str, generation: GetObjectMetadataCacheGeneration) -> Self {
|
||||
Self {
|
||||
bucket,
|
||||
object,
|
||||
generation: generation.value,
|
||||
hash: generation.hash,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Hash for GetObjectMetadataCacheLookupKey<'_> {
|
||||
fn hash<H: Hasher>(&self, state: &mut H) {
|
||||
self.hash.hash(state);
|
||||
self.generation.hash(state);
|
||||
}
|
||||
}
|
||||
|
||||
impl moka::Equivalent<GetObjectMetadataCacheKey> for GetObjectMetadataCacheLookupKey<'_> {
|
||||
fn equivalent(&self, key: &GetObjectMetadataCacheKey) -> bool {
|
||||
self.bucket == key.bucket.as_ref()
|
||||
&& self.object == key.object.as_ref()
|
||||
&& self.generation == key.generation
|
||||
&& self.hash == key.hash
|
||||
}
|
||||
}
|
||||
|
||||
impl Hash for GetObjectMetadataCacheKey {
|
||||
fn hash<H: Hasher>(&self, state: &mut H) {
|
||||
self.hash.hash(state);
|
||||
@@ -4433,6 +4740,15 @@ impl Hash for GetObjectMetadataCacheKey {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum GetObjectMetadataCacheCapability {
|
||||
Full,
|
||||
// A quorum-selected layout can serve body reads, but is not a mutation layout.
|
||||
QuorumSelected,
|
||||
// Quorum-validated metadata used only to evaluate a terminal read condition.
|
||||
ConditionOnly,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct GetObjectMetadataCacheEntry {
|
||||
#[allow(dead_code)] // Kept for debugging; moka handles TTL internally
|
||||
@@ -4441,6 +4757,7 @@ struct GetObjectMetadataCacheEntry {
|
||||
parts_metadata: Vec<FileInfo>,
|
||||
online_disks: Vec<Option<DiskStore>>,
|
||||
read_quorum: usize,
|
||||
capability: GetObjectMetadataCacheCapability,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -4971,6 +5288,7 @@ impl SetDisks {
|
||||
ctx,
|
||||
capacity_scope_cache: Arc::new(std::sync::RwLock::new(CapacityScopeCache::default())),
|
||||
capacity_dirty_generation: Arc::new(AtomicU64::new(u64::MAX)),
|
||||
tier_free_version_rename_data_source_capable: Arc::new(AtomicBool::new(false)),
|
||||
orphan_purge_backoff: Arc::new(std::sync::Mutex::new(HashMap::new())),
|
||||
#[cfg(test)]
|
||||
storage_class_config_override: Arc::new(std::sync::RwLock::new(None)),
|
||||
@@ -4985,6 +5303,15 @@ impl SetDisks {
|
||||
&self.ctx
|
||||
}
|
||||
|
||||
pub(in crate::set_disk) fn tier_free_version_rename_data_source_capable(&self) -> bool {
|
||||
self.tier_free_version_rename_data_source_capable.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
pub(in crate::set_disk) fn observe_tier_free_version_rename_data_source_capability(&self, capable: bool) {
|
||||
self.tier_free_version_rename_data_source_capable
|
||||
.store(capable, Ordering::Release);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn set_instance_ctx_for_test(&mut self, ctx: Arc<InstanceContext>) {
|
||||
self.local_lock_manager = ctx.lock_manager();
|
||||
|
||||
@@ -31,6 +31,7 @@ use crate::disk::DiskAPI;
|
||||
pub(crate) enum BucketInfoQuorum {
|
||||
Read,
|
||||
Write,
|
||||
HealCandidates,
|
||||
}
|
||||
|
||||
impl SetDisks {
|
||||
@@ -104,7 +105,9 @@ impl SetDisks {
|
||||
// class. A namespace read must intersect that majority; object
|
||||
// readers still enforce the persisted layout's data-shard quorum.
|
||||
BucketInfoQuorum::Read => reduce_read_quorum_errs(&errs, BUCKET_OP_IGNORED_ERRS, disk_count.div_ceil(2).max(1)),
|
||||
BucketInfoQuorum::Write => reduce_write_quorum_errs(&errs, BUCKET_OP_IGNORED_ERRS, disk_count / 2 + 1),
|
||||
BucketInfoQuorum::Write | BucketInfoQuorum::HealCandidates => {
|
||||
reduce_write_quorum_errs(&errs, BUCKET_OP_IGNORED_ERRS, disk_count / 2 + 1)
|
||||
}
|
||||
};
|
||||
if let Some(err) = error {
|
||||
return Err(err.into());
|
||||
@@ -123,8 +126,42 @@ impl SetDisks {
|
||||
}
|
||||
|
||||
pub(crate) async fn list_bucket_for_scanner(&self, _opts: &BucketOptions) -> Result<(Vec<BucketInfo>, bool)> {
|
||||
self.list_bucket_with_quorum(BucketInfoQuorum::Write).await
|
||||
}
|
||||
|
||||
pub(crate) async fn bucket_needs_reconstruction(&self, bucket: &str) -> Result<bool> {
|
||||
let results = join_all(self.disk_inventory().await.into_iter().map(|disk| async move {
|
||||
match disk {
|
||||
Some(disk) => disk.stat_volume(bucket).await,
|
||||
None => Err(DiskError::DiskNotFound),
|
||||
}
|
||||
}))
|
||||
.await;
|
||||
let mut present = false;
|
||||
let mut missing = false;
|
||||
for result in results {
|
||||
match result {
|
||||
Ok(_) => present = true,
|
||||
Err(DiskError::VolumeNotFound) => missing = true,
|
||||
Err(error) => return Err(error.into()),
|
||||
}
|
||||
}
|
||||
if !present {
|
||||
return Err(Error::VolumeNotFound);
|
||||
}
|
||||
Ok(missing)
|
||||
}
|
||||
|
||||
pub(crate) async fn list_bucket_with_quorum(&self, quorum: BucketInfoQuorum) -> Result<(Vec<BucketInfo>, bool)> {
|
||||
let disks = self.disk_inventory().await;
|
||||
let write_quorum = (disks.len() / 2) + 1;
|
||||
// Discovery uses the same namespace read boundary as stat_bucket.
|
||||
// It grants no authority to mutate buckets or accept object reads.
|
||||
let required = match quorum {
|
||||
BucketInfoQuorum::Read => disks.len().div_ceil(2).max(1),
|
||||
BucketInfoQuorum::Write => write_quorum,
|
||||
BucketInfoQuorum::HealCandidates => disks.len().max(1),
|
||||
};
|
||||
|
||||
let mut futures = Vec::with_capacity(disks.len());
|
||||
for disk in disks {
|
||||
@@ -153,7 +190,13 @@ impl SetDisks {
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(err) = reduce_write_quorum_errs(&errs, BUCKET_OP_IGNORED_ERRS, write_quorum) {
|
||||
let error = match quorum {
|
||||
BucketInfoQuorum::Read => reduce_read_quorum_errs(&errs, BUCKET_OP_IGNORED_ERRS, required),
|
||||
BucketInfoQuorum::Write | BucketInfoQuorum::HealCandidates => {
|
||||
reduce_write_quorum_errs(&errs, BUCKET_OP_IGNORED_ERRS, required)
|
||||
}
|
||||
};
|
||||
if let Some(err) = error {
|
||||
return Err(err.into());
|
||||
}
|
||||
|
||||
@@ -176,10 +219,17 @@ impl SetDisks {
|
||||
}
|
||||
}
|
||||
|
||||
topology_complete &= counts.values().all(|(count, _)| *count >= write_quorum);
|
||||
let minimum_presence = if matches!(quorum, BucketInfoQuorum::HealCandidates) {
|
||||
// Candidate presence is not mutation authority. Every disk must answer,
|
||||
// and the heal owner validates the committed generation before recreation.
|
||||
1
|
||||
} else {
|
||||
topology_complete &= counts.values().all(|(count, _)| *count >= write_quorum);
|
||||
required
|
||||
};
|
||||
let mut buckets = counts
|
||||
.into_values()
|
||||
.filter_map(|(count, bucket)| (count >= write_quorum).then_some(bucket))
|
||||
.filter_map(|(count, bucket)| (count >= minimum_presence).then_some(bucket))
|
||||
.collect::<Vec<_>>();
|
||||
buckets.sort_by(|left, right| left.name.cmp(&right.name));
|
||||
Ok((buckets, topology_complete))
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -19,6 +19,7 @@
|
||||
|
||||
pub(crate) mod bitrot_self_verify;
|
||||
pub(crate) mod bucket;
|
||||
pub(crate) mod delete_recovery;
|
||||
pub(crate) mod heal;
|
||||
pub(crate) mod heal_walk;
|
||||
pub(crate) mod list;
|
||||
|
||||
@@ -63,7 +63,6 @@ use crate::crash_inject::{self, CrashPoint};
|
||||
use crate::disk::DiskAPI;
|
||||
#[cfg(test)]
|
||||
use crate::disk::DiskOption;
|
||||
#[cfg(test)]
|
||||
use crate::disk::STORAGE_FORMAT_FILE;
|
||||
#[cfg(test)]
|
||||
use crate::disk::new_disk;
|
||||
@@ -231,6 +230,60 @@ pub(crate) struct StaleMultipartCleanupGuard {
|
||||
lock_guard: ObjectLockDiagGuard,
|
||||
}
|
||||
|
||||
/// Exact-key recovery is admitted only after every disk proves that the upload
|
||||
/// metadata is absent. Keep the checked disk generation and upload lock through
|
||||
/// deletion; a quorum miss is not proof that an upload is an orphan.
|
||||
/// Final object data, completion replay identity, and old-data cleanup receipts
|
||||
/// live under the destination bucket/object, never this staging path. This also
|
||||
/// permits removing an exact orphan from a previous bucket incarnation without
|
||||
/// claiming that its UploadID belongs to the current incarnation.
|
||||
pub(crate) struct OrphanMultipartCleanupGuard {
|
||||
set: Arc<SetDisks>,
|
||||
disks: Vec<Option<DiskStore>>,
|
||||
upload_path: String,
|
||||
lock_guard: Arc<ObjectLockDiagGuard>,
|
||||
exists: bool,
|
||||
}
|
||||
|
||||
impl OrphanMultipartCleanupGuard {
|
||||
pub(crate) async fn delete(&self, bucket: &str, object: &str, opts: &ObjectOptions) -> Result<bool> {
|
||||
fence_commit_on_lock_loss(Some(&self.lock_guard), "orphan_multipart_cleanup", &self.upload_path)?;
|
||||
ensure_multipart_bucket_lifecycle_lock_held(bucket, object, opts)?;
|
||||
if !self.exists {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let results = join_all(self.disks.iter().map(|disk| async {
|
||||
let Some(disk) = disk else {
|
||||
return Err(DiskError::DiskNotFound);
|
||||
};
|
||||
match disk
|
||||
.delete(
|
||||
RUSTFS_META_MULTIPART_BUCKET,
|
||||
&self.upload_path,
|
||||
crate::disk::DeleteOptions {
|
||||
recursive: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Err(DiskError::FileNotFound | DiskError::PathNotFound | DiskError::VolumeNotFound) => Ok(()),
|
||||
result => result,
|
||||
}
|
||||
}))
|
||||
.await;
|
||||
self.set.record_capacity_scope_if_needed(None, &self.disks);
|
||||
let errors = results.into_iter().map(|result| result.err()).collect::<Vec<_>>();
|
||||
if let Some(err) = reduce_write_quorum_errs(&errors, OBJECT_OP_IGNORED_ERRS, self.disks.len()) {
|
||||
return Err(to_object_err(err.into(), vec![bucket, object]));
|
||||
}
|
||||
fence_commit_on_lock_loss(Some(&self.lock_guard), "orphan_multipart_cleanup", &self.upload_path)?;
|
||||
ensure_multipart_bucket_lifecycle_lock_held(bucket, object, opts)?;
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct DataMovementMultipartAbortGuard {
|
||||
upload_path: String,
|
||||
write_quorum: Option<usize>,
|
||||
@@ -557,6 +610,34 @@ fn multipart_bucket_incarnation_matches(metadata: &HashMap<String, String>, expe
|
||||
matches!(multipart_bucket_incarnation_id(metadata), Ok(Some(actual)) if actual == expected)
|
||||
}
|
||||
|
||||
fn orphan_multipart_upload_id_is_canonical(upload_id: &str) -> bool {
|
||||
let Ok(decoded) = base64_simd::URL_SAFE_NO_PAD.decode_to_vec(upload_id.as_bytes()) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(decoded) = std::str::from_utf8(&decoded) else {
|
||||
return false;
|
||||
};
|
||||
let Some((_, raw)) = decoded.split_once('.') else {
|
||||
return false;
|
||||
};
|
||||
let Some((uuid, timestamp)) = raw.split_once('x') else {
|
||||
return false;
|
||||
};
|
||||
let Ok(id) = Uuid::parse_str(uuid) else {
|
||||
return false;
|
||||
};
|
||||
if id.is_nil() || id.to_string() != uuid {
|
||||
return false;
|
||||
}
|
||||
let Ok(timestamp_ns) = timestamp.parse::<i128>() else {
|
||||
return false;
|
||||
};
|
||||
if timestamp_ns.to_string() != timestamp {
|
||||
return false;
|
||||
}
|
||||
OffsetDateTime::from_unix_timestamp_nanos(timestamp_ns).is_ok()
|
||||
}
|
||||
|
||||
fn validate_multipart_bucket_incarnation(
|
||||
metadata: &HashMap<String, String>,
|
||||
bucket: &str,
|
||||
@@ -1246,6 +1327,114 @@ impl SetDisks {
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn lock_orphan_multipart_cleanup(
|
||||
self: &Arc<Self>,
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
upload_id: &str,
|
||||
opts: &ObjectOptions,
|
||||
held_guards: &[OrphanMultipartCleanupGuard],
|
||||
) -> Result<OrphanMultipartCleanupGuard> {
|
||||
let invalid_upload = || StorageError::InvalidUploadID(bucket.to_owned(), object.to_owned(), upload_id.to_owned());
|
||||
if opts.data_movement
|
||||
|| opts.no_lock
|
||||
|| self.local_lock_manager.is_disabled()
|
||||
|| !orphan_multipart_upload_id_is_canonical(upload_id)
|
||||
{
|
||||
return Err(invalid_upload());
|
||||
}
|
||||
// This path is derived from the caller's exact bucket/key and a canonical
|
||||
// UUID/timestamp upload identity, never from the shared MPU directory list.
|
||||
let upload_path = Self::get_upload_id_dir(bucket, object, upload_id);
|
||||
// Pool labels do not partition the raw ObjectKey sent to lock clients.
|
||||
// Reuse the actual owner only for this exact path and proven backend;
|
||||
// independent distributed domains still need their own guards.
|
||||
let distributed = self.ctx.is_dist_erasure().await;
|
||||
let mut lock_guard = None;
|
||||
for held in held_guards {
|
||||
if held.upload_path != upload_path {
|
||||
continue;
|
||||
}
|
||||
let shared_domain = match (distributed, held.set.ctx.is_dist_erasure().await) {
|
||||
(false, false) => self.shares_namespace_lock_domain(&held.set).await,
|
||||
(true, true) => {
|
||||
let same = super::super::same_distributed_lock_domain(&self.lockers, &held.set.lockers);
|
||||
if !same
|
||||
&& self
|
||||
.lockers
|
||||
.iter()
|
||||
.any(|client| held.set.lockers.iter().any(|held_client| Arc::ptr_eq(client, held_client)))
|
||||
{
|
||||
// A guard for only part of another quorum is not proof
|
||||
// of ownership there, and acquiring it again may wait
|
||||
// on ourselves. Preserve all staging in this topology.
|
||||
return Err(Error::other("orphan multipart cleanup cannot cover partially overlapping lock domains"));
|
||||
}
|
||||
same
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
if shared_domain {
|
||||
lock_guard = Some(Arc::clone(&held.lock_guard));
|
||||
break;
|
||||
}
|
||||
}
|
||||
let lock_guard = match lock_guard {
|
||||
Some(guard) => guard,
|
||||
None => Arc::new(
|
||||
self.acquire_write_lock_diag("orphan_multipart_cleanup", RUSTFS_META_MULTIPART_BUCKET, &upload_path)
|
||||
.await?,
|
||||
),
|
||||
};
|
||||
fence_commit_on_lock_loss(Some(&lock_guard), "orphan_multipart_cleanup", &upload_path)?;
|
||||
ensure_multipart_bucket_lifecycle_lock_held(bucket, object, opts)?;
|
||||
let disks = self.disks.read().await.clone();
|
||||
if disks.len() != self.set_drive_count || disks.is_empty() {
|
||||
return Err(Error::ErasureReadQuorum);
|
||||
}
|
||||
// Raw full fan-out cannot quorum-short-circuit or interpret an empty
|
||||
// version list as absence of the physical metadata file.
|
||||
let (_, errors) = Self::read_all_raw_file_info(&disks, RUSTFS_META_MULTIPART_BUCKET, &upload_path, false).await;
|
||||
if errors.len() != disks.len() {
|
||||
return Err(Error::ErasureReadQuorum);
|
||||
}
|
||||
for error in errors {
|
||||
match error {
|
||||
Some(DiskError::FileNotFound | DiskError::VolumeNotFound) => {}
|
||||
// Even one valid metadata copy may still be recoverable. Never
|
||||
// convert a partial, corrupt, or unreachable upload into debris.
|
||||
None => return Err(invalid_upload()),
|
||||
Some(error) => return Err(to_object_err(error.into(), vec![bucket, object, upload_id])),
|
||||
}
|
||||
}
|
||||
let mut exists = false;
|
||||
for disk in &disks {
|
||||
let Some(disk) = disk else {
|
||||
return Err(Error::ErasureReadQuorum);
|
||||
};
|
||||
match disk.list_dir(bucket, RUSTFS_META_MULTIPART_BUCKET, &upload_path, -1).await {
|
||||
Ok(entries) => {
|
||||
// Raw metadata reads report a directory named xl.meta as
|
||||
// FileNotFound. Its physical presence is unknown/corrupt
|
||||
// metadata, not proof that this staging can be deleted.
|
||||
if entries.iter().any(|entry| entry.trim_end_matches('/') == STORAGE_FORMAT_FILE) {
|
||||
return Err(to_object_err(DiskError::FileCorrupt.into(), vec![bucket, object, upload_id]));
|
||||
}
|
||||
exists = true;
|
||||
}
|
||||
Err(DiskError::FileNotFound | DiskError::VolumeNotFound) => {}
|
||||
Err(error) => return Err(to_object_err(error.into(), vec![bucket, object, upload_id])),
|
||||
}
|
||||
}
|
||||
Ok(OrphanMultipartCleanupGuard {
|
||||
set: self.clone(),
|
||||
disks,
|
||||
upload_path,
|
||||
lock_guard,
|
||||
exists,
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) async fn list_multipart_uploads_for_incarnation(
|
||||
&self,
|
||||
@@ -1678,6 +1867,7 @@ impl crate::storage_api_contracts::multipart::MultipartOperations for SetDisks {
|
||||
drop(admission_guard);
|
||||
}
|
||||
|
||||
let mut commit_owns_cleanup = false;
|
||||
let result: Result<PartInfo> = async {
|
||||
let erasure =
|
||||
Arc::new(coding::Erasure::try_new(fi.erasure.data_blocks, fi.erasure.parity_blocks, fi.erasure.block_size)
|
||||
@@ -1881,120 +2071,171 @@ impl crate::storage_api_contracts::multipart::MultipartOperations for SetDisks {
|
||||
.await?;
|
||||
(Some(upload_guard), Some(part_guard))
|
||||
};
|
||||
let (commit_fi, _) = self
|
||||
.check_upload_id_exists_with_opts(bucket, object, upload_id, multipart_size_limit.is_some(), opts)
|
||||
.await?;
|
||||
let commit_size_limit = multipart_size_limit_from_metadata(&commit_fi.metadata)?;
|
||||
if commit_size_limit != multipart_size_limit {
|
||||
return Err(Error::InvalidArgument(
|
||||
"multipart upload".to_string(),
|
||||
SUFFIX_MAX_TOTAL_OBJECT_SIZE.to_string(),
|
||||
"size limit metadata changed or is missing".to_string(),
|
||||
));
|
||||
}
|
||||
ensure_data_movement_upload_access(&commit_fi, bucket, object, upload_id, opts)?;
|
||||
ensure_multipart_bucket_incarnation(
|
||||
&self.ctx,
|
||||
&commit_fi,
|
||||
bucket,
|
||||
object,
|
||||
upload_id,
|
||||
opts.expected_bucket_incarnation_id,
|
||||
)
|
||||
.await?;
|
||||
#[cfg(test)]
|
||||
pause_multipart_commit(bucket, object, MultipartCommitPause::PutPartBeforeLockLost).await;
|
||||
fence_commit_on_lock_loss(_upload_commit_guard.as_ref(), "put_object_part_commit", &upload_id_path)?;
|
||||
fence_commit_on_lock_loss(_part_commit_guard.as_ref(), "put_object_part_commit", &part_lock_path)?;
|
||||
if opts
|
||||
.namespace_lock_fence
|
||||
.as_ref()
|
||||
.is_some_and(NamespaceLockFence::is_lock_lost)
|
||||
{
|
||||
return Err(StorageError::NamespaceLockQuorumUnavailable {
|
||||
mode: "put_object_part_outer_lock",
|
||||
bucket: bucket.to_string(),
|
||||
object: object.to_string(),
|
||||
required: 1,
|
||||
achieved: 0,
|
||||
});
|
||||
}
|
||||
ensure_multipart_bucket_lifecycle_lock_held(bucket, object, opts)?;
|
||||
|
||||
if let Some(limit) = commit_size_limit {
|
||||
let current_size = self
|
||||
.current_multipart_logical_size(bucket, object, upload_id, &upload_id_path, &commit_fi, part_id)
|
||||
.await?;
|
||||
let candidate_size = u64::try_from(actual_size).map_err(|_| Error::PartMissingOrCorrupt)?;
|
||||
admitted_multipart_size(current_size, candidate_size, limit)?;
|
||||
}
|
||||
|
||||
let decommission_capacity_guard = if let Some(store) = opts.decommission_capacity_admission.as_ref() {
|
||||
Some(
|
||||
store
|
||||
.acquire_external_decommission_capacity_fence(
|
||||
&[self.pool_index],
|
||||
DecommissionCapacityAdmission::ExistingMultipart,
|
||||
)
|
||||
.await?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
#[cfg(test)]
|
||||
pause_multipart_commit(bucket, object, MultipartCommitPause::PutPartAfterCapacityAdmission).await;
|
||||
if decommission_capacity_guard.as_ref().is_some_and(|guard| guard.is_lock_lost()) {
|
||||
return Err(StorageError::NamespaceLockQuorumUnavailable {
|
||||
mode: "put_object_part_decommission_capacity",
|
||||
bucket: bucket.to_string(),
|
||||
object: object.to_string(),
|
||||
required: 1,
|
||||
achieved: 0,
|
||||
});
|
||||
}
|
||||
let rename_result = self
|
||||
.rename_part(
|
||||
&shuffle_disks,
|
||||
RUSTFS_META_TMP_BUCKET,
|
||||
&tmp_part_path,
|
||||
RUSTFS_META_MULTIPART_BUCKET,
|
||||
&part_path,
|
||||
part_info_buff.into(),
|
||||
write_quorum,
|
||||
Some(MultipartWriteQuorumContext {
|
||||
stage: MULTIPART_WRITE_QUORUM_RENAME_PART,
|
||||
// Only guards owned by this operation may outlive its caller. Internal
|
||||
// data-movement and borrowed-lock callers retain their synchronous
|
||||
// lifetime; a fence by itself cannot transfer the outer lock lease.
|
||||
let detach_commit_owner = _upload_commit_guard.is_some()
|
||||
&& opts.namespace_lock_fence.is_none()
|
||||
&& (opts.bucket_lifecycle_lock_fence.is_none() || opts.bucket_lifecycle_lock_owner.is_some());
|
||||
let commit_set = self.clone();
|
||||
let commit_bucket = bucket.to_owned();
|
||||
let commit_object = object.to_owned();
|
||||
let commit_upload_id = upload_id.to_owned();
|
||||
let commit_upload_id_path = upload_id_path.clone();
|
||||
let commit_tmp_part = tmp_part.clone();
|
||||
let commit_opts = opts.clone();
|
||||
commit_owns_cleanup = true;
|
||||
// The body is already staged. Keep every commit guard through the
|
||||
// prepare, publish, settlement/rollback and temporary cleanup I/O,
|
||||
// even when the HTTP request stops awaiting this task.
|
||||
let commit = async move {
|
||||
let _upload_commit_guard = _upload_commit_guard;
|
||||
let _part_commit_guard = _part_commit_guard;
|
||||
let _capped_staging_guard = _capped_staging_guard;
|
||||
let bucket = commit_bucket.as_str();
|
||||
let object = commit_object.as_str();
|
||||
let upload_id = commit_upload_id.as_str();
|
||||
let upload_id_path = commit_upload_id_path;
|
||||
let opts = &commit_opts;
|
||||
let result: Result<PartInfo> = async {
|
||||
let (commit_fi, _) = commit_set
|
||||
.check_upload_id_exists_with_opts(bucket, object, upload_id, multipart_size_limit.is_some(), opts)
|
||||
.await?;
|
||||
let commit_size_limit = multipart_size_limit_from_metadata(&commit_fi.metadata)?;
|
||||
if commit_size_limit != multipart_size_limit {
|
||||
return Err(Error::InvalidArgument(
|
||||
"multipart upload".to_string(),
|
||||
SUFFIX_MAX_TOTAL_OBJECT_SIZE.to_string(),
|
||||
"size limit metadata changed or is missing".to_string(),
|
||||
));
|
||||
}
|
||||
ensure_data_movement_upload_access(&commit_fi, bucket, object, upload_id, opts)?;
|
||||
ensure_multipart_bucket_incarnation(
|
||||
&commit_set.ctx,
|
||||
&commit_fi,
|
||||
bucket,
|
||||
object,
|
||||
upload_id,
|
||||
part_number: Some(part_id),
|
||||
}),
|
||||
)
|
||||
opts.expected_bucket_incarnation_id,
|
||||
)
|
||||
.await?;
|
||||
#[cfg(test)]
|
||||
pause_multipart_commit(bucket, object, MultipartCommitPause::PutPartBeforeLockLost).await;
|
||||
fence_commit_on_lock_loss(_upload_commit_guard.as_ref(), "put_object_part_commit", &upload_id_path)?;
|
||||
fence_commit_on_lock_loss(_part_commit_guard.as_ref(), "put_object_part_commit", &part_lock_path)?;
|
||||
if opts
|
||||
.namespace_lock_fence
|
||||
.as_ref()
|
||||
.is_some_and(NamespaceLockFence::is_lock_lost)
|
||||
{
|
||||
return Err(StorageError::NamespaceLockQuorumUnavailable {
|
||||
mode: "put_object_part_outer_lock",
|
||||
bucket: bucket.to_string(),
|
||||
object: object.to_string(),
|
||||
required: 1,
|
||||
achieved: 0,
|
||||
});
|
||||
}
|
||||
ensure_multipart_bucket_lifecycle_lock_held(bucket, object, opts)?;
|
||||
|
||||
if let Some(limit) = commit_size_limit {
|
||||
let current_size = commit_set
|
||||
.current_multipart_logical_size(bucket, object, upload_id, &upload_id_path, &commit_fi, part_id)
|
||||
.await?;
|
||||
let candidate_size = u64::try_from(actual_size).map_err(|_| Error::PartMissingOrCorrupt)?;
|
||||
admitted_multipart_size(current_size, candidate_size, limit)?;
|
||||
}
|
||||
|
||||
let decommission_capacity_guard = if let Some(store) = opts.decommission_capacity_admission.as_ref() {
|
||||
Some(
|
||||
store
|
||||
.acquire_external_decommission_capacity_fence(
|
||||
&[commit_set.pool_index],
|
||||
DecommissionCapacityAdmission::ExistingMultipart,
|
||||
)
|
||||
.await?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
#[cfg(test)]
|
||||
pause_multipart_commit(bucket, object, MultipartCommitPause::PutPartAfterCapacityAdmission).await;
|
||||
if decommission_capacity_guard.as_ref().is_some_and(|guard| guard.is_lock_lost()) {
|
||||
return Err(StorageError::NamespaceLockQuorumUnavailable {
|
||||
mode: "put_object_part_decommission_capacity",
|
||||
bucket: bucket.to_string(),
|
||||
object: object.to_string(),
|
||||
required: 1,
|
||||
achieved: 0,
|
||||
});
|
||||
}
|
||||
let rename_result = commit_set
|
||||
.rename_part(
|
||||
&shuffle_disks,
|
||||
RUSTFS_META_TMP_BUCKET,
|
||||
&tmp_part_path,
|
||||
RUSTFS_META_MULTIPART_BUCKET,
|
||||
&part_path,
|
||||
part_info_buff.into(),
|
||||
write_quorum,
|
||||
Some(MultipartWriteQuorumContext {
|
||||
stage: MULTIPART_WRITE_QUORUM_RENAME_PART,
|
||||
bucket,
|
||||
object,
|
||||
upload_id,
|
||||
part_number: Some(part_id),
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
drop(decommission_capacity_guard);
|
||||
let _ = rename_result?;
|
||||
#[cfg(test)]
|
||||
observe_multipart_commit(bucket, object, MultipartCommitPause::PutPartBeforeLockLost);
|
||||
|
||||
#[cfg(test)]
|
||||
pause_multipart_commit(bucket, object, MultipartCommitPause::PutPartAfterRename).await;
|
||||
|
||||
let ret: PartInfo = PartInfo {
|
||||
etag: Some(etag.clone()),
|
||||
part_num: part_id,
|
||||
last_mod: Some(OffsetDateTime::now_utc()),
|
||||
size: w_size,
|
||||
actual_size,
|
||||
};
|
||||
|
||||
// error!("put_object_part ret {:?}", &ret);
|
||||
|
||||
Ok(ret)
|
||||
}
|
||||
.await;
|
||||
drop(decommission_capacity_guard);
|
||||
let _ = rename_result?;
|
||||
#[cfg(test)]
|
||||
observe_multipart_commit(bucket, object, MultipartCommitPause::PutPartBeforeLockLost);
|
||||
|
||||
#[cfg(test)]
|
||||
pause_multipart_commit(bucket, object, MultipartCommitPause::PutPartAfterRename).await;
|
||||
drop(_part_commit_guard);
|
||||
drop(_upload_commit_guard);
|
||||
|
||||
let ret: PartInfo = PartInfo {
|
||||
etag: Some(etag.clone()),
|
||||
part_num: part_id,
|
||||
last_mod: Some(OffsetDateTime::now_utc()),
|
||||
size: w_size,
|
||||
actual_size,
|
||||
if result.is_err()
|
||||
&& let Err(err) = commit_set.delete_all(RUSTFS_META_TMP_BUCKET, &commit_tmp_part).await
|
||||
{
|
||||
warn!(
|
||||
event = EVENT_SET_DISK_MULTIPART,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_SET_DISK,
|
||||
result = "cleanup_failed",
|
||||
bucket,
|
||||
object,
|
||||
error = ?err,
|
||||
"failed to clean multipart commit staging"
|
||||
);
|
||||
}
|
||||
result
|
||||
};
|
||||
|
||||
// error!("put_object_part ret {:?}", &ret);
|
||||
|
||||
Ok(ret)
|
||||
if detach_commit_owner {
|
||||
tokio::spawn(commit)
|
||||
.await
|
||||
.map_err(|err| Error::other_with_context("put_object_part commit task failed", err))?
|
||||
} else {
|
||||
commit.await
|
||||
}
|
||||
}
|
||||
.await;
|
||||
|
||||
if result.is_err()
|
||||
if !commit_owns_cleanup
|
||||
&& result.is_err()
|
||||
&& let Err(err) = self.delete_all(RUSTFS_META_TMP_BUCKET, &tmp_part).await
|
||||
{
|
||||
warn!(tmp_part = %tmp_part, error = ?err, "failed to cleanup multipart temporary data");
|
||||
@@ -4233,6 +4474,300 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orphan_multipart_identity_requires_canonical_upload_id() {
|
||||
let created = OffsetDateTime::UNIX_EPOCH + time::Duration::seconds(10);
|
||||
let id = Uuid::new_v4();
|
||||
let timestamp = (created + time::Duration::seconds(1)).unix_timestamp_nanos();
|
||||
let encode = |raw: &str| runtime_sources::deployment_upload_id(raw);
|
||||
let upload_id = encode(&format!("{id}x{timestamp}"));
|
||||
assert!(orphan_multipart_upload_id_is_canonical(&upload_id));
|
||||
for raw in [
|
||||
format!("{}x{timestamp}", Uuid::nil()),
|
||||
format!("{}x{timestamp}", id.simple()),
|
||||
format!("{id}x0{timestamp}"),
|
||||
format!("{id}x+{timestamp}"),
|
||||
format!("{id}x{timestamp}/../another-upload"),
|
||||
format!("../{id}x{timestamp}"),
|
||||
format!("{id}x{}", i128::MAX),
|
||||
] {
|
||||
assert!(
|
||||
!orphan_multipart_upload_id_is_canonical(&encode(&raw)),
|
||||
"noncanonical upload identity must be rejected: {raw}"
|
||||
);
|
||||
}
|
||||
assert!(!orphan_multipart_upload_id_is_canonical("not-base64"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn orphan_multipart_cleanup_requires_every_disk_to_prove_metadata_absence() {
|
||||
let (_temp_dirs, disks, set) = hermetic_set_disks(4).await;
|
||||
let bucket = "orphan-multipart-proof";
|
||||
let object = "object";
|
||||
make_bucket_on_all(&disks, bucket).await;
|
||||
let upload = set
|
||||
.new_multipart_upload(bucket, object, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("create upload");
|
||||
let upload_path = SetDisks::get_upload_id_dir(bucket, object, &upload.upload_id);
|
||||
let metadata_path = format!("{upload_path}/{STORAGE_FORMAT_FILE}");
|
||||
let leftover_path = format!("{upload_path}/part.late");
|
||||
for disk in &disks {
|
||||
disk.write_all(RUSTFS_META_MULTIPART_BUCKET, &leftover_path, Bytes::from_static(b"late part"))
|
||||
.await
|
||||
.expect("write late part fixture");
|
||||
}
|
||||
for disk in &disks[..3] {
|
||||
disk.delete(RUSTFS_META_MULTIPART_BUCKET, &metadata_path, Default::default())
|
||||
.await
|
||||
.expect("remove majority metadata");
|
||||
}
|
||||
let opts = ObjectOptions::default();
|
||||
assert!(
|
||||
set.lock_orphan_multipart_cleanup(bucket, object, &upload.upload_id, &opts, &[])
|
||||
.await
|
||||
.is_err(),
|
||||
"one surviving metadata copy must block orphan recovery"
|
||||
);
|
||||
disks[3]
|
||||
.write_all(RUSTFS_META_MULTIPART_BUCKET, &metadata_path, Bytes::from_static(b"corrupt metadata"))
|
||||
.await
|
||||
.expect("corrupt final metadata copy");
|
||||
assert!(
|
||||
set.lock_orphan_multipart_cleanup(bucket, object, &upload.upload_id, &opts, &[])
|
||||
.await
|
||||
.is_err(),
|
||||
"corrupt metadata must not be treated as absence"
|
||||
);
|
||||
disks[3]
|
||||
.delete(RUSTFS_META_MULTIPART_BUCKET, &metadata_path, Default::default())
|
||||
.await
|
||||
.expect("remove final metadata copy");
|
||||
set.disks.write().await[3] = None;
|
||||
assert!(
|
||||
set.lock_orphan_multipart_cleanup(bucket, object, &upload.upload_id, &opts, &[])
|
||||
.await
|
||||
.is_err(),
|
||||
"an unreachable disk must not be hidden by absence quorum"
|
||||
);
|
||||
set.disks.write().await[3] = Some(disks[3].clone());
|
||||
for disk in &disks {
|
||||
assert_eq!(
|
||||
disk.read_all(RUSTFS_META_MULTIPART_BUCKET, &leftover_path)
|
||||
.await
|
||||
.expect("failed preflight must preserve every part"),
|
||||
Bytes::from_static(b"late part")
|
||||
);
|
||||
}
|
||||
|
||||
let guard = set
|
||||
.lock_orphan_multipart_cleanup(bucket, object, &upload.upload_id, &opts, &[])
|
||||
.await
|
||||
.expect("all disks now prove metadata absence");
|
||||
assert!(guard.delete(bucket, object, &opts).await.expect("recover exact staging"));
|
||||
drop(guard);
|
||||
for disk in &disks {
|
||||
assert!(matches!(
|
||||
disk.list_dir(bucket, RUSTFS_META_MULTIPART_BUCKET, &upload_path, -1).await,
|
||||
Err(DiskError::FileNotFound | DiskError::VolumeNotFound)
|
||||
));
|
||||
}
|
||||
let guard = set
|
||||
.lock_orphan_multipart_cleanup(bucket, object, &upload.upload_id, &opts, &[])
|
||||
.await
|
||||
.expect("already absent staging remains provably absent");
|
||||
assert!(
|
||||
!guard
|
||||
.delete(bucket, object, &opts)
|
||||
.await
|
||||
.expect("absence does not report recovery")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn orphan_multipart_cleanup_rejects_metadata_directory_on_one_disk() {
|
||||
let (temp_dirs, disks, set) = hermetic_set_disks(4).await;
|
||||
let bucket = "orphan-multipart-directory";
|
||||
let object = "object";
|
||||
make_bucket_on_all(&disks, bucket).await;
|
||||
let upload = set
|
||||
.new_multipart_upload(bucket, object, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("create upload");
|
||||
let upload_path = SetDisks::get_upload_id_dir(bucket, object, &upload.upload_id);
|
||||
let metadata_path = format!("{upload_path}/{STORAGE_FORMAT_FILE}");
|
||||
let leftover_path = format!("{upload_path}/part.late");
|
||||
for disk in &disks {
|
||||
disk.delete(RUSTFS_META_MULTIPART_BUCKET, &metadata_path, Default::default())
|
||||
.await
|
||||
.expect("remove upload metadata");
|
||||
disk.write_all(RUSTFS_META_MULTIPART_BUCKET, &leftover_path, Bytes::from_static(b"late part"))
|
||||
.await
|
||||
.expect("write leftover part");
|
||||
}
|
||||
let metadata_directory = temp_dirs[3].path().join(RUSTFS_META_MULTIPART_BUCKET).join(&metadata_path);
|
||||
tokio::fs::create_dir_all(&metadata_directory)
|
||||
.await
|
||||
.expect("replace one missing metadata file with a directory");
|
||||
let (_, errors) =
|
||||
SetDisks::read_all_raw_file_info(&set.disks.read().await, RUSTFS_META_MULTIPART_BUCKET, &upload_path, false).await;
|
||||
assert!(
|
||||
errors.iter().all(|error| matches!(error, Some(DiskError::FileNotFound))),
|
||||
"raw metadata reads alone must reproduce the directory-as-absence case: {errors:?}"
|
||||
);
|
||||
assert!(
|
||||
set.lock_orphan_multipart_cleanup(bucket, object, &upload.upload_id, &ObjectOptions::default(), &[])
|
||||
.await
|
||||
.is_err(),
|
||||
"one physical metadata directory must block cleanup on every disk"
|
||||
);
|
||||
assert!(metadata_directory.is_dir(), "failed preflight must preserve the metadata directory");
|
||||
for disk in &disks {
|
||||
assert_eq!(
|
||||
disk.read_all(RUSTFS_META_MULTIPART_BUCKET, &leftover_path)
|
||||
.await
|
||||
.expect("failed preflight must preserve every leftover part"),
|
||||
Bytes::from_static(b"late part")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum OrphanMultipartLockDomainCase {
|
||||
Shared,
|
||||
Independent,
|
||||
PartiallyOverlapping,
|
||||
}
|
||||
|
||||
async fn check_orphan_multipart_distributed_lock_owners(case: OrphanMultipartLockDomainCase) {
|
||||
let ctx = Arc::new(crate::runtime::instance::InstanceContext::new());
|
||||
ctx.update_erasure_type(SetupType::DistErasure).await;
|
||||
let first_client: Arc<dyn LockClient> =
|
||||
Arc::new(LocalClient::with_manager(Arc::new(rustfs_lock::GlobalLockManager::new())));
|
||||
let second_client: Arc<dyn LockClient> =
|
||||
Arc::new(LocalClient::with_manager(Arc::new(rustfs_lock::GlobalLockManager::new())));
|
||||
let second_clients = match case {
|
||||
OrphanMultipartLockDomainCase::Shared => vec![first_client.clone()],
|
||||
OrphanMultipartLockDomainCase::Independent => vec![second_client],
|
||||
OrphanMultipartLockDomainCase::PartiallyOverlapping => vec![first_client.clone(), second_client],
|
||||
};
|
||||
let (first_dirs, first_disks, first_set) =
|
||||
hermetic_set_disks_with_lockers_and_ctx(4, 0, 2, vec![first_client], ctx.clone()).await;
|
||||
let (second_dirs, second_disks, second_set) = hermetic_set_disks_with_lockers_and_ctx(4, 1, 2, second_clients, ctx).await;
|
||||
let bucket = "orphan-multipart-dist-locks";
|
||||
let object = "object";
|
||||
make_bucket_on_all(&first_disks, bucket).await;
|
||||
make_bucket_on_all(&second_disks, bucket).await;
|
||||
let upload_id = runtime_sources::deployment_upload_id(&format!(
|
||||
"{}x{}",
|
||||
Uuid::new_v4(),
|
||||
OffsetDateTime::now_utc().unix_timestamp_nanos()
|
||||
));
|
||||
let upload_path = SetDisks::get_upload_id_dir(bucket, object, &upload_id);
|
||||
for directory in first_dirs.iter().chain(second_dirs.iter()) {
|
||||
let staging = directory.path().join(RUSTFS_META_MULTIPART_BUCKET).join(&upload_path);
|
||||
tokio::fs::create_dir_all(&staging)
|
||||
.await
|
||||
.expect("create exact orphan staging");
|
||||
tokio::fs::write(staging.join("part.late"), b"late part")
|
||||
.await
|
||||
.expect("write orphan part");
|
||||
}
|
||||
let opts = ObjectOptions::default();
|
||||
let first_guard = first_set
|
||||
.lock_orphan_multipart_cleanup(bucket, object, &upload_id, &opts, &[])
|
||||
.await
|
||||
.expect("first domain has an orphan");
|
||||
let second_result = tokio::time::timeout(
|
||||
Duration::from_secs(10),
|
||||
second_set.lock_orphan_multipart_cleanup(bucket, object, &upload_id, &opts, std::slice::from_ref(&first_guard)),
|
||||
)
|
||||
.await
|
||||
.expect("lock-domain admission must not wait on a guard it already owns");
|
||||
if case == OrphanMultipartLockDomainCase::PartiallyOverlapping {
|
||||
let error = match second_result {
|
||||
Ok(_) => panic!("one existing quorum cannot authorize a partially overlapping domain"),
|
||||
Err(error) => error,
|
||||
};
|
||||
assert!(error.to_string().contains("partially overlapping lock domains"));
|
||||
for directory in first_dirs.iter().chain(second_dirs.iter()) {
|
||||
assert!(
|
||||
directory
|
||||
.path()
|
||||
.join(RUSTFS_META_MULTIPART_BUCKET)
|
||||
.join(&upload_path)
|
||||
.join("part.late")
|
||||
.exists(),
|
||||
"uncertain lock coverage must preserve every physical part"
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let second_guard = second_result.expect("the second pool has a proven lock domain");
|
||||
assert_eq!(
|
||||
Arc::ptr_eq(&first_guard.lock_guard, &second_guard.lock_guard),
|
||||
case == OrphanMultipartLockDomainCase::Shared,
|
||||
"only the same exact backend may share a real upload guard across pool labels"
|
||||
);
|
||||
let second_namespace = second_set
|
||||
.new_ns_lock(RUSTFS_META_MULTIPART_BUCKET, &upload_path)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut second_writer = Box::pin(second_namespace.get_write_lock(Duration::from_secs(10)));
|
||||
assert!(futures::poll!(&mut second_writer).is_pending());
|
||||
if case == OrphanMultipartLockDomainCase::Shared {
|
||||
drop(first_guard);
|
||||
assert!(
|
||||
futures::poll!(&mut second_writer).is_pending(),
|
||||
"the second pool must retain the shared real owner after the first guard drops"
|
||||
);
|
||||
drop(second_guard);
|
||||
drop(
|
||||
tokio::time::timeout(Duration::from_secs(10), second_writer)
|
||||
.await
|
||||
.expect("the shared backend should release after the last owner")
|
||||
.expect("shared-domain writer resumes"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
let first_namespace = first_set
|
||||
.new_ns_lock(RUSTFS_META_MULTIPART_BUCKET, &upload_path)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut first_writer = Box::pin(first_namespace.get_write_lock(Duration::from_secs(10)));
|
||||
assert!(futures::poll!(&mut first_writer).is_pending());
|
||||
drop(first_guard);
|
||||
drop(
|
||||
tokio::time::timeout(Duration::from_secs(10), first_writer)
|
||||
.await
|
||||
.expect("first domain should release independently")
|
||||
.expect("first writer resumes"),
|
||||
);
|
||||
assert!(futures::poll!(&mut second_writer).is_pending(), "the second real owner must remain held");
|
||||
drop(second_guard);
|
||||
drop(
|
||||
tokio::time::timeout(Duration::from_secs(10), second_writer)
|
||||
.await
|
||||
.expect("second domain should release with its owner")
|
||||
.expect("second writer resumes"),
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn orphan_multipart_cleanup_keeps_separate_distributed_lock_owners() {
|
||||
check_orphan_multipart_distributed_lock_owners(OrphanMultipartLockDomainCase::Independent).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn orphan_multipart_cleanup_reuses_shared_distributed_owner_across_pools() {
|
||||
check_orphan_multipart_distributed_lock_owners(OrphanMultipartLockDomainCase::Shared).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn orphan_multipart_cleanup_rejects_partially_overlapping_distributed_domains() {
|
||||
check_orphan_multipart_distributed_lock_owners(OrphanMultipartLockDomainCase::PartiallyOverlapping).await;
|
||||
}
|
||||
|
||||
async fn stage_upload_with_create_opts(
|
||||
set_disks: &Arc<SetDisks>,
|
||||
bucket: &str,
|
||||
@@ -6370,6 +6905,76 @@ mod tests {
|
||||
.expect("abort should delete the upload after UploadPart releases the lock");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn cancelled_put_object_part_keeps_upload_lock_through_commit() {
|
||||
for pause in [
|
||||
MultipartCommitPause::PutPartAfterCapacityAdmission,
|
||||
MultipartCommitPause::PutPartAfterRename,
|
||||
] {
|
||||
let manager = Arc::new(rustfs_lock::GlobalLockManager::new());
|
||||
let signaling = Arc::new(SignalingLockClient::new(Arc::new(LocalClient::with_manager(manager))));
|
||||
let lockers: Vec<Arc<dyn LockClient>> = vec![signaling.clone()];
|
||||
let (_temp_dirs, disk_stores, set_disks) = hermetic_set_disks_with_lockers(4, 0, 2, lockers).await;
|
||||
let bucket = "multipart-cancelled-part-commit-bucket";
|
||||
let object = "object";
|
||||
make_bucket_on_all(&disk_stores, bucket).await;
|
||||
let upload = set_disks
|
||||
.new_multipart_upload(bucket, object, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("multipart upload should be created");
|
||||
let upload_id = upload.upload_id;
|
||||
let upload_path = SetDisks::get_upload_id_dir(bucket, object, &upload_id);
|
||||
signaling.set_target(rustfs_lock::ObjectKey::new(RUSTFS_META_MULTIPART_BUCKET, upload_path.clone()));
|
||||
let _setup_type_guard = SetupTypeGuard::switch_to(SetupType::DistErasure).await;
|
||||
let barrier = MultipartCommitBarrier::install(bucket, object, pause);
|
||||
|
||||
let put_store = set_disks.clone();
|
||||
let put_upload_id = upload_id.clone();
|
||||
let put = tokio::spawn(async move {
|
||||
let mut reader = PutObjReader::from_vec(vec![0x45; 4096]);
|
||||
put_store
|
||||
.put_object_part(bucket, object, &put_upload_id, 1, &mut reader, &ObjectOptions::default())
|
||||
.await
|
||||
});
|
||||
barrier.wait_until_paused().await;
|
||||
put.abort();
|
||||
assert!(
|
||||
put.await
|
||||
.expect_err("the request waiter should remain cancellable")
|
||||
.is_cancelled()
|
||||
);
|
||||
|
||||
let abort_store = set_disks.clone();
|
||||
let abort_upload_id = upload_id.clone();
|
||||
let mut abort = tokio::spawn(async move {
|
||||
abort_store
|
||||
.abort_multipart_upload(bucket, object, &abort_upload_id, &ObjectOptions::default())
|
||||
.await
|
||||
});
|
||||
signaling.wait_for_attempts(2).await;
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_millis(100), &mut abort).await.is_err(),
|
||||
"Abort must not acquire the upload lock while a cancelled request's commit is paused at {pause:?}"
|
||||
);
|
||||
barrier.release();
|
||||
tokio::time::timeout(Duration::from_secs(10), abort)
|
||||
.await
|
||||
.expect("Abort should finish after the commit drains")
|
||||
.expect("Abort task should not panic")
|
||||
.expect("Abort should delete the committed part staging");
|
||||
for disk in &disk_stores {
|
||||
assert!(
|
||||
matches!(
|
||||
disk.list_dir(bucket, RUSTFS_META_MULTIPART_BUCKET, &upload_path, -1).await,
|
||||
Err(DiskError::FileNotFound)
|
||||
),
|
||||
"the upload directory must be absent after the commit and Abort finish"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn put_object_part_different_part_numbers_commit_concurrently() {
|
||||
@@ -9799,6 +10404,18 @@ mod tests {
|
||||
"the post-commit crash must leave the upload listable for reclamation"
|
||||
);
|
||||
|
||||
let cleanup = set_disks
|
||||
.lock_orphan_multipart_cleanup(bucket, object, &u_new, &ObjectOptions::default(), &[])
|
||||
.await
|
||||
.expect("the committed upload has no staging metadata on any disk");
|
||||
assert!(
|
||||
cleanup
|
||||
.delete(bucket, object, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("recover completed upload staging")
|
||||
);
|
||||
drop(cleanup);
|
||||
|
||||
// The commit rename already consumed the upload's xl.meta, so the
|
||||
// retry cannot read staging. It must still return the committed
|
||||
// object: AWS CompleteMultipartUpload is idempotent for the same
|
||||
@@ -9817,21 +10434,6 @@ mod tests {
|
||||
!upload_is_listed(&set_disks, bucket, object, &u_new).await,
|
||||
"the idempotent retry must reclaim staging left behind by the crash"
|
||||
);
|
||||
|
||||
// Reclaim the leftover exactly as the production tail does: delete_all
|
||||
// on the upload path (abort_multipart_upload cannot — the upload's
|
||||
// metadata is gone, so it too answers InvalidUploadID).
|
||||
let upload_dir = SetDisks::get_upload_id_dir(bucket, object, &u_new);
|
||||
set_disks
|
||||
.delete_all(RUSTFS_META_MULTIPART_BUCKET, &upload_dir)
|
||||
.await
|
||||
.expect("sweeping the leftover upload staging should succeed");
|
||||
assert!(
|
||||
!upload_is_listed(&set_disks, bucket, object, &u_new).await,
|
||||
"reclaiming the upload must drop it from the listing"
|
||||
);
|
||||
let (body_after, _) = read_object(&set_disks, bucket, object).await;
|
||||
assert_eq!(body_after, new, "reclaiming the leftover upload must not disturb the committed object");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -9878,6 +10480,18 @@ mod tests {
|
||||
.expect("the failed post-commit completion should release its object guard"),
|
||||
);
|
||||
|
||||
let cleanup = set_disks
|
||||
.lock_orphan_multipart_cleanup(bucket, object, &u_new, &ObjectOptions::default(), &[])
|
||||
.await
|
||||
.expect("committed staging has no upload metadata");
|
||||
assert!(
|
||||
cleanup
|
||||
.delete(bucket, object, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("orphan recovery must only remove upload staging")
|
||||
);
|
||||
drop(cleanup);
|
||||
|
||||
let (body, _) = read_object(&set_disks, bucket, object).await;
|
||||
assert_eq!(body, new, "the committed replacement must remain readable after the crash");
|
||||
let mut receipts = 0;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+1361
-71
File diff suppressed because it is too large
Load Diff
@@ -20,6 +20,7 @@ use crate::bucket::{
|
||||
use crate::error::is_err_bucket_not_found;
|
||||
use crate::runtime::sources as runtime_sources;
|
||||
use crate::set_disk::{BucketInfoQuorum, get_lock_acquire_timeout};
|
||||
use crate::storage_api_contracts::admin::DiskSetSelector;
|
||||
use crate::storage_api_contracts::bucket::{BUCKET_LIFECYCLE_LOCK_OBJECT, SRBucketDeleteOp};
|
||||
use crate::storage_api_contracts::namespace::NamespaceLocking as _;
|
||||
use futures::stream::{self, StreamExt};
|
||||
@@ -1151,7 +1152,7 @@ impl ECStore {
|
||||
let mut scoped_results = futures::future::join_all(self.bucket_sets().map(|(pool_index, set_index, set)| async move {
|
||||
let result = match quorum {
|
||||
BucketInfoQuorum::Read => set.stat_bucket_with_quorum(bucket, quorum).await,
|
||||
BucketInfoQuorum::Write => set.get_bucket_info(bucket, opts).await,
|
||||
BucketInfoQuorum::Write | BucketInfoQuorum::HealCandidates => set.get_bucket_info(bucket, opts).await,
|
||||
};
|
||||
(pool_index, set_index, result)
|
||||
}))
|
||||
@@ -1231,34 +1232,70 @@ impl ECStore {
|
||||
#[instrument(skip(self))]
|
||||
pub(super) async fn handle_list_bucket(&self, opts: &BucketOptions) -> Result<Vec<BucketInfo>> {
|
||||
// TODO(backlog): support cached bucket listing via opts.cached
|
||||
Ok(self.list_bucket_from_sets(opts, usize::MAX).await?.buckets)
|
||||
Ok(self
|
||||
.list_bucket_from_sets(opts, usize::MAX, BucketInfoQuorum::Write, None)
|
||||
.await?
|
||||
.buckets)
|
||||
}
|
||||
|
||||
pub async fn list_bucket_for_scanner(&self, opts: &BucketOptions) -> Result<crate::cluster::rpc::ScannerBucketListing> {
|
||||
self.list_bucket_from_sets(opts, SCANNER_BUCKET_LIST_SET_CONCURRENCY).await
|
||||
self.list_bucket_from_sets(opts, SCANNER_BUCKET_LIST_SET_CONCURRENCY, BucketInfoQuorum::Write, None)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Discover candidates for metadata initialization at each set's namespace
|
||||
/// read quorum. Metadata loading still validates persisted configuration;
|
||||
/// this listing must not be used as a complete scanner or mutation view.
|
||||
pub async fn list_buckets_for_metadata_startup(&self) -> Result<Vec<BucketInfo>> {
|
||||
Ok(self
|
||||
.list_bucket_from_sets(
|
||||
&BucketOptions {
|
||||
no_metadata: true,
|
||||
..Default::default()
|
||||
},
|
||||
SCANNER_BUCKET_LIST_SET_CONCURRENCY,
|
||||
BucketInfoQuorum::Read,
|
||||
None,
|
||||
)
|
||||
.await?
|
||||
.buckets)
|
||||
}
|
||||
|
||||
/// Preserve namespace completeness for administrator traversal. A scoped
|
||||
/// request must not depend on unrelated sets being available.
|
||||
pub async fn list_buckets_for_admin_heal(
|
||||
&self,
|
||||
selector: Option<DiskSetSelector>,
|
||||
) -> Result<crate::cluster::rpc::ScannerBucketListing> {
|
||||
self.list_bucket_from_sets(
|
||||
&BucketOptions {
|
||||
no_metadata: true,
|
||||
..Default::default()
|
||||
},
|
||||
SCANNER_BUCKET_LIST_SET_CONCURRENCY,
|
||||
BucketInfoQuorum::HealCandidates,
|
||||
selector,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn list_bucket_from_sets(
|
||||
&self,
|
||||
opts: &BucketOptions,
|
||||
max_concurrency: usize,
|
||||
quorum: BucketInfoQuorum,
|
||||
selector: Option<DiskSetSelector>,
|
||||
) -> Result<crate::cluster::rpc::ScannerBucketListing> {
|
||||
let set_count = self.pools.iter().map(|pool| pool.disk_set.len()).sum();
|
||||
let sets = self
|
||||
.bucket_sets()
|
||||
.filter(|(pool, set, _)| selector.is_none_or(|scope| scope.pool_idx == *pool && scope.set_idx == *set))
|
||||
.collect::<Vec<_>>();
|
||||
let set_count = sets.len();
|
||||
let concurrency = bucket_list_set_concurrency(set_count, max_concurrency);
|
||||
let deleted = opts.deleted;
|
||||
let cached = opts.cached;
|
||||
let no_metadata = opts.no_metadata;
|
||||
let mut set_listings = stream::iter(self.bucket_sets().map(move |(pool_index, set_index, set)| {
|
||||
let opts = BucketOptions {
|
||||
deleted,
|
||||
cached,
|
||||
no_metadata,
|
||||
};
|
||||
async move {
|
||||
set.list_bucket_for_scanner(&opts)
|
||||
.await
|
||||
.map(|(buckets, complete)| (pool_index, set_index, buckets, complete))
|
||||
}
|
||||
let mut set_listings = stream::iter(sets.into_iter().map(move |(pool_index, set_index, set)| async move {
|
||||
set.list_bucket_with_quorum(quorum)
|
||||
.await
|
||||
.map(|(buckets, complete)| (pool_index, set_index, buckets, complete))
|
||||
}))
|
||||
.buffer_unordered(concurrency);
|
||||
let mut topology_complete = set_count != 0;
|
||||
@@ -2224,6 +2261,64 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_bucket_discovery_preserves_completeness_and_scope() {
|
||||
use crate::storage_api_contracts::admin::DiskSetSelector;
|
||||
let (temp, store) = setup_bucket_quorum_test_env(&[2, 2], None).await;
|
||||
let empty = store
|
||||
.list_buckets_for_admin_heal(None)
|
||||
.await
|
||||
.expect("empty namespace enumeration");
|
||||
assert!(empty.buckets.is_empty());
|
||||
assert!(empty.topology_complete);
|
||||
for disk in 0..2 {
|
||||
tokio::fs::create_dir(temp.path().join(format!("pool0-disk{disk}/healthy-bucket")))
|
||||
.await
|
||||
.expect("healthy bucket");
|
||||
}
|
||||
tokio::fs::create_dir(temp.path().join("pool1-disk0/partial-bucket"))
|
||||
.await
|
||||
.expect("single surviving bucket directory");
|
||||
let global = store
|
||||
.list_buckets_for_admin_heal(None)
|
||||
.await
|
||||
.expect("online disk enumeration");
|
||||
assert!(global.topology_complete, "every disk answered the candidate inventory");
|
||||
assert_eq!(
|
||||
global.buckets.iter().map(|b| b.name.as_str()).collect::<Vec<_>>(),
|
||||
["healthy-bucket", "partial-bucket"]
|
||||
);
|
||||
let healthy = store
|
||||
.list_buckets_for_admin_heal(Some(DiskSetSelector::new(0, 0)))
|
||||
.await
|
||||
.expect("healthy scope");
|
||||
assert!(healthy.topology_complete);
|
||||
assert_eq!(healthy.buckets.len(), 1);
|
||||
assert_eq!(healthy.set_buckets.len(), 1);
|
||||
let partial = store
|
||||
.list_buckets_for_admin_heal(Some(DiskSetSelector::new(1, 0)))
|
||||
.await
|
||||
.expect("partial scope");
|
||||
assert!(partial.topology_complete);
|
||||
assert_eq!(partial.buckets[0].name, "partial-bucket");
|
||||
let invalid = store
|
||||
.list_buckets_for_admin_heal(Some(DiskSetSelector::new(0, 1)))
|
||||
.await
|
||||
.expect("unconfigured scope");
|
||||
assert!(!invalid.topology_complete);
|
||||
assert!(invalid.buckets.is_empty());
|
||||
let offline = take_set_disks_offline(&store, &store.pools[1].disk_set[0], &[0]).await;
|
||||
assert!(store.list_buckets_for_admin_heal(None).await.is_err(), "quorum errors must remain errors");
|
||||
assert!(
|
||||
store
|
||||
.list_buckets_for_admin_heal(Some(DiskSetSelector::new(0, 0)))
|
||||
.await
|
||||
.expect("unrelated offline pool")
|
||||
.topology_complete
|
||||
);
|
||||
restore_set_disks(&store, &store.pools[1].disk_set[0], offline).await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_not_override_when_metadata_created_is_unix_epoch() {
|
||||
assert!(!should_override_created_from_metadata(OffsetDateTime::UNIX_EPOCH));
|
||||
@@ -3543,6 +3638,79 @@ mod tests {
|
||||
assert_eq!(info_err, StorageError::ErasureReadQuorum);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn metadata_startup_listing_uses_each_sets_read_quorum() {
|
||||
for drives_per_pool in [vec![2], vec![3], vec![4], vec![6], vec![8], vec![4, 6]] {
|
||||
let (_temp_dir, store) = setup_bucket_quorum_test_env(&drives_per_pool, None).await;
|
||||
let bucket = "metadata-startup-read-quorum";
|
||||
for pool in &store.pools {
|
||||
pool.disk_set[0]
|
||||
.make_bucket(bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("healthy set should create the startup candidate");
|
||||
}
|
||||
let mut offline = Vec::new();
|
||||
for (pool_index, &width) in drives_per_pool.iter().enumerate() {
|
||||
let set = &store.pools[pool_index].disk_set[0];
|
||||
let indexes = (width.div_ceil(2)..width).collect::<Vec<_>>();
|
||||
offline.push(take_set_disks_offline(&store, set, &indexes).await);
|
||||
}
|
||||
let listing = store
|
||||
.list_buckets_for_metadata_startup()
|
||||
.await
|
||||
.expect("startup must discover buckets at every set's read quorum");
|
||||
assert_eq!(listing.len(), 1);
|
||||
assert_eq!(listing[0].name, bucket);
|
||||
if drives_per_pool.iter().any(|width| width % 2 == 0) {
|
||||
let error = store
|
||||
.list_bucket_for_scanner(&BucketOptions::default())
|
||||
.await
|
||||
.expect_err("startup discovery must not weaken scanner quorum");
|
||||
assert_eq!(error, StorageError::ErasureWriteQuorum);
|
||||
let error = store
|
||||
.list_bucket(&BucketOptions {
|
||||
no_metadata: true,
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.expect_err("ordinary listing must retain its existing quorum");
|
||||
assert_eq!(error, StorageError::ErasureWriteQuorum);
|
||||
}
|
||||
// A healthy earlier pool must not hide read-quorum loss in a later pool.
|
||||
let last_pool = store.pools.len() - 1;
|
||||
let set = &store.pools[last_pool].disk_set[0];
|
||||
let extra = take_set_disks_offline(&store, set, &[0]).await;
|
||||
let error = store
|
||||
.list_buckets_for_metadata_startup()
|
||||
.await
|
||||
.expect_err("every set must retain read quorum for startup discovery");
|
||||
assert_eq!(error, StorageError::ErasureReadQuorum);
|
||||
restore_set_disks(&store, set, extra).await;
|
||||
for (pool_index, disks) in offline.into_iter().enumerate() {
|
||||
restore_set_disks(&store, &store.pools[pool_index].disk_set[0], disks).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn metadata_startup_listing_does_not_accept_single_disk_bucket_remnants() {
|
||||
let (temp_dir, store) = setup_bucket_quorum_test_env(&[4], None).await;
|
||||
let bucket = "incomplete-startup-bucket";
|
||||
tokio::fs::create_dir(temp_dir.path().join("pool0-disk0").join(bucket))
|
||||
.await
|
||||
.expect("create one uncommitted directory remnant");
|
||||
let set = &store.pools[0].disk_set[0];
|
||||
let offline = take_set_disks_offline(&store, set, &[2, 3]).await;
|
||||
let listing = store
|
||||
.list_buckets_for_metadata_startup()
|
||||
.await
|
||||
.expect("two responsive disks satisfy discovery quorum");
|
||||
assert!(listing.iter().all(|info| info.name != bucket));
|
||||
restore_set_disks(&store, set, offline).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn scanner_bucket_listing_unions_every_erasure_set() {
|
||||
|
||||
@@ -270,13 +270,151 @@ impl ECStore {
|
||||
.map_err(|error| Error::other_with_context("bucket heal owner task failed", error))?
|
||||
}
|
||||
|
||||
/// Recover hidden delete journals through the lifecycle-owned heal boundary.
|
||||
pub async fn recover_delete_transactions(self: &Arc<Self>, bucket: &str, opts: &HealOpts) -> Result<HealResultItem> {
|
||||
Ok(self
|
||||
.recover_delete_transactions_at_incarnation(bucket, None, None, opts)
|
||||
.await?
|
||||
.unwrap_or_else(|| HealResultItem {
|
||||
heal_item_type: HealItemType::Bucket.to_string(),
|
||||
bucket: bucket.to_owned(),
|
||||
detail: "delete recovery: discovered=0".to_owned(),
|
||||
..Default::default()
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn recover_delete_transactions_at_incarnation(
|
||||
self: &Arc<Self>,
|
||||
bucket: &str,
|
||||
object: Option<&str>,
|
||||
expected: Option<Uuid>,
|
||||
opts: &HealOpts,
|
||||
) -> Result<Option<HealResultItem>> {
|
||||
self.recover_delete_transactions_in_scope(bucket, object, None, expected, opts)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn recover_delete_prefix_at_incarnation(
|
||||
self: &Arc<Self>,
|
||||
bucket: &str,
|
||||
prefix: &str,
|
||||
expected: Option<Uuid>,
|
||||
opts: &HealOpts,
|
||||
) -> Result<Option<HealResultItem>> {
|
||||
self.recover_delete_transactions_in_scope(bucket, None, Some(prefix), expected, opts)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn recover_delete_transactions_in_scope(
|
||||
self: &Arc<Self>,
|
||||
bucket: &str,
|
||||
object: Option<&str>,
|
||||
prefix: Option<&str>,
|
||||
expected: Option<Uuid>,
|
||||
opts: &HealOpts,
|
||||
) -> Result<Option<HealResultItem>> {
|
||||
if opts.no_lock && !opts.dry_run {
|
||||
return Err(StorageError::InvalidArgument(
|
||||
"heal".to_string(),
|
||||
"no_lock".to_string(),
|
||||
"delete recovery requires namespace locking".to_string(),
|
||||
));
|
||||
}
|
||||
let expected = match expected {
|
||||
Some(expected) => expected,
|
||||
None => self.bucket_incarnation_id_from_disk(bucket).await?,
|
||||
};
|
||||
let object = object.map(str::to_owned);
|
||||
let prefix = prefix.map(str::to_owned);
|
||||
self.run_bucket_heal_at_incarnation(bucket, expected, opts, |store, bucket, opts| async move {
|
||||
let scope = bucket_heal_scope(&bucket).ok_or(StorageError::PreconditionFailed)?;
|
||||
let explicit_delete = opts.remove
|
||||
&& metadata_sys::has_authoritative_never_versioned_state_in(&store.ctx, &bucket).await?
|
||||
&& matches!(
|
||||
metadata_sys::get_object_lock_config_state_in(&store.ctx, &bucket).await?,
|
||||
metadata_sys::ObjectLockConfigState::ConfirmedAbsent
|
||||
);
|
||||
let movement_gate = store.ctx.data_movement_operation_gate();
|
||||
let _movement_guard = movement_gate.read().await;
|
||||
let can_commit = || scope.check().is_ok();
|
||||
let recovery_context = crate::set_disk::DeleteRecoveryContext {
|
||||
dry_run: opts.dry_run,
|
||||
explicit_delete,
|
||||
namespace_owner: (!opts.dry_run)
|
||||
.then(|| Arc::new((scope.clone(), store.ctx.begin_namespace_commit())) as Arc<dyn Send + Sync>),
|
||||
..crate::set_disk::DeleteRecoveryContext::new(Some(scope.incarnation), &can_commit)
|
||||
};
|
||||
let mut summary = crate::set_disk::DeleteRecoverySummary::default();
|
||||
for pool in store.get_pools_for_heal_object(&opts)? {
|
||||
if store.is_suspended(pool.pool_idx).await || store.is_pool_rebalancing(pool.pool_idx).await {
|
||||
return Err(StorageError::SlowDown);
|
||||
}
|
||||
for (set_index, set) in pool.disk_set.iter().enumerate() {
|
||||
if opts.set.is_some_and(|requested| requested != set_index) {
|
||||
continue;
|
||||
}
|
||||
if let Some(object) = &object {
|
||||
if !Arc::ptr_eq(set, &pool.get_disks_by_key(object)) {
|
||||
continue;
|
||||
}
|
||||
summary.add(set.recover_delete_object(&bucket, object, recovery_context.clone()).await?);
|
||||
} else if let Some(prefix) = &prefix {
|
||||
summary.add(set.recover_delete_prefix(&bucket, prefix, recovery_context.clone()).await?);
|
||||
} else {
|
||||
summary.add(set.recover_delete_bucket(&bucket, recovery_context.clone()).await?);
|
||||
}
|
||||
}
|
||||
}
|
||||
if summary.pending > 0 {
|
||||
return Err(StorageError::ErasureReadQuorum);
|
||||
}
|
||||
if object.is_some() && !opts.dry_run && summary.blocked > 0 {
|
||||
return Err(StorageError::SlowDown);
|
||||
}
|
||||
if summary.discovered == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(HealResultItem {
|
||||
heal_item_type: if object.is_some() {
|
||||
HealItemType::Object
|
||||
} else {
|
||||
HealItemType::Bucket
|
||||
}
|
||||
.to_string(),
|
||||
object: object.or(prefix).unwrap_or_default(),
|
||||
bucket,
|
||||
detail: format!(
|
||||
"delete recovery{}: discovered={}, committed={}, rolled_back={}, reclaimed={}, blocked={}",
|
||||
if opts.dry_run { " (dry run)" } else { "" },
|
||||
summary.discovered,
|
||||
summary.committed,
|
||||
summary.rolled_back,
|
||||
summary.reclaimed,
|
||||
summary.blocked
|
||||
),
|
||||
..Default::default()
|
||||
}))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn heal_bucket_at_incarnation(
|
||||
self: &Arc<Self>,
|
||||
bucket: &str,
|
||||
expected: uuid::Uuid,
|
||||
opts: &HealOpts,
|
||||
) -> Result<HealResultItem> {
|
||||
self.run_bucket_heal_at_incarnation(bucket, expected, opts, |store, bucket, opts| async move {
|
||||
self.run_bucket_heal_at_incarnation(bucket, expected, opts, move |store, bucket, mut opts| async move {
|
||||
if !opts.recreate && !opts.remove {
|
||||
let mut missing = false;
|
||||
for (pool, set) in store.heal_erasure_set_scopes(&opts).await? {
|
||||
missing |= store.pools[pool].get_disks(set).bucket_needs_reconstruction(&bucket).await?;
|
||||
}
|
||||
if missing {
|
||||
crate::bucket::metadata_sys::validate_bucket_reconstruction_in(&store.ctx, &bucket, expected).await?;
|
||||
opts.recreate = true;
|
||||
}
|
||||
}
|
||||
store.heal_bucket(&bucket, &opts).await
|
||||
})
|
||||
.await
|
||||
@@ -1323,6 +1461,178 @@ mod tests {
|
||||
.expect("minimal pool should build")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn admin_bucket_recovery_recreates_missing_directories_at_current_incarnation() {
|
||||
let (root, store, shutdown) = multi_pool_heal_store().await;
|
||||
let bucket = "admin-bucket-recovery";
|
||||
store
|
||||
.make_bucket(bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("create a committed bucket generation");
|
||||
let incarnation = store
|
||||
.bucket_incarnation_id_from_disk(bucket)
|
||||
.await
|
||||
.expect("persisted generation");
|
||||
for disk in 1..4 {
|
||||
tokio::fs::remove_dir(root.path().join(format!("pool0-disk{disk}/{bucket}")))
|
||||
.await
|
||||
.expect("remove only an empty fixture bucket directory");
|
||||
}
|
||||
store
|
||||
.heal_bucket_at_incarnation(bucket, incarnation, &HealOpts::default())
|
||||
.await
|
||||
.expect("default administrator recovery should use committed generation authority");
|
||||
for disk in 0..4 {
|
||||
assert!(root.path().join(format!("pool0-disk{disk}/{bucket}")).is_dir());
|
||||
}
|
||||
assert_eq!(
|
||||
store
|
||||
.bucket_incarnation_id_from_disk(bucket)
|
||||
.await
|
||||
.expect("recovered generation"),
|
||||
incarnation
|
||||
);
|
||||
shutdown.cancel();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn admin_bucket_recovery_rejects_missing_corrupt_uncommitted_and_retired_authority() {
|
||||
let (root, store, shutdown) = multi_pool_heal_store().await;
|
||||
for state in ["missing", "corrupt", "uncommitted", "mismatched", "retired"] {
|
||||
let bucket = format!("recovery-authority-{state}");
|
||||
store
|
||||
.make_bucket(&bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("create fixture generation");
|
||||
let incarnation = store
|
||||
.bucket_incarnation_id_from_disk(&bucket)
|
||||
.await
|
||||
.expect("fixture identity");
|
||||
let (mut metadata, persisted) = metadata_sys::get_config_from_disk_with_presence_in(&store.ctx, &bucket)
|
||||
.await
|
||||
.expect("committed metadata");
|
||||
assert!(persisted && metadata.bucket_creation_committed);
|
||||
let path = metadata.save_file_path();
|
||||
match state {
|
||||
"missing" => delete_config(store.clone(), &path).await.expect("remove metadata authority"),
|
||||
"corrupt" => save_config(store.clone(), &path, b"corrupt bucket metadata".to_vec())
|
||||
.await
|
||||
.expect("corrupt authority"),
|
||||
"uncommitted" | "mismatched" => {
|
||||
if state == "uncommitted" {
|
||||
metadata.bucket_creation_committed = false;
|
||||
} else {
|
||||
metadata.bucket_incarnation_id = Uuid::new_v4();
|
||||
}
|
||||
metadata
|
||||
.save_with_store_committed(store.clone())
|
||||
.await
|
||||
.expect("inject inconsistent metadata authority");
|
||||
}
|
||||
"retired" => {
|
||||
// A returning disk may retain a directory after retirement was committed.
|
||||
crate::bucket::retirement::commit_retirement(store.clone(), &bucket, incarnation, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("persist retirement evidence");
|
||||
}
|
||||
_ => unreachable!("fixed fixture states"),
|
||||
}
|
||||
for disk in 1..4 {
|
||||
tokio::fs::remove_dir(root.path().join(format!("pool0-disk{disk}/{bucket}")))
|
||||
.await
|
||||
.expect("lose fixture directories");
|
||||
}
|
||||
assert!(
|
||||
store
|
||||
.heal_bucket_at_incarnation(&bucket, incarnation, &HealOpts::default())
|
||||
.await
|
||||
.is_err(),
|
||||
"must reject {state}"
|
||||
);
|
||||
for disk in 1..4 {
|
||||
assert!(
|
||||
!root.path().join(format!("pool0-disk{disk}/{bucket}")).exists(),
|
||||
"{state} cannot authorize reconstruction"
|
||||
);
|
||||
}
|
||||
}
|
||||
shutdown.cancel();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn admin_bucket_recovery_rejects_offline_disks_and_fully_missing_sets() {
|
||||
for offline in [false, true] {
|
||||
let (root, store, shutdown) = multi_pool_heal_store().await;
|
||||
let bucket = "recovery-unproven-set";
|
||||
store
|
||||
.make_bucket(bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("fixture bucket");
|
||||
let incarnation = store.bucket_incarnation_id_from_disk(bucket).await.expect("fixture identity");
|
||||
let first_missing = usize::from(offline);
|
||||
for disk in first_missing..4 {
|
||||
tokio::fs::remove_dir(root.path().join(format!("pool0-disk{disk}/{bucket}")))
|
||||
.await
|
||||
.expect("remove fixture directories");
|
||||
}
|
||||
if offline {
|
||||
let disk = store.pools[0].disk_set[0].disks.read().await[0]
|
||||
.clone()
|
||||
.expect("online fixture disk");
|
||||
disk.close().await.expect("stop disk monitoring");
|
||||
disk.force_offline_for_test();
|
||||
}
|
||||
assert!(
|
||||
store
|
||||
.heal_bucket_at_incarnation(bucket, incarnation, &HealOpts::default())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
for disk in first_missing..4 {
|
||||
assert!(!root.path().join(format!("pool0-disk{disk}/{bucket}")).exists());
|
||||
}
|
||||
shutdown.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn admin_bucket_recovery_dry_run_preserves_missing_directories() {
|
||||
let (root, store, shutdown) = multi_pool_heal_store().await;
|
||||
let bucket = "recovery-dry-run";
|
||||
store
|
||||
.make_bucket(bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("fixture bucket");
|
||||
let incarnation = store.bucket_incarnation_id_from_disk(bucket).await.expect("fixture identity");
|
||||
for disk in 1..4 {
|
||||
tokio::fs::remove_dir(root.path().join(format!("pool0-disk{disk}/{bucket}")))
|
||||
.await
|
||||
.expect("remove fixture directories");
|
||||
}
|
||||
store
|
||||
.heal_bucket_at_incarnation(
|
||||
bucket,
|
||||
incarnation,
|
||||
&HealOpts {
|
||||
dry_run: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("dry run can inspect eligible recovery");
|
||||
for disk in 1..4 {
|
||||
assert!(
|
||||
!root.path().join(format!("pool0-disk{disk}/{bucket}")).exists(),
|
||||
"dry run must not reconstruct directories"
|
||||
);
|
||||
}
|
||||
shutdown.cancel();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn bucket_incarnation_heal_preserves_successor_shards_and_allows_fresh_repair() {
|
||||
|
||||
@@ -108,6 +108,10 @@ fn should_retry_format_load(err: &Error) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
fn format_load_retry_exhausted(err: &Error, retries: u32) -> bool {
|
||||
retries >= 10 && !matches!(err, Error::ErasureReadQuorum | Error::FirstDiskWait | Error::NotFirstDisk)
|
||||
}
|
||||
|
||||
fn should_auto_start_rebalance_after_init(decommission_running: bool, rebalance_resume_required: bool) -> bool {
|
||||
rebalance_resume_required && !decommission_running
|
||||
}
|
||||
@@ -484,7 +488,7 @@ impl ECStore {
|
||||
check_disk_fatal_errs(&errs)?;
|
||||
|
||||
let loaded_format = {
|
||||
let mut times = 0;
|
||||
let mut times = 0_u32;
|
||||
let mut interval = 1;
|
||||
loop {
|
||||
match init_format::connect_load_init_formats_with_instance_ctx(
|
||||
@@ -500,13 +504,13 @@ impl ECStore {
|
||||
Ok(fm) => break Ok(fm),
|
||||
Err(e) if !should_retry_format_load(&e) => break Err(e),
|
||||
// Wrap the final error if we are giving up
|
||||
Err(e) if times >= 10 => {
|
||||
Err(e) if format_load_retry_exhausted(&e, times) => {
|
||||
break Err(Error::other(format!("store init failed to load formats after {times} retries: {e}")));
|
||||
}
|
||||
// Retrying so just drop the error
|
||||
Err(_) => {}
|
||||
}
|
||||
times += 1;
|
||||
times = times.saturating_add(1);
|
||||
if interval < 16 {
|
||||
interval *= 2;
|
||||
}
|
||||
@@ -519,6 +523,12 @@ impl ECStore {
|
||||
"Retrying storage format load"
|
||||
);
|
||||
select! {
|
||||
_ = ctx.cancelled() => {
|
||||
return Err(Error::other(std::io::Error::new(
|
||||
std::io::ErrorKind::Interrupted,
|
||||
"storage format initialization cancelled",
|
||||
)));
|
||||
}
|
||||
_ = tokio::signal::ctrl_c() => {
|
||||
info!(
|
||||
event = EVENT_STORE_FORMAT_RETRY,
|
||||
@@ -2201,6 +2211,23 @@ mod tests {
|
||||
assert_eq!(attempts.load(Ordering::SeqCst), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_peer_wait_does_not_exhaust_the_retry_budget() {
|
||||
for retries in [0, 9, 10, u32::MAX] {
|
||||
for error in [
|
||||
StorageError::ErasureReadQuorum,
|
||||
StorageError::FirstDiskWait,
|
||||
StorageError::NotFirstDisk,
|
||||
] {
|
||||
assert!(
|
||||
!super::format_load_retry_exhausted(&error, retries),
|
||||
"peer availability must remain retryable"
|
||||
);
|
||||
}
|
||||
assert_eq!(super::format_load_retry_exhausted(&StorageError::Timeout, retries), retries >= 10);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_retry_format_load_rejects_permanent_corruption() {
|
||||
assert!(!should_retry_format_load(&StorageError::CorruptedFormat));
|
||||
@@ -5003,6 +5030,339 @@ mod tests {
|
||||
shutdown.cancel();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial(storage_class_env)]
|
||||
async fn repeated_abort_recovers_exact_orphan_before_decommission_drains() {
|
||||
use crate::storage_api_contracts::namespace::NamespaceLocking as _;
|
||||
use sha2::Digest;
|
||||
|
||||
let temp_dir = tempfile::tempdir().expect("create orphan recovery store dir");
|
||||
let (_ctx, store, shutdown) =
|
||||
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "orphan-multipart-drain", &[4, 4])).await;
|
||||
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
|
||||
let bucket = format!("orphan-multipart-{}", uuid::Uuid::new_v4());
|
||||
let object = "exact-object.bin";
|
||||
store
|
||||
.make_bucket(&bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("create bucket");
|
||||
let incarnation = store.bucket_incarnation_id(&bucket).await.expect("bucket incarnation");
|
||||
let lifecycle_guard = store.acquire_bucket_lifecycle_read_lock(&bucket).await.expect("bucket guard");
|
||||
let mut upload_opts = ObjectOptions {
|
||||
expected_bucket_incarnation_id: Some(incarnation),
|
||||
..Default::default()
|
||||
};
|
||||
upload_opts.add_bucket_lifecycle_lock_guard(&lifecycle_guard);
|
||||
let upload = store.pools[0]
|
||||
.new_multipart_upload(&bucket, object, &upload_opts)
|
||||
.await
|
||||
.expect("create source upload");
|
||||
drop(lifecycle_guard);
|
||||
store
|
||||
.abort_multipart_upload(&bucket, object, &upload.upload_id, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("initial abort");
|
||||
|
||||
let upload_sha =
|
||||
hex_simd::encode_to_string(sha2::Sha256::digest(format!("{bucket}/{object}").as_bytes()), hex_simd::AsciiCase::Lower);
|
||||
let upload_uuid = crate::runtime::sources::upload_uuid_suffix(&upload.upload_id);
|
||||
let staging_dir = |pool_index: usize, disk_index: usize| {
|
||||
temp_dir
|
||||
.path()
|
||||
.join(format!("pool{pool_index}/set0/disk{disk_index}"))
|
||||
.join(crate::disk::RUSTFS_META_MULTIPART_BUCKET)
|
||||
.join(&upload_sha)
|
||||
.join(&upload_uuid)
|
||||
};
|
||||
for disk_index in 0..3 {
|
||||
let path = staging_dir(0, disk_index);
|
||||
tokio::fs::create_dir_all(&path).await.expect("create late part directory");
|
||||
tokio::fs::write(path.join("part.1"), b"late part after abort")
|
||||
.await
|
||||
.expect("write late part fixture");
|
||||
}
|
||||
mark_test_pool_decommissioning(&store, 0).await;
|
||||
assert!(
|
||||
store
|
||||
.list_multipart_uploads(&bucket, "", None, None, None, 100)
|
||||
.await
|
||||
.expect("logical upload listing")
|
||||
.uploads
|
||||
.is_empty()
|
||||
);
|
||||
assert!(store.ensure_decommission_multipart_uploads_drained_for_test(0).await.is_err());
|
||||
assert!(
|
||||
store
|
||||
.abort_multipart_upload(&bucket, "different-key", &upload.upload_id, &ObjectOptions::default())
|
||||
.await
|
||||
.is_err(),
|
||||
"the same UploadID for a different key must not recover this path"
|
||||
);
|
||||
|
||||
// A minority metadata copy in another pool blocks recovery everywhere.
|
||||
let conflicting_metadata = staging_dir(1, 0).join(crate::disk::STORAGE_FORMAT_FILE);
|
||||
tokio::fs::create_dir_all(conflicting_metadata.parent().expect("metadata parent"))
|
||||
.await
|
||||
.expect("create conflicting pool staging");
|
||||
tokio::fs::write(&conflicting_metadata, b"unreadable metadata")
|
||||
.await
|
||||
.expect("write conflicting metadata");
|
||||
assert!(
|
||||
store
|
||||
.abort_multipart_upload(&bucket, object, &upload.upload_id, &ObjectOptions::default())
|
||||
.await
|
||||
.is_err(),
|
||||
"no pool may be cleaned until every pool proves absence"
|
||||
);
|
||||
for disk_index in 0..3 {
|
||||
assert!(staging_dir(0, disk_index).join("part.1").exists());
|
||||
}
|
||||
tokio::fs::remove_file(conflicting_metadata)
|
||||
.await
|
||||
.expect("remove conflicting fixture");
|
||||
let (paused, release) = crate::store::multipart::pause_orphan_multipart_cleanup_for_test(&bucket);
|
||||
let recovery_store = store.clone();
|
||||
let recovery_bucket = bucket.clone();
|
||||
let recovery_upload = upload.upload_id.clone();
|
||||
let mut recovery = tokio::spawn(async move {
|
||||
recovery_store
|
||||
.abort_multipart_upload(&recovery_bucket, object, &recovery_upload, &ObjectOptions::default())
|
||||
.await
|
||||
});
|
||||
tokio::time::timeout(Duration::from_secs(10), async {
|
||||
tokio::select! {
|
||||
paused = paused => paused.expect("cleanup owner announces arrival"),
|
||||
result = &mut recovery => panic!("recovery returned before physical cleanup: {result:?}"),
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("recovery should reach physical cleanup");
|
||||
recovery.abort();
|
||||
assert!(recovery.await.expect_err("request waiter is cancellable").is_cancelled());
|
||||
let upload_lock = store.pools[0]
|
||||
.get_disks_by_key(object)
|
||||
.new_ns_lock(crate::disk::RUSTFS_META_MULTIPART_BUCKET, &format!("{upload_sha}/{upload_uuid}"))
|
||||
.await
|
||||
.expect("same upload namespace");
|
||||
let mut upload_writer = Box::pin(upload_lock.get_write_lock(Duration::from_secs(10)));
|
||||
let mut bucket_writer = Box::pin(store.acquire_bucket_lifecycle_write_lock(&bucket));
|
||||
assert!(
|
||||
futures::poll!(&mut upload_writer).is_pending(),
|
||||
"cancelled recovery must retain the upload lock"
|
||||
);
|
||||
assert!(
|
||||
futures::poll!(&mut bucket_writer).is_pending(),
|
||||
"cancelled recovery must retain the real bucket guard"
|
||||
);
|
||||
release.send(()).expect("allow physical cleanup to finish");
|
||||
let (upload_guard, bucket_guard) =
|
||||
tokio::time::timeout(Duration::from_secs(10), async { tokio::join!(upload_writer, bucket_writer) })
|
||||
.await
|
||||
.expect("both guards should release after physical cleanup");
|
||||
drop(upload_guard.expect("upload writer resumes"));
|
||||
drop(bucket_guard.expect("bucket writer resumes"));
|
||||
for pool_index in 0..2 {
|
||||
for disk_index in 0..4 {
|
||||
assert!(!staging_dir(pool_index, disk_index).exists(), "every checked staging path must be absent");
|
||||
}
|
||||
}
|
||||
store
|
||||
.ensure_decommission_multipart_uploads_drained_for_test(0)
|
||||
.await
|
||||
.expect("physical decommission gate should open after recovery");
|
||||
assert!(
|
||||
matches!(
|
||||
store
|
||||
.abort_multipart_upload(&bucket, object, &upload.upload_id, &ObjectOptions::default())
|
||||
.await,
|
||||
Err(StorageError::InvalidUploadID(_, _, _))
|
||||
),
|
||||
"an absent upload must not claim to have recovered anything"
|
||||
);
|
||||
shutdown.cancel();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial(storage_class_env)]
|
||||
async fn repeated_abort_keeps_no_such_upload_while_a_disk_is_offline() {
|
||||
use sha2::Digest;
|
||||
|
||||
let temp_dir = tempfile::tempdir().expect("create offline-disk recovery store dir");
|
||||
let (_ctx, store, shutdown) =
|
||||
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "orphan-multipart-offline", &[4, 4])).await;
|
||||
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
|
||||
let bucket = format!("orphan-multipart-{}", uuid::Uuid::new_v4());
|
||||
let object = "offline-object.bin";
|
||||
store
|
||||
.make_bucket(&bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("create bucket");
|
||||
let incarnation = store.bucket_incarnation_id(&bucket).await.expect("bucket incarnation");
|
||||
let lifecycle_guard = store.acquire_bucket_lifecycle_read_lock(&bucket).await.expect("bucket guard");
|
||||
let mut upload_opts = ObjectOptions {
|
||||
expected_bucket_incarnation_id: Some(incarnation),
|
||||
..Default::default()
|
||||
};
|
||||
upload_opts.add_bucket_lifecycle_lock_guard(&lifecycle_guard);
|
||||
let upload = store.pools[0]
|
||||
.new_multipart_upload(&bucket, object, &upload_opts)
|
||||
.await
|
||||
.expect("create source upload");
|
||||
drop(lifecycle_guard);
|
||||
store
|
||||
.abort_multipart_upload(&bucket, object, &upload.upload_id, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("initial abort");
|
||||
|
||||
let upload_sha =
|
||||
hex_simd::encode_to_string(sha2::Sha256::digest(format!("{bucket}/{object}").as_bytes()), hex_simd::AsciiCase::Lower);
|
||||
let upload_uuid = crate::runtime::sources::upload_uuid_suffix(&upload.upload_id);
|
||||
let staging_dir = |disk_index: usize| {
|
||||
temp_dir
|
||||
.path()
|
||||
.join(format!("pool0/set0/disk{disk_index}"))
|
||||
.join(crate::disk::RUSTFS_META_MULTIPART_BUCKET)
|
||||
.join(&upload_sha)
|
||||
.join(&upload_uuid)
|
||||
};
|
||||
for disk_index in 0..3 {
|
||||
let path = staging_dir(disk_index);
|
||||
tokio::fs::create_dir_all(&path).await.expect("create late part directory");
|
||||
tokio::fs::write(path.join("part.1"), b"late part after abort")
|
||||
.await
|
||||
.expect("write late part fixture");
|
||||
}
|
||||
|
||||
// One offline disk means absence cannot be proven. The idempotent Abort
|
||||
// must keep answering NoSuchUpload instead of surfacing a disk error,
|
||||
// and must not delete anything.
|
||||
let set = store.pools[0].get_disks_by_key(object);
|
||||
let offline = set.disks.write().await[3].take();
|
||||
assert!(offline.is_some(), "fixture disk must have been online");
|
||||
assert!(
|
||||
matches!(
|
||||
store
|
||||
.abort_multipart_upload(&bucket, object, &upload.upload_id, &ObjectOptions::default())
|
||||
.await,
|
||||
Err(StorageError::InvalidUploadID(_, _, _))
|
||||
),
|
||||
"unprovable absence must keep NoSuchUpload while a disk is offline"
|
||||
);
|
||||
for disk_index in 0..3 {
|
||||
assert!(
|
||||
staging_dir(disk_index).join("part.1").exists(),
|
||||
"no staging may be deleted while a disk is offline"
|
||||
);
|
||||
}
|
||||
|
||||
set.disks.write().await[3] = offline;
|
||||
store
|
||||
.abort_multipart_upload(&bucket, object, &upload.upload_id, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("recovery resumes once every disk is observable");
|
||||
for disk_index in 0..4 {
|
||||
assert!(!staging_dir(disk_index).exists(), "recovered staging must be absent");
|
||||
}
|
||||
shutdown.cancel();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial(storage_class_env)]
|
||||
async fn orphan_multipart_recovery_preserves_current_bucket_incarnation() {
|
||||
use sha2::Digest;
|
||||
|
||||
let temp_dir = tempfile::tempdir().expect("create bucket lifetime recovery store dir");
|
||||
let (_ctx, store, shutdown) =
|
||||
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "orphan-bucket-lifetime", &[4])).await;
|
||||
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
|
||||
let bucket = format!("orphan-lifetime-{}", uuid::Uuid::new_v4());
|
||||
let object = "object.bin";
|
||||
store
|
||||
.make_bucket(&bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("create original bucket");
|
||||
let original_incarnation = store.bucket_incarnation_id(&bucket).await.expect("original incarnation");
|
||||
let upload = store
|
||||
.new_multipart_upload(&bucket, object, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("create original upload");
|
||||
store
|
||||
.abort_multipart_upload(&bucket, object, &upload.upload_id, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("abort original upload");
|
||||
store
|
||||
.delete_bucket(&bucket, &crate::storage_api_contracts::bucket::DeleteBucketOptions::default())
|
||||
.await
|
||||
.expect("delete original bucket");
|
||||
store
|
||||
.make_bucket(&bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("recreate bucket");
|
||||
assert_ne!(store.bucket_incarnation_id(&bucket).await.expect("new incarnation"), original_incarnation);
|
||||
let mut reader = PutObjReader::from_vec(b"current bucket object".to_vec());
|
||||
store
|
||||
.put_object(&bucket, object, &mut reader, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("write current bucket object");
|
||||
let current_upload = store
|
||||
.new_multipart_upload(&bucket, object, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("create current incarnation upload for the same key");
|
||||
|
||||
let upload_sha =
|
||||
hex_simd::encode_to_string(sha2::Sha256::digest(format!("{bucket}/{object}").as_bytes()), hex_simd::AsciiCase::Lower);
|
||||
let upload_uuid = crate::runtime::sources::upload_uuid_suffix(&upload.upload_id);
|
||||
let leftover = temp_dir
|
||||
.path()
|
||||
.join("pool0/set0/disk0")
|
||||
.join(crate::disk::RUSTFS_META_MULTIPART_BUCKET)
|
||||
.join(upload_sha)
|
||||
.join(upload_uuid)
|
||||
.join("part.1");
|
||||
tokio::fs::create_dir_all(leftover.parent().expect("staging parent"))
|
||||
.await
|
||||
.expect("create old incarnation staging");
|
||||
tokio::fs::write(&leftover, b"previous bucket lifetime")
|
||||
.await
|
||||
.expect("write previous incarnation fixture");
|
||||
for opts in [
|
||||
ObjectOptions {
|
||||
expected_bucket_incarnation_id: Some(original_incarnation),
|
||||
..Default::default()
|
||||
},
|
||||
ObjectOptions {
|
||||
expected_bucket_incarnation_id: Some(uuid::Uuid::nil()),
|
||||
..Default::default()
|
||||
},
|
||||
] {
|
||||
assert!(
|
||||
store
|
||||
.abort_multipart_upload(&bucket, object, &upload.upload_id, &opts)
|
||||
.await
|
||||
.is_err(),
|
||||
"a stale or nil current-bucket identity must fail closed"
|
||||
);
|
||||
assert!(leftover.exists(), "failed bucket lifetime proof must preserve the path");
|
||||
}
|
||||
store
|
||||
.abort_multipart_upload(&bucket, object, &upload.upload_id, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("exact metadata-less staging from an old incarnation is unreferenced");
|
||||
assert!(!leftover.exists());
|
||||
store
|
||||
.get_multipart_info(&bucket, object, ¤t_upload.upload_id, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("recovery must preserve the current incarnation's active upload");
|
||||
let mut reader = store
|
||||
.get_object_reader(&bucket, object, None, HeaderMap::new(), &ObjectOptions::default())
|
||||
.await
|
||||
.expect("current object must remain readable");
|
||||
let mut body = Vec::new();
|
||||
reader.stream.read_to_end(&mut body).await.expect("read current object");
|
||||
assert_eq!(body, b"current bucket object");
|
||||
shutdown.cancel();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial(storage_class_env)]
|
||||
async fn active_multipart_upload_routes_before_faulted_suspended_source() {
|
||||
@@ -9567,8 +9927,18 @@ mod tests {
|
||||
#[cfg(feature = "test-util")]
|
||||
async fn decommission_migrates_and_verifies_registered_durable_ilm_records_scenario() {
|
||||
let temp_dir = tempfile::tempdir().expect("create temp store dir");
|
||||
let (ctx, store, _shutdown) =
|
||||
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "durable-ilm-decommission", &[4, 4])).await;
|
||||
// The explicit recovery below must be the only journal consumer,
|
||||
// including when receipt validation outlasts the background interval.
|
||||
let mut instance_ctx = crate::runtime::instance::InstanceContext::new();
|
||||
instance_ctx.suppress_tier_delete_journal_recovery_for_test();
|
||||
let (ctx, store, _shutdown) = without_storage_class_env(build_isolated_test_store_with_layout(
|
||||
temp_dir.path(),
|
||||
"durable-ilm-decommission",
|
||||
&[(1, 4), (1, 4)],
|
||||
CancellationToken::new(),
|
||||
Some(Arc::new(instance_ctx)),
|
||||
))
|
||||
.await;
|
||||
|
||||
let tier_name = "DECOMMISSION-ILM";
|
||||
let backend = register_transition_reconcile_test_tier(&ctx.tier_config_mgr(), tier_name).await;
|
||||
@@ -10040,6 +10410,16 @@ mod tests {
|
||||
.await
|
||||
.expect("manual task receipt should restore after the corruption check");
|
||||
|
||||
assert!(matches!(
|
||||
com::read_config(store.pools[0].clone(), &tier_path).await,
|
||||
Err(Error::ConfigNotFound)
|
||||
));
|
||||
assert_eq!(
|
||||
com::read_config(store.pools[1].clone(), &tier_path)
|
||||
.await
|
||||
.expect("manual recovery must still observe the exact migrated tier journal"),
|
||||
tier_bytes
|
||||
);
|
||||
let tier_stats = recover_tier_delete_journal_entries(store.clone(), 100, None)
|
||||
.await
|
||||
.expect("tier journal recovery should consume the migrated record before completion");
|
||||
@@ -10796,8 +11176,19 @@ mod tests {
|
||||
const JOURNAL_COUNT: usize = 65;
|
||||
|
||||
let temp_dir = tempfile::tempdir().expect("create bounded rollback store dir");
|
||||
let (ctx, store, _shutdown) =
|
||||
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "bounded-dispatch-rollback", &[4])).await;
|
||||
// The test's worker must be the only one to roll back the seeded manifest: if
|
||||
// the setup outlasts TIER_DELETE_JOURNAL_RECOVERY_INTERVAL, a background pass
|
||||
// rolls it back before the slow-delete hook is installed.
|
||||
let mut instance_ctx = crate::runtime::instance::InstanceContext::new();
|
||||
instance_ctx.suppress_tier_delete_journal_recovery_for_test();
|
||||
let (ctx, store, _shutdown) = without_storage_class_env(build_isolated_test_store_with_layout(
|
||||
temp_dir.path(),
|
||||
"bounded-dispatch-rollback",
|
||||
&[(1, 4)],
|
||||
CancellationToken::new(),
|
||||
Some(Arc::new(instance_ctx)),
|
||||
))
|
||||
.await;
|
||||
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
|
||||
let bucket = "bounded-dispatch-rollback-bucket";
|
||||
store
|
||||
@@ -10953,8 +11344,19 @@ mod tests {
|
||||
#[serial_test::serial(storage_class_env)]
|
||||
async fn dispatch_manifest_page_timeout_detaches_one_deduplicated_worker_until_convergence() {
|
||||
let temp_dir = tempfile::tempdir().expect("create detached rollback store dir");
|
||||
let (ctx, store, _shutdown) =
|
||||
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "detached-dispatch-rollback", &[4])).await;
|
||||
// The test's own passes must be the only ones to process the seeded manifest:
|
||||
// if the setup outlasts TIER_DELETE_JOURNAL_RECOVERY_INTERVAL, a background
|
||||
// pass rolls it back before the slow-delete hook is installed.
|
||||
let mut instance_ctx = crate::runtime::instance::InstanceContext::new();
|
||||
instance_ctx.suppress_tier_delete_journal_recovery_for_test();
|
||||
let (ctx, store, _shutdown) = without_storage_class_env(build_isolated_test_store_with_layout(
|
||||
temp_dir.path(),
|
||||
"detached-dispatch-rollback",
|
||||
&[(1, 4)],
|
||||
CancellationToken::new(),
|
||||
Some(Arc::new(instance_ctx)),
|
||||
))
|
||||
.await;
|
||||
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
|
||||
let bucket = "detached-dispatch-rollback-bucket";
|
||||
store
|
||||
@@ -11022,9 +11424,20 @@ mod tests {
|
||||
const WORKER_LIMIT: usize = 4;
|
||||
|
||||
let temp_dir = tempfile::tempdir().expect("create bounded detached rollback store dir");
|
||||
let (ctx, store, shutdown) =
|
||||
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "bounded-detached-dispatch-rollback", &[4]))
|
||||
.await;
|
||||
// The test's passes must be the only ones to process the seeded manifests: if
|
||||
// the setup outlasts TIER_DELETE_JOURNAL_RECOVERY_INTERVAL, a background pass
|
||||
// rolls them back before the pause hook is installed. The restarted store
|
||||
// below keeps background recovery, which its half of the test relies on.
|
||||
let mut instance_ctx = crate::runtime::instance::InstanceContext::new();
|
||||
instance_ctx.suppress_tier_delete_journal_recovery_for_test();
|
||||
let (ctx, store, shutdown) = without_storage_class_env(build_isolated_test_store_with_layout(
|
||||
temp_dir.path(),
|
||||
"bounded-detached-dispatch-rollback",
|
||||
&[(1, 4)],
|
||||
CancellationToken::new(),
|
||||
Some(Arc::new(instance_ctx)),
|
||||
))
|
||||
.await;
|
||||
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
|
||||
let tier_name = "BOUNDED-DETACHED-DISPATCH-ROLLBACK";
|
||||
let _backend = register_mock_tier(&ctx.tier_config_mgr(), tier_name).await;
|
||||
@@ -17346,6 +17759,19 @@ mod tests {
|
||||
#[tokio::test]
|
||||
#[serial_test::serial(storage_class_env)]
|
||||
async fn delete_holds_bucket_incarnation_sentinel_through_object_commit() {
|
||||
// The recreation waits for the delete's sentinel on its lifecycle write lock
|
||||
// from before `barrier.release()`, so that lock wait also has to cover the
|
||||
// delete's commit. Raise the lock acquire timeout from the 5 s default to give
|
||||
// the commit room on a starved runner; a kept sentinel still fails the test,
|
||||
// after 30 s instead of 5 s.
|
||||
temp_env::async_with_vars(
|
||||
[(rustfs_config::ENV_OBJECT_LOCK_ACQUIRE_TIMEOUT, Some("30"))],
|
||||
delete_holds_bucket_incarnation_sentinel_through_object_commit_body(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn delete_holds_bucket_incarnation_sentinel_through_object_commit_body() {
|
||||
let temp = tempfile::tempdir().expect("create temp store dir");
|
||||
let (_ctx, store, _shutdown) =
|
||||
without_storage_class_env(build_isolated_test_store(temp.path(), "delete-bucket-incarnation", &[4])).await;
|
||||
@@ -17418,13 +17844,19 @@ mod tests {
|
||||
"bucket delete and same-name recreation must wait for the in-flight object delete"
|
||||
);
|
||||
barrier.release();
|
||||
delete
|
||||
// The recreation's lock wait started before the release; give the delete as long.
|
||||
tokio::time::timeout(Duration::from_secs(30), delete)
|
||||
.await
|
||||
.expect("delete should finish once the barrier is released")
|
||||
.expect("delete task should join")
|
||||
.expect("old-generation delete should complete");
|
||||
tokio::time::timeout(Duration::from_secs(10), recreate)
|
||||
// If the delete kept the sentinel, `delete_bucket` would fail on its lifecycle
|
||||
// write lock after the lock acquire timeout (30 s here). The rest of the
|
||||
// recreation is fsync-bound IO on four disks, so this bound, above the lock
|
||||
// timeout, only stops a hang.
|
||||
tokio::time::timeout(Duration::from_secs(120), recreate)
|
||||
.await
|
||||
.expect("bucket recreation should finish after delete releases the sentinel")
|
||||
.expect("bucket recreation should not hang after delete releases the sentinel")
|
||||
.expect("bucket recreation task should join")
|
||||
.expect("bucket recreation should succeed");
|
||||
|
||||
@@ -17477,6 +17909,19 @@ mod tests {
|
||||
#[tokio::test]
|
||||
#[serial_test::serial(storage_class_env)]
|
||||
async fn delete_objects_holds_bucket_incarnation_sentinel_through_commit() {
|
||||
// The recreation waits for the delete's sentinel on its lifecycle write lock
|
||||
// from before `barrier.release()`, so that lock wait also has to cover the
|
||||
// delete's commit. Raise the lock acquire timeout from the 5 s default to give
|
||||
// the commit room on a starved runner; a kept sentinel still fails the test,
|
||||
// after 30 s instead of 5 s.
|
||||
temp_env::async_with_vars(
|
||||
[(rustfs_config::ENV_OBJECT_LOCK_ACQUIRE_TIMEOUT, Some("30"))],
|
||||
delete_objects_holds_bucket_incarnation_sentinel_through_commit_body(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn delete_objects_holds_bucket_incarnation_sentinel_through_commit_body() {
|
||||
let temp = tempfile::tempdir().expect("create temp store dir");
|
||||
let (_ctx, store, _shutdown) =
|
||||
without_storage_class_env(build_isolated_test_store(temp.path(), "batch-delete-bucket-incarnation", &[4])).await;
|
||||
@@ -17538,14 +17983,22 @@ mod tests {
|
||||
"bucket recreation must wait for the in-flight batch delete"
|
||||
);
|
||||
barrier.release();
|
||||
let (_deleted, errors) = delete.await.expect("batch delete task should join");
|
||||
// The recreation's lock wait started before the release; give the delete as long.
|
||||
let (_deleted, errors) = tokio::time::timeout(Duration::from_secs(30), delete)
|
||||
.await
|
||||
.expect("batch delete should finish once the barrier is released")
|
||||
.expect("batch delete task should join");
|
||||
assert!(
|
||||
errors.iter().all(Option::is_none),
|
||||
"old-generation batch delete should complete: {errors:?}"
|
||||
);
|
||||
tokio::time::timeout(Duration::from_secs(10), recreate)
|
||||
// If the delete kept the sentinel, `delete_bucket` would fail on its lifecycle
|
||||
// write lock after the lock acquire timeout (30 s here). The rest of the
|
||||
// recreation is fsync-bound IO on four disks, so this bound, above the lock
|
||||
// timeout, only stops a hang.
|
||||
tokio::time::timeout(Duration::from_secs(120), recreate)
|
||||
.await
|
||||
.expect("bucket recreation should finish after batch delete releases the sentinel")
|
||||
.expect("bucket recreation should not hang after batch delete releases the sentinel")
|
||||
.expect("bucket recreation task should join")
|
||||
.expect("bucket recreation should succeed");
|
||||
store
|
||||
@@ -18471,6 +18924,70 @@ mod tests {
|
||||
assert_eq!(body, b"new-generation");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial_test::serial]
|
||||
#[serial_test::serial(storage_class_env)]
|
||||
async fn cancelled_ecstore_put_part_keeps_bucket_lifecycle_lock_through_commit() {
|
||||
for pause in [
|
||||
crate::set_disk::MultipartCommitPause::PutPartAfterCapacityAdmission,
|
||||
crate::set_disk::MultipartCommitPause::PutPartAfterRename,
|
||||
] {
|
||||
let temp_dir = tempfile::tempdir().expect("create cancelled multipart lifecycle store dir");
|
||||
let (_ctx, store, shutdown) =
|
||||
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "cancelled-multipart-lifecycle", &[4, 4]))
|
||||
.await;
|
||||
crate::bucket::metadata_sys::init_bucket_metadata_sys(Arc::clone(&store), Vec::new()).await;
|
||||
let bucket = format!("cancelled-part-lifecycle-{}", Uuid::new_v4());
|
||||
let object = "part.bin";
|
||||
store
|
||||
.make_bucket(&bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("create bucket for multipart lifecycle cancellation");
|
||||
let upload = store
|
||||
.new_multipart_upload(&bucket, object, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("create upload through the owning ECStore");
|
||||
let barrier = crate::set_disk::MultipartCommitBarrier::install(&bucket, object, pause);
|
||||
let put_store = Arc::clone(&store);
|
||||
let put_bucket = bucket.clone();
|
||||
let put_upload_id = upload.upload_id.clone();
|
||||
let put = tokio::spawn(async move {
|
||||
let mut reader = PutObjReader::from_vec(vec![0x45; 4096]);
|
||||
put_store
|
||||
.put_object_part(&put_bucket, object, &put_upload_id, 1, &mut reader, &ObjectOptions::default())
|
||||
.await
|
||||
});
|
||||
barrier.wait_until_paused().await;
|
||||
put.abort();
|
||||
assert!(
|
||||
put.await
|
||||
.expect_err("the ECStore request waiter must remain cancellable")
|
||||
.is_cancelled()
|
||||
);
|
||||
|
||||
let mut writer = Box::pin(store.acquire_bucket_lifecycle_write_lock(&bucket));
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_millis(100), &mut writer).await.is_err(),
|
||||
"canceling UploadPart at {pause:?} must not release its bucket lifecycle owner before the commit drains"
|
||||
);
|
||||
barrier.release();
|
||||
let lifecycle_guard = tokio::time::timeout(Duration::from_secs(10), writer)
|
||||
.await
|
||||
.expect("the bucket lifecycle writer must resume after the part commit drains")
|
||||
.expect("acquire the bucket lifecycle write lock after cancellation");
|
||||
drop(lifecycle_guard);
|
||||
|
||||
let parts = store
|
||||
.list_object_parts(&bucket, object, &upload.upload_id, None, 100, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("the cancelled request's owned part commit must finish successfully");
|
||||
assert_eq!(parts.parts.len(), 1, "the commit must publish exactly the uploaded part at {pause:?}");
|
||||
assert_eq!(parts.parts[0].part_num, 1);
|
||||
assert_eq!(parts.parts[0].size, 4096);
|
||||
shutdown.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial(storage_class_env)]
|
||||
async fn pre_upgrade_multipart_upload_can_complete_and_abort_in_the_same_bucket_lifetime() {
|
||||
@@ -22023,7 +22540,6 @@ mod tests {
|
||||
.make_bucket(bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("kill-point bucket should be created");
|
||||
let _compaction_proof = install_transition_transaction_compaction_fleet_proof_for_test("object-transaction-fencing-test");
|
||||
|
||||
for (index, point, expected_state, expected_revision, expected_committed, expected_recovered) in [
|
||||
(
|
||||
@@ -22067,10 +22583,14 @@ mod tests {
|
||||
.await
|
||||
.expect("kill-point source should be written");
|
||||
let remote_before = backend.object_count().await;
|
||||
// Earlier cancellation/recovery cases must not consume this
|
||||
// transaction's 30-second homogeneous-fleet proof window.
|
||||
let _compaction_proof =
|
||||
install_transition_transaction_compaction_fleet_proof_for_test("object-transaction-fencing-test");
|
||||
let barrier = TransitionTransactionKillPointBarrier::install(bucket, &object, point);
|
||||
let transition_store = store.clone();
|
||||
let transition_object = object.clone();
|
||||
let transition = tokio::spawn(async move {
|
||||
let mut transition = tokio::spawn(async move {
|
||||
transition_store
|
||||
.transition_object(
|
||||
bucket,
|
||||
@@ -22089,7 +22609,12 @@ mod tests {
|
||||
)
|
||||
.await
|
||||
});
|
||||
barrier.wait_until_paused().await;
|
||||
tokio::select! {
|
||||
_ = barrier.wait_until_paused() => {}
|
||||
result = &mut transition => {
|
||||
panic!("transition exited before reaching {point:?}: {result:?}");
|
||||
}
|
||||
}
|
||||
transition.abort();
|
||||
assert!(
|
||||
transition
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user